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.
@@ -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.
@@ -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."""