terp-cli 0.25.0__tar.gz → 0.28.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.
- {terp_cli-0.25.0 → terp_cli-0.28.0}/PKG-INFO +8 -8
- {terp_cli-0.25.0 → terp_cli-0.28.0}/pyproject.toml +8 -8
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/__init__.py +110 -15
- terp_cli-0.28.0/src/terp/cli/_output.py +60 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/capabilities.py +33 -1
- terp_cli-0.28.0/src/terp/cli/dev.py +472 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/verify.py +129 -11
- terp_cli-0.25.0/src/terp/cli/_output.py +0 -26
- terp_cli-0.25.0/src/terp/cli/dev.py +0 -240
- {terp_cli-0.25.0 → terp_cli-0.28.0}/.gitignore +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/_appref.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/_engine.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/_subjects.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/access.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/apidocs.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/authz_surface.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/deploy_safety.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/docker.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/envfile.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/envschema.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/envseams.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/fmt.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/grants.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/jobs.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/leases.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/module_roles.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/openapi.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/outbox.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/ports.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/profiles.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/py.typed +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/routes.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/scaffold.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/schema.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/seed.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/service_accounts.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/smoke.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/users.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/version.py +0 -0
- {terp_cli-0.25.0 → terp_cli-0.28.0}/src/terp/cli/workbench.py +0 -0
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: terp-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.28.0
|
|
4
4
|
Summary: Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs.
|
|
5
5
|
Project-URL: Repository, https://github.com/AITT-NL/terp-framework
|
|
6
6
|
Project-URL: Changelog, https://github.com/AITT-NL/terp-framework/blob/main/CHANGELOG.md
|
|
7
7
|
License-Expression: Apache-2.0
|
|
8
8
|
Requires-Python: >=3.13
|
|
9
9
|
Requires-Dist: pyyaml>=6.0
|
|
10
|
-
Requires-Dist: terp-arch==0.
|
|
11
|
-
Requires-Dist: terp-core==0.
|
|
12
|
-
Requires-Dist: terp-migrations==0.
|
|
10
|
+
Requires-Dist: terp-arch==0.28.0
|
|
11
|
+
Requires-Dist: terp-core==0.28.0
|
|
12
|
+
Requires-Dist: terp-migrations==0.28.0
|
|
13
13
|
Provides-Extra: jobs
|
|
14
|
-
Requires-Dist: terp-cap-outbox==0.
|
|
15
|
-
Requires-Dist: terp-cap-scheduler-apscheduler==0.
|
|
14
|
+
Requires-Dist: terp-cap-outbox==0.28.0; extra == 'jobs'
|
|
15
|
+
Requires-Dist: terp-cap-scheduler-apscheduler==0.28.0; extra == 'jobs'
|
|
16
16
|
Provides-Extra: scheduler
|
|
17
|
-
Requires-Dist: terp-cap-scheduler-apscheduler==0.
|
|
17
|
+
Requires-Dist: terp-cap-scheduler-apscheduler==0.28.0; extra == 'scheduler'
|
|
18
18
|
Provides-Extra: worker
|
|
19
|
-
Requires-Dist: terp-cap-outbox==0.
|
|
19
|
+
Requires-Dist: terp-cap-outbox==0.28.0; extra == 'worker'
|
|
@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "terp-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.28.0"
|
|
8
8
|
description = "Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs."
|
|
9
9
|
requires-python = ">=3.13"
|
|
10
10
|
license = "Apache-2.0"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"terp-core==0.
|
|
12
|
+
"terp-core==0.28.0",
|
|
13
13
|
# `terp migrate` delegates to terp-migrations (lazily imported), keeping Alembic
|
|
14
14
|
# off the path for `terp inspect` / `terp guide` while making migrations work.
|
|
15
|
-
"terp-migrations==0.
|
|
15
|
+
"terp-migrations==0.28.0",
|
|
16
16
|
# `terp guide rules` projects the live rule registry; terp-arch is imported lazily
|
|
17
17
|
# (only when that topic is rendered), so it stays off the common `terp guide` path.
|
|
18
|
-
"terp-arch==0.
|
|
18
|
+
"terp-arch==0.28.0",
|
|
19
19
|
# `terp verify --only env-seams` and `terp smoke` read the compose profiles as data
|
|
20
20
|
# (anchors and `<<:` merge keys resolved), so both answer without a Docker daemon or
|
|
21
21
|
# even the `docker` binary. Declared explicitly rather than leant on: every app
|
|
@@ -29,11 +29,11 @@ dependencies = [
|
|
|
29
29
|
# or a scheduler engine into apps that only use inspect/check/migrate. Deployments can select
|
|
30
30
|
# one process role, while `jobs` is the convenient complete jobs-process bundle.
|
|
31
31
|
[project.optional-dependencies]
|
|
32
|
-
worker = ["terp-cap-outbox==0.
|
|
33
|
-
scheduler = ["terp-cap-scheduler-apscheduler==0.
|
|
32
|
+
worker = ["terp-cap-outbox==0.28.0"]
|
|
33
|
+
scheduler = ["terp-cap-scheduler-apscheduler==0.28.0"]
|
|
34
34
|
jobs = [
|
|
35
|
-
"terp-cap-outbox==0.
|
|
36
|
-
"terp-cap-scheduler-apscheduler==0.
|
|
35
|
+
"terp-cap-outbox==0.28.0",
|
|
36
|
+
"terp-cap-scheduler-apscheduler==0.28.0",
|
|
37
37
|
]
|
|
38
38
|
|
|
39
39
|
[project.scripts]
|
|
@@ -16,7 +16,7 @@ from terp.core import ControlPlane, CorsPolicy, ModuleSpec
|
|
|
16
16
|
if TYPE_CHECKING: # terp-arch stays off the common `terp guide` / `terp inspect` path
|
|
17
17
|
from terp.arch import ScanRoot
|
|
18
18
|
|
|
19
|
-
from terp.cli._output import emit
|
|
19
|
+
from terp.cli._output import emit, use_utf8_output
|
|
20
20
|
from terp.cli.access import (
|
|
21
21
|
build_access_graph_for_app,
|
|
22
22
|
render_access,
|
|
@@ -229,8 +229,11 @@ Adopt it by declaring the table (a project from the template already has it):
|
|
|
229
229
|
|
|
230
230
|
[tool.deptry.per_rule_ignores]
|
|
231
231
|
# `terp.*` is one PEP 420 namespace across distributions, and pydantic
|
|
232
|
-
# re-exports through sqlmodel / pydantic-settings.
|
|
233
|
-
|
|
232
|
+
# re-exports through sqlmodel / pydantic-settings. fastapi, sqlmodel,
|
|
233
|
+
# sqlalchemy and alembic are the stack terp-core and terp-migrations bring,
|
|
234
|
+
# which the template's code, `terp new module` and `terp migrate make` all
|
|
235
|
+
# import directly: constrained by that release, not by a second pin here.
|
|
236
|
+
DEP003 = ["terp", "pydantic", "fastapi", "sqlmodel", "sqlalchemy", "alembic"]
|
|
234
237
|
|
|
235
238
|
and installing the tool: `uv add --dev deptry`.
|
|
236
239
|
|
|
@@ -1554,7 +1557,82 @@ Using capabilities
|
|
|
1554
1557
|
api_key, token, ...) to a string literal — or a recognizable secret-token literal
|
|
1555
1558
|
anywhere — is refused by the no_hardcoded_credentials rule. Wire secrets through
|
|
1556
1559
|
settings / sealed config (ADR 0055), never source.
|
|
1557
|
-
|
|
1560
|
+
- Sending e-mail is a capability too: `import smtplib` is refused by the same
|
|
1561
|
+
no_raw_outbound_http rule, and terp-cap-mail is where it goes — `terp guide mail`.
|
|
1562
|
+
""",
|
|
1563
|
+
"mail": """\
|
|
1564
|
+
Sending e-mail (terp-cap-mail)
|
|
1565
|
+
|
|
1566
|
+
- Mail is a capability concern, never a module concern: `import smtplib` anywhere in the
|
|
1567
|
+
app is refused by the no_raw_outbound_http rule. Install the capability:
|
|
1568
|
+
uv add terp-cap-mail
|
|
1569
|
+
- ONE relay per application, declared ONCE in the composition root (app/main.py) from
|
|
1570
|
+
fixed environment variables:
|
|
1571
|
+
import os
|
|
1572
|
+
from terp.capabilities.mail import configure_mail, mail_settings_from_environment
|
|
1573
|
+
configure_mail(mail_settings_from_environment(os.environ))
|
|
1574
|
+
MAIL_FROM the sender of EVERY message: "Name <noreply@example.com>" or a bare
|
|
1575
|
+
address
|
|
1576
|
+
SMTP_HOST the relay's bare hostname (smtp.example.com)
|
|
1577
|
+
SMTP_PORT optional: 587 for starttls, 465 for tls
|
|
1578
|
+
SMTP_SECURITY starttls (the default) | tls | none
|
|
1579
|
+
SMTP_USERNAME / SMTP_PASSWORD set together, or neither
|
|
1580
|
+
The session is encrypted and the certificate verified; there is no setting that turns
|
|
1581
|
+
verification off. `none` exists for a local mail catcher: a production boot refuses
|
|
1582
|
+
it, and signing in over it is refused everywhere. With neither MAIL_FROM nor SMTP_HOST set,
|
|
1583
|
+
development LOGS each message instead of delivering it and a production boot is
|
|
1584
|
+
REFUSED. Declare the variables in environment.schema.json so a deployment renders them
|
|
1585
|
+
per environment: SMTP_PASSWORD with "format": "secret", SMTP_HOST "format":
|
|
1586
|
+
"hostname", SMTP_PORT "format": "port", and MAIL_FROM + SMTP_HOST in "required" so a
|
|
1587
|
+
deploy without a relay is refused before it boots.
|
|
1588
|
+
- Register the job the capability enqueues (control_plane/jobs.py):
|
|
1589
|
+
from terp.capabilities.mail import MAIL_SEND
|
|
1590
|
+
job_catalog = JobCatalog([MAIL_SEND])
|
|
1591
|
+
A job catalog needs ControlPlane(job_system_actor_id=...) in production — `terp guide
|
|
1592
|
+
jobs`.
|
|
1593
|
+
- Send from INSIDE the write the mail is about: the service's _after_write hook runs in
|
|
1594
|
+
that write's transaction, so the mail commits - or rolls back - with it. A send_mail
|
|
1595
|
+
after the write has returned is a transaction of its own, and nothing ties the two.
|
|
1596
|
+
from terp.core import AuditAction
|
|
1597
|
+
from terp.capabilities.mail import MailMessage, send_mail
|
|
1598
|
+
class OrderService(BaseService[Order, OrderCreate, OrderUpdate]):
|
|
1599
|
+
def _after_write(self, session, entity, action):
|
|
1600
|
+
if action is AuditAction.UPDATED and entity.status == "shipped":
|
|
1601
|
+
send_mail(session, MailMessage(
|
|
1602
|
+
to=[entity.customer_email], # plain addresses, at most MAX_RECIPIENTS
|
|
1603
|
+
subject="Your order has shipped", # one line: a line break is refused
|
|
1604
|
+
text=f"Order {entity.number} is on its way.", # plain text
|
|
1605
|
+
reply_to="sales@example.com", # optional: where a person's answer goes
|
|
1606
|
+
))
|
|
1607
|
+
List the job on the sending module, ModuleSpec(jobs=[MAIL_SEND]), so boot checks that
|
|
1608
|
+
the catalog declares it.
|
|
1609
|
+
There is no per-message sender (every mail is from MAIL_FROM), no HTML body and no
|
|
1610
|
+
attachment. The same notice to many people is one send_mail per person, which also
|
|
1611
|
+
keeps every recipient's address private from the others.
|
|
1612
|
+
- send_mail only ENQUEUES the MAIL_SEND job. Wire the durable outbox so the mail commits
|
|
1613
|
+
— or rolls back — with the write, and survives a restart:
|
|
1614
|
+
from terp.capabilities.outbox import OutboxJobQueue
|
|
1615
|
+
create_app(..., job_queue=OutboxJobQueue(),
|
|
1616
|
+
require_durable_jobs=settings.is_production)
|
|
1617
|
+
and run `terp jobs worker` beside the API. A relay that is down is retried with
|
|
1618
|
+
backoff and ends dead-lettered: `terp outbox backlog` counts it and `terp outbox
|
|
1619
|
+
dead-letters` lists it. With the
|
|
1620
|
+
in-process default queue (no worker) the mail is sent inline, and a failing relay
|
|
1621
|
+
fails the request that asked for it.
|
|
1622
|
+
- Test without a relay:
|
|
1623
|
+
from terp.capabilities.mail import (
|
|
1624
|
+
CapturingMailTransport, MailSettings, configure_mail, reset_mail)
|
|
1625
|
+
sent = CapturingMailTransport()
|
|
1626
|
+
configure_mail(MailSettings(sender="App <noreply@example.test>",
|
|
1627
|
+
host="smtp.example.test"), transport=sent)
|
|
1628
|
+
...exercise the feature...
|
|
1629
|
+
assert sent.sent[0]["Subject"] == "Your order has shipped"
|
|
1630
|
+
reset_mail()
|
|
1631
|
+
- A provider reachable only over its HTTP API is a different transport, declared in the
|
|
1632
|
+
same place: configure_mail(settings, transport=<a callable built on
|
|
1633
|
+
terp.capabilities.egress>). Call sites stay send_mail either way.
|
|
1634
|
+
""",
|
|
1635
|
+
"migrations": """\
|
|
1558
1636
|
Database migrations (terp migrate)
|
|
1559
1637
|
|
|
1560
1638
|
- Each table-owning package (capability or app module) owns an INDEPENDENT, linear
|
|
@@ -1684,6 +1762,10 @@ Frontend module screens (@terpjs/react-core)
|
|
|
1684
1762
|
style={} / className / module stylesheets -> layout via Stack/DetailList; design tokens
|
|
1685
1763
|
<a href="/..."> -> the router's Link (role-aware, no reload)
|
|
1686
1764
|
deep imports (@terpjs/*/src, @terpjs/*/dist) -> import from the package root only
|
|
1765
|
+
data-terp / data-terp-* anywhere in src -> compose the component that renders it
|
|
1766
|
+
The data-terp markers are react-core's own: its stylesheet and the runtime layout check
|
|
1767
|
+
trust them. A framework screen you replace (renderTerpApp({ login })) is yours, so its
|
|
1768
|
+
tests find it by role and accessible name, not by the framework screen's markers.
|
|
1687
1769
|
- Frontend security defaults (each its own lint rule, same error-only footing):
|
|
1688
1770
|
dangerouslySetInnerHTML and DOM HTML-injection sinks (innerHTML/outerHTML/
|
|
1689
1771
|
insertAdjacentHTML/document.write) are refused — render text, or Markdown from
|
|
@@ -1709,6 +1791,12 @@ Frontend module screens (@terpjs/react-core)
|
|
|
1709
1791
|
OpenAPI export) and unwrap(...) which throws a typed ApiError carrying code/status.
|
|
1710
1792
|
- The one governed opt-out is a justified `// terp-allow-<rule>: <reason>` marker whose
|
|
1711
1793
|
counts must exactly match the app's checked-in escape-hatch-budget.json (a ratchet).
|
|
1794
|
+
- Unit tests sit beside the code as src/**/*.test.tsx and run with
|
|
1795
|
+
npm --prefix frontend test (vitest in jsdom; `terp verify` runs it too). jsdom has no
|
|
1796
|
+
<dialog> modal API, so frontend/vitest.setup.ts calls installDialogPolyfill() from
|
|
1797
|
+
@terpjs/react-core/testing: a test can open a ConfirmDialog and confirm it, cancel it or
|
|
1798
|
+
press Escape. Focus and the inert page behind a modal are not emulated there; they
|
|
1799
|
+
belong to the browser suite in conformance/.
|
|
1712
1800
|
- Run the lint locally: npm --prefix frontend run lint (part of the gate).
|
|
1713
1801
|
""",
|
|
1714
1802
|
"dataview": """\
|
|
@@ -2142,11 +2230,15 @@ Compliant decision path for outbound HTTP
|
|
|
2142
2230
|
1. Preserve the requested integration and its external contract. Removing the live
|
|
2143
2231
|
call, returning static/local data, or moving the client import to an unscanned
|
|
2144
2232
|
helper only to make the gate green is not a compliant fix.
|
|
2145
|
-
2. Use
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2233
|
+
2. Use the maintained capability whose semantics match:
|
|
2234
|
+
- an HTTP call to a host you can name: terp-cap-egress (EgressClient, with that
|
|
2235
|
+
exact host on its EgressPolicy allowlist);
|
|
2236
|
+
- signed webhook POST delivery to subscriber URLs: terp-cap-webhooks;
|
|
2237
|
+
- sending e-mail: terp-cap-mail (configure_mail once in the composition root,
|
|
2238
|
+
send_mail per message) - never smtplib. `terp guide mail`.
|
|
2239
|
+
3. There is no sanctioned path to an ARBITRARY host: EgressPolicy takes exact hostnames
|
|
2240
|
+
and refuses wildcards, so a feature that must fetch whatever URL a user supplies has
|
|
2241
|
+
no compliant implementation today.
|
|
2150
2242
|
4. When no matching capability exists, stop and report the missing capability. Leave
|
|
2151
2243
|
the check red until a human approves an escape hatch or the platform supplies a
|
|
2152
2244
|
reviewed adapter capability. Do not create an app-local helper package merely to
|
|
@@ -3499,19 +3591,21 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
3499
3591
|
dev_parser.add_argument(
|
|
3500
3592
|
"--port",
|
|
3501
3593
|
type=int,
|
|
3502
|
-
default=
|
|
3594
|
+
default=None,
|
|
3503
3595
|
help=(
|
|
3504
|
-
|
|
3505
|
-
"
|
|
3596
|
+
"Backend host port (default: the pair claimed for this checkout, the one "
|
|
3597
|
+
"`terp ports show` prints and a workbench and docker compose use; claimed "
|
|
3598
|
+
f"and published on first run; {DEFAULT_API_PORT} if none can be claimed)"
|
|
3506
3599
|
),
|
|
3507
3600
|
)
|
|
3508
3601
|
dev_parser.add_argument(
|
|
3509
3602
|
"--web-port",
|
|
3510
3603
|
type=int,
|
|
3511
|
-
default=
|
|
3604
|
+
default=None,
|
|
3512
3605
|
help=(
|
|
3513
|
-
|
|
3514
|
-
"
|
|
3606
|
+
"Frontend host port (default: the web half of the same claimed pair; "
|
|
3607
|
+
f"{DEFAULT_WEB_PORT} if none can be claimed); the frontend dev server refuses "
|
|
3608
|
+
"a taken port rather than wandering to the next one"
|
|
3515
3609
|
),
|
|
3516
3610
|
)
|
|
3517
3611
|
dev_parser.add_argument(
|
|
@@ -3853,6 +3947,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
3853
3947
|
|
|
3854
3948
|
def main(argv: Sequence[str] | None = None) -> None:
|
|
3855
3949
|
"""Console entry point."""
|
|
3950
|
+
use_utf8_output()
|
|
3856
3951
|
parser = _build_parser()
|
|
3857
3952
|
args = parser.parse_args(argv)
|
|
3858
3953
|
if args.command == "inspect" and args.inspect_command == "control-plane":
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""The one place ``terp``'s command dispatch writes to standard output.
|
|
2
|
+
|
|
3
|
+
A CLI's job is to print, so ``no_print`` is not a defect report against this package --
|
|
4
|
+
it is the reason ``packages/backend/cli`` is recorded in
|
|
5
|
+
``packages/backend/UNSCANNED.json`` rather than scanned. What the record is for is the
|
|
6
|
+
direction of travel: a count there may fall and never rise, so the dispatcher acquiring
|
|
7
|
+
one more bare ``print`` per command added is the shape that makes it rise forever.
|
|
8
|
+
|
|
9
|
+
One seam fixes that and is worth having on its own terms. Everything the dispatcher
|
|
10
|
+
says to an operator goes through here, which is where a ``--quiet``, a machine-readable
|
|
11
|
+
envelope, or a decision to route diagnostics to stderr would go. Thirty-eight call sites
|
|
12
|
+
answering that question independently is thirty-eight places such a change has to find.
|
|
13
|
+
|
|
14
|
+
Deliberately not a logger. This is a command's ANSWER, not a diagnostic about producing
|
|
15
|
+
it: it belongs on stdout unconditionally, it is what a shell pipes into the next thing,
|
|
16
|
+
and a log level that could suppress it would make the tool lie about having run.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import codecs
|
|
22
|
+
import sys
|
|
23
|
+
|
|
24
|
+
__all__ = ["emit", "use_utf8_output"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def emit(text: object = "") -> None:
|
|
28
|
+
"""Write one line of command output to stdout, flushed.
|
|
29
|
+
|
|
30
|
+
Flushed because a pipe is block-buffered: a one-shot command loses nothing by it, but
|
|
31
|
+
``terp dev`` runs until it is stopped, and a line saying it restarted the backend is only
|
|
32
|
+
worth reading while that is true. Unflushed, it sat in the buffer until the process ended,
|
|
33
|
+
and a stopped process's buffer is simply lost.
|
|
34
|
+
"""
|
|
35
|
+
print(text, flush=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def use_utf8_output() -> None:
|
|
39
|
+
"""Write standard output and error as UTF-8 where the stream would not.
|
|
40
|
+
|
|
41
|
+
On Windows a pipe or a file takes the ANSI code page -- cp1252 on a Western install --
|
|
42
|
+
and a pipe is exactly how ``terp`` is read by an agent, an editor task or a workbench,
|
|
43
|
+
none of which is a console. cp1252 has no ``→``, so the line ``terp dev`` prints before
|
|
44
|
+
it starts the servers ended the command with ``UnicodeEncodeError`` instead; and text
|
|
45
|
+
this CLI does not author -- a module's label, a finding quoting a source line -- can do
|
|
46
|
+
the same to any command. UTF-8 encodes every string, and it is the default Python itself
|
|
47
|
+
moves to for these streams (PEP 686).
|
|
48
|
+
|
|
49
|
+
Changed only where it is not already UTF-8, so a console (which Python already drives
|
|
50
|
+
through the console API as UTF-8) and every Linux or macOS terminal are left exactly as
|
|
51
|
+
they were. A stream that cannot be reconfigured -- none at all, or a replacement such
|
|
52
|
+
as ``io.StringIO`` -- is left alone. ``backslashreplace`` rather than ``strict``, so the
|
|
53
|
+
one thing UTF-8 cannot encode, a lone surrogate from an undecodable file name, prints
|
|
54
|
+
as an escape rather than ending the command.
|
|
55
|
+
"""
|
|
56
|
+
for stream in (sys.stdout, sys.stderr):
|
|
57
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
58
|
+
if reconfigure is None or codecs.lookup(stream.encoding).name == "utf-8":
|
|
59
|
+
continue
|
|
60
|
+
reconfigure(encoding="utf-8", errors="backslashreplace")
|
|
@@ -44,6 +44,17 @@ class Capability:
|
|
|
44
44
|
guide: str | None = None
|
|
45
45
|
"""``terp guide`` topic covering it, when one exists."""
|
|
46
46
|
|
|
47
|
+
inventory: str | None = None
|
|
48
|
+
"""The command that lists what this app holds of it, when the CLI has one.
|
|
49
|
+
|
|
50
|
+
The registry is built from packages, so it can say an app *has* a capability and
|
|
51
|
+
nothing about what the app has *done* with it. That gap misled a reader into
|
|
52
|
+
concluding an app had no machine credentials because the identity line said only
|
|
53
|
+
"user store": the service accounts it had issued were one command away, and nothing
|
|
54
|
+
on this screen named the command. Rendered for installed capabilities only — an
|
|
55
|
+
unadopted one has nothing to list.
|
|
56
|
+
"""
|
|
57
|
+
|
|
47
58
|
@property
|
|
48
59
|
def distribution(self) -> str:
|
|
49
60
|
"""The PyPI distribution name."""
|
|
@@ -62,6 +73,7 @@ CAPABILITIES: tuple[Capability, ...] = (
|
|
|
62
73
|
kind="routed",
|
|
63
74
|
wiring="create_app(..., discover_capabilities=True)",
|
|
64
75
|
guide="access",
|
|
76
|
+
inventory="terp grant list <subject>",
|
|
65
77
|
),
|
|
66
78
|
Capability(
|
|
67
79
|
name="audit",
|
|
@@ -90,6 +102,13 @@ CAPABILITIES: tuple[Capability, ...] = (
|
|
|
90
102
|
wiring="create_app(..., discover_capabilities=True)",
|
|
91
103
|
guide="files",
|
|
92
104
|
),
|
|
105
|
+
Capability(
|
|
106
|
+
name="mfa",
|
|
107
|
+
summary="A TOTP second factor with recovery codes, wired into login through the second_factor seam.",
|
|
108
|
+
kind="library",
|
|
109
|
+
wiring="build_login_module(..., second_factor=MfaService()) + specs=[mfa.module]",
|
|
110
|
+
guide="passwords",
|
|
111
|
+
),
|
|
93
112
|
Capability(
|
|
94
113
|
name="groups",
|
|
95
114
|
summary="Admin-managed user groups that bundle access-grant permissions.",
|
|
@@ -99,9 +118,11 @@ CAPABILITIES: tuple[Capability, ...] = (
|
|
|
99
118
|
),
|
|
100
119
|
Capability(
|
|
101
120
|
name="identity",
|
|
102
|
-
summary="Persisted user store backing authentication.",
|
|
121
|
+
summary="Persisted user store backing authentication, and the service accounts an integration signs in with.",
|
|
103
122
|
kind="library",
|
|
104
123
|
wiring="create_app(..., discover_capabilities=True)",
|
|
124
|
+
guide="package-boundaries",
|
|
125
|
+
inventory="terp service-account list",
|
|
105
126
|
),
|
|
106
127
|
Capability(
|
|
107
128
|
name="jobs_celery",
|
|
@@ -123,6 +144,14 @@ CAPABILITIES: tuple[Capability, ...] = (
|
|
|
123
144
|
kind="library",
|
|
124
145
|
wiring="create_app(..., lease_store=DatabaseLeaseStore()) + specs=[leases.module]",
|
|
125
146
|
guide="leases",
|
|
147
|
+
inventory="terp leases list",
|
|
148
|
+
),
|
|
149
|
+
Capability(
|
|
150
|
+
name="mail",
|
|
151
|
+
summary="Send e-mail through one declared relay — encrypted, a fixed sender, delivered by the jobs seam so a send commits with its write.",
|
|
152
|
+
kind="library",
|
|
153
|
+
wiring="configure_mail(mail_settings_from_environment(os.environ)) + JobCatalog([MAIL_SEND])",
|
|
154
|
+
guide="mail",
|
|
126
155
|
),
|
|
127
156
|
Capability(
|
|
128
157
|
name="oidc",
|
|
@@ -371,6 +400,7 @@ def render_capabilities(*, fmt: str = "text", root: str | pathlib.Path = ".") ->
|
|
|
371
400
|
"kind": cap.kind,
|
|
372
401
|
"wiring": cap.wiring,
|
|
373
402
|
"guide": cap.guide,
|
|
403
|
+
"inventory": cap.inventory,
|
|
374
404
|
"installed": version is not None,
|
|
375
405
|
"version": version,
|
|
376
406
|
"seams": list(seams.get(cap.name, ())),
|
|
@@ -410,6 +440,8 @@ def render_capabilities(*, fmt: str = "text", root: str | pathlib.Path = ".") ->
|
|
|
410
440
|
for cap, version in installed:
|
|
411
441
|
lines.append(f" {cap.distribution:<32} {version:<10} {cap.kind}")
|
|
412
442
|
lines.append(f" {cap.summary}")
|
|
443
|
+
if cap.inventory is not None:
|
|
444
|
+
lines.append(f" what this app has: uv run {cap.inventory}")
|
|
413
445
|
missing = unwired.get(cap.name, ())
|
|
414
446
|
if missing:
|
|
415
447
|
lines.append(f" not used here: {', '.join(missing)}")
|