qx-core 0.1.0__tar.gz
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.
- qx_core-0.1.0/.gitignore +51 -0
- qx_core-0.1.0/PKG-INFO +31 -0
- qx_core-0.1.0/README.md +19 -0
- qx_core-0.1.0/pyproject.toml +20 -0
- qx_core-0.1.0/src/qx/core/__init__.py +130 -0
- qx_core-0.1.0/src/qx/core/config/__init__.py +87 -0
- qx_core-0.1.0/src/qx/core/context/__init__.py +133 -0
- qx_core-0.1.0/src/qx/core/domain/__init__.py +0 -0
- qx_core-0.1.0/src/qx/core/domain/audit.py +58 -0
- qx_core-0.1.0/src/qx/core/domain/events.py +114 -0
- qx_core-0.1.0/src/qx/core/entities/__init__.py +254 -0
- qx_core-0.1.0/src/qx/core/errors/__init__.py +213 -0
- qx_core-0.1.0/src/qx/core/py.typed +0 -0
- qx_core-0.1.0/src/qx/core/result/__init__.py +215 -0
- qx_core-0.1.0/src/qx/core/types/__init__.py +0 -0
- qx_core-0.1.0/src/qx/core/types/pagination.py +138 -0
- qx_core-0.1.0/src/qx/core/utils/__init__.py +0 -0
- qx_core-0.1.0/src/qx/core/utils/time.py +21 -0
- qx_core-0.1.0/tests/test_context.py +70 -0
- qx_core-0.1.0/tests/test_entities.py +84 -0
- qx_core-0.1.0/tests/test_result.py +160 -0
qx_core-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.pyo
|
|
5
|
+
*.pyd
|
|
6
|
+
.Python
|
|
7
|
+
*.so
|
|
8
|
+
*.egg
|
|
9
|
+
*.egg-info/
|
|
10
|
+
dist/
|
|
11
|
+
build/
|
|
12
|
+
eggs/
|
|
13
|
+
.eggs/
|
|
14
|
+
sdist/
|
|
15
|
+
wheels/
|
|
16
|
+
*.egg-link
|
|
17
|
+
|
|
18
|
+
# Virtual environments
|
|
19
|
+
.venv/
|
|
20
|
+
venv/
|
|
21
|
+
env/
|
|
22
|
+
ENV/
|
|
23
|
+
|
|
24
|
+
# uv
|
|
25
|
+
.uv/
|
|
26
|
+
|
|
27
|
+
# Testing
|
|
28
|
+
.pytest_cache/
|
|
29
|
+
.coverage
|
|
30
|
+
htmlcov/
|
|
31
|
+
.tox/
|
|
32
|
+
|
|
33
|
+
# Type checking
|
|
34
|
+
.mypy_cache/
|
|
35
|
+
.ruff_cache/
|
|
36
|
+
|
|
37
|
+
# IDE
|
|
38
|
+
.idea/
|
|
39
|
+
.vscode/
|
|
40
|
+
*.swp
|
|
41
|
+
*.swo
|
|
42
|
+
|
|
43
|
+
# OS
|
|
44
|
+
.DS_Store
|
|
45
|
+
Thumbs.db
|
|
46
|
+
|
|
47
|
+
# Docker
|
|
48
|
+
*.env.local
|
|
49
|
+
|
|
50
|
+
# Dist artifacts
|
|
51
|
+
dist/
|
qx_core-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qx-core
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Qx framework core primitives: Result, Error, Entity, RequestContext
|
|
5
|
+
Author: Qx Engineering
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.14
|
|
8
|
+
Requires-Dist: pydantic-settings>=2.4.0
|
|
9
|
+
Requires-Dist: pydantic>=2.8.0
|
|
10
|
+
Requires-Dist: typing-extensions>=4.12.0
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# qx-core
|
|
14
|
+
|
|
15
|
+
Foundational primitives for the Qx framework.
|
|
16
|
+
|
|
17
|
+
## What lives here
|
|
18
|
+
|
|
19
|
+
- **`qx.core.result`** — `Result[T]` algebraic type with success/failure variants and combinators.
|
|
20
|
+
- **`qx.core.errors`** — Canonical error hierarchy (`Error`, `ValidationError`, `DomainError`, `NotFoundError`, etc.) used across the framework. HTTP/gRPC layers map these to transport codes.
|
|
21
|
+
- **`qx.core.entities`** — Base `Entity`, `AggregateRoot`, and `ValueObject` with auditing, optimistic concurrency, and soft-delete.
|
|
22
|
+
- **`qx.core.context`** — `RequestContext` propagated via `contextvars` across async boundaries (request_id, correlation_id, trace_id, user_id, tenant_id).
|
|
23
|
+
- **`qx.core.domain`** — `DomainEvent`, `IntegrationEvent`, `Notification` base types.
|
|
24
|
+
- **`qx.core.types`** — Common types: `Identifier`, `Page`, `Cursor`, `Sort`, `Filter`.
|
|
25
|
+
- **`qx.core.config`** — Layered configuration via `pydantic-settings`.
|
|
26
|
+
|
|
27
|
+
## Design rules
|
|
28
|
+
|
|
29
|
+
- This package has **zero infrastructure dependencies**. No SQLAlchemy, no Redis, no FastAPI, no NATS.
|
|
30
|
+
- Public API is exported from `qx.core`. Submodules are implementation detail and may move.
|
|
31
|
+
- Backward compatibility from `0.x` is best-effort; `1.0` will lock the API surface.
|
qx_core-0.1.0/README.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# qx-core
|
|
2
|
+
|
|
3
|
+
Foundational primitives for the Qx framework.
|
|
4
|
+
|
|
5
|
+
## What lives here
|
|
6
|
+
|
|
7
|
+
- **`qx.core.result`** — `Result[T]` algebraic type with success/failure variants and combinators.
|
|
8
|
+
- **`qx.core.errors`** — Canonical error hierarchy (`Error`, `ValidationError`, `DomainError`, `NotFoundError`, etc.) used across the framework. HTTP/gRPC layers map these to transport codes.
|
|
9
|
+
- **`qx.core.entities`** — Base `Entity`, `AggregateRoot`, and `ValueObject` with auditing, optimistic concurrency, and soft-delete.
|
|
10
|
+
- **`qx.core.context`** — `RequestContext` propagated via `contextvars` across async boundaries (request_id, correlation_id, trace_id, user_id, tenant_id).
|
|
11
|
+
- **`qx.core.domain`** — `DomainEvent`, `IntegrationEvent`, `Notification` base types.
|
|
12
|
+
- **`qx.core.types`** — Common types: `Identifier`, `Page`, `Cursor`, `Sort`, `Filter`.
|
|
13
|
+
- **`qx.core.config`** — Layered configuration via `pydantic-settings`.
|
|
14
|
+
|
|
15
|
+
## Design rules
|
|
16
|
+
|
|
17
|
+
- This package has **zero infrastructure dependencies**. No SQLAlchemy, no Redis, no FastAPI, no NATS.
|
|
18
|
+
- Public API is exported from `qx.core`. Submodules are implementation detail and may move.
|
|
19
|
+
- Backward compatibility from `0.x` is best-effort; `1.0` will lock the API surface.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "qx-core"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Qx framework core primitives: Result, Error, Entity, RequestContext"
|
|
5
|
+
requires-python = ">=3.14"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
authors = [{ name = "Qx Engineering" }]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
dependencies = [
|
|
10
|
+
"pydantic>=2.8.0",
|
|
11
|
+
"pydantic-settings>=2.4.0",
|
|
12
|
+
"typing-extensions>=4.12.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[build-system]
|
|
16
|
+
requires = ["hatchling"]
|
|
17
|
+
build-backend = "hatchling.build"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["src/qx"]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Public surface of ``qx-core``.
|
|
2
|
+
|
|
3
|
+
Import from this module rather than from internal submodules; the submodule
|
|
4
|
+
layout is subject to change while this surface is stable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from qx.core.config import (
|
|
10
|
+
AppSection,
|
|
11
|
+
Environment,
|
|
12
|
+
LoggingSection,
|
|
13
|
+
QxSettings,
|
|
14
|
+
)
|
|
15
|
+
from qx.core.context import (
|
|
16
|
+
RequestContext,
|
|
17
|
+
current_context,
|
|
18
|
+
request_scope,
|
|
19
|
+
reset_context,
|
|
20
|
+
set_context,
|
|
21
|
+
)
|
|
22
|
+
from qx.core.domain.audit import AuditAction, AuditEntry
|
|
23
|
+
from qx.core.domain.events import (
|
|
24
|
+
DomainEvent,
|
|
25
|
+
Event,
|
|
26
|
+
IntegrationEvent,
|
|
27
|
+
Notification,
|
|
28
|
+
)
|
|
29
|
+
from qx.core.entities import (
|
|
30
|
+
AggregateRoot,
|
|
31
|
+
Entity,
|
|
32
|
+
Identifier,
|
|
33
|
+
ValueObject,
|
|
34
|
+
aggregate,
|
|
35
|
+
entity,
|
|
36
|
+
uuid4,
|
|
37
|
+
uuid7,
|
|
38
|
+
)
|
|
39
|
+
from qx.core.errors import (
|
|
40
|
+
ConfigurationError,
|
|
41
|
+
ConflictError,
|
|
42
|
+
DomainError,
|
|
43
|
+
Error,
|
|
44
|
+
ErrorException,
|
|
45
|
+
ForbiddenError,
|
|
46
|
+
InfrastructureError,
|
|
47
|
+
NotFoundError,
|
|
48
|
+
PreconditionFailedError,
|
|
49
|
+
RateLimitedError,
|
|
50
|
+
TimeoutError,
|
|
51
|
+
UnauthorizedError,
|
|
52
|
+
ValidationError,
|
|
53
|
+
)
|
|
54
|
+
from qx.core.result import Failure, Result, Success
|
|
55
|
+
from qx.core.types.pagination import (
|
|
56
|
+
CursorPage,
|
|
57
|
+
CursorPagination,
|
|
58
|
+
Filter,
|
|
59
|
+
FilterOp,
|
|
60
|
+
OffsetPage,
|
|
61
|
+
OffsetPagination,
|
|
62
|
+
Page,
|
|
63
|
+
Sort,
|
|
64
|
+
SortDirection,
|
|
65
|
+
)
|
|
66
|
+
from qx.core.utils.time import utcnow
|
|
67
|
+
|
|
68
|
+
__version__ = "0.1.0"
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
"AggregateRoot",
|
|
72
|
+
"AppSection",
|
|
73
|
+
# Audit
|
|
74
|
+
"AuditAction",
|
|
75
|
+
"AuditEntry",
|
|
76
|
+
"ConfigurationError",
|
|
77
|
+
"ConflictError",
|
|
78
|
+
"CursorPage",
|
|
79
|
+
"CursorPagination",
|
|
80
|
+
"DomainError",
|
|
81
|
+
"DomainEvent",
|
|
82
|
+
"Entity",
|
|
83
|
+
"Environment",
|
|
84
|
+
# Errors
|
|
85
|
+
"Error",
|
|
86
|
+
"ErrorException",
|
|
87
|
+
# Domain events
|
|
88
|
+
"Event",
|
|
89
|
+
"Failure",
|
|
90
|
+
"Filter",
|
|
91
|
+
"FilterOp",
|
|
92
|
+
"ForbiddenError",
|
|
93
|
+
# Entities
|
|
94
|
+
"Identifier",
|
|
95
|
+
"InfrastructureError",
|
|
96
|
+
"IntegrationEvent",
|
|
97
|
+
"LoggingSection",
|
|
98
|
+
"NotFoundError",
|
|
99
|
+
"Notification",
|
|
100
|
+
"OffsetPage",
|
|
101
|
+
# Pagination/filter
|
|
102
|
+
"OffsetPagination",
|
|
103
|
+
"Page",
|
|
104
|
+
"PreconditionFailedError",
|
|
105
|
+
# Config
|
|
106
|
+
"QxSettings",
|
|
107
|
+
"RateLimitedError",
|
|
108
|
+
# Context
|
|
109
|
+
"RequestContext",
|
|
110
|
+
# Result
|
|
111
|
+
"Result",
|
|
112
|
+
"Sort",
|
|
113
|
+
"SortDirection",
|
|
114
|
+
"Success",
|
|
115
|
+
"TimeoutError",
|
|
116
|
+
"UnauthorizedError",
|
|
117
|
+
"ValidationError",
|
|
118
|
+
"ValueObject",
|
|
119
|
+
"__version__",
|
|
120
|
+
"aggregate",
|
|
121
|
+
"current_context",
|
|
122
|
+
"entity",
|
|
123
|
+
"request_scope",
|
|
124
|
+
"reset_context",
|
|
125
|
+
"set_context",
|
|
126
|
+
# Utils
|
|
127
|
+
"utcnow",
|
|
128
|
+
"uuid4",
|
|
129
|
+
"uuid7",
|
|
130
|
+
]
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Layered configuration via :mod:`pydantic_settings`.
|
|
2
|
+
|
|
3
|
+
Resolution order (highest priority first):
|
|
4
|
+
|
|
5
|
+
1. Explicit constructor arguments
|
|
6
|
+
2. Environment variables (prefix: ``QX_``)
|
|
7
|
+
3. ``.env.{environment}`` file
|
|
8
|
+
4. ``.env.local`` file
|
|
9
|
+
5. ``.env`` file
|
|
10
|
+
6. Defaults declared on the settings class
|
|
11
|
+
|
|
12
|
+
The ``environment`` selector itself comes from the env var ``QX_ENV``
|
|
13
|
+
(``local`` | ``dev`` | ``staging`` | ``prod``). Default is ``local``.
|
|
14
|
+
|
|
15
|
+
Services subclass ``QxSettings`` and add their own sections. Avoid putting
|
|
16
|
+
service-specific knobs on the framework base — those belong to the service.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import ClassVar, Literal
|
|
24
|
+
|
|
25
|
+
from pydantic import Field
|
|
26
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
27
|
+
|
|
28
|
+
__all__ = ["AppSection", "Environment", "LoggingSection", "QxSettings"]
|
|
29
|
+
|
|
30
|
+
Environment = Literal["local", "dev", "staging", "prod", "test"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _detect_env() -> Environment:
|
|
34
|
+
raw = os.environ.get("QX_ENV", "local").lower()
|
|
35
|
+
if raw in {"local", "dev", "staging", "prod", "test"}:
|
|
36
|
+
return raw # type: ignore[return-value]
|
|
37
|
+
return "local"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _env_file_chain() -> tuple[str, ...]:
|
|
41
|
+
"""Return the chain of .env files, lowest priority first.
|
|
42
|
+
|
|
43
|
+
pydantic-settings merges multiple env files with later ones overriding
|
|
44
|
+
earlier ones. We want the most-specific file to override less-specific.
|
|
45
|
+
"""
|
|
46
|
+
env = _detect_env()
|
|
47
|
+
cwd = Path.cwd()
|
|
48
|
+
candidates: list[Path] = [
|
|
49
|
+
cwd / ".env",
|
|
50
|
+
cwd / ".env.local",
|
|
51
|
+
cwd / f".env.{env}",
|
|
52
|
+
]
|
|
53
|
+
return tuple(str(p) for p in candidates if p.exists())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AppSection(BaseSettings):
|
|
57
|
+
name: str = Field(default="qx-service")
|
|
58
|
+
version: str = Field(default="0.0.0")
|
|
59
|
+
instance_id: str | None = Field(default=None)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class LoggingSection(BaseSettings):
|
|
63
|
+
level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
|
64
|
+
json_output: bool = True
|
|
65
|
+
include_caller: bool = False
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class QxSettings(BaseSettings):
|
|
69
|
+
"""Base settings.
|
|
70
|
+
|
|
71
|
+
Services should subclass and add their own sub-models (database, redis,
|
|
72
|
+
nats, third-party integrations). Keep secrets out of defaults — pydantic
|
|
73
|
+
will load them from env at runtime.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
|
|
77
|
+
env_prefix="QX_",
|
|
78
|
+
env_nested_delimiter="__",
|
|
79
|
+
env_file=_env_file_chain(),
|
|
80
|
+
env_file_encoding="utf-8",
|
|
81
|
+
extra="ignore",
|
|
82
|
+
case_sensitive=False,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
environment: Environment = Field(default_factory=_detect_env)
|
|
86
|
+
app: AppSection = Field(default_factory=AppSection)
|
|
87
|
+
logging: LoggingSection = Field(default_factory=LoggingSection)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Request-scoped context propagated via :mod:`contextvars`.
|
|
2
|
+
|
|
3
|
+
Every request, message consumption, or scheduled job runs inside a
|
|
4
|
+
``RequestContext`` that carries the standard correlation, tracing, and tenancy
|
|
5
|
+
fields. The context is set at the edge (HTTP middleware, NATS subscriber
|
|
6
|
+
wrapper, CLI command entrypoint) and read everywhere downstream — logs,
|
|
7
|
+
spans, repositories, domain events — without threading it through every
|
|
8
|
+
function signature.
|
|
9
|
+
|
|
10
|
+
Implementation choice: ``contextvars.ContextVar`` rather than a global. This
|
|
11
|
+
makes the context **task-local** under asyncio, so concurrent requests in the
|
|
12
|
+
same process don't see each other's data. This is non-negotiable for
|
|
13
|
+
correctness.
|
|
14
|
+
|
|
15
|
+
Read pattern::
|
|
16
|
+
|
|
17
|
+
from qx.core.context import current_context
|
|
18
|
+
ctx = current_context()
|
|
19
|
+
log.info("processing", correlation_id=ctx.correlation_id)
|
|
20
|
+
|
|
21
|
+
Write pattern (edge code only)::
|
|
22
|
+
|
|
23
|
+
with request_scope(correlation_id=cid, user_id=uid):
|
|
24
|
+
await handler.run()
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from contextlib import contextmanager
|
|
30
|
+
from contextvars import ContextVar, Token
|
|
31
|
+
from dataclasses import dataclass, field, replace
|
|
32
|
+
from typing import TYPE_CHECKING, Any
|
|
33
|
+
from uuid import UUID, uuid4
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from collections.abc import Iterator
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"RequestContext",
|
|
40
|
+
"current_context",
|
|
41
|
+
"request_scope",
|
|
42
|
+
"reset_context",
|
|
43
|
+
"set_context",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True, slots=True)
|
|
48
|
+
class RequestContext:
|
|
49
|
+
"""Immutable per-request context.
|
|
50
|
+
|
|
51
|
+
All fields are optional except ``correlation_id`` and ``request_id``, which
|
|
52
|
+
we always synthesize at edges if the caller didn't provide one. Making this
|
|
53
|
+
frozen forces deliberate mutation via ``with_changes`` — accidental updates
|
|
54
|
+
inside a handler can't silently leak to siblings.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
request_id: UUID = field(default_factory=uuid4)
|
|
58
|
+
correlation_id: UUID = field(default_factory=uuid4)
|
|
59
|
+
trace_id: str | None = None
|
|
60
|
+
span_id: str | None = None
|
|
61
|
+
|
|
62
|
+
user_id: UUID | None = None
|
|
63
|
+
tenant_id: UUID | None = None
|
|
64
|
+
actor_kind: str | None = None # "user" | "service" | "system"
|
|
65
|
+
|
|
66
|
+
# Free-form bag for adapter-specific data (ip address, user-agent, request path).
|
|
67
|
+
# Don't put large objects here; this gets copied on every context inheritance.
|
|
68
|
+
attributes: dict[str, Any] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
def with_changes(self, **changes: Any) -> RequestContext:
|
|
71
|
+
return replace(self, **changes)
|
|
72
|
+
|
|
73
|
+
def child(self, **overrides: Any) -> RequestContext:
|
|
74
|
+
"""Derive a child context (e.g., when fanning out to background work).
|
|
75
|
+
|
|
76
|
+
``correlation_id`` and ``trace_id`` carry over; ``request_id`` is fresh.
|
|
77
|
+
"""
|
|
78
|
+
defaults: dict[str, Any] = {
|
|
79
|
+
"request_id": uuid4(),
|
|
80
|
+
"correlation_id": self.correlation_id,
|
|
81
|
+
"trace_id": self.trace_id,
|
|
82
|
+
"span_id": self.span_id,
|
|
83
|
+
"user_id": self.user_id,
|
|
84
|
+
"tenant_id": self.tenant_id,
|
|
85
|
+
"actor_kind": self.actor_kind,
|
|
86
|
+
"attributes": dict(self.attributes),
|
|
87
|
+
}
|
|
88
|
+
defaults.update(overrides)
|
|
89
|
+
return RequestContext(**defaults)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_EMPTY = RequestContext()
|
|
93
|
+
_var: ContextVar[RequestContext] = ContextVar("qx_request_context", default=_EMPTY)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def current_context() -> RequestContext:
|
|
97
|
+
"""Read the current context. Returns an empty default outside a scope.
|
|
98
|
+
|
|
99
|
+
Returning a default rather than raising is intentional: utility code (logging,
|
|
100
|
+
health checks) should not crash because no scope was established. Edge code
|
|
101
|
+
that *requires* a context should check ``ctx is not EMPTY`` explicitly.
|
|
102
|
+
"""
|
|
103
|
+
return _var.get()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def set_context(ctx: RequestContext) -> Token[RequestContext]:
|
|
107
|
+
"""Imperative setter. Prefer ``request_scope`` for structured use."""
|
|
108
|
+
return _var.set(ctx)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def reset_context(token: Token[RequestContext]) -> None:
|
|
112
|
+
_var.reset(token)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@contextmanager
|
|
116
|
+
def request_scope(
|
|
117
|
+
ctx: RequestContext | None = None,
|
|
118
|
+
/,
|
|
119
|
+
**overrides: Any,
|
|
120
|
+
) -> Iterator[RequestContext]:
|
|
121
|
+
"""Establish a request-scoped context.
|
|
122
|
+
|
|
123
|
+
Either pass a fully-built ``ctx``, or pass kwargs to derive a fresh context
|
|
124
|
+
from the current one (which may be the empty default).
|
|
125
|
+
"""
|
|
126
|
+
if ctx is None:
|
|
127
|
+
base = current_context()
|
|
128
|
+
ctx = base.with_changes(**overrides) if overrides else base.child()
|
|
129
|
+
token = _var.set(ctx)
|
|
130
|
+
try:
|
|
131
|
+
yield ctx
|
|
132
|
+
finally:
|
|
133
|
+
_var.reset(token)
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Audit log primitive.
|
|
2
|
+
|
|
3
|
+
Distinct from the per-entity audit *fields* (created_by, updated_by, ...) on
|
|
4
|
+
``Entity``. Those describe the *current* state; this is the *history* of state
|
|
5
|
+
changes, written as an append-only stream. The two are complementary:
|
|
6
|
+
|
|
7
|
+
- Entity audit fields are cheap to query ("who last touched this user?")
|
|
8
|
+
- Audit log entries are expensive to query but exhaustive ("show me every
|
|
9
|
+
permission change for this user in 2024")
|
|
10
|
+
|
|
11
|
+
Audit entries should be written by infrastructure (repository decorators,
|
|
12
|
+
mediator behaviors), never by domain code. The domain doesn't know it's being
|
|
13
|
+
audited — that's the point.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, ClassVar
|
|
19
|
+
from uuid import UUID, uuid4
|
|
20
|
+
|
|
21
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
22
|
+
from qx.core.utils.time import utcnow
|
|
23
|
+
|
|
24
|
+
__all__ = ["AuditAction", "AuditEntry"]
|
|
25
|
+
|
|
26
|
+
AuditAction = str # Keep as free-form string; conventions: "user.created", "policy.updated"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AuditEntry(BaseModel):
|
|
30
|
+
"""A single audit log row.
|
|
31
|
+
|
|
32
|
+
``before`` and ``after`` are arbitrary JSON-serializable snapshots. For large
|
|
33
|
+
aggregates, prefer storing a diff rather than full snapshots — the storage
|
|
34
|
+
layer can compute that. ``correlation_id`` ties multiple entries together
|
|
35
|
+
when one user action triggers cascading changes.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(frozen=True)
|
|
39
|
+
|
|
40
|
+
id: UUID = Field(default_factory=uuid4)
|
|
41
|
+
action: AuditAction
|
|
42
|
+
entity_type: str
|
|
43
|
+
entity_id: UUID
|
|
44
|
+
|
|
45
|
+
before: dict[str, Any] | None = None
|
|
46
|
+
after: dict[str, Any] | None = None
|
|
47
|
+
|
|
48
|
+
actor_id: UUID | None = None
|
|
49
|
+
actor_kind: str | None = None
|
|
50
|
+
tenant_id: UUID | None = None
|
|
51
|
+
|
|
52
|
+
correlation_id: UUID | None = None
|
|
53
|
+
trace_id: str | None = None
|
|
54
|
+
ip_address: str | None = None
|
|
55
|
+
user_agent: str | None = None
|
|
56
|
+
|
|
57
|
+
occurred_at: Any = Field(default_factory=utcnow)
|
|
58
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Event base types.
|
|
2
|
+
|
|
3
|
+
We distinguish three flavors of "something happened" messaging:
|
|
4
|
+
|
|
5
|
+
1. **DomainEvent** — in-process, fired inside a transaction, processed by other
|
|
6
|
+
parts of the same service. Handlers run synchronously with the writing
|
|
7
|
+
transaction so failures roll back. Example: "user registered" updating a
|
|
8
|
+
read model.
|
|
9
|
+
|
|
10
|
+
2. **IntegrationEvent** — cross-process, published to other services via the
|
|
11
|
+
outbox. Never dispatched in-process; always durable. Example: "user
|
|
12
|
+
registered" notifying the billing service.
|
|
13
|
+
|
|
14
|
+
3. **Notification** — in-process, fire-and-forget, side-channel. Used for
|
|
15
|
+
logging, audit, metrics. Failures in notification handlers should not affect
|
|
16
|
+
the originating command. Example: "audit log this admin action".
|
|
17
|
+
|
|
18
|
+
The three types share base fields but the dispatcher treats them differently.
|
|
19
|
+
Mixing them up is the most common CQRS mistake — when in doubt, choose the more
|
|
20
|
+
restrictive option.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any, ClassVar
|
|
26
|
+
from uuid import UUID, uuid4
|
|
27
|
+
|
|
28
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
29
|
+
from qx.core.utils.time import utcnow
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"DomainEvent",
|
|
33
|
+
"Event",
|
|
34
|
+
"IntegrationEvent",
|
|
35
|
+
"Notification",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Event(BaseModel):
|
|
40
|
+
"""Base event. Carries identity, timing, causation, correlation.
|
|
41
|
+
|
|
42
|
+
Subclasses set ``event_name`` as a stable string used by routers and the
|
|
43
|
+
outbox schema. The ``version`` field lets the same logical event evolve
|
|
44
|
+
without breaking subscribers — bump it when the payload shape changes in a
|
|
45
|
+
non-additive way.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
model_config: ClassVar[ConfigDict] = ConfigDict(
|
|
49
|
+
frozen=True,
|
|
50
|
+
extra="forbid",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
event_id: UUID = Field(default_factory=uuid4)
|
|
54
|
+
event_name: ClassVar[str] = "" # subclasses must override
|
|
55
|
+
event_version: ClassVar[int] = 1
|
|
56
|
+
|
|
57
|
+
occurred_at: Any = Field(default_factory=utcnow)
|
|
58
|
+
correlation_id: UUID | None = None
|
|
59
|
+
causation_id: UUID | None = None
|
|
60
|
+
tenant_id: UUID | None = None
|
|
61
|
+
actor_id: UUID | None = None
|
|
62
|
+
|
|
63
|
+
def __init_subclass__(cls, **kwargs: Any) -> None:
|
|
64
|
+
super().__init_subclass__(**kwargs)
|
|
65
|
+
# Enforce that concrete events define event_name. Abstract intermediates
|
|
66
|
+
# (DomainEvent, IntegrationEvent, Notification) are skipped.
|
|
67
|
+
if cls.__name__ in {"DomainEvent", "IntegrationEvent", "Notification"}:
|
|
68
|
+
return
|
|
69
|
+
if not cls.event_name:
|
|
70
|
+
raise TypeError(
|
|
71
|
+
f"{cls.__qualname__} must define a non-empty class-level "
|
|
72
|
+
"`event_name` for routing and outbox storage."
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def envelope(self) -> dict[str, Any]:
|
|
76
|
+
"""Serialization envelope used by outbox and message bus."""
|
|
77
|
+
return {
|
|
78
|
+
"event_id": str(self.event_id),
|
|
79
|
+
"event_name": self.event_name,
|
|
80
|
+
"event_version": self.event_version,
|
|
81
|
+
"occurred_at": self.occurred_at.isoformat(),
|
|
82
|
+
"correlation_id": str(self.correlation_id) if self.correlation_id else None,
|
|
83
|
+
"causation_id": str(self.causation_id) if self.causation_id else None,
|
|
84
|
+
"tenant_id": str(self.tenant_id) if self.tenant_id else None,
|
|
85
|
+
"actor_id": str(self.actor_id) if self.actor_id else None,
|
|
86
|
+
"payload": self.model_dump(
|
|
87
|
+
mode="json",
|
|
88
|
+
exclude={
|
|
89
|
+
"event_id",
|
|
90
|
+
"occurred_at",
|
|
91
|
+
"correlation_id",
|
|
92
|
+
"causation_id",
|
|
93
|
+
"tenant_id",
|
|
94
|
+
"actor_id",
|
|
95
|
+
},
|
|
96
|
+
),
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class DomainEvent(Event):
|
|
101
|
+
"""In-process event. Handlers run in the writing transaction."""
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class IntegrationEvent(Event):
|
|
105
|
+
"""Cross-process event. Published via the transactional outbox.
|
|
106
|
+
|
|
107
|
+
Subclasses should consider their payload a public schema contract: subscribers
|
|
108
|
+
in other services rely on field stability. Bump ``event_version`` for breaking
|
|
109
|
+
changes and keep both versions live during the migration window.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class Notification(Event):
|
|
114
|
+
"""In-process fire-and-forget event. Failures do not affect the originator."""
|