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.
- specx/__init__.py +1 -0
- specx/_internal/__init__.py +1 -0
- specx/_internal/exceptions.py +5 -0
- specx/_internal/python_ast/__init__.py +1 -0
- specx/_internal/python_ast/scanner.py +84 -0
- specx/foundation/__init__.py +1 -0
- specx/foundation/capability.py +9 -0
- specx/foundation/command.py +16 -0
- specx/foundation/configurator.py +8 -0
- specx/foundation/delivery/__init__.py +1 -0
- specx/foundation/delivery/controller.py +18 -0
- specx/foundation/delivery/fastapi/__init__.py +1 -0
- specx/foundation/delivery/fastapi/schema.py +15 -0
- specx/foundation/delivery/service.py +8 -0
- specx/foundation/dto.py +15 -0
- specx/foundation/effect_service.py +12 -0
- specx/foundation/entity.py +15 -0
- specx/foundation/enums.py +10 -0
- specx/foundation/exceptions.py +16 -0
- specx/foundation/factory.py +8 -0
- specx/foundation/gateway.py +9 -0
- specx/foundation/infrastructure/__init__.py +1 -0
- specx/foundation/infrastructure/sqlalchemy/__init__.py +1 -0
- specx/foundation/infrastructure/sqlalchemy/model.py +23 -0
- specx/foundation/pure_service.py +12 -0
- specx/foundation/query.py +16 -0
- specx/foundation/read_service.py +12 -0
- specx/foundation/repository.py +8 -0
- specx/foundation/settings.py +17 -0
- specx/foundation/unit_of_work.py +7 -0
- specx/foundation/unit_of_work_manager.py +29 -0
- specx/foundation/use_case.py +8 -0
- specx/py.typed +1 -0
- specx/testing/__init__.py +1 -0
- specx/testing/architecture/__init__.py +28 -0
- specx/testing/architecture/checker.py +40 -0
- specx/testing/architecture/context.py +722 -0
- specx/testing/architecture/models.py +102 -0
- specx/testing/architecture/registry.py +55 -0
- specx/testing/architecture/rule.py +19 -0
- specx/testing/architecture/rule_id.py +68 -0
- specx/testing/architecture/rules.py +1951 -0
- specx-0.0.0a1.dist-info/METADATA +228 -0
- specx-0.0.0a1.dist-info/RECORD +46 -0
- specx-0.0.0a1.dist-info/WHEEL +4 -0
- 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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
1
|
+
|
|
@@ -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)
|
specx/foundation/dto.py
ADDED
|
@@ -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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -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,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,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
|
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())
|