diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 3b3c4d06..3d7c4372 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -49,3 +49,17 @@ jobs: - name: Test run: make test-python-unit + + doctrine: + name: Doctrine + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install -r requirements-dev.txt + - name: Doctrine tests + run: PYTHONPATH=src pytest src/julee/core/doctrine/ diff --git a/pyproject.toml b/pyproject.toml index 10f3d5dc..39fd7557 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "julee" -version = "0.1.12" +version = "0.1.13" description = "Julee - Clean architecture for accountable and transparent digital supply chains" readme = "README.md" requires-python = ">=3.11" diff --git a/src/julee/__init__.py b/src/julee/__init__.py index aa3808ae..49d77da5 100644 --- a/src/julee/__init__.py +++ b/src/julee/__init__.py @@ -1,3 +1,3 @@ """Julee - Clean architecture for accountable and transparent digital supply chains.""" -__version__ = "0.1.12" +__version__ = "0.1.13" diff --git a/src/julee/api/services/system_initialization.py b/src/julee/api/services/system_initialization.py index 221ef327..3b0c8c17 100644 --- a/src/julee/api/services/system_initialization.py +++ b/src/julee/api/services/system_initialization.py @@ -14,6 +14,7 @@ from typing import Any from julee.domain.use_cases.initialize_system_data import ( + InitializeSystemDataRequest, InitializeSystemDataUseCase, ) @@ -128,7 +129,9 @@ async def _execute_system_data_initialization( try: self.logger.debug("Starting task: %s", task_name) - await self.initialize_system_data_use_case.execute() + await self.initialize_system_data_use_case.execute( + InitializeSystemDataRequest() + ) results["tasks_completed"].append(task_name) results["metadata"][task_name] = { diff --git a/src/julee/contrib/polling/apps/worker/pipelines.py b/src/julee/contrib/polling/apps/worker/pipelines.py index 25ca0551..c68b453f 100644 --- a/src/julee/contrib/polling/apps/worker/pipelines.py +++ b/src/julee/contrib/polling/apps/worker/pipelines.py @@ -20,7 +20,10 @@ from julee.contrib.polling.infrastructure.temporal.proxies import ( WorkflowPollerServiceProxy, ) -from julee.contrib.polling.use_cases.poll_data import PollDataRequest, PollDataUseCase +from julee.contrib.polling.use_cases.poll_data import ( + PollDataRequest, + PollDataUseCase, +) logger = logging.getLogger(__name__) @@ -123,16 +126,12 @@ async def run( handler=self.get_handler(), analyzer=self.get_analyzer(), ) - result = await use_case.execute(request) + response = await use_case.execute(request) - self.endpoint_id = result.get("endpoint_id", self.endpoint_id) - self.has_new_data = result.get("detection_result", {}).get( - "has_new_data", False - ) + self.endpoint_id = response.endpoint_id + self.has_new_data = response.new_items_found self.current_step = "completed" - result["completed_at"] = workflow.now().isoformat() - workflow.logger.info( "New data detection pipeline completed successfully", extra={ @@ -141,7 +140,19 @@ async def run( }, ) - return result + return { + "polling_result": { + "content_hash": response.content_hash, + "content": response.content, + "polled_at": response.polled_at, + }, + "detection_result": { + "has_new_data": response.new_items_found, + "current_hash": response.content_hash, + }, + "endpoint_id": response.endpoint_id, + "completed_at": workflow.now().isoformat(), + } except Exception as e: self.current_step = "failed" diff --git a/src/julee/contrib/polling/use_cases/poll_data.py b/src/julee/contrib/polling/use_cases/poll_data.py index 7af9445a..130c93cd 100644 --- a/src/julee/contrib/polling/use_cases/poll_data.py +++ b/src/julee/contrib/polling/use_cases/poll_data.py @@ -28,6 +28,17 @@ class PollDataRequest(BaseModel): previous_completion: dict | None = None +class PollDataResponse(BaseModel): + """Output for PollDataUseCase.""" + + endpoint_id: str + content_hash: str + content: str + polled_at: str + new_items_found: bool + items_processed: int + + class PollDataUseCase: """ Use case for polling an endpoint and detecting new data. @@ -38,9 +49,8 @@ class PollDataUseCase: 3. Compare with the previous run's hash (from previous_completion) 4. If content has changed and an analyzer is set, identify new item IDs 5. Delegate to the optional PollingResultHandler with the item IDs - 6. Return a completion result dict in the same shape that - NewDataDetectionPipeline returns, so Temporal schedule - last-completion-result works unchanged. + 6. Return a PollDataResponse; the pipeline builds the Temporal + last-completion-result dict from it. """ @@ -54,7 +64,7 @@ def __init__( self._handler = handler self._analyzer = analyzer - async def execute(self, request: PollDataRequest) -> dict: + async def execute(self, request: PollDataRequest) -> PollDataResponse: """ Execute the poll-and-detect use case. @@ -64,9 +74,7 @@ async def execute(self, request: PollDataRequest) -> dict: for the first run). Returns: - Completion result dict with keys: - polling_result, detection_result, handler_acknowledgement, - endpoint_id + PollDataResponse with polling outcome and detection results. """ config = request.config endpoint_id = config.endpoint_identifier @@ -99,13 +107,14 @@ async def execute(self, request: PollDataRequest) -> dict: has_new_data = previous_hash != current_hash # Step 5: Analyze and invoke handler if new data detected - handler_acknowledgement = None + items_processed = 0 if has_new_data: try: item_ids = await self._analyzer.identify_new_items( previous_data, current_content ) - handler_acknowledgement = await self._handler.handle_new_data( + items_processed = len(item_ids) + await self._handler.handle_new_data( endpoint_id, item_ids, current_hash, @@ -120,22 +129,13 @@ async def execute(self, request: PollDataRequest) -> dict: }, exc_info=True, ) - # handler_acknowledgement stays None to signal the exception # Step 6: Return completion result - return { - "polling_result": { - "success": polling_result.success, - "content_hash": current_hash, - "content": current_content.decode("utf-8", errors="ignore"), - "polled_at": polled_at, - "content_length": len(current_content), - }, - "detection_result": { - "has_new_data": has_new_data, - "previous_hash": previous_hash, - "current_hash": current_hash, - }, - "handler_acknowledgement": handler_acknowledgement, - "endpoint_id": endpoint_id, - } + return PollDataResponse( + endpoint_id=endpoint_id, + content_hash=current_hash, + content=current_content.decode("utf-8", errors="ignore"), + polled_at=polled_at, + new_items_found=has_new_data, + items_processed=items_processed, + ) diff --git a/src/julee/core/doctrine/test_use_case.py b/src/julee/core/doctrine/test_use_case.py index 897b618a..ba1e9760 100644 --- a/src/julee/core/doctrine/test_use_case.py +++ b/src/julee/core/doctrine/test_use_case.py @@ -5,6 +5,7 @@ """ import importlib +import inspect import pytest @@ -216,3 +217,152 @@ async def test_all_use_cases_MUST_have_matching_response(self, repo): assert not violations, "Use cases missing matching responses:\n" + "\n".join( violations ) + + @pytest.mark.asyncio + async def test_execute_MUST_accept_matching_request(self, repo): + """execute() MUST declare its first parameter as {Prefix}Request. + + Ensures the request class is part of the method's contract, not + just present in the module. + """ + use_case = ListUseCasesUseCase(repo) + response = await use_case.execute(ListCodeArtifactsRequest()) + + bounded_contexts = await repo.list_all() + import_paths = {bc.slug: bc.import_path for bc in bounded_contexts} + + violations = [] + suffix_len = len(USE_CASE_SUFFIX) + for artifact in response.artifacts: + name = artifact.artifact.name + ctx = artifact.bounded_context + if name in GENERIC_BASE_CLASSES: + continue + if not name.endswith(USE_CASE_SUFFIX): + continue + + prefix = name[:-suffix_len] + expected_request = f"{prefix}{REQUEST_SUFFIX}" + + bc_import_path = import_paths.get(ctx) + cls = ( + _resolve_class(bc_import_path, artifact.artifact.file, name) + if bc_import_path + else None + ) + + if cls is not None: + execute = getattr(cls, "execute", None) + if not callable(execute): + continue + sig = inspect.signature(execute) + params = [p for p in sig.parameters.values() if p.name != "self"] + if not params: + violations.append( + f"{ctx}.{name}: execute() has no request parameter" + ) + continue + annotation = params[0].annotation + actual_name = ( + annotation.__name__ + if hasattr(annotation, "__name__") + else str(annotation) + ) + else: + # Fall back to AST-parsed signature + execute_method = next( + (m for m in artifact.artifact.methods if m.name == "execute"), + None, + ) + if execute_method is None: + continue + if not execute_method.parameters: + violations.append( + f"{ctx}.{name}: execute() has no request parameter" + ) + continue + actual_name = execute_method.parameters[0].type_annotation + + if actual_name != expected_request: + violations.append( + f"{ctx}.{name}: execute() first parameter is '{actual_name}'" + f", expected '{expected_request}'" + ) + + assert not violations, "Use cases with wrong request type:\n" + "\n".join( + violations + ) + + @pytest.mark.asyncio + async def test_execute_MUST_return_matching_response(self, repo): + """execute() MUST declare its return type as {Prefix}Response. + + Ensures the response class is actually wired into execute(), not + just present in the module. + """ + use_case = ListUseCasesUseCase(repo) + response = await use_case.execute(ListCodeArtifactsRequest()) + + bounded_contexts = await repo.list_all() + import_paths = {bc.slug: bc.import_path for bc in bounded_contexts} + + violations = [] + suffix_len = len(USE_CASE_SUFFIX) + for artifact in response.artifacts: + name = artifact.artifact.name + ctx = artifact.bounded_context + if name in GENERIC_BASE_CLASSES: + continue + if not name.endswith(USE_CASE_SUFFIX): + continue + + prefix = name[:-suffix_len] + expected_response = f"{prefix}{RESPONSE_SUFFIX}" + + bc_import_path = import_paths.get(ctx) + cls = ( + _resolve_class(bc_import_path, artifact.artifact.file, name) + if bc_import_path + else None + ) + + if cls is not None: + execute = getattr(cls, "execute", None) + if not callable(execute): + continue + sig = inspect.signature(execute) + return_annotation = sig.return_annotation + if return_annotation is inspect.Parameter.empty: + violations.append( + f"{ctx}.{name}: execute() has no return annotation" + ) + continue + actual_name = ( + return_annotation.__name__ + if hasattr(return_annotation, "__name__") + else str(return_annotation) + ) + else: + # Fall back to AST-parsed signature + execute_method = next( + (m for m in artifact.artifact.methods if m.name == "execute"), + None, + ) + if execute_method is None: + continue + if not execute_method.return_type: + violations.append( + f"{ctx}.{name}: execute() has no return annotation" + ) + continue + actual_name = execute_method.return_type + + if actual_name != expected_response: + violations.append( + f"{ctx}.{name}: execute() returns '{actual_name}'" + f", expected '{expected_response}'" + ) + + assert not violations, "Use cases with wrong return type:\n" + "\n".join( + violations + ) diff --git a/src/julee/core/infrastructure/repositories/introspection/bounded_context.py b/src/julee/core/infrastructure/repositories/introspection/bounded_context.py index aeb27efd..9028aa85 100644 --- a/src/julee/core/infrastructure/repositories/introspection/bounded_context.py +++ b/src/julee/core/infrastructure/repositories/introspection/bounded_context.py @@ -235,14 +235,16 @@ def _discover_all(self) -> list[BoundedContext]: continue if _is_gitignored(candidate, self.project_root): continue - if candidate.name in RESERVED_WORDS: - continue if not self._is_python_package(candidate): continue + # Reserved words cannot be bounded contexts themselves, but may + # still be nested solution containers (e.g. contrib/, apps/). + is_reserved = candidate.name in RESERVED_WORDS + markers = self._detect_markers(candidate) - if self._is_bounded_context(markers): + if not is_reserved and self._is_bounded_context(markers): # It's a bounded context is_contrib = candidate.name == CONTRIB_DIR context = BoundedContext( diff --git a/src/julee/domain/use_cases/extract_assemble_data.py b/src/julee/domain/use_cases/extract_assemble_data.py index 8327c0dd..9aa09d7d 100644 --- a/src/julee/domain/use_cases/extract_assemble_data.py +++ b/src/julee/domain/use_cases/extract_assemble_data.py @@ -16,6 +16,7 @@ import jsonschema import multihash +from pydantic import BaseModel from julee.domain.models import ( Assembly, @@ -41,6 +42,16 @@ logger = logging.getLogger(__name__) +class ExtractAssembleDataRequest(BaseModel): + document_id: str + assembly_specification_id: str + workflow_id: str + + +class ExtractAssembleDataResponse(BaseModel): + assembly: Assembly + + class ExtractAssembleDataUseCase: """ Use case for extracting and assembling documents according to @@ -129,6 +140,16 @@ def __init__( KnowledgeServiceConfigRepository, # type: ignore[type-abstract] ) + async def execute( + self, request: ExtractAssembleDataRequest + ) -> ExtractAssembleDataResponse: + assembly = await self.assemble_data( + request.document_id, + request.assembly_specification_id, + request.workflow_id, + ) + return ExtractAssembleDataResponse(assembly=assembly) + async def assemble_data( self, document_id: str, diff --git a/src/julee/domain/use_cases/initialize_system_data.py b/src/julee/domain/use_cases/initialize_system_data.py index 536c51b6..e56090b7 100644 --- a/src/julee/domain/use_cases/initialize_system_data.py +++ b/src/julee/domain/use_cases/initialize_system_data.py @@ -20,6 +20,7 @@ from typing import Any import yaml +from pydantic import BaseModel from julee.domain.models.assembly_specification import ( AssemblySpecification, @@ -45,6 +46,14 @@ logger = logging.getLogger(__name__) +class InitializeSystemDataRequest(BaseModel): + pass + + +class InitializeSystemDataResponse(BaseModel): + pass + + class InitializeSystemDataUseCase: """ Use case for initializing required system data on application startup. @@ -81,7 +90,9 @@ def __init__( self.assembly_spec_repo = assembly_specification_repository self.logger = logging.getLogger("InitializeSystemDataUseCase") - async def execute(self) -> None: + async def execute( + self, request: InitializeSystemDataRequest + ) -> InitializeSystemDataResponse: """ Execute system data initialization. @@ -100,6 +111,7 @@ async def execute(self) -> None: await self._ensure_assembly_specifications_exist() self.logger.info("System data initialization completed successfully") + return InitializeSystemDataResponse() except Exception as e: self.logger.error( diff --git a/src/julee/domain/use_cases/tests/test_initialize_system_data.py b/src/julee/domain/use_cases/tests/test_initialize_system_data.py index 678f02db..596b4a63 100644 --- a/src/julee/domain/use_cases/tests/test_initialize_system_data.py +++ b/src/julee/domain/use_cases/tests/test_initialize_system_data.py @@ -20,6 +20,7 @@ ServiceApi, ) from julee.domain.use_cases.initialize_system_data import ( + InitializeSystemDataRequest, InitializeSystemDataUseCase, ) from julee.repositories.memory.assembly_specification import ( @@ -123,7 +124,7 @@ async def test_execute_success_creates_configs_from_fixture( ) -> None: """Test successful execution creates configs from fixture.""" # Execute use case - await use_case.execute() + await use_case.execute(InitializeSystemDataRequest()) # Verify all configs were created saved_configs = await memory_config_repository.list_all() @@ -159,7 +160,7 @@ async def test_execute_success_configs_already_exist( await memory_config_repository.save(sample_anthropic_config) # Execute use case - await use_case.execute() + await use_case.execute(InitializeSystemDataRequest()) # Verify only the existing config is in the repository (no duplicates) all_configs = await memory_config_repository.list_all() @@ -179,7 +180,7 @@ async def test_execute_mixed_existing_and_new_configs( await memory_config_repository.save(sample_anthropic_config) # Execute use case - await use_case.execute() + await use_case.execute(InitializeSystemDataRequest()) # Verify all configs from fixture exist (including pre-existing one) final_configs = await memory_config_repository.list_all() @@ -211,7 +212,7 @@ async def test_config_creation_uses_correct_values_from_fixture( ) -> None: """Test that created configs have correct values from fixture.""" # Execute use case - await use_case.execute() + await use_case.execute(InitializeSystemDataRequest()) # Get all saved configs saved_configs = await memory_config_repository.list_all() @@ -246,12 +247,12 @@ async def test_use_case_is_idempotent( ) -> None: """Test that running the use case multiple times is safe.""" # First run - configs don't exist, get created - await use_case.execute() + await use_case.execute(InitializeSystemDataRequest()) first_run_configs = await memory_config_repository.list_all() first_run_count = len(first_run_configs) # Second run - configs now exist, should not create duplicates - await use_case.execute() + await use_case.execute(InitializeSystemDataRequest()) second_run_configs = await memory_config_repository.list_all() second_run_count = len(second_run_configs) @@ -301,7 +302,7 @@ async def test_config_initialization_only( ) # Execute the use case to initialize configs - await use_case.execute() + await use_case.execute(InitializeSystemDataRequest()) # Verify configs were created saved_configs = await memory_config_repository.list_all() @@ -422,7 +423,7 @@ async def test_full_workflow_new_system( # Setup - repository starts empty # Execute initialization - await use_case.execute() + await use_case.execute(InitializeSystemDataRequest()) # Verify all configs were created saved_configs = await memory_config_repository.list_all() @@ -446,7 +447,7 @@ async def test_full_workflow_existing_system( await memory_config_repository.save(sample_anthropic_config) # Execute initialization - await use_case.execute() + await use_case.execute(InitializeSystemDataRequest()) # Verify configs exist and no duplicates were created final_configs = await memory_config_repository.list_all() diff --git a/src/julee/domain/use_cases/validate_document.py b/src/julee/domain/use_cases/validate_document.py index 8907206f..66679b25 100644 --- a/src/julee/domain/use_cases/validate_document.py +++ b/src/julee/domain/use_cases/validate_document.py @@ -15,6 +15,7 @@ from datetime import datetime import multihash +from pydantic import BaseModel from julee.domain.models import ( ContentStream, @@ -42,6 +43,15 @@ logger = logging.getLogger(__name__) +class ValidateDocumentRequest(BaseModel): + document_id: str + policy_id: str + + +class ValidateDocumentResponse(BaseModel): + validation: DocumentPolicyValidation + + class ValidateDocumentUseCase: """ Use case for validating documents against policies. @@ -130,6 +140,14 @@ def __init__( ) self.now_fn = now_fn + async def execute( + self, request: ValidateDocumentRequest + ) -> ValidateDocumentResponse: + validation = await self.validate_document( + request.document_id, request.policy_id + ) + return ValidateDocumentResponse(validation=validation) + async def validate_document( self, document_id: str, policy_id: str ) -> DocumentPolicyValidation: