nace-cli 0.2.0__tar.gz → 0.3.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.
- {nace_cli-0.2.0 → nace_cli-0.3.0}/PKG-INFO +18 -5
- {nace_cli-0.2.0 → nace_cli-0.3.0}/README.md +16 -3
- {nace_cli-0.2.0 → nace_cli-0.3.0}/pyproject.toml +2 -2
- {nace_cli-0.2.0 → nace_cli-0.3.0}/pyproject.toml.orig +2 -2
- {nace_cli-0.2.0 → nace_cli-0.3.0}/src/nace_cli/__init__.py +1 -1
- nace_cli-0.3.0/src/nace_cli/_browser.py +241 -0
- {nace_cli-0.2.0 → nace_cli-0.3.0}/src/nace_cli/_cli.py +12 -2
- nace_cli-0.3.0/src/nace_cli/_domains.py +178 -0
- {nace_cli-0.2.0 → nace_cli-0.3.0}/src/nace_cli/_inputs.py +2 -2
- {nace_cli-0.2.0 → nace_cli-0.3.0}/src/nace_cli/_search.py +9 -6
- {nace_cli-0.2.0 → nace_cli-0.3.0}/src/nace_cli/_workspaces.py +20 -139
- {nace_cli-0.2.0 → nace_cli-0.3.0}/LICENSE +0 -0
- {nace_cli-0.2.0 → nace_cli-0.3.0}/src/nace_cli/_decide.py +0 -0
- {nace_cli-0.2.0 → nace_cli-0.3.0}/src/nace_cli/_docops.py +0 -0
- {nace_cli-0.2.0 → nace_cli-0.3.0}/src/nace_cli/_entrypoint.py +0 -0
- {nace_cli-0.2.0 → nace_cli-0.3.0}/src/nace_cli/_jobs.py +0 -0
- {nace_cli-0.2.0 → nace_cli-0.3.0}/src/nace_cli/_login.py +0 -0
- {nace_cli-0.2.0 → nace_cli-0.3.0}/src/nace_cli/_output.py +0 -0
- {nace_cli-0.2.0 → nace_cli-0.3.0}/src/nace_cli/_schemas.py +0 -0
- {nace_cli-0.2.0 → nace_cli-0.3.0}/src/nace_cli/_upload.py +0 -0
- {nace_cli-0.2.0 → nace_cli-0.3.0}/src/nace_cli/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nace-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Nace CLI: calibrated decisions and document jobs from the shell
|
|
5
5
|
Keywords: drex,nace,cli,document-intelligence
|
|
6
6
|
Author: Nace AI
|
|
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
|
|
|
16
16
|
Classifier: Programming Language :: Python :: 3.13
|
|
17
17
|
Classifier: Programming Language :: Python :: 3.14
|
|
18
18
|
Classifier: Typing :: Typed
|
|
19
|
-
Requires-Dist: nace-sdk>=0.
|
|
19
|
+
Requires-Dist: nace-sdk>=0.3.0,<1
|
|
20
20
|
Requires-Dist: truststore>=0.10.4,<1
|
|
21
21
|
Requires-Python: >=3.11
|
|
22
22
|
Project-URL: Homepage, https://console.nace.ai
|
|
@@ -114,7 +114,7 @@ nace schema-version sch_... 1
|
|
|
114
114
|
## Workspaces and search
|
|
115
115
|
|
|
116
116
|
```bash
|
|
117
|
-
nace workspace-create fy25-audit
|
|
117
|
+
nace workspace-create fy25-audit
|
|
118
118
|
nace workspace-use <workspace_id> # later commands use it; --workspace ID or $NACE_WORKSPACE_ID win
|
|
119
119
|
nace upload ./report.pdf --workspace <workspace_id> --path reports/2025/report.pdf
|
|
120
120
|
nace upload ./fy25 --workspace <workspace_id> --ingest # every file under a folder, 8 at a time, then ingested
|
|
@@ -122,16 +122,29 @@ nace ingest --path-prefix reports/ # makes the files searchable
|
|
|
122
122
|
nace workspace-files --status failed
|
|
123
123
|
nace search "What were total liabilities at year end?"
|
|
124
124
|
nace qa "What was total freight spend in FY2024?" # stderr: session <id>; follow up with --session
|
|
125
|
-
nace kg-build
|
|
126
125
|
```
|
|
127
126
|
|
|
128
127
|
`search` and `qa` print the answer as the ndi CLI did: the answer, `evidences (N):` with each evidence as `1. path, page N` and its quote indented under it, then the Ground jobs to poll, the tables the answer refers to and any catalog rows; the route goes to stderr. Lists (`jobs`, `workspaces`, `workspace-files`) print a `<what> (N):` header, an indented line per item and `next: <cursor>` when more pages follow. Uploads go to a workspace only with `--workspace`; without it, to the server. `nace workspace-delete` takes the workspace only from `--workspace`.
|
|
129
128
|
|
|
129
|
+
## Domains
|
|
130
|
+
|
|
131
|
+
A domain is the two-level taxonomy (group and subgroup classes) a workspace classifies its files against. Nace provides some; your account can author its own. A draft is edited in place and published once; a published or Nace-provided domain is frozen, so duplicate it and edit the copy.
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
nace domains list --owner platform --status published # domains (N):, then <domain_id> <slug> v<n> <status> <owner> <display_name>
|
|
135
|
+
nace domains get --id <domain_id> # the domain's line, then its classes
|
|
136
|
+
nace domains create --slug leases --display-name "Lease files" --taxonomy leases.taxonomy.json # --taxonomy: JSON, @FILE or -
|
|
137
|
+
nace domains update --id <domain_id> --display-name "Leases"
|
|
138
|
+
nace domains publish --id <domain_id> # one-way; a taxonomy that fails validation exits 1 and lists each error
|
|
139
|
+
nace domains duplicate --id <domain_id> # a draft copy of any domain you see
|
|
140
|
+
nace workspace-create fy25-leases --domain leases # a published domain's slug; it can't be changed later
|
|
141
|
+
```
|
|
142
|
+
|
|
130
143
|
## Output and exit codes
|
|
131
144
|
|
|
132
145
|
Stdout carries the payload and stderr the status, so `nace parse doc.pdf > doc.md` captures only the document. A wait that runs out prints the job id on stdout. Exit `0` on success, `1` on an API or job failure (including an ingestion that failed a file, from `nace ingest`, `nace upload --ingest` or `nace job`) (the message names the error type, code, request id and up to five fields that failed validation), `2` on a usage or configuration error.
|
|
133
146
|
|
|
134
|
-
Requests are retried up to twice on a `408`, `429`, `5xx`, dropped connection or timeout, but only while the retry can start within 30 s of the first attempt, so a request that fails after 30 s, such as one that hit `--timeout` (120 s) or a slow `529`, is not retried. `schema-create`, `schema-version-add
|
|
147
|
+
Requests are retried up to twice on a `408`, `429`, `5xx`, dropped connection or timeout, but only while the retry can start within 30 s of the first attempt, so a request that fails after 30 s, such as one that hit `--timeout` (120 s) or a slow `529`, is not retried. `schema-create`, `schema-version-add`, `workspace-create`, `domains create`, `domains publish` and `domains duplicate` are never retried.
|
|
135
148
|
|
|
136
149
|
Full guide: https://console.nace.ai/docs/developer-tools/cli (install, sign-in, `decide`, documents, uploads, jobs, schemas, workspaces, search, exit codes).
|
|
137
150
|
|
|
@@ -89,7 +89,7 @@ nace schema-version sch_... 1
|
|
|
89
89
|
## Workspaces and search
|
|
90
90
|
|
|
91
91
|
```bash
|
|
92
|
-
nace workspace-create fy25-audit
|
|
92
|
+
nace workspace-create fy25-audit
|
|
93
93
|
nace workspace-use <workspace_id> # later commands use it; --workspace ID or $NACE_WORKSPACE_ID win
|
|
94
94
|
nace upload ./report.pdf --workspace <workspace_id> --path reports/2025/report.pdf
|
|
95
95
|
nace upload ./fy25 --workspace <workspace_id> --ingest # every file under a folder, 8 at a time, then ingested
|
|
@@ -97,16 +97,29 @@ nace ingest --path-prefix reports/ # makes the files searchable
|
|
|
97
97
|
nace workspace-files --status failed
|
|
98
98
|
nace search "What were total liabilities at year end?"
|
|
99
99
|
nace qa "What was total freight spend in FY2024?" # stderr: session <id>; follow up with --session
|
|
100
|
-
nace kg-build
|
|
101
100
|
```
|
|
102
101
|
|
|
103
102
|
`search` and `qa` print the answer as the ndi CLI did: the answer, `evidences (N):` with each evidence as `1. path, page N` and its quote indented under it, then the Ground jobs to poll, the tables the answer refers to and any catalog rows; the route goes to stderr. Lists (`jobs`, `workspaces`, `workspace-files`) print a `<what> (N):` header, an indented line per item and `next: <cursor>` when more pages follow. Uploads go to a workspace only with `--workspace`; without it, to the server. `nace workspace-delete` takes the workspace only from `--workspace`.
|
|
104
103
|
|
|
104
|
+
## Domains
|
|
105
|
+
|
|
106
|
+
A domain is the two-level taxonomy (group and subgroup classes) a workspace classifies its files against. Nace provides some; your account can author its own. A draft is edited in place and published once; a published or Nace-provided domain is frozen, so duplicate it and edit the copy.
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
nace domains list --owner platform --status published # domains (N):, then <domain_id> <slug> v<n> <status> <owner> <display_name>
|
|
110
|
+
nace domains get --id <domain_id> # the domain's line, then its classes
|
|
111
|
+
nace domains create --slug leases --display-name "Lease files" --taxonomy leases.taxonomy.json # --taxonomy: JSON, @FILE or -
|
|
112
|
+
nace domains update --id <domain_id> --display-name "Leases"
|
|
113
|
+
nace domains publish --id <domain_id> # one-way; a taxonomy that fails validation exits 1 and lists each error
|
|
114
|
+
nace domains duplicate --id <domain_id> # a draft copy of any domain you see
|
|
115
|
+
nace workspace-create fy25-leases --domain leases # a published domain's slug; it can't be changed later
|
|
116
|
+
```
|
|
117
|
+
|
|
105
118
|
## Output and exit codes
|
|
106
119
|
|
|
107
120
|
Stdout carries the payload and stderr the status, so `nace parse doc.pdf > doc.md` captures only the document. A wait that runs out prints the job id on stdout. Exit `0` on success, `1` on an API or job failure (including an ingestion that failed a file, from `nace ingest`, `nace upload --ingest` or `nace job`) (the message names the error type, code, request id and up to five fields that failed validation), `2` on a usage or configuration error.
|
|
108
121
|
|
|
109
|
-
Requests are retried up to twice on a `408`, `429`, `5xx`, dropped connection or timeout, but only while the retry can start within 30 s of the first attempt, so a request that fails after 30 s, such as one that hit `--timeout` (120 s) or a slow `529`, is not retried. `schema-create`, `schema-version-add
|
|
122
|
+
Requests are retried up to twice on a `408`, `429`, `5xx`, dropped connection or timeout, but only while the retry can start within 30 s of the first attempt, so a request that fails after 30 s, such as one that hit `--timeout` (120 s) or a slow `529`, is not retried. `schema-create`, `schema-version-add`, `workspace-create`, `domains create`, `domains publish` and `domains duplicate` are never retried.
|
|
110
123
|
|
|
111
124
|
Full guide: https://console.nace.ai/docs/developer-tools/cli (install, sign-in, `decide`, documents, uploads, jobs, schemas, workspaces, search, exit codes).
|
|
112
125
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "nace-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Nace CLI: calibrated decisions and document jobs from the shell"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -24,7 +24,7 @@ classifiers = [
|
|
|
24
24
|
"Typing :: Typed",
|
|
25
25
|
]
|
|
26
26
|
dependencies = [
|
|
27
|
-
"nace-sdk>=0.
|
|
27
|
+
"nace-sdk>=0.3.0,<1",
|
|
28
28
|
"truststore>=0.10.4,<1",
|
|
29
29
|
]
|
|
30
30
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "nace-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.3.0"
|
|
4
4
|
description = "Nace CLI: calibrated decisions and document jobs from the shell"
|
|
5
5
|
authors = [
|
|
6
6
|
{ name = "Nace AI", email = "engineering@nace.ai" }
|
|
@@ -27,7 +27,7 @@ classifiers = [
|
|
|
27
27
|
"Typing :: Typed",
|
|
28
28
|
]
|
|
29
29
|
dependencies = [
|
|
30
|
-
"nace-sdk>=0.
|
|
30
|
+
"nace-sdk>=0.3.0,<1",
|
|
31
31
|
"truststore>=0.10.4,<1",
|
|
32
32
|
]
|
|
33
33
|
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""``nace browser run|get|list|cancel|artifacts``: a cloud browser agent that does a task on the sites you allow.
|
|
2
|
+
|
|
3
|
+
A run is billed once, when it ends: its Drex Max and LLM tokens and its browser seconds, never more
|
|
4
|
+
than ``--max-cost``. Reading and listing runs is free;
|
|
5
|
+
a run that fails, times out, or is cancelled before completion is not charged.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import sys
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urlsplit
|
|
14
|
+
|
|
15
|
+
from nace_sdk import BROWSER_RUN_STATUSES, BrowserRun, BrowserRunRef, NaceBrowserRunTimeoutError, NaceClient
|
|
16
|
+
from nace_sdk._core.endpoints import BROWSER_RUN_MAX_PAGE_SIZE, DEFAULT_BROWSER_WAIT_SECONDS
|
|
17
|
+
|
|
18
|
+
from nace_cli._inputs import read_json
|
|
19
|
+
from nace_cli._output import to_json
|
|
20
|
+
from nace_cli._schemas import _add_page_args, _emit_page
|
|
21
|
+
|
|
22
|
+
#: What `--max-cost`, `--max-seconds` and `--max-llm-calls` take, as Drex accepts them.
|
|
23
|
+
MAX_COST_RANGE = (0.01, 1.0)
|
|
24
|
+
MAX_SECONDS_RANGE = (1, 1800)
|
|
25
|
+
MAX_LLM_CALLS_RANGE = (1, 100)
|
|
26
|
+
#: How much of a run's task a `nace browser list` line shows.
|
|
27
|
+
_TASK_CHARS = 60
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
|
|
31
|
+
browser = sub.add_parser(
|
|
32
|
+
"browser",
|
|
33
|
+
help="Browser runs: a cloud browser agent does a task on the sites you allow and returns an answer.",
|
|
34
|
+
description="Browser runs: a cloud browser agent does a task on the sites you allow and returns an answer.",
|
|
35
|
+
)
|
|
36
|
+
commands = browser.add_subparsers(dest="browser_command", metavar="{run,get,list,cancel,artifacts}", required=True)
|
|
37
|
+
|
|
38
|
+
run = commands.add_parser("run", parents=[common], help="Start a run and wait for it; print its answer.")
|
|
39
|
+
run.add_argument("task", help="What the agent should do, in plain language: 1 to 4000 characters.")
|
|
40
|
+
run.add_argument("--start-url", required=True, metavar="URL", help="The page to open first: an http:// or https:// URL.")
|
|
41
|
+
run.add_argument(
|
|
42
|
+
"--allow",
|
|
43
|
+
dest="allowed_origins",
|
|
44
|
+
action="append",
|
|
45
|
+
default=None,
|
|
46
|
+
metavar="ORIGIN",
|
|
47
|
+
help="An origin the browser may load, such as https://shop.example.com. Repeatable. Without it, only the start URL's origin.",
|
|
48
|
+
)
|
|
49
|
+
run.add_argument(
|
|
50
|
+
"--answer-schema", default=None, metavar="SCHEMA", help="JSON Schema for a structured answer: a file path, @FILE, inline JSON, or -."
|
|
51
|
+
)
|
|
52
|
+
run.add_argument(
|
|
53
|
+
"--max-cost", type=float, default=None, metavar="USD", help="The most the run may be charged, in US dollars, 0.01 up to 1 (default 0.04)."
|
|
54
|
+
)
|
|
55
|
+
run.add_argument("--max-seconds", type=int, default=None, metavar="N", help="How long the run may browse, 1 to 1800 seconds (default 600).")
|
|
56
|
+
run.add_argument(
|
|
57
|
+
"--max-llm-calls", type=int, default=None, metavar="N", help="How many planning and reading calls it may make, 1 to 100 (default 20)."
|
|
58
|
+
)
|
|
59
|
+
run.add_argument(
|
|
60
|
+
"--metadata", action="append", default=None, metavar="KEY=VALUE", help="A string of your own, returned with the run. Repeatable."
|
|
61
|
+
)
|
|
62
|
+
run.add_argument("--idempotency-key", default=None, metavar="KEY", help="The same key returns the same run instead of starting another.")
|
|
63
|
+
run.add_argument("--no-wait", action="store_true", help="Print the run id and return at once.")
|
|
64
|
+
run.add_argument(
|
|
65
|
+
"--wait-timeout",
|
|
66
|
+
type=float,
|
|
67
|
+
default=DEFAULT_BROWSER_WAIT_SECONDS,
|
|
68
|
+
metavar="SECONDS",
|
|
69
|
+
help=f"How long to wait for the run (default {DEFAULT_BROWSER_WAIT_SECONDS:.0f}s). It keeps going server-side after.",
|
|
70
|
+
)
|
|
71
|
+
run.set_defaults(run=run_run, family="client")
|
|
72
|
+
|
|
73
|
+
get = commands.add_parser("get", parents=[common], help="Read one run: its status, answer and cost.")
|
|
74
|
+
get.add_argument("run_id", help="The run's id (brun_...).")
|
|
75
|
+
get.set_defaults(run=run_get, family="client")
|
|
76
|
+
|
|
77
|
+
listing = commands.add_parser("list", parents=[common], help="List your runs, newest first.")
|
|
78
|
+
listing.add_argument("--status", choices=BROWSER_RUN_STATUSES, default=None, help="Only runs in this status.")
|
|
79
|
+
_add_page_args(listing, "Runs", maximum=BROWSER_RUN_MAX_PAGE_SIZE)
|
|
80
|
+
listing.set_defaults(run=run_list, family="client")
|
|
81
|
+
|
|
82
|
+
cancel = commands.add_parser("cancel", parents=[common], help="Cancel a queued or running run.")
|
|
83
|
+
cancel.add_argument("run_id", help="The run's id (brun_...).")
|
|
84
|
+
cancel.set_defaults(run=run_cancel, family="client")
|
|
85
|
+
|
|
86
|
+
artifacts = commands.add_parser("artifacts", parents=[common], help="List a run's artifacts, such as screenshots, with their links.")
|
|
87
|
+
artifacts.add_argument("run_id", help="The run's id (brun_...).")
|
|
88
|
+
artifacts.set_defaults(run=run_artifacts, family="client")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def start_origin(start_url: str) -> str:
|
|
92
|
+
"""The origin of ``start_url`` (``https://shop.example.com``), which a run without ``--allow`` may load.
|
|
93
|
+
|
|
94
|
+
Raises:
|
|
95
|
+
ValueError: Not an ``http://`` or ``https://`` URL with a host.
|
|
96
|
+
"""
|
|
97
|
+
parts = urlsplit(start_url.strip())
|
|
98
|
+
host = parts.netloc.rpartition("@")[2].lower()
|
|
99
|
+
if parts.scheme.lower() not in ("http", "https") or not host:
|
|
100
|
+
raise ValueError(f"--start-url must be an http:// or https:// URL, got {start_url!r}")
|
|
101
|
+
return f"{parts.scheme.lower()}://{host}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _in_range(flag: str, value: float | None, bounds: tuple[float, float]) -> None:
|
|
105
|
+
low, high = bounds
|
|
106
|
+
if value is not None and not low <= value <= high:
|
|
107
|
+
raise ValueError(f"{flag} must be between {low:g} and {high:g}, got {value:g}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _limits(args: argparse.Namespace) -> dict[str, Any] | None:
|
|
111
|
+
"""The limits named on the command line; one left out takes Drex's default."""
|
|
112
|
+
_in_range("--max-cost", args.max_cost, MAX_COST_RANGE)
|
|
113
|
+
_in_range("--max-seconds", args.max_seconds, MAX_SECONDS_RANGE)
|
|
114
|
+
_in_range("--max-llm-calls", args.max_llm_calls, MAX_LLM_CALLS_RANGE)
|
|
115
|
+
limits = {"max_cost_usd": args.max_cost, "max_seconds": args.max_seconds, "max_llm_calls": args.max_llm_calls}
|
|
116
|
+
named = {key: value for key, value in limits.items() if value is not None}
|
|
117
|
+
return named or None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _metadata(pairs: list[str] | None) -> dict[str, str] | None:
|
|
121
|
+
if not pairs:
|
|
122
|
+
return None
|
|
123
|
+
metadata: dict[str, str] = {}
|
|
124
|
+
for pair in pairs:
|
|
125
|
+
key, sep, value = pair.partition("=")
|
|
126
|
+
if not sep or not key:
|
|
127
|
+
raise ValueError(f"--metadata needs KEY=VALUE, got {pair!r}")
|
|
128
|
+
metadata[key] = value
|
|
129
|
+
return metadata
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _status_line(run: BrowserRun | BrowserRunRef) -> str:
|
|
133
|
+
kind = run.outcome.kind if isinstance(run, BrowserRun) and run.outcome is not None else None
|
|
134
|
+
return f"browser run {run.id} {run.status}" + (f" ({kind})" if kind and kind != run.status else "")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _run_json(run: BrowserRun | BrowserRunRef) -> str:
|
|
138
|
+
return run.model_dump_json(indent=2, exclude_none=True)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _print_run(run: BrowserRun, args: argparse.Namespace) -> None:
|
|
142
|
+
"""The run whole with ``--json``; otherwise its answer on stdout and its status, reason and cost on stderr."""
|
|
143
|
+
if args.json:
|
|
144
|
+
print(_run_json(run))
|
|
145
|
+
return
|
|
146
|
+
print(_status_line(run), file=sys.stderr)
|
|
147
|
+
if run.outcome is not None and run.outcome.reason:
|
|
148
|
+
print(f" {run.outcome.reason}", file=sys.stderr)
|
|
149
|
+
if run.final_url:
|
|
150
|
+
print(f" final url: {run.final_url}", file=sys.stderr)
|
|
151
|
+
if run.usage is not None and run.usage.cost_usd is not None:
|
|
152
|
+
print(f" cost: ${run.usage.cost_usd:.4f}", file=sys.stderr)
|
|
153
|
+
if run.answer is not None:
|
|
154
|
+
print(to_json(run.answer))
|
|
155
|
+
elif run.answer_text:
|
|
156
|
+
print(run.answer_text)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def run_run(client: NaceClient, args: argparse.Namespace) -> int:
|
|
160
|
+
"""Start a run, then wait for it unless ``--no-wait``. A run that ended other than ``succeeded`` exits ``1``."""
|
|
161
|
+
origin = start_origin(args.start_url)
|
|
162
|
+
origins = args.allowed_origins or [origin]
|
|
163
|
+
limits = _limits(args)
|
|
164
|
+
metadata = _metadata(args.metadata)
|
|
165
|
+
answer_schema = None
|
|
166
|
+
if args.answer_schema is not None:
|
|
167
|
+
answer_schema = read_json(args.answer_schema, what="--answer-schema")
|
|
168
|
+
if not isinstance(answer_schema, dict):
|
|
169
|
+
raise ValueError("--answer-schema must be a JSON object")
|
|
170
|
+
if not args.wait_timeout >= 0:
|
|
171
|
+
raise ValueError("--wait-timeout must be 0 or more seconds")
|
|
172
|
+
runs = client.browser_runs
|
|
173
|
+
created = runs.create(
|
|
174
|
+
task=args.task,
|
|
175
|
+
start_url=args.start_url,
|
|
176
|
+
allowed_origins=origins,
|
|
177
|
+
answer_schema=answer_schema,
|
|
178
|
+
limits=limits,
|
|
179
|
+
metadata=metadata,
|
|
180
|
+
idempotency_key=args.idempotency_key,
|
|
181
|
+
)
|
|
182
|
+
print(_status_line(created), file=sys.stderr)
|
|
183
|
+
if args.no_wait:
|
|
184
|
+
print(_run_json(created) if args.json else created.id)
|
|
185
|
+
return 0
|
|
186
|
+
try:
|
|
187
|
+
run = runs.wait(created.id, timeout=args.wait_timeout)
|
|
188
|
+
except NaceBrowserRunTimeoutError as exc:
|
|
189
|
+
print(f"error: {exc}; read it later with `nace browser get {exc.run.id}`", file=sys.stderr)
|
|
190
|
+
print(_run_json(exc.run) if args.json else exc.run.id)
|
|
191
|
+
return 1
|
|
192
|
+
_print_run(run, args)
|
|
193
|
+
if run.status == "succeeded":
|
|
194
|
+
return 0
|
|
195
|
+
kind = f" [{run.outcome.kind}]" if run.outcome is not None else ""
|
|
196
|
+
print(f"error: browser run {run.id} ended {run.status}{kind}", file=sys.stderr)
|
|
197
|
+
return 1
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def run_get(client: NaceClient, args: argparse.Namespace) -> int:
|
|
201
|
+
_print_run(client.browser_runs.get(args.run_id), args)
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _list_line(run: BrowserRun) -> str:
|
|
206
|
+
task = run.task if len(run.task) <= _TASK_CHARS else f"{run.task[: _TASK_CHARS - 1]}…"
|
|
207
|
+
cost = f"${run.usage.cost_usd:.4f}" if run.usage is not None and run.usage.cost_usd is not None else "-"
|
|
208
|
+
return f"{run.id} {run.status} {cost} {run.created_at} {task}".rstrip()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def run_list(client: NaceClient, args: argparse.Namespace) -> int:
|
|
212
|
+
runs = client.browser_runs
|
|
213
|
+
return _emit_page(
|
|
214
|
+
args,
|
|
215
|
+
"runs",
|
|
216
|
+
lambda: runs.list(limit=args.limit, status=args.status, cursor=args.cursor),
|
|
217
|
+
lambda: runs.iter_all(status=args.status),
|
|
218
|
+
summary=_list_line,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def run_cancel(client: NaceClient, args: argparse.Namespace) -> int:
|
|
223
|
+
ref = client.browser_runs.cancel(args.run_id)
|
|
224
|
+
if args.json:
|
|
225
|
+
print(_run_json(ref))
|
|
226
|
+
else:
|
|
227
|
+
print(_status_line(ref), file=sys.stderr)
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def run_artifacts(client: NaceClient, args: argparse.Namespace) -> int:
|
|
232
|
+
listed = client.browser_runs.artifacts(args.run_id)
|
|
233
|
+
if args.json:
|
|
234
|
+
print(to_json({"items": [item.model_dump(mode="json", exclude_none=True) for item in listed.items]}))
|
|
235
|
+
return 0
|
|
236
|
+
for item in listed.items:
|
|
237
|
+
step = f"step {item.step}" if item.step is not None else "-"
|
|
238
|
+
print(f"{item.kind} {step} {item.url or '-'}")
|
|
239
|
+
if not listed.items:
|
|
240
|
+
print("no artifacts", file=sys.stderr)
|
|
241
|
+
return 0
|
|
@@ -16,7 +16,7 @@ from nace_sdk import NaceAPIConnectionError, NaceAPIError, NaceClient, NaceError
|
|
|
16
16
|
from nace_sdk.constants import LOG_LEVEL_ENV, read_env
|
|
17
17
|
from nace_sdk.credentials import CredentialsError, resolve_credentials
|
|
18
18
|
|
|
19
|
-
from nace_cli import _decide, _docops, _jobs, _login, _schemas, _search, _upload, _workspaces
|
|
19
|
+
from nace_cli import _browser, _decide, _docops, _domains, _jobs, _login, _schemas, _search, _upload, _workspaces
|
|
20
20
|
from nace_cli._inputs import check_one_stdin_flag
|
|
21
21
|
from nace_cli._output import CommandFailed
|
|
22
22
|
|
|
@@ -82,6 +82,14 @@ def _validation_lines(exc: NaceAPIError) -> list[dict[str, Any]]:
|
|
|
82
82
|
"""Where a request failed validation: ``error.issues``, else the document routes' ``error.detail.errors``."""
|
|
83
83
|
if exc.issues:
|
|
84
84
|
return exc.issues
|
|
85
|
+
if isinstance(exc.detail, dict) and isinstance(exc.detail.get("validation"), dict):
|
|
86
|
+
# A refused domain publish: `detail.validation.errors` is each `{code, path, message}` to fix.
|
|
87
|
+
found = exc.detail["validation"].get("errors")
|
|
88
|
+
return [
|
|
89
|
+
{"path": entry.get("path") or "", "message": f"{entry.get('message') or ''} ({entry.get('code') or 'invalid'})"}
|
|
90
|
+
for entry in found or []
|
|
91
|
+
if isinstance(entry, dict)
|
|
92
|
+
]
|
|
85
93
|
errors = exc.detail.get("errors") if isinstance(exc.detail, dict) else None
|
|
86
94
|
if not isinstance(errors, list):
|
|
87
95
|
return []
|
|
@@ -144,7 +152,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
144
152
|
parser = argparse.ArgumentParser(
|
|
145
153
|
prog="nace",
|
|
146
154
|
description=(
|
|
147
|
-
"Nace CLI: calibrated decisions, document jobs, workspaces and
|
|
155
|
+
"Nace CLI: calibrated decisions, document jobs, workspaces, search and browser runs. Credentials come from "
|
|
148
156
|
"$NACE_API_KEY / $NACE_BASE_URL or ~/.nace/config.toml (`nace login`). "
|
|
149
157
|
"Set $NACE_LOG_LEVEL=debug to log each request to stderr."
|
|
150
158
|
),
|
|
@@ -167,7 +175,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
167
175
|
_jobs.add_parsers(sub, common)
|
|
168
176
|
_schemas.add_parsers(sub, common)
|
|
169
177
|
_workspaces.add_parsers(sub, common)
|
|
178
|
+
_domains.add_parsers(sub, common)
|
|
170
179
|
_search.add_parsers(sub, common)
|
|
180
|
+
_browser.add_parsers(sub, common)
|
|
171
181
|
return parser
|
|
172
182
|
|
|
173
183
|
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Domains: the taxonomies a workspace classifies its files against.
|
|
2
|
+
|
|
3
|
+
``nace domains`` takes an action: ``list`` lists them as ``nace workspaces`` lists workspaces, ``get``
|
|
4
|
+
reads one with its classes, and ``create``, ``update``, ``publish`` and ``duplicate`` write them. Every
|
|
5
|
+
input is a flag (``--id``, ``--slug``, ...). A draft is edited in place and published once, and a
|
|
6
|
+
published or platform domain is frozen, so ``nace domains duplicate`` copies it into a draft.
|
|
7
|
+
``nace workspace-create --domain SLUG`` pins a published one.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from nace_sdk import Domain, DomainSummary, DomainWrite, NaceClient
|
|
17
|
+
from nace_sdk._core.endpoints import DOMAIN_MAX_PAGE_SIZE
|
|
18
|
+
|
|
19
|
+
from nace_cli._inputs import read_json
|
|
20
|
+
from nace_cli._output import CommandFailed
|
|
21
|
+
from nace_cli._schemas import _add_page_args
|
|
22
|
+
from nace_cli._workspaces import _emit_listing, _total
|
|
23
|
+
|
|
24
|
+
#: The two owners a domain has, for ``nace domains list --owner``.
|
|
25
|
+
OWNERS = ("platform", "tenant")
|
|
26
|
+
#: The two statuses a domain version has, for ``nace domains list --status``.
|
|
27
|
+
STATUSES = ("draft", "published")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser) -> None:
|
|
31
|
+
domains = sub.add_parser("domains", help="List, read, create, update, publish and duplicate domains.")
|
|
32
|
+
actions = domains.add_subparsers(dest="domains_action", metavar="{list,get,create,update,publish,duplicate}", required=True)
|
|
33
|
+
|
|
34
|
+
listing = actions.add_parser("list", parents=[common], help="List the domains your account sees: Nace's and its own.")
|
|
35
|
+
listing.add_argument("--owner", default=None, choices=OWNERS, help="Only the platform's domains, or only the account's own.")
|
|
36
|
+
listing.add_argument("--status", default=None, choices=STATUSES, help="Only drafts, or only published versions.")
|
|
37
|
+
listing.add_argument("--slug", default=None, metavar="SLUG", help="Only the versions of this slug.")
|
|
38
|
+
_add_page_args(listing, "Domains", default=50, maximum=DOMAIN_MAX_PAGE_SIZE)
|
|
39
|
+
listing.set_defaults(run=run_list, family="client")
|
|
40
|
+
|
|
41
|
+
show = actions.add_parser("get", parents=[common], help="Print a domain with its taxonomy classes.")
|
|
42
|
+
show.add_argument("--id", dest="domain_id", required=True, metavar="DOMAIN_ID", help="The domain's id, as `nace domains list` lists it.")
|
|
43
|
+
show.set_defaults(run=run_get, family="client")
|
|
44
|
+
|
|
45
|
+
create = actions.add_parser("create", parents=[common], help="Create a draft domain owned by your account.")
|
|
46
|
+
create.add_argument(
|
|
47
|
+
"--slug", required=True, metavar="SLUG", help="The name a workspace is created against with --domain. It can't be changed later."
|
|
48
|
+
)
|
|
49
|
+
create.add_argument("--display-name", required=True, metavar="NAME", help="The name people see.")
|
|
50
|
+
create.add_argument("--taxonomy", default=None, metavar="JSON", help='The classes, as {"classes": [...]}: inline JSON, @FILE, a path, or -.')
|
|
51
|
+
create.set_defaults(run=run_create, family="client")
|
|
52
|
+
|
|
53
|
+
update = actions.add_parser("update", parents=[common], help="Rename a draft domain or replace its taxonomy.")
|
|
54
|
+
update.add_argument("--id", dest="domain_id", required=True, metavar="DOMAIN_ID", help="The draft's id.")
|
|
55
|
+
update.add_argument("--display-name", default=None, metavar="NAME", help="The new name people see.")
|
|
56
|
+
update.add_argument("--taxonomy", default=None, metavar="JSON", help="The whole new taxonomy: inline JSON, @FILE, a path, or -.")
|
|
57
|
+
update.set_defaults(run=run_update, family="client")
|
|
58
|
+
|
|
59
|
+
publish = actions.add_parser("publish", parents=[common], help="Publish a draft domain so workspaces can use it. It can't be undone.")
|
|
60
|
+
publish.add_argument("--id", dest="domain_id", required=True, metavar="DOMAIN_ID", help="The draft's id.")
|
|
61
|
+
publish.set_defaults(run=run_publish, family="client")
|
|
62
|
+
|
|
63
|
+
duplicate = actions.add_parser("duplicate", parents=[common], help="Copy any domain you can see into a new draft.")
|
|
64
|
+
duplicate.add_argument("--id", dest="domain_id", required=True, metavar="DOMAIN_ID", help="The id of the domain to copy.")
|
|
65
|
+
duplicate.set_defaults(run=run_duplicate, family="client")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --- Rendering ----------------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _domain_line(item: DomainSummary) -> str:
|
|
72
|
+
return f"{item.domain_id} {item.slug} v{item.version} {item.status} {item.owner} {item.display_name}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _written_line(item: DomainSummary) -> str:
|
|
76
|
+
return f"{item.domain_id} {item.slug} v{item.version} {item.status}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _one_line(text: Any) -> str:
|
|
80
|
+
return " ".join(str(text or "").split())
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _domain_detail(item: Domain) -> str:
|
|
84
|
+
"""The domain's line, ``inherits:`` when it has a parent, then ``classes (N):`` with each class and its aliases."""
|
|
85
|
+
lines = [_domain_line(item)]
|
|
86
|
+
if item.parent_slug:
|
|
87
|
+
lines.append(f"inherits: {item.parent_slug}")
|
|
88
|
+
classes = [entry for entry in item.taxonomy.get("classes") or [] if isinstance(entry, dict)]
|
|
89
|
+
lines.append(f"classes ({len(classes)}):")
|
|
90
|
+
for entry in classes:
|
|
91
|
+
description = _one_line(entry.get("description"))
|
|
92
|
+
lines.append(f" {entry.get('group')} / {entry.get('subgroup')}{f': {description}' if description else ''}")
|
|
93
|
+
aliases = [str(alias) for alias in entry.get("aliases") or []]
|
|
94
|
+
if aliases:
|
|
95
|
+
lines.append(f" aliases: {', '.join(aliases)}")
|
|
96
|
+
return "\n".join(lines)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _validation_errors(written: DomainWrite) -> list[dict[str, Any]]:
|
|
100
|
+
return [dict(error) for error in written.validation.errors]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _error_line(error: dict[str, Any]) -> str:
|
|
104
|
+
where = f"{error['path']}: " if error.get("path") else ""
|
|
105
|
+
return f" - {where}{error.get('message', '')} ({error.get('code', 'invalid')})"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _emit_write(written: DomainWrite, args: argparse.Namespace) -> int:
|
|
109
|
+
"""The saved domain's line, or ``{domain, validation}`` with ``--json``; what validation found goes to stderr."""
|
|
110
|
+
if args.json:
|
|
111
|
+
print(written.model_dump_json(indent=2, by_alias=True, exclude_none=True))
|
|
112
|
+
return 0
|
|
113
|
+
print(_written_line(written.domain))
|
|
114
|
+
errors = _validation_errors(written)
|
|
115
|
+
if errors:
|
|
116
|
+
print(f"the taxonomy has {len(errors)} validation errors:", file=sys.stderr)
|
|
117
|
+
for error in errors:
|
|
118
|
+
print(_error_line(error), file=sys.stderr)
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _taxonomy(value: str | None) -> dict[str, Any] | None:
|
|
123
|
+
if value is None:
|
|
124
|
+
return None
|
|
125
|
+
loaded = read_json(value, what="--taxonomy")
|
|
126
|
+
if not isinstance(loaded, dict):
|
|
127
|
+
raise ValueError("--taxonomy must be a JSON object")
|
|
128
|
+
return loaded
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- Commands -----------------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def run_list(client: NaceClient, args: argparse.Namespace) -> int:
|
|
135
|
+
domains = client.domains
|
|
136
|
+
filters = {"owner": args.owner, "status": args.status, "slug": args.slug}
|
|
137
|
+
return _emit_listing(
|
|
138
|
+
args,
|
|
139
|
+
"domains",
|
|
140
|
+
lambda: domains.list(limit=args.limit, cursor=args.cursor, **filters),
|
|
141
|
+
lambda: domains.iter(**filters),
|
|
142
|
+
line=_domain_line,
|
|
143
|
+
extra=lambda page, items: {"total_count": _total(page, items)},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def run_get(client: NaceClient, args: argparse.Namespace) -> int:
|
|
148
|
+
item = client.domains.get(args.domain_id.strip())
|
|
149
|
+
print(item.model_dump_json(indent=2, by_alias=True, exclude_none=True) if args.json else _domain_detail(item))
|
|
150
|
+
return 0
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run_create(client: NaceClient, args: argparse.Namespace) -> int:
|
|
154
|
+
if not args.slug.strip():
|
|
155
|
+
raise ValueError("--slug must not be blank")
|
|
156
|
+
if not args.display_name.strip():
|
|
157
|
+
raise ValueError("--display-name must not be blank")
|
|
158
|
+
written = client.domains.create(slug=args.slug.strip(), display_name=args.display_name, taxonomy=_taxonomy(args.taxonomy))
|
|
159
|
+
return _emit_write(written, args)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run_update(client: NaceClient, args: argparse.Namespace) -> int:
|
|
163
|
+
if args.display_name is None and args.taxonomy is None:
|
|
164
|
+
raise ValueError("pass at least one change: --display-name or --taxonomy")
|
|
165
|
+
written = client.domains.update(args.domain_id.strip(), display_name=args.display_name, taxonomy=_taxonomy(args.taxonomy))
|
|
166
|
+
return _emit_write(written, args)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def run_publish(client: NaceClient, args: argparse.Namespace) -> int:
|
|
170
|
+
written = client.domains.publish(args.domain_id.strip())
|
|
171
|
+
code = _emit_write(written, args)
|
|
172
|
+
if not written.validation.valid:
|
|
173
|
+
raise CommandFailed("the domain was not published: fix the taxonomy errors above")
|
|
174
|
+
return code
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def run_duplicate(client: NaceClient, args: argparse.Namespace) -> int:
|
|
178
|
+
return _emit_write(client.domains.duplicate(args.domain_id.strip()), args)
|
|
@@ -22,10 +22,10 @@ STDIN_FLAGS = {
|
|
|
22
22
|
"targets_file": "--targets",
|
|
23
23
|
"password": "--password",
|
|
24
24
|
"access": "--access",
|
|
25
|
-
"
|
|
26
|
-
"knowledge_graph": "--knowledge-graph",
|
|
25
|
+
"taxonomy": "--taxonomy",
|
|
27
26
|
"output_schema": "--output-schema",
|
|
28
27
|
"clarification_answers": "--clarification-answers",
|
|
28
|
+
"answer_schema": "--answer-schema",
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
|
|
@@ -56,11 +56,9 @@ def _add_common_args(parser: argparse.ArgumentParser) -> None:
|
|
|
56
56
|
"--path", action="append", dest="paths", default=[], metavar="PATH", help="Search this uploaded file too, by its path; repeatable."
|
|
57
57
|
)
|
|
58
58
|
context = parser.add_mutually_exclusive_group()
|
|
59
|
-
context.add_argument(
|
|
60
|
-
|
|
61
|
-
)
|
|
62
|
-
# The ndi CLI's way to say --context "": the API reads an empty context as none, an omitted one as the workspace's.
|
|
63
|
-
context.add_argument("--no-context", action="store_true", help="Run with no context at all, not even the workspace's global context.")
|
|
59
|
+
context.add_argument("--context", default=None, metavar="TEXT", help="Background the answer takes as given; without it, none.")
|
|
60
|
+
# The ndi CLI's way to say --context "": an empty context is sent as given.
|
|
61
|
+
context.add_argument("--no-context", action="store_true", help='Send an empty context: the same as --context "".')
|
|
64
62
|
parser.add_argument(
|
|
65
63
|
"--output-schema", default=None, metavar="JSON", help="A JSON Schema the answer must follow: inline JSON, @FILE, a path, or -."
|
|
66
64
|
)
|
|
@@ -68,7 +66,12 @@ def _add_common_args(parser: argparse.ArgumentParser) -> None:
|
|
|
68
66
|
|
|
69
67
|
def _add_run_args(parser: argparse.ArgumentParser) -> None:
|
|
70
68
|
parser.add_argument("--wait-seconds", type=int, default=None, metavar="N", help=f"Hold the create open up to N seconds (max {MAX_WAIT_SECONDS}).")
|
|
71
|
-
parser.add_argument(
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--idempotency-key",
|
|
71
|
+
default=None,
|
|
72
|
+
metavar="KEY",
|
|
73
|
+
help="Retry identical inputs with the same key; use a new key for changed inputs.",
|
|
74
|
+
)
|
|
72
75
|
add_async_args(parser)
|
|
73
76
|
parser.add_argument(
|
|
74
77
|
"-o",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Workspaces, their files,
|
|
1
|
+
"""Workspaces, their files, and ingestion.
|
|
2
2
|
|
|
3
3
|
Every command that works on one workspace picks it with ``--workspace``, else ``$NACE_WORKSPACE_ID``,
|
|
4
4
|
else the workspace ``nace workspace-use`` saved; with none, it exits ``2``. ``workspace-delete`` and
|
|
@@ -11,7 +11,6 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import argparse
|
|
13
13
|
import sys
|
|
14
|
-
import textwrap
|
|
15
14
|
from collections.abc import Callable, Iterable, Iterator
|
|
16
15
|
from typing import Any
|
|
17
16
|
|
|
@@ -28,7 +27,7 @@ from nace_cli._schemas import _add_page_args
|
|
|
28
27
|
|
|
29
28
|
#: The ingestion statuses a file can be in, for ``nace workspace-files --status``.
|
|
30
29
|
INGESTION_STATUSES = ("discovered", "queued", "ingesting", "ingested", "stale", "expired", "failed", "not_required")
|
|
31
|
-
#: Ingestions
|
|
30
|
+
#: Ingestions run long: their wait defaults to an hour.
|
|
32
31
|
LONG_WAIT_SECONDS = 3600.0
|
|
33
32
|
NO_WORKSPACE = (
|
|
34
33
|
"no workspace: pass --workspace ID, set $NACE_WORKSPACE_ID, or save one with `nace workspace-use ID` "
|
|
@@ -85,7 +84,9 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
|
|
|
85
84
|
add_workspace_arg(show, "The workspace to print")
|
|
86
85
|
show.set_defaults(run=run_get, family="client")
|
|
87
86
|
|
|
88
|
-
stats = sub.add_parser(
|
|
87
|
+
stats = sub.add_parser(
|
|
88
|
+
"workspace-stats", parents=[common], help="Summarize a workspace: files by ingestion status, bytes, pages, index documents."
|
|
89
|
+
)
|
|
89
90
|
add_workspace_arg(stats, "The workspace to summarize")
|
|
90
91
|
stats.set_defaults(run=run_stats, family="client")
|
|
91
92
|
|
|
@@ -100,19 +101,13 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
|
|
|
100
101
|
"--domain",
|
|
101
102
|
default=None,
|
|
102
103
|
metavar="SLUG",
|
|
103
|
-
help="The published domain
|
|
104
|
+
help="The slug of a published domain the workspace classifies its files against (`nace domains list`). It can't be changed later.",
|
|
104
105
|
)
|
|
105
106
|
create.add_argument(
|
|
106
107
|
"--access", default=None, metavar="JSON", help='Access labels, {"labels": [...], "default_label": ...}: inline JSON, @FILE, a path, or -.'
|
|
107
108
|
)
|
|
108
|
-
_add_settings_args(create, creating=True)
|
|
109
109
|
create.set_defaults(run=run_create, family="client")
|
|
110
110
|
|
|
111
|
-
update = sub.add_parser("workspace-update", parents=[common], help="Change a workspace's settings.")
|
|
112
|
-
add_workspace_arg(update, "The workspace to change")
|
|
113
|
-
_add_settings_args(update, creating=False)
|
|
114
|
-
update.set_defaults(run=run_update, family="client")
|
|
115
|
-
|
|
116
111
|
delete = sub.add_parser("workspace-delete", parents=[common], help="Delete a workspace and everything in it. It can't be undone.")
|
|
117
112
|
# Only the flag, as in the ndi CLI and the MCP server's delete_workspace: a deletion never falls
|
|
118
113
|
# back to $NACE_WORKSPACE_ID or the saved workspace.
|
|
@@ -126,13 +121,6 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
|
|
|
126
121
|
add_async_args(delete)
|
|
127
122
|
delete.set_defaults(run=run_delete, family="client")
|
|
128
123
|
|
|
129
|
-
build = sub.add_parser("kg-build", parents=[common], help="Build a workspace's knowledge graph over its ingested files.")
|
|
130
|
-
add_workspace_arg(build)
|
|
131
|
-
build.add_argument("--force", action="store_true", help="Clear the graph and rebuild it from scratch.")
|
|
132
|
-
add_async_args(build, wait_timeout=LONG_WAIT_SECONDS)
|
|
133
|
-
build.add_argument("--idempotency-key", default=None, metavar="KEY", help="Retry-safe key; the same key returns the same job.")
|
|
134
|
-
build.set_defaults(run=run_kg_build, family="client")
|
|
135
|
-
|
|
136
124
|
files = sub.add_parser("workspace-files", parents=[common], help="List a workspace's files.")
|
|
137
125
|
add_workspace_arg(files)
|
|
138
126
|
add_path_prefix(files, "Only files whose path starts with PATH.")
|
|
@@ -179,42 +167,13 @@ def add_parsers(sub: argparse._SubParsersAction, common: argparse.ArgumentParser
|
|
|
179
167
|
ingest.add_argument("--stale-only", action="store_true", help="Only files that changed since they were last ingested.")
|
|
180
168
|
ingest.add_argument("--reingest-unchanged", action="store_true", help="Also rebuild files that are ingested and unchanged.")
|
|
181
169
|
add_async_args(ingest, wait_timeout=LONG_WAIT_SECONDS)
|
|
182
|
-
ingest.add_argument(
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
reconcile = sub.add_parser("reconcile", parents=[common], help="Check a workspace's stored files against its file records.")
|
|
186
|
-
add_workspace_arg(reconcile)
|
|
187
|
-
add_path_prefix(reconcile, "Only check files under PATH (default: the whole workspace).")
|
|
188
|
-
reconcile.add_argument("--auto-ingest", action="store_true", help="Also start an ingestion of what it finds.")
|
|
189
|
-
add_async_args(reconcile)
|
|
190
|
-
reconcile.set_defaults(run=run_reconcile, family="client")
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def _add_settings_args(parser: argparse.ArgumentParser, *, creating: bool) -> None:
|
|
194
|
-
parser.add_argument(
|
|
195
|
-
"--retention-policy", default=None, metavar="JSON", help="derived_ttl_days, source_ttl_days and job_ttl_days, as a JSON object."
|
|
196
|
-
)
|
|
197
|
-
graph = parser.add_mutually_exclusive_group()
|
|
198
|
-
graph.add_argument(
|
|
199
|
-
"--knowledge-graph", default=None, metavar="JSON", help="auto_build, exclude_path_prefixes and exclude_categories, as a JSON object."
|
|
200
|
-
)
|
|
201
|
-
graph.add_argument(
|
|
202
|
-
"--auto-build",
|
|
203
|
-
choices=("on", "off"),
|
|
170
|
+
ingest.add_argument(
|
|
171
|
+
"--idempotency-key",
|
|
204
172
|
default=None,
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
else "Change only knowledge_graph.auto_build, keeping the rest of the block.",
|
|
173
|
+
metavar="KEY",
|
|
174
|
+
help="Retry identical inputs with the same key; use a new key for changed inputs.",
|
|
208
175
|
)
|
|
209
|
-
|
|
210
|
-
"--currency",
|
|
211
|
-
default=None,
|
|
212
|
-
metavar="CODE",
|
|
213
|
-
help="ISO 4217 code ingestion reads for a symbol several currencies share, or none (default USD)."
|
|
214
|
-
if creating
|
|
215
|
-
else "ISO 4217 code ingestion reads from the next ingestion on, or none.",
|
|
216
|
-
)
|
|
217
|
-
parser.add_argument("--global-context", default=None, metavar="TEXT", help="Background every search in the workspace should know.")
|
|
176
|
+
ingest.set_defaults(run=run_ingest, family="client")
|
|
218
177
|
|
|
219
178
|
|
|
220
179
|
# --- Rendering ---------------------------------------------------------------------------------
|
|
@@ -235,13 +194,6 @@ def _workspace_line(item: Workspace) -> str:
|
|
|
235
194
|
return f"{item.workspace_id} {item.name} {item.status}"
|
|
236
195
|
|
|
237
196
|
|
|
238
|
-
def _workspace_detail(item: Workspace) -> str:
|
|
239
|
-
"""The workspace's line, then its global context indented under ``global context:`` when it has one."""
|
|
240
|
-
if not item.global_context:
|
|
241
|
-
return _workspace_line(item)
|
|
242
|
-
return f"{_workspace_line(item)}\nglobal context:\n{textwrap.indent(item.global_context, ' ')}"
|
|
243
|
-
|
|
244
|
-
|
|
245
197
|
def _name_line(item: Workspace) -> str:
|
|
246
198
|
return f"{item.workspace_id} {item.name}"
|
|
247
199
|
|
|
@@ -260,7 +212,6 @@ def _stats_lines(stats: WorkspaceStats) -> str:
|
|
|
260
212
|
f"bytes: {stats.bytes_total or 0}",
|
|
261
213
|
f"pages ingested: {stats.pages_ingested or 0}",
|
|
262
214
|
f"index documents: {stats.index_documents or 0}",
|
|
263
|
-
f"kg: {stats.kg_nodes or 0} nodes / {stats.kg_edges or 0} edges",
|
|
264
215
|
]
|
|
265
216
|
return "\n".join(lines)
|
|
266
217
|
|
|
@@ -329,7 +280,7 @@ def run_list(client: NaceClient, args: argparse.Namespace) -> int:
|
|
|
329
280
|
def run_get(client: NaceClient, args: argparse.Namespace) -> int:
|
|
330
281
|
item = client.workspaces.get(workspace_id(args))
|
|
331
282
|
print(f"from {_picked_by(args)}", file=sys.stderr)
|
|
332
|
-
return _emit_workspace(item, args,
|
|
283
|
+
return _emit_workspace(item, args, _workspace_line(item))
|
|
333
284
|
|
|
334
285
|
|
|
335
286
|
def run_stats(client: NaceClient, args: argparse.Namespace) -> int:
|
|
@@ -367,48 +318,12 @@ def _json_object(value: str | None, flag: str) -> dict[str, Any] | None:
|
|
|
367
318
|
return loaded
|
|
368
319
|
|
|
369
320
|
|
|
370
|
-
def _currency(value: str | None) -> dict[str, Any] | None:
|
|
371
|
-
if value is None:
|
|
372
|
-
return None
|
|
373
|
-
code = value.strip()
|
|
374
|
-
if code.lower() == "none":
|
|
375
|
-
return {"default": None}
|
|
376
|
-
if len(code) != 3 or not code.isalpha():
|
|
377
|
-
raise ValueError(f"--currency takes a three-letter ISO 4217 code, such as USD or EUR, or none; got {value!r}")
|
|
378
|
-
return {"default": code.upper()}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
def _settings(args: argparse.Namespace) -> dict[str, Any]:
|
|
382
|
-
return {
|
|
383
|
-
"retention_policy": _json_object(args.retention_policy, "--retention-policy"),
|
|
384
|
-
"knowledge_graph": _json_object(args.knowledge_graph, "--knowledge-graph"),
|
|
385
|
-
"currency": _currency(args.currency),
|
|
386
|
-
"global_context": args.global_context,
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
|
|
390
321
|
def run_create(client: NaceClient, args: argparse.Namespace) -> int:
|
|
391
322
|
name = args.name
|
|
392
323
|
if not name.strip() or len(name) > 120:
|
|
393
324
|
raise ValueError("NAME must be 1 to 120 characters, not blank")
|
|
394
|
-
settings = _settings(args)
|
|
395
|
-
if args.auto_build is not None:
|
|
396
|
-
settings["knowledge_graph"] = {"auto_build": args.auto_build == "on"}
|
|
397
325
|
access = _json_object(args.access, "--access")
|
|
398
|
-
item = client.workspaces.create(name=name, domain_slug=args.domain, access=access
|
|
399
|
-
return _emit_workspace(item, args, _name_line(item))
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
def run_update(client: NaceClient, args: argparse.Namespace) -> int:
|
|
403
|
-
settings = _settings(args)
|
|
404
|
-
if args.auto_build is None and all(value is None for value in settings.values()):
|
|
405
|
-
raise ValueError("pass at least one setting to change: --auto-build, --knowledge-graph, --retention-policy, --currency or --global-context")
|
|
406
|
-
target = workspace_id(args)
|
|
407
|
-
if args.auto_build is not None:
|
|
408
|
-
# The block replaces whole: send the current one back with only auto_build changed.
|
|
409
|
-
current = client.workspaces.get(target).knowledge_graph
|
|
410
|
-
settings["knowledge_graph"] = {**current, "auto_build": args.auto_build == "on"}
|
|
411
|
-
item = client.workspaces.update(target, **settings)
|
|
326
|
+
item = client.workspaces.create(name=name, domain_slug=args.domain, access=access)
|
|
412
327
|
return _emit_workspace(item, args, _name_line(item))
|
|
413
328
|
|
|
414
329
|
|
|
@@ -423,10 +338,15 @@ def run_delete(client: NaceClient, args: argparse.Namespace) -> int:
|
|
|
423
338
|
if not target:
|
|
424
339
|
raise ValueError("workspace-delete needs --workspace ID: it never deletes the workspace $NACE_WORKSPACE_ID or `nace workspace-use` names")
|
|
425
340
|
job = client.workspaces.delete(target, confirm_name=args.confirm_name)
|
|
341
|
+
# Acceptance removes the workspace immediately; teardown may still be running.
|
|
342
|
+
try:
|
|
343
|
+
_forget_if_saved(target)
|
|
344
|
+
except OSError as exc:
|
|
345
|
+
# The delete cannot be undone: still show its job if the config cannot be written.
|
|
346
|
+
print(f"couldn't forget it as the saved workspace: {exc}", file=sys.stderr)
|
|
426
347
|
if job is None:
|
|
427
348
|
# 204: deleted before, and no job is left to read.
|
|
428
349
|
print("the workspace was already deleted", file=sys.stderr)
|
|
429
|
-
_forget_if_saved(target)
|
|
430
350
|
print(f"deleted {target}")
|
|
431
351
|
return 0
|
|
432
352
|
|
|
@@ -440,31 +360,9 @@ def run_delete(client: NaceClient, args: argparse.Namespace) -> int:
|
|
|
440
360
|
f"the workspace is already gone from your list; quote job {job.job_id} to support to finish the teardown",
|
|
441
361
|
file=sys.stderr,
|
|
442
362
|
)
|
|
443
|
-
elif not args.async_submit:
|
|
444
|
-
_forget_if_saved(target)
|
|
445
363
|
return code
|
|
446
364
|
|
|
447
365
|
|
|
448
|
-
def run_kg_build(client: NaceClient, args: argparse.Namespace) -> int:
|
|
449
|
-
job = client.workspaces.build_knowledge_graph(workspace_id(args), force=True if args.force else None, idempotency_key=args.idempotency_key)
|
|
450
|
-
|
|
451
|
-
def summarize(done: DocumentJob) -> int:
|
|
452
|
-
result = _result(done)
|
|
453
|
-
print(f"{done.job_id} {done.status} {count(result.get('node_count'))} nodes {count(result.get('edge_count'))} edges")
|
|
454
|
-
_failed_files(result.get("files_failed"))
|
|
455
|
-
return 0
|
|
456
|
-
|
|
457
|
-
return finish_job(client, job, args, summarize)
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
def _failed_files(entries: Any) -> None:
|
|
461
|
-
for entry in entries if isinstance(entries, list) else []:
|
|
462
|
-
if isinstance(entry, dict):
|
|
463
|
-
error = entry.get("error")
|
|
464
|
-
message = error.get("message") or error.get("code") if isinstance(error, dict) else error
|
|
465
|
-
print(f"failed {entry.get('path')}: {message or 'no detail'}", file=sys.stderr)
|
|
466
|
-
|
|
467
|
-
|
|
468
366
|
# --- Files --------------------------------------------------------------------------------------
|
|
469
367
|
|
|
470
368
|
|
|
@@ -565,7 +463,7 @@ def run_ingest(client: NaceClient, args: argparse.Namespace) -> int:
|
|
|
565
463
|
|
|
566
464
|
|
|
567
465
|
def summarize_ingestion(done: DocumentJob) -> int:
|
|
568
|
-
"""The ndi CLI's summary of an ingestion on stdout; each failed file
|
|
466
|
+
"""The ndi CLI's summary of an ingestion on stdout; each failed file on stderr too.
|
|
569
467
|
|
|
570
468
|
``ingested N | skipped N | failed N | U units`` (with ``| empty N`` after ``skipped`` when some
|
|
571
469
|
file had no rows), then one `` <status> <path>[ <error>]`` line per file, its status as given.
|
|
@@ -584,21 +482,4 @@ def summarize_ingestion(done: DocumentJob) -> int:
|
|
|
584
482
|
print(f" {outcome.get('status')} {outcome.get('path')}{f' {message}' if message else ''}")
|
|
585
483
|
if outcome.get("status") == "failed":
|
|
586
484
|
print(f"failed {outcome.get('path')}: {message or 'no detail'}", file=sys.stderr)
|
|
587
|
-
if result.get("kg_build_job_id"):
|
|
588
|
-
print(f"knowledge-graph build {result['kg_build_job_id']}", file=sys.stderr)
|
|
589
485
|
return 0
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
def run_reconcile(client: NaceClient, args: argparse.Namespace) -> int:
|
|
593
|
-
job = client.workspaces.reconcile(workspace_id(args), path_prefix=args.path_prefix, auto_ingest=True if args.auto_ingest else None)
|
|
594
|
-
|
|
595
|
-
def summarize(done: DocumentJob) -> int:
|
|
596
|
-
result = _result(done)
|
|
597
|
-
counts = ("scanned", "files_scanned"), ("discovered", "discovered"), ("stale", "marked_stale"), ("orphans", "orphans_removed")
|
|
598
|
-
line = ", ".join(f"{word} {count(result.get(field))}" for word, field in counts)
|
|
599
|
-
if result.get("ingestion_job_id"):
|
|
600
|
-
line += f"; ingestion {result['ingestion_job_id']}"
|
|
601
|
-
print(line)
|
|
602
|
-
return 0
|
|
603
|
-
|
|
604
|
-
return finish_job(client, job, args, summarize)
|
|
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
|