diff --git a/pyproject.toml b/pyproject.toml index d27308fc..10f3d5dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,8 @@ dependencies = [ "six>=1.16.0", "jsonschema>=4.0.0", "jsonpointer>=3.0.0", + # Code introspection (doctrine tests, CLI) + "griffe>=1.0.0,<2", ] [project.optional-dependencies] @@ -202,6 +204,12 @@ module = [ ] ignore_missing_imports = true +[tool.julee] +# julee is itself a julee solution +search_root = "src/julee" +docs_root = "docs" +skip_policies = ["temporal-pipelines"] # julee core doesn't use Temporal + [tool.pydantic-mypy] init_forbid_extra = true init_typed = true diff --git a/requirements-dev.txt b/requirements-dev.txt index f9d27fed..0e37dd9c 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -31,7 +31,7 @@ babel==2.18.0 # via sphinx bandit==1.9.4 # via julee (pyproject.toml) -black==26.1.0 +black==26.3.1 # via julee (pyproject.toml) build==1.4.0 # via pip-tools @@ -45,7 +45,7 @@ cffi==2.0.0 # via argon2-cffi-bindings cfgv==3.5.0 # via pre-commit -charset-normalizer==3.4.4 +charset-normalizer==3.4.6 # via requests click==8.3.1 # via @@ -53,6 +53,8 @@ click==8.3.1 # julee (pyproject.toml) # pip-tools # uvicorn +colorama==0.4.6 + # via griffe coverage[toml]==7.13.4 # via pytest-cov distlib==0.4.0 @@ -67,7 +69,7 @@ execnet==2.1.2 # via pytest-xdist factory-boy==3.3.3 # via julee (pyproject.toml) -faker==40.5.1 +faker==40.11.0 # via factory-boy fastapi==0.135.1 # via @@ -75,10 +77,12 @@ fastapi==0.135.1 # julee (pyproject.toml) fastapi-pagination==0.15.10 # via julee (pyproject.toml) -filelock==3.25.0 +filelock==3.25.2 # via # python-discovery # virtualenv +griffe==1.15.0 + # via julee (pyproject.toml) h11==0.16.0 # via # httpcore @@ -89,7 +93,7 @@ httpx==0.28.1 # via anthropic hypothesis==6.151.9 # via julee (pyproject.toml) -identify==2.6.17 +identify==2.6.18 # via pre-commit idna==3.11 # via @@ -147,7 +151,7 @@ pathspec==1.0.4 # mypy pip-tools==7.5.3 # via julee (pyproject.toml) -platformdirs==4.9.2 +platformdirs==4.9.4 # via # black # python-discovery @@ -194,7 +198,7 @@ pytest-cov==7.0.0 # via julee (pyproject.toml) pytest-xdist==3.8.0 # via julee (pyproject.toml) -python-discovery==1.1.0 +python-discovery==1.1.3 # via virtualenv python-magic==0.4.27 # via julee (pyproject.toml) @@ -222,7 +226,7 @@ rpds-py==0.30.0 # via # jsonschema # referencing -ruff==0.15.4 +ruff==0.15.6 # via julee (pyproject.toml) six==1.17.0 # via julee (pyproject.toml) @@ -256,7 +260,7 @@ types-jsonschema==4.26.0.20260202 # via julee (pyproject.toml) types-protobuf==6.32.1.20260221 # via temporalio -types-python-dateutil==2.9.0.20260302 +types-python-dateutil==2.9.0.20260305 # via julee (pyproject.toml) types-pyyaml==6.0.12.20250915 # via julee (pyproject.toml) @@ -286,7 +290,7 @@ urllib3==2.6.3 # requests uvicorn==0.41.0 # via julee (pyproject.toml) -virtualenv==21.1.0 +virtualenv==21.2.0 # via pre-commit wheel==0.46.3 # via pip-tools diff --git a/requirements.txt b/requirements.txt index c50dfe83..9906f414 100644 --- a/requirements.txt +++ b/requirements.txt @@ -34,6 +34,8 @@ click==8.3.1 # via # julee (pyproject.toml) # uvicorn +colorama==0.4.6 + # via griffe distro==1.9.0 # via anthropic docstring-parser==0.17.0 @@ -44,6 +46,8 @@ fastapi==0.135.1 # julee (pyproject.toml) fastapi-pagination==0.15.10 # via julee (pyproject.toml) +griffe==1.15.0 + # via julee (pyproject.toml) h11==0.16.0 # via # httpcore diff --git a/src/julee/core/infrastructure/__init__.py b/src/julee/core/infrastructure/__init__.py new file mode 100644 index 00000000..7ba39413 --- /dev/null +++ b/src/julee/core/infrastructure/__init__.py @@ -0,0 +1,4 @@ +"""Shared infrastructure components. + +Infrastructure implementations that can be used across bounded contexts. +""" diff --git a/src/julee/core/infrastructure/repositories/__init__.py b/src/julee/core/infrastructure/repositories/__init__.py new file mode 100644 index 00000000..253552c7 --- /dev/null +++ b/src/julee/core/infrastructure/repositories/__init__.py @@ -0,0 +1,4 @@ +"""Repository implementations for Shared. + +Contains memory, file, and introspection repository implementations. +""" diff --git a/src/julee/core/infrastructure/repositories/file/__init__.py b/src/julee/core/infrastructure/repositories/file/__init__.py new file mode 100644 index 00000000..dbf90472 --- /dev/null +++ b/src/julee/core/infrastructure/repositories/file/__init__.py @@ -0,0 +1,4 @@ +"""File repository implementations. + +Provides base classes for file-backed repository implementations. +""" diff --git a/src/julee/core/infrastructure/repositories/file/solution_config.py b/src/julee/core/infrastructure/repositories/file/solution_config.py new file mode 100644 index 00000000..d0c601fa --- /dev/null +++ b/src/julee/core/infrastructure/repositories/file/solution_config.py @@ -0,0 +1,62 @@ +"""File-based solution configuration repository. + +Reads [tool.julee] configuration from pyproject.toml. +""" + +from pathlib import Path + +import tomllib + +from julee.core.entities.policy import SolutionPolicyConfig + + +class FileSolutionConfigRepository: + """Reads solution configuration from pyproject.toml.""" + + def get_policy_config_sync(self, solution_root: Path) -> SolutionPolicyConfig: + """Read policy configuration from [tool.julee] in pyproject.toml. + + Synchronous version for CLI and test fixture usage. + + Args: + solution_root: Path to the solution root directory + + Returns: + SolutionPolicyConfig with parsed settings, or defaults if not found + """ + pyproject_path = solution_root / "pyproject.toml" + + if not pyproject_path.exists(): + return SolutionPolicyConfig() + + try: + with open(pyproject_path, "rb") as f: + data = tomllib.load(f) + except tomllib.TOMLDecodeError: + return SolutionPolicyConfig() + + tool_julee = data.get("tool", {}).get("julee", None) + + if tool_julee is None: + return SolutionPolicyConfig() + + return SolutionPolicyConfig( + is_julee_solution=True, + policies=tuple(tool_julee.get("policies", [])), + skip_policies=tuple(tool_julee.get("skip_policies", [])), + search_root=tool_julee.get("search_root"), + docs_root=tool_julee.get("docs_root"), + ) + + async def get_policy_config(self, solution_root: Path) -> SolutionPolicyConfig: + """Read policy configuration from [tool.julee] in pyproject.toml. + + Async version for use case compatibility. + + Args: + solution_root: Path to the solution root directory + + Returns: + SolutionPolicyConfig with parsed settings, or defaults if not found + """ + return self.get_policy_config_sync(solution_root) diff --git a/src/julee/core/infrastructure/repositories/introspection/__init__.py b/src/julee/core/infrastructure/repositories/introspection/__init__.py new file mode 100644 index 00000000..63f41fe5 --- /dev/null +++ b/src/julee/core/infrastructure/repositories/introspection/__init__.py @@ -0,0 +1,5 @@ +"""Introspection repositories. + +Repository implementations that discover entities by inspecting the filesystem +and code structure, rather than persisting entities. +""" diff --git a/src/julee/core/infrastructure/repositories/introspection/bounded_context.py b/src/julee/core/infrastructure/repositories/introspection/bounded_context.py new file mode 100644 index 00000000..aeb27efd --- /dev/null +++ b/src/julee/core/infrastructure/repositories/introspection/bounded_context.py @@ -0,0 +1,283 @@ +"""Filesystem-based bounded context repository. + +Discovers bounded contexts by scanning the filesystem structure. +This is a read-only repository - bounded contexts are defined by +the filesystem, not created through this repository. +""" + +import ast +import subprocess +from pathlib import Path + +from julee.core.doctrine_constants import ( + CONTRIB_DIR, + ENTITIES_PATH, + REPOSITORIES_PATH, + RESERVED_WORDS, + SERVICES_PATH, + USE_CASES_PATH, + VIEWPOINT_SLUGS, +) +from julee.core.entities.bounded_context import BoundedContext, StructuralMarkers + +__all__ = ["FilesystemBoundedContextRepository"] + + +# ============================================================================= +# Docstring Extraction +# ============================================================================= + + +def _get_first_docstring_line(path: Path) -> str | None: + """Extract first line of docstring from a Python package's __init__.py. + + Args: + path: Directory containing __init__.py + + Returns: + First non-empty line of docstring or None if not found + """ + init_file = path / "__init__.py" + if not init_file.exists(): + return None + + try: + source = init_file.read_text() + tree = ast.parse(source) + docstring = ast.get_docstring(tree) + if docstring: + for line in docstring.split("\n"): + line = line.strip() + if line: + return line + except (SyntaxError, OSError): + pass + + return None + + +# ============================================================================= +# Gitignore Handling +# ============================================================================= + + +def _is_gitignored(path: Path, project_root: Path) -> bool: + """Check if a path is ignored by git. + + Uses `git check-ignore` to respect .gitignore rules. + Falls back to False if git is not available or path is not in a repo. + """ + try: + result = subprocess.run( + ["git", "check-ignore", "-q", str(path)], + cwd=project_root, + capture_output=True, + timeout=5, + ) + # Exit code 0 means the path IS ignored + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + # git not available or other error - don't ignore + return False + + +# ============================================================================= +# Repository Implementation +# ============================================================================= + + +class FilesystemBoundedContextRepository: + """Repository that discovers bounded contexts by scanning filesystem. + + Inspects directory structure to find bounded contexts that follow + the {entities,repositories,services,use_cases} pattern (flattened) + or the legacy domain/{models,repositories,services,use_cases} pattern. + """ + + def __init__( + self, + project_root: Path, + search_root: str, + ) -> None: + """Initialize repository. + + Args: + project_root: Root directory of the project + search_root: Root directory for bounded context discovery, + relative to project_root (e.g., "src/myapp"). + """ + self.project_root = project_root + self.search_root = search_root + self._cache: list[BoundedContext] | None = None + + def _is_python_package(self, path: Path) -> bool: + """Check if directory is a Python package.""" + return (path / "__init__.py").exists() + + def _has_subdir(self, path: Path, parts: tuple[str, ...]) -> bool: + """Check if path contains a subdirectory.""" + return path.joinpath(*parts).is_dir() + + def _detect_markers(self, path: Path) -> StructuralMarkers: + """Detect structural markers in a directory.""" + return StructuralMarkers( + has_domain_models=self._has_subdir(path, ENTITIES_PATH), + has_domain_repositories=self._has_subdir(path, REPOSITORIES_PATH), + has_domain_services=self._has_subdir(path, SERVICES_PATH), + has_domain_use_cases=self._has_subdir(path, USE_CASES_PATH), + has_tests=self._has_subdir(path, ("tests",)), + has_parsers=self._has_subdir(path, ("parsers",)), + has_serializers=self._has_subdir(path, ("serializers",)), + ) + + def _is_bounded_context(self, markers: StructuralMarkers) -> bool: + """Check if markers indicate a bounded context. + + A bounded context must have models or use_cases. + """ + return markers.has_domain_models or markers.has_domain_use_cases + + def _discover_in_directory( + self, + search_path: Path, + is_contrib: bool = False, + ) -> list[BoundedContext]: + """Discover bounded contexts in a directory.""" + contexts = [] + + if not search_path.exists(): + return contexts + + for candidate in search_path.iterdir(): + if not candidate.is_dir(): + continue + + # Skip dot-prefixed directories + if candidate.name.startswith("."): + continue + + # Skip gitignored directories + if _is_gitignored(candidate, self.project_root): + continue + + # Skip reserved words + if candidate.name in RESERVED_WORDS: + continue + + # Must be a Python package + if not self._is_python_package(candidate): + continue + + markers = self._detect_markers(candidate) + + # Must have bounded context structure + if not self._is_bounded_context(markers): + continue + + context = BoundedContext( + slug=candidate.name, + path=str(candidate), + description=_get_first_docstring_line(candidate), + is_contrib=is_contrib, + is_viewpoint=candidate.name in VIEWPOINT_SLUGS, + markers=markers, + ) + contexts.append(context) + + return sorted(contexts, key=lambda c: c.slug) + + def _is_nested_solution(self, path: Path) -> bool: + """Check if a directory is a nested solution container. + + A nested solution is a Python package that: + - Does NOT have BC structure itself (no entities/ or use_cases/) + - Contains at least one subdirectory that IS a bounded context + + Examples: contrib/, experimental/, plugins/ + """ + if not self._is_python_package(path): + return False + + # If it has BC structure, it's a BC not a nested solution + markers = self._detect_markers(path) + if self._is_bounded_context(markers): + return False + + # Check if any child is a bounded context + for child in path.iterdir(): + if not child.is_dir() or child.name.startswith("."): + continue + if not self._is_python_package(child): + continue + child_markers = self._detect_markers(child) + if self._is_bounded_context(child_markers): + return True + + return False + + def _discover_all(self) -> list[BoundedContext]: + """Discover all bounded contexts. + + Scans top-level directories and recursively discovers BCs in + nested solutions. A nested solution is a Python package that + contains BCs but isn't a BC itself (e.g., contrib/, experimental/). + """ + search_path = self.project_root / self.search_root + all_contexts: list[BoundedContext] = [] + + if not search_path.exists(): + return all_contexts + + for candidate in search_path.iterdir(): + if not candidate.is_dir(): + continue + if candidate.name.startswith("."): + continue + if _is_gitignored(candidate, self.project_root): + continue + if candidate.name in RESERVED_WORDS: + continue + if not self._is_python_package(candidate): + continue + + markers = self._detect_markers(candidate) + + if self._is_bounded_context(markers): + # It's a bounded context + is_contrib = candidate.name == CONTRIB_DIR + context = BoundedContext( + slug=candidate.name, + path=str(candidate), + description=_get_first_docstring_line(candidate), + is_contrib=is_contrib, + is_viewpoint=candidate.name in VIEWPOINT_SLUGS, + markers=markers, + ) + all_contexts.append(context) + elif self._is_nested_solution(candidate): + # It's a nested solution - discover BCs within it + is_contrib = candidate.name == CONTRIB_DIR + nested_contexts = self._discover_in_directory( + candidate, is_contrib=is_contrib + ) + all_contexts.extend(nested_contexts) + + return sorted(all_contexts, key=lambda c: c.slug) + + async def list_all(self) -> list[BoundedContext]: + """List all discovered bounded contexts.""" + if self._cache is None: + self._cache = self._discover_all() + return self._cache + + async def get(self, slug: str) -> BoundedContext | None: + """Get a bounded context by slug.""" + contexts = await self.list_all() + for context in contexts: + if context.slug == slug: + return context + return None + + def invalidate_cache(self) -> None: + """Clear the discovery cache.""" + self._cache = None diff --git a/src/julee/core/parsers/__init__.py b/src/julee/core/parsers/__init__.py new file mode 100644 index 00000000..c42a1587 --- /dev/null +++ b/src/julee/core/parsers/__init__.py @@ -0,0 +1,9 @@ +"""Code parsers for introspection. + +AST-based parsers for extracting class and module information from Python source. + +Note: Imports are done lazily to avoid circular imports. Import directly from +submodules: +- julee.core.parsers.ast for class/module parsing +- julee.core.parsers.imports for import analysis +""" diff --git a/src/julee/core/parsers/ast.py b/src/julee/core/parsers/ast.py new file mode 100644 index 00000000..169a4f82 --- /dev/null +++ b/src/julee/core/parsers/ast.py @@ -0,0 +1,521 @@ +"""Python code introspection using griffe. + +Parses Python source files to extract class information for Clean Architecture +bounded contexts. Uses griffe for static analysis without importing source modules. + +Pipeline analysis uses stdlib ast for method body inspection, as griffe does +not analyse statement-level patterns inside method bodies. + +Note: Lazy imports within functions avoid circular imports, since use_cases +import from this module. +""" + +import ast +import functools +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import griffe + +if TYPE_CHECKING: + from julee.core.entities.code_info import ( + BoundedContextInfo, + ClassInfo, + ) + from julee.core.entities.pipeline import Pipeline + +logger = logging.getLogger(__name__) + + +# ============================================================================= +# GRIFFE-BASED CLASS EXTRACTION +# ============================================================================= + + +def _griffe_load_file(py_file: Path) -> griffe.Module | None: + """Load a single Python file with griffe (no imports).""" + try: + return griffe.load( + py_file.stem, + search_paths=[str(py_file.parent)], + allow_inspection=False, + ) + except Exception as e: + logger.warning(f"Could not parse {py_file}: {e}") + return None + + +def _griffe_class_to_classinfo(cls: griffe.Class, file_name: str) -> "ClassInfo": + from julee.core.entities.code_info import ( + ClassInfo, + FieldInfo, + MethodInfo, + ParameterInfo, + ) + + docstring = cls.docstring.value.split("\n")[0].strip() if cls.docstring else "" + + fields = [ + FieldInfo( + name=member.name, + type_annotation=str(member.annotation) if member.annotation else "", + default=str(member.value) if member.value else None, + ) + for member in cls.members.values() + if isinstance(member, griffe.Attribute) and member.annotation is not None + ] + + methods = [] + for member in cls.members.values(): + if not isinstance(member, griffe.Function) or member.name.startswith("_"): + continue + params = [ + ParameterInfo( + name=p.name, + type_annotation=str(p.annotation) if p.annotation else "", + ) + for p in member.parameters + if p.name != "self" + and p.kind + not in ( + griffe.ParameterKind.var_positional, + griffe.ParameterKind.var_keyword, + ) + ] + method_doc = ( + member.docstring.value.split("\n")[0].strip() if member.docstring else "" + ) + methods.append( + MethodInfo( + name=member.name, + is_async="async" in member.labels, + parameters=params, + return_type=str(member.returns) if member.returns else "", + docstring=method_doc, + ) + ) + + return ClassInfo( + name=cls.name, + docstring=docstring, + file=file_name, + bases=[str(b) for b in cls.bases], + fields=fields, + methods=methods, + ) + + +def _classes_from_file(py_file: Path, relative_to: Path) -> list["ClassInfo"]: + """Parse all classes from a file, with path relative to relative_to.""" + try: + rel = str(py_file.relative_to(relative_to)) + except ValueError: + rel = py_file.name + + module = _griffe_load_file(py_file) + if module is None: + return [] + + return [_griffe_class_to_classinfo(cls, rel) for cls in module.classes.values()] + + +def parse_python_classes( + directory: Path, + recursive: bool = True, + exclude_tests: bool = True, + exclude_files: list[str] | None = None, +) -> list["ClassInfo"]: + """Extract class information from Python files in a directory. + + Args: + directory: Directory to scan for .py files + recursive: If True, scan subdirectories recursively + exclude_tests: If True, exclude test files and test classes + exclude_files: List of file names to exclude (e.g., ["requests.py"]) + + Returns: + List of ClassInfo objects sorted by class name + """ + if not directory.exists(): + return [] + + exclude_files = exclude_files or [] + classes = [] + pattern = "**/*.py" if recursive else "*.py" + for py_file in directory.glob(pattern): + if py_file.name.startswith("_"): + continue + if exclude_tests and ( + py_file.name.startswith("test_") or "/tests/" in str(py_file) + ): + continue + if py_file.name in exclude_files: + continue + for cls in _classes_from_file(py_file, directory): + if exclude_tests and cls.name.startswith("Test"): + continue + classes.append(cls) + + return sorted(classes, key=lambda c: c.name) + + +def parse_python_classes_from_file(file_path: Path) -> list["ClassInfo"]: + """Extract class information from a single Python file. + + Args: + file_path: Path to the Python file + + Returns: + List of ClassInfo objects sorted by class name + """ + if not file_path.exists(): + return [] + return sorted(_classes_from_file(file_path, file_path.parent), key=lambda c: c.name) + + +def parse_module_docstring(module_path: Path) -> tuple[str | None, str | None]: + """Extract module docstring from a Python file. + + Args: + module_path: Path to Python file + + Returns: + Tuple of (first_line, full_docstring) or (None, None) if not found + """ + if not module_path.exists(): + return None, None + + module = _griffe_load_file(module_path) + if module is None: + return None, None + + if module.docstring: + full = module.docstring.value + return full.split("\n")[0].strip(), full + + return None, None + + +def _resolve_layer_path(context_dir: Path, path_tuple: tuple[str, ...]) -> Path: + result = context_dir + for part in path_tuple: + result = result / part + return result + + +@functools.lru_cache(maxsize=64) +def _parse_bounded_context_cached(context_dir_str: str) -> "BoundedContextInfo | None": + from julee.core.doctrine_constants import ( + ENTITIES_PATH, + REPOSITORIES_PATH, + SERVICES_PATH, + USE_CASES_PATH, + ) + from julee.core.entities.code_info import BoundedContextInfo + + context_dir = Path(context_dir_str) + if not context_dir.exists() or not context_dir.is_dir(): + return None + + objective, full_docstring = parse_module_docstring(context_dir / "__init__.py") + + use_cases_dir = _resolve_layer_path(context_dir, USE_CASES_PATH) + entities_dir = _resolve_layer_path(context_dir, ENTITIES_PATH) + repositories_dir = _resolve_layer_path(context_dir, REPOSITORIES_PATH) + services_dir = _resolve_layer_path(context_dir, SERVICES_PATH) + + all_classes = parse_python_classes(use_cases_dir) + requests = [c for c in all_classes if c.name.endswith("Request")] + responses = [c for c in all_classes if c.name.endswith("Response")] + use_cases = [c for c in all_classes if c.name.endswith("UseCase")] + + all_service_classes = parse_python_classes(services_dir) + service_protocols = [c for c in all_service_classes if c.name.endswith("Service")] + + return BoundedContextInfo( + slug=context_dir.name, + entities=parse_python_classes(entities_dir), + use_cases=use_cases, + requests=requests, + responses=responses, + repository_protocols=parse_python_classes(repositories_dir), + service_protocols=service_protocols, + has_infrastructure=(context_dir / "infrastructure").exists(), + code_dir=context_dir.name, + objective=objective, + docstring=full_docstring, + ) + + +def parse_bounded_context(context_dir: Path) -> "BoundedContextInfo | None": + """Introspect a bounded context directory for Clean Architecture structure.""" + return _parse_bounded_context_cached(str(context_dir)) + + +def _has_bounded_context_structure(context_dir: Path) -> bool: + from julee.core.doctrine_constants import ENTITIES_PATH, USE_CASES_PATH + + for path_tuple in [ENTITIES_PATH, USE_CASES_PATH]: + path = context_dir + for part in path_tuple: + path = path / part + if path.exists(): + return True + return False + + +def scan_bounded_contexts( + src_dir: Path, + exclude: list[str] | None = None, +) -> list["BoundedContextInfo"]: + """Scan a source directory for all bounded contexts.""" + if not src_dir.exists(): + logger.info(f"Source directory not found: {src_dir}") + return [] + + exclude = exclude or [] + contexts = [] + for context_dir in src_dir.iterdir(): + if not context_dir.is_dir(): + continue + if context_dir.name.startswith((".", "_")): + continue + if context_dir.name in exclude: + continue + if not _has_bounded_context_structure(context_dir): + continue + context_info = parse_bounded_context(context_dir) + if context_info: + contexts.append(context_info) + logger.info( + f"Introspected bounded context '{context_info.slug}': {context_info.summary()}" + ) + + return contexts + + +# ============================================================================= +# PIPELINE PARSING (stdlib ast — griffe does not analyse method bodies) +# ============================================================================= + + +def _get_decorator_names(decorators: list[ast.expr]) -> list[str]: + names = [] + for dec in decorators: + try: + if isinstance(dec, ast.Name): + names.append(dec.id) + elif isinstance(dec, ast.Attribute): + names.append(ast.unparse(dec)) + elif isinstance(dec, ast.Call): + if isinstance(dec.func, ast.Name): + names.append(dec.func.id) + elif isinstance(dec.func, ast.Attribute): + names.append(ast.unparse(dec.func)) + except Exception: + pass + return names + + +def _has_decorator( + node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef, decorator_name: str +) -> bool: + return decorator_name in _get_decorator_names(node.decorator_list) + + +def _find_method( + class_node: ast.ClassDef, method_name: str +) -> ast.FunctionDef | ast.AsyncFunctionDef | None: + for node in class_node.body: + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + if node.name == method_name: + return node + return None + + +def _method_delegates_to_use_case( + method_node: ast.FunctionDef | ast.AsyncFunctionDef, +) -> tuple[bool, str | None]: + from julee.core.doctrine_constants import USE_CASE_SUFFIX + + use_case_instantiated: str | None = None + use_case_called = False + + for node in ast.walk(method_node): + if isinstance(node, ast.Assign): + if isinstance(node.value, ast.Call) and isinstance( + node.value.func, ast.Name + ): + if node.value.func.id.endswith(USE_CASE_SUFFIX): + use_case_instantiated = node.value.func.id + if isinstance(node, ast.Await): + if isinstance(node.value, ast.Call): + call = node.value + if isinstance(call.func, ast.Attribute) and call.func.attr == "execute": + use_case_called = True + + return use_case_instantiated is not None and use_case_called, use_case_instantiated + + +def _method_calls_method( + method_node: ast.FunctionDef | ast.AsyncFunctionDef, + target_method: str, +) -> bool: + for node in ast.walk(method_node): + call_node = None + if isinstance(node, ast.Await) and isinstance(node.value, ast.Call): + call_node = node.value + elif isinstance(node, ast.Call): + call_node = node + if call_node and isinstance(call_node.func, ast.Attribute): + if call_node.func.attr == target_method: + if ( + isinstance(call_node.func.value, ast.Name) + and call_node.func.value.id == "self" + ): + return True + return False + + +def _method_sets_dispatches( + method_node: ast.FunctionDef | ast.AsyncFunctionDef, +) -> bool: + for node in ast.walk(method_node): + if isinstance(node, ast.Assign): + for target in node.targets: + if isinstance(target, ast.Attribute) and target.attr == "dispatches": + return True + return False + + +def _parse_pipeline_class( + class_node: ast.ClassDef, + file_path: str, + bounded_context: str = "", +): + from julee.core.doctrine_constants import PIPELINE_SUFFIX + from julee.core.entities.code_info import MethodInfo, ParameterInfo + from julee.core.entities.pipeline import Pipeline + + is_pipeline_by_name = class_node.name.endswith(PIPELINE_SUFFIX) + has_workflow_decorator = _has_decorator(class_node, "workflow.defn") + if not is_pipeline_by_name and not has_workflow_decorator: + return None + + docstring = ast.get_docstring(class_node) or "" + first_line = docstring.split("\n")[0].strip() if docstring else "" + + run_method = _find_method(class_node, "run") + has_run_method = run_method is not None + has_run_decorator = False + delegates_to_use_case = False + wrapped_use_case: str | None = None + + if run_method: + has_run_decorator = _has_decorator(run_method, "workflow.run") + delegates_to_use_case, wrapped_use_case = _method_delegates_to_use_case( + run_method + ) + + run_next_method = _find_method(class_node, "run_next") + has_run_next_method = run_next_method is not None + run_next_has_workflow_decorator = False + run_calls_run_next = False + sets_dispatches_on_response = False + + if run_next_method: + run_next_has_workflow_decorator = _has_decorator( + run_next_method, "workflow.run" + ) + if run_method: + run_calls_run_next = _method_calls_method(run_method, "run_next") + sets_dispatches_on_response = _method_sets_dispatches(run_method) + + methods = [] + for node in class_node.body: + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef): + params = [ + ParameterInfo( + name=arg.arg, + type_annotation=( + ast.unparse(arg.annotation) if arg.annotation else "" + ), + ) + for arg in node.args.args + if arg.arg != "self" + ] + method_doc = ast.get_docstring(node) or "" + methods.append( + MethodInfo( + name=node.name, + is_async=isinstance(node, ast.AsyncFunctionDef), + parameters=params, + return_type=ast.unparse(node.returns) if node.returns else "", + docstring=method_doc.split("\n")[0].strip() if method_doc else "", + ) + ) + + return Pipeline( + name=class_node.name, + docstring=first_line, + file=file_path, + bounded_context=bounded_context, + has_workflow_decorator=has_workflow_decorator, + has_run_decorator=has_run_decorator, + has_run_method=has_run_method, + wrapped_use_case=wrapped_use_case, + delegates_to_use_case=delegates_to_use_case, + methods=methods, + has_run_next_method=has_run_next_method, + run_next_has_workflow_decorator=run_next_has_workflow_decorator, + run_calls_run_next=run_calls_run_next, + sets_dispatches_on_response=sets_dispatches_on_response, + ) + + +def parse_pipelines_from_file( + file_path: Path, + bounded_context: str = "", +) -> "list[Pipeline]": + """Extract pipeline information from a Python file.""" + if not file_path.exists(): + return [] + + pipelines = [] + try: + source = file_path.read_text() + tree = ast.parse(source, filename=str(file_path)) + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + pipeline = _parse_pipeline_class(node, file_path.name, bounded_context) + if pipeline: + pipelines.append(pipeline) + except SyntaxError as e: + logger.warning(f"Syntax error in {file_path}: {e}") + except Exception as e: + logger.warning(f"Could not parse {file_path}: {e}") + + return sorted(pipelines, key=lambda p: p.name) + + +def parse_pipelines_from_bounded_context(context_dir: Path) -> "list[Pipeline]": + """Extract pipelines from a bounded context.""" + from julee.core.doctrine_constants import PIPELINE_LOCATION + + pipelines = [] + bounded_context = context_dir.name + + canonical_path = context_dir / PIPELINE_LOCATION + if canonical_path.exists(): + pipelines.extend(parse_pipelines_from_file(canonical_path, bounded_context)) + else: + worker_dir = context_dir / "apps" / "worker" + if worker_dir.exists(): + for py_file in worker_dir.glob("*.py"): + if not py_file.name.startswith("_"): + pipelines.extend( + parse_pipelines_from_file(py_file, bounded_context) + ) + + return sorted(pipelines, key=lambda p: p.name) diff --git a/src/julee/core/repositories/__init__.py b/src/julee/core/repositories/__init__.py new file mode 100644 index 00000000..745b2c0d --- /dev/null +++ b/src/julee/core/repositories/__init__.py @@ -0,0 +1,8 @@ +"""Shared repository protocols. + +Defines the generic repository interface following clean architecture patterns. + +Import directly from submodules: + from julee.core.repositories.base import BaseRepository + from julee.core.repositories.bounded_context import BoundedContextRepository +""" diff --git a/src/julee/core/repositories/bounded_context.py b/src/julee/core/repositories/bounded_context.py new file mode 100644 index 00000000..02a04cb4 --- /dev/null +++ b/src/julee/core/repositories/bounded_context.py @@ -0,0 +1,42 @@ +"""BoundedContext repository protocol. + +Defines the interface for discovering and accessing bounded contexts +in a codebase. Implementations may read from the filesystem, from +cached state, or from other sources. +""" + +from typing import Protocol, runtime_checkable + +from julee.core.entities.bounded_context import BoundedContext + + +@runtime_checkable +class BoundedContextRepository(Protocol): + """Repository for bounded context discovery and access. + + Unlike typical CRUD repositories, this repository is primarily + read-oriented - bounded contexts are defined by the filesystem + structure, not created through the repository. + + The repository may filter results based on doctrinal configuration + (reserved words, required structural markers, etc.). + """ + + async def list_all(self) -> list[BoundedContext]: + """List all discovered bounded contexts. + + Returns: + All bounded contexts that pass doctrinal filters + """ + ... + + async def get(self, slug: str) -> BoundedContext | None: + """Get a bounded context by its slug. + + Args: + slug: The directory name / identifier + + Returns: + BoundedContext if found, None otherwise + """ + ...