icdev-core 0.2.1__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.
- icdev_core-0.2.1/PKG-INFO +136 -0
- icdev_core-0.2.1/README.md +118 -0
- icdev_core-0.2.1/icdev/core/__init__.py +22 -0
- icdev_core-0.2.1/icdev/core/context.py +205 -0
- icdev_core-0.2.1/icdev/core/domain.py +265 -0
- icdev_core-0.2.1/icdev/core/paths.py +179 -0
- icdev_core-0.2.1/icdev/core/schema/tables.yaml +521 -0
- icdev_core-0.2.1/icdev/core/sensitivity.py +148 -0
- icdev_core-0.2.1/icdev_core.egg-info/PKG-INFO +136 -0
- icdev_core-0.2.1/icdev_core.egg-info/SOURCES.txt +16 -0
- icdev_core-0.2.1/icdev_core.egg-info/dependency_links.txt +1 -0
- icdev_core-0.2.1/icdev_core.egg-info/requires.txt +2 -0
- icdev_core-0.2.1/icdev_core.egg-info/top_level.txt +1 -0
- icdev_core-0.2.1/pyproject.toml +65 -0
- icdev_core-0.2.1/setup.cfg +4 -0
- icdev_core-0.2.1/tests/test_domain_declaration.py +193 -0
- icdev_core-0.2.1/tests/test_namespace_package.py +71 -0
- icdev_core-0.2.1/tests/test_sensitivity.py +56 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: icdev-core
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: ICDEV shared core: domain declaration, path resolution, identity assertion and sensitivity labelling for the ICDEV[IT] and ICDEV[FT] parents.
|
|
5
|
+
Author: Sovanna Chuon
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/icdev-ai/icdev-core
|
|
8
|
+
Keywords: icdev,domain,paths,identity,sensitivity
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Requires-Dist: PyYAML>=6.0
|
|
17
|
+
Requires-Dist: python-dotenv>=1.0
|
|
18
|
+
|
|
19
|
+
# icdev-core
|
|
20
|
+
|
|
21
|
+
The shared core of the ICDEV domain split: the small set of modules **both** parents need,
|
|
22
|
+
carved out of [`icdev-ai/icdev`](https://github.com/icdev-ai/icdev) with history preserved.
|
|
23
|
+
|
|
24
|
+
Distribution name is `icdev-core`; the **import root stays `icdev.core`**, unchanged.
|
|
25
|
+
|
|
26
|
+
## What is in here, and why only this
|
|
27
|
+
|
|
28
|
+
| module | answers |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `paths` | *where is the repo root?* The ONE resolver. |
|
|
31
|
+
| `domain` | *which parent is this checkout?* Reads `icdev_domain.yaml`. |
|
|
32
|
+
| `context` | *may this process touch this database?* `assert_identity` / `check_identity` / `load_env`. |
|
|
33
|
+
| `sensitivity` | *how sensitive is this table?* The one classification ladder. |
|
|
34
|
+
| `schema/tables.yaml` | the core-owned table manifest. |
|
|
35
|
+
|
|
36
|
+
The boundary was chosen from measurement, not from prose (`xcore-dec-01`). Two facts decided it:
|
|
37
|
+
|
|
38
|
+
- **`icdev/core` has zero dependency on `tools/`.** It imports stdlib, plus `yaml` and
|
|
39
|
+
`dotenv` *locally inside the functions that need them* so importing the module pulls in no
|
|
40
|
+
third-party code. That is what made it separable at all.
|
|
41
|
+
- **ICDEV[FT] already imports `icdev.core` in 32 files** and obtains it by putting the IT
|
|
42
|
+
checkout on `sys.path`. Replacing that `sys.path` coupling with a real dependency is the
|
|
43
|
+
whole point of this repo.
|
|
44
|
+
|
|
45
|
+
Public API, derived from what the parents actually call rather than what was declared:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
context.assert_identity 19 paths.repo_root 13
|
|
49
|
+
context.load_env 3 domain.{Domain, DomainError, load_domain} 1
|
|
50
|
+
context.check_identity 1 sensitivity (IT row_security)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## `icdev` is a namespace package, and that is load-bearing
|
|
54
|
+
|
|
55
|
+
This distribution ships `icdev/core/` and **no `icdev/__init__.py`**, so `icdev` is a PEP 420
|
|
56
|
+
namespace package.
|
|
57
|
+
|
|
58
|
+
Both this distribution and the ICDEV[IT] parent install into the same `icdev` name. A regular
|
|
59
|
+
package has ONE `__path__`, so whichever were found first would win and the other's subpackages
|
|
60
|
+
would silently vanish -- `icdev.core` unimportable in one direction, `icdev.tools` in the other.
|
|
61
|
+
That is exactly what was measured before the fix: with the parent installed editable, `icdev`
|
|
62
|
+
resolved to `C:/AI/ICDev/icdev` and installing this package beside it changed nothing at all.
|
|
63
|
+
|
|
64
|
+
`pkgutil.extend_path` in **both** distributions was tried first and rejected. It merges
|
|
65
|
+
`__path__` correctly, but only ONE `icdev/__init__.py` ever *executes* -- and when this one won,
|
|
66
|
+
the parent's `_alias_tools_namespace()` never ran: the function that makes ~1,900
|
|
67
|
+
`from tools.X import ...` imports resolve inside the parent's published wheel. An
|
|
68
|
+
order-dependent silent break of every installed deployment is worse than the shadowing it was
|
|
69
|
+
meant to fix.
|
|
70
|
+
|
|
71
|
+
Shipping none here makes the parent's the only `__init__.py`, so it runs whatever the path
|
|
72
|
+
order, and `extend_path` on the parent's side pulls `icdev/core/` in beside `icdev/tools/`.
|
|
73
|
+
|
|
74
|
+
Pinned by `tests/test_namespace_package.py` and by a CI step that inspects the built wheel --
|
|
75
|
+
because if setuptools' `namespaces` discovery ever defaults off, this repo publishes a wheel
|
|
76
|
+
with no `icdev.core` in it and nothing here notices; the ImportError surfaces in a parent.
|
|
77
|
+
|
|
78
|
+
## What is deliberately NOT in here
|
|
79
|
+
|
|
80
|
+
**`shim.py` stayed in the IT parent.** It exists solely to make `tools.X` and
|
|
81
|
+
`icdev.tools.X` resolve to one module object in IT's dual tree — and the FT parent has no
|
|
82
|
+
`tools/` directory at all. Shipping it here would put one parent's layout knowledge inside
|
|
83
|
+
the package both parents install.
|
|
84
|
+
|
|
85
|
+
**The "functional core"** — storage, kanban, llm, genesis — stays in the IT parent. The
|
|
86
|
+
carve-out cards used "core" for both that and this package; they are different by three
|
|
87
|
+
orders of magnitude (3,690 files against 6), and only this one is separable today.
|
|
88
|
+
|
|
89
|
+
## Why this repo is public
|
|
90
|
+
|
|
91
|
+
Every module here was **already public** in `icdev-ai/icdev`, so publishing them separately
|
|
92
|
+
exposes nothing new. A private core would be strictly worse: the *public* parent depends on
|
|
93
|
+
it, so installing it in public CI would require a deploy token — a genuinely new secret in a
|
|
94
|
+
public workflow, traded for hiding files that are already visible.
|
|
95
|
+
|
|
96
|
+
## Using it
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
pip install -e ../icdev-core # development, from a sibling checkout
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
pip install icdev-core # from PyPI (0.2.1+); `pip install icdev` pulls it in
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Releases are semver tags. Publishing a GitHub release runs `.github/workflows/pypi-publish.yml`,
|
|
107
|
+
which uploads to PyPI via Trusted Publishing; the tag must equal `v<pyproject version>`.
|
|
108
|
+
For air-gapped installs, mirror the wheel to a local wheelhouse and use
|
|
109
|
+
`pip install --no-index --find-links`. Pure Python, no build step.
|
|
110
|
+
|
|
111
|
+
## Acceptance — what this package can and cannot prove
|
|
112
|
+
|
|
113
|
+
The original criterion here read *"proven when ICDEV[FT] drops its `sys.path.insert` of the IT
|
|
114
|
+
checkout and installs this package instead."* **That is not reachable, and stating it made a
|
|
115
|
+
finished carve-out look permanently incomplete.** Measured on ICDEV[FT] 2026-08-27:
|
|
116
|
+
|
|
117
|
+
| ICDEV[FT] modules importing | count | supplied by |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| `icdev.core.*` | 32 | **this package** |
|
|
120
|
+
| `tools.*` | 72 | only the ICDEV[IT] checkout |
|
|
121
|
+
|
|
122
|
+
`tools` exists because ICDEV[IT]'s `icdev/__init__.py` binds it to `icdev.tools`. This package
|
|
123
|
+
ships `icdev/core/` and deliberately nothing else, so it cannot supply it and installing it
|
|
124
|
+
cannot remove that checkout.
|
|
125
|
+
|
|
126
|
+
**What IS achieved, and is verified:**
|
|
127
|
+
|
|
128
|
+
- ICDEV[IT] no longer ships `icdev/core` and depends on this distribution (`xcore-cut-02`).
|
|
129
|
+
- Both parents pin a tag, never a branch, and a parent's own gate fails if it calls a symbol the
|
|
130
|
+
pinned core does not export (`coherence_checker --check core_api`).
|
|
131
|
+
- A change here is proven against ICDEV[IT] **before** merge by `core-compat.yml`, and against
|
|
132
|
+
ICDEV[FT] daily by the matching workflow in that repository (`xcore-compat-01`).
|
|
133
|
+
|
|
134
|
+
**What is still outstanding:** ICDEV[FT]'s 72 `tools.*` imports. Removing that coupling is its
|
|
135
|
+
own piece of work — a second extraction, or repointing those callers — and is not a side effect
|
|
136
|
+
of this package existing.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# icdev-core
|
|
2
|
+
|
|
3
|
+
The shared core of the ICDEV domain split: the small set of modules **both** parents need,
|
|
4
|
+
carved out of [`icdev-ai/icdev`](https://github.com/icdev-ai/icdev) with history preserved.
|
|
5
|
+
|
|
6
|
+
Distribution name is `icdev-core`; the **import root stays `icdev.core`**, unchanged.
|
|
7
|
+
|
|
8
|
+
## What is in here, and why only this
|
|
9
|
+
|
|
10
|
+
| module | answers |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `paths` | *where is the repo root?* The ONE resolver. |
|
|
13
|
+
| `domain` | *which parent is this checkout?* Reads `icdev_domain.yaml`. |
|
|
14
|
+
| `context` | *may this process touch this database?* `assert_identity` / `check_identity` / `load_env`. |
|
|
15
|
+
| `sensitivity` | *how sensitive is this table?* The one classification ladder. |
|
|
16
|
+
| `schema/tables.yaml` | the core-owned table manifest. |
|
|
17
|
+
|
|
18
|
+
The boundary was chosen from measurement, not from prose (`xcore-dec-01`). Two facts decided it:
|
|
19
|
+
|
|
20
|
+
- **`icdev/core` has zero dependency on `tools/`.** It imports stdlib, plus `yaml` and
|
|
21
|
+
`dotenv` *locally inside the functions that need them* so importing the module pulls in no
|
|
22
|
+
third-party code. That is what made it separable at all.
|
|
23
|
+
- **ICDEV[FT] already imports `icdev.core` in 32 files** and obtains it by putting the IT
|
|
24
|
+
checkout on `sys.path`. Replacing that `sys.path` coupling with a real dependency is the
|
|
25
|
+
whole point of this repo.
|
|
26
|
+
|
|
27
|
+
Public API, derived from what the parents actually call rather than what was declared:
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
context.assert_identity 19 paths.repo_root 13
|
|
31
|
+
context.load_env 3 domain.{Domain, DomainError, load_domain} 1
|
|
32
|
+
context.check_identity 1 sensitivity (IT row_security)
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## `icdev` is a namespace package, and that is load-bearing
|
|
36
|
+
|
|
37
|
+
This distribution ships `icdev/core/` and **no `icdev/__init__.py`**, so `icdev` is a PEP 420
|
|
38
|
+
namespace package.
|
|
39
|
+
|
|
40
|
+
Both this distribution and the ICDEV[IT] parent install into the same `icdev` name. A regular
|
|
41
|
+
package has ONE `__path__`, so whichever were found first would win and the other's subpackages
|
|
42
|
+
would silently vanish -- `icdev.core` unimportable in one direction, `icdev.tools` in the other.
|
|
43
|
+
That is exactly what was measured before the fix: with the parent installed editable, `icdev`
|
|
44
|
+
resolved to `C:/AI/ICDev/icdev` and installing this package beside it changed nothing at all.
|
|
45
|
+
|
|
46
|
+
`pkgutil.extend_path` in **both** distributions was tried first and rejected. It merges
|
|
47
|
+
`__path__` correctly, but only ONE `icdev/__init__.py` ever *executes* -- and when this one won,
|
|
48
|
+
the parent's `_alias_tools_namespace()` never ran: the function that makes ~1,900
|
|
49
|
+
`from tools.X import ...` imports resolve inside the parent's published wheel. An
|
|
50
|
+
order-dependent silent break of every installed deployment is worse than the shadowing it was
|
|
51
|
+
meant to fix.
|
|
52
|
+
|
|
53
|
+
Shipping none here makes the parent's the only `__init__.py`, so it runs whatever the path
|
|
54
|
+
order, and `extend_path` on the parent's side pulls `icdev/core/` in beside `icdev/tools/`.
|
|
55
|
+
|
|
56
|
+
Pinned by `tests/test_namespace_package.py` and by a CI step that inspects the built wheel --
|
|
57
|
+
because if setuptools' `namespaces` discovery ever defaults off, this repo publishes a wheel
|
|
58
|
+
with no `icdev.core` in it and nothing here notices; the ImportError surfaces in a parent.
|
|
59
|
+
|
|
60
|
+
## What is deliberately NOT in here
|
|
61
|
+
|
|
62
|
+
**`shim.py` stayed in the IT parent.** It exists solely to make `tools.X` and
|
|
63
|
+
`icdev.tools.X` resolve to one module object in IT's dual tree — and the FT parent has no
|
|
64
|
+
`tools/` directory at all. Shipping it here would put one parent's layout knowledge inside
|
|
65
|
+
the package both parents install.
|
|
66
|
+
|
|
67
|
+
**The "functional core"** — storage, kanban, llm, genesis — stays in the IT parent. The
|
|
68
|
+
carve-out cards used "core" for both that and this package; they are different by three
|
|
69
|
+
orders of magnitude (3,690 files against 6), and only this one is separable today.
|
|
70
|
+
|
|
71
|
+
## Why this repo is public
|
|
72
|
+
|
|
73
|
+
Every module here was **already public** in `icdev-ai/icdev`, so publishing them separately
|
|
74
|
+
exposes nothing new. A private core would be strictly worse: the *public* parent depends on
|
|
75
|
+
it, so installing it in public CI would require a deploy token — a genuinely new secret in a
|
|
76
|
+
public workflow, traded for hiding files that are already visible.
|
|
77
|
+
|
|
78
|
+
## Using it
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install -e ../icdev-core # development, from a sibling checkout
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
pip install icdev-core # from PyPI (0.2.1+); `pip install icdev` pulls it in
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Releases are semver tags. Publishing a GitHub release runs `.github/workflows/pypi-publish.yml`,
|
|
89
|
+
which uploads to PyPI via Trusted Publishing; the tag must equal `v<pyproject version>`.
|
|
90
|
+
For air-gapped installs, mirror the wheel to a local wheelhouse and use
|
|
91
|
+
`pip install --no-index --find-links`. Pure Python, no build step.
|
|
92
|
+
|
|
93
|
+
## Acceptance — what this package can and cannot prove
|
|
94
|
+
|
|
95
|
+
The original criterion here read *"proven when ICDEV[FT] drops its `sys.path.insert` of the IT
|
|
96
|
+
checkout and installs this package instead."* **That is not reachable, and stating it made a
|
|
97
|
+
finished carve-out look permanently incomplete.** Measured on ICDEV[FT] 2026-08-27:
|
|
98
|
+
|
|
99
|
+
| ICDEV[FT] modules importing | count | supplied by |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `icdev.core.*` | 32 | **this package** |
|
|
102
|
+
| `tools.*` | 72 | only the ICDEV[IT] checkout |
|
|
103
|
+
|
|
104
|
+
`tools` exists because ICDEV[IT]'s `icdev/__init__.py` binds it to `icdev.tools`. This package
|
|
105
|
+
ships `icdev/core/` and deliberately nothing else, so it cannot supply it and installing it
|
|
106
|
+
cannot remove that checkout.
|
|
107
|
+
|
|
108
|
+
**What IS achieved, and is verified:**
|
|
109
|
+
|
|
110
|
+
- ICDEV[IT] no longer ships `icdev/core` and depends on this distribution (`xcore-cut-02`).
|
|
111
|
+
- Both parents pin a tag, never a branch, and a parent's own gate fails if it calls a symbol the
|
|
112
|
+
pinned core does not export (`coherence_checker --check core_api`).
|
|
113
|
+
- A change here is proven against ICDEV[IT] **before** merge by `core-compat.yml`, and against
|
|
114
|
+
ICDEV[FT] daily by the matching workflow in that repository (`xcore-compat-01`).
|
|
115
|
+
|
|
116
|
+
**What is still outstanding:** ICDEV[FT]'s 72 `tools.*` imports. Removing that coupling is its
|
|
117
|
+
own piece of work — a second extraction, or repointing those callers — and is not a side effect
|
|
118
|
+
of this package existing.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# CUI // SP-CTI
|
|
2
|
+
"""ICDEV core contract — the seam between the domain-neutral kernel and a parent.
|
|
3
|
+
|
|
4
|
+
A parent (ICDEV[IT], ICDEV[FT], ...) declares itself in an ``icdev_domain.yaml``
|
|
5
|
+
at its repository root. This package reads that declaration and answers the
|
|
6
|
+
three questions every kernel module used to answer for itself, 2,054 times
|
|
7
|
+
over, with ``Path(__file__).resolve().parent...``:
|
|
8
|
+
|
|
9
|
+
* :mod:`icdev.core.paths` — where is the repository root and its data?
|
|
10
|
+
* :mod:`icdev.core.domain` — which domain is this, and what did it declare?
|
|
11
|
+
* :mod:`icdev.core.context` — is this process allowed to run HERE, against
|
|
12
|
+
THIS database? (``assert_identity``)
|
|
13
|
+
|
|
14
|
+
Nothing in this package imports anything outside the standard library and
|
|
15
|
+
PyYAML, so it can be imported before ``tools.db.storage`` and from either the
|
|
16
|
+
``tools.*`` shim or the ``icdev.tools.*`` canonical namespace.
|
|
17
|
+
|
|
18
|
+
Programme: docs/programmes/icdev-domain-split.md (xit-decl-01).
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
__all__ = ["paths", "domain", "context"]
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# CUI // SP-CTI
|
|
2
|
+
"""Process identity: is THIS process allowed to run HERE, against THIS database?
|
|
3
|
+
|
|
4
|
+
Two ICDEV parents run on one machine, share one shell and one PostgreSQL
|
|
5
|
+
server, and both read ``<PREFIX>_PG_DATABASE`` / ``<PREFIX>_DATABASE_URL``
|
|
6
|
+
from the process environment. An ICDEV[FT] session that inherits ICDEV[IT]'s
|
|
7
|
+
``.env`` would open ``icdev`` and write trading rows into it; nothing today
|
|
8
|
+
would notice. :func:`assert_identity` is the refusal, called at the start of
|
|
9
|
+
every long-lived or state-changing entry point (dashboard, genesis daemon,
|
|
10
|
+
``tools/db/migrate.py``, ``tools/kanban/cli.py``).
|
|
11
|
+
|
|
12
|
+
The check is FAIL-CLOSED ON A DECLARED MISMATCH and NEVER on silence: a
|
|
13
|
+
process whose env names no database at all is reported ``unmeasured`` and
|
|
14
|
+
allowed through, because SQLite-only deployments, CI and tests legitimately
|
|
15
|
+
run without one. A declaration that lists no ``db.databases`` asserts
|
|
16
|
+
nothing and is likewise ``unmeasured``.
|
|
17
|
+
|
|
18
|
+
python -m icdev.core.context --check # human summary, exit 1 on mismatch
|
|
19
|
+
python -m icdev.core.context --check --json
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
from dataclasses import asdict, dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from urllib.parse import urlsplit
|
|
30
|
+
|
|
31
|
+
from icdev.core import paths as core_paths
|
|
32
|
+
from icdev.core.domain import Domain, DomainError, load_domain
|
|
33
|
+
|
|
34
|
+
IDENTITY_GUARD_ENV = "ICDEV_IDENTITY_GUARD" # =0 -> report only, never refuse
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class IdentityMismatch(RuntimeError):
|
|
38
|
+
"""The process environment names a database this parent did not declare."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class IdentityReport:
|
|
43
|
+
domain_key: str
|
|
44
|
+
domain_name: str
|
|
45
|
+
domain_source: str
|
|
46
|
+
root: str
|
|
47
|
+
database_declared: tuple[str, ...]
|
|
48
|
+
database_observed: str | None
|
|
49
|
+
database_source: str | None # name_env | dsn_env | None
|
|
50
|
+
verdict: str # match | mismatch | unmeasured
|
|
51
|
+
enforced: bool
|
|
52
|
+
detail: str
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict:
|
|
55
|
+
d = asdict(self)
|
|
56
|
+
d["database_declared"] = list(self.database_declared)
|
|
57
|
+
return d
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _database_from_dsn(dsn: str) -> str | None:
|
|
61
|
+
"""Return the database name from a libpq URL, or None when it has none."""
|
|
62
|
+
try:
|
|
63
|
+
parts = urlsplit(dsn)
|
|
64
|
+
except ValueError:
|
|
65
|
+
return None
|
|
66
|
+
if parts.scheme not in ("postgresql", "postgres"):
|
|
67
|
+
return None
|
|
68
|
+
name = parts.path.lstrip("/")
|
|
69
|
+
return name or None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def observed_database(domain: Domain, environ: os._Environ | dict | None = None) -> tuple[str | None, str | None]:
|
|
73
|
+
"""Return ``(database_name, which_env_var)`` as the process env declares it.
|
|
74
|
+
|
|
75
|
+
``name_env`` wins over ``dsn_env`` because that is the precedence
|
|
76
|
+
``tools/db/storage.py`` gives ``ICDEV_PG_DATABASE`` when both are set for
|
|
77
|
+
the keyword form; a DSN's path is consulted only when the name is absent.
|
|
78
|
+
"""
|
|
79
|
+
env = os.environ if environ is None else environ
|
|
80
|
+
name = (env.get(domain.db.name_env) or "").strip()
|
|
81
|
+
if name:
|
|
82
|
+
return name, domain.db.name_env
|
|
83
|
+
dsn = (env.get(domain.db.dsn_env) or "").strip()
|
|
84
|
+
if dsn:
|
|
85
|
+
parsed = _database_from_dsn(dsn)
|
|
86
|
+
if parsed:
|
|
87
|
+
return parsed, domain.db.dsn_env
|
|
88
|
+
return None, None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def load_env(root: Path | None = None) -> Path | None:
|
|
92
|
+
"""Load ``<root>/.env`` (the PARENT's, never cwd's). Returns the path loaded."""
|
|
93
|
+
root = root or core_paths.repo_root()
|
|
94
|
+
env_file = root / ".env"
|
|
95
|
+
if not env_file.is_file():
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
from dotenv import load_dotenv
|
|
99
|
+
except ImportError: # pragma: no cover — dotenv is a declared dependency
|
|
100
|
+
return None
|
|
101
|
+
load_dotenv(env_file, override=False)
|
|
102
|
+
return env_file
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_identity(
|
|
106
|
+
*,
|
|
107
|
+
anchor: str | os.PathLike[str] | None = None,
|
|
108
|
+
domain: Domain | None = None,
|
|
109
|
+
environ: dict | None = None,
|
|
110
|
+
) -> IdentityReport:
|
|
111
|
+
"""Compute the identity verdict without raising."""
|
|
112
|
+
dom = domain or load_domain(anchor=anchor)
|
|
113
|
+
observed, which = observed_database(dom, environ)
|
|
114
|
+
declared = tuple(dom.db.databases)
|
|
115
|
+
enforced = os.environ.get(IDENTITY_GUARD_ENV, "1").strip().lower() not in ("0", "false", "no", "monitor")
|
|
116
|
+
if not declared:
|
|
117
|
+
verdict, detail = "unmeasured", "the declaration lists no db.databases, so it asserts nothing"
|
|
118
|
+
elif observed is None:
|
|
119
|
+
verdict, detail = "unmeasured", (
|
|
120
|
+
f"neither {dom.db.name_env} nor {dom.db.dsn_env} names a database in this process"
|
|
121
|
+
)
|
|
122
|
+
elif observed in declared:
|
|
123
|
+
verdict, detail = "match", f"{which}={observed} is declared by {dom.key}"
|
|
124
|
+
else:
|
|
125
|
+
verdict, detail = "mismatch", (
|
|
126
|
+
f"{which} names database {observed!r} but domain {dom.key!r} declares only "
|
|
127
|
+
f"{list(declared)} — this process is running against another parent's database"
|
|
128
|
+
)
|
|
129
|
+
return IdentityReport(
|
|
130
|
+
domain_key=dom.key,
|
|
131
|
+
domain_name=dom.name,
|
|
132
|
+
domain_source=dom.source,
|
|
133
|
+
root=str(dom.root),
|
|
134
|
+
database_declared=declared,
|
|
135
|
+
database_observed=observed,
|
|
136
|
+
database_source=which,
|
|
137
|
+
verdict=verdict,
|
|
138
|
+
enforced=enforced,
|
|
139
|
+
detail=detail,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def assert_identity(
|
|
144
|
+
*,
|
|
145
|
+
anchor: str | os.PathLike[str] | None = None,
|
|
146
|
+
domain: Domain | None = None,
|
|
147
|
+
environ: dict | None = None,
|
|
148
|
+
) -> IdentityReport:
|
|
149
|
+
"""Refuse (``IdentityMismatch``) on a declared mismatch; return the report otherwise.
|
|
150
|
+
|
|
151
|
+
Stand it down with ``ICDEV_IDENTITY_GUARD=0`` (the report is still
|
|
152
|
+
computed and returned, so a caller can log it), never with a shell
|
|
153
|
+
neutraliser.
|
|
154
|
+
"""
|
|
155
|
+
report = check_identity(anchor=anchor, domain=domain, environ=environ)
|
|
156
|
+
if report.verdict == "mismatch" and report.enforced:
|
|
157
|
+
raise IdentityMismatch(report.detail)
|
|
158
|
+
return report
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def describe(anchor: str | os.PathLike[str] | None = None) -> dict:
|
|
162
|
+
"""Everything ``icdev status`` prints about where and who this process is."""
|
|
163
|
+
out: dict = {"paths": core_paths.describe(anchor)}
|
|
164
|
+
try:
|
|
165
|
+
dom = load_domain(anchor=anchor)
|
|
166
|
+
except DomainError as exc:
|
|
167
|
+
out["domain"] = None
|
|
168
|
+
out["error"] = str(exc)
|
|
169
|
+
return out
|
|
170
|
+
out["domain"] = dom.to_dict()
|
|
171
|
+
out["identity"] = check_identity(domain=dom).to_dict()
|
|
172
|
+
return out
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main(argv: list[str] | None = None) -> int:
|
|
176
|
+
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
177
|
+
ap.add_argument("--check", action="store_true", help="exit 1 on a declared mismatch")
|
|
178
|
+
ap.add_argument("--json", action="store_true")
|
|
179
|
+
ap.add_argument("--no-env", action="store_true", help="do not load <root>/.env first")
|
|
180
|
+
args = ap.parse_args(argv)
|
|
181
|
+
|
|
182
|
+
if not args.no_env:
|
|
183
|
+
load_env()
|
|
184
|
+
try:
|
|
185
|
+
info = describe()
|
|
186
|
+
except DomainError as exc:
|
|
187
|
+
print(f"domain declaration error: {exc}", file=sys.stderr)
|
|
188
|
+
return 2
|
|
189
|
+
if args.json:
|
|
190
|
+
print(json.dumps(info, indent=2))
|
|
191
|
+
else:
|
|
192
|
+
d = info.get("domain") or {}
|
|
193
|
+
ident = info.get("identity") or {}
|
|
194
|
+
print(f"domain : {d.get('key')} ({d.get('name')}) from {d.get('source')}")
|
|
195
|
+
print(f"root : {info['paths']['root']} [{info['paths']['source']}]")
|
|
196
|
+
print(f"database : declared {d.get('db', {}).get('databases')} observed "
|
|
197
|
+
f"{ident.get('database_observed')!r} via {ident.get('database_source')}")
|
|
198
|
+
print(f"identity : {ident.get('verdict', 'unknown').upper()} — {ident.get('detail', info.get('error'))}")
|
|
199
|
+
if args.check and (info.get("identity") or {}).get("verdict") == "mismatch":
|
|
200
|
+
return 1
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__": # pragma: no cover
|
|
205
|
+
raise SystemExit(main())
|