kontract 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.
- kontract-0.1.0/PKG-INFO +67 -0
- kontract-0.1.0/README.md +53 -0
- kontract-0.1.0/kontract/__init__.py +17 -0
- kontract-0.1.0/kontract/cli.py +100 -0
- kontract-0.1.0/kontract/envelope.py +36 -0
- kontract-0.1.0/kontract/errors.py +29 -0
- kontract-0.1.0/kontract/model.py +90 -0
- kontract-0.1.0/kontract/resolver.py +127 -0
- kontract-0.1.0/kontract.egg-info/PKG-INFO +67 -0
- kontract-0.1.0/kontract.egg-info/SOURCES.txt +17 -0
- kontract-0.1.0/kontract.egg-info/dependency_links.txt +1 -0
- kontract-0.1.0/kontract.egg-info/entry_points.txt +2 -0
- kontract-0.1.0/kontract.egg-info/requires.txt +6 -0
- kontract-0.1.0/kontract.egg-info/top_level.txt +1 -0
- kontract-0.1.0/pyproject.toml +28 -0
- kontract-0.1.0/setup.cfg +4 -0
- kontract-0.1.0/tests/test_cli.py +42 -0
- kontract-0.1.0/tests/test_model.py +32 -0
- kontract-0.1.0/tests/test_resolver.py +68 -0
kontract-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kontract
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cross-repo fetch of a capability's process/contract tier (ADR-BCM-URBA-0016 / ADR-DSN-0002).
|
|
5
|
+
Author: yremy
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: kpack<3,>=2.0.0
|
|
10
|
+
Requires-Dist: pyyaml<7,>=6.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
13
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
14
|
+
|
|
15
|
+
# kontract
|
|
16
|
+
|
|
17
|
+
Cross-repo fetch of a business capability's **process/contract tier** — the DDD
|
|
18
|
+
tactical model (`aggregates.yaml`, `commands.yaml`, `policies.yaml`,
|
|
19
|
+
`read-models.yaml`, `bus.yaml`, `api.yaml`, JSON schemas) that says HOW a
|
|
20
|
+
capability is actually contracted, plus the `.bcm-provenance.json` pin that says
|
|
21
|
+
which corpus commit it derives from (ADR-DSN-0001).
|
|
22
|
+
|
|
23
|
+
Sibling of [`kpack`](https://github.com/papeete-hub/kpack), sharing its transport
|
|
24
|
+
only: `kontract` depends on `kpack` as a library (`kpack.config`, `kpack.registry`,
|
|
25
|
+
`kpack.remote`, `kpack.provenance`) and adds the one resolution step kpack doesn't
|
|
26
|
+
have — a business context to the *solution* context that `refines` it
|
|
27
|
+
(ADR-GCM-URBA-0006). See
|
|
28
|
+
[`reliever-design/adr/ADR-DSN-0002`](https://github.com/papeete-foundry/reliever-design/blob/main/adr/ADR-DSN-0002-kontract-resolution.md)
|
|
29
|
+
for the design record.
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
CAP_ID (e.g. BNK.RLVR.CAP.SUP.002.BEN)
|
|
35
|
+
-> business context (segs 0-1, e.g. BNK.RLVR) -- kpack's own bootstrap --
|
|
36
|
+
-> ENTERPRISE (seg 0) -> kpack config -> registry repo -> vocab.yaml contexts:
|
|
37
|
+
-> confirm business context is registered -- new: kontract's own step --
|
|
38
|
+
-> scan contexts for kind: solution, refines: <business context> (exactly one expected)
|
|
39
|
+
-> that context's repo -> kpack.remote.resolve_repo_root (fetch + cache, same as kpack)
|
|
40
|
+
-> serve process/<CAP_ID>/ from the resolved checkout
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
No caching beyond kpack's own git-clone cache (`--cache-dir` / `--no-fetch` /
|
|
44
|
+
`--fresh`) — every call is a fresh fetch by design.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install -e ".[dev]"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
kontract fetch <CAP_ID> [--ref REF] [--repo-root PATH] [--cache-dir DIR]
|
|
56
|
+
[--no-fetch] [--fresh] [--config PATH]
|
|
57
|
+
[--registry-repo SLUG] [--registry-ref REF] [--compact]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Uses the same `~/.config/kpack/config.yaml` / `./.kpack.yaml` / `--config`
|
|
61
|
+
enterprise -> registry bootstrap as `kpack` — no separate kontract config file.
|
|
62
|
+
|
|
63
|
+
## Develop
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
make test # pytest against a fake business+solution repo pair (tests/conftest.py)
|
|
67
|
+
```
|
kontract-0.1.0/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# kontract
|
|
2
|
+
|
|
3
|
+
Cross-repo fetch of a business capability's **process/contract tier** — the DDD
|
|
4
|
+
tactical model (`aggregates.yaml`, `commands.yaml`, `policies.yaml`,
|
|
5
|
+
`read-models.yaml`, `bus.yaml`, `api.yaml`, JSON schemas) that says HOW a
|
|
6
|
+
capability is actually contracted, plus the `.bcm-provenance.json` pin that says
|
|
7
|
+
which corpus commit it derives from (ADR-DSN-0001).
|
|
8
|
+
|
|
9
|
+
Sibling of [`kpack`](https://github.com/papeete-hub/kpack), sharing its transport
|
|
10
|
+
only: `kontract` depends on `kpack` as a library (`kpack.config`, `kpack.registry`,
|
|
11
|
+
`kpack.remote`, `kpack.provenance`) and adds the one resolution step kpack doesn't
|
|
12
|
+
have — a business context to the *solution* context that `refines` it
|
|
13
|
+
(ADR-GCM-URBA-0006). See
|
|
14
|
+
[`reliever-design/adr/ADR-DSN-0002`](https://github.com/papeete-foundry/reliever-design/blob/main/adr/ADR-DSN-0002-kontract-resolution.md)
|
|
15
|
+
for the design record.
|
|
16
|
+
|
|
17
|
+
## How it works
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
CAP_ID (e.g. BNK.RLVR.CAP.SUP.002.BEN)
|
|
21
|
+
-> business context (segs 0-1, e.g. BNK.RLVR) -- kpack's own bootstrap --
|
|
22
|
+
-> ENTERPRISE (seg 0) -> kpack config -> registry repo -> vocab.yaml contexts:
|
|
23
|
+
-> confirm business context is registered -- new: kontract's own step --
|
|
24
|
+
-> scan contexts for kind: solution, refines: <business context> (exactly one expected)
|
|
25
|
+
-> that context's repo -> kpack.remote.resolve_repo_root (fetch + cache, same as kpack)
|
|
26
|
+
-> serve process/<CAP_ID>/ from the resolved checkout
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
No caching beyond kpack's own git-clone cache (`--cache-dir` / `--no-fetch` /
|
|
30
|
+
`--fresh`) — every call is a fresh fetch by design.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install -e ".[dev]"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
kontract fetch <CAP_ID> [--ref REF] [--repo-root PATH] [--cache-dir DIR]
|
|
42
|
+
[--no-fetch] [--fresh] [--config PATH]
|
|
43
|
+
[--registry-repo SLUG] [--registry-ref REF] [--compact]
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Uses the same `~/.config/kpack/config.yaml` / `./.kpack.yaml` / `--config`
|
|
47
|
+
enterprise -> registry bootstrap as `kpack` — no separate kontract config file.
|
|
48
|
+
|
|
49
|
+
## Develop
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
make test # pytest against a fake business+solution repo pair (tests/conftest.py)
|
|
53
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""kontract — cross-repo fetch of a capability's process/contract tier.
|
|
2
|
+
|
|
3
|
+
Sibling of ``kpack`` (ADR-DSN-0001's "bet"): depends on it as a library for the
|
|
4
|
+
registry/config/remote transport (``kpack.config``, ``kpack.registry``,
|
|
5
|
+
``kpack.remote``, ``kpack.provenance``) and adds the one resolution step kpack
|
|
6
|
+
doesn't have — a business context (e.g. ``BNK.RLVR``) to the *solution* context
|
|
7
|
+
that ``refines`` it (ADR-GCM-URBA-0006), then serves ``process/<CAP_ID>/`` from
|
|
8
|
+
that resolved repo.
|
|
9
|
+
|
|
10
|
+
See ``reliever-design/adr/ADR-DSN-0002`` for the design record.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
__version__ = "0.1.0"
|
|
14
|
+
|
|
15
|
+
# Envelope shape contract, mirroring kpack's own Rule 2 (SemVer, independent of
|
|
16
|
+
# corpus/solution content).
|
|
17
|
+
SCHEMA_VERSION = "1.0.0"
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""kontract command-line interface (plan §"CLI surface").
|
|
2
|
+
|
|
3
|
+
One command, mirroring kpack's own flag conventions for consistency:
|
|
4
|
+
|
|
5
|
+
kontract fetch <CAP_ID> [--ref REF] [--repo-root PATH] [--cache-dir DIR]
|
|
6
|
+
[--no-fetch] [--fresh] [--config PATH]
|
|
7
|
+
[--registry-repo SLUG] [--registry-ref REF]
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
from kpack.errors import KpackError
|
|
17
|
+
|
|
18
|
+
from . import __version__, envelope, model, resolver
|
|
19
|
+
from .errors import KontractError
|
|
20
|
+
from .resolver import Flags
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _add_global_flags(p: argparse.ArgumentParser) -> None:
|
|
24
|
+
g = p.add_argument_group("resolution")
|
|
25
|
+
g.add_argument("--ref", default="main", help="solution-repo ref (tag / 40-char SHA / branch)")
|
|
26
|
+
g.add_argument("--repo-root", help="local checkout override for the solution repo")
|
|
27
|
+
g.add_argument("--cache-dir", help="cache dir (default ~/.cache/kpack)")
|
|
28
|
+
g.add_argument("--no-fetch", action="store_true", help="skip per-invocation git fetch")
|
|
29
|
+
g.add_argument("--fresh", action="store_true", help="wipe + re-clone the cache")
|
|
30
|
+
g.add_argument("--config", help="kpack config file (enterprise -> registry)")
|
|
31
|
+
g.add_argument("--registry-repo", help="override the resolved registry repo")
|
|
32
|
+
g.add_argument("--registry-ref", help="override the registry ref (default main)")
|
|
33
|
+
p.add_argument("--compact", action="store_true", help="one-line JSON")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _flags(args: argparse.Namespace) -> Flags:
|
|
37
|
+
return Flags(
|
|
38
|
+
ref=args.ref,
|
|
39
|
+
repo_root=args.repo_root,
|
|
40
|
+
cache_dir=args.cache_dir,
|
|
41
|
+
no_fetch=args.no_fetch,
|
|
42
|
+
fresh=args.fresh,
|
|
43
|
+
config=args.config,
|
|
44
|
+
registry_repo=args.registry_repo,
|
|
45
|
+
registry_ref=args.registry_ref,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _emit(obj, compact: bool) -> None:
|
|
50
|
+
if compact:
|
|
51
|
+
json.dump(obj, sys.stdout, separators=(",", ":"), sort_keys=False, default=str)
|
|
52
|
+
else:
|
|
53
|
+
json.dump(obj, sys.stdout, indent=2, sort_keys=False, default=str)
|
|
54
|
+
sys.stdout.write("\n")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _warn(env: dict) -> None:
|
|
58
|
+
for w in env.get("warnings", []):
|
|
59
|
+
print(f"warning: {w}", file=sys.stderr)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def cmd_fetch(args) -> int:
|
|
63
|
+
resolved = resolver.resolve(args.capability_id, _flags(args))
|
|
64
|
+
body = model.get_model(resolved.solution_root, args.capability_id)
|
|
65
|
+
env = envelope.base_envelope(resolved, "fetch_process_model")
|
|
66
|
+
env.update(body)
|
|
67
|
+
_warn(env)
|
|
68
|
+
_emit(env, args.compact)
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
73
|
+
parser = argparse.ArgumentParser(
|
|
74
|
+
prog="kontract",
|
|
75
|
+
description="Cross-repo fetch of a capability's process/contract tier.",
|
|
76
|
+
)
|
|
77
|
+
parser.add_argument("--version", action="version", version=f"kontract {__version__}")
|
|
78
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
79
|
+
|
|
80
|
+
p = sub.add_parser("fetch", help="fetch a capability's process/contract model")
|
|
81
|
+
p.add_argument("capability_id")
|
|
82
|
+
_add_global_flags(p)
|
|
83
|
+
p.set_defaults(func=cmd_fetch)
|
|
84
|
+
|
|
85
|
+
return parser
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main(argv=None) -> int:
|
|
89
|
+
args = build_parser().parse_args(argv)
|
|
90
|
+
try:
|
|
91
|
+
return args.func(args)
|
|
92
|
+
except (KontractError, KpackError) as exc:
|
|
93
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
94
|
+
return getattr(exc, "exit_code", 1)
|
|
95
|
+
except BrokenPipeError: # pragma: no cover
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__": # pragma: no cover
|
|
100
|
+
sys.exit(main())
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Envelope assembly for kontract, shaped like kpack's own (design consistency).
|
|
2
|
+
|
|
3
|
+
Carries both resolved coordinates: the ``solution`` repo's provenance (reusing
|
|
4
|
+
kpack's git-derived provenance helper) and, unless ``--repo-root`` short-circuited
|
|
5
|
+
resolution, the ``registry`` binding that produced it — so a fetch is replayable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from kpack.provenance import corpus_provenance
|
|
11
|
+
|
|
12
|
+
from . import SCHEMA_VERSION, __version__
|
|
13
|
+
from .resolver import Resolved
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _solution_block(resolved: Resolved) -> dict:
|
|
17
|
+
prov = corpus_provenance(resolved.solution_root)
|
|
18
|
+
return {
|
|
19
|
+
"enterprise": resolved.business_context.split(".")[0],
|
|
20
|
+
"refines": resolved.business_context,
|
|
21
|
+
"context": resolved.solution_context,
|
|
22
|
+
"repo": resolved.solution_repo,
|
|
23
|
+
**prov,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def base_envelope(resolved: Resolved, tool: str) -> dict:
|
|
28
|
+
env = {
|
|
29
|
+
"schema_version": SCHEMA_VERSION,
|
|
30
|
+
"engine": {"name": "kontract", "version": __version__},
|
|
31
|
+
"solution": _solution_block(resolved),
|
|
32
|
+
"tool": tool,
|
|
33
|
+
}
|
|
34
|
+
if resolved.registry_repo:
|
|
35
|
+
env["registry"] = {"repo": resolved.registry_repo, "ref": resolved.registry_ref}
|
|
36
|
+
return env
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Typed errors with stable CLI exit codes (mirrors kpack.errors' convention).
|
|
2
|
+
|
|
3
|
+
Exit codes:
|
|
4
|
+
0 success
|
|
5
|
+
1 unexpected error
|
|
6
|
+
2 unknown capability / bad target / no process model
|
|
7
|
+
3 context / registry resolution error (including ambiguous refines)
|
|
8
|
+
4 remote / git failure (raised as kpack.errors.RemoteError, passed through)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class KontractError(Exception):
|
|
15
|
+
"""Base class for all kontract errors."""
|
|
16
|
+
|
|
17
|
+
exit_code = 1
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TargetError(KontractError):
|
|
21
|
+
"""Bad capability id, or no process model for it."""
|
|
22
|
+
|
|
23
|
+
exit_code = 2
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ResolutionError(KontractError):
|
|
27
|
+
"""The business -> solution context resolution failed."""
|
|
28
|
+
|
|
29
|
+
exit_code = 3
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Serve ``process/<CAP_ID>/`` from a resolved solution repo (plan step 3).
|
|
2
|
+
|
|
3
|
+
A direct port of ``kpack.process.get_model`` — small and corpus-shape-specific,
|
|
4
|
+
so a port rather than an import — with one addition kpack's version has no reason
|
|
5
|
+
to make: also read and include ``.bcm-provenance.json``. ADR-DSN-0001 makes that
|
|
6
|
+
file mandatory precisely so a consumer can see which corpus commit the contract
|
|
7
|
+
was derived from; a missing one is surfaced as a warning, not silently dropped.
|
|
8
|
+
|
|
9
|
+
Schemas live one level down, under ``process/<CAP_ID>/schemas/*.schema.json``
|
|
10
|
+
(confirmed against the real reliever-design layout) — read explicitly from that
|
|
11
|
+
subdirectory rather than only the model folder's top level.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
from .errors import TargetError
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _read_yaml(path: Path) -> dict:
|
|
25
|
+
raw = path.read_text()
|
|
26
|
+
try:
|
|
27
|
+
return {"raw": raw, "parsed": yaml.safe_load(raw), "parse_error": None}
|
|
28
|
+
except yaml.YAMLError as exc:
|
|
29
|
+
return {"raw": raw, "parsed": None, "parse_error": str(exc)}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_model(solution_root: Path, capability_id: str) -> dict:
|
|
33
|
+
solution_root = Path(solution_root)
|
|
34
|
+
base = solution_root / "process" / capability_id
|
|
35
|
+
if not base.is_dir():
|
|
36
|
+
raise TargetError(f"no process model for {capability_id}")
|
|
37
|
+
|
|
38
|
+
model, schemas, files, warnings = {}, {}, [], []
|
|
39
|
+
readme = None
|
|
40
|
+
provenance = None
|
|
41
|
+
|
|
42
|
+
for f in sorted(base.iterdir()):
|
|
43
|
+
if f.is_dir():
|
|
44
|
+
continue
|
|
45
|
+
rel = str(f.relative_to(solution_root))
|
|
46
|
+
if f.name == ".bcm-provenance.json":
|
|
47
|
+
try:
|
|
48
|
+
provenance = json.loads(f.read_text())
|
|
49
|
+
except json.JSONDecodeError as exc:
|
|
50
|
+
warnings.append(f"invalid provenance {f.name}: {exc}")
|
|
51
|
+
files.append({"path": rel, "role": "provenance"})
|
|
52
|
+
elif f.suffix in (".yaml", ".yml"):
|
|
53
|
+
model[f.stem] = _read_yaml(f)
|
|
54
|
+
files.append({"path": rel, "role": f.stem})
|
|
55
|
+
elif f.name.endswith(".schema.json"):
|
|
56
|
+
try:
|
|
57
|
+
schemas[f.name] = json.loads(f.read_text())
|
|
58
|
+
except json.JSONDecodeError as exc:
|
|
59
|
+
warnings.append(f"invalid schema {f.name}: {exc}")
|
|
60
|
+
files.append({"path": rel, "role": "schema"})
|
|
61
|
+
elif f.name.lower() == "readme.md":
|
|
62
|
+
readme = f.read_text()
|
|
63
|
+
files.append({"path": rel, "role": "readme"})
|
|
64
|
+
|
|
65
|
+
schemas_dir = base / "schemas"
|
|
66
|
+
if schemas_dir.is_dir():
|
|
67
|
+
for f in sorted(schemas_dir.iterdir()):
|
|
68
|
+
if f.name.endswith(".schema.json"):
|
|
69
|
+
rel = str(f.relative_to(solution_root))
|
|
70
|
+
try:
|
|
71
|
+
schemas[f.name] = json.loads(f.read_text())
|
|
72
|
+
except json.JSONDecodeError as exc:
|
|
73
|
+
warnings.append(f"invalid schema {f.name}: {exc}")
|
|
74
|
+
files.append({"path": rel, "role": "schema"})
|
|
75
|
+
|
|
76
|
+
if provenance is None:
|
|
77
|
+
warnings.append(
|
|
78
|
+
f"no .bcm-provenance.json for {capability_id} — this model cannot prove "
|
|
79
|
+
f"which corpus commit it derives from (ADR-DSN-0001)"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
"target": {"kind": "process_model", "id": capability_id},
|
|
84
|
+
"model": model,
|
|
85
|
+
"schemas": schemas,
|
|
86
|
+
"provenance": provenance,
|
|
87
|
+
"readme": readme,
|
|
88
|
+
"files": files,
|
|
89
|
+
"warnings": warnings,
|
|
90
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Cross-repo resolution: business capability id -> solution repo checkout.
|
|
2
|
+
|
|
3
|
+
The one resolution step kpack doesn't have (plan §"The actual gap to build", step 2):
|
|
4
|
+
|
|
5
|
+
1. Derive the capability's business context from segments 0-1 of its id.
|
|
6
|
+
2. Confirm that context is really registered (``kind: business``, or at least a
|
|
7
|
+
real registered context — never trust an unverified id shape).
|
|
8
|
+
3. Scan the same registry for the context with ``kind: solution`` whose
|
|
9
|
+
``refines`` equals that business context (ADR-GCM-URBA-0006). Exactly one is
|
|
10
|
+
expected; zero or more than one is an error, not a silent pick.
|
|
11
|
+
4. Hand that context's ``repo`` to ``kpack.remote.resolve_repo_root`` — identical
|
|
12
|
+
fetch/cache/ref semantics kpack itself uses, no new transport code.
|
|
13
|
+
|
|
14
|
+
Steps 1 (enterprise/registry bootstrap) and 4 (fetch) are reused verbatim from
|
|
15
|
+
kpack; only the ``refines`` scan in between is new.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from kpack import config as config_mod
|
|
24
|
+
from kpack import registry as registry_mod
|
|
25
|
+
from kpack import remote
|
|
26
|
+
|
|
27
|
+
from .errors import ResolutionError, TargetError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Flags:
|
|
32
|
+
ref: str = "main"
|
|
33
|
+
repo_root: str | None = None
|
|
34
|
+
cache_dir: str | None = None
|
|
35
|
+
no_fetch: bool = False
|
|
36
|
+
fresh: bool = False
|
|
37
|
+
config: str | None = None
|
|
38
|
+
registry_repo: str | None = None
|
|
39
|
+
registry_ref: str | None = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Resolved:
|
|
44
|
+
capability_id: str
|
|
45
|
+
business_context: str
|
|
46
|
+
solution_context: str | None # None when --repo-root short-circuits the registry
|
|
47
|
+
solution_repo: str | None
|
|
48
|
+
solution_root: Path
|
|
49
|
+
registry_repo: str | None
|
|
50
|
+
registry_ref: str | None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def business_context_of(capability_id: str) -> str:
|
|
54
|
+
"""The two-segment business context prefix of a capability id (ADR-GCM-URBA-0001)."""
|
|
55
|
+
parts = capability_id.split(".")
|
|
56
|
+
if len(parts) < 3:
|
|
57
|
+
raise TargetError(
|
|
58
|
+
f"{capability_id!r} is not a valid capability id "
|
|
59
|
+
f"(expected <ENTERPRISE>.<SCOPE>.<ASSET_TYPE>....)"
|
|
60
|
+
)
|
|
61
|
+
return ".".join(parts[:2])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def find_solution_context(contexts: dict, business_context: str) -> str:
|
|
65
|
+
"""The context with kind: solution and refines: business_context. Exactly one expected."""
|
|
66
|
+
business = contexts.get(business_context)
|
|
67
|
+
if business is None:
|
|
68
|
+
known = ", ".join(sorted(contexts))
|
|
69
|
+
raise ResolutionError(
|
|
70
|
+
f"context {business_context!r} is not in the authoritative registry. Known: {known}"
|
|
71
|
+
)
|
|
72
|
+
kind = business.get("kind")
|
|
73
|
+
if kind not in (None, "business"):
|
|
74
|
+
raise ResolutionError(
|
|
75
|
+
f"context {business_context!r} is registered with kind {kind!r}, expected "
|
|
76
|
+
f"'business' (or unspecified) for a capability id's context"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
matches = sorted(
|
|
80
|
+
code
|
|
81
|
+
for code, entry in contexts.items()
|
|
82
|
+
if entry.get("kind") == "solution" and entry.get("refines") == business_context
|
|
83
|
+
)
|
|
84
|
+
if not matches:
|
|
85
|
+
raise ResolutionError(
|
|
86
|
+
f"no solution context refines {business_context!r} in the registry "
|
|
87
|
+
f"(expected exactly one entry with kind: solution and refines: {business_context})"
|
|
88
|
+
)
|
|
89
|
+
if len(matches) > 1:
|
|
90
|
+
raise ResolutionError(
|
|
91
|
+
f"multiple solution contexts refine {business_context!r}: {', '.join(matches)} "
|
|
92
|
+
f"— ambiguous, expected exactly one"
|
|
93
|
+
)
|
|
94
|
+
return matches[0]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def resolve(capability_id: str, flags: Flags) -> Resolved:
|
|
98
|
+
cache = Path(flags.cache_dir) if flags.cache_dir else None
|
|
99
|
+
business_context = business_context_of(capability_id)
|
|
100
|
+
|
|
101
|
+
# --repo-root short-circuit: skip the registry entirely (dev / single checkout),
|
|
102
|
+
# same convention as kpack's own resolve_context.
|
|
103
|
+
if flags.repo_root:
|
|
104
|
+
solution_root = remote.resolve_repo_root(
|
|
105
|
+
business_context, flags.ref, cache_dir=cache, repo_root_override=flags.repo_root
|
|
106
|
+
)
|
|
107
|
+
return Resolved(capability_id, business_context, None, None, solution_root, None, None)
|
|
108
|
+
|
|
109
|
+
# Full bootstrap: enterprise -> registry -> contexts -> refines scan -> solution repo.
|
|
110
|
+
cfg = config_mod.load_config(flags.config)
|
|
111
|
+
enterprise = business_context.split(".")[0]
|
|
112
|
+
reg_repo, reg_ref = config_mod.resolve_registry(
|
|
113
|
+
cfg, enterprise, repo_override=flags.registry_repo, ref_override=flags.registry_ref
|
|
114
|
+
)
|
|
115
|
+
registry_root = remote.resolve_repo_root(
|
|
116
|
+
reg_repo, reg_ref, cache_dir=cache, no_fetch=flags.no_fetch, fresh=flags.fresh
|
|
117
|
+
)
|
|
118
|
+
contexts = registry_mod.load_contexts(registry_root)
|
|
119
|
+
solution_context = find_solution_context(contexts, business_context)
|
|
120
|
+
solution_repo = registry_mod.context_repo(contexts, solution_context)
|
|
121
|
+
solution_root = remote.resolve_repo_root(
|
|
122
|
+
solution_repo, flags.ref, cache_dir=cache, no_fetch=flags.no_fetch, fresh=flags.fresh
|
|
123
|
+
)
|
|
124
|
+
return Resolved(
|
|
125
|
+
capability_id, business_context, solution_context, solution_repo, solution_root,
|
|
126
|
+
reg_repo, reg_ref,
|
|
127
|
+
)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: kontract
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Cross-repo fetch of a capability's process/contract tier (ADR-BCM-URBA-0016 / ADR-DSN-0002).
|
|
5
|
+
Author: yremy
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: kpack<3,>=2.0.0
|
|
10
|
+
Requires-Dist: pyyaml<7,>=6.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
13
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
14
|
+
|
|
15
|
+
# kontract
|
|
16
|
+
|
|
17
|
+
Cross-repo fetch of a business capability's **process/contract tier** — the DDD
|
|
18
|
+
tactical model (`aggregates.yaml`, `commands.yaml`, `policies.yaml`,
|
|
19
|
+
`read-models.yaml`, `bus.yaml`, `api.yaml`, JSON schemas) that says HOW a
|
|
20
|
+
capability is actually contracted, plus the `.bcm-provenance.json` pin that says
|
|
21
|
+
which corpus commit it derives from (ADR-DSN-0001).
|
|
22
|
+
|
|
23
|
+
Sibling of [`kpack`](https://github.com/papeete-hub/kpack), sharing its transport
|
|
24
|
+
only: `kontract` depends on `kpack` as a library (`kpack.config`, `kpack.registry`,
|
|
25
|
+
`kpack.remote`, `kpack.provenance`) and adds the one resolution step kpack doesn't
|
|
26
|
+
have — a business context to the *solution* context that `refines` it
|
|
27
|
+
(ADR-GCM-URBA-0006). See
|
|
28
|
+
[`reliever-design/adr/ADR-DSN-0002`](https://github.com/papeete-foundry/reliever-design/blob/main/adr/ADR-DSN-0002-kontract-resolution.md)
|
|
29
|
+
for the design record.
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
CAP_ID (e.g. BNK.RLVR.CAP.SUP.002.BEN)
|
|
35
|
+
-> business context (segs 0-1, e.g. BNK.RLVR) -- kpack's own bootstrap --
|
|
36
|
+
-> ENTERPRISE (seg 0) -> kpack config -> registry repo -> vocab.yaml contexts:
|
|
37
|
+
-> confirm business context is registered -- new: kontract's own step --
|
|
38
|
+
-> scan contexts for kind: solution, refines: <business context> (exactly one expected)
|
|
39
|
+
-> that context's repo -> kpack.remote.resolve_repo_root (fetch + cache, same as kpack)
|
|
40
|
+
-> serve process/<CAP_ID>/ from the resolved checkout
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
No caching beyond kpack's own git-clone cache (`--cache-dir` / `--no-fetch` /
|
|
44
|
+
`--fresh`) — every call is a fresh fetch by design.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install -e ".[dev]"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Usage
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
kontract fetch <CAP_ID> [--ref REF] [--repo-root PATH] [--cache-dir DIR]
|
|
56
|
+
[--no-fetch] [--fresh] [--config PATH]
|
|
57
|
+
[--registry-repo SLUG] [--registry-ref REF] [--compact]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Uses the same `~/.config/kpack/config.yaml` / `./.kpack.yaml` / `--config`
|
|
61
|
+
enterprise -> registry bootstrap as `kpack` — no separate kontract config file.
|
|
62
|
+
|
|
63
|
+
## Develop
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
make test # pytest against a fake business+solution repo pair (tests/conftest.py)
|
|
67
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
kontract/__init__.py
|
|
4
|
+
kontract/cli.py
|
|
5
|
+
kontract/envelope.py
|
|
6
|
+
kontract/errors.py
|
|
7
|
+
kontract/model.py
|
|
8
|
+
kontract/resolver.py
|
|
9
|
+
kontract.egg-info/PKG-INFO
|
|
10
|
+
kontract.egg-info/SOURCES.txt
|
|
11
|
+
kontract.egg-info/dependency_links.txt
|
|
12
|
+
kontract.egg-info/entry_points.txt
|
|
13
|
+
kontract.egg-info/requires.txt
|
|
14
|
+
kontract.egg-info/top_level.txt
|
|
15
|
+
tests/test_cli.py
|
|
16
|
+
tests/test_model.py
|
|
17
|
+
tests/test_resolver.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
kontract
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "kontract"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Cross-repo fetch of a capability's process/contract tier (ADR-BCM-URBA-0016 / ADR-DSN-0002)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
authors = [{ name = "yremy" }]
|
|
13
|
+
dependencies = ["kpack>=2.0.0,<3", "pyyaml>=6.0,<7"]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
kontract = "kontract.cli:main"
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = ["pytest>=7", "build>=1.0"]
|
|
20
|
+
|
|
21
|
+
[tool.setuptools.packages.find]
|
|
22
|
+
where = ["."]
|
|
23
|
+
include = ["kontract*"]
|
|
24
|
+
exclude = ["tests*"]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
pythonpath = ["."]
|
|
28
|
+
testpaths = ["tests"]
|
kontract-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from kontract.cli import main
|
|
6
|
+
from tests.conftest import CAP_ID
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_fetch_end_to_end(world, capsys):
|
|
10
|
+
rc = main(["fetch", CAP_ID])
|
|
11
|
+
assert rc == 0
|
|
12
|
+
out = json.loads(capsys.readouterr().out)
|
|
13
|
+
assert out["engine"]["name"] == "kontract"
|
|
14
|
+
assert out["solution"]["context"] == "ACME.SOL"
|
|
15
|
+
assert out["solution"]["refines"] == "ACME.BIZ"
|
|
16
|
+
assert out["registry"]["repo"] == str(world["registry"])
|
|
17
|
+
assert out["target"]["id"] == CAP_ID
|
|
18
|
+
assert "aggregates" in out["model"]
|
|
19
|
+
assert out["provenance"]["capability_id"] == CAP_ID
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_fetch_unknown_capability_exits_2(world, capsys):
|
|
23
|
+
rc = main(["fetch", "ACME.BIZ.CAP.NOPE.001"])
|
|
24
|
+
assert rc == 2
|
|
25
|
+
assert "no process model" in capsys.readouterr().err
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_fetch_ambiguous_solution_exits_3(world, capsys):
|
|
29
|
+
from tests.conftest import _commit, _write
|
|
30
|
+
|
|
31
|
+
contexts = [
|
|
32
|
+
{"code": "ACME.GOV", "kind": "governance", "repo": str(world["registry"])},
|
|
33
|
+
{"code": "ACME.BIZ", "kind": "business", "repo": "unused"},
|
|
34
|
+
{"code": "ACME.SOL", "kind": "solution", "repo": str(world["solution"]), "refines": "ACME.BIZ"},
|
|
35
|
+
{"code": "ACME.SOL2", "kind": "solution", "repo": str(world["solution"]), "refines": "ACME.BIZ"},
|
|
36
|
+
]
|
|
37
|
+
_write(world["registry"], "vocab.yaml", {"contexts": contexts})
|
|
38
|
+
_commit(world["registry"], "registry v2 — add ambiguous second solution context")
|
|
39
|
+
|
|
40
|
+
rc = main(["fetch", CAP_ID, "--fresh"])
|
|
41
|
+
assert rc == 3
|
|
42
|
+
assert "ambiguous" in capsys.readouterr().err
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from kontract.errors import TargetError
|
|
6
|
+
from kontract.model import get_model
|
|
7
|
+
from tests.conftest import CAP_ID
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_get_model_reads_yaml_readme_schemas_and_provenance(world):
|
|
11
|
+
m = get_model(world["solution"], CAP_ID)
|
|
12
|
+
assert m["target"] == {"kind": "process_model", "id": CAP_ID}
|
|
13
|
+
assert set(m["model"]) == {"aggregates", "commands"}
|
|
14
|
+
assert m["model"]["aggregates"]["parsed"] == {"aggregates": [{"name": "Score"}]}
|
|
15
|
+
assert m["readme"] == "Process model for Scoring."
|
|
16
|
+
assert "CMD.SCORE.schema.json" in m["schemas"]
|
|
17
|
+
assert m["provenance"]["capability_id"] == CAP_ID
|
|
18
|
+
assert m["warnings"] == []
|
|
19
|
+
paths = {f["path"] for f in m["files"]}
|
|
20
|
+
assert f"process/{CAP_ID}/schemas/CMD.SCORE.schema.json" in paths
|
|
21
|
+
assert f"process/{CAP_ID}/.bcm-provenance.json" in paths
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_get_model_warns_on_missing_provenance(world_no_provenance):
|
|
25
|
+
m = get_model(world_no_provenance["solution"], CAP_ID)
|
|
26
|
+
assert m["provenance"] is None
|
|
27
|
+
assert any("no .bcm-provenance.json" in w for w in m["warnings"])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_get_model_missing_capability_raises(world):
|
|
31
|
+
with pytest.raises(TargetError):
|
|
32
|
+
get_model(world["solution"], "ACME.BIZ.CAP.NOPE.001")
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from kontract.errors import ResolutionError, TargetError
|
|
6
|
+
from kontract.resolver import Flags, business_context_of, find_solution_context, resolve
|
|
7
|
+
from tests.conftest import CAP_ID
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_business_context_of():
|
|
11
|
+
assert business_context_of(CAP_ID) == "ACME.BIZ"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_business_context_of_rejects_short_id():
|
|
15
|
+
with pytest.raises(TargetError):
|
|
16
|
+
business_context_of("ACME.BIZ")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_find_solution_context_happy_path():
|
|
20
|
+
contexts = {
|
|
21
|
+
"ACME.BIZ": {"kind": "business", "repo": "x"},
|
|
22
|
+
"ACME.SOL": {"kind": "solution", "refines": "ACME.BIZ", "repo": "y"},
|
|
23
|
+
}
|
|
24
|
+
assert find_solution_context(contexts, "ACME.BIZ") == "ACME.SOL"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_find_solution_context_unknown_business_context():
|
|
28
|
+
with pytest.raises(ResolutionError, match="not in the authoritative registry"):
|
|
29
|
+
find_solution_context({}, "ACME.BIZ")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_find_solution_context_wrong_kind():
|
|
33
|
+
contexts = {"ACME.BIZ": {"kind": "solution", "repo": "x"}}
|
|
34
|
+
with pytest.raises(ResolutionError, match="expected 'business'"):
|
|
35
|
+
find_solution_context(contexts, "ACME.BIZ")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_find_solution_context_zero_matches():
|
|
39
|
+
contexts = {"ACME.BIZ": {"kind": "business", "repo": "x"}}
|
|
40
|
+
with pytest.raises(ResolutionError, match="no solution context refines"):
|
|
41
|
+
find_solution_context(contexts, "ACME.BIZ")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_find_solution_context_ambiguous():
|
|
45
|
+
contexts = {
|
|
46
|
+
"ACME.BIZ": {"kind": "business", "repo": "x"},
|
|
47
|
+
"ACME.SOL": {"kind": "solution", "refines": "ACME.BIZ", "repo": "y"},
|
|
48
|
+
"ACME.SOL2": {"kind": "solution", "refines": "ACME.BIZ", "repo": "z"},
|
|
49
|
+
}
|
|
50
|
+
with pytest.raises(ResolutionError, match="ambiguous"):
|
|
51
|
+
find_solution_context(contexts, "ACME.BIZ")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_resolve_end_to_end(world):
|
|
55
|
+
resolved = resolve(CAP_ID, Flags())
|
|
56
|
+
assert resolved.business_context == "ACME.BIZ"
|
|
57
|
+
assert resolved.solution_context == "ACME.SOL"
|
|
58
|
+
assert resolved.solution_repo == str(world["solution"])
|
|
59
|
+
assert resolved.solution_root.is_dir()
|
|
60
|
+
assert (resolved.solution_root / "process" / CAP_ID).is_dir()
|
|
61
|
+
assert resolved.registry_repo == str(world["registry"])
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_resolve_repo_root_short_circuit(world):
|
|
65
|
+
resolved = resolve(CAP_ID, Flags(repo_root=str(world["solution"])))
|
|
66
|
+
assert resolved.solution_context is None
|
|
67
|
+
assert resolved.registry_repo is None
|
|
68
|
+
assert resolved.solution_root == world["solution"].resolve()
|