agnara-cli 0.1.0a8__py3-none-any.whl
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/__init__.py +37 -0
- agnara_cli/__main__.py +7 -0
- agnara_cli/_app.py +398 -0
- agnara_cli/_app_template.py +447 -0
- agnara_cli/_apps.py +117 -0
- agnara_cli/_context.py +170 -0
- agnara_cli/_exposure_template.py +113 -0
- agnara_cli/_generate.py +179 -0
- agnara_cli/_graph.py +145 -0
- agnara_cli/_inspect.py +48 -0
- agnara_cli/_main.py +111 -0
- agnara_cli/_manifest.py +324 -0
- agnara_cli/_minimal_template.py +237 -0
- agnara_cli/_names.py +53 -0
- agnara_cli/_project.py +128 -0
- agnara_cli/_render.py +147 -0
- agnara_cli/_schema.py +173 -0
- agnara_cli/_target.py +189 -0
- agnara_cli/_template_format.py +70 -0
- agnara_cli/_templates.py +368 -0
- agnara_cli/_view.py +149 -0
- agnara_cli/_write.py +25 -0
- agnara_cli/py.typed +0 -0
- agnara_cli-0.1.0a8.dist-info/METADATA +117 -0
- agnara_cli-0.1.0a8.dist-info/RECORD +28 -0
- agnara_cli-0.1.0a8.dist-info/WHEEL +4 -0
- agnara_cli-0.1.0a8.dist-info/entry_points.txt +2 -0
- agnara_cli-0.1.0a8.dist-info/licenses/LICENSE +202 -0
agnara_cli/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Project introspection and scaffolding CLI for Agnara.
|
|
2
|
+
|
|
3
|
+
Owns ``agnara project create``, ``agnara app create``, capability
|
|
4
|
+
generation, introspection commands and diagnostics. Templates live here,
|
|
5
|
+
never in ``agnara-core``.
|
|
6
|
+
|
|
7
|
+
Currently implemented: ``agnara project create`` and ``agnara app create``,
|
|
8
|
+
which generate a project and a bounded context from a reviewable plan,
|
|
9
|
+
``agnara apps``, which lists what ``agnara.toml`` declares without importing
|
|
10
|
+
anything, ``agnara inspect``, which imports a compiled
|
|
11
|
+
application and presents its filtered protocol-neutral introspection
|
|
12
|
+
snapshot as text or as deterministic JSON, ``agnara graph``, which draws the
|
|
13
|
+
relationships in that same snapshot, ``agnara schema openapi``, which exports
|
|
14
|
+
the OpenAPI document a composition already produced, and ``agnara context``,
|
|
15
|
+
which writes the visible capabilities as Markdown for a model to read. Exposure
|
|
16
|
+
selection and capability generation remain ahead in EPIC 0A.
|
|
17
|
+
|
|
18
|
+
Depends on ``agnara-core``. Must not import a sibling adapter.
|
|
19
|
+
See ``ARCHITECTURE.md`` section 15, ``docs/CLI_SPEC.md`` and EPIC 0A.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
#: The supported programmatic surface of this distribution.
|
|
23
|
+
#:
|
|
24
|
+
#: ``agnara-cli`` is consumed as the ``agnara`` command. The four names below
|
|
25
|
+
#: are what a caller needs to run that command in-process — a test harness, a
|
|
26
|
+
#: task runner, a wrapper script — and nothing else is a contract. Manifest
|
|
27
|
+
#: parsing, generation planning and target resolution are how the commands are
|
|
28
|
+
#: implemented; they were re-exported from underscore-prefixed modules without
|
|
29
|
+
#: ever being documented, used or designed as an API (ADR 0076).
|
|
30
|
+
from ._main import EXIT_FAILED, EXIT_OK, EXIT_USAGE, main
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"EXIT_FAILED",
|
|
34
|
+
"EXIT_OK",
|
|
35
|
+
"EXIT_USAGE",
|
|
36
|
+
"main",
|
|
37
|
+
]
|
agnara_cli/__main__.py
ADDED
agnara_cli/_app.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""``agnara app create``: the second generator, and the one that proves the first.
|
|
2
|
+
|
|
3
|
+
It reuses `_generate` unchanged apart from one addition the first generator did
|
|
4
|
+
not need: declaring that a file is *meant* to be rewritten, so adding a table to
|
|
5
|
+
``agnara.toml`` is an update rather than a conflict with itself.
|
|
6
|
+
|
|
7
|
+
The manifest edit appends to the existing text instead of re-serializing it.
|
|
8
|
+
Re-serializing would silently discard comments and ordering an operator wrote,
|
|
9
|
+
which AGENTS.md's "update project metadata safely" and "never silently delete
|
|
10
|
+
modified files" both rule out. See ADR 0061.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from agnara_cli._app_template import app_files
|
|
21
|
+
from agnara_cli._generate import (
|
|
22
|
+
GenerationError,
|
|
23
|
+
GenerationPlan,
|
|
24
|
+
apply_plan,
|
|
25
|
+
build_plan,
|
|
26
|
+
plan_json,
|
|
27
|
+
render_plan,
|
|
28
|
+
)
|
|
29
|
+
from agnara_cli._manifest import (
|
|
30
|
+
ARCHITECTURES,
|
|
31
|
+
EXPOSURES,
|
|
32
|
+
MANIFEST_FILENAME,
|
|
33
|
+
ProjectManifest,
|
|
34
|
+
find_manifest,
|
|
35
|
+
load_manifest,
|
|
36
|
+
)
|
|
37
|
+
from agnara_cli._minimal_template import minimal_app_files
|
|
38
|
+
from agnara_cli._names import validated_identifier
|
|
39
|
+
|
|
40
|
+
__all__ = ["add_app_alias_parsers", "add_app_parser", "run_app_create"]
|
|
41
|
+
|
|
42
|
+
#: Convenience alias -> the profile it fixes, from `docs/CLI_SPEC.md`
|
|
43
|
+
#: "Convenience aliases". That document lists exactly these four and warns
|
|
44
|
+
#: against adding scaffolding surface casually, so `core` and `full` have no
|
|
45
|
+
#: alias.
|
|
46
|
+
#:
|
|
47
|
+
#: `app-agent` maps to the `agentic` profile: the alias and the profile are
|
|
48
|
+
#: spelled differently on purpose, and only this table relates them.
|
|
49
|
+
ALIASES: dict[str, str] = {
|
|
50
|
+
"app-api": "api",
|
|
51
|
+
"app-mcp": "mcp",
|
|
52
|
+
"app-agent": "agentic",
|
|
53
|
+
"app-worker": "worker",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#: Architecture -> the template that generates it. `ARCHITECTURES` is the
|
|
57
|
+
#: manifest vocabulary and is deliberately wider: `docs/CLI_SPEC.md` calls
|
|
58
|
+
#: `vertical` a "potential future profile", so it is a name a manifest may
|
|
59
|
+
#: carry before a generator exists for it. Selecting one that is not here is
|
|
60
|
+
#: refused rather than quietly generating a different layout.
|
|
61
|
+
TEMPLATES: dict[str, Callable[[str, str, tuple[str, ...]], dict[str, str]]] = {
|
|
62
|
+
"modular-hexagonal": app_files,
|
|
63
|
+
"minimal": minimal_app_files,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#: Profile -> the exposures it starts an app with, in the order it scaffolds
|
|
67
|
+
#: them. The mapping is the table in `docs/CLI_SPEC.md` "Profiles".
|
|
68
|
+
#:
|
|
69
|
+
#: A profile is a scaffolding alias and nothing more (ADR 0013): it resolves to
|
|
70
|
+
#: exposures and then disappears, so no profile name is ever written to
|
|
71
|
+
#: `agnara.toml`. Recording one would make it look like a runtime app type,
|
|
72
|
+
#: which is exactly what ADR 0013 refuses.
|
|
73
|
+
PROFILES: dict[str, tuple[str, ...]] = {
|
|
74
|
+
"core": (),
|
|
75
|
+
"api": ("http",),
|
|
76
|
+
"mcp": ("mcp",),
|
|
77
|
+
"agentic": ("mcp", "a2a"),
|
|
78
|
+
"worker": ("tasks", "events"),
|
|
79
|
+
"full": ("http", "mcp", "a2a", "events", "tasks"),
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _add_create_arguments(parser: argparse.ArgumentParser, *, profile: str | None) -> None:
|
|
84
|
+
"""Define ``app create`` once, for the command and for every alias.
|
|
85
|
+
|
|
86
|
+
`docs/CLI_SPEC.md` requires the aliases to "not create separate code paths",
|
|
87
|
+
so they are not a second parser that happens to agree today -- they are this
|
|
88
|
+
one, with ``profile`` fixed.
|
|
89
|
+
|
|
90
|
+
When `profile` is given the parser does not offer ``--profile``: the alias
|
|
91
|
+
*is* the profile, and exposing both would let a user write
|
|
92
|
+
``agnara app-mcp tools --profile worker``, a command that contradicts
|
|
93
|
+
itself and whose answer would only ever be arbitrary.
|
|
94
|
+
"""
|
|
95
|
+
parser.add_argument("name", help="the app name; a single lower-case Python identifier")
|
|
96
|
+
parser.add_argument(
|
|
97
|
+
"--architecture",
|
|
98
|
+
# The manifest vocabulary, not just what is implemented: a reserved
|
|
99
|
+
# name earns the explanation in `_resolved_architecture` rather than
|
|
100
|
+
# argparse's "invalid choice", which reads like a typo.
|
|
101
|
+
choices=sorted(ARCHITECTURES),
|
|
102
|
+
help=(
|
|
103
|
+
"the layout to generate. Defaults to the project's "
|
|
104
|
+
"[defaults] architecture in agnara.toml."
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
if profile is None:
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--profile",
|
|
110
|
+
choices=sorted(PROFILES),
|
|
111
|
+
help=(
|
|
112
|
+
"start from a named set of exposures. Scaffolding only: the "
|
|
113
|
+
"profile is not recorded, its exposures are. Defaults to core."
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
parser.add_argument(
|
|
117
|
+
"--with",
|
|
118
|
+
dest="exposures",
|
|
119
|
+
metavar="a,b",
|
|
120
|
+
help=(
|
|
121
|
+
"comma-separated inbound adapters to scaffold: "
|
|
122
|
+
+ ", ".join(EXPOSURES)
|
|
123
|
+
+ ". Each adds one adapters/inbound/<name>.py."
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
parser.add_argument(
|
|
127
|
+
"--project",
|
|
128
|
+
metavar="DIR",
|
|
129
|
+
help=(
|
|
130
|
+
f"directory to search for {MANIFEST_FILENAME}; its ancestors are "
|
|
131
|
+
"searched too. Defaults to the working directory."
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
"--dry-run",
|
|
136
|
+
action="store_true",
|
|
137
|
+
help="show what would be written and stop, creating nothing",
|
|
138
|
+
)
|
|
139
|
+
parser.add_argument(
|
|
140
|
+
"--overwrite",
|
|
141
|
+
action="store_true",
|
|
142
|
+
help="allow replacing files that already exist",
|
|
143
|
+
)
|
|
144
|
+
parser.add_argument(
|
|
145
|
+
"--json",
|
|
146
|
+
action="store_true",
|
|
147
|
+
help="emit the plan as deterministic JSON",
|
|
148
|
+
)
|
|
149
|
+
parser.set_defaults(handler=run_app_create, profile=profile)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def add_app_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
153
|
+
"""Register ``app`` and its subcommands on the root parser."""
|
|
154
|
+
parser = subparsers.add_parser(
|
|
155
|
+
"app",
|
|
156
|
+
help="create and manage apps inside a project",
|
|
157
|
+
description="App-level scaffolding. An app is one bounded context.",
|
|
158
|
+
)
|
|
159
|
+
actions = parser.add_subparsers(dest="app_command", required=True, metavar="ACTION")
|
|
160
|
+
create = actions.add_parser(
|
|
161
|
+
"create",
|
|
162
|
+
help="create an app in the current project",
|
|
163
|
+
description=(
|
|
164
|
+
"Generate a bounded context and declare it in agnara.toml. "
|
|
165
|
+
"Nothing is written until the whole plan is known. The command "
|
|
166
|
+
"never prompts."
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
_add_create_arguments(create, profile=None)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def add_app_alias_parsers(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None:
|
|
173
|
+
"""Register the convenience aliases from `docs/CLI_SPEC.md`.
|
|
174
|
+
|
|
175
|
+
Each is ``agnara app create`` with one profile fixed. They exist for
|
|
176
|
+
discoverability, and that document is explicit that they "MUST behave as
|
|
177
|
+
aliases only", so they share `_add_create_arguments` and `run_app_create`
|
|
178
|
+
rather than reimplementing either.
|
|
179
|
+
"""
|
|
180
|
+
for alias, profile in ALIASES.items():
|
|
181
|
+
parser = subparsers.add_parser(
|
|
182
|
+
alias,
|
|
183
|
+
help=f"shorthand for 'app create --profile {profile}'",
|
|
184
|
+
description=(
|
|
185
|
+
f"Exactly 'agnara app create NAME --profile {profile}'. Every "
|
|
186
|
+
"other option of that command applies here unchanged."
|
|
187
|
+
),
|
|
188
|
+
)
|
|
189
|
+
_add_create_arguments(parser, profile=profile)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
#: Identifiers the generated ``module.py`` already binds at module scope. The
|
|
193
|
+
#: app's own name becomes ``<name> = App("<name>")`` in that module, so an app
|
|
194
|
+
#: called ``app`` or ``register`` would generate code that shadows itself and
|
|
195
|
+
#: fails the moment the project imports it.
|
|
196
|
+
_RESERVED = frozenset(
|
|
197
|
+
{
|
|
198
|
+
"annotations",
|
|
199
|
+
"app",
|
|
200
|
+
"dependencies",
|
|
201
|
+
"get_record",
|
|
202
|
+
"list_records",
|
|
203
|
+
"module",
|
|
204
|
+
"provide_records",
|
|
205
|
+
"provider",
|
|
206
|
+
"register",
|
|
207
|
+
}
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _validated_name(name: str) -> str:
|
|
212
|
+
return validated_identifier(
|
|
213
|
+
name,
|
|
214
|
+
subject="app name",
|
|
215
|
+
reserved=_RESERVED,
|
|
216
|
+
reserved_because="the generated module already binds that name",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _manifest(arguments: argparse.Namespace) -> tuple[ProjectManifest, Path]:
|
|
221
|
+
start = Path(arguments.project) if arguments.project else Path.cwd()
|
|
222
|
+
if arguments.project and not start.is_dir():
|
|
223
|
+
raise GenerationError(f"{start}: is not a directory")
|
|
224
|
+
found = find_manifest(start)
|
|
225
|
+
if found is None:
|
|
226
|
+
raise GenerationError(
|
|
227
|
+
f"no {MANIFEST_FILENAME} found in {start.resolve()} or any parent "
|
|
228
|
+
"directory. Create a project first with 'agnara project create'."
|
|
229
|
+
)
|
|
230
|
+
return load_manifest(found), found
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _declaration(name: str, project: str, architecture: str, exposures: tuple[str, ...]) -> str:
|
|
234
|
+
"""The manifest table this app adds, rendered deterministically."""
|
|
235
|
+
listed = ", ".join(f'"{exposure}"' for exposure in exposures)
|
|
236
|
+
return (
|
|
237
|
+
f"\n[apps.{name}]\n"
|
|
238
|
+
f'module = "{project}.apps.{name}"\n'
|
|
239
|
+
f'path = "src/{project}/apps/{name}"\n'
|
|
240
|
+
f'architecture = "{architecture}"\n'
|
|
241
|
+
f"exposures = [{listed}]\n"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _updated_manifest(
|
|
246
|
+
manifest: ProjectManifest,
|
|
247
|
+
source: Path,
|
|
248
|
+
name: str,
|
|
249
|
+
architecture: str,
|
|
250
|
+
exposures: tuple[str, ...],
|
|
251
|
+
) -> str:
|
|
252
|
+
"""Append one app table, preserving everything already in the file.
|
|
253
|
+
|
|
254
|
+
Appending rather than re-serializing is deliberate: a manifest carries
|
|
255
|
+
comments and an ordering its author chose, and a generator that rewrote it
|
|
256
|
+
from a parsed model would delete both without saying so.
|
|
257
|
+
"""
|
|
258
|
+
# Read without newline translation so the file keeps the line endings it
|
|
259
|
+
# already has: rewriting a CRLF manifest as LF would change every line an
|
|
260
|
+
# operator wrote, which is exactly what appending is meant to avoid.
|
|
261
|
+
with source.open(encoding="utf-8", newline="") as handle:
|
|
262
|
+
existing = handle.read()
|
|
263
|
+
newline = "\r\n" if "\r\n" in existing else "\n"
|
|
264
|
+
if not existing.endswith("\n"):
|
|
265
|
+
existing += newline
|
|
266
|
+
declaration = _declaration(name, manifest.name, architecture, exposures)
|
|
267
|
+
return existing + declaration.replace("\n", newline)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _resolved_architecture(requested: str | None, manifest: ProjectManifest, source: Path) -> str:
|
|
271
|
+
"""The architecture to generate, and to record for what was generated.
|
|
272
|
+
|
|
273
|
+
An explicit ``--architecture`` wins; otherwise the project's declared
|
|
274
|
+
default applies. Either way the result must name a template we have,
|
|
275
|
+
because the manifest entry is a claim about the files on disk: writing one
|
|
276
|
+
layout while declaring another is what makes ``agnara apps`` lie.
|
|
277
|
+
"""
|
|
278
|
+
architecture = requested or manifest.default_architecture
|
|
279
|
+
if architecture in TEMPLATES:
|
|
280
|
+
return architecture
|
|
281
|
+
|
|
282
|
+
available = ", ".join(sorted(TEMPLATES))
|
|
283
|
+
if requested is None:
|
|
284
|
+
raise GenerationError(
|
|
285
|
+
f"{source}: [defaults] architecture is {architecture!r}, which has "
|
|
286
|
+
f"no template yet. Choose one with --architecture ({available}), "
|
|
287
|
+
"or change the project default."
|
|
288
|
+
)
|
|
289
|
+
raise GenerationError(
|
|
290
|
+
f"architecture {architecture!r} is a reserved name with no generator "
|
|
291
|
+
f"yet. Available: {available}."
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _requested_exposures(requested: str | None) -> list[str]:
|
|
296
|
+
"""Parse ``--with``, rejecting a malformed list or an unknown exposure."""
|
|
297
|
+
if requested is None:
|
|
298
|
+
return []
|
|
299
|
+
|
|
300
|
+
seen: list[str] = []
|
|
301
|
+
for entry in requested.split(","):
|
|
302
|
+
exposure = entry.strip()
|
|
303
|
+
if not exposure:
|
|
304
|
+
raise GenerationError(
|
|
305
|
+
f"--with {requested!r} has an empty entry; list exposures as "
|
|
306
|
+
"'http,mcp' with no trailing or repeated commas"
|
|
307
|
+
)
|
|
308
|
+
if exposure not in EXPOSURES:
|
|
309
|
+
raise GenerationError(
|
|
310
|
+
f"unknown exposure {exposure!r}. Available: {', '.join(EXPOSURES)}"
|
|
311
|
+
)
|
|
312
|
+
if exposure not in seen:
|
|
313
|
+
seen.append(exposure)
|
|
314
|
+
return seen
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _resolved_exposures(
|
|
318
|
+
requested: str | None, profile: str | None, architecture: str
|
|
319
|
+
) -> tuple[str, ...]:
|
|
320
|
+
"""The inbound adapters to scaffold, and to record for what was scaffolded.
|
|
321
|
+
|
|
322
|
+
A profile contributes its initial exposures and then disappears: ADR 0013
|
|
323
|
+
makes profiles scaffolding aliases, not runtime app types, so nothing about
|
|
324
|
+
the profile reaches the manifest. Only the result does.
|
|
325
|
+
|
|
326
|
+
``--with`` adds to a profile rather than replacing it. `docs/CLI_SPEC.md`
|
|
327
|
+
permits either reading -- "combined/overridden" -- and ADR 0064 records why
|
|
328
|
+
union wins.
|
|
329
|
+
|
|
330
|
+
Order is the profile's exposures first, then anything ``--with`` adds that
|
|
331
|
+
the profile did not already bring, with repeats dropped. A manifest should
|
|
332
|
+
read back as the request that produced it.
|
|
333
|
+
|
|
334
|
+
Raises:
|
|
335
|
+
GenerationError: an empty entry, an unknown exposure, or any exposure
|
|
336
|
+
at all on an architecture that has no adapters package.
|
|
337
|
+
"""
|
|
338
|
+
seen = list(PROFILES[profile]) if profile is not None else []
|
|
339
|
+
for exposure in _requested_exposures(requested):
|
|
340
|
+
if exposure not in seen:
|
|
341
|
+
seen.append(exposure)
|
|
342
|
+
|
|
343
|
+
if seen and architecture != "modular-hexagonal":
|
|
344
|
+
source = "--with" if profile is None else f"--profile {profile}"
|
|
345
|
+
raise GenerationError(
|
|
346
|
+
f"the {architecture} architecture has no adapters package, so it "
|
|
347
|
+
f"cannot carry an inbound adapter for {', '.join(seen)}. Generate "
|
|
348
|
+
f"this app with --architecture modular-hexagonal, or leave {source} off."
|
|
349
|
+
)
|
|
350
|
+
return tuple(seen)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _plan(arguments: argparse.Namespace) -> tuple[GenerationPlan, ProjectManifest, str]:
|
|
354
|
+
name = _validated_name(arguments.name)
|
|
355
|
+
manifest, source = _manifest(arguments)
|
|
356
|
+
if any(app.name == name for app in manifest.apps):
|
|
357
|
+
raise GenerationError(
|
|
358
|
+
f"{source}: app {name!r} is already declared. Remove it from the "
|
|
359
|
+
"manifest first, or choose another name."
|
|
360
|
+
)
|
|
361
|
+
architecture = _resolved_architecture(
|
|
362
|
+
getattr(arguments, "architecture", None), manifest, source
|
|
363
|
+
)
|
|
364
|
+
exposures = _resolved_exposures(
|
|
365
|
+
getattr(arguments, "exposures", None),
|
|
366
|
+
getattr(arguments, "profile", None),
|
|
367
|
+
architecture,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
root = source.parent
|
|
371
|
+
files = TEMPLATES[architecture](manifest.name, name, exposures)
|
|
372
|
+
files[MANIFEST_FILENAME] = _updated_manifest(manifest, source, name, architecture, exposures)
|
|
373
|
+
plan = build_plan(root, files, updates=(MANIFEST_FILENAME,))
|
|
374
|
+
return plan, manifest, name
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _next_steps(project: str, name: str) -> str:
|
|
378
|
+
return (
|
|
379
|
+
f"\nDeclared {name} in {MANIFEST_FILENAME}. Wire it into the composition "
|
|
380
|
+
f"root, src/{project}/bootstrap.py:\n\n"
|
|
381
|
+
f" from {project}.apps.{name} import module as {name}_module\n\n"
|
|
382
|
+
f" {name}_module.register(app, dependencies)\n"
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def run_app_create(arguments: argparse.Namespace) -> str:
|
|
387
|
+
"""Plan the app, then write it unless this is a dry run."""
|
|
388
|
+
plan, manifest, name = _plan(arguments)
|
|
389
|
+
if arguments.dry_run:
|
|
390
|
+
if arguments.json:
|
|
391
|
+
return json.dumps(plan_json(plan), indent=2, sort_keys=True)
|
|
392
|
+
return render_plan(plan)
|
|
393
|
+
|
|
394
|
+
apply_plan(plan, overwrite=arguments.overwrite)
|
|
395
|
+
if arguments.json:
|
|
396
|
+
return json.dumps(plan_json(plan), indent=2, sort_keys=True)
|
|
397
|
+
written = "\n".join(f"{action.verb} {action.path}" for action in plan.actions)
|
|
398
|
+
return written + "\n" + _next_steps(manifest.name, name)
|