From d7e01ce2a4f94449d0eced1535a2f30e055adb82 Mon Sep 17 00:00:00 2001 From: Dane Jones Date: Fri, 13 Jun 2025 14:13:46 -0400 Subject: [PATCH 01/13] Add initial project structure with configuration and example files - Created .coveragerc for coverage reporting configuration - Added .editorconfig for consistent coding styles - Introduced .flake8 for linting configuration - Set up CI workflow in .github/workflows/ci.yml - Updated .gitignore to exclude additional files - Configured pre-commit hooks in .pre-commit-config.yaml - Added Sphinx documentation configuration in docs/conf.py and docs/index.rst - Created example scripts in examples/ directory - Implemented unit tests for core functionality and examples - Defined development and core dependencies in requirements files --- .coveragerc | 10 +++ .editorconfig | 13 +++ .flake8 | 3 + .github/workflows/ci.yml | 34 ++++++++ .gitignore | 9 ++ .pre-commit-config.yaml | 16 ++++ README.md | 2 +- docs/conf.py | 17 ++++ docs/index.rst | 17 ++++ examples/__init__.py | 16 ++++ examples/game_example.py | 136 ++++++++++++++++++++++------- run_examples.py => examples/run.py | 16 ++-- examples/shopping_cart_example.py | 27 ++++-- mypy.ini | 6 ++ pureshell/__init__.py | 80 ++++++++++++----- requirements-dev.txt | 13 +++ requirements.txt | 2 + tests/conftest.py | 5 ++ tests/test_framework.py | 24 ++--- tests/test_game_example.py | 28 +++--- tests/test_shopping_cart.py | 20 +++-- 21 files changed, 401 insertions(+), 93 deletions(-) create mode 100644 .coveragerc create mode 100644 .editorconfig create mode 100644 .flake8 create mode 100644 .github/workflows/ci.yml create mode 100644 .pre-commit-config.yaml create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 examples/__init__.py rename run_examples.py => examples/run.py (85%) create mode 100644 mypy.ini create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/conftest.py 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..16d0f2e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [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..410476e 100644 --- a/README.md +++ b/README.md @@ -140,7 +140,7 @@ 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. ```bash -python run_examples.py +python -m example.run ``` This will present a menu where you can choose between: 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/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 85% rename from run_examples.py rename to examples/run.py index 6f11ca8..758eca6 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(): """ @@ -28,7 +30,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 +40,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..1f186c5 100644 --- a/pureshell/__init__.py +++ b/pureshell/__init__.py @@ -1,4 +1,5 @@ """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 @@ -8,23 +9,34 @@ # 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" + " 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. @@ -60,8 +80,11 @@ def wrapper(*args, **kwargs) -> _ReturnType | None: """Wraps the pure function call, injecting live state.""" # 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.") + 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: @@ -84,16 +107,22 @@ def wrapper(*args, **kwargs) -> _ReturnType | None: 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 return entity_cls + return decorator + def shell_method( - live_attr_names: str | tuple[str, ...], pure_func: Callable[..., Any] | str | None = None, mutates: bool = False + live_attr_names: str | tuple[str, ...], + pure_func: Callable[..., Any] | str | None = None, + mutates: bool = False, ) -> Callable[[Callable], PureShellMethod[Any]]: """ A method decorator that links a method to a pure function. @@ -103,26 +132,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]: """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: """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): """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,20 +165,24 @@ 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.""" super().__init_subclass__(**kwargs) for name, value in vars(cls).items(): - is_allowed_side_effect = hasattr(value, '_is_side_effect') - - if not callable(value) or \ - (name.startswith('__') and name.endswith('__')) or \ - is_allowed_side_effect or \ - isinstance(value, PureShellMethod): + is_allowed_side_effect = hasattr(value, "_is_side_effect") + + if ( + not callable(value) + or (name.startswith("__") and name.endswith("__")) + or is_allowed_side_effect + or isinstance(value, PureShellMethod) + ): continue raise TypeError( f"Class '{cls.__name__}' has a method '{name}' with business logic. " diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..4bd4d0b --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,13 @@ +# Development and test dependencies +pytest +pre-commit +black +flake8 +isort +mypy +pytest-cov +sphinx +pip-tools + +# 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..1ea2f86 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -1,19 +1,20 @@ """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__), '..'))) +import unittest 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.""" @@ -22,8 +23,9 @@ def test_stateful_entity_enforcement(self): with self.assertRaisesRegex(TypeError, "has a method 'rogue_method'"): # 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 +34,13 @@ 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__': + +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_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() From fe2feab0437e2a9817b7edd1d302f379928ff87a Mon Sep 17 00:00:00 2001 From: Dane Jones Date: Fri, 13 Jun 2025 14:29:57 -0400 Subject: [PATCH 02/13] Enhance installation instructions and add development setup details in README --- README.md | 113 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 103 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 410476e..9b66fbc 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,42 @@ The framework is built around a few key components: ## πŸš€ Installation -```bash -pip install pureshell -``` +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 @@ -139,23 +172,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 -m example.run +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 From d4061208d6e625d1b82b2f0a344ecacb6e23cc13 Mon Sep 17 00:00:00 2001 From: Dane Jones Date: Fri, 13 Jun 2025 15:36:11 -0400 Subject: [PATCH 03/13] Fix string bug in GetAttrNotFoundError for improved error messages --- pureshell/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pureshell/__init__.py b/pureshell/__init__.py index 1f186c5..c12b695 100644 --- a/pureshell/__init__.py +++ b/pureshell/__init__.py @@ -2,7 +2,7 @@ # __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, overload # ============================================================================== # --- 1. Generic Type Variables & Metaprogramming Tools --- @@ -19,7 +19,7 @@ class GetAttrNotFoundError(AttributeError): 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__}'." + f" found on instance of '{instance.__class__.__name__}'." ) From bbd26a7b59401a9f55b4476751f0be068b9f4a16 Mon Sep 17 00:00:00 2001 From: Dane Jones Date: Fri, 13 Jun 2025 15:54:15 -0400 Subject: [PATCH 04/13] Update CI workflow to trigger on all branches for push events --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 16d0f2e..e8a6f7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: ["**"] pull_request: branches: [main] From 2adecfae530375bf2bfae8cc102d27ab4e67d282 Mon Sep 17 00:00:00 2001 From: Dane Jones Date: Fri, 13 Jun 2025 15:56:16 -0400 Subject: [PATCH 05/13] Restrict CI workflow to trigger only on master and main branches --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8a6f7f..5e1cff8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: ["**"] + branches: [master, main] pull_request: - branches: [main] + branches: [master, main] jobs: build: From 1c060c78c149140b974db94fadf84e0bd6758f42 Mon Sep 17 00:00:00 2001 From: Dane Jones Date: Fri, 13 Jun 2025 16:27:31 -0400 Subject: [PATCH 06/13] Add "improve-development-environment" branch to CI push triggers --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e1cff8..b041358 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [master, main] + branches: [master, main, "improve-development-environment"] pull_request: branches: [master, main] From b9d31935e81a1a5b95589478219ab9c112353728 Mon Sep 17 00:00:00 2001 From: Dane Jones Date: Sat, 14 Jun 2025 11:37:19 -0400 Subject: [PATCH 07/13] Refactor type hints in PureShellMethod and decorators for improved clarity and consistency --- pureshell/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pureshell/__init__.py b/pureshell/__init__.py index c12b695..58ef7d4 100644 --- a/pureshell/__init__.py +++ b/pureshell/__init__.py @@ -2,7 +2,7 @@ # __init__.py # pylint: disable=line-too-long,protected-access -from typing import Any, Callable, Generic, TypeVar, Union, overload +from typing import Any, Callable, Generic, TypeVar, Union, cast, overload # ============================================================================== # --- 1. Generic Type Variables & Metaprogramming Tools --- @@ -76,7 +76,7 @@ def __get__( 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.""" # Resolve the pure function at call time if isinstance(self.func_or_name, str): @@ -103,7 +103,7 @@ 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 @@ -123,7 +123,7 @@ def shell_method( live_attr_names: str | tuple[str, ...], pure_func: Callable[..., Any] | str | None = None, mutates: bool = False, -) -> Callable[[Callable], PureShellMethod[Any]]: +) -> Callable[[Callable[..., Any]], PureShellMethod[Any]]: """ A method decorator that links a method to a pure function. @@ -133,7 +133,7 @@ def shell_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__ @@ -142,7 +142,7 @@ def decorator(func_placeholder: Callable) -> PureShellMethod[Any]: 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) From 51c94d2544d2be00b934471c67a485d591e9e371 Mon Sep 17 00:00:00 2001 From: Dane Jones Date: Sat, 14 Jun 2025 12:29:31 -0400 Subject: [PATCH 08/13] Add dynamic ruleset injection feature and corresponding examples - Introduced dynamic ruleset injection in StatefulEntity for flexible behavior. - Added example demonstrating configurable bot behavior with different rulesets. - Updated run.py to include the new dynamic behavior example. - Enhanced unit tests to cover dynamic ruleset features and ensure proper functionality. --- README.md | 48 ++++++- examples/dynamic_behavior_example.py | 186 +++++++++++++++++++++++++++ examples/run.py | 3 +- pureshell/__init__.py | 47 +++++-- tests/test_framework.py | 74 ++++++++++- 5 files changed, 347 insertions(+), 11 deletions(-) create mode 100644 examples/dynamic_behavior_example.py diff --git a/README.md b/README.md index 9b66fbc..3fbfdbe 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,52 @@ 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. +### New in version 0.2.0: Dynamic Ruleset Injection + +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): + super().__init__(initial_state, ruleset_instance) + + @shell_method(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={}, ruleset_instance=BehaviorA()) +entity_a.perform_action() # Will use BehaviorA.act + +# Create entity with BehaviorB +entity_b = MyEntity(initial_state={}, 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. @@ -172,7 +218,7 @@ 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)). +First, ensure you have installed the project in editable mode and the development dependencies (see [πŸš€ Installation](#-installation)). Then, run the examples module: 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/run.py b/examples/run.py index 758eca6..2cf9214 100644 --- a/examples/run.py +++ b/examples/run.py @@ -15,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: diff --git a/pureshell/__init__.py b/pureshell/__init__.py index 58ef7d4..17bbaf9 100644 --- a/pureshell/__init__.py +++ b/pureshell/__init__.py @@ -78,16 +78,45 @@ def __get__( 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_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." ) - rules_provider = getattr(instance, "_rules") - actual_func = getattr(rules_provider, self.func_or_name) - else: + 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 = [] @@ -169,7 +198,9 @@ def __init_subclass__(cls, **kwargs): class StatefulEntity: """A base class that ENFORCES the stateful shell pattern.""" - _rules: type | None = None + _rules: type | None = None # Class-level rules, set by @ruleset_provider + # Instance-level rules, optionally set in __init__ by subclass + _instance_rules: Any | None = None def __init_subclass__(cls, **kwargs): """Inspects subclasses to ensure they don't contain raw business logic.""" diff --git a/tests/test_framework.py b/tests/test_framework.py index 1ea2f86..a9976dc 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -2,7 +2,7 @@ import unittest -from pureshell import StatefulEntity, Ruleset +from pureshell import Ruleset, StatefulEntity, ruleset_provider, shell_method # test_framework.py # pylint: disable=line-too-long,protected-access,wrong-import-position @@ -42,5 +42,77 @@ def rogue_rule(self): return True +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() From 089c03092a9999b31fa6576c40f03694204ab799 Mon Sep 17 00:00:00 2001 From: Dane Jones Date: Sat, 14 Jun 2025 12:30:07 -0400 Subject: [PATCH 09/13] Add tests for extracting and grouping Python code blocks from README.md --- tests/test_readme_examples.py | 106 ++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 tests/test_readme_examples.py diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py new file mode 100644 index 0000000..f2b5fd9 --- /dev/null +++ b/tests/test_readme_examples.py @@ -0,0 +1,106 @@ +import re +import subprocess + +import pytest + + +# Helper function to extract python code blocks from markdown +def extract_python_code_blocks(markdown_content): + code_blocks = re.findall(r"```python\n(.*?)\n```", markdown_content, re.DOTALL) + return code_blocks + + +# Helper function to group python code blocks by markdown heading +def group_code_blocks_by_heading(markdown_content: str): + groups = [] + current_heading_text = "Top Level (before first heading)" + # Stores individual code blocks for the current heading + current_blocks_for_heading = [] + + lines = markdown_content.splitlines() + in_python_code_block = False + current_block_lines = [] + + 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) + groups.append({"heading": current_heading_text, "code": merged_code}) + current_blocks_for_heading = [] + + for line in lines: + if line.startswith("#"): # New heading + if in_python_code_block: + # Current block is terminated by a new heading + current_blocks_for_heading.append("\\n".join(current_block_lines)) + current_block_lines = [] + in_python_code_block = False + + finalize_current_heading_group() # Finalize blocks for the PREVIOUS heading + current_heading_text = line.strip() + continue + + if line.strip() == "```python": + # A ```python inside another, treat as end of prior + if in_python_code_block: + current_blocks_for_heading.append("\\n".join(current_block_lines)) + # current_block_lines are reset below + + in_python_code_block = True + current_block_lines = [] # Reset for the new block + continue + + if line.strip() == "```" and in_python_code_block: + # Normal end of a python code block + current_blocks_for_heading.append("\\n".join(current_block_lines)) + current_block_lines = [] + in_python_code_block = False + continue + + if in_python_code_block: + current_block_lines.append(line) + + # After loop, handle any unterminated block + if in_python_code_block: + current_blocks_for_heading.append("\\n".join(current_block_lines)) + + # Finalize the very last group of blocks + finalize_current_heading_group() + + return groups + + +# Test to run readme examples +def test_readme_examples(capsys): + with open("README.md", "r", encoding="utf-8") as f: + readme_content = f.read() + + code_groups = group_code_blocks_by_heading(readme_content) + + if not code_groups: + pytest.skip("No code groups found in README.md") + + for i, group in enumerate(code_groups): + if not group["code"].strip(): # Skip if no actual code in this group + print(f"Skipping empty code group under heading: {group['heading']}") + continue + + print(f"Testing code group under heading: {group['heading']}") + + try: + subprocess.run( + ["python", "-c", group["code"]], + capture_output=True, + text=True, + check=True, + cwd=".", # Run from project root + ) + except subprocess.CalledProcessError as e: + pytest.fail( + f"README.md code under heading '{group['heading']}' " + f"(group {i}) failed:\n" + f"Code:\n{group['code']}\n" + f"Error:\n{e.stderr}" + ) + except FileNotFoundError: + pytest.fail("Python interpreter not found. Ensure python is in your PATH.") From bce61934fde1bdaa68f9c71249a54ca9fc38a3f2 Mon Sep 17 00:00:00 2001 From: Dane Jones Date: Sat, 14 Jun 2025 14:01:27 -0400 Subject: [PATCH 10/13] Enhance StatefulEntity initialization and enforce method restrictions in subclasses --- pureshell/__init__.py | 68 ++++++++++++++++++++++++++++++++----------- 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/pureshell/__init__.py b/pureshell/__init__.py index 17bbaf9..a230797 100644 --- a/pureshell/__init__.py +++ b/pureshell/__init__.py @@ -198,25 +198,59 @@ def __init_subclass__(cls, **kwargs): class StatefulEntity: """A base class that ENFORCES the stateful shell pattern.""" - _rules: type | None = None # Class-level rules, set by @ruleset_provider - # Instance-level rules, optionally set in __init__ by subclass - _instance_rules: Any | None = None + _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): - """Inspects subclasses to ensure they don't contain raw business logic.""" + """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 ( - not callable(value) - or (name.startswith("__") and name.endswith("__")) - or is_allowed_side_effect - or isinstance(value, PureShellMethod) - ): + 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 - 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." - ) + + # Check if it's tagged as a side_effect_method + if hasattr(value, "_is_side_effect") and getattr(value, "_is_side_effect"): + continue + + # 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 + + 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" From 53d1c09386d8eac66f7682f40b780bb972d27658 Mon Sep 17 00:00:00 2001 From: Dane Jones Date: Sat, 14 Jun 2025 14:03:20 -0400 Subject: [PATCH 11/13] Update regex in test_stateful_entity_enforcement for more specific error matching --- tests/test_framework.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_framework.py b/tests/test_framework.py index a9976dc..4d60663 100644 --- a/tests/test_framework.py +++ b/tests/test_framework.py @@ -20,7 +20,9 @@ class TestPatternEnforcement(unittest.TestCase): 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 From c518439760a0e2b37180598e0af3c80723e70291 Mon Sep 17 00:00:00 2001 From: Dane Jones Date: Sat, 14 Jun 2025 14:04:43 -0400 Subject: [PATCH 12/13] Refactor README examples and update test to use markdown-it-py for code extraction --- README.md | 7 +- requirements-dev.txt | 1 + tests/test_readme_examples.py | 140 +++++++++++++++++++--------------- 3 files changed, 85 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 3fbfdbe..e4d46b5 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,10 @@ class BehaviorB(Ruleset): 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(mutates=True) + @shell_method("state_data", pure_func="act", mutates=True) def perform_action(self) -> None: pass # Logic delegated to ruleset's 'act' method @@ -61,11 +62,11 @@ class MyEntity(StatefulEntity): # entity_default = MyEntity(initial_state={}) # Create entity with BehaviorA -entity_a = MyEntity(initial_state={}, ruleset_instance=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={}, ruleset_instance=BehaviorB()) +entity_b = MyEntity(initial_state={"state_data": {}}, ruleset_instance=BehaviorB()) entity_b.perform_action() # Will use BehaviorB.act ``` diff --git a/requirements-dev.txt b/requirements-dev.txt index 4bd4d0b..f82fdbf 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -8,6 +8,7 @@ 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/tests/test_readme_examples.py b/tests/test_readme_examples.py index f2b5fd9..827d9c4 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -1,68 +1,54 @@ -import re import subprocess +import os import pytest +from markdown_it import MarkdownIt -# Helper function to extract python code blocks from markdown -def extract_python_code_blocks(markdown_content): - code_blocks = re.findall(r"```python\n(.*?)\n```", markdown_content, re.DOTALL) - return code_blocks +# 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) - -# Helper function to group python code blocks by markdown heading -def group_code_blocks_by_heading(markdown_content: str): groups = [] current_heading_text = "Top Level (before first heading)" - # Stores individual code blocks for the current heading current_blocks_for_heading = [] - lines = markdown_content.splitlines() - in_python_code_block = False - current_block_lines = [] - 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) - groups.append({"heading": current_heading_text, "code": merged_code}) + 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 = [] - for line in lines: - if line.startswith("#"): # New heading - if in_python_code_block: - # Current block is terminated by a new heading - current_blocks_for_heading.append("\\n".join(current_block_lines)) - current_block_lines = [] - in_python_code_block = False - - finalize_current_heading_group() # Finalize blocks for the PREVIOUS heading - current_heading_text = line.strip() - continue + i = 0 + while i < len(tokens): + token = tokens[i] - if line.strip() == "```python": - # A ```python inside another, treat as end of prior - if in_python_code_block: - current_blocks_for_heading.append("\\n".join(current_block_lines)) - # current_block_lines are reset below + if token.type == "heading_open": + # Finalize blocks for the PREVIOUS heading + finalize_current_heading_group() - in_python_code_block = True - current_block_lines = [] # Reset for the new block - continue + # 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() - if line.strip() == "```" and in_python_code_block: - # Normal end of a python code block - current_blocks_for_heading.append("\\n".join(current_block_lines)) - current_block_lines = [] - in_python_code_block = False - continue + # Skip to heading_close + while i < len(tokens) and tokens[i].type != "heading_close": + i += 1 - if in_python_code_block: - current_block_lines.append(line) + elif token.type == "fence" and token.info.strip().lower() == "python": + if token.content: + current_blocks_for_heading.append(token.content.strip()) - # After loop, handle any unterminated block - if in_python_code_block: - current_blocks_for_heading.append("\\n".join(current_block_lines)) + i += 1 # Finalize the very last group of blocks finalize_current_heading_group() @@ -72,35 +58,69 @@ def finalize_current_heading_group(): # Test to run readme examples def test_readme_examples(capsys): - with open("README.md", "r", encoding="utf-8") as f: - readme_content = f.read() + 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 = group_code_blocks_by_heading(readme_content) + code_groups = get_readme_code_groups_markdown_it(readme_content) if not code_groups: - pytest.skip("No code groups found in README.md") + 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(): # Skip if no actual code in this group + 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: - subprocess.run( + 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=True, - cwd=".", # Run from project root - ) - except subprocess.CalledProcessError as e: - pytest.fail( - f"README.md code under heading '{group['heading']}' " - f"(group {i}) failed:\n" - f"Code:\n{group['code']}\n" - f"Error:\n{e.stderr}" + 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." From 65a93dba38ca28c5d9e6e47f227323461115b032 Mon Sep 17 00:00:00 2001 From: Dane Jones Date: Sat, 14 Jun 2025 14:37:57 -0400 Subject: [PATCH 13/13] Use setattr for dynamic assignment in ruleset_provider and add type hints to __init_subclass__ methods --- pureshell/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pureshell/__init__.py b/pureshell/__init__.py index a230797..6470e9a 100644 --- a/pureshell/__init__.py +++ b/pureshell/__init__.py @@ -142,7 +142,7 @@ def ruleset_provider(rules_cls: type) -> Callable[[type], type]: 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 @@ -181,7 +181,7 @@ def side_effect_method(func: Callable[..., Any]) -> Callable[..., Any]: 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(): @@ -224,7 +224,7 @@ def __init__( elif not hasattr(self, "_instance_rules"): # Ensure _instance_rules exists self._instance_rules = None - def __init_subclass__(cls, **kwargs): + 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():