[project]
name = "mgf-common"
version = "0.50.0"
description = "Shared infrastructure (typed exceptions, central config, structured logging + tracing) for MGF projects"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
authors = [
    { name = "Bassam Alsanie" },
    { name = "mgf-common contributors" },
]
keywords = [
    "logging",
    "configuration",
    "exceptions",
    "observability",
    "opentelemetry",
    "pydantic-settings",
]
classifiers = [
    "Development Status :: 3 - Alpha",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Operating System :: POSIX :: Linux",
    "Operating System :: MacOS",
    "Operating System :: Microsoft :: Windows",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Programming Language :: Python :: 3.13",
    "Topic :: Software Development :: Libraries",
    "Topic :: System :: Logging",
    "Typing :: Typed",
]

# Core dependencies. Kept tight on purpose — every dep here ships
# transitively into every consumer project. Upper bounds pinned at
# the next MAJOR per RA-05 — a breaking MAJOR-bump in any of these
# would propagate into every consumer of every sibling. Bound
# walks forward with each tested upgrade.
dependencies = [
    # Kept tiny on purpose. mgf-common's CORE (typed exceptions, layered
    # config, structured logging + tracing, vault, process mgmt) is
    # pure-Python with NO `mgf-*` runtime dep — so a consumer resolves the
    # cornerstone with zero Codeberg transitive deps. The engineering-
    # standards corpus (`mgf-standard`) is an OPTIONAL extra
    # (`mgf-common[standards]`, below): it backs only the federation CLI
    # (`standards` / `agents` / `catch-up` / `sync-prompt`), which degrade
    # gracefully (print an actionable error) when it is absent.
    #
    # It was a HARD dep through 0.45.0 — but that made `mgf-common 0.45.x`
    # unresolvable for consumers: `mgf-standard` is Codeberg-only, and
    # uv does NOT apply `[tool.uv.sources]` index-pinning to *transitive*
    # deps, so it fell back to public PyPI (<=0.1.4), failed `mgf-common`
    # 0.45's `>=0.1.6`, and backtracked the cornerstone off 0.45. Demoting
    # it to an extra removes that transitive burden from the core (0.45.1).
    "pydantic>=2.6,<3",
    "pydantic-settings>=2.2,<3",
    "pyyaml>=6.0,<8",
]

[project.optional-dependencies]
# The federation CLI corpus (OPTIONAL — the fix in 0.45.1). `mgf-common
# standards / agents / catch-up / sync-prompt` surface the canonical rules +
# federation registry through `mgf.standard` (standards_dir / libraries /
# read_recipe). All four degrade gracefully — they print an actionable error
# when `mgf-standard` is not importable — so it is opt-in:
#     pip install 'mgf-common[standards]'
# It is Codeberg-only and is mapped to the `codeberg-mgf` index below (a
# direct extra dep, so uv's [tool.uv.sources] pin actually applies — unlike a
# transitive dep). >=0.1.6 carries PACKAGE_DISTRIBUTION.md (PKG-01..12) + the
# live registry; <0.2 per the 0.1.x window.
standards = [
    "mgf-standard>=0.1.6,<0.2",
]
# Tier C observability — fully opt-in. Code paths that import these
# names use lazy imports (try / except ImportError) so the package
# is usable without the extra installed.
observability = [
    "opentelemetry-api>=1.27,<2",
    "opentelemetry-sdk>=1.27,<2",
    "opentelemetry-exporter-otlp-proto-http>=1.27,<2",
    "opentelemetry-instrumentation-httpx>=0.48b0,<1",
    "sentry-sdk>=2.0,<3",
]
# Vault — system-keyring + Fernet-encrypted-file backends. Pulled
# in lazily by mgf.common.vault.KeyringVault and EncryptedFileVault
# only when those backends are constructed; mgf.common.vault.EnvVault
# is stdlib-only and works without the extra.
vault = [
    "keyring>=24,<26",
    "cryptography>=42,<48",
]
# Async HTTP client adapters were extracted to the sibling package
# `mgf-http` at v0.29.0 per ASPIRE-06 (federation split plan, see
# docs/inprogress/federation_roadmap.md). Consumers wanting the typed
# httpx wrapper install `mgf-http` directly:
#
#     pip install mgf-http              # was: pip install 'mgf-common[http]'
#     pip install 'mgf-http[test]'      # was: 'mgf-common[http-test]'
#
# Migration map in docs/cutover/v0.29.0.md. The HttpClientError
# class was renamed to HttpTransportError at the cutover to
# disambiguate from the HTTP-01 hierarchy parent (which keeps the
# HttpClientError name in mgf.common.exceptions).
# FastAPI integration adapters were extracted to the sibling
# package `mgf-fastapi` at v0.28.0 per ASPIRE-06 (federation split
# plan, see docs/inprogress/federation_roadmap.md). Consumers wanting
# the FastAPI surface install `mgf-fastapi` directly:
#
#     pip install mgf-fastapi          # was: pip install 'mgf-common[fastapi]'
#     pip install 'mgf-fastapi[testing]'   # was: 'mgf-common[fastapi-test]'
#
# Migration map in docs/cutover/v0.28.0.md.
# Async SQLAlchemy + alembic adapters were extracted to the sibling
# packages `mgf-sqlalchemy` and `mgf-alembic` at v0.30.0 per ASPIRE-06
# (federation split plan, see docs/inprogress/federation_roadmap.md).
# Consumers wanting the surface install the siblings directly:
#
#     pip install mgf-sqlalchemy             # was: pip install 'mgf-common[db]'
#     pip install 'mgf-sqlalchemy[test]'     # was: 'mgf-common[db-test]'
#     pip install mgf-alembic                # was: pip install 'mgf-common[alembic]'
#     pip install 'mgf-alembic[test]'        # was: 'mgf-common[alembic-test]'
#
# The FastAPI-shaped helpers `get_session` / `setup_db` (previously
# in `mgf.common.db`) moved to `mgf.fastapi.db` (gated behind
# `mgf-fastapi[sqlalchemy]`) so the pure-SQLAlchemy sibling stays
# framework-agnostic per the federation roadmap.
#
# Migration map in docs/cutover/v0.30.0.md.
# Django integration adapters were extracted to the sibling
# package `mgf-django` at v0.31.0 per ASPIRE-06 (federation split
# plan, see docs/inprogress/federation_roadmap.md). Consumers wanting
# the Django surface install `mgf-django` directly:
#
#     pip install mgf-django                # was: pip install 'mgf-common[django]'
#
# Migration map in docs/cutover/v0.31.0.md. The Django app label
# also rebrands from "mgf_common" to "mgf_django" at the cutover
# (Django labels can't contain dots).
dev = [
    "pytest>=8.0,<10",
    "pytest-cov>=5.0,<8",
    # Async testing per rule TS-12 — required to test
    # mgf.common.progress.asyncio.AsyncCancellationToken and any
    # future async surface.
    "pytest-asyncio>=0.23,<2",
    # Per-test timeout + thread-stack dump on hang (TS-23 — the
    # supervisor runs pytest as a subprocess with this wired so a
    # hung test produces a stack-trace bundle rather than blocking
    # CI for half an hour).
    "pytest-timeout>=2.3,<3",
    # Federation test supervisor — extracted from
    # mgf-common/scripts/_mtest/ on 2026-05-26 after all 7 siblings
    # adopted it via copy-paste. Replaces the in-tree copy. The
    # supervisor surfaces via the `mgf-test-supervisor` console
    # script (alias: `mtest`) and via `mgf-fed test`. PyPI
    # publish landed 2026-05-26 at v0.1.2; the `git+ssh://` source
    # + the `[tool.hatch.metadata] allow-direct-references` opt-in
    # both got dropped in mgf-common 0.41.1 along with this comment.
    # Floor is >=0.1.9 (federation cohort for mgf-common 0.44): the
    # supervisor↔mgf-common alignment contract (tests/unit/testing/
    # test_supervisor_alignment.py) requires the 1.1.0 record schema —
    # the nullable `perf` field (TS-28, supervisor 0.1.8). The M2
    # Wave-2 surface (slow-less MARKER_TIER + unified TierBudget field
    # names, supervisor 0.1.18) is feature-detected by the alignment
    # suite, which skips loudly against a pre-Wave-2 supervisor —
    # the resolver picks 0.1.18+ up automatically once published.
    "mgf-test-supervisor>=0.1.9,<0.2",
    # The standards corpus — in `dev` so mgf-common's own federation-CLI
    # tests (standards / agents / catch-up / sync-prompt) run against the
    # real corpus. At runtime it is the OPTIONAL `[standards]` extra above.
    "mgf-standard>=0.1.6,<0.2",
    "hypothesis>=6.100,<7",
    "ruff>=0.4,<0.16",
    "mypy>=1.10,<2",
    "types-PyYAML>=6.0,<8",
    # Layering enforcement (rule DP-01) — keeps mgf.common a leaf,
    # forbids any upward dependency on consumer projects.
    "import-linter>=2.0,<3",
]

[project.urls]
Homepage = "https://codeberg.org/magogi-admin/mgf-common"
Issues = "https://codeberg.org/magogi-admin/mgf-common/issues"

# v0.47 federation reorg: the diagnostic console-script moved to
# mgf-fed (`pip install 'mgf-fed[cli]'` → `mgf-fed explain` /
# `mgf-fed app-doctor` / …). mgf-common is now a pure importable
# library and ships no console-script of its own.

# Pytest plugin. Auto-registered for any project that installs
# mgf-common; consumers opt out with ``-p no:mgf_common``.
# Ships fixtures: mgf_settings, mgf_context, mgf_context_with_capture,
# redaction_recorder, mgf_otel_provider (session-scoped).
[project.entry-points."pytest11"]
mgf_common = "mgf.common.pytest_plugin"

[build-system]
requires = ["hatchling>=1.21"]
build-backend = "hatchling.build"

# PEP 420 implicit namespace package. The wheel ships ``src/mgf/``
# WITHOUT an ``__init__.py`` at the top so future ``mgf.<sibling>``
# projects can contribute their own subpackages under the same
# top-level ``mgf`` namespace (e.g., a future ``mgf.cli``).
[tool.hatch.build.targets.wheel]
packages = ["src/mgf"]

# The engineering-standards corpus is no longer shipped from this repo.
# It moved to the `mgf-standard` sibling (the runtime dependency
# declared above); `mgf-common standards <topic>` now resolves it through
# `mgf.standard.standards_dir()`. See docs/cutover for the migration.
# (Was: [tool.hatch.build.targets.wheel.force-include] "docs/standards".)

# ----------------------------------------------------------------------
# Package distribution — resolve mgf-* from the Codeberg PyPI registry
# (mgf-standard docs/standards/PACKAGE_DISTRIBUTION.md §6 · rules
# PKG-01 / PKG-06). Every `mgf-*` package is published to and resolved
# from the Codeberg registry on the `magogi-admin` account — NOT pypi.org.
# Third-party deps (pydantic, pyyaml, pytest, ruff, …) still resolve from
# public PyPI; only the `mgf-*` names mapped below are pinned to the named
# index, so a public package can never shadow an `mgf-*` sibling
# (`explicit = true` closes the dependency-confusion hole — PKG-06).
#
# Auth (never commit the token): export a read:package Codeberg token —
#     export UV_INDEX_CODEBERG_MGF_USERNAME="$CB_USER"
#     export UV_INDEX_CODEBERG_MGF_PASSWORD="$CB_TOKEN"   # read:package
# then `uv sync`. See PACKAGE_DISTRIBUTION.md §4/§6 for the full setup.
# ----------------------------------------------------------------------
[tool.ruff]
line-length = 100
target-version = "py311"
src = ["src", "tests"]
extend-exclude = [".venv", "build", "dist"]

[tool.ruff.lint]
# `×` (U+00D7 MULTIPLICATION SIGN) is intentional notation in
# technical comments / docstrings ("10k lines × 200 bytes",
# "~3× the budget"). `ℹ` (INFORMATION SOURCE) is a deliberate UX
# glyph in `doctor` output (and asserted by its tests). Declaring
# them allowed avoids scattering per-line `# noqa: RUF001/002/003`.
allowed-confusables = ["×", "ℹ"]
select = [
    "E",     # pycodestyle errors
    "W",     # pycodestyle warnings
    "F",     # pyflakes
    "I",     # isort
    "N",     # pep8-naming
    "UP",    # pyupgrade
    "B",     # bugbear
    "A",     # builtins shadowing
    "C4",    # comprehensions
    "PIE",   # misc
    "SIM",   # simplify (includes SIM115 — open() without context manager → rule DP-13)
    "RET",   # return style
    "RUF",   # ruff-specific (includes RUF006 — unawaited tasks → rule DP-11)
    "PTH",   # pathlib
    "TCH",   # type-checking imports
    "DTZ",   # naive datetime usage → rule DP-12
    "ASYNC", # asyncio correctness → rule DP-11
    "S",     # flake8-bandit (security checks: subprocess, eval, etc. → SC-* rules)
    "FA",    # flake8-future-annotations (FA100/FA102 → rule AP-08)
]
ignore = [
    "E501",   # line length — handled by formatter
    "B008",   # function call in default argument
    "SIM108", # ternary-vs-if (subjective)
    "S101",   # assert: blanket ignore covered by per-file-ignore for tests/
    "S603",   # subprocess call: covered by the run_command wrapper (rule SC-02)
]

[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = [
    "S101",  # asserts allowed in tests
    "S105",  # hardcoded "passwords" in vault test fixtures (not real secrets)
    "S106",  # hardcoded passwords in test kwargs (vault config tests)
    "S108",  # /tmp paths in test assertion strings (not real fs use)
    "S603",  # subprocess in tests/unit/test_examples.py drives the example smoke harness
    "S607",  # tests spawn standard system utils by bare name (e.g. `sleep`) — portable + intentional
    "N802",  # test function naming
    "PLR2004", # magic numbers in tests
]
"examples/**/*.py" = [
    "S101",   # asserts in examples are illustrative, not load-bearing
    "S311",   # examples may use random.* for demo simplicity (not security)
    "S603",   # subprocess used legitimately in some advanced examples
]
"scripts/package_retention.py" = [
    "S310",   # fixed-host HTTPS to the Codeberg API (PKG-08 retention tool) — not a user-controlled URL scheme
]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "lf"

# ----------------------------------------------------------------------
# mypy — strict type checking on our own code
# ----------------------------------------------------------------------
[tool.mypy]
python_version = "3.11"
mypy_path = "src"
# Explicit list — discover the "mgf.common" namespace package.
packages = ["mgf.common"]
# Required for PEP 420 namespace packages: tells mypy that
# ``src/`` is the package root, so ``src/mgf/common/_identity.py``
# is unambiguously ``mgf.common._identity`` (without this, mypy
# also sees it as ``common._identity`` via a shorter path and
# fails with "source file found twice under different module names").
explicit_package_bases = true
namespace_packages = true
strict = true
warn_unused_ignores = true
warn_redundant_casts = true
warn_return_any = true
warn_unused_configs = false
disallow_untyped_defs = true
disallow_incomplete_defs = true
no_implicit_optional = true
show_error_codes = true
pretty = true

# Optional ``observability`` extras — the OpenTelemetry SDK +
# Sentry SDK + systemd journald binding are pulled in lazily by the
# observability subpackage. The override below keeps mypy --strict
# clean on installs that do not have the extra installed (which is
# the default and the CI configuration).
[[tool.mypy.overrides]]
module = [
    "sentry_sdk",
    "opentelemetry",
    "opentelemetry.*",
    "systemd",
    "systemd.*",
    # Django ships no public py.typed; type stubs are an optional
    # third-party package. Treat django.* as Any for our internal
    # mypy run — consumers using django-stubs see real types.
    "django",
    "django.*",
]
ignore_missing_imports = true

# Allow our internal Django adapter to subclass Any-typed
# django.apps.AppConfig without violating --strict's
# disallow_subclassing_any. The adapter is a thin shim; consumers
# get real types via django-stubs in their own mypy runs.
[[tool.mypy.overrides]]
module = [
    "mgf.common.django",
    "mgf.common.django.*",
]
disallow_subclassing_any = false

# ----------------------------------------------------------------------
# pytest
# ----------------------------------------------------------------------
[tool.pytest.ini_options]
minversion = "8.0"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
    "-ra",
    "--strict-markers",
    "--strict-config",
    "--showlocals",
    # Exclude long-running marker layers from the default per-PR
    # suite. The full taxonomy is in TESTING.md §13.11 (TS-22..27
    # era). All excluded markers are runnable via explicit
    # `pytest -m <marker>` invocation; `mgf-fed test --tier N`
    # picks them up via the tier model. The default suite stays
    # ≤30 s so per-PR CI gives a fast signal.
    "-m", "not perf and not stress and not e2e and not conformance and not use_case",
]
# Per-test timeout (TS-23). 30s default — slow markers
# (stress / perf / e2e / use_case) override via
# `@pytest.mark.timeout(N)` in their files. `thread` method
# dumps every Python thread's stack on timeout, which is the
# debug-gold for a hang. `faulthandler_timeout` is 2× the
# pytest-timeout: kernel-level SIGABRT safety net if even the
# pytest-timeout thread is wedged.
timeout = 30
timeout_method = "thread"
faulthandler_timeout = 60
# pytest-asyncio: per-test event loop (the recommended default
# per TESTING.md §7.1 — async tests get clean isolation; no
# cross-test loop-state leakage). `asyncio_mode = "auto"` lets us
# write async tests without per-test `@pytest.mark.asyncio` —
# but we keep `strict` so the marker IS required and discovery
# stays explicit (deferred to "auto" if discovery noise becomes
# painful at scale).
asyncio_mode = "strict"
asyncio_default_fixture_loop_scope = "function"
filterwarnings = [
    "error",
    # Allow our own DeprecationWarnings during tests — the legacy
    # multi-call bootstrap shape (configure / setup_logging /
    # install_excepthook) is deprecated, but the legacy-shape tests
    # MUST keep passing while the shims live. Consumers see the
    # warnings via standard `-W error::DeprecationWarning` if they
    # choose. See src/mgf/common/_warnings.py for the design rationale.
    "default::mgf.common._warnings.MgfDeprecationWarning",
    # pytest-asyncio (1.3.0) leaves the function-scoped event loop of
    # each `@pytest.mark.asyncio` test unclosed. The loop — and the
    # AF_UNIX socketpair it uses for its self-pipe — is finalised by
    # the GC at an arbitrary later moment; `error` would then promote
    # that GC-time `ResourceWarning: unclosed ...` and pin it, via
    # pytest's unraisable-exception plugin, on whichever unrelated
    # test happens to be running — an order-dependent spurious
    # failure. This is a test-runner artifact: mgf-common itself
    # ships no event loops, and DP-13 (deterministic resource
    # release) governs shipped code, which is unaffected. The two
    # ignores are scoped to the exact artifact — ResourceWarning
    # from asyncio's loop finalisation, and its self-pipe sockets.
    "ignore:unclosed event loop:ResourceWarning",
    "ignore:unclosed <socket.socket:ResourceWarning",
]
# Test layer markers (v0.23.0). With ``--strict-markers``, every
# marker must be declared here. Run a layer in isolation with
# ``pytest -m smoke`` / ``pytest -m e2e`` / ``pytest -m integration``.
markers = [
    # The four organisational markers (§13.1 — tied to directory layout).
    "smoke: minimal post-install sanity (fast; no fixtures); tier-0",
    "e2e: end-to-end test exercising bootstrap + multiple subsystems; tier-3",
    "integration: cross-module integration test (slower; may launch real subprocesses)",
    # Extended marker taxonomy (§13.11 — federation superset, TS-22 tier overlay).
    "contract: wire-format + SDK-surface pins; tier-1",
    "property: invariant-under-all-inputs test (Hypothesis); tier-1",
    "regression: pin for a specific past bug; docstring cites the fixing commit; tier-1",
    "fuzz: random / generated / hostile input — asserts safety properties; tier-1",
    "concurrency: real-threads contention test; tier-2",
    "conformance: design-promise tests (G/S guarantees); release-gated; tier-2",
    "use_case: reproduces a consumer scenario from FEEDBACK paper or recipe doc; tier-2",
    "perf: performance pin — pins a hot path's per-call cost (PF-* + TS-14). Excluded from default; run via `pytest -m perf` for the nightly perf sweep; tier-3",
    "stress: sustained high-load test; tier-3; nightly only",
    # `slow` (legacy, TS-18) completed its deprecation cycle and was
    # REMOVED (M2 Wave-2, 2026-07). Use the specific tier-3 marker
    # (perf / stress / e2e) that says WHY a test is slow instead.
]

[tool.coverage.run]
source = ["src/mgf"]
branch = true
# M2 Wave-2 honest gate: the 3.12 coverage step drives the suite through
# the test SUPERVISOR (`coverage run -m mgf.test_supervisor --tier 0-2`),
# which runs pytest in child processes — subprocess patching (coverage
# >= 7.10) is what lets the gate measure them; the CI step `coverage
# combine`s the per-chunk data files before reporting. Harmless under
# plain `coverage run -m pytest` (in-process data is still the parent
# .coverage file). See TESTING.md §19.8.
patch = ["subprocess"]

[tool.coverage.report]
exclude_lines = [
    "pragma: no cover",
    "raise NotImplementedError",
    "if TYPE_CHECKING:",
    "if __name__ == .__main__.:",
]
show_missing = true
skip_covered = false
# RA-08 (TS-06): coverage floor. Set at the currently-measured
# total (80%) so any regression below this trips CI. The way to
# clear a drop is to add tests, not lower the gate. Raise this
# number when total coverage climbs; never lower it.
fail_under = 80

# mgf-fed (woodpecker generator) CI carve-outs. Lives in [tool.mgf-fed], NOT
# [tool.mgf] — the latter is MgfSettings' config source (extra="forbid"), so a
# subtable here broke MgfSettings()/bootstrap() from the repo root.
[tool.mgf-fed]
coverage_floor = 85
# Accepted vuln-scan ignores (pip-audit --ignore-vuln). GHSA-537c-gmf6-5ccf:
# cryptography <48.0.1 statically links an OpenSSL with a wheel-build advisory —
# present only in the CI tooling's transitive deps (pip-audit pulls cryptography),
# NOT in mgf-common's runtime/dev deps, and per the advisory only sdist-builders
# must act (PyPI-wheel installs are unaffected). Re-evaluate when pip-audit's pin moves.
vuln_ignore = ["GHSA-537c-gmf6-5ccf"]

[tool.uv]
index-url = "https://195.15.203.250.sslip.io/magogi/prod/+simple/"
