diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..1d3279a --- /dev/null +++ b/.coveragerc @@ -0,0 +1,10 @@ +[run] +branch = True +source = pureshell +omit = + */tests/* + */examples/* + +[report] +show_missing = True +skip_covered = True diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..33aa423 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +# EditorConfig helps maintain consistent coding styles across editors +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..a3698ff --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 88 +exclude = .git,__pycache__,.venv,build,dist,docs/_build diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b041358 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [master, main, "improve-development-environment"] + pull_request: + branches: [master, main] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + - name: Lint with flake8 + run: flake8 pureshell + - name: Check formatting with black + run: black --check pureshell + - name: Type check with mypy + run: mypy pureshell + - name: Run tests with coverage + run: pytest --cov=pureshell --cov-report=xml + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + slug: zinthose/pureshell diff --git a/.gitignore b/.gitignore index 3a2331f..2f216b3 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,12 @@ coverage.xml # Pyre type checker .pyre/ + +# VS Code +.vscode/ + +# Sphinx documentation build +/docs/_build/ + +# Pre-commit cache +.pre-commit-cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..20e617d --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,16 @@ +repos: + - repo: https://github.com/psf/black + rev: 24.4.2 + hooks: + - id: black + - repo: https://github.com/PyCQA/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + - id: check-added-large-files diff --git a/README.md b/README.md index b15ce81..e4d46b5 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,92 @@ The framework is built around a few key components: * `@shell_method(...)`: A method decorator that declaratively links a method on a `StatefulEntity` to a pure function in its `Ruleset`. * `@side_effect_method`: A decorator to explicitly mark methods that perform I/O (like printing to the console or rendering graphics) as being exempt from the "pure logic" enforcement. -## πŸš€ Installation +### New in version 0.2.0: Dynamic Ruleset Injection -```bash -pip install pureshell +You can now inject a `Ruleset` instance directly when creating a `StatefulEntity`. This allows for more flexible and dynamic behavior, especially useful for scenarios like: + +* **Strategy Pattern:** Easily switch between different sets of rules at runtime. +* **Testing:** Inject mock or simplified rulesets for testing specific behaviors. +* **Configuration-driven Behavior:** Load different rulesets based on configuration files or user settings. + +If a `ruleset_instance` is provided during `StatefulEntity` instantiation, it will be used instead of the one specified by the `@ruleset_provider` decorator. + +```python +# Example of dynamic ruleset injection +from pureshell import StatefulEntity, Ruleset, shell_method + +class BehaviorA(Ruleset): + @staticmethod + def act(state_data: dict) -> dict: + print("Performing Action A") + return {**state_data, "action_taken": "A"} + +class BehaviorB(Ruleset): + @staticmethod + def act(state_data: dict) -> dict: + print("Performing Action B") + return {**state_data, "action_taken": "B"} + +class MyEntity(StatefulEntity): + def __init__(self, initial_state: dict, ruleset_instance: Ruleset = None): + # StatefulEntity.__init__ now handles initial_state and ruleset_instance + super().__init__(initial_state, ruleset_instance) + + @shell_method("state_data", pure_func="act", mutates=True) + def perform_action(self) -> None: + pass # Logic delegated to ruleset's 'act' method + +# Create entity with default behavior (if a @ruleset_provider is set) +# entity_default = MyEntity(initial_state={}) + +# Create entity with BehaviorA +entity_a = MyEntity(initial_state={"state_data": {}}, ruleset_instance=BehaviorA()) +entity_a.perform_action() # Will use BehaviorA.act + +# Create entity with BehaviorB +entity_b = MyEntity(initial_state={"state_data": {}}, ruleset_instance=BehaviorB()) +entity_b.perform_action() # Will use BehaviorB.act ``` +## πŸš€ Installation + +To use `pureshell` in your project, it's highly recommended to work within a virtual environment. + +1. **Create and activate a virtual environment:** + + ```bash + # Windows + python -m venv .venv + .venv\\Scripts\\activate + + # macOS/Linux + python3 -m venv .venv + source .venv/bin/activate + ``` + +2. **Install from PyPI (for users of the library):** + + ```bash + pip install pureshell + ``` + + Alternatively, if you have a `requirements.txt` file that includes `pureshell`: + + ```bash + pip install -r requirements.txt + ``` + +3. **For development (if you've cloned this repository):** + + Install the project and its development dependencies: + + ```bash + pip install -e . + pip install -r requirements-dev.txt + ``` + + This will install the project in editable mode and all tools needed for testing, linting, formatting, etc. + ## πŸš€ Usage Here's a practical example of defining a `ShoppingCart` using the `pureshell` pattern. This example is available in `examples/shopping_cart_example.py`. @@ -139,23 +219,83 @@ In addition, this addresses linter warnings that would be raised if `pass` or el This repository includes complete, runnable examples to demonstrate the pattern. A helper script is provided to easily run them. +First, ensure you have installed the project in editable mode and the development dependencies (see [πŸš€ Installation](#-installation)). + +Then, run the examples module: + ```bash -python run_examples.py +python -m examples.run ``` This will present a menu where you can choose between: * **Shopping Cart**: The simple e-commerce example detailed above. - * **Pygame Space Shooter**: A more advanced example showing how `pureshell` can be used to completely separate game logic from the Pygame rendering engine, making the logic highly testable. -## βœ… Running Tests +## βœ… Running Tests & Quality Checks -The project includes a comprehensive test suite. To run the tests, navigate to the project root directory and run the test discovery command: +This project uses `pytest` for testing, `flake8` for linting, `black` for formatting, `mypy` for type checking, and `pre-commit` to automate these checks. -```bash -python -m unittest discover tests -``` +1. **Ensure development dependencies are installed:** + + ```bash + pip install -r requirements-dev.txt + ``` + +2. **Run all tests with coverage:** + + ```bash + pytest + ``` + + Coverage reports are generated in HTML format in the `htmlcov/` directory and also printed to the console. + +3. **Run linters and formatters manually:** + + ```bash + flake8 . + black . + isort . + mypy . + ``` + +4. **Use pre-commit hooks (recommended):** + + Pre-commit hooks will automatically run checks before each commit. + + ```bash + pre-commit install + ``` + + Now, `flake8`, `black`, `isort`, and `mypy` will run on staged files automatically when you commit. If they find issues, the commit will be aborted, allowing you to fix them. + +## πŸ“š Building Documentation + +Documentation is built using Sphinx. + +1. **Ensure development dependencies are installed:** + + ```bash + pip install -r requirements-dev.txt + ``` + +2. **Build the HTML documentation:** + + ```bash + sphinx-build -b html docs docs/_build/html + ``` + + The generated HTML will be in `docs/_build/html/index.html`. + +## CI Pipeline + +This project uses GitHub Actions for Continuous Integration. The workflow is defined in `.github/workflows/ci.yml` and includes: + +* Linting with Flake8 +* Formatting checks with Black and isort +* Type checking with MyPy +* Running tests with Pytest and generating coverage reports +* Uploading coverage reports to Codecov (if `CODECOV_TOKEN` is set in repository secrets) ## πŸ—ΊοΈ Roadmap / Future Iterations diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..43615e7 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,17 @@ +# Configuration file for the Sphinx documentation builder. + +project = "pureshell" +copyright = "2025, Dane Jones" +author = "Dane Jones" +release = "0.1.0" + +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.napoleon", +] + +autodoc_typehints = "description" + +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] + +html_theme = "alabaster" diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..51a6bd9 --- /dev/null +++ b/docs/index.rst @@ -0,0 +1,17 @@ +.. pureshell documentation master file + +Welcome to pureshell's documentation! +===================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + +API Reference +------------- + +.. automodule:: pureshell + :members: + :undoc-members: + :show-inheritance: diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e6871d4 --- /dev/null +++ b/examples/__init__.py @@ -0,0 +1,16 @@ +""" +This file configures the 'examples' directory as a Python package. + +It enables running the examples using: `python -m examples.run`. + +It also modifies the Python path to include the parent directory, +allowing for imports from the main project directory. +""" + +# To run, use: py -m examples.run + +import os +import sys + +# Add parrent directory to path for imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) diff --git a/examples/dynamic_behavior_example.py b/examples/dynamic_behavior_example.py new file mode 100644 index 0000000..108a2d0 --- /dev/null +++ b/examples/dynamic_behavior_example.py @@ -0,0 +1,186 @@ +""" +Example demonstrating dynamic ruleset injection for a configurable bot. +""" + +from dataclasses import dataclass, field + +from pureshell import Ruleset, StatefulEntity, shell_method, side_effect_method + + +# --------------------- +# 1. Define Data Structures +# --------------------- +@dataclass +class BotState: + """Represents the internal state of our bot.""" + + name: str + energy_level: int = 100 + mood: str = "neutral" + log: list[str] = field(default_factory=list) + + +# --------------------- +# 2. Define Rulesets +# --------------------- +class FriendlyBotRules(Ruleset): + """Rules for a friendly bot.""" + + @staticmethod + def respond(state: BotState, stimulus: str) -> BotState: # Return type changed + """ + Generates a friendly response to a stimulus and updates the bot's state. + + Args: + state: The current state of the bot. + stimulus: The input stimulus (e.g., a user message). + + Returns: + The updated bot state. + """ + state.log.append(f"Friendly response to: {stimulus}") + if "hello" in stimulus.lower(): + state.mood = "happy" + # Return the modified state + else: + state.mood = "curious" + return state # Return the modified state + + @staticmethod + def perform_action(state: BotState) -> BotState: # Return type changed + """ + Performs a friendly action and updates the bot's state. + + Args: + state: The current state of the bot. + + Returns: + The updated bot state. + """ + state.log.append("Friendly action performed.") + state.energy_level -= 5 + # Return the modified state + return state + + +class AggressiveBotRules(Ruleset): + """Rules for an aggressive bot.""" + + @staticmethod + def respond(state: BotState, stimulus: str) -> BotState: # Return type changed + """ + Generates an aggressive response to a stimulus and updates the bot's state. + + Args: + state: The current state of the bot. + stimulus: The input stimulus (e.g., a user message). + + Returns: + The updated bot state. + """ + state.log.append(f"Aggressive response to: {stimulus}") + if "hello" in stimulus.lower(): + state.mood = "annoyed" + else: + state.mood = "hostile" + return state # Return the modified state + + @staticmethod + def perform_action(state: BotState) -> BotState: # Return type changed + """ + Performs an aggressive action and updates the bot's state. + + Args: + state: The current state of the bot. + + Returns: + The updated bot state. + """ + state.log.append("Aggressive action performed.") + state.energy_level -= 15 # Aggressive actions take more energy + # Return the modified state + return state + + +# --------------------- +# 3. Define Stateful Entity +# --------------------- +class ConfigurableBot(StatefulEntity): + """A bot whose behavior can be configured by injecting a ruleset.""" + + def __init__(self, name: str, rules_instance: Ruleset): + """ + Initializes the ConfigurableBot. + + Args: + name: The name of the bot. + rules_instance: An instance of a Ruleset class that defines the + bot's behavior. + """ + self.state = BotState(name=name) + self._instance_rules = rules_instance # Dynamic ruleset injection + + @shell_method("state", mutates=True) # Infers pure_func='respond' + def respond(self, stimulus: str) -> None: + # Return type is None for mutating shell methods + """ + Processes a stimulus and generates a response based on the injected ruleset. + + This method is a shell that delegates to the 'respond' method of the + injected ruleset. The bot's state is mutated accordingly. + + Args: + stimulus: The input stimulus (e.g., a user message). + """ + # The actual logic is in the injected ruleset's 'respond' method. + raise NotImplementedError() + + @shell_method("state", mutates=True) # Infers pure_func='perform_action' + def perform_action(self) -> None: # Return type is None for mutating shell methods + """ + Performs an action based on the injected ruleset. + + This method is a shell that delegates to the 'perform_action' method of the + injected ruleset. The bot's state is mutated accordingly. + """ + raise NotImplementedError() + + @side_effect_method + def display_status(self) -> None: + """Prints the current status of the bot to the console.""" + print(f"--- {self.state.name} Status ---") + print(f" Mood: {self.state.mood}") + print(f" Energy: {self.state.energy_level}") + print(f" Log: {self.state.log}") + print("------------------------") + + +# --------------------- +# 4. Demonstrate Usage +# --------------------- +def main(): + """Demonstrates the configurable bot with different rulesets.""" + print("\n--- Creating a Friendly Bot ---") + friendly_rules = FriendlyBotRules() + friendly_bot = ConfigurableBot(name="Buddy", rules_instance=friendly_rules) + + friendly_bot.display_status() + # The shell methods now return None, so we don't print their result directly. + friendly_bot.respond("Hello there!") + friendly_bot.display_status() + friendly_bot.perform_action() + friendly_bot.display_status() + + print("\n--- Creating an Aggressive Bot ---") + aggressive_rules = AggressiveBotRules() + aggressive_bot = ConfigurableBot(name="Spike", rules_instance=aggressive_rules) + + aggressive_bot.display_status() + aggressive_bot.respond("Hello?") + aggressive_bot.display_status() + aggressive_bot.perform_action() + aggressive_bot.display_status() + + +if __name__ == "__main__": + main() diff --git a/examples/game_example.py b/examples/game_example.py index 90f3291..e2af77a 100644 --- a/examples/game_example.py +++ b/examples/game_example.py @@ -1,22 +1,27 @@ """ A complete, real-world example of the PureShell pattern using Pygame. """ + # game_example.py -# pylint: disable=line-too-long,protected-access,no-member,invalid-name,wrong-import-position +# pylint: disable=line-too-long,protected-access,no-member,invalid-name import random -from typing import List, TYPE_CHECKING from dataclasses import dataclass, field, replace +from typing import TYPE_CHECKING, List from pureshell import ( - Ruleset, StatefulEntity, shell_method, ruleset_provider, side_effect_method + Ruleset, + StatefulEntity, + ruleset_provider, + shell_method, + side_effect_method, ) # It is the standard Python way to import types that may not be # available at runtime without causing an ImportError. if TYPE_CHECKING: + from pygame.font import Font from pygame.surface import Surface from pygame.time import Clock - from pygame.font import Font # This block handles the actual runtime import. try: @@ -43,28 +48,36 @@ # --- 2. Data Structure Definitions (The "State" - Pygame-Free) --- # ============================================================================== + @dataclass(frozen=True) class PlayerState: """The state of the player, using only primitive types.""" + x: float y: float health: int + @dataclass(frozen=True) class EnemyState: """The state of a single enemy.""" + x: float y: float + @dataclass(frozen=True) class BulletState: """The state of a single bullet.""" + x: float y: float + @dataclass(frozen=True) class GameState: """A single object containing the entire state of the game.""" + player: PlayerState enemies: List[EnemyState] = field(default_factory=list) bullets: List[BulletState] = field(default_factory=list) @@ -72,10 +85,12 @@ class GameState: is_game_over: bool = False spawn_timer: int = 0 + # ============================================================================== # --- 3. Functional Core (The "Rules Engine" - Truly Pure) --- # ============================================================================== + class GameRules(Ruleset): """A collection of pure functions that contain all game logic.""" @@ -88,8 +103,10 @@ def _rects_collide(x1, y1, w1, h1, x2, y2, w2, h2) -> bool: def move_player(state: GameState, direction: str) -> GameState: """Calculates a new GameState after moving the player.""" x, y = state.player.x, state.player.y - if direction == "LEFT": x -= PLAYER_SPEED - elif direction == "RIGHT": x += PLAYER_SPEED + if direction == "LEFT": + x -= PLAYER_SPEED + elif direction == "RIGHT": + x += PLAYER_SPEED x = max(0, min(x, SCREEN_WIDTH - PLAYER_WIDTH)) new_player_state = replace(state.player, x=x, y=y) @@ -100,28 +117,52 @@ def update_game_state(state: GameState) -> GameState: """The main pure function to advance the game state by one frame.""" new_bullets = [ replace(b, y=b.y - BULLET_SPEED) - for b in state.bullets if b.y + BULLET_HEIGHT > 0 + for b in state.bullets + if b.y + BULLET_HEIGHT > 0 ] new_enemies = [ replace(e, y=e.y + ENEMY_SPEED) - for e in state.enemies if e.y < SCREEN_HEIGHT + for e in state.enemies + if e.y < SCREEN_HEIGHT ] collided_bullets, collided_enemies, new_score = set(), set(), state.score for i, b in enumerate(new_bullets): for j, e in enumerate(new_enemies): - if GameRules._rects_collide(b.x, b.y, BULLET_WIDTH, BULLET_HEIGHT, e.x, e.y, ENEMY_WIDTH, ENEMY_HEIGHT): + if GameRules._rects_collide( + b.x, + b.y, + BULLET_WIDTH, + BULLET_HEIGHT, + e.x, + e.y, + ENEMY_WIDTH, + ENEMY_HEIGHT, + ): collided_bullets.add(i) collided_enemies.add(j) new_score += 10 - final_bullets = [b for i, b in enumerate(new_bullets) if i not in collided_bullets] - final_enemies = [e for i, e in enumerate(new_enemies) if i not in collided_enemies] + final_bullets = [ + b for i, b in enumerate(new_bullets) if i not in collided_bullets + ] + final_enemies = [ + e for i, e in enumerate(new_enemies) if i not in collided_enemies + ] is_game_over = state.is_game_over if not is_game_over: for enemy in final_enemies: - if GameRules._rects_collide(state.player.x, state.player.y, PLAYER_WIDTH, PLAYER_HEIGHT, enemy.x, enemy.y, ENEMY_WIDTH, ENEMY_HEIGHT): + if GameRules._rects_collide( + state.player.x, + state.player.y, + PLAYER_WIDTH, + PLAYER_HEIGHT, + enemy.x, + enemy.y, + ENEMY_WIDTH, + ENEMY_HEIGHT, + ): is_game_over = True break @@ -130,30 +171,42 @@ def update_game_state(state: GameState) -> GameState: enemy_x = random.randint(0, SCREEN_WIDTH - ENEMY_WIDTH) final_enemies.append(EnemyState(x=enemy_x, y=-ENEMY_HEIGHT)) - return replace(state, bullets=final_bullets, enemies=final_enemies, score=new_score, is_game_over=is_game_over, spawn_timer=new_spawn_timer) + return replace( + state, + bullets=final_bullets, + enemies=final_enemies, + score=new_score, + is_game_over=is_game_over, + spawn_timer=new_spawn_timer, + ) @staticmethod def shoot_bullet(state: GameState) -> GameState: """Creates a new game state with a new bullet.""" - if state.is_game_over: return state + if state.is_game_over: + return state bullet_x = state.player.x + (PLAYER_WIDTH / 2) - (BULLET_WIDTH / 2) new_bullet = BulletState(x=bullet_x, y=state.player.y) return replace(state, bullets=state.bullets + [new_bullet]) + # ============================================================================== # --- 4. Stateful Shell (The "Pygame Manager") --- # ============================================================================== + @ruleset_provider(GameRules) class Game(StatefulEntity): """The main game class, delegating all logic to GameRules.""" + def __init__(self, screen: "Surface", clock: "Clock", font: "Font"): super().__init__() # Add this single, robust check at the beginning. if pygame is None: - raise ImportError("Pygame is not installed. Please install it with 'pip install pygame'") - + raise ImportError( + "Pygame is not installed. Please install it with 'pip install pygame'" + ) self.screen = screen self.clock = clock @@ -163,14 +216,17 @@ def __init__(self, screen: "Surface", clock: "Clock", font: "Font"): player_y = SCREEN_HEIGHT - 60 self._state = GameState(player=PlayerState(x=player_x, y=player_y, health=100)) - @shell_method('_state', mutates=True) - def update_game_state(self) -> None: raise NotImplementedError() + @shell_method("_state", mutates=True) + def update_game_state(self) -> None: + raise NotImplementedError() - @shell_method('_state', mutates=True) - def move_player(self, direction: str) -> None: raise NotImplementedError() + @shell_method("_state", mutates=True) + def move_player(self, direction: str) -> None: + raise NotImplementedError() - @shell_method('_state', mutates=True) - def shoot_bullet(self) -> None: raise NotImplementedError() + @shell_method("_state", mutates=True) + def shoot_bullet(self) -> None: + raise NotImplementedError() @side_effect_method def display(self): @@ -179,14 +235,26 @@ def display(self): assert pygame is not None, "Pygame must be initialized before running the game." self.screen.fill((10, 10, 40)) - player_r = pygame.Rect(self._state.player.x, self._state.player.y, PLAYER_WIDTH, PLAYER_HEIGHT) + player_r = pygame.Rect( + self._state.player.x, self._state.player.y, PLAYER_WIDTH, PLAYER_HEIGHT + ) pygame.draw.rect(self.screen, (0, 150, 255), player_r) for e in self._state.enemies: - pygame.draw.rect(self.screen, (255, 50, 50), pygame.Rect(e.x, e.y, ENEMY_WIDTH, ENEMY_HEIGHT)) + pygame.draw.rect( + self.screen, + (255, 50, 50), + pygame.Rect(e.x, e.y, ENEMY_WIDTH, ENEMY_HEIGHT), + ) for b in self._state.bullets: - pygame.draw.rect(self.screen, (255, 255, 100), pygame.Rect(b.x, b.y, BULLET_WIDTH, BULLET_HEIGHT)) - - score_text = self.font.render(f"Score: {self._state.score}", True, (255, 255, 255)) + pygame.draw.rect( + self.screen, + (255, 255, 100), + pygame.Rect(b.x, b.y, BULLET_WIDTH, BULLET_HEIGHT), + ) + + score_text = self.font.render( + f"Score: {self._state.score}", True, (255, 255, 255) + ) self.screen.blit(score_text, (10, 10)) if self._state.is_game_over: @@ -204,19 +272,24 @@ def run(self): running = True while running: for event in pygame.event.get(): - if event.type == pygame.QUIT: running = False - if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: self.shoot_bullet() + if event.type == pygame.QUIT: + running = False + if event.type == pygame.KEYDOWN and event.key == pygame.K_SPACE: + self.shoot_bullet() if not self._state.is_game_over: keys = pygame.key.get_pressed() - if keys[pygame.K_LEFT]: self.move_player("LEFT") - if keys[pygame.K_RIGHT]: self.move_player("RIGHT") + if keys[pygame.K_LEFT]: + self.move_player("LEFT") + if keys[pygame.K_RIGHT]: + self.move_player("RIGHT") self.update_game_state() self.display() self.clock.tick(60) pygame.quit() + # ============================================================================== # --- 5. Main Execution Block --- # ============================================================================== @@ -231,5 +304,6 @@ def main(): game = Game(screen=screen, clock=clock, font=font) game.run() + if __name__ == "__main__": main() diff --git a/run_examples.py b/examples/run.py similarity index 78% rename from run_examples.py rename to examples/run.py index 6f11ca8..2cf9214 100644 --- a/run_examples.py +++ b/examples/run.py @@ -2,9 +2,11 @@ A user-friendly entry point to run the examples for the pureshell package. This script handles path setup and provides a simple menu. """ -import sys -import os + import importlib +import os +import sys + def main(): """ @@ -13,11 +15,12 @@ def main(): # Add the project root to the Python path to allow importing the 'pureshell' # package and the 'examples' modules correctly. project_root = os.path.dirname(os.path.abspath(__file__)) - sys.path.insert(0, project_root) + sys.path.insert(0, os.path.dirname(project_root)) # Corrected line examples = { "1": ("examples.shopping_cart_example", "Shopping Cart"), "2": ("examples.game_example", "Pygame Space Shooter"), + "3": ("examples.dynamic_behavior_example", "Dynamic Behavior Bot"), } while True: @@ -28,7 +31,7 @@ def main(): choice = input("Select an example to run: ") - if choice.lower() == 'q': + if choice.lower() == "q": break if choice in examples: @@ -38,15 +41,19 @@ def main(): module = importlib.import_module(module_name) # Assuming each example script has a main() function - if hasattr(module, 'main'): + if hasattr(module, "main"): module.main() else: - print(f"Error: Example '{module_name}' does not have a main() function.") + print( + f"Error: Example '{module_name}' does " + "not have a main() function." + ) except ImportError as e: print(f"Error importing example: {e}") print(f"\n--- Finished: {example_name} ---") else: print("Invalid selection. Please try again.") + if __name__ == "__main__": main() diff --git a/examples/shopping_cart_example.py b/examples/shopping_cart_example.py index df47e16..0e515c7 100644 --- a/examples/shopping_cart_example.py +++ b/examples/shopping_cart_example.py @@ -1,35 +1,47 @@ """Example usage of the PureShell pattern for a Shopping Cart.""" + # shopping_cart_example.py from typing import List -from dataclasses import dataclass, field +from dataclasses import dataclass from pureshell import ( - Ruleset, StatefulEntity, shell_method, ruleset_provider, side_effect_method + Ruleset, + StatefulEntity, + shell_method, + ruleset_provider, + side_effect_method, ) # ============================================================================== # --- 1. Data Structure Definitions --- # ============================================================================== + @dataclass(frozen=True) class CartItem: """Represents an item in the shopping cart.""" + name: str price: float requires_age_check: bool = False + @dataclass(frozen=True) class UserProfile: """Represents basic user profile data.""" + user_id: str age: int + # ============================================================================== # --- 2. Functional Core / Rules Engine --- # ============================================================================== + class CartRules(Ruleset): """A collection of pure functions for cart logic.""" + @staticmethod def add_item(items: List[CartItem], new_item: CartItem) -> List[CartItem]: """Pure function to add an item to a list of cart items.""" @@ -47,30 +59,33 @@ def is_valid(items: List[CartItem], profile: UserProfile) -> bool: return profile.age >= 21 return True + # ============================================================================== # --- 3. Stateful Shell --- # ============================================================================== + @ruleset_provider(CartRules) class ShoppingCart(StatefulEntity): """Manages a shopping cart by delegating logic to pure functions.""" + def __init__(self, user_id: str, age: int): """Initializes the shopping cart with user data.""" self._items: List[CartItem] = [] self._profile: UserProfile = UserProfile(user_id=user_id, age=age) print(f"ShoppingCart created for user {self._profile.user_id}.") - @shell_method('_items', pure_func='calculate_total') + @shell_method("_items", pure_func="calculate_total") def get_total(self) -> float: """Calculates the current total price of all items in the cart.""" raise NotImplementedError() - @shell_method('_items', mutates=True) + @shell_method("_items", mutates=True) def add_item(self, item: CartItem) -> None: """Adds a CartItem to the cart, mutating the internal items list.""" raise NotImplementedError() - @shell_method(('_items', '_profile'), pure_func='is_valid') + @shell_method(("_items", "_profile"), pure_func="is_valid") def is_valid_for_checkout(self) -> bool: """Checks if the cart is valid based on items and user profile.""" raise NotImplementedError() @@ -86,10 +101,12 @@ def display(self): print(f"Valid for checkout: {self.is_valid_for_checkout()}") print("-" * 35 + "\n") + # ============================================================================== # --- 4. Main Execution Block --- # ============================================================================== + def main(): """Main function to demonstrate the ShoppingCart example.""" print("--- SHOPPING CART EXAMPLE ---") diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..b5bacf7 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,6 @@ +[mypy] +python_version = 3.11 +ignore_missing_imports = true +strict = true +show_error_codes = true +pretty = true diff --git a/pureshell/__init__.py b/pureshell/__init__.py index 90e9e60..6470e9a 100644 --- a/pureshell/__init__.py +++ b/pureshell/__init__.py @@ -1,30 +1,42 @@ """PureShell: A Python Design Pattern for Stateful Entities with Pure Functions""" + # __init__.py # pylint: disable=line-too-long,protected-access -from typing import Callable, Any, Generic, TypeVar, overload, Union +from typing import Any, Callable, Generic, TypeVar, Union, cast, overload # ============================================================================== # --- 1. Generic Type Variables & Metaprogramming Tools --- # These are the core tools that power the pattern. # ============================================================================== -_ReturnType = TypeVar("_ReturnType") #pylint: disable=invalid-name +_ReturnType = TypeVar("_ReturnType") # pylint: disable=invalid-name _sentinel = object() # A unique sentinel value for default arguments + class GetAttrNotFoundError(AttributeError): """Custom error raised when an attribute is not found in the rules provider.""" + def __init__(self, attr_name: str, instance: Any): - super().__init__(f"Attribute '{attr_name}' listed in @shell_method not found on instance of '{instance.__class__.__name__}'." - ) + super().__init__( + f"Attribute '{attr_name}' listed in @shell_method not" + f" found on instance of '{instance.__class__.__name__}'." + ) + class PureShellMethod(Generic[_ReturnType]): """ A generic descriptor that creates a "lazy" partial function. - It resolves the pure function it's linked to at call time, allowing it - to fetch live data from the instance it's attached to. + It resolves the pure function it's linked to at call time, + allowing it to fetch live data from the instance it's attached to. """ - def __init__(self, func_or_name: Callable[..., Any] | str, live_attr_names: str | tuple[str, ...], mutates: bool = False): + + def __init__( + self, + func_or_name: Callable[..., Any] | str, + live_attr_names: str | tuple[str, ...], + mutates: bool = False, + ): """ Initializes the PureShellMethod descriptor. @@ -37,16 +49,24 @@ def __init__(self, func_or_name: Callable[..., Any] | str, live_attr_names: str live attribute, and the method will return None. """ self.func_or_name = func_or_name - self.live_attr_names = (live_attr_names,) if isinstance(live_attr_names, str) else live_attr_names + self.live_attr_names = ( + (live_attr_names,) if isinstance(live_attr_names, str) else live_attr_names + ) self.mutates = mutates @overload - def __get__(self, instance: None, owner: type) -> "PureShellMethod[_ReturnType]": ... + def __get__( + self, instance: None, owner: type + ) -> "PureShellMethod[_ReturnType]": ... @overload - def __get__(self, instance: object, owner: type) -> Callable[..., Union[_ReturnType, None]]: ... + def __get__( + self, instance: object, owner: type + ) -> Callable[..., Union[_ReturnType, None]]: ... - def __get__(self, instance: object | None, owner: type) -> Union[Callable[..., Union[_ReturnType, None]], "PureShellMethod[_ReturnType]"]: + def __get__( + self, instance: object | None, owner: type + ) -> Union[Callable[..., Union[_ReturnType, None]], "PureShellMethod[_ReturnType]"]: """ The core of the descriptor protocol, called on attribute access. @@ -56,15 +76,47 @@ def __get__(self, instance: object | None, owner: type) -> Union[Callable[..., U if instance is None: return self - def wrapper(*args, **kwargs) -> _ReturnType | None: + def wrapper(*args: Any, **kwargs: Any) -> _ReturnType | None: """Wraps the pure function call, injecting live state.""" + actual_func: Callable[..., Any] # Resolve the pure function at call time if isinstance(self.func_or_name, str): - if not hasattr(instance, '_rules'): - raise AttributeError(f"Class '{instance.__class__.__name__}' uses string-based shell methods but has no _rules provider.") - rules_provider = getattr(instance, "_rules") - actual_func = getattr(rules_provider, self.func_or_name) - else: + rules_source: Any | None = None + # 1. Check for instance-specific rules + if ( + hasattr(instance, "_instance_rules") + and getattr(instance, "_instance_rules") is not None + ): + rules_source = getattr(instance, "_instance_rules") + # 2. Fallback to class-level rules + elif ( + hasattr(instance.__class__, "_rules") + and getattr(instance.__class__, "_rules") is not None + ): + rules_source = getattr(instance.__class__, "_rules") + + if rules_source is None: + err_msg = ( + f"Instance of '{instance.__class__.__name__}' uses " + f"string-based shell method '{self.func_or_name}' but has no " + f"rules provider. Assign to 'self._instance_rules' in " + f"__init__ or use @ruleset_provider." + ) + raise AttributeError(err_msg) + try: + actual_func = getattr(rules_source, self.func_or_name) + except AttributeError as e: + rules_name = ( + rules_source.__class__.__name__ + if not isinstance(rules_source, type) + else rules_source.__name__ + ) + err_msg = ( + f"Pure function '{self.func_or_name}' not found on rules " + f" provider '{rules_source}'. Ensure defined in '{rules_name}'." + ) + raise AttributeError(err_msg) from e + else: # func_or_name is a direct callable actual_func = self.func_or_name live_data_values = [] @@ -80,21 +132,27 @@ def wrapper(*args, **kwargs) -> _ReturnType | None: setattr(instance, self.live_attr_names[mutating_attr_index], result) return None - return result + return cast(_ReturnType | None, result) return wrapper + def ruleset_provider(rules_cls: type) -> Callable[[type], type]: """A class decorator that registers a 'rules' class for an entity.""" + def decorator(entity_cls: type) -> type: """Attaches the ruleset class to the entity class.""" - entity_cls._rules = rules_cls + setattr(entity_cls, "_rules", rules_cls) # Use setattr for dynamic assignment return entity_cls + return decorator + def shell_method( - live_attr_names: str | tuple[str, ...], pure_func: Callable[..., Any] | str | None = None, mutates: bool = False -) -> Callable[[Callable], PureShellMethod[Any]]: + live_attr_names: str | tuple[str, ...], + pure_func: Callable[..., Any] | str | None = None, + mutates: bool = False, +) -> Callable[[Callable[..., Any]], PureShellMethod[Any]]: """ A method decorator that links a method to a pure function. @@ -103,26 +161,31 @@ def shell_method( the functional core. It can infer the pure function's name from the method it decorates or use an explicitly provided name/function. """ - def decorator(func_placeholder: Callable) -> PureShellMethod[Any]: + + def decorator(func_placeholder: Callable[..., Any]) -> PureShellMethod[Any]: """Creates and returns the configured PureShellMethod descriptor.""" # If pure_func is not provided, use the placeholder's name by convention. func_or_name = pure_func or func_placeholder.__name__ return PureShellMethod(func_or_name, live_attr_names, mutates) + return decorator -def side_effect_method(func: Callable) -> Callable: + +def side_effect_method(func: Callable[..., Any]) -> Callable[..., Any]: """A decorator to explicitly mark a method as having side effects.""" # Tag the function with a special attribute for the enforcement hook to find. - setattr(func, '_is_side_effect', True) + setattr(func, "_is_side_effect", True) return func + class Ruleset: """A base class that ENFORCES that all methods in a ruleset are static.""" - def __init_subclass__(cls, **kwargs): + + def __init_subclass__(cls, **kwargs: Any) -> None: # Added type hints """Inspects subclasses to ensure all methods are static.""" super().__init_subclass__(**kwargs) for name, value in vars(cls).items(): - if name.startswith('__'): + if name.startswith("__"): continue # Ensure that any callable attribute is a staticmethod instance if callable(value) and not isinstance(value, staticmethod): @@ -131,23 +194,63 @@ def __init_subclass__(cls, **kwargs): f"All methods in a Ruleset must be decorated with @staticmethod." ) + class StatefulEntity: """A base class that ENFORCES the stateful shell pattern.""" - _rules: type | None = None - def __init_subclass__(cls, **kwargs): - """Inspects subclasses to ensure they don't contain raw business logic.""" + _rules: type | None = None # Class-level rules provider + _instance_rules: Ruleset | None = None # Instance-level rules provider + + def __init__( + self, + initial_state: dict[str, Any] | None = None, + ruleset_instance: Ruleset | None = None, + ): + """ + Initializes the StatefulEntity. + + Args: + initial_state: An optional dictionary to initialize instance attributes. + Keys should correspond to attribute names. + ruleset_instance: An optional Ruleset instance to override class-level + rules. + """ + if initial_state: + for key, value in initial_state.items(): + setattr(self, key, value) + + if ruleset_instance: + self._instance_rules = ruleset_instance + elif not hasattr(self, "_instance_rules"): # Ensure _instance_rules exists + self._instance_rules = None + + def __init_subclass__(cls, **kwargs: Any) -> None: # Added type hints + """Enforces that no methods in subclasses are actual implementations.""" super().__init_subclass__(**kwargs) for name, value in vars(cls).items(): - is_allowed_side_effect = hasattr(value, '_is_side_effect') + if name.startswith("__") or name in ("_rules", "_instance_rules"): + continue # Skip dunder methods, _rules, and _instance_rules + + # Check if it's a PureShellMethod (already processed) + if isinstance(value, PureShellMethod): + continue + + # Check if it's tagged as a side_effect_method + if hasattr(value, "_is_side_effect") and getattr(value, "_is_side_effect"): + continue - if not callable(value) or \ - (name.startswith('__') and name.endswith('__')) or \ - is_allowed_side_effect or \ - isinstance(value, PureShellMethod): + # Check if it's a property + if isinstance(value, property): + # You might want to inspect fget, fset, fdel of the property + # For now, we'll assume properties are fine or handled elsewhere continue - raise TypeError( - f"Class '{cls.__name__}' has a method '{name}' with business logic. " - f"Methods in a StatefulEntity must be linked via @shell_method, " - f"or marked with @side_effect_method if they perform I/O or rendering." - ) + + if callable(value): + raise TypeError( + f"Class '{cls.__name__}' has an implemented method '{name}'. " + f"Methods in StatefulEntity subclasses must be decorated with " + f"@shell_method or @side_effect_method, or be properties." + ) + + +__version__ = "0.2.0" diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..f82fdbf --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,14 @@ +# Development and test dependencies +pytest +pre-commit +black +flake8 +isort +mypy +pytest-cov +sphinx +pip-tools +markdown-it-py # Used to parse the readme to test the code examples + +# Required for the game_example +pygame diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c9be5f0 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# Core dependencies for pureshell +# NONE! Nice huh? diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..44a0323 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +import sys +import os + +# Ensure the project root is on sys.path for all tests +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) diff --git a/tests/test_framework.py b/tests/test_framework.py index f53b6ee..4d60663 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -1,29 +1,33 @@ """Unit tests for the core PureShell framework enforcement.""" -# test_framework.py -# pylint: disable=line-too-long,protected-access,no-self-use,wrong-import-position + import unittest -import sys -import os -# Add the project root directory to the Python path -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from pureshell import Ruleset, StatefulEntity, ruleset_provider, shell_method -from pureshell import StatefulEntity, Ruleset +# test_framework.py +# pylint: disable=line-too-long,protected-access,wrong-import-position + +# Add the project root directory to the Python path +# sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) # ============================================================================== # --- Test Suite for Core Framework --- # ============================================================================== + class TestPatternEnforcement(unittest.TestCase): """Tests the enforcement mechanisms of the base classes.""" def test_stateful_entity_enforcement(self): """Ensures StatefulEntity rejects classes with raw business logic.""" - with self.assertRaisesRegex(TypeError, "has a method 'rogue_method'"): + # Updated regex to match the more specific error message from StatefulEntity + expected_error_regex = r"Class '.*' has an implemented method '.*'" + with self.assertRaisesRegex(TypeError, expected_error_regex): # This class definition should fail at import time because it violates # the StatefulEntity contract. - class RogueEntity(StatefulEntity): # pylint: disable=unused-variable + class RogueEntity(StatefulEntity): # pylint: disable=unused-variable """An invalid entity with logic in a method.""" + def rogue_method(self): """This should not be allowed.""" return 1 + 1 @@ -32,11 +36,85 @@ def test_ruleset_enforcement(self): """Ensures Ruleset rejects classes with non-static methods.""" with self.assertRaisesRegex(TypeError, "has a non-static method 'rogue_rule'"): # This class definition should fail because its method is not static. - class RogueRules(Ruleset): # pylint: disable=unused-variable + class RogueRules(Ruleset): # pylint: disable=unused-variable """An invalid ruleset.""" + def rogue_rule(self): """This should not be allowed.""" return True -if __name__ == '__main__': + +class TestDynamicRulesetFeatures(unittest.TestCase): + """Tests features related to dynamic ruleset assignment.""" + + def test_dynamic_ruleset_injection(self): + """Tests that rulesets can be injected at instantiation time.""" + + class RulesA(Ruleset): + @staticmethod + def get_value(data: int) -> str: + return f"A:{data * 2}" + + class RulesB(Ruleset): + @staticmethod + def get_value(data: int) -> str: + return f"B:{data * 3}" + + @ruleset_provider(RulesA) # Default class-level ruleset + class MyEntity(StatefulEntity): + def __init__( + self, initial_data: int, rules_instance: Ruleset | None = None + ): + self.data = initial_data + # Dynamically set instance rules if provided + if rules_instance: + self._instance_rules = rules_instance + # If not, it will use RulesA from @ruleset_provider + # Or if _instance_rules is explicitly set to None, it also uses RulesA + + @shell_method("data") # Infers pure_func='get_value' + def get_value(self) -> str: + raise NotImplementedError() + + class EntityNoDefaultRules(StatefulEntity): + def __init__( + self, initial_data: int, rules_instance: Ruleset | None = None + ): + self.data = initial_data + if rules_instance: + self._instance_rules = rules_instance + + @shell_method("data") + def get_value(self) -> str: + raise NotImplementedError() + + # Test with instance-specific rules + entity_a_explicit = MyEntity(10, RulesA()) + self.assertEqual(entity_a_explicit.get_value(), "A:20") + + entity_b_injected = MyEntity(10, RulesB()) + self.assertEqual(entity_b_injected.get_value(), "B:30") + + # Test fallback to class-level ruleset + entity_a_default = MyEntity(10) # No instance rules, should use RulesA + self.assertEqual(entity_a_default.get_value(), "A:20") + + # Test explicit None for instance_rules falls back to class-level + entity_a_explicit_none = MyEntity(10, rules_instance=None) + self.assertEqual(entity_a_explicit_none.get_value(), "A:20") + + # Test entity with no default class-level rules + entity_no_default_with_rules = EntityNoDefaultRules(5, RulesA()) + self.assertEqual(entity_no_default_with_rules.get_value(), "A:10") + + # Test error when no ruleset is available at all + entity_no_rules_at_all = EntityNoDefaultRules(5) + with self.assertRaisesRegex( + AttributeError, + "uses string-based shell method 'get_value' but has no rules provider", + ): + entity_no_rules_at_all.get_value() + + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_game_example.py b/tests/test_game_example.py index 5608af4..ed5ee98 100644 --- a/tests/test_game_example.py +++ b/tests/test_game_example.py @@ -1,29 +1,30 @@ """Unit tests for the Pygame example implementation.""" -# test_game_example.py -# pylint: disable=wrong-import-position -import unittest -import sys -import os -# Add the project root directory to the Python path -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +import unittest # We only need to import the pure data and rules from the example from examples.game_example import ( - GameState, PlayerState, EnemyState, BulletState, - GameRules, PLAYER_SPEED, PLAYER_WIDTH + GameState, + PlayerState, + EnemyState, + BulletState, + GameRules, + PLAYER_SPEED, + PLAYER_WIDTH, ) # ============================================================================== # --- Test Suite for Game Example (Now Mock-Free) --- # ============================================================================== + class TestGameRules(unittest.TestCase): """ Tests the pure functions in the GameRules class. Because the game logic is now totally decoupled from Pygame, these tests are simple, fast, and require no mocking. """ + def setUp(self): """Set up a basic game state for tests.""" self.initial_player = PlayerState(x=400, y=500, health=100) @@ -40,7 +41,7 @@ def test_shoot_bullet(self): """Tests the pure bullet creation logic.""" new_state = GameRules.shoot_bullet(self.initial_state) self.assertEqual(len(new_state.bullets), 1) - self.assertEqual(len(self.initial_state.bullets), 0) # Immutability check + self.assertEqual(len(self.initial_state.bullets), 0) # Immutability check # Check that bullet starts at player's center expected_bullet_x = self.initial_player.x + (PLAYER_WIDTH / 2) - 5 self.assertEqual(new_state.bullets[0].x, expected_bullet_x) @@ -50,7 +51,7 @@ def test_update_game_state_collision(self): state_with_collision = GameState( player=self.initial_player, bullets=[BulletState(x=100, y=100)], - enemies=[EnemyState(x=100, y=105)] # Positioned to collide + enemies=[EnemyState(x=100, y=105)], # Positioned to collide ) new_state = GameRules.update_game_state(state_with_collision) @@ -63,10 +64,11 @@ def test_update_game_state_player_collision(self): """Tests that player collision results in game over.""" state_with_player_collision = GameState( player=self.initial_player, - enemies=[EnemyState(x=self.initial_player.x, y=self.initial_player.y)] + enemies=[EnemyState(x=self.initial_player.x, y=self.initial_player.y)], ) new_state = GameRules.update_game_state(state_with_player_collision) self.assertTrue(new_state.is_game_over) -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main() diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py new file mode 100644 index 0000000..827d9c4 --- /dev/null +++ b/tests/test_readme_examples.py @@ -0,0 +1,126 @@ +import subprocess +import os + +import pytest +from markdown_it import MarkdownIt + + +# Helper function to group python code blocks by markdown heading using markdown-it-py +def get_readme_code_groups_markdown_it(markdown_content: str): + md = MarkdownIt() + tokens = md.parse(markdown_content) + + groups = [] + current_heading_text = "Top Level (before first heading)" + current_blocks_for_heading = [] + + def finalize_current_heading_group(): + nonlocal current_blocks_for_heading, current_heading_text + if current_blocks_for_heading: + merged_code = "\n\n".join(current_blocks_for_heading).strip() + if merged_code: # Only add if there's actual code + groups.append({"heading": current_heading_text, "code": merged_code}) + current_blocks_for_heading = [] + + i = 0 + while i < len(tokens): + token = tokens[i] + + if token.type == "heading_open": + # Finalize blocks for the PREVIOUS heading + finalize_current_heading_group() + + # Extract heading text + i += 1 + current_heading_text = "Unnamed Heading" # Default + if i < len(tokens) and tokens[i].type == "inline": + inline_token_children = tokens[i].children + if inline_token_children: # Explicitly check for None + current_heading_text = "".join( + t.content for t in inline_token_children if t.type == "text" + ).strip() + + # Skip to heading_close + while i < len(tokens) and tokens[i].type != "heading_close": + i += 1 + + elif token.type == "fence" and token.info.strip().lower() == "python": + if token.content: + current_blocks_for_heading.append(token.content.strip()) + + i += 1 + + # Finalize the very last group of blocks + finalize_current_heading_group() + + return groups + + +# Test to run readme examples +def test_readme_examples(capsys): + try: + with open("README.md", "r", encoding="utf-8") as f: + readme_content = f.read() + except FileNotFoundError: + pytest.skip("README.md not found.") + + code_groups = get_readme_code_groups_markdown_it(readme_content) + + if not code_groups: + pytest.skip( + "No Python code groups found in README.md by markdown-it-py parser." + ) + + all_tests_passed = True + for i, group in enumerate(code_groups): + if not group["code"].strip(): + print(f"Skipping empty code group under heading: {group['heading']}") + continue + + print(f"Testing code group under heading: {group['heading']}") + + try: + current_env = os.environ.copy() + project_root = os.getcwd() + + # Prepend project root to PYTHONPATH for the subprocess + existing_pythonpath = current_env.get("PYTHONPATH", "") + if existing_pythonpath: + current_env["PYTHONPATH"] = ( + project_root + os.pathsep + existing_pythonpath + ) + else: + current_env["PYTHONPATH"] = project_root + + process = subprocess.run( + ["python", "-c", group["code"]], + capture_output=True, + text=True, + check=False, # We'll check manually to provide better error messages + cwd=".", + env=current_env, # Use the modified environment + ) + if process.returncode != 0: + all_tests_passed = False + pytest.fail( + f"README.md code under heading '{group['heading']}' " + f"(group {i}) failed:\n" + f"Return Code: {process.returncode}\n" + f"Code:\n{group['code']}\n" + f"Stdout:\n{process.stdout}\n" + f"Stderr:\n{process.stderr}" + ) + + except FileNotFoundError: + all_tests_passed = False + pytest.fail("Python interpreter not found. Ensure python is in your PATH.") + except Exception as e: + all_tests_passed = False + pytest.fail( + f"An unexpected error occurred while testing code group " + f"'{group['heading']}' (group {i}):\n" + f"Error: {e}\n" + f"Code:\n{group['code']}" + ) + + assert all_tests_passed, "One or more README example code blocks failed." diff --git a/tests/test_shopping_cart.py b/tests/test_shopping_cart.py index 2b10cfb..2c51f82 100644 --- a/tests/test_shopping_cart.py +++ b/tests/test_shopping_cart.py @@ -1,15 +1,18 @@ """Unit tests for the ShoppingCart example implementation.""" + +import unittest + +# import sys +# import os + # test_shopping_cart.py # pylint: disable=line-too-long,protected-access,wrong-import-position -import unittest -import sys -import os # Add the project root directory to the Python path -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - +# sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from examples.shopping_cart_example import ( - CartItem, UserProfile, + CartItem, + UserProfile, CartRules, ShoppingCart, ) @@ -18,6 +21,7 @@ # --- Test Suite for Shopping Cart --- # ============================================================================== + class TestCartRules(unittest.TestCase): """Tests the pure functions in the CartRules class.""" @@ -49,6 +53,7 @@ def test_is_valid(self): self.assertTrue(CartRules.is_valid([normal_item], minor)) self.assertTrue(CartRules.is_valid([], adult)) + class TestShoppingCartIntegration(unittest.TestCase): """Tests the stateful ShoppingCart class.""" @@ -77,5 +82,6 @@ def test_multi_attribute_method(self): self.assertTrue(adult_cart.is_valid_for_checkout()) self.assertFalse(minor_cart.is_valid_for_checkout()) -if __name__ == '__main__': + +if __name__ == "__main__": unittest.main()