specx 0.0.0a1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. specx/__init__.py +1 -0
  2. specx/_internal/__init__.py +1 -0
  3. specx/_internal/exceptions.py +5 -0
  4. specx/_internal/python_ast/__init__.py +1 -0
  5. specx/_internal/python_ast/scanner.py +84 -0
  6. specx/foundation/__init__.py +1 -0
  7. specx/foundation/capability.py +9 -0
  8. specx/foundation/command.py +16 -0
  9. specx/foundation/configurator.py +8 -0
  10. specx/foundation/delivery/__init__.py +1 -0
  11. specx/foundation/delivery/controller.py +18 -0
  12. specx/foundation/delivery/fastapi/__init__.py +1 -0
  13. specx/foundation/delivery/fastapi/schema.py +15 -0
  14. specx/foundation/delivery/service.py +8 -0
  15. specx/foundation/dto.py +15 -0
  16. specx/foundation/effect_service.py +12 -0
  17. specx/foundation/entity.py +15 -0
  18. specx/foundation/enums.py +10 -0
  19. specx/foundation/exceptions.py +16 -0
  20. specx/foundation/factory.py +8 -0
  21. specx/foundation/gateway.py +9 -0
  22. specx/foundation/infrastructure/__init__.py +1 -0
  23. specx/foundation/infrastructure/sqlalchemy/__init__.py +1 -0
  24. specx/foundation/infrastructure/sqlalchemy/model.py +23 -0
  25. specx/foundation/pure_service.py +12 -0
  26. specx/foundation/query.py +16 -0
  27. specx/foundation/read_service.py +12 -0
  28. specx/foundation/repository.py +8 -0
  29. specx/foundation/settings.py +17 -0
  30. specx/foundation/unit_of_work.py +7 -0
  31. specx/foundation/unit_of_work_manager.py +29 -0
  32. specx/foundation/use_case.py +8 -0
  33. specx/py.typed +1 -0
  34. specx/testing/__init__.py +1 -0
  35. specx/testing/architecture/__init__.py +28 -0
  36. specx/testing/architecture/checker.py +40 -0
  37. specx/testing/architecture/context.py +722 -0
  38. specx/testing/architecture/models.py +102 -0
  39. specx/testing/architecture/registry.py +55 -0
  40. specx/testing/architecture/rule.py +19 -0
  41. specx/testing/architecture/rule_id.py +68 -0
  42. specx/testing/architecture/rules.py +1951 -0
  43. specx-0.0.0a1.dist-info/METADATA +228 -0
  44. specx-0.0.0a1.dist-info/RECORD +46 -0
  45. specx-0.0.0a1.dist-info/WHEEL +4 -0
  46. specx-0.0.0a1.dist-info/licenses/LICENSE.md +21 -0
specx/__init__.py ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,5 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class BaseSpecxError(Exception):
5
+ """Base for package errors raised by Specx internals and testing APIs."""
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from dataclasses import dataclass
5
+ from fnmatch import fnmatch
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass(frozen=True, kw_only=True, slots=True)
10
+ class PythonSourceFile:
11
+ """Parsed Python file and its lightweight static metadata."""
12
+
13
+ path: Path
14
+ tree: ast.Module
15
+ imports: frozenset[str]
16
+ aliases: dict[str, str]
17
+
18
+
19
+ @dataclass(frozen=True, kw_only=True, slots=True)
20
+ class PythonAstProject:
21
+ """Static Python source index for a project under inspection."""
22
+
23
+ files: dict[Path, PythonSourceFile]
24
+
25
+ def source_file(self, path: Path) -> PythonSourceFile:
26
+ return self.files[path]
27
+
28
+
29
+ @dataclass(frozen=True, kw_only=True, slots=True)
30
+ class PythonAstScanner:
31
+ """Parse project Python files once so rules can share AST metadata."""
32
+
33
+ project_root: Path
34
+ excluded_patterns: tuple[str, ...] = ()
35
+
36
+ def scan(self, roots: tuple[Path, ...]) -> PythonAstProject:
37
+ files: dict[Path, PythonSourceFile] = {}
38
+ for root in roots:
39
+ if not root.exists():
40
+ continue
41
+ for path in sorted(root.rglob("*.py")):
42
+ if self._is_excluded(path):
43
+ continue
44
+ text = path.read_text(encoding="utf-8")
45
+ tree = ast.parse(text, filename=str(path))
46
+ files[path] = PythonSourceFile(
47
+ path=path,
48
+ tree=tree,
49
+ imports=_imports(tree),
50
+ aliases=_import_aliases(tree),
51
+ )
52
+ return PythonAstProject(files=files)
53
+
54
+ def _is_excluded(self, path: Path) -> bool:
55
+ relative = path.relative_to(self.project_root).as_posix()
56
+ return any(fnmatch(relative, pattern) for pattern in self.excluded_patterns)
57
+
58
+
59
+ def _imports(tree: ast.Module) -> frozenset[str]:
60
+ modules: set[str] = set()
61
+ for node in ast.walk(tree):
62
+ if isinstance(node, ast.Import):
63
+ modules.update(alias.name for alias in node.names)
64
+ elif isinstance(node, ast.ImportFrom):
65
+ module_name = "." * node.level + (node.module or "")
66
+ if module_name:
67
+ modules.add(module_name)
68
+ separator = "" if module_name == "" or module_name.endswith(".") else "."
69
+ modules.update(
70
+ f"{module_name}{separator}{alias.name}" for alias in node.names if alias.name != "*"
71
+ )
72
+ return frozenset(modules)
73
+
74
+
75
+ def _import_aliases(tree: ast.Module) -> dict[str, str]:
76
+ aliases: dict[str, str] = {}
77
+ for node in ast.walk(tree):
78
+ if isinstance(node, ast.Import):
79
+ for alias in node.names:
80
+ aliases[alias.asname or alias.name.split(".")[0]] = alias.name.split(".")[-1]
81
+ elif isinstance(node, ast.ImportFrom):
82
+ for alias in node.names:
83
+ aliases[alias.asname or alias.name] = alias.name
84
+ return aliases
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,9 @@
1
+ class BaseCapability:
2
+ """Base for small injectable collaborators narrower than services.
3
+
4
+ Capabilities do one focused thing, may be injected or faked, and do not own
5
+ application workflows, unit-of-work scopes, repositories, or gateways.
6
+
7
+ Example:
8
+ SlugGeneratingCapability creates slugs for display labels.
9
+ """
@@ -0,0 +1,16 @@
1
+ from dataclasses import dataclass
2
+
3
+ from specx.foundation.dto import BaseDTO
4
+
5
+
6
+ @dataclass(frozen=True, kw_only=True, slots=True)
7
+ class BaseCommand(BaseDTO):
8
+ """Base for use-case inputs that request state-changing work.
9
+
10
+ Example:
11
+ from dataclasses import dataclass
12
+
13
+ @dataclass(frozen=True, kw_only=True, slots=True)
14
+ class CreateTaskCommand(BaseCommand):
15
+ title: str
16
+ """
@@ -0,0 +1,8 @@
1
+ class BaseConfigurator:
2
+ """Base for bootstrap objects that apply configuration to runtime systems.
3
+
4
+ Example:
5
+ class LoggingConfigurator(BaseConfigurator):
6
+ def configure(self) -> None:
7
+ return None
8
+ """
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,18 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Generic, TypeVar
3
+
4
+ RegistryT = TypeVar("RegistryT")
5
+
6
+
7
+ class BaseController(ABC, Generic[RegistryT]):
8
+ """Base for delivery controllers that register public routes.
9
+
10
+ Example:
11
+ class TasksController(BaseController[APIRouter]):
12
+ def register(self, registry: APIRouter) -> None:
13
+ registry.add_api_route("/api/v1/tasks", self.list_tasks)
14
+ """
15
+
16
+ @abstractmethod
17
+ def register(self, registry: RegistryT) -> None:
18
+ raise NotImplementedError
@@ -0,0 +1,15 @@
1
+ from typing import ClassVar
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+
6
+ class BaseFastAPISchema(BaseModel):
7
+ """Base for FastAPI request and response schemas.
8
+
9
+ Example:
10
+ class TaskResponseSchema(BaseFastAPISchema):
11
+ id: int
12
+ title: str
13
+ """
14
+
15
+ model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", from_attributes=True)
@@ -0,0 +1,8 @@
1
+ class BaseDeliveryService:
2
+ """Base for delivery-only helper services such as auth or rate limiting.
3
+
4
+ Example:
5
+ class ApiKeyParsingService(BaseDeliveryService):
6
+ def parse(self, *, header: str) -> str:
7
+ return header.removeprefix("Bearer ")
8
+ """
@@ -0,0 +1,15 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True, kw_only=True, slots=True)
5
+ class BaseDTO:
6
+ """Base for application payloads returned by core use cases.
7
+
8
+ Example:
9
+ from dataclasses import dataclass
10
+
11
+ @dataclass(frozen=True, kw_only=True, slots=True)
12
+ class TaskDTO(BaseDTO):
13
+ id: int
14
+ title: str
15
+ """
@@ -0,0 +1,12 @@
1
+ class BaseEffectService:
2
+ """Base for helpers that perform or coordinate side effects.
3
+
4
+ Effect services may mutate owned state through an active unit of work passed
5
+ by a use case, or call outbound gateways with real side effects. They must
6
+ not open unit-of-work scopes or own transaction lifecycle.
7
+
8
+ Example:
9
+ class TaskCompletionService(BaseEffectService):
10
+ async def complete(self, *, task_id: int) -> TaskDTO:
11
+ return TaskDTO(id=task_id, title="Done")
12
+ """
@@ -0,0 +1,15 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True, kw_only=True, slots=True)
5
+ class BaseEntity:
6
+ """Base for framework-free core state.
7
+
8
+ Example:
9
+ from dataclasses import dataclass
10
+
11
+ @dataclass(frozen=True, kw_only=True, slots=True)
12
+ class TaskEntity(BaseEntity):
13
+ id: int
14
+ title: str
15
+ """
@@ -0,0 +1,10 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class BaseStrEnum(StrEnum):
5
+ """Base for string enums used by settings and application values.
6
+
7
+ Example:
8
+ class EnvironmentEnum(BaseStrEnum):
9
+ LOCAL = "local"
10
+ """
@@ -0,0 +1,16 @@
1
+ class BaseApplicationError(Exception):
2
+ """Base for application errors translated by delivery layers.
3
+
4
+ Example:
5
+ class TaskNotFoundError(BaseApplicationError):
6
+ task_id: int
7
+ """
8
+
9
+
10
+ class BaseApplicationValueError(ValueError):
11
+ """Base for invalid application values rejected before persistence.
12
+
13
+ Example:
14
+ class InvalidTaskTitleValueError(BaseApplicationValueError):
15
+ title: str
16
+ """
@@ -0,0 +1,8 @@
1
+ class BaseFactory:
2
+ """Base for classes that compose or create runtime objects.
3
+
4
+ Example:
5
+ class FastAPIFactory(BaseFactory):
6
+ def __call__(self) -> FastAPI:
7
+ return FastAPI()
8
+ """
@@ -0,0 +1,9 @@
1
+ class BaseGateway:
2
+ """Base for outbound business capability ports.
3
+
4
+ Gateways are core-facing interfaces to external systems. A gateway should
5
+ expose business language, not SDK, HTTP, queue, or vendor details.
6
+
7
+ Example:
8
+ EmailGateway sends transactional emails.
9
+ """
@@ -0,0 +1,23 @@
1
+ from typing import ClassVar
2
+
3
+ from sqlalchemy import MetaData
4
+ from sqlalchemy.orm import DeclarativeBase
5
+
6
+
7
+ class BaseSQLAlchemyModel(DeclarativeBase):
8
+ """Base for SQLAlchemy declarative models.
9
+
10
+ Example:
11
+ class TaskModel(BaseSQLAlchemyModel):
12
+ __tablename__ = "tasks"
13
+ """
14
+
15
+ metadata: ClassVar[MetaData] = MetaData(
16
+ naming_convention={
17
+ "ix": "ix_%(column_0_label)s",
18
+ "uq": "uq_%(table_name)s_%(column_0_name)s",
19
+ "ck": "ck_%(table_name)s_%(constraint_name)s",
20
+ "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
21
+ "pk": "pk_%(table_name)s",
22
+ },
23
+ )
@@ -0,0 +1,12 @@
1
+ class BasePureService:
2
+ """Base for deterministic business helpers.
3
+
4
+ Pure services do not perform I/O, use repositories, call gateways, or
5
+ depend on unit-of-work objects, settings, clocks, UUID generators, random
6
+ numbers, HTTP clients, SQLAlchemy, Redis, or SDKs.
7
+
8
+ Example:
9
+ class TaskTitleNormalizerService(BasePureService):
10
+ def normalize(self, *, title: str) -> str:
11
+ return " ".join(title.split())
12
+ """
@@ -0,0 +1,16 @@
1
+ from dataclasses import dataclass
2
+
3
+ from specx.foundation.dto import BaseDTO
4
+
5
+
6
+ @dataclass(frozen=True, kw_only=True, slots=True)
7
+ class BaseQuery(BaseDTO):
8
+ """Base for use-case inputs that request read-only results.
9
+
10
+ Example:
11
+ from dataclasses import dataclass
12
+
13
+ @dataclass(frozen=True, kw_only=True, slots=True)
14
+ class GetTaskQuery(BaseQuery):
15
+ task_id: int
16
+ """
@@ -0,0 +1,12 @@
1
+ class BaseReadService:
2
+ """Base for read-only orchestration helpers.
3
+
4
+ Read services may read from repositories or read gateways, usually through
5
+ an active unit of work passed by the caller. They may map entities to DTOs,
6
+ but must not commit, roll back, publish, send email, or call write APIs.
7
+
8
+ Example:
9
+ class TaskLookupService(BaseReadService):
10
+ async def get(self, *, task_id: int) -> TaskDTO:
11
+ return TaskDTO(id=task_id, title="Ship")
12
+ """
@@ -0,0 +1,8 @@
1
+ class BaseRepository:
2
+ """Base for core repository ports and their infrastructure adapters.
3
+
4
+ Example:
5
+ class TaskRepository(BaseRepository):
6
+ async def get(self, *, task_id: int) -> TaskEntity | None:
7
+ return None
8
+ """
@@ -0,0 +1,17 @@
1
+ from typing import ClassVar
2
+
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+
5
+
6
+ class BaseRuntimeSettings(BaseSettings):
7
+ """Base for runtime settings loaded from environment variables.
8
+
9
+ Example:
10
+ class DatabaseSettings(BaseRuntimeSettings):
11
+ database_url: str = "sqlite+aiosqlite:///./app.sqlite3"
12
+ """
13
+
14
+ model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
15
+ env_file=".env",
16
+ extra="ignore",
17
+ )
@@ -0,0 +1,7 @@
1
+ class BaseUnitOfWork:
2
+ """Base for active transaction objects exposed inside manager scopes.
3
+
4
+ Example:
5
+ async with manager as unit_of_work:
6
+ task = await unit_of_work.tasks.get(task_id=1)
7
+ """
@@ -0,0 +1,29 @@
1
+ from abc import ABC, abstractmethod
2
+ from types import TracebackType
3
+ from typing import Generic, Literal, TypeVar
4
+
5
+ from specx.foundation.unit_of_work import BaseUnitOfWork
6
+
7
+ UnitOfWorkT = TypeVar("UnitOfWorkT", bound=BaseUnitOfWork)
8
+
9
+
10
+ class BaseUnitOfWorkManager(ABC, Generic[UnitOfWorkT]):
11
+ """Base for managers that open, finish, and close active units of work.
12
+
13
+ Example:
14
+ async with task_unit_of_work_manager as unit_of_work:
15
+ task = await unit_of_work.tasks.get(task_id=1)
16
+ """
17
+
18
+ @abstractmethod
19
+ async def __aenter__(self) -> UnitOfWorkT:
20
+ raise NotImplementedError
21
+
22
+ @abstractmethod
23
+ async def __aexit__(
24
+ self,
25
+ exc_type: type[BaseException] | None,
26
+ exc: BaseException | None,
27
+ traceback: TracebackType | None,
28
+ ) -> Literal[False]:
29
+ raise NotImplementedError
@@ -0,0 +1,8 @@
1
+ class BaseUseCase:
2
+ """Base for externally meaningful application actions.
3
+
4
+ Example:
5
+ class CreateTaskUseCase(BaseUseCase):
6
+ async def execute(self, *, command: CreateTaskCommand) -> TaskDTO:
7
+ return TaskDTO(id=1, title=command.title)
8
+ """
specx/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from specx.testing.architecture.checker import assert_specx_architecture, check_specx_architecture
4
+ from specx.testing.architecture.context import ArchitectureContext
5
+ from specx.testing.architecture.models import (
6
+ RuleIdentifier,
7
+ SpecxArchitectureConfig,
8
+ SpecxArchitectureReport,
9
+ SpecxArchitectureViolation,
10
+ SpecxConfigurationError,
11
+ )
12
+ from specx.testing.architecture.registry import SpecxRuleRegistryError
13
+ from specx.testing.architecture.rule import BaseRule
14
+ from specx.testing.architecture.rule_id import SpecxRuleId
15
+
16
+ __all__ = [
17
+ "ArchitectureContext",
18
+ "BaseRule",
19
+ "RuleIdentifier",
20
+ "SpecxArchitectureConfig",
21
+ "SpecxArchitectureReport",
22
+ "SpecxArchitectureViolation",
23
+ "SpecxConfigurationError",
24
+ "SpecxRuleId",
25
+ "SpecxRuleRegistryError",
26
+ "assert_specx_architecture",
27
+ "check_specx_architecture",
28
+ ]
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from specx._internal.python_ast.scanner import PythonAstScanner
4
+ from specx.testing.architecture.context import ArchitectureContext
5
+ from specx.testing.architecture.models import (
6
+ SpecxArchitectureConfig,
7
+ SpecxArchitectureReport,
8
+ SpecxArchitectureViolation,
9
+ )
10
+ from specx.testing.architecture.registry import SpecxRuleRegistry
11
+
12
+
13
+ def check_specx_architecture(config: SpecxArchitectureConfig) -> SpecxArchitectureReport:
14
+ """Run enabled Specx architecture rules and return a grouped report."""
15
+
16
+ scanner = PythonAstScanner(
17
+ project_root=config.project_root,
18
+ excluded_patterns=config.path_exclusions,
19
+ )
20
+ ast_project = scanner.scan(roots=(config.project_root / "src", config.project_root / "tests"))
21
+ context = ArchitectureContext(config=config, ast_project=ast_project)
22
+ registry = SpecxRuleRegistry.build(extra_rules=config.extra_rules)
23
+ violations: list[SpecxArchitectureViolation] = []
24
+
25
+ for rule_type in registry.enabled_rules(disabled_rules=config.disabled_rules):
26
+ rule = rule_type()
27
+ violations.extend(rule.check(context))
28
+
29
+ return SpecxArchitectureReport(
30
+ project_root=config.project_root,
31
+ violations=tuple(violations),
32
+ )
33
+
34
+
35
+ def assert_specx_architecture(config: SpecxArchitectureConfig) -> None:
36
+ """Assert that a project satisfies every enabled Specx architecture rule."""
37
+
38
+ report = check_specx_architecture(config)
39
+ if report.has_violations:
40
+ raise AssertionError(report.format())