diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 021c66a..5a0221c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + fetch-depth: 0 - name: Setup Python uses: actions/setup-python@v5 @@ -58,17 +60,44 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install ruff black mypy + pip install -e ".[dev]" - - name: Run ruff (linting) + - name: Run full-package Ruff fatal checks + run: | + ruff check src/ --select E9,F63,F7,F82 --output-format=github + + - name: Resolve quality diff base + id: quality-base + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + PUSH_BASE_SHA: ${{ github.event.before }} + run: | + case "$EVENT_NAME" in + pull_request) base_sha="$PR_BASE_SHA" ;; + push) base_sha="$PUSH_BASE_SHA" ;; + *) base_sha="" ;; + esac + if [[ -z "$base_sha" || "$base_sha" =~ ^0+$ ]] || + ! git cat-file -e "${base_sha}^{commit}"; then + base_sha="$(git rev-parse HEAD^)" + fi + echo "sha=$base_sha" >> "$GITHUB_OUTPUT" + + - name: Reject Ruff diagnostics introduced on added lines run: | - ruff check src/ --output-format=github + python scripts/check_ruff_added_lines.py \ + --base "${{ steps.quality-base.outputs.sha }}" \ + --head "$GITHUB_SHA" - - name: Run black (formatting check) + - name: Report repository-wide Black baseline (non-blocking) + continue-on-error: true run: | black --check src/ - - name: Run mypy (type checking) + - name: Report repository-wide Mypy baseline (non-blocking) + continue-on-error: true run: | mypy src/ --ignore-missing-imports diff --git a/docs/conf.py b/docs/conf.py index 755d2f4..f5abbe5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,7 +11,7 @@ copyright = "2024, Tristan Simas" author = "Tristan Simas" version = "1.0" -release = "1.0.20" +release = "1.0.21" # General configuration extensions = [ diff --git a/pyproject.toml b/pyproject.toml index 5833633..d61eb0f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "objectstate" -version = "1.0.20" +version = "1.0.21" description = "Generic lazy dataclass configuration framework with dual-axis inheritance and contextvars-based resolution" authors = [{name = "Tristan Simas", email = "tristan.simas@mail.mcgill.ca"}] license = {text = "MIT"} @@ -31,9 +31,9 @@ dependencies = [ dev = [ "pytest>=7.0", "pytest-cov>=4.0", - "black>=23.0", - "ruff>=0.1.0", - "mypy>=1.0", + "black==26.5.1", + "ruff==0.16.0", + "mypy==2.3.0", ] docs = [ "sphinx>=7.0", @@ -64,6 +64,8 @@ target-version = ["py311", "py312", "py313"] [tool.ruff] line-length = 100 target-version = "py311" + +[tool.ruff.lint] select = ["E", "F", "I", "N", "W", "UP"] [tool.mypy] diff --git a/scripts/check_ruff_added_lines.py b/scripts/check_ruff_added_lines.py new file mode 100644 index 0000000..3e0779a --- /dev/null +++ b/scripts/check_ruff_added_lines.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Fail when configured Ruff diagnostics touch lines added by a Git diff.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +HUNK_HEADER = re.compile( + r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@" +) + + +def _git_output(*arguments: str, binary: bool = False) -> str | bytes: + result = subprocess.run( + ("git", *arguments), + check=True, + capture_output=True, + text=not binary, + ) + return result.stdout + + +def _changed_python_files(base: str, head: str | None) -> tuple[Path, ...]: + revision_arguments = (base, head) if head is not None else (base,) + output = _git_output( + "diff", + "--name-only", + "--diff-filter=ACMR", + "-z", + *revision_arguments, + "--", + "*.py", + binary=True, + ) + assert isinstance(output, bytes) + return tuple( + Path(os.fsdecode(path)) + for path in output.split(b"\0") + if path + ) + + +def _added_lines( + path: Path, + base: str, + head: str | None, +) -> frozenset[int]: + revision_arguments = (base, head) if head is not None else (base,) + output = _git_output( + "diff", + "--unified=0", + "--no-ext-diff", + "--no-color", + *revision_arguments, + "--", + os.fspath(path), + ) + assert isinstance(output, str) + added: set[int] = set() + for line in output.splitlines(): + match = HUNK_HEADER.match(line) + if match is None: + continue + first_line = int(match.group(1)) + line_count = int(match.group(2) or "1") + added.update(range(first_line, first_line + line_count)) + return frozenset(added) + + +def _ruff_diagnostics(paths: tuple[Path, ...]) -> list[dict[str, object]]: + result = subprocess.run( + ("ruff", "check", "--output-format=json", *(os.fspath(path) for path in paths)), + check=False, + capture_output=True, + text=True, + ) + try: + diagnostics = json.loads(result.stdout) + except json.JSONDecodeError as error: + raise RuntimeError( + "Ruff did not return JSON diagnostics.\n" + f"stdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) from error + if result.returncode not in {0, 1}: + raise RuntimeError( + f"Ruff failed with exit code {result.returncode}:\n{result.stderr}" + ) + return diagnostics + + +def _github_escape(value: object) -> str: + return ( + str(value) + .replace("%", "%25") + .replace("\r", "%0D") + .replace("\n", "%0A") + ) + + +def _relative_diagnostic_path(filename: object) -> Path: + path = Path(str(filename)) + if path.is_absolute(): + return path.relative_to(Path.cwd()) + return path + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", required=True, help="Git revision before the change") + parser.add_argument( + "--head", + help="Git revision after the change; omit to inspect the current worktree", + ) + arguments = parser.parse_args() + + changed_paths = _changed_python_files(arguments.base, arguments.head) + if not changed_paths: + print("No changed Python files.") + return 0 + + line_index = { + path: _added_lines(path, arguments.base, arguments.head) + for path in changed_paths + } + introduced = [] + for diagnostic in _ruff_diagnostics(changed_paths): + path = _relative_diagnostic_path(diagnostic["filename"]) + location = diagnostic["location"] + assert isinstance(location, dict) + row = int(location["row"]) + if row in line_index.get(path, frozenset()): + introduced.append((path, row, location, diagnostic)) + + if not introduced: + print( + "Configured Ruff rules report no diagnostics on added Python lines " + f"across {len(changed_paths)} changed files." + ) + return 0 + + for path, row, location, diagnostic in introduced: + code = diagnostic["code"] + message = diagnostic["message"] + print( + f"::error file={_github_escape(path)},line={row}," + f"col={location['column']},title=Ruff {code}::" + f"{_github_escape(message)}" + ) + print(f"{len(introduced)} Ruff diagnostics touch added Python lines.") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/objectstate/__init__.py b/src/objectstate/__init__.py index 1162c71..3987484 100644 --- a/src/objectstate/__init__.py +++ b/src/objectstate/__init__.py @@ -214,7 +214,7 @@ 'ObjectStateEditSession', ] -__version__ = '1.0.20' +__version__ = '1.0.21' __author__ = 'OpenHCS Team' __description__ = 'Generic configuration framework for lazy dataclass resolution' diff --git a/src/objectstate/field_access.py b/src/objectstate/field_access.py index e50b249..44d2c2f 100644 --- a/src/objectstate/field_access.py +++ b/src/objectstate/field_access.py @@ -23,6 +23,12 @@ def parts(self) -> tuple[str, ...]: return () return tuple(part for part in self.value.split(".") if part) + @property + def field_name(self) -> str: + """Return the declared leaf name represented by this path.""" + + return self.parts[-1] if self.parts else "" + def child(self, field_name: str) -> "DottedFieldPath": if self.value == "": return DottedFieldPath(field_name) diff --git a/src/objectstate/lazy_factory.py b/src/objectstate/lazy_factory.py index b6513e8..525caf1 100644 --- a/src/objectstate/lazy_factory.py +++ b/src/objectstate/lazy_factory.py @@ -1,18 +1,17 @@ """Generic lazy dataclass factory using flexible resolution.""" # Standard library imports +import copy import dataclasses import logging import re import sys from abc import ABC from contextlib import contextmanager +from dataclasses import MISSING, dataclass, field, fields, is_dataclass, make_dataclass from functools import lru_cache - -from dataclasses import dataclass, fields, is_dataclass, make_dataclass, MISSING, field from typing import Any, Callable, Dict, List, Optional, Tuple, Type, TypeVar, Union, get_type_hints -from objectstate.ui_visibility import mark_ui_hidden_config from python_introspect import ( make_optional, register_type_resolver, @@ -20,6 +19,8 @@ resolve_optional, ) +from objectstate.ui_visibility import mark_ui_hidden_config + # Note: dual_axis_resolver_recursive and lazy_placeholder imports kept inline to avoid circular imports @@ -40,6 +41,38 @@ def _resolved_dataclass_annotations(dataclass_type: type) -> Dict[str, object]: ) +def _recreated_dataclass_namespace( + declared_type: type, + recreated_name: str, +) -> dict[str, object]: + """Return declaration-owned metadata for a recreated dataclass.""" + + namespace: dict[str, object] = {"__doc__": declared_type.__doc__} + if recreated_name == declared_type.__name__: + namespace["__qualname__"] = declared_type.__qualname__ + return namespace + + +def _restore_recreated_dataclass_metadata( + recreated_type: type, + declared_type: type, +) -> type: + """Restore runtime metadata that class creation may replace or remove.""" + + recreated_type.__module__ = declared_type.__module__ + recreated_type.__doc__ = declared_type.__doc__ + if recreated_type.__name__ == declared_type.__name__: + recreated_type.__qualname__ = declared_type.__qualname__ + + declared_first_line = declared_type.__dict__.get("__firstlineno__", MISSING) + if declared_first_line is not MISSING: + try: + setattr(recreated_type, "__firstlineno__", declared_first_line) + except (AttributeError, TypeError): + pass + return recreated_type + + # ============================================================================= # UNIFIED NONE-FORCING: Single path for both base and lazy classes # Replaces the old 3-stage approach (pre-process setattr, post-process Field patch) @@ -105,8 +138,6 @@ def rebuild_with_none_defaults( Returns: A new class with the same fields but modified defaults """ - import copy - if not dataclasses.is_dataclass(cls): raise ValueError(f"{cls} is not a dataclass") @@ -138,12 +169,13 @@ def rebuild_with_none_defaults( field_defs.append((f.name, declared_type, copy.copy(f))) # Collect non-dunder attributes to preserve (methods, class vars, etc.) - namespace = {} + recreated_name = new_name or cls.__name__ + namespace = _recreated_dataclass_namespace(cls, recreated_name) for key, value in cls.__dict__.items(): if key.startswith('__') and key.endswith('__'): # Skip most dunders (make_dataclass will generate them) - # BUT preserve __registry_key__ for AutoRegisterMeta - if key != '__registry_key__': + # BUT preserve lifecycle/registration declarations. + if key not in {"__post_init__", "__registry_key__"}: continue if key == '__dataclass_fields__': continue # Will be regenerated @@ -164,18 +196,13 @@ def rebuild_with_none_defaults( # Create new class new_cls = make_dataclass( - new_name or cls.__name__, + recreated_name, fields=field_defs, bases=bases, namespace=namespace, frozen=is_frozen, ) - # Preserve module and qualname - new_cls.__module__ = cls.__module__ - if new_name is None: - new_cls.__qualname__ = cls.__qualname__ - # Preserve original metaclass if it's not just type # This is critical for AutoRegisterMeta and other custom metaclasses if orig_metaclass is not type: @@ -188,7 +215,7 @@ def rebuild_with_none_defaults( dict(new_cls.__dict__), ) - return new_cls + return _restore_recreated_dataclass_metadata(new_cls, cls) def replace_raw(instance, **changes): @@ -937,6 +964,10 @@ def _create_lazy_dataclass_unified( debug_template, ), bases=bases, + namespace=_recreated_dataclass_namespace( + base_class, + lazy_class_name, + ), frozen=base_is_frozen # Match base class frozen status ) @@ -969,10 +1000,7 @@ def __init_with_tracking__(self, **kwargs): for method_name, method_impl in method_bindings.items(): setattr(lazy_class, method_name, method_impl) - # CRITICAL: Preserve original module for proper imports in generated code - # make_dataclass() sets __module__ to the caller's module (lazy_factory.py) - # We need to set it to the base class's original module for correct import paths - lazy_class.__module__ = base_class.__module__ + _restore_recreated_dataclass_metadata(lazy_class, base_class) # Automatically register the lazy dataclass with the type registry register_lazy_type_mapping(lazy_class, base_class) @@ -1606,10 +1634,9 @@ def _inject_multiple_fields_into_dataclass(target_class: Type, configs: List[Dic """Mathematical simplification: Batch field injection with direct dataclass recreation.""" # Imports moved to top-level - # Direct field reconstruction - guaranteed by dataclass contract + annotations = _resolved_dataclass_annotations(target_class) existing_fields = [ - (f.name, f.type, field(default_factory=f.default_factory) if f.default_factory != MISSING - else f.default if f.default != MISSING else f.type) + (f.name, annotations.get(f.name, f.type), copy.copy(f)) for f in fields(target_class) ] @@ -1639,14 +1666,14 @@ def create_field_definition(config): target_class.__name__, all_fields, bases=target_class.__bases__, + namespace=_recreated_dataclass_namespace( + target_class, + target_class.__name__, + ), frozen=target_class.__dataclass_params__.frozen ) - # CRITICAL: Preserve original module for proper imports in generated code - # make_dataclass() sets __module__ to the caller's module (lazy_factory.py) - # We need to set it to the target class's original module for correct import paths - new_class.__module__ = target_class.__module__ - + _restore_recreated_dataclass_metadata(new_class, target_class) # Preserve global-config registration across dataclass recreation. # Registration is set by @auto_create_decorator but lost when make_dataclass creates a new class. diff --git a/src/objectstate/object_state.py b/src/objectstate/object_state.py index 11e4769..f6a921f 100644 --- a/src/objectstate/object_state.py +++ b/src/objectstate/object_state.py @@ -171,6 +171,10 @@ def __init__( # === Flat Storage (NEW - for flattened architecture) === self._path_to_type: Dict[str, ParameterOwner] = {} # Maps dotted paths to their owner target + self._direct_parameter_paths: dict[ + DottedFieldPath, + tuple[DottedFieldPath, ...], + ] = {} self._cached_object: Optional[Any] = None # Cached result of to_object() self._cached_object_applied: bool = False # True if cached delegate was applied to object_instance @@ -203,20 +207,15 @@ def __init__( if hasattr(extraction_target, param_name): self._excluded_params[param_name] = getattr(extraction_target, param_name) - # Flatten parameter extraction - walk nested dataclasses recursively - # Uses _extraction_target (delegate) instead of object_instance for delegation support - self._extract_all_parameters_flat(extraction_target, prefix='', exclude_params=self._exclude_param_names) - - # NOTE: Signature defaults are now populated by _extract_all_parameters_flat() - # for all fields including nested ones (flattened dotted paths). - - # Apply initial_values overrides (e.g., saved kwargs for functions) - if initial_values: - for param_name, value in initial_values.items(): - self.parameters[param_name] = value - self.parameters.update( - self._dataclass_parameter_updates(param_name, value) - ) + # Flatten parameter extraction - walk nested dataclasses recursively. + # Uses _extraction_target (delegate) instead of object_instance for + # delegation support. The structure and its immutable path index are + # replaced atomically once for this extraction. + self._replace_parameter_structure( + extraction_target, + exclude_params=self._exclude_param_names, + initial_values=initial_values, + ) # === Structure (1 attribute) === self._parent_state: Optional['ObjectState'] = parent_state @@ -502,6 +501,24 @@ def type_for_path(self, field_path: str) -> ParameterOwner: f"ObjectState path is not registered: {field_path!r}" ) from exc + def direct_parameter_paths( + self, + field_path: str = "", + ) -> tuple[DottedFieldPath, ...]: + """Return immutable direct-child paths for one flat parameter scope. + + The path topology is rebuilt when ObjectState extracts a replacement + object. Values remain authoritative in ``parameters`` and are therefore + never cached in this projection. + """ + + return self._direct_parameter_paths.get(DottedFieldPath(field_path), ()) + + def has_parameter_descendants(self, field_path: str) -> bool: + """Return whether an authoritative flat parameter owns child paths.""" + + return bool(self.direct_parameter_paths(field_path)) + # === Resolved Change Subscription === def on_resolved_changed(self, callback: Callable[[Set[str]], None]) -> None: @@ -1557,11 +1574,8 @@ def update_object_instance(self, new_instance: Any) -> None: self._extraction_target = new_instance # Re-extract parameters from new instance - self.parameters.clear() - self._path_to_type.clear() - self._extract_all_parameters_flat( + self._replace_parameter_structure( new_instance, - prefix='', exclude_params=self._exclude_param_names ) @@ -1999,6 +2013,15 @@ def _compute_resolved_snapshot(self, use_saved: bool = False) -> Dict[str, Any]: from objectstate.dual_axis_resolver import resolve_with_provenance from objectstate.lazy_factory import has_lazy_resolution + if not isinstance(self.parameters, dict): + raise TypeError( + "ObjectState.parameters must remain a canonical flat dictionary." + ) + if not isinstance(self._saved_parameters, dict): + raise TypeError( + "ObjectState._saved_parameters must remain a canonical flat dictionary." + ) + # Get ancestor objects WITH scope_ids for provenance tracking # use_saved=True returns object_instance (saved), False returns to_object() (live) ancestor_objects_with_scopes = ObjectStateRegistry.get_ancestor_objects_with_scopes( @@ -2032,34 +2055,13 @@ def _compute_resolved_snapshot(self, use_saved: bool = False) -> Dict[str, Any]: # NOT "current live edits resolved with saved ancestor context". # This is key for dirty detection: dirty = live_resolved != saved_resolved # - # Robustness: Some older snapshot restores or partial state restores can yield - # `_saved_parameters=None`. Avoid crashing ("NoneType has no attribute 'get'") - # during save/close flows. - logger.debug(f"🐛 _compute_resolved_snapshot: scope={self.scope_id!r}, use_saved={use_saved}, _saved_parameters is None={self._saved_parameters is None}, parameters is None={self.parameters is None}") - if use_saved and self._saved_parameters is None: - logger.warning(f"🐛 _compute_resolved_snapshot: _saved_parameters is None for scope={self.scope_id!r}, using parameters as fallback") - self._saved_parameters = self._copy_parameters_for_saved_baseline() - if self.parameters is None: - logger.warning(f"🐛 _compute_resolved_snapshot: parameters is None for scope={self.scope_id!r}, initializing to empty dict") - self.parameters = {} - params_source = self._saved_parameters if use_saved else self.parameters - if params_source is None: - logger.error(f"🐛 _compute_resolved_snapshot: params_source is STILL None after guards! scope={self.scope_id!r}, use_saved={use_saved}") - params_source = {} # UNIFIED: Resolve ALL fields in single context stack # For each path, check if it has a lazy dataclass container type - logger.debug(f"🐛 _compute_resolved_snapshot: About to iterate parameters, params_source type={type(params_source).__name__}, is None={params_source is None}") with stack: for dotted_path in self.parameters.keys(): - try: - raw_value = params_source.get(dotted_path) - except AttributeError as e: - logger.error(f"🐛 _compute_resolved_snapshot: ERROR accessing params_source.get({dotted_path!r})! params_source type={type(params_source).__name__}, is None={params_source is None}, scope={self.scope_id!r}") - logger.error(f"🐛 _compute_resolved_snapshot: _saved_parameters type={type(self._saved_parameters).__name__}, is None={self._saved_parameters is None}") - logger.error(f"🐛 _compute_resolved_snapshot: parameters type={type(self.parameters).__name__}, is None={self.parameters is None}") - raise + raw_value = params_source.get(dotted_path) container_type = self._path_to_type.get(dotted_path) parts = dotted_path.split('.') @@ -2336,8 +2338,6 @@ def _restore_saved_impl(self, *, propagate_descendants: bool = True) -> None: # CRITICAL: For delegation, extract from the delegate (pipeline_config), not the lifecycle object. # This keeps flat parameters aligned with the form's target object after window close/reopen. # Also refresh _extraction_target in case the delegate attribute was replaced externally. - self.parameters.clear() - self._path_to_type.clear() extraction_target = self._extraction_target if self._delegate_attr is not None: try: @@ -2346,7 +2346,10 @@ def _restore_saved_impl(self, *, propagate_descendants: bool = True) -> None: except Exception: # Fallback to existing extraction target if delegate access fails extraction_target = self._extraction_target - self._extract_all_parameters_flat(extraction_target, prefix='', exclude_params=self._exclude_param_names) + self._replace_parameter_structure( + extraction_target, + exclude_params=self._exclude_param_names, + ) # CRITICAL: Also restore _saved_parameters to match current parameters # After restore, parameters == saved (both extracted from object_instance) @@ -2512,6 +2515,48 @@ def _extract_all_parameters_flat(self, obj: Any, prefix: str = '', exclude_param # info.default_value is now guaranteed to be the CLASS signature default self._signature_defaults[dotted_path] = info.default_value + def _replace_parameter_structure( + self, + obj: Any, + *, + exclude_params: list[str] | None = None, + initial_values: dict[str, Any] | None = None, + ) -> None: + """Extract one canonical flat parameter structure and index it once.""" + + self.parameters.clear() + self._path_to_type.clear() + self._signature_defaults.clear() + self._parameter_descriptions.clear() + self._extract_all_parameters_flat( + obj, + prefix="", + exclude_params=exclude_params, + ) + if initial_values: + for param_name, value in initial_values.items(): + self.parameters[param_name] = value + self.parameters.update( + self._dataclass_parameter_updates(param_name, value) + ) + self._index_parameter_paths() + + def _index_parameter_paths(self) -> None: + """Index immutable direct-child topology from canonical flat parameters.""" + + paths_by_owner: dict[DottedFieldPath, list[DottedFieldPath]] = {} + for dotted_path in self.parameters: + owner_path, separator, _field_name = dotted_path.rpartition(".") + if not separator: + owner_path = "" + paths_by_owner.setdefault(DottedFieldPath(owner_path), []).append( + DottedFieldPath(dotted_path) + ) + self._direct_parameter_paths = { + owner_path: tuple(parameter_paths) + for owner_path, parameter_paths in paths_by_owner.items() + } + def to_object(self, *, update_delegate: bool = False, sync_delegate: bool = True) -> Any: """Reconstruct object from flat parameters with updated nested configs. diff --git a/src/objectstate/object_state_registry.py b/src/objectstate/object_state_registry.py index eb21bd0..46ffbad 100644 --- a/src/objectstate/object_state_registry.py +++ b/src/objectstate/object_state_registry.py @@ -1692,6 +1692,7 @@ def _apply_time_travel_snapshot_state( state._live_resolved = copy.deepcopy(state_snap.live_resolved) state._live_provenance = copy.deepcopy(state_snap.provenance) state.parameters = parameters + state._index_parameter_paths() state._saved_parameters = saved_parameters state._cached_object = None state._cached_object_applied = False diff --git a/src/objectstate/transaction_checkpoint.py b/src/objectstate/transaction_checkpoint.py index 9ca0dca..245da1c 100644 --- a/src/objectstate/transaction_checkpoint.py +++ b/src/objectstate/transaction_checkpoint.py @@ -108,6 +108,7 @@ def restore(self, state: ObjectState) -> None: self.extraction_target, ) state.parameters = copied["parameters"] + state._index_parameter_paths() state._saved_parameters = copied["saved_parameters"] state._live_resolved = copied["live_resolved"] state._saved_resolved = copied["saved_resolved"] diff --git a/tests/test_lazy_factory.py b/tests/test_lazy_factory.py index 1213dd2..54a2585 100644 --- a/tests/test_lazy_factory.py +++ b/tests/test_lazy_factory.py @@ -1,15 +1,22 @@ """Tests for lazy factory module.""" -import pytest +import sys from dataclasses import dataclass, field, fields from typing import Annotated, ClassVar, get_type_hints +import pytest +from annotated_types import Ge +from python_introspect import ( + AnnotatedDataclassValidationMixin, + AnnotationValidationError, + optional_member_type, +) + from objectstate import ( LazyDataclassFactory, - register_lazy_type_mapping, get_base_type_for_lazy, patch_lazy_constructors, + register_lazy_type_mapping, ) -from python_introspect import optional_member_type def test_make_lazy_simple(): @@ -71,6 +78,112 @@ class StreamingConfig(BaseConfig): assert StreamingConfig().inherited is None +def test_none_default_rebuild_preserves_declaration_metadata_and_validation() -> None: + from objectstate.lazy_factory import rebuild_with_none_defaults + + @dataclass + class ValidatedConfig(AnnotatedDataclassValidationMixin): + """Authoritative rationale for the validated configuration.""" + + workers: Annotated[int, Ge(1)] = 1 + + rebuilt = rebuild_with_none_defaults(ValidatedConfig, set()) + + assert rebuilt.__doc__ == ValidatedConfig.__doc__ + assert rebuilt.__module__ == ValidatedConfig.__module__ + assert rebuilt.__qualname__ == ValidatedConfig.__qualname__ + if "__firstlineno__" in ValidatedConfig.__dict__: + assert rebuilt.__firstlineno__ == ValidatedConfig.__firstlineno__ + with pytest.raises(AnnotationValidationError, match="must be at least 1"): + rebuilt(workers=0) + + +def test_lazy_dataclass_preserves_declaration_metadata_and_validation() -> None: + @dataclass + class ValidatedConfig(AnnotatedDataclassValidationMixin): + """Authoritative rationale inherited by the lazy projection.""" + + port: Annotated[int, Ge(1)] = 5555 + + lazy_validated_config = LazyDataclassFactory.make_lazy_simple(ValidatedConfig) + + assert lazy_validated_config.__doc__ == ValidatedConfig.__doc__ + assert lazy_validated_config.__module__ == ValidatedConfig.__module__ + if "__firstlineno__" in ValidatedConfig.__dict__: + assert ( + lazy_validated_config.__firstlineno__ + == ValidatedConfig.__firstlineno__ + ) + assert object.__getattribute__(lazy_validated_config(), "port") is None + with pytest.raises(AnnotationValidationError, match="must be at least 1"): + lazy_validated_config(port=0) + + +def test_injected_global_config_preserves_metadata_and_validation( + monkeypatch, +) -> None: + from objectstate.lazy_factory import _inject_multiple_fields_into_dataclass + + @dataclass + class GlobalMetadataValidationRoot(AnnotatedDataclassValidationMixin): + """Authoritative rationale for the global configuration.""" + + workers: Annotated[int, Ge(1)] = field( + default=1, + metadata={"description": "Worker count rationale."}, + ) + + @dataclass + class InjectedConstraintConfig(AnnotatedDataclassValidationMixin): + """Authoritative rationale for the injected configuration.""" + + port: Annotated[int, Ge(1)] = 5555 + + module = sys.modules[__name__] + generated_names = ( + "GlobalMetadataValidationRoot", + "MetadataValidationRoot", + "LazyInjectedConstraintConfig", + ) + for generated_name in generated_names: + monkeypatch.setattr(module, generated_name, None, raising=False) + + _inject_multiple_fields_into_dataclass( + GlobalMetadataValidationRoot, + [ + { + "config_class": InjectedConstraintConfig, + "field_name": "injected_constraint_config", + "lazy_class_name": "LazyInjectedConstraintConfig", + } + ], + ) + + rebuilt_global = module.GlobalMetadataValidationRoot + lazy_global = module.MetadataValidationRoot + lazy_injected = module.LazyInjectedConstraintConfig + + assert rebuilt_global.__doc__ == GlobalMetadataValidationRoot.__doc__ + assert lazy_global.__doc__ == GlobalMetadataValidationRoot.__doc__ + assert lazy_injected.__doc__ == InjectedConstraintConfig.__doc__ + rebuilt_workers = next( + item for item in fields(rebuilt_global) if item.name == "workers" + ) + assert rebuilt_workers.metadata == { + "description": "Worker count rationale.", + } + assert isinstance( + rebuilt_global().injected_constraint_config, + InjectedConstraintConfig, + ) + with pytest.raises(AnnotationValidationError, match="must be at least 1"): + rebuilt_global(workers=0) + with pytest.raises(AnnotationValidationError, match="must be at least 1"): + lazy_global(workers=0) + with pytest.raises(AnnotationValidationError, match="must be at least 1"): + lazy_injected(port=0) + + def test_lazy_dataclass_fields(): """Test that lazy dataclass has same fields as base.""" @dataclass diff --git a/tests/test_object_state_update_instance.py b/tests/test_object_state_update_instance.py index e6558d8..3080f02 100644 --- a/tests/test_object_state_update_instance.py +++ b/tests/test_object_state_update_instance.py @@ -5,6 +5,7 @@ import pytest from objectstate import ( + DottedFieldPath, LazyDataclassFactory, ObjectState, ObjectStateRegistry, @@ -54,6 +55,18 @@ class ConfigWithDataclassLeaf: leaf: DataclassLeaf | None = None +@dataclass +class NestedTopology: + threshold: int = 1 + label: str = "nested" + + +@dataclass +class ConfigWithNestedTopology: + enabled: bool = True + nested: NestedTopology = field(default_factory=NestedTopology) + + class DelegatedHost: __objectstate_delegate__ = "config" @@ -76,6 +89,70 @@ def test_update_object_instance_notifies_resolved_changes_and_saved_baseline(): assert state.last_changed_field == "threshold" +def test_direct_parameter_paths_project_canonical_flat_topology_without_values(): + state = ObjectState(ConfigWithNestedTopology(), scope_id="config") + + assert state.direct_parameter_paths() == ( + DottedFieldPath("enabled"), + DottedFieldPath("nested"), + ) + assert state.direct_parameter_paths("nested") == ( + DottedFieldPath("nested.threshold"), + DottedFieldPath("nested.label"), + ) + assert state.direct_parameter_paths("nested.threshold") == () + assert state.has_parameter_descendants("nested") is True + assert state.has_parameter_descendants("nested.threshold") is False + + state.update_parameter("nested.threshold", 7) + + assert state.direct_parameter_paths("nested") == ( + DottedFieldPath("nested.threshold"), + DottedFieldPath("nested.label"), + ) + assert state.parameters["nested.threshold"] == 7 + + +def test_resolved_snapshot_rejects_partial_parameter_state_without_recovery(): + state = ObjectState(ConfigWithNestedTopology(), scope_id="config") + expected_topology = state.direct_parameter_paths("nested") + state.parameters = None + + with pytest.raises( + TypeError, + match="parameters must remain a canonical flat dictionary", + ): + state._compute_resolved_snapshot() + + assert state.parameters is None + assert state.direct_parameter_paths("nested") == expected_topology + + +def test_replacement_rebuilds_direct_parameter_topology_once(monkeypatch): + state = ObjectState(PlainConfig(), scope_id="config") + index_calls = 0 + original_index = state._index_parameter_paths + + def counted_index() -> None: + nonlocal index_calls + index_calls += 1 + original_index() + + monkeypatch.setattr(state, "_index_parameter_paths", counted_index) + + state.update_object_instance(ConfigWithNestedTopology()) + + assert index_calls == 1 + assert state.direct_parameter_paths() == ( + DottedFieldPath("enabled"), + DottedFieldPath("nested"), + ) + assert state.direct_parameter_paths("nested") == ( + DottedFieldPath("nested.threshold"), + DottedFieldPath("nested.label"), + ) + + def test_update_object_instance_publishes_registry_resolved_change_without_local_subscriber(): state = ObjectState(PlainConfig(), scope_id="config") events: list[tuple[str, set[str]]] = [] diff --git a/tests/test_transaction_checkpoint.py b/tests/test_transaction_checkpoint.py index ff5b029..7f44cf3 100644 --- a/tests/test_transaction_checkpoint.py +++ b/tests/test_transaction_checkpoint.py @@ -6,6 +6,7 @@ import pytest from objectstate import ( + DottedFieldPath, ObjectState, ObjectStateRegistry, ObjectStateTransactionCheckpoint, @@ -29,6 +30,16 @@ def __init__(self, config: CallbackConfig) -> None: self.config = config +@dataclass(frozen=True) +class NestedConfig: + value: int = 1 + + +@dataclass(frozen=True) +class ConfigWithNestedTopology: + nested: NestedConfig = NestedConfig() + + def test_checkpoint_restores_exact_dirty_nondelegated_state() -> None: original = CallbackConfig() state = ObjectState(original, scope_id="plain") @@ -83,6 +94,19 @@ def test_checkpoint_restores_exact_delegate_and_callable_identities() -> None: assert state.is_raw_dirty is True +def test_checkpoint_restores_parameter_topology_after_replacement() -> None: + state = ObjectState(ConfigWithNestedTopology(), scope_id="nested") + checkpoint = ObjectStateTransactionCheckpoint.capture(state) + + state.update_object_instance(CallbackConfig()) + checkpoint.restore(state) + + assert state.direct_parameter_paths() == (DottedFieldPath("nested"),) + assert state.direct_parameter_paths("nested") == ( + DottedFieldPath("nested.value"), + ) + + def test_checkpoint_emits_only_fields_whose_transaction_state_changed() -> None: state = ObjectState(CallbackConfig(), scope_id="notifications") state.update_parameter("value", 2)