agnara-cli 1.0.2__tar.gz → 1.0.4__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.
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/.gitignore +7 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/PKG-INFO +42 -4
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/README.md +39 -1
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/pyproject.toml +3 -3
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/__init__.py +11 -7
- agnara_cli-1.0.4/src/agnara_cli/_command.py +9 -0
- agnara_cli-1.0.4/src/agnara_cli/_doctor.py +222 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_generate.py +12 -10
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_main.py +13 -3
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_schema.py +18 -15
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_target.py +18 -11
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_view.py +0 -1
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_write.py +6 -3
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/LICENSE +0 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/__main__.py +0 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_app.py +0 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_app_template.py +0 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_apps.py +0 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_context.py +0 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_exposure_template.py +0 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_graph.py +0 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_inspect.py +0 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_manifest.py +0 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_minimal_template.py +0 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_names.py +0 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_project.py +0 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_render.py +0 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_template_format.py +0 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/_templates.py +0 -0
- {agnara_cli-1.0.2 → agnara_cli-1.0.4}/src/agnara_cli/py.typed +0 -0
|
@@ -34,6 +34,13 @@ uv.lock.bak
|
|
|
34
34
|
.dist-test/
|
|
35
35
|
.release-validation/
|
|
36
36
|
|
|
37
|
+
# Git worktrees for parallel agent work. MULTI_AGENT_PROTOCOL.md asks for a
|
|
38
|
+
# sibling directory outside the checkout; `.worktrees/` is the only supported
|
|
39
|
+
# location inside it. Untracked, a nested worktree makes `git status` dirty, so
|
|
40
|
+
# the `repository-clean` release gate reports uncommitted changes for a tree
|
|
41
|
+
# whose own content is untouched.
|
|
42
|
+
/.worktrees/
|
|
43
|
+
|
|
37
44
|
# Editors / OS
|
|
38
45
|
.idea/
|
|
39
46
|
.vscode/
|
|
@@ -1,32 +1,40 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agnara-cli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.4
|
|
4
4
|
Summary: Project introspection and scaffolding CLI for Agnara.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Blandskron/agnara
|
|
6
6
|
Project-URL: Repository, https://github.com/Blandskron/agnara
|
|
7
7
|
Author-email: Agnara Maintainers <maintainers@agnara.dev>
|
|
8
8
|
License: Apache-2.0
|
|
9
9
|
License-File: LICENSE
|
|
10
|
-
Classifier: Development Status ::
|
|
10
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
11
11
|
Classifier: Intended Audience :: Developers
|
|
12
12
|
Classifier: License :: OSI Approved :: Apache Software License
|
|
13
13
|
Classifier: Operating System :: OS Independent
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.14
|
|
15
15
|
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
16
16
|
Requires-Python: >=3.14
|
|
17
|
-
Requires-Dist: agnara==1.0.
|
|
17
|
+
Requires-Dist: agnara==1.0.4
|
|
18
18
|
Description-Content-Type: text/markdown
|
|
19
19
|
|
|
20
20
|
# agnara-cli
|
|
21
21
|
|
|
22
22
|
Project introspection and scaffolding CLI. Owns project/app generators, templates and diagnostics.
|
|
23
23
|
|
|
24
|
+
```bash
|
|
25
|
+
pip install "agnara-cli==1.0.3"
|
|
26
|
+
agnara --help
|
|
27
|
+
```
|
|
28
|
+
|
|
24
29
|
This distribution is built and versioned with the synchronized workspace
|
|
25
30
|
set. Which versions exist on an index is answered by its PyPI project page,
|
|
26
31
|
not by this file: a README ships inside the artifact and cannot describe
|
|
27
32
|
the state of a publication that happens after it is built.
|
|
28
33
|
|
|
29
34
|
It installs the `agnara` console script.
|
|
35
|
+
The governed Python exports are listed in
|
|
36
|
+
[`docs/API_REFERENCE.md`](../../docs/API_REFERENCE.md); generated projects use
|
|
37
|
+
the governed kernel imports. Importing a project target executes that module.
|
|
30
38
|
|
|
31
39
|
- Import package: `agnara_cli`
|
|
32
40
|
- Depends on: the exact synchronized `agnara` version
|
|
@@ -40,11 +48,41 @@ allowed dependency graph.
|
|
|
40
48
|
`agnara project create`, `agnara app create` with its `app-api`, `app-mcp`,
|
|
41
49
|
`app-agent` and `app-worker` profile shorthands, `agnara apps`,
|
|
42
50
|
`agnara inspect`, `agnara graph`, `agnara schema openapi` and `agnara context`
|
|
43
|
-
are implemented.
|
|
51
|
+
are implemented. The current checkout also implements the static `doctor`
|
|
52
|
+
baseline described below; a development-server command remains unimplemented.
|
|
53
|
+
The [maturity inventory](../../docs/MATURITY.md) records current boundaries;
|
|
54
|
+
proposed commands in a specification do not make them available.
|
|
44
55
|
|
|
45
56
|
`agnara --help` lists the commands the installed version actually has, which is
|
|
46
57
|
the answer to trust over any list written down elsewhere.
|
|
47
58
|
|
|
59
|
+
## `agnara doctor` (unreleased checkout)
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
agnara doctor --project path/to/project
|
|
63
|
+
agnara doctor --project path/to/project --json
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Read-only checks cover the executing Python >=3.14 baseline, strict project
|
|
67
|
+
manifest, contained app directories and installed core/CLI/declared HTTP/MCP
|
|
68
|
+
version metadata. A2A declarations fail with a message about missing diagnostic
|
|
69
|
+
coverage, rather than claiming the provisional adapter has no runtime. Events
|
|
70
|
+
declarations fail as a reserved namespace without an event runtime; Tasks
|
|
71
|
+
declarations fail because no task runtime is implemented. These checks do not
|
|
72
|
+
inspect those distributions' metadata and fail whether or not they are installed.
|
|
73
|
+
No project or adapter code is imported, no files are written and no dependencies
|
|
74
|
+
are installed.
|
|
75
|
+
|
|
76
|
+
Reports always list unchecked runtime and architecture boundaries. The project's
|
|
77
|
+
free-form Python constraint is not evaluated, and matching package metadata does
|
|
78
|
+
not prove importability or transitive dependency compatibility. A passing
|
|
79
|
+
report is not a runtime-readiness assertion. See the
|
|
80
|
+
[doctor contract](../../docs/CLI_SPEC.md#agnara-doctor) and proposed ADR 0096.
|
|
81
|
+
|
|
82
|
+
The versioned JSON or text report goes to stdout. Any failed check exits 1;
|
|
83
|
+
all checks passing exits 0. Argument errors retain exit 2 on stderr. This
|
|
84
|
+
command is unreleased checkout behavior, not a claim about the 1.0.3 wheel.
|
|
85
|
+
|
|
48
86
|
## `agnara inspect`
|
|
49
87
|
|
|
50
88
|
```bash
|
|
@@ -2,12 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
Project introspection and scaffolding CLI. Owns project/app generators, templates and diagnostics.
|
|
4
4
|
|
|
5
|
+
```bash
|
|
6
|
+
pip install "agnara-cli==1.0.3"
|
|
7
|
+
agnara --help
|
|
8
|
+
```
|
|
9
|
+
|
|
5
10
|
This distribution is built and versioned with the synchronized workspace
|
|
6
11
|
set. Which versions exist on an index is answered by its PyPI project page,
|
|
7
12
|
not by this file: a README ships inside the artifact and cannot describe
|
|
8
13
|
the state of a publication that happens after it is built.
|
|
9
14
|
|
|
10
15
|
It installs the `agnara` console script.
|
|
16
|
+
The governed Python exports are listed in
|
|
17
|
+
[`docs/API_REFERENCE.md`](../../docs/API_REFERENCE.md); generated projects use
|
|
18
|
+
the governed kernel imports. Importing a project target executes that module.
|
|
11
19
|
|
|
12
20
|
- Import package: `agnara_cli`
|
|
13
21
|
- Depends on: the exact synchronized `agnara` version
|
|
@@ -21,11 +29,41 @@ allowed dependency graph.
|
|
|
21
29
|
`agnara project create`, `agnara app create` with its `app-api`, `app-mcp`,
|
|
22
30
|
`app-agent` and `app-worker` profile shorthands, `agnara apps`,
|
|
23
31
|
`agnara inspect`, `agnara graph`, `agnara schema openapi` and `agnara context`
|
|
24
|
-
are implemented.
|
|
32
|
+
are implemented. The current checkout also implements the static `doctor`
|
|
33
|
+
baseline described below; a development-server command remains unimplemented.
|
|
34
|
+
The [maturity inventory](../../docs/MATURITY.md) records current boundaries;
|
|
35
|
+
proposed commands in a specification do not make them available.
|
|
25
36
|
|
|
26
37
|
`agnara --help` lists the commands the installed version actually has, which is
|
|
27
38
|
the answer to trust over any list written down elsewhere.
|
|
28
39
|
|
|
40
|
+
## `agnara doctor` (unreleased checkout)
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
agnara doctor --project path/to/project
|
|
44
|
+
agnara doctor --project path/to/project --json
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Read-only checks cover the executing Python >=3.14 baseline, strict project
|
|
48
|
+
manifest, contained app directories and installed core/CLI/declared HTTP/MCP
|
|
49
|
+
version metadata. A2A declarations fail with a message about missing diagnostic
|
|
50
|
+
coverage, rather than claiming the provisional adapter has no runtime. Events
|
|
51
|
+
declarations fail as a reserved namespace without an event runtime; Tasks
|
|
52
|
+
declarations fail because no task runtime is implemented. These checks do not
|
|
53
|
+
inspect those distributions' metadata and fail whether or not they are installed.
|
|
54
|
+
No project or adapter code is imported, no files are written and no dependencies
|
|
55
|
+
are installed.
|
|
56
|
+
|
|
57
|
+
Reports always list unchecked runtime and architecture boundaries. The project's
|
|
58
|
+
free-form Python constraint is not evaluated, and matching package metadata does
|
|
59
|
+
not prove importability or transitive dependency compatibility. A passing
|
|
60
|
+
report is not a runtime-readiness assertion. See the
|
|
61
|
+
[doctor contract](../../docs/CLI_SPEC.md#agnara-doctor) and proposed ADR 0096.
|
|
62
|
+
|
|
63
|
+
The versioned JSON or text report goes to stdout. Any failed check exits 1;
|
|
64
|
+
all checks passing exits 0. Argument errors retain exit 2 on stderr. This
|
|
65
|
+
command is unreleased checkout behavior, not a claim about the 1.0.3 wheel.
|
|
66
|
+
|
|
29
67
|
## `agnara inspect`
|
|
30
68
|
|
|
31
69
|
```bash
|
|
@@ -1,20 +1,20 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "agnara-cli"
|
|
3
|
-
version = "1.0.
|
|
3
|
+
version = "1.0.4"
|
|
4
4
|
description = "Project introspection and scaffolding CLI for Agnara."
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.14"
|
|
7
7
|
authors = [{name = "Agnara Maintainers", email = "maintainers@agnara.dev"}]
|
|
8
8
|
license = {text = "Apache-2.0"}
|
|
9
9
|
classifiers = [
|
|
10
|
-
"Development Status ::
|
|
10
|
+
"Development Status :: 5 - Production/Stable",
|
|
11
11
|
"Intended Audience :: Developers",
|
|
12
12
|
"License :: OSI Approved :: Apache Software License",
|
|
13
13
|
"Operating System :: OS Independent",
|
|
14
14
|
"Programming Language :: Python :: 3.14",
|
|
15
15
|
"Topic :: Software Development :: Libraries :: Application Frameworks",
|
|
16
16
|
]
|
|
17
|
-
dependencies = ["agnara==1.0.
|
|
17
|
+
dependencies = ["agnara==1.0.4"]
|
|
18
18
|
|
|
19
19
|
[project.scripts]
|
|
20
20
|
agnara = "agnara_cli._main:main"
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
"""Project introspection and scaffolding CLI for Agnara.
|
|
2
2
|
|
|
3
|
-
Owns
|
|
4
|
-
|
|
5
|
-
never in ``agnara-core``.
|
|
3
|
+
Owns project/app scaffolding and introspection commands. Templates live here,
|
|
4
|
+
never in the ``agnara`` kernel.
|
|
6
5
|
|
|
7
6
|
Currently implemented: ``agnara project create`` and ``agnara app create``,
|
|
8
7
|
which generate a project and a bounded context from a reviewable plan,
|
|
@@ -12,11 +11,16 @@ application and presents its filtered protocol-neutral introspection
|
|
|
12
11
|
snapshot as text or as deterministic JSON, ``agnara graph``, which draws the
|
|
13
12
|
relationships in that same snapshot, ``agnara schema openapi``, which exports
|
|
14
13
|
the OpenAPI document a composition already produced, and ``agnara context``,
|
|
15
|
-
which writes the visible capabilities as Markdown for a model to read.
|
|
16
|
-
|
|
14
|
+
which writes the visible capabilities as Markdown for a model to read.
|
|
15
|
+
App profiles and ``--with`` select generated adapter bindings. There is no
|
|
16
|
+
standalone capability generator or development-server command.
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
The unreleased checkout's ``agnara doctor`` performs read-only static checks
|
|
19
|
+
of the manifest, app paths and installed version metadata. Runtime registration,
|
|
20
|
+
dependency cycles and architecture rules remain explicitly unchecked.
|
|
21
|
+
|
|
22
|
+
Depends on ``agnara``. Must not import a sibling adapter.
|
|
23
|
+
See ``ARCHITECTURE.md`` section 15, ``docs/CLI_SPEC.md`` and ``docs/MATURITY.md``.
|
|
20
24
|
"""
|
|
21
25
|
|
|
22
26
|
#: The supported programmatic surface of this distribution.
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Check project metadata and, on request, an explicit runtime composition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
|
|
10
|
+
from agnara_cli._apps import _manifest as load_project_manifest
|
|
11
|
+
from agnara_cli._command import CommandResult
|
|
12
|
+
from agnara_cli._manifest import ManifestError, ProjectManifest
|
|
13
|
+
from agnara_cli._target import TargetError, resolve_target
|
|
14
|
+
|
|
15
|
+
_ADAPTERS = {"http": "agnara-http", "mcp": "agnara-mcp"}
|
|
16
|
+
_UNCHECKED_EXPOSURE_MESSAGES = {
|
|
17
|
+
"a2a": "adapter metadata checks are not implemented for this declared exposure",
|
|
18
|
+
"events": "reserved namespace; no implemented Agnara event runtime",
|
|
19
|
+
"tasks": "no implemented Agnara task runtime",
|
|
20
|
+
}
|
|
21
|
+
_UNCHECKED = (
|
|
22
|
+
"project_python_constraint",
|
|
23
|
+
"application_imports",
|
|
24
|
+
"runtime_registration",
|
|
25
|
+
"dependency_cycles",
|
|
26
|
+
"protocol_configuration",
|
|
27
|
+
"architecture_rules",
|
|
28
|
+
"transitive_dependencies",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def add_doctor_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
33
|
+
parser = subparsers.add_parser(
|
|
34
|
+
"doctor",
|
|
35
|
+
help="check project metadata and optionally an explicit runtime composition",
|
|
36
|
+
description=(
|
|
37
|
+
"Static checks import no project code. An explicit --runtime target imports "
|
|
38
|
+
"trusted project code and checks compilation and declared app registration."
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument("--project", metavar="DIR", help="directory to search and its ancestors")
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--runtime", metavar="MODULE:ATTRIBUTE", help="import and compile this target"
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--path", action="append", default=[], metavar="DIR", help="runtime import path"
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument("--dependencies", metavar="ATTRIBUTE", help="runtime target's DIRegistry")
|
|
49
|
+
parser.add_argument("--json", action="store_true", help="emit a versioned diagnostic report")
|
|
50
|
+
|
|
51
|
+
def handle(arguments: argparse.Namespace) -> CommandResult:
|
|
52
|
+
if arguments.runtime is None:
|
|
53
|
+
if arguments.path:
|
|
54
|
+
parser.error("--path requires --runtime")
|
|
55
|
+
if arguments.dependencies is not None:
|
|
56
|
+
parser.error("--dependencies requires --runtime")
|
|
57
|
+
return run_doctor(arguments)
|
|
58
|
+
|
|
59
|
+
parser.set_defaults(handler=handle)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _python_version() -> tuple[int, int, int]:
|
|
63
|
+
return sys.version_info[:3]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _distribution_check(
|
|
67
|
+
name: str,
|
|
68
|
+
checks: list[dict[str, str]],
|
|
69
|
+
*,
|
|
70
|
+
kernel: str | None = None,
|
|
71
|
+
compare: bool = False,
|
|
72
|
+
) -> str | None:
|
|
73
|
+
try:
|
|
74
|
+
installed = version(name)
|
|
75
|
+
except PackageNotFoundError:
|
|
76
|
+
checks.append({"id": f"distribution:{name}", "status": "fail", "message": "not installed"})
|
|
77
|
+
return None
|
|
78
|
+
if not isinstance(installed, str) or not installed.strip():
|
|
79
|
+
checks.append(
|
|
80
|
+
{
|
|
81
|
+
"id": f"distribution:{name}",
|
|
82
|
+
"status": "fail",
|
|
83
|
+
"message": "installed metadata has no non-empty version",
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
return None
|
|
87
|
+
status = "pass"
|
|
88
|
+
message = f"installed {installed}"
|
|
89
|
+
if compare and kernel is None:
|
|
90
|
+
status, message = "fail", "cannot compare versions without installed agnara metadata"
|
|
91
|
+
elif compare and installed != kernel:
|
|
92
|
+
status, message = "fail", f"installed {installed}; expected kernel version {kernel}"
|
|
93
|
+
checks.append({"id": f"distribution:{name}", "status": status, "message": message})
|
|
94
|
+
return installed
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _app_checks(manifest: ProjectManifest, checks: list[dict[str, str]]) -> None:
|
|
98
|
+
assert manifest.source is not None
|
|
99
|
+
root = manifest.source.parent.resolve()
|
|
100
|
+
for app in manifest.apps:
|
|
101
|
+
status = "pass"
|
|
102
|
+
message = f"directory exists: {app.path}"
|
|
103
|
+
try:
|
|
104
|
+
destination = (root / app.path).resolve()
|
|
105
|
+
if not destination.is_relative_to(root):
|
|
106
|
+
status, message = "fail", f"directory escapes project: {app.path}"
|
|
107
|
+
elif not destination.is_dir():
|
|
108
|
+
status, message = "fail", f"directory is missing or inaccessible: {app.path}"
|
|
109
|
+
except OSError, RuntimeError:
|
|
110
|
+
status, message = "fail", f"cannot resolve app directory: {app.path}"
|
|
111
|
+
checks.append({"id": f"app:{app.name}:path", "status": status, "message": message})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def run_doctor(arguments: argparse.Namespace) -> CommandResult:
|
|
115
|
+
checks: list[dict[str, str]] = []
|
|
116
|
+
interpreter = _python_version()
|
|
117
|
+
checks.append(
|
|
118
|
+
{
|
|
119
|
+
"id": "python",
|
|
120
|
+
"status": "pass" if interpreter >= (3, 14) else "fail",
|
|
121
|
+
"message": f"executing Python {'.'.join(map(str, interpreter))}; requires >=3.14",
|
|
122
|
+
}
|
|
123
|
+
)
|
|
124
|
+
manifest = None
|
|
125
|
+
try:
|
|
126
|
+
manifest = load_project_manifest(arguments)
|
|
127
|
+
checks.append({"id": "manifest", "status": "pass", "message": "valid agnara.toml"})
|
|
128
|
+
except ManifestError as error:
|
|
129
|
+
checks.append({"id": "manifest", "status": "fail", "message": str(error)})
|
|
130
|
+
|
|
131
|
+
kernel = _distribution_check("agnara", checks)
|
|
132
|
+
_distribution_check("agnara-cli", checks, kernel=kernel, compare=True)
|
|
133
|
+
unchecked = list(_UNCHECKED)
|
|
134
|
+
project = None
|
|
135
|
+
if manifest is None:
|
|
136
|
+
unchecked.extend(("declared_app_paths", "declared_adapters"))
|
|
137
|
+
else:
|
|
138
|
+
project = {"name": manifest.name, "python": manifest.python}
|
|
139
|
+
_app_checks(manifest, checks)
|
|
140
|
+
exposures = dict.fromkeys(exposure for app in manifest.apps for exposure in app.exposures)
|
|
141
|
+
for exposure in exposures:
|
|
142
|
+
distribution = _ADAPTERS.get(exposure)
|
|
143
|
+
if distribution is None:
|
|
144
|
+
checks.append(
|
|
145
|
+
{
|
|
146
|
+
"id": f"exposure:{exposure}",
|
|
147
|
+
"status": "fail",
|
|
148
|
+
"message": _UNCHECKED_EXPOSURE_MESSAGES[exposure],
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
else:
|
|
152
|
+
_distribution_check(distribution, checks, kernel=kernel, compare=True)
|
|
153
|
+
|
|
154
|
+
if arguments.runtime is not None:
|
|
155
|
+
_runtime_checks(arguments, manifest, checks, unchecked)
|
|
156
|
+
|
|
157
|
+
status = "fail" if any(check["status"] == "fail" for check in checks) else "pass"
|
|
158
|
+
report = {
|
|
159
|
+
"format": "agnara.doctor",
|
|
160
|
+
"format_version": 1,
|
|
161
|
+
"project": project,
|
|
162
|
+
"checks": checks,
|
|
163
|
+
"status": status,
|
|
164
|
+
"unchecked": unchecked,
|
|
165
|
+
}
|
|
166
|
+
if arguments.json:
|
|
167
|
+
output = json.dumps(report, indent=2, sort_keys=True)
|
|
168
|
+
else:
|
|
169
|
+
label = manifest.name if manifest is not None else "unknown project"
|
|
170
|
+
lines = [f"doctor {label}: {status}"]
|
|
171
|
+
lines.extend(f"[{check['status']}] {check['id']}: {check['message']}" for check in checks)
|
|
172
|
+
lines.append("Unchecked: " + ", ".join(unchecked))
|
|
173
|
+
output = "\n".join(lines)
|
|
174
|
+
return CommandResult(output, 1 if status == "fail" else 0)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _runtime_checks(
|
|
178
|
+
arguments: argparse.Namespace,
|
|
179
|
+
manifest: ProjectManifest,
|
|
180
|
+
checks: list[dict[str, str]],
|
|
181
|
+
unchecked: list[str],
|
|
182
|
+
) -> None:
|
|
183
|
+
unchecked.remove("application_imports")
|
|
184
|
+
try:
|
|
185
|
+
target = resolve_target(
|
|
186
|
+
arguments.runtime,
|
|
187
|
+
dependencies=arguments.dependencies,
|
|
188
|
+
)
|
|
189
|
+
except TargetError as error:
|
|
190
|
+
checks.append({"id": "runtime:target", "status": "fail", "message": str(error)})
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
checks.append({"id": "runtime:target", "status": "pass", "message": "target compiled"})
|
|
194
|
+
unchecked.remove("runtime_registration")
|
|
195
|
+
for app in manifest.apps:
|
|
196
|
+
registered = target.app.apps.get(app.name)
|
|
197
|
+
checks.append(
|
|
198
|
+
{
|
|
199
|
+
"id": f"app:{app.name}:registration",
|
|
200
|
+
"status": "pass" if registered is not None else "fail",
|
|
201
|
+
"message": "registered"
|
|
202
|
+
if registered is not None
|
|
203
|
+
else "declared app is not registered",
|
|
204
|
+
}
|
|
205
|
+
)
|
|
206
|
+
module_check = f"app:{app.name}:module"
|
|
207
|
+
module = registered.descriptor.module if registered is not None else None
|
|
208
|
+
if module is None:
|
|
209
|
+
unchecked.append(module_check)
|
|
210
|
+
continue
|
|
211
|
+
matches = module == app.module
|
|
212
|
+
checks.append(
|
|
213
|
+
{
|
|
214
|
+
"id": module_check,
|
|
215
|
+
"status": "pass" if matches else "fail",
|
|
216
|
+
"message": (
|
|
217
|
+
f"descriptor module matches {app.module}"
|
|
218
|
+
if matches
|
|
219
|
+
else f"descriptor module {module} differs from declared {app.module}"
|
|
220
|
+
),
|
|
221
|
+
}
|
|
222
|
+
)
|
|
@@ -114,7 +114,7 @@ def build_plan(
|
|
|
114
114
|
FileAction(
|
|
115
115
|
path=path,
|
|
116
116
|
contents=files[relative],
|
|
117
|
-
exists=target.exists(),
|
|
117
|
+
exists=target.is_symlink() or target.exists(),
|
|
118
118
|
intended_update=relative in intended,
|
|
119
119
|
)
|
|
120
120
|
)
|
|
@@ -152,13 +152,7 @@ def plan_json(plan: GenerationPlan) -> dict[str, object]:
|
|
|
152
152
|
|
|
153
153
|
|
|
154
154
|
def apply_plan(plan: GenerationPlan, *, overwrite: bool = False) -> GenerationPlan:
|
|
155
|
-
"""
|
|
156
|
-
|
|
157
|
-
Conflicts are checked against the whole plan first. A run that would
|
|
158
|
-
replace a file the operator did not authorize must leave the directory
|
|
159
|
-
exactly as it was, not half-written, so the refusal happens before the
|
|
160
|
-
first ``write_text``.
|
|
161
|
-
"""
|
|
155
|
+
"""Reject planned conflicts before writing and apply metadata updates last."""
|
|
162
156
|
conflicts = plan.conflicts
|
|
163
157
|
if conflicts and not overwrite:
|
|
164
158
|
listed = ", ".join(str(action.path) for action in conflicts)
|
|
@@ -166,14 +160,22 @@ def apply_plan(plan: GenerationPlan, *, overwrite: bool = False) -> GenerationPl
|
|
|
166
160
|
f"refusing to replace existing file(s) in {plan.root}: {listed}. "
|
|
167
161
|
"Pass --overwrite to allow it."
|
|
168
162
|
)
|
|
169
|
-
for action in plan.actions:
|
|
163
|
+
for action in sorted(plan.actions, key=lambda action: action.intended_update):
|
|
170
164
|
target = plan.resolve(action)
|
|
165
|
+
mode = "w" if overwrite or action.intended_update else "x"
|
|
171
166
|
try:
|
|
172
167
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
173
168
|
# newline="\n" so a generated file is byte-identical on every
|
|
174
169
|
# platform; a project is shared, and its line endings are its own
|
|
175
170
|
# decision rather than that of the machine that created it.
|
|
176
|
-
target.
|
|
171
|
+
with target.open(mode, encoding="utf-8", newline="\n") as stream:
|
|
172
|
+
stream.write(action.contents)
|
|
173
|
+
except FileExistsError as error:
|
|
174
|
+
if mode == "x" and error.filename == str(target):
|
|
175
|
+
raise GenerationError(
|
|
176
|
+
f"refusing to replace existing file {target}. Pass --overwrite to allow it."
|
|
177
|
+
) from error
|
|
178
|
+
raise GenerationError(f"cannot write {target}: {error}") from error
|
|
177
179
|
except OSError as error:
|
|
178
180
|
raise GenerationError(f"cannot write {target}: {error}") from error
|
|
179
181
|
return plan
|
|
@@ -10,7 +10,8 @@ Exit codes:
|
|
|
10
10
|
visible" or nothing at all, as when a document was written to a file
|
|
11
11
|
``1``
|
|
12
12
|
the operator's input or application could not be used, reported as a
|
|
13
|
-
diagnostic on stderr rather than a traceback
|
|
13
|
+
diagnostic on stderr rather than a traceback; doctor instead emits its
|
|
14
|
+
structured failed-check report on stdout
|
|
14
15
|
``2``
|
|
15
16
|
argparse rejected the command line
|
|
16
17
|
"""
|
|
@@ -20,18 +21,21 @@ from __future__ import annotations
|
|
|
20
21
|
import argparse
|
|
21
22
|
import sys
|
|
22
23
|
from collections.abc import Sequence
|
|
24
|
+
from contextlib import redirect_stdout
|
|
23
25
|
from importlib.metadata import PackageNotFoundError, version
|
|
24
26
|
|
|
25
27
|
from agnara_cli._app import add_app_alias_parsers, add_app_parser
|
|
26
28
|
from agnara_cli._apps import add_apps_parser
|
|
29
|
+
from agnara_cli._command import CommandResult
|
|
27
30
|
from agnara_cli._context import add_context_parser
|
|
31
|
+
from agnara_cli._doctor import add_doctor_parser
|
|
28
32
|
from agnara_cli._generate import GenerationError
|
|
29
33
|
from agnara_cli._graph import add_graph_parser
|
|
30
34
|
from agnara_cli._inspect import add_inspect_parser
|
|
31
35
|
from agnara_cli._manifest import ManifestError
|
|
32
36
|
from agnara_cli._project import add_project_parser
|
|
33
37
|
from agnara_cli._schema import add_schema_parser
|
|
34
|
-
from agnara_cli._target import TargetError
|
|
38
|
+
from agnara_cli._target import TargetError, target_search_path
|
|
35
39
|
|
|
36
40
|
__all__ = ["main"]
|
|
37
41
|
|
|
@@ -62,6 +66,7 @@ def _parser() -> argparse.ArgumentParser:
|
|
|
62
66
|
add_project_parser(subparsers)
|
|
63
67
|
add_schema_parser(subparsers)
|
|
64
68
|
add_context_parser(subparsers)
|
|
69
|
+
add_doctor_parser(subparsers)
|
|
65
70
|
return parser
|
|
66
71
|
|
|
67
72
|
|
|
@@ -77,10 +82,15 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
77
82
|
"""
|
|
78
83
|
arguments = _parser().parse_args(argv)
|
|
79
84
|
try:
|
|
80
|
-
output
|
|
85
|
+
# Handlers return their output. Imported targets can also print.
|
|
86
|
+
with target_search_path(getattr(arguments, "path", ())), redirect_stdout(sys.stderr):
|
|
87
|
+
output = arguments.handler(arguments)
|
|
81
88
|
except (GenerationError, ManifestError, TargetError) as error:
|
|
82
89
|
print(f"agnara: {error}", file=sys.stderr)
|
|
83
90
|
return EXIT_FAILED
|
|
91
|
+
if isinstance(output, CommandResult):
|
|
92
|
+
_emit(output.output)
|
|
93
|
+
return output.exit_code
|
|
84
94
|
_emit(output)
|
|
85
95
|
return EXIT_OK
|
|
86
96
|
|
|
@@ -19,7 +19,7 @@ from __future__ import annotations
|
|
|
19
19
|
import argparse
|
|
20
20
|
import json
|
|
21
21
|
from collections.abc import Callable, Mapping
|
|
22
|
-
from typing import Any, cast
|
|
22
|
+
from typing import Any, Never, cast
|
|
23
23
|
|
|
24
24
|
from agnara_cli._target import TargetError, resolve_attribute
|
|
25
25
|
from agnara_cli._write import write_document
|
|
@@ -91,6 +91,10 @@ def add_schema_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentPa
|
|
|
91
91
|
openapi.set_defaults(handler=run_schema)
|
|
92
92
|
|
|
93
93
|
|
|
94
|
+
def _reject_json_constant(token: str) -> Never:
|
|
95
|
+
raise ValueError(f"{token} is not a JSON number")
|
|
96
|
+
|
|
97
|
+
|
|
94
98
|
def _document(value: object) -> tuple[dict[str, Any], bytes | None]:
|
|
95
99
|
"""Read a target into a document, keeping its exact bytes when it has them.
|
|
96
100
|
|
|
@@ -119,8 +123,10 @@ def _document(value: object) -> tuple[dict[str, Any], bytes | None]:
|
|
|
119
123
|
except UnicodeDecodeError as error:
|
|
120
124
|
raise TargetError("the OpenAPI document is not valid UTF-8") from error
|
|
121
125
|
try:
|
|
122
|
-
parsed = json.loads(decoded)
|
|
123
|
-
except
|
|
126
|
+
parsed = json.loads(decoded, parse_constant=_reject_json_constant)
|
|
127
|
+
except RecursionError as error:
|
|
128
|
+
raise TargetError("the OpenAPI document is nested too deeply to parse") from error
|
|
129
|
+
except ValueError as error:
|
|
124
130
|
raise TargetError(f"the OpenAPI document is not valid JSON: {error}") from error
|
|
125
131
|
elif isinstance(value, Mapping):
|
|
126
132
|
parsed = dict(value)
|
|
@@ -144,27 +150,24 @@ def _document(value: object) -> tuple[dict[str, Any], bytes | None]:
|
|
|
144
150
|
|
|
145
151
|
|
|
146
152
|
def _encode(document: Mapping[str, Any], served: bytes | None, *, pretty: bool) -> bytes:
|
|
147
|
-
if pretty:
|
|
148
|
-
return json.dumps(
|
|
149
|
-
document,
|
|
150
|
-
allow_nan=False,
|
|
151
|
-
ensure_ascii=False,
|
|
152
|
-
indent=2,
|
|
153
|
-
sort_keys=True,
|
|
154
|
-
).encode("utf-8")
|
|
155
|
-
if served is not None:
|
|
153
|
+
if served is not None and not pretty:
|
|
156
154
|
# Exactly what a server would send, including whatever the projection
|
|
157
155
|
# chose. Re-serializing here would silently normalize it.
|
|
158
156
|
return served
|
|
159
157
|
try:
|
|
160
|
-
|
|
161
|
-
|
|
158
|
+
options = (
|
|
159
|
+
{**_JSON_ARGUMENTS, "separators": None, "indent": 2} if pretty else _JSON_ARGUMENTS
|
|
160
|
+
)
|
|
161
|
+
return json.dumps(document, **options).encode("utf-8")
|
|
162
|
+
except RecursionError as error:
|
|
163
|
+
raise TargetError("the OpenAPI document is nested too deeply to serialize") from error
|
|
164
|
+
except (TypeError, ValueError) as error:
|
|
162
165
|
raise TargetError(f"the OpenAPI document cannot be serialized as JSON: {error}") from error
|
|
163
166
|
|
|
164
167
|
|
|
165
168
|
def run_schema(arguments: argparse.Namespace) -> bytes | None:
|
|
166
169
|
"""Export the document, to stdout as exact bytes or to a file."""
|
|
167
|
-
value = resolve_attribute(arguments.target
|
|
170
|
+
value = resolve_attribute(arguments.target)
|
|
168
171
|
document, served = _document(value)
|
|
169
172
|
body = _encode(document, served, pretty=arguments.pretty)
|
|
170
173
|
if arguments.output is None:
|
|
@@ -16,7 +16,8 @@ from __future__ import annotations
|
|
|
16
16
|
import importlib
|
|
17
17
|
import sys
|
|
18
18
|
import traceback
|
|
19
|
-
from collections.abc import
|
|
19
|
+
from collections.abc import Iterator, Sequence
|
|
20
|
+
from contextlib import contextmanager
|
|
20
21
|
from dataclasses import dataclass
|
|
21
22
|
from pathlib import Path
|
|
22
23
|
|
|
@@ -105,11 +106,19 @@ def _split(target: str) -> tuple[str, str]:
|
|
|
105
106
|
return module_name, attribute
|
|
106
107
|
|
|
107
108
|
|
|
108
|
-
|
|
109
|
-
|
|
109
|
+
@contextmanager
|
|
110
|
+
def target_search_path(search_path: Sequence[str]) -> Iterator[None]:
|
|
111
|
+
"""Keep CLI search paths available while target code runs."""
|
|
110
112
|
added = [str(Path(entry).resolve()) for entry in search_path]
|
|
111
113
|
original = list(sys.path)
|
|
112
114
|
sys.path[:0] = added
|
|
115
|
+
try:
|
|
116
|
+
yield
|
|
117
|
+
finally:
|
|
118
|
+
sys.path[:] = original
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _import(module_name: str) -> object:
|
|
113
122
|
try:
|
|
114
123
|
return importlib.import_module(module_name)
|
|
115
124
|
except ImportError as error:
|
|
@@ -124,11 +133,9 @@ def _import(module_name: str, search_path: Sequence[str]) -> object:
|
|
|
124
133
|
# A module that raises while executing is the user's code failing, not
|
|
125
134
|
# a CLI failure, and the operator needs the reason rather than a stack.
|
|
126
135
|
raise TargetError(f"importing {module_name!r} failed: {_reason(error)}") from error
|
|
127
|
-
finally:
|
|
128
|
-
sys.path[:] = original
|
|
129
136
|
|
|
130
137
|
|
|
131
|
-
def resolve_attribute(target: str
|
|
138
|
+
def resolve_attribute(target: str) -> object:
|
|
132
139
|
"""Import ``module:attribute`` and return whatever it names.
|
|
133
140
|
|
|
134
141
|
The generic half of target resolution. A command that needs an application
|
|
@@ -137,7 +144,7 @@ def resolve_attribute(target: str, *, search_path: Iterable[str] = ()) -> object
|
|
|
137
144
|
and reads it structurally instead.
|
|
138
145
|
"""
|
|
139
146
|
module_name, attribute = _split(target)
|
|
140
|
-
module = _import(module_name
|
|
147
|
+
module = _import(module_name)
|
|
141
148
|
return _attribute(module, attribute)
|
|
142
149
|
|
|
143
150
|
|
|
@@ -153,7 +160,6 @@ def _registry(module: object, name: str | None) -> DIRegistry | None:
|
|
|
153
160
|
def resolve_target(
|
|
154
161
|
target: str,
|
|
155
162
|
*,
|
|
156
|
-
search_path: Iterable[str] = (),
|
|
157
163
|
dependencies: str | None = None,
|
|
158
164
|
) -> ResolvedTarget:
|
|
159
165
|
"""Import ``module:attribute`` and compile every declared capability.
|
|
@@ -164,7 +170,7 @@ def resolve_target(
|
|
|
164
170
|
fails with the reason rather than being described as if it had none.
|
|
165
171
|
"""
|
|
166
172
|
module_name, attribute = _split(target)
|
|
167
|
-
module = _import(module_name
|
|
173
|
+
module = _import(module_name)
|
|
168
174
|
app = _attribute(module, attribute)
|
|
169
175
|
if not isinstance(app, Agnara):
|
|
170
176
|
raise TargetError(
|
|
@@ -173,9 +179,10 @@ def resolve_target(
|
|
|
173
179
|
registry = _registry(module, dependencies)
|
|
174
180
|
compile_against = DIRegistry() if registry is None else registry
|
|
175
181
|
try:
|
|
182
|
+
capabilities = app.compile()
|
|
176
183
|
plans = tuple(
|
|
177
|
-
ExecutionPlan.compile(
|
|
178
|
-
for capability_id in
|
|
184
|
+
ExecutionPlan.compile(capabilities[capability_id], compile_against)
|
|
185
|
+
for capability_id in capabilities
|
|
179
186
|
)
|
|
180
187
|
except Exception as error:
|
|
181
188
|
# A schema error with no registry named is nearly always a dependency
|
|
@@ -130,7 +130,6 @@ def resolve_view(arguments: argparse.Namespace) -> View:
|
|
|
130
130
|
"""
|
|
131
131
|
resolved = resolve_target(
|
|
132
132
|
arguments.target,
|
|
133
|
-
search_path=arguments.path,
|
|
134
133
|
dependencies=arguments.dependencies,
|
|
135
134
|
)
|
|
136
135
|
visibility = POSTURES[arguments.visibility](_rule(arguments.as_scope, arguments.hide))
|
|
@@ -17,9 +17,12 @@ __all__ = ["write_document"]
|
|
|
17
17
|
def write_document(destination: str, body: bytes, *, overwrite: bool) -> None:
|
|
18
18
|
"""Write ``body`` to ``destination``, refusing an unauthorized replacement."""
|
|
19
19
|
path = Path(destination)
|
|
20
|
-
if path.exists() and not overwrite:
|
|
21
|
-
raise TargetError(f"{destination!r} already exists; pass --overwrite to replace it")
|
|
22
20
|
try:
|
|
23
|
-
path.
|
|
21
|
+
with path.open("wb" if overwrite else "xb") as stream:
|
|
22
|
+
stream.write(body)
|
|
23
|
+
except FileExistsError as error:
|
|
24
|
+
raise TargetError(
|
|
25
|
+
f"{destination!r} already exists; pass --overwrite to replace it"
|
|
26
|
+
) from error
|
|
24
27
|
except OSError as error:
|
|
25
28
|
raise TargetError(f"cannot write {destination!r}: {error}") from error
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|