deepcell-cli 0.6.1__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.
Files changed (67) hide show
  1. deepcell_cli/__init__.py +12 -0
  2. deepcell_cli/__main__.py +5 -0
  3. deepcell_cli/_findings.py +84 -0
  4. deepcell_cli/capabilities.py +560 -0
  5. deepcell_cli/capability-contract.json +15622 -0
  6. deepcell_cli/client.py +503 -0
  7. deepcell_cli/commands/__init__.py +1 -0
  8. deepcell_cli/commands/_batch_input.py +29 -0
  9. deepcell_cli/commands/_datatypes.py +56 -0
  10. deepcell_cli/commands/_negative_args.py +133 -0
  11. deepcell_cli/commands/_swapped_args.py +153 -0
  12. deepcell_cli/commands/_version_display.py +40 -0
  13. deepcell_cli/commands/_write_opts.py +139 -0
  14. deepcell_cli/commands/account.py +123 -0
  15. deepcell_cli/commands/auth.py +610 -0
  16. deepcell_cli/commands/changes.py +307 -0
  17. deepcell_cli/commands/deck.py +594 -0
  18. deepcell_cli/commands/defs.py +3890 -0
  19. deepcell_cli/commands/describe.py +902 -0
  20. deepcell_cli/commands/doc.py +529 -0
  21. deepcell_cli/commands/doctor.py +257 -0
  22. deepcell_cli/commands/download.py +36 -0
  23. deepcell_cli/commands/edit.py +384 -0
  24. deepcell_cli/commands/example.py +161 -0
  25. deepcell_cli/commands/export.py +81 -0
  26. deepcell_cli/commands/export_docx.py +57 -0
  27. deepcell_cli/commands/export_pdf.py +66 -0
  28. deepcell_cli/commands/export_pptx.py +45 -0
  29. deepcell_cli/commands/files.py +386 -0
  30. deepcell_cli/commands/grep.py +90 -0
  31. deepcell_cli/commands/guide.py +431 -0
  32. deepcell_cli/commands/help_cmd.py +348 -0
  33. deepcell_cli/commands/impact.py +382 -0
  34. deepcell_cli/commands/import_cmd.py +208 -0
  35. deepcell_cli/commands/ingest.py +110 -0
  36. deepcell_cli/commands/merge.py +399 -0
  37. deepcell_cli/commands/query.py +718 -0
  38. deepcell_cli/commands/reasoning.py +2981 -0
  39. deepcell_cli/commands/ref.py +279 -0
  40. deepcell_cli/commands/replace.py +326 -0
  41. deepcell_cli/commands/rules.py +206 -0
  42. deepcell_cli/commands/share.py +186 -0
  43. deepcell_cli/commands/sync.py +804 -0
  44. deepcell_cli/commands/upgrade.py +185 -0
  45. deepcell_cli/commands/variant.py +353 -0
  46. deepcell_cli/commands/version.py +445 -0
  47. deepcell_cli/commands/viewer.py +54 -0
  48. deepcell_cli/commands/workspace.py +101 -0
  49. deepcell_cli/config.py +352 -0
  50. deepcell_cli/context.py +187 -0
  51. deepcell_cli/errors.py +141 -0
  52. deepcell_cli/logging_setup.py +161 -0
  53. deepcell_cli/main.py +518 -0
  54. deepcell_cli/mcp_server.py +906 -0
  55. deepcell_cli/oauth_provider.py +580 -0
  56. deepcell_cli/output.py +503 -0
  57. deepcell_cli/revision.py +164 -0
  58. deepcell_cli/stages.py +223 -0
  59. deepcell_cli/surface.py +628 -0
  60. deepcell_cli/sync_state.py +120 -0
  61. deepcell_cli/upgrade_check.py +399 -0
  62. deepcell_cli/xml_replace.py +89 -0
  63. deepcell_cli-0.6.1.dist-info/METADATA +264 -0
  64. deepcell_cli-0.6.1.dist-info/RECORD +67 -0
  65. deepcell_cli-0.6.1.dist-info/WHEEL +5 -0
  66. deepcell_cli-0.6.1.dist-info/entry_points.txt +3 -0
  67. deepcell_cli-0.6.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,382 @@
1
+ """``deepcell impact`` — what still needs review after a change.
2
+
3
+ Mirrors ``backend/jingwei_api/routers/impact.py`` 1:1. A thin pass-through, so
4
+ the CLI, the viewer and an agent get the same answer about what a change
5
+ reached and what counted as material — the judgement lives once, in
6
+ ``src/core/impact``, and nothing here re-decides any of it.
7
+
8
+ This replaces the six-command sequence `guide revise/premise-change` §2 taught
9
+ by hand (`assumption impact`, `reasoning impact`, `relationships`, `cell-meta`
10
+ and one `doc backlinks` per affected id). Those still work and still answer
11
+ narrower questions; this answers the whole one in a single call, and is the
12
+ only one that carries the impact through to the Document sections and Deck
13
+ slides rather than stopping at the reasoning node.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ import shlex
21
+
22
+ import click
23
+
24
+ from deepcell_cli.context import Ctx, pass_ctx
25
+ from deepcell_cli.output import echo_info, echo_success, output, print_plain
26
+
27
+ from ._swapped_args import FileFirstCommand, FileFirstGroup
28
+
29
+ #: How a precision reads to a person. The distinction is the reason an affected
30
+ #: set stops being either too broad or incomplete, so it is spelled out rather
31
+ #: than printed as a term of art.
32
+ _PRECISION = {"exact": "uses this value", "row": "uses this row"}
33
+
34
+ #: Kinds that reached a place without citing anything, so no precision phrase
35
+ #: describes them. Named here rather than imported: `backend/` is not on the
36
+ #: CLI's path, and this is a rendering choice about one line of output, not a
37
+ #: second opinion about what the kind means.
38
+ _NOT_A_REFERENCE = {"stale_literal"}
39
+
40
+ #: `HEAD` or `HEAD~N`, the spelling `describe --lint --since` already takes.
41
+ #: Case-insensitive: `head~1` reached the server as-is and came back a 422.
42
+ _HEAD_REVISION = re.compile(r"^HEAD(?:~(\d+))?$", re.IGNORECASE)
43
+
44
+ #: What the server accepts verbatim: a 7-64 character hex SHA.
45
+ _SHA_REVISION = re.compile(r"^[0-9a-fA-F]{7,64}$")
46
+
47
+ _SINCE_HELP = (
48
+ "The revision to compare from: a SHA from `deepcell log`, or HEAD~N for "
49
+ "the N-th commit before the workspace's newest (resolved through the log "
50
+ "here; the server takes a 7-64 character SHA only). Note the SHA before "
51
+ "you edit — a premise change is usually several commits, and HEAD~1 "
52
+ "reaches only the last of them."
53
+ )
54
+
55
+
56
+ def _resolve_revision(ctx: Ctx, slug: str, revision: str, option: str) -> str:
57
+ """Turn `HEAD~N` into the SHA the server insists on.
58
+
59
+ `option` is the flag the value came from (`--since` or `--revision`), so
60
+ the usage error names the one to fix.
61
+
62
+ The server rejects anything that is not a hex SHA, so the CLI resolves the
63
+ shorthand the same way a reader would by hand: the workspace log, newest
64
+ first, whichever file each commit touched — which is what git's `HEAD~N`
65
+ means on the linear `main` every workspace has. The file filter is left
66
+ off on purpose: `describe --lint --since HEAD~1` is git-native and counts
67
+ every commit, and two commands spelling `HEAD~1` as different commits
68
+ would be worse than one spelling it only as a SHA.
69
+ """
70
+ match = _HEAD_REVISION.match(revision)
71
+ if match is None:
72
+ if _SHA_REVISION.match(revision):
73
+ return revision
74
+ # Anything else would round-trip to the server as a 422 in
75
+ # pydantic's words; say what the two accepted spellings are here.
76
+ raise click.BadParameter(
77
+ f"{revision!r} is neither a SHA (7-64 hex characters, from "
78
+ "`deepcell log`) nor HEAD~N.",
79
+ param_hint=option,
80
+ )
81
+ depth = int(match.group(1) or 0)
82
+ commits = ctx.client.get(
83
+ f"/workspaces/{slug}/versions", params={"max_count": depth + 1},
84
+ )
85
+ if not isinstance(commits, list) or len(commits) <= depth:
86
+ raise click.BadParameter(
87
+ f"{revision} is past the start of this workspace's history "
88
+ f"({len(commits) if isinstance(commits, list) else 0} commit(s)). "
89
+ "Pass a SHA from `deepcell log`.",
90
+ param_hint=option,
91
+ )
92
+ return commits[depth]["sha"]
93
+
94
+
95
+ @click.group(cls=FileFirstGroup)
96
+ def impact() -> None:
97
+ """What a change reached, and what still needs review."""
98
+
99
+
100
+ @impact.command("show", cls=FileFirstCommand)
101
+ @click.argument("filename")
102
+ @click.option("--since", "base_revision", required=True, help=_SINCE_HELP)
103
+ @click.option(
104
+ "--revision", default=None,
105
+ help="The revision to compare to (a SHA or HEAD~N). Defaults to the "
106
+ "working tree.",
107
+ )
108
+ @click.option(
109
+ "--threshold", type=float, default=None,
110
+ help=(
111
+ "Relative move at or above which a value change is material. "
112
+ "Defaults to the built-in 1%."
113
+ ),
114
+ )
115
+ @click.option(
116
+ "--include-reviewed", is_flag=True, default=False,
117
+ help="Also show items somebody has already marked reviewed.",
118
+ )
119
+ @pass_ctx
120
+ def show(
121
+ ctx: Ctx,
122
+ filename: str,
123
+ base_revision: str,
124
+ revision: str | None,
125
+ threshold: float | None,
126
+ include_reviewed: bool,
127
+ ) -> None:
128
+ """List every place that may need review after a change.
129
+
130
+ Run it AFTER the edit lands: it compares two revisions, so before the
131
+ edit there is nothing to compare. Every item prints its `locator` and a
132
+ ready-to-paste `impact review` line — the review command needs both the
133
+ surface and the locator, and this is the only place they are printed.
134
+
135
+ Same-file only. Inverting cross-file links would mean scanning every
136
+ .deepcell in the workspace, which has no index and would undercount while
137
+ looking authoritative — so the scope is printed rather than assumed.
138
+ """
139
+ slug = ctx.require_workspace()
140
+ body = {
141
+ "workspace_slug": slug,
142
+ "source_filename": filename,
143
+ "base_revision": _resolve_revision(ctx, slug, base_revision, "--since"),
144
+ "include_reviewed": include_reviewed,
145
+ }
146
+ if revision is not None:
147
+ body["revision"] = _resolve_revision(ctx, slug, revision, "--revision")
148
+ if threshold is not None:
149
+ body["threshold"] = threshold
150
+
151
+ data = ctx.client.post("/impact", json=body)
152
+ if ctx.fmt != "plain":
153
+ output(data, ctx.fmt)
154
+ return
155
+
156
+ items = data.get("items", [])
157
+ # A created file whose queue somebody has WORKED THROUGH is aligned in the
158
+ # ordinary sense, and saying it "holds nothing to review" would deny the
159
+ # review that just happened — on a payload whose own `reviewed` counts it.
160
+ never_reviewed = not data.get("reviewed")
161
+ if not items:
162
+ if data.get("base_absent") and never_reviewed:
163
+ # `--since` named a revision older than the file itself, and the
164
+ # new file cites nothing a reader has to judge. "Everything is
165
+ # aligned" would credit a re-check that never had anything to do.
166
+ echo_success(
167
+ f"{filename} did not exist at that revision — this change "
168
+ f"created it, and it holds nothing to review."
169
+ )
170
+ else:
171
+ echo_success("Everything is aligned.")
172
+ _print_summary(data.get("summary") or {})
173
+ return
174
+
175
+ counts = data.get("counts") or {}
176
+ tally = " · ".join(f"{surface} {n}" for surface, n in sorted(counts.items()))
177
+ echo_info(f"{len(items)} place(s) need review — {tally}")
178
+ if data.get("base_absent") and never_reviewed:
179
+ # Every place is listed because the file is new, not because a change
180
+ # reached it. Without this the count reads as damage. Dropped once
181
+ # anything has been reviewed, when the claim stops being true.
182
+ print_plain(
183
+ f"{filename} did not exist at that revision — this change created "
184
+ f"it, so nothing here has been reviewed yet."
185
+ )
186
+ print_plain(f"scope: {data.get('scope', 'this file only')}")
187
+ print_plain("")
188
+
189
+ for item in items:
190
+ marker = "!" if item["state"] == "unresolved" else "·"
191
+ agent = " [agent can help]" if item.get("agent_eligible") else ""
192
+ print_plain(f"{marker} {item['surface'].upper()} {item['display']}{agent}")
193
+ print_plain(f" {item['cause']['summary']}")
194
+ if item["kind"] in _NOT_A_REFERENCE:
195
+ # Every phrase in `_PRECISION` describes how a *citation* addresses
196
+ # what moved, and these kinds are not citations — the text spells
197
+ # the number out. "Uses this value" beside "the text writes 200.75
198
+ # out as a literal" contradicts itself in two consecutive lines.
199
+ print_plain(f" {item['gate_reason']}")
200
+ else:
201
+ print_plain(
202
+ f" {_PRECISION.get(item['precision'], item['precision'])}"
203
+ f" — {item['gate_reason']}"
204
+ )
205
+ if item.get("path"):
206
+ # "Why is this affected?" — the nodes the change travelled through.
207
+ print_plain(f" via {' -> '.join(item['path'])}")
208
+ print_plain(f" key {item['item_key']}")
209
+ print_plain(f" locator {item['locator']}")
210
+ # The next command, spelled out. `impact review` needs the surface and
211
+ # the locator as well as the key, and nothing else prints the locator
212
+ # — without this line the listing could not be acted on from its own
213
+ # output. A broken reference gets no review line because the server
214
+ # refuses one: it is rebound, not reviewed.
215
+ if item["state"] == "unresolved":
216
+ print_plain(" rebind the reference; a review does not clear it")
217
+ else:
218
+ print_plain(f" review: {_review_line(filename, item)}")
219
+ print_plain("")
220
+
221
+ unresolved = data.get("unresolved", 0)
222
+ if unresolved:
223
+ # Said out loud because it is the one thing that stops this file being
224
+ # aligned however many paragraphs get confirmed.
225
+ echo_info(
226
+ f"{unresolved} unresolved reference(s) — these need a rebind, "
227
+ "not a review."
228
+ )
229
+
230
+
231
+ @impact.command("review", cls=FileFirstCommand)
232
+ @click.argument("filename")
233
+ @click.argument("item_key")
234
+ @click.option(
235
+ "--surface", required=True,
236
+ help="The surface the item is on, as `impact show` prints it.",
237
+ )
238
+ @click.option(
239
+ "--locator", required=True,
240
+ help="The item's address — the `locator` line `impact show` prints.",
241
+ )
242
+ @click.option(
243
+ "--outcome", type=click.Choice(["confirmed", "revised"]), default="confirmed",
244
+ help="confirmed: it still reads correctly. revised: it was rewritten.",
245
+ )
246
+ @click.option(
247
+ "--kind", default=None,
248
+ help="The item's kind. An unresolved one is refused: it needs a rebind.",
249
+ )
250
+ @pass_ctx
251
+ def review(
252
+ ctx: Ctx, filename: str, item_key: str, surface: str, locator: str,
253
+ outcome: str, kind: str | None,
254
+ ) -> None:
255
+ """Mark one item reviewed — the marker disappears.
256
+
257
+ `impact show` prints this command ready to paste under every item, key,
258
+ surface and locator filled in. It comes back on its own if the same place
259
+ is reached by a *new* change: the key folds in what changed, not only
260
+ where.
261
+
262
+ `--kind` is optional and worth passing: a `broken_ref` is refused outright,
263
+ rather than recorded and then ignored on the next read because a review
264
+ decision cannot rebind an address.
265
+ """
266
+ ctx.client.post("/impact/review", json={
267
+ "workspace_slug": ctx.require_workspace(),
268
+ "source_filename": filename,
269
+ "item_key": item_key,
270
+ "surface": surface,
271
+ "locator": locator,
272
+ "outcome": outcome,
273
+ **({"kind": kind} if kind else {}),
274
+ })
275
+ echo_success(f"Reviewed ({outcome}): {item_key}")
276
+
277
+
278
+ @impact.command("apply", cls=FileFirstCommand)
279
+ @click.argument("filename")
280
+ @click.argument("item_key")
281
+ @click.option("--surface", required=True, help="The surface the item is on.")
282
+ @click.option("--locator", required=True, help="The item's address.")
283
+ @click.option("--op", required=True, help="The reasoning op to apply.")
284
+ @click.option("--target-id", required=True, help="The Claim or Assumption it addresses.")
285
+ @click.option(
286
+ "--payload", default=None,
287
+ help="The op's fields, as JSON. Omit for an op that needs none.",
288
+ )
289
+ @click.option(
290
+ "--new-claim", default=None,
291
+ help="JSON for the replacement Claim. Required by supersede_claim.",
292
+ )
293
+ @click.option(
294
+ "--new-assumption", default=None,
295
+ help="JSON for the replacement Assumption. Required by supersede_assumption.",
296
+ )
297
+ @click.option(
298
+ "--expected-revision", default=None,
299
+ help="The revision you read at. A newer document is a 409 rather than an "
300
+ "overwrite.",
301
+ )
302
+ @pass_ctx
303
+ def apply(
304
+ ctx: Ctx, filename: str, item_key: str, surface: str, locator: str,
305
+ op: str, target_id: str, payload: str | None, new_claim: str | None,
306
+ new_assumption: str | None, expected_revision: str | None,
307
+ ) -> None:
308
+ """Apply one reviewed fix, and record it as `revised`.
309
+
310
+ The write and the ledger row are one decision. Applied and unrecorded, the
311
+ item comes back on the next read as though nothing happened and you fix it
312
+ twice.
313
+
314
+ Only reasoning ops, and only the ones a review may make: no `delete_*`
315
+ (supersession preserves history a delete destroys) and no
316
+ `set_conclusion` / `set_key_question` (reframing the document is the
317
+ author's call). Anything else is refused, whatever proposed it.
318
+ """
319
+ def _json(flag: str, raw: str | None):
320
+ if raw is None:
321
+ return None
322
+ try:
323
+ return json.loads(raw)
324
+ except json.JSONDecodeError as exc:
325
+ raise click.BadParameter(f"{flag} is not valid JSON: {exc}")
326
+
327
+ data = ctx.client.post("/impact/apply", json={
328
+ "workspace_slug": ctx.require_workspace(),
329
+ "source_filename": filename,
330
+ "item_key": item_key,
331
+ "surface": surface,
332
+ "locator": locator,
333
+ "op": op,
334
+ "target_id": target_id,
335
+ "payload": _json("--payload", payload) or {},
336
+ **({"new_claim": _json("--new-claim", new_claim)} if new_claim else {}),
337
+ **({"new_assumption": _json("--new-assumption", new_assumption)}
338
+ if new_assumption else {}),
339
+ **({"expected_revision": expected_revision} if expected_revision else {}),
340
+ })
341
+ echo_success(f"Applied and recorded as revised: {item_key}")
342
+ if data.get("revision"):
343
+ echo_info(f"Now at {data['revision']}.")
344
+
345
+
346
+ @impact.command("reopen", cls=FileFirstCommand)
347
+ @click.argument("filename")
348
+ @click.argument("item_key")
349
+ @pass_ctx
350
+ def reopen(ctx: Ctx, filename: str, item_key: str) -> None:
351
+ """Undo one review decision."""
352
+ data = ctx.client.post("/impact/reopen", json={
353
+ "workspace_slug": ctx.require_workspace(),
354
+ "source_filename": filename,
355
+ "item_key": item_key,
356
+ })
357
+ if data.get("removed"):
358
+ echo_success(f"Reopened: {item_key}")
359
+ else:
360
+ echo_info(f"Nothing to reopen: {item_key} was not marked reviewed.")
361
+
362
+
363
+ def _review_line(filename: str, item: dict) -> str:
364
+ """The `impact review` invocation that clears `item`, shell-quoted."""
365
+ return (
366
+ f"deepcell impact review {shlex.quote(filename)} {item['item_key']}"
367
+ f" --surface {item['surface']} --locator {shlex.quote(item['locator'])}"
368
+ f" --kind {item['kind']} --outcome confirmed"
369
+ )
370
+
371
+
372
+ def _print_summary(summary: dict) -> None:
373
+ if not summary:
374
+ return
375
+ lines = [
376
+ (summary.get("updated_automatically", 0), "value(s) updated automatically"),
377
+ (summary.get("confirmed", 0), "item(s) confirmed"),
378
+ (summary.get("revised", 0), "item(s) revised"),
379
+ ]
380
+ for count, label in lines:
381
+ if count:
382
+ print_plain(f" {count} {label}")
@@ -0,0 +1,208 @@
1
+ """``deepcell import`` — import xlsx/csv files into .deepcell documents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from deepcell_cli.commands._datatypes import warn_unrecognized_data_type
11
+ from deepcell_cli.context import Ctx, pass_ctx
12
+ from deepcell_cli.output import echo_success, echo_warning
13
+
14
+
15
+ def _load_json_arg(value: str) -> list:
16
+ """Load JSON from a string or file path."""
17
+ path = Path(value)
18
+ try:
19
+ is_file = path.exists()
20
+ except OSError:
21
+ # `Path.exists()` raises ENAMETOOLONG on an inline payload longer
22
+ # than a path component, which read as "Import failed" for any items
23
+ # list past a few entries. A string that cannot be a path is JSON.
24
+ is_file = False
25
+ if is_file:
26
+ return json.loads(path.read_text())
27
+ try:
28
+ return json.loads(value)
29
+ except json.JSONDecodeError as e:
30
+ raise click.ClickException(f"Invalid JSON: {e}")
31
+
32
+
33
+ @click.command("import")
34
+ @click.argument("file", type=click.Path(exists=True))
35
+ @click.option("--items", required=True, help="Items JSON string or path to JSON file.")
36
+ @click.option("--contexts", required=True, help="Contexts JSON string or path to JSON file.")
37
+ @click.option("--name", default=None, help="Name for the .deepcell file (default: input filename).")
38
+ @click.option("--workspace", default=None, help="Target workspace slug.")
39
+ @click.option("--aggregations", default=None, help="Aggregation rules JSON string or path to JSON file.")
40
+ @click.option("--keep-raw", is_flag=True, default=False, help="Store raw values alongside aggregated results.")
41
+ @pass_ctx
42
+ def import_cmd(
43
+ ctx: Ctx,
44
+ file: str,
45
+ items: str,
46
+ contexts: str,
47
+ name: str | None,
48
+ workspace: str | None,
49
+ aggregations: str | None,
50
+ keep_raw: bool,
51
+ ) -> None:
52
+ """Import an xlsx or csv file into a .deepcell document.
53
+
54
+ Requires structured analysis as JSON: items (with row numbers) and
55
+ contexts (with column letters). The cell map is auto-generated from
56
+ the item rows × context columns cross-product. The result is saved as
57
+ NAME.deepcell (--name, default: the input file's stem) and replaces a
58
+ file of that name — import creates; it does not merge into an existing
59
+ document.
60
+
61
+ Use --aggregations to specify custom mapping rules that aggregate
62
+ source values into target coordinates. Only aggregated results are
63
+ stored unless --keep-raw is also provided.
64
+
65
+ \b
66
+ An items entry takes:
67
+ id, name, level required
68
+ parent_id hierarchy
69
+ data_type monetary | percentage | ratio | quantity |
70
+ count | number | text | date — the set
71
+ `deepcell ref datatype` documents. Anything
72
+ else is stored verbatim with a warning and
73
+ renders as plain text.
74
+ scale, currency REQUIRED on a monetary item (house rule R7) —
75
+ scale is the power of ten the values are
76
+ stated in (3 = thousands), currency an ISO
77
+ 4217 code. Omit them and every monetary row
78
+ fails `describe --lint`, because 4200 could
79
+ be dollars, thousands or millions.
80
+ row, sheet where the data lives in the workbook. A
81
+ multi-sheet workbook needs `sheet` on EVERY
82
+ item: one without it is read from the first
83
+ item's sheet (else Sheet1) — silently the
84
+ wrong tab, or a skipped cell.
85
+
86
+ \b
87
+ A contexts entry takes:
88
+ id, name, period_type required (annual | quarterly | monthly | custom)
89
+ status which status this column belongs to
90
+ status_archetype what that status IS (R13): actual |
91
+ preliminary | restated | estimate | guidance
92
+ | consensus | forecast | budget | plan |
93
+ target. `deepcell ref status` lists them.
94
+ state closed | open | future (R13) — period kinds only
95
+ start_date, end_date ISO dates
96
+ column workbook column letter — a letter only, no
97
+ sheet. Every context column is read on every
98
+ item's sheet, so one import carries ONE column
99
+ layout. A tab whose periods sit in other
100
+ columns is a second pass into the same file
101
+ via `deepcell edit FILE --batch`, never a
102
+ second import under another name (rule R3).
103
+ kind period (default) or a non-temporal label
104
+
105
+ Everything R7 and R13 require can be stated at import time. Leaving them
106
+ out means a follow-up `defs update-item` per item and `defs update-context`
107
+ per context before the document is rule-clean.
108
+
109
+ Provenance: one <Source kind="workbook"> with a <Cites at="sheet:TAB!B5">
110
+ row per mapped cell (item row × context column). `deepcell guide ingest/tabular` covers what to do
111
+ after that.
112
+ """
113
+ file_path = Path(file)
114
+ if name is None:
115
+ name = file_path.stem
116
+
117
+ # Parse JSON arguments
118
+ items_data = _load_json_arg(items)
119
+ contexts_data = _load_json_arg(contexts)
120
+
121
+ # Same check `defs add-item --data-type` runs. The backend stores the
122
+ # type verbatim, so a typo (`moneytary`) or the retired `string` would
123
+ # otherwise land in the file and every formatter would silently fall back
124
+ # to its default. A warning, not a rejection — the vocabulary is open.
125
+ if isinstance(items_data, list):
126
+ for index, entry in enumerate(items_data):
127
+ if not isinstance(entry, dict):
128
+ continue
129
+ message = warn_unrecognized_data_type(entry.get("data_type"))
130
+ if message:
131
+ label = entry.get("id") or f"items[{index}]"
132
+ echo_warning(f"{label}: {message}")
133
+
134
+ fields: dict[str, str] = {
135
+ "items": json.dumps(items_data),
136
+ "contexts": json.dumps(contexts_data),
137
+ "name": name,
138
+ "workspace_slug": workspace or ctx.require_workspace(),
139
+ }
140
+
141
+ if aggregations:
142
+ agg_data = _load_json_arg(aggregations)
143
+ fields["aggregations"] = json.dumps(agg_data)
144
+
145
+ if keep_raw:
146
+ fields["keep_raw"] = "true"
147
+
148
+ # Send to backend
149
+ try:
150
+ result = ctx.client.post_multipart(
151
+ "/import",
152
+ file_path=str(file_path),
153
+ file_name=file_path.name,
154
+ fields=fields,
155
+ )
156
+ except click.ClickException:
157
+ raise
158
+ except Exception as e:
159
+ raise click.ClickException(f"Import failed: {e}")
160
+
161
+ skipped = result.get("skipped_values") or []
162
+
163
+ # Handle JSON output format
164
+ if ctx.fmt == "json":
165
+ click.echo(json.dumps(result, indent=2))
166
+ if skipped:
167
+ raise click.exceptions.Exit(1)
168
+ return
169
+
170
+ # Display result
171
+ summary = result.get("summary", {})
172
+ dest = result.get("file_path", name)
173
+ ws = result.get("workspace", "default")
174
+
175
+ echo_success(f"Imported {file_path.name} -> {dest}")
176
+ click.echo(f" Workspace: {ws}", err=True)
177
+ click.echo(f" Items: {summary.get('items', 0)}", err=True)
178
+ click.echo(f" Contexts: {summary.get('contexts', 0)}", err=True)
179
+ click.echo(f" Values: {summary.get('values', 0)}", err=True)
180
+
181
+ fc = summary.get("formulas_converted", 0)
182
+ fw = summary.get("formulas_warnings", 0)
183
+ if fc or fw:
184
+ click.echo(f" Formulas: {fc} converted, {fw} warnings", err=True)
185
+
186
+ # Show warnings
187
+ for w in result.get("warnings", []):
188
+ echo_warning(f"{w.get('sheet', '')}:{w.get('cell', '')} -- {w.get('issue', '')}")
189
+
190
+ for s in skipped:
191
+ echo_warning(f"Skipped {s.get('sheet', '')}:{s.get('cell', '')} ({s.get('item_id', '')}) -- {s.get('issue', '')}")
192
+
193
+ if aggregations and not keep_raw:
194
+ echo_warning(
195
+ "Raw source values were not stored. "
196
+ "Use --keep-raw to retain source values alongside aggregated results."
197
+ )
198
+
199
+ if skipped:
200
+ # Skipped cells = data missing from the imported model. The import is
201
+ # committed, but it must not read as a clean success (same contract as
202
+ # `variant merge`, which exits 1 on had_data_loss).
203
+ echo_warning(
204
+ f"{len(skipped)} source value(s) skipped — the imported model is "
205
+ "missing data; fix the cells above and re-import, or fill them "
206
+ "with `deepcell edit`"
207
+ )
208
+ raise click.exceptions.Exit(1)