diff --git a/README.md b/README.md index 2290fc2..8cbe039 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,23 @@ This produces a `./curve.bin` binary with: That outputs an interactive graph GUI. +### Minesweeper (using `packaged.toml` for configuration) + +You can use a `packaged.toml` file and simply do `packaged path/to/project` to +create your package. For example, try the `minesweeper` project: + +```bash +packaged ./example/minesweeper +``` + +[This configuration](tests/end_to_end/test_packages/minesweeper/packaged.toml) +is used for building the package. The equivalent command to build the project +without `pyproject.toml` would be: + +```bash +packaged minesweeper.bin 'pip install .' 'python -m minesweeper' ./example/minesweeper +``` + ### Textual Demo Since the dependencies themselves contain all the source code needed, you can diff --git a/example/minesweeper/Sprites/Grid.png b/example/minesweeper/Sprites/Grid.png new file mode 100644 index 0000000..534577b Binary files /dev/null and b/example/minesweeper/Sprites/Grid.png differ diff --git a/example/minesweeper/Sprites/empty.png b/example/minesweeper/Sprites/empty.png new file mode 100644 index 0000000..b214624 Binary files /dev/null and b/example/minesweeper/Sprites/empty.png differ diff --git a/example/minesweeper/Sprites/flag.png b/example/minesweeper/Sprites/flag.png new file mode 100644 index 0000000..e903c70 Binary files /dev/null and b/example/minesweeper/Sprites/flag.png differ diff --git a/example/minesweeper/Sprites/grid1.png b/example/minesweeper/Sprites/grid1.png new file mode 100644 index 0000000..513e269 Binary files /dev/null and b/example/minesweeper/Sprites/grid1.png differ diff --git a/example/minesweeper/Sprites/grid2.png b/example/minesweeper/Sprites/grid2.png new file mode 100644 index 0000000..b94df4f Binary files /dev/null and b/example/minesweeper/Sprites/grid2.png differ diff --git a/example/minesweeper/Sprites/grid3.png b/example/minesweeper/Sprites/grid3.png new file mode 100644 index 0000000..600729d Binary files /dev/null and b/example/minesweeper/Sprites/grid3.png differ diff --git a/example/minesweeper/Sprites/grid4.png b/example/minesweeper/Sprites/grid4.png new file mode 100644 index 0000000..162372d Binary files /dev/null and b/example/minesweeper/Sprites/grid4.png differ diff --git a/example/minesweeper/Sprites/grid5.png b/example/minesweeper/Sprites/grid5.png new file mode 100644 index 0000000..5e711dd Binary files /dev/null and b/example/minesweeper/Sprites/grid5.png differ diff --git a/example/minesweeper/Sprites/grid6.png b/example/minesweeper/Sprites/grid6.png new file mode 100644 index 0000000..fe74452 Binary files /dev/null and b/example/minesweeper/Sprites/grid6.png differ diff --git a/example/minesweeper/Sprites/grid7.png b/example/minesweeper/Sprites/grid7.png new file mode 100644 index 0000000..4550616 Binary files /dev/null and b/example/minesweeper/Sprites/grid7.png differ diff --git a/example/minesweeper/Sprites/grid8.png b/example/minesweeper/Sprites/grid8.png new file mode 100644 index 0000000..49302ce Binary files /dev/null and b/example/minesweeper/Sprites/grid8.png differ diff --git a/example/minesweeper/Sprites/mine.png b/example/minesweeper/Sprites/mine.png new file mode 100644 index 0000000..296aa82 Binary files /dev/null and b/example/minesweeper/Sprites/mine.png differ diff --git a/example/minesweeper/Sprites/mineClicked.png b/example/minesweeper/Sprites/mineClicked.png new file mode 100644 index 0000000..e008765 Binary files /dev/null and b/example/minesweeper/Sprites/mineClicked.png differ diff --git a/example/minesweeper/Sprites/mineFalse.png b/example/minesweeper/Sprites/mineFalse.png new file mode 100644 index 0000000..b722442 Binary files /dev/null and b/example/minesweeper/Sprites/mineFalse.png differ diff --git a/example/minesweeper/minesweeper.py b/example/minesweeper/minesweeper.py new file mode 100644 index 0000000..c314135 --- /dev/null +++ b/example/minesweeper/minesweeper.py @@ -0,0 +1,266 @@ +"""Example taken from https://github.com/ixora-0/Minesweeper""" + +import pygame +import random + +pygame.init() + +bg_color = (192, 192, 192) +grid_color = (128, 128, 128) + +game_width = 10 # Change this to increase size +game_height = 10 # Change this to increase size +numMine = 9 # Number of mines +grid_size = ( + 32 # Size of grid (WARNING: macke sure to change the images dimension as well) +) +border = 16 # Top border +top_border = 100 # Left, Right, Bottom border +display_width = grid_size * game_width + border * 2 # Display width +display_height = grid_size * game_height + border + top_border # Display height +gameDisplay = pygame.display.set_mode((display_width, display_height)) # Create display +timer = pygame.time.Clock() # Create timer +pygame.display.set_caption("Minesweeper") # S Set the caption of window + +# Import files +spr_emptyGrid = pygame.image.load("Sprites/empty.png") +spr_flag = pygame.image.load("Sprites/flag.png") +spr_grid = pygame.image.load("Sprites/Grid.png") +spr_grid1 = pygame.image.load("Sprites/grid1.png") +spr_grid2 = pygame.image.load("Sprites/grid2.png") +spr_grid3 = pygame.image.load("Sprites/grid3.png") +spr_grid4 = pygame.image.load("Sprites/grid4.png") +spr_grid5 = pygame.image.load("Sprites/grid5.png") +spr_grid6 = pygame.image.load("Sprites/grid6.png") +spr_grid7 = pygame.image.load("Sprites/grid7.png") +spr_grid8 = pygame.image.load("Sprites/grid8.png") +spr_grid7 = pygame.image.load("Sprites/grid7.png") +spr_mine = pygame.image.load("Sprites/mine.png") +spr_mineClicked = pygame.image.load("Sprites/mineClicked.png") +spr_mineFalse = pygame.image.load("Sprites/mineFalse.png") + + +# Create global values +grid = [] # The main grid +mines = [] # Pos of the mines + + +# Create funtion to draw texts +def drawText(txt, s, yOff=0): + screen_text = pygame.font.SysFont("Calibri", s, True).render(txt, True, (0, 0, 0)) + rect = screen_text.get_rect() + rect.center = ( + game_width * grid_size / 2 + border, + game_height * grid_size / 2 + top_border + yOff, + ) + gameDisplay.blit(screen_text, rect) + + +# Create class grid +class Grid: + def __init__(self, xGrid, yGrid, type): + self.xGrid = xGrid # X pos of grid + self.yGrid = yGrid # Y pos of grid + self.clicked = False # Boolean var to check if the grid has been clicked + self.mineClicked = ( + False # Bool var to check if the grid is clicked and its a mine + ) + self.mineFalse = False # Bool var to check if the player flagged the wrong grid + self.flag = False # Bool var to check if player flagged the grid + # Create rectObject to handle drawing and collisions + self.rect = pygame.Rect( + border + self.xGrid * grid_size, + top_border + self.yGrid * grid_size, + grid_size, + grid_size, + ) + self.val = type # Value of the grid, -1 is mine + + def drawGrid(self): + # Draw the grid according to bool variables and value of grid + if self.mineFalse: + gameDisplay.blit(spr_mineFalse, self.rect) + else: + if self.clicked: + if self.val == -1: + if self.mineClicked: + gameDisplay.blit(spr_mineClicked, self.rect) + else: + gameDisplay.blit(spr_mine, self.rect) + else: + if self.val == 0: + gameDisplay.blit(spr_emptyGrid, self.rect) + elif self.val == 1: + gameDisplay.blit(spr_grid1, self.rect) + elif self.val == 2: + gameDisplay.blit(spr_grid2, self.rect) + elif self.val == 3: + gameDisplay.blit(spr_grid3, self.rect) + elif self.val == 4: + gameDisplay.blit(spr_grid4, self.rect) + elif self.val == 5: + gameDisplay.blit(spr_grid5, self.rect) + elif self.val == 6: + gameDisplay.blit(spr_grid6, self.rect) + elif self.val == 7: + gameDisplay.blit(spr_grid7, self.rect) + elif self.val == 8: + gameDisplay.blit(spr_grid8, self.rect) + + else: + if self.flag: + gameDisplay.blit(spr_flag, self.rect) + else: + gameDisplay.blit(spr_grid, self.rect) + + def revealGrid(self): + self.clicked = True + # Auto reveal if it's a 0 + if self.val == 0: + for x in range(-1, 2): + if self.xGrid + x >= 0 and self.xGrid + x < game_width: + for y in range(-1, 2): + if self.yGrid + y >= 0 and self.yGrid + y < game_height: + if not grid[self.yGrid + y][self.xGrid + x].clicked: + grid[self.yGrid + y][self.xGrid + x].revealGrid() + elif self.val == -1: + # Auto reveal all mines if it's a mine + for m in mines: + if not grid[m[1]][m[0]].clicked: + grid[m[1]][m[0]].revealGrid() + + def updateValue(self): + # Update the value when all grid is generated + if self.val != -1: + for x in range(-1, 2): + if self.xGrid + x >= 0 and self.xGrid + x < game_width: + for y in range(-1, 2): + if self.yGrid + y >= 0 and self.yGrid + y < game_height: + if grid[self.yGrid + y][self.xGrid + x].val == -1: + self.val += 1 + + +def gameLoop(): + gameState = "Playing" # Game state + mineLeft = numMine # Number of mine left + global grid # Access global var + grid = [] + global mines + t = 0 # Set time to 0 + + # Generating mines + mines = [[random.randrange(0, game_width), random.randrange(0, game_height)]] + + for c in range(numMine - 1): + pos = [random.randrange(0, game_width), random.randrange(0, game_height)] + same = True + while same: + for i in range(len(mines)): + if pos == mines[i]: + pos = [ + random.randrange(0, game_width), + random.randrange(0, game_height), + ] + break + if i == len(mines) - 1: + same = False + mines.append(pos) + + # Generating entire grid + for j in range(game_height): + line = [] + for i in range(game_width): + if [i, j] in mines: + line.append(Grid(i, j, -1)) + else: + line.append(Grid(i, j, 0)) + grid.append(line) + + # Update of the grid + for i in grid: + for j in i: + j.updateValue() + + # Main Loop + while gameState != "Exit": + # Reset screen + gameDisplay.fill(bg_color) + + # User inputs + for event in pygame.event.get(): + # Check if player close window + if event.type == pygame.QUIT: + gameState = "Exit" + # Check if play restart + if gameState == "Game Over" or gameState == "Win": + if event.type == pygame.KEYDOWN: + if event.key == pygame.K_r: + gameState = "Exit" + gameLoop() + else: + if event.type == pygame.MOUSEBUTTONUP: + for i in grid: + for j in i: + if j.rect.collidepoint(event.pos): + if event.button == 1: + # If player left clicked of the grid + j.revealGrid() + # Toggle flag off + if j.flag: + mineLeft += 1 + j.flag = False + # If it's a mine + if j.val == -1: + gameState = "Game Over" + j.mineClicked = True + elif event.button == 3: + # If the player right clicked + if not j.clicked: + if j.flag: + j.flag = False + mineLeft += 1 + else: + j.flag = True + mineLeft -= 1 + + # Check if won + w = True + for i in grid: + for j in i: + j.drawGrid() + if j.val != -1 and not j.clicked: + w = False + if w and gameState != "Exit": + gameState = "Win" + + # Draw Texts + if gameState != "Game Over" and gameState != "Win": + t += 1 + elif gameState == "Game Over": + drawText("Game Over!", 50) + drawText("R to restart", 35, 50) + for i in grid: + for j in i: + if j.flag and j.val != -1: + j.mineFalse = True + else: + drawText("You WON!", 50) + drawText("R to restart", 35, 50) + # Draw time + s = str(t // 15) + screen_text = pygame.font.SysFont("Calibri", 50).render(s, True, (0, 0, 0)) + gameDisplay.blit(screen_text, (border, border)) + # Draw mine left + screen_text = pygame.font.SysFont("Calibri", 50).render( + mineLeft.__str__(), True, (0, 0, 0) + ) + gameDisplay.blit(screen_text, (display_width - border - 50, border)) + + pygame.display.update() # Update screen + + timer.tick(15) # Tick fps + + +gameLoop() +pygame.quit() +quit() diff --git a/example/minesweeper/packaged.toml b/example/minesweeper/packaged.toml new file mode 100644 index 0000000..112d8cf --- /dev/null +++ b/example/minesweeper/packaged.toml @@ -0,0 +1,3 @@ +output_path = "minesweeper.bin" +build_command = "pip install ." +startup_command = "python -m minesweeper" diff --git a/example/minesweeper/setup.cfg b/example/minesweeper/setup.cfg new file mode 100644 index 0000000..99b9cd5 --- /dev/null +++ b/example/minesweeper/setup.cfg @@ -0,0 +1,7 @@ +[metadata] +name = minesweeper + +[options] +packages = find: +install_requires = + pygame diff --git a/example/minesweeper/setup.py b/example/minesweeper/setup.py new file mode 100644 index 0000000..8bf1ba9 --- /dev/null +++ b/example/minesweeper/setup.py @@ -0,0 +1,2 @@ +from setuptools import setup +setup() diff --git a/setup.cfg b/setup.cfg index 4443b7f..1f6737c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -26,6 +26,7 @@ classifiers = packages = find: install_requires = yen>=0.4.2 + tomli>=1.1.0; python_version<'3.11' python_requires = >=3.8 package_dir = =src diff --git a/src/packaged/cli.py b/src/packaged/cli.py index 940de69..5b55e59 100644 --- a/src/packaged/cli.py +++ b/src/packaged/cli.py @@ -5,15 +5,15 @@ import argparse import os.path import platform +import sys from packaged import SourceDirectoryNotFound, ensure_makeself, create_package - - -class CLIArgs: - source_directory: str - output_path: str - build_command: str - startup_command: str +from packaged.config import ( + Config, + ConfigValidationError, + config_file_exists, + parse_config, +) def error(message: str) -> None: @@ -23,33 +23,58 @@ def error(message: str) -> None: def cli(argv: list[str] | None = None) -> int: """CLI interface.""" - parser = argparse.ArgumentParser() - parser.add_argument("output_path", help="Filename for the generated binary") - parser.add_argument( - "build_command", help="Python command to run when building the package" - ) - parser.add_argument("startup_command", help="Command to run when the script starts") - parser.add_argument( - "source_directory", - help="Folder containing your python source to package", - type=os.path.abspath, - nargs="?", - default=None, - ) - args = parser.parse_args(argv, namespace=CLIArgs) + # Manually set argv from sys.argv, as we need to check its length to + # choose to parse config instead. + if argv is None: + argv = sys.argv[1:] if platform.system() == "Windows": error("Sorry, Windows is not supported yet. Ask for it on GitHub!") return 2 - ensure_makeself() - try: - create_package( + if len(argv) == 1 and config_file_exists(argv[0]): + # Use values from config file instead + try: + config = parse_config(argv[0]) + except ConfigValidationError as exc: + error(f"Expected key {exc.key!r} in config") + return 3 + + source_directory, output_path, build_command, startup_command = ( + config.source_directory, + config.output_path, + config.build_command, + config.startup_command, + ) + + else: + parser = argparse.ArgumentParser() + parser.add_argument("output_path", help="Filename for the generated binary") + parser.add_argument( + "build_command", help="Python command to run when building the package" + ) + parser.add_argument( + "startup_command", help="Command to run when the script starts" + ) + parser.add_argument( + "source_directory", + help="Folder containing your python source to package", + type=os.path.abspath, + nargs="?", + default=None, + ) + args = parser.parse_args(argv, namespace=Config) + + source_directory, output_path, build_command, startup_command = ( args.source_directory, args.output_path, args.build_command, args.startup_command, ) + + ensure_makeself() + try: + create_package(source_directory, output_path, build_command, startup_command) except SourceDirectoryNotFound as exc: error(f"Folder {exc.directory_path!r} does not exist.") diff --git a/src/packaged/config.py b/src/packaged/config.py new file mode 100644 index 0000000..ce9ce9e --- /dev/null +++ b/src/packaged/config.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass +import os +import sys + +if sys.version_info < (3, 11): + import tomli as tomllib +else: + import tomllib + + +class ConfigValidationError(Exception): + """Raised when the toml config has some problem.""" + + def __init__(self, key: str) -> None: + super().__init__(key) + self.key = key + + +@dataclass +class Config: + source_directory: str | None + output_path: str + build_command: str + startup_command: str + + +CONFIG_NAME = "./packaged.toml" + + +def config_file_exists(source_directory: str) -> bool: + """Returns true if `packaged.toml` exists in current directory.""" + return os.path.isfile(os.path.join(source_directory, CONFIG_NAME)) + + +def parse_config(source_directory: str) -> Config: + """ + Parses the config file according to this format: + + source_directory = "." + output_path = "myproject.bin" + build_command = "pip install ." + startup_command = "python -m myproject" + """ + with open(os.path.join(source_directory, CONFIG_NAME), "rb") as config_file: + config_data = tomllib.load(config_file) + + try: + config = Config( + os.path.abspath(source_directory), + config_data["output_path"], + config_data["build_command"], + config_data["startup_command"], + ) + except KeyError as exc: + key = exc.args[0] + raise ConfigValidationError(key) + + return config diff --git a/tests/end_to_end/packaged_test.py b/tests/end_to_end/packaged_test.py index e2825dc..30fb4cd 100644 --- a/tests/end_to_end/packaged_test.py +++ b/tests/end_to_end/packaged_test.py @@ -6,6 +6,7 @@ from typing import Iterator import packaged +import packaged.config TEST_PACKAGES = os.path.join(os.path.dirname(__file__), "test_packages") @@ -31,6 +32,8 @@ def build_package( def get_output(path: str) -> str: """Runs the executable with `--nox11` so that it still works as a subprocess.""" + if not path.startswith((".", "/")): + path = "./" + path return subprocess.check_output([path, "--nox11"]).decode() @@ -53,3 +56,17 @@ def test_numpy_pandas() -> None: "python somefile.py", ): assert "0 -2.222222\ndtype: float64" in get_output(executable_path) + + +def test_config_parsing() -> None: + """Packages `configtest` to ensure config parsing works as expected.""" + package_path = os.path.join(TEST_PACKAGES, "configtest") + config = packaged.config.parse_config(package_path) + + with build_package( + config.source_directory, + config.output_path, + config.build_command, + config.startup_command, + ): + assert "('R', 'G', 'B')\n('C', 'M', 'Y', 'K')" in get_output(config.output_path) diff --git a/tests/end_to_end/test_packages/configtest/configtest.py b/tests/end_to_end/test_packages/configtest/configtest.py new file mode 100644 index 0000000..f0a9305 --- /dev/null +++ b/tests/end_to_end/test_packages/configtest/configtest.py @@ -0,0 +1,14 @@ +import os.path + +from PIL import Image + + +def main(): + with Image.open(os.path.join(os.path.dirname(__file__), "testimage.jpg")) as img: + print(img.getbands()) + cmyk_img = img.convert("CMYK") + print(cmyk_img.getbands()) + + +if __name__ == "__main__": + main() diff --git a/tests/end_to_end/test_packages/configtest/packaged.toml b/tests/end_to_end/test_packages/configtest/packaged.toml new file mode 100644 index 0000000..ba4dd98 --- /dev/null +++ b/tests/end_to_end/test_packages/configtest/packaged.toml @@ -0,0 +1,3 @@ +output_path = "configtest.bin" +build_command = "pip install ." +startup_command = "python -m configtest" diff --git a/tests/end_to_end/test_packages/configtest/setup.cfg b/tests/end_to_end/test_packages/configtest/setup.cfg new file mode 100644 index 0000000..e9d06bc --- /dev/null +++ b/tests/end_to_end/test_packages/configtest/setup.cfg @@ -0,0 +1,7 @@ +[metadata] +name = configtest + +[options] +packages = find: +install_requires = + pillow diff --git a/tests/end_to_end/test_packages/configtest/setup.py b/tests/end_to_end/test_packages/configtest/setup.py new file mode 100644 index 0000000..8bf1ba9 --- /dev/null +++ b/tests/end_to_end/test_packages/configtest/setup.py @@ -0,0 +1,2 @@ +from setuptools import setup +setup() diff --git a/tests/end_to_end/test_packages/configtest/testimage.jpg b/tests/end_to_end/test_packages/configtest/testimage.jpg new file mode 100644 index 0000000..e6dca2e Binary files /dev/null and b/tests/end_to_end/test_packages/configtest/testimage.jpg differ