dataspring-cli 0.4.3__tar.gz → 0.4.5__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.
Files changed (22) hide show
  1. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/PKG-INFO +1 -1
  2. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/cli/bundled_manifest.py +1 -1
  3. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/cli/contract.py +15 -4
  4. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/cli/generated.py +4 -3
  5. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/cli/main.py +180 -16
  6. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/pyproject.toml +1 -1
  7. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/skills/dataspring-author/SKILL.md +105 -7
  8. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/skills/dataspring-consume/SKILL.md +16 -9
  9. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/.gitignore +0 -0
  10. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/README.md +0 -0
  11. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/cli/__init__.py +0 -0
  12. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/cli/auth.py +0 -0
  13. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/cli/output.py +0 -0
  14. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/cli/refusals.py +0 -0
  15. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/cli/runtime.py +0 -0
  16. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/cli/skills_commands.py +0 -0
  17. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/cli/skilltree.py +0 -0
  18. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/cli/upgrade.py +0 -0
  19. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/cli/version.py +0 -0
  20. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/hatch_build.py +0 -0
  21. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/settings.py +0 -0
  22. {dataspring_cli-0.4.3 → dataspring_cli-0.4.5}/skills/dataspring-correct/SKILL.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dataspring-cli
3
- Version: 0.4.3
3
+ Version: 0.4.5
4
4
  Summary: DataSpring CLI - Query metrics and manage dashboards from the terminal
5
5
  Project-URL: Homepage, https://dataspring.app
6
6
  Project-URL: Documentation, https://dataspring.app/docs
@@ -6,7 +6,7 @@ the server's to say whether the server has operations this CLI does not
6
6
  know. tests/test_skills_surface.py asserts it is current.
7
7
  """
8
8
 
9
- MANIFEST_HASH = "e61e4a47f3c193beb2f6d731efde82839f5e62788bfa18560277459487bb26a7"
9
+ MANIFEST_HASH = "54af1f0a9d07ef692b63e0648d835ce5efc020bd9588db6d75897b36af4e0232"
10
10
  TOOL_KEYS = (
11
11
  "business_context_edit",
12
12
  "semantic_model_edit",
@@ -656,6 +656,14 @@ class CheckDatacoreAction(DispatchParams):
656
656
 
657
657
  action: Literal["check"]
658
658
  note: str | None = Field(default=None, description="Optional note, recorded on the check run")
659
+ build: bool = Field(
660
+ default=True,
661
+ description=(
662
+ "false = validate only: compile the pipelines, check the connections, dbt parse, the semantic "
663
+ "validation per metric and the manifest import's dry run, with no dbt build (an answer in well under "
664
+ "a minute, beside a running check or load). It is NEVER the green check deploy or auto_deploy needs"
665
+ ),
666
+ )
659
667
 
660
668
 
661
669
  class DeployDatacoreAction(DispatchParams):
@@ -707,16 +715,19 @@ class RunPipelineAction(DispatchParams):
707
715
  description=(
708
716
  "A date window to load instead of the pipeline's own; wider than backfill_chunk becomes a chunked backfill. "
709
717
  "Both ends are inclusive days, and so is every chunk; a resource whose API reads its end bound as exclusive "
710
- "declares `window.end: exclusive` in the pipeline and is sent the day after each chunk's last day"
718
+ "declares `window.end: exclusive` in the pipeline and is sent the day after each chunk's last day. A rest resource "
719
+ "whose window has a `step` sends one request per step of the window or chunk, not one for the whole window"
711
720
  ),
712
721
  )
713
722
  cursor: RunCursorParams | None = Field(default=None, description="{from: <value>}: a cursor to re-pull from")
714
723
  backfill_chunk: str | None = Field(
715
724
  default=None,
716
725
  description=(
717
- "Chunk size for a windowed run: 7d, 2w or 1M (calendar months, the default). Every chunk before the last "
718
- "loads only the windowed resources (and the ones they read rows from) and skips its dbt step, each skip "
719
- "recorded with its reason on the child run; the last chunk loads every resource and builds the models once"
726
+ "Chunk size for a windowed run: <n>d, <n>w or <n>M (days, weeks or calendar months; 1M is the default, "
727
+ "3M a quarter, 12M a year; there is no Y). Every chunk before the last "
728
+ "loads only the windowed resources (with their resolve children and the ones they read rows from) and "
729
+ "skips its dbt step, each skip recorded with its reason on the child run; the last chunk loads every "
730
+ "resource and builds the models once"
720
731
  ),
721
732
  )
722
733
 
@@ -882,11 +882,12 @@ datacore_run = typer.Typer(name='datacore_run', help="The datacore's check-then-
882
882
  @datacore_run.command('check', short_help='Check the current draft (member): compile every pipeline, build the changed dbt models into <org>_dev with their tests, and validate the semantic manifest per metric. Nothing reaches production.')
883
883
  def datacore_run__check(
884
884
  note: Annotated[Optional[str], typer.Option('--note', help='Optional note, recorded on the check run')] = None,
885
+ build: Annotated[Optional[bool], typer.Option('--build/--no-build', help="false = validate only: compile the pipelines, check the connections, dbt parse, the semantic validation per metric and the manifest import's dry run, with no dbt build (an answer in well under a minute, beside a running check or load). It is NEVER the green check deploy or auto_deploy needs (default: True)")] = None,
885
886
  from_file: FromFile = None,
886
887
  output: Output = None,
887
888
  ) -> None:
888
889
  """Check the current draft (member): compile every pipeline, build the changed dbt models into <org>_dev with their tests, and validate the semantic manifest per metric. Nothing reaches production."""
889
- run_tool('datacore_run', contract.DatacoreRunParams, 'check', {'note': note}, from_file=from_file, output=output)
890
+ run_tool('datacore_run', contract.DatacoreRunParams, 'check', {'note': note, 'build': build}, from_file=from_file, output=output)
890
891
 
891
892
 
892
893
  @datacore_run.command('deploy', short_help='Promote the checked draft to deployed (admin), or roll back to an earlier deployed snapshot; then_build also rebuilds changed models.')
@@ -905,9 +906,9 @@ def datacore_run__deploy(
905
906
  @datacore_run.command('run', short_help='Load a pipeline now (admin): its own window, a given window, or a chunked backfill whose chunks run back to back.')
906
907
  def datacore_run__run(
907
908
  pipeline: Annotated[str, typer.Option('--pipeline', help='Pipeline name (a deployed pipelines/<name>.yaml)')],
908
- window: Annotated[Optional[str], typer.Option('--window-json', help="A date window to load instead of the pipeline's own; wider than backfill_chunk becomes a chunked backfill. Both ends are inclusive days, and so is every chunk; a resource whose API reads its end bound as exclusive declares `window.end: exclusive` in the pipeline and is sent the day after each chunk's last day (JSON object with keys: start, end, or @path to a .json/.yaml file)")] = None,
909
+ window: Annotated[Optional[str], typer.Option('--window-json', help="A date window to load instead of the pipeline's own; wider than backfill_chunk becomes a chunked backfill. Both ends are inclusive days, and so is every chunk; a resource whose API reads its end bound as exclusive declares `window.end: exclusive` in the pipeline and is sent the day after each chunk's last day. A rest resource whose window has a `step` sends one request per step of the window or chunk, not one for the whole window (JSON object with keys: start, end, or @path to a .json/.yaml file)")] = None,
909
910
  cursor: Annotated[Optional[str], typer.Option('--cursor-json', help='{from: <value>}: a cursor to re-pull from (JSON object with keys: from, or @path to a .json/.yaml file)')] = None,
910
- backfill_chunk: Annotated[Optional[str], typer.Option('--backfill-chunk', help='Chunk size for a windowed run: 7d, 2w or 1M (calendar months, the default). Every chunk before the last loads only the windowed resources (and the ones they read rows from) and skips its dbt step, each skip recorded with its reason on the child run; the last chunk loads every resource and builds the models once')] = None,
911
+ backfill_chunk: Annotated[Optional[str], typer.Option('--backfill-chunk', help='Chunk size for a windowed run: <n>d, <n>w or <n>M (days, weeks or calendar months; 1M is the default, 3M a quarter, 12M a year; there is no Y). Every chunk before the last loads only the windowed resources (with their resolve children and the ones they read rows from) and skips its dbt step, each skip recorded with its reason on the child run; the last chunk loads every resource and builds the models once')] = None,
911
912
  from_file: FromFile = None,
912
913
  output: Output = None,
913
914
  ) -> None:
@@ -94,7 +94,9 @@ class DataSpringGroup(TyperGroup):
94
94
  #: exit codes an agent branches on (hub D-99, suite standards §6).
95
95
  ROOT_HELP = """DataSpring CLI: query metrics, run the datacore and manage boards from the terminal.
96
96
 
97
- Sign in with `dataspring auth login`. Every command acts in one org: --org, then DATASPRING_ORG or FORSPRING_ORG, then .forspring.json, then the key's own org, then the only stored key; `dataspring whoami` says which. No command changes the org for later ones.
97
+ Sign in with `dataspring auth login`: it prints a code and a link; open the link on any device and approve (works over SSH and in containers too). Every command acts in one org: --org, then DATASPRING_ORG or FORSPRING_ORG, then .forspring.json, then the key's own org, then the only stored key; `dataspring whoami` says which. No command changes the org for later ones.
98
+
99
+ Agents over MCP need no CLI: `claude mcp add --transport http --scope user dataspring https://dataspring.app/api/mcp/`, then sign in and approve the org in the browser. For CI with no person present: `export DATASPRING_API_KEY=<key>` (a key from the console's "Automation and CI"; it acts in its own org).
98
100
 
99
101
  A refusal prints `<reason>: <what happened> Fix: <what fixes it>` and exits 4 (not_signed_in, product_off, org_suspended, role_too_low, org_mismatch), 3 (not_a_member), 2 (org_required) or 1 (account_unavailable: retry). Any other failure exits 1, a usage error 2, a removed command 64."""
100
102
 
@@ -136,7 +138,7 @@ learned_app = typer.Typer(help="The learned trail: what DataSpring learned for t
136
138
  app.add_typer(learned_app, name="learned")
137
139
  warehouses_app = typer.Typer(help="The org's warehouses: external and managed, and which is active")
138
140
  app.add_typer(warehouses_app, name="warehouses")
139
- datacore_app = typer.Typer(help="The datacore workspace: pull, edit locally, push, check, deploy, build, run, cancel; schema, freshness and sample of its tables")
141
+ datacore_app = typer.Typer(help="The datacore workspace: pull, edit locally, push, check, deploy, build, run, cancel; schema, freshness and sample of its tables; the reference (pipeline and connection schemas, the connector catalog)")
140
142
  app.add_typer(datacore_app, name="datacore")
141
143
  from cli.skills_commands import skills_app # noqa: E402
142
144
  app.add_typer(skills_app, name="skills")
@@ -593,17 +595,25 @@ def auth_login(
593
595
  no_browser: Annotated[bool, typer.Option("--no-browser", help="Do not try to open a browser; just print the URL")] = False,
594
596
  all_orgs: Annotated[bool, typer.Option("--all-orgs", help="Ask for a key in every org you belong to, not only the one you approve (the browser says so)")] = False,
595
597
  ):
596
- """Sign in with the device flow: approve in any browser where you are signed in with your Forspring account.
598
+ """Sign in: the CLI prints a code and a link; open the link on any device and approve.
597
599
 
598
- Prints the URL and the code at once (also when the output is redirected,
599
- so an agent can run it in the background and hand both to a person),
600
- then waits for the approval. Mints an ordinary API key labelled 'CLI on
601
- <hostname>' for the org you approve (one per org you are in with
602
- --all-orgs), stored in ~/.dataspring/credentials.json.
600
+ It works over SSH and in containers too: the machine running the CLI
601
+ needs no browser. The URL and the code are printed at once (also when the
602
+ output is redirected, so an agent can run it in the background and hand
603
+ both to a person), then it waits for the approval. Mints an ordinary API
604
+ key labelled 'CLI on <hostname>' for the org you approve (one per org you
605
+ are in with --all-orgs), stored in ~/.dataspring/credentials.json.
603
606
 
604
607
  Logging in picks no org for later commands: with more than one key
605
608
  stored, name it per command (--org, DATASPRING_ORG, .forspring.json).
606
- DATASPRING_API_KEY, when set, always wins over the stored keys.
609
+
610
+ Automation and CI, with no person present: export DATASPRING_API_KEY=<key>
611
+ (a key made in the console's connect page, under "Automation and CI"); it
612
+ always wins over the stored keys and acts in its own org. --key stores
613
+ such a key instead.
614
+
615
+ Agents over MCP do not need this: claude mcp add --transport http
616
+ --scope user dataspring https://dataspring.app/api/mcp/
607
617
  """
608
618
  try:
609
619
  if key:
@@ -2844,7 +2854,8 @@ def _check_finding(entry: dict) -> str:
2844
2854
  def _print_check_report(run: dict) -> None:
2845
2855
  report = run.get("check") or {}
2846
2856
  status = run.get("status")
2847
- line = f"check {run.get('id')}: {status}"
2857
+ validate_only = run.get("build") is False or report.get("build") is False
2858
+ line = f"{'validate-only check' if validate_only else 'check'} {run.get('id')}: {status}"
2848
2859
  if report.get("snapshot"):
2849
2860
  line += f" (snapshot {report['snapshot']})"
2850
2861
  (print_success if status == "succeeded" else print_error)(line)
@@ -2857,6 +2868,17 @@ def _print_check_report(run: dict) -> None:
2857
2868
  dbt = report.get("dbt") or {}
2858
2869
  if dbt.get("status") == "skipped" and dbt.get("reason"):
2859
2870
  typer.echo(f" dbt skipped: {dbt['reason']}")
2871
+ elif dbt and validate_only:
2872
+ # Tick ip2: parsed and validated, nothing built; the counts are what the parse found.
2873
+ parsed = dbt.get("parsed") or {}
2874
+ counts = ", ".join(f"{parsed[k]} {k.replace('_', ' ')}" for k in ("models", "semantic_models", "metrics") if k in parsed)
2875
+ typer.echo(f" dbt {dbt.get('status')}: parsed and validated, nothing built" + (f" ({counts})" if counts else ""))
2876
+ if dbt.get("error"):
2877
+ typer.echo(f" {dbt['error']}")
2878
+ for failure in dbt.get("failures") or []:
2879
+ typer.echo(f" FAIL {_check_finding(failure)}")
2880
+ for warning in dbt.get("warnings") or []:
2881
+ typer.echo(f" warn {_check_finding(warning)}")
2860
2882
  elif dbt:
2861
2883
  typer.echo(
2862
2884
  f" dbt {dbt.get('status')}: {dbt.get('models', 0)} models, {dbt.get('tests', 0)} tests, "
@@ -2882,6 +2904,8 @@ def _print_check_report(run: dict) -> None:
2882
2904
  if target and dbt.get("models"):
2883
2905
  typer.echo(f" read it dataspring sql \"SELECT * FROM {target}.<model> LIMIT 20\" "
2884
2906
  f"(the models this check built; {target} expires tables after 7 days)")
2907
+ if validate_only:
2908
+ typer.echo(" note validate-only: never the green check a deploy needs; `dataspring datacore check` builds and tests")
2885
2909
 
2886
2910
 
2887
2911
  def _poll_run(run_id: str, token: str, *, interval: float = 10.0, timeout: float = 1800.0) -> dict:
@@ -2905,10 +2929,25 @@ def _poll_run(run_id: str, token: str, *, interval: float = 10.0, timeout: float
2905
2929
  @datacore_app.command("check")
2906
2930
  def datacore_check_cmd(
2907
2931
  wait: Annotated[bool, typer.Option("--wait/--no-wait", help="Poll the check run to its end")] = True,
2932
+ validate_only: Annotated[
2933
+ bool,
2934
+ typer.Option(
2935
+ "--validate-only",
2936
+ help="Parse and validate only (pipelines, connections, dbt parse, semantic and import validation), no dbt "
2937
+ "build; never the green check deploy needs",
2938
+ ),
2939
+ ] = False,
2908
2940
  ):
2909
2941
  """Check the draft: schema validation here, then a check run of the
2910
2942
  tenant's job (compile, dbt build into <org>_dev, tests, dry-run bytes).
2911
2943
 
2944
+ --validate-only is the edit loop's cheap answer: the same file checks,
2945
+ pipelines compiled, connections checked, `dbt parse`, the semantic
2946
+ validation per metric and the manifest import's dry run, and NO dbt
2947
+ build, so it starts beside a running check or load and answers in well
2948
+ under a minute. It never counts as the green check `datacore deploy`
2949
+ (or auto_deploy) needs: run a full check before deploying.
2950
+
2912
2951
  The check runs as the org's check identity, which can write <org>_dev and
2913
2952
  nothing else, so it never touches production; a dbt project that would
2914
2953
  build a model elsewhere is refused, naming the models. A workspace with
@@ -2923,24 +2962,27 @@ def datacore_check_cmd(
2923
2962
 
2924
2963
  Example:
2925
2964
  dataspring datacore check
2965
+ dataspring datacore check --validate-only
2926
2966
  """
2927
2967
  require_auth()
2928
2968
  token = auth.get_access_token()
2969
+ what = "validate-only check" if validate_only else "check"
2929
2970
  try:
2930
- result = call_tool("datacore_run", "check", {}, token=token)
2971
+ result = call_tool("datacore_run", "check", {"build": False} if validate_only else {}, token=token)
2931
2972
  except Exception as e:
2932
2973
  fail(e, role_action="check the datacore")
2933
2974
  if not result.get("ok"):
2934
- print_error(f"check failed at {result.get('stage')} for snapshot {result.get('snapshot')}")
2975
+ print_error(f"{what} failed at {result.get('stage')} for snapshot {result.get('snapshot')}")
2935
2976
  for error in result.get("errors") or []:
2936
2977
  typer.echo(f" {error}")
2937
2978
  raise typer.Exit(1)
2938
2979
  run_id = result["run_id"]
2939
- print_info(f"check run {run_id} {result.get('status')} for snapshot {result.get('snapshot')}")
2980
+ print_info(f"{what} run {run_id} {result.get('status')} for snapshot {result.get('snapshot')}")
2940
2981
  if not wait:
2941
2982
  return
2942
2983
  try:
2943
- run = _poll_run(run_id, token)
2984
+ # A validate-only run is over in seconds; a build takes minutes.
2985
+ run = _poll_run(run_id, token, interval=3.0 if validate_only else 10.0)
2944
2986
  except typer.Exit:
2945
2987
  raise
2946
2988
  except Exception as e:
@@ -3069,7 +3111,7 @@ def datacore_run_cmd(
3069
3111
  pipeline: Annotated[str, typer.Argument(help="Pipeline name")],
3070
3112
  start: Annotated[Optional[str], typer.Option("--start", help="Window start, YYYY-MM-DD")] = None,
3071
3113
  end: Annotated[Optional[str], typer.Option("--end", help="Window end, YYYY-MM-DD")] = None,
3072
- chunk: Annotated[Optional[str], typer.Option("--chunk", help="Backfill chunk: 7d, 2w or 1M. Chunks before the last load only windowed resources and skip dbt; the last loads everything and builds the models")] = None,
3114
+ chunk: Annotated[Optional[str], typer.Option("--chunk", help="Backfill chunk: <n>d, <n>w or <n>M (7d, 2w, 1M the default, 3M a quarter, 12M a year). Chunks before the last load only windowed resources and skip dbt; the last loads everything and builds the models")] = None,
3073
3115
  cursor_from: Annotated[Optional[str], typer.Option("--from", help="Cursor value to re-pull from")] = None,
3074
3116
  wait: Annotated[bool, typer.Option("--wait", help="Poll the run to its end")] = False,
3075
3117
  ):
@@ -3077,7 +3119,10 @@ def datacore_run_cmd(
3077
3119
 
3078
3120
  --start and --end are both inclusive days, and so is every chunk. A
3079
3121
  resource whose API reads its end date as exclusive declares
3080
- `end: exclusive` in its `window` and is sent the day after.
3122
+ `end: exclusive` in its `window` and is sent the day after. A rest
3123
+ resource whose `window` has a `step` (7d, 2w, 1M) sends one request
3124
+ per step of the run's window, and of every chunk, rather than one for
3125
+ the whole window (for a source with a per-request row cap).
3081
3126
 
3082
3127
  A window wider than --chunk is a backfill: one child run per chunk, each
3083
3128
  started as the previous one ends, one backfill per pipeline at a time.
@@ -3302,6 +3347,125 @@ def datacore_sample_cmd(
3302
3347
  console.print(f"[dim]{statement} as {result.get('identity')}; job {result.get('job_id')}[/]")
3303
3348
 
3304
3349
 
3350
+ DATACORE_REFERENCE_PATH = "/api/datacore/reference"
3351
+ ReferencePart = Literal["pipeline", "connection", "connector", "catalog"]
3352
+
3353
+
3354
+ def _print_reference_index(payload: dict) -> None:
3355
+ for part, info in (payload.get("schemas") or {}).items():
3356
+ typer.echo(f"{part:<11} {info.get('file')}")
3357
+ typer.echo("")
3358
+ rows = [
3359
+ {"name": c["name"], "kind": "connector", "version": c.get("version"),
3360
+ "secrets": ", ".join(c.get("secrets") or []), "tables": ", ".join(c.get("tables") or []),
3361
+ "description": c.get("description", "")}
3362
+ for c in payload.get("connectors") or []
3363
+ ] + [
3364
+ {"name": p["name"], "kind": "plugin", "version": p.get("version"),
3365
+ "secrets": ", ".join(p.get("secret_fields") or []), "tables": ", ".join(p.get("tables") or []),
3366
+ "description": ""}
3367
+ for p in payload.get("plugins") or []
3368
+ ]
3369
+ format_output(rows, format="table", columns=["name", "kind", "version", "secrets", "tables", "description"],
3370
+ title="the connector catalog")
3371
+ typer.echo("")
3372
+ typer.echo(payload.get("secret_names", ""))
3373
+ typer.echo("Whole parts: dataspring datacore reference pipeline|connection|connector|catalog [<name>]")
3374
+
3375
+
3376
+ def _print_reference_entry(payload: dict) -> None:
3377
+ import yaml
3378
+
3379
+ typer.echo(f"{payload['name']} ({payload['kind']} version {payload.get('version')}): {payload.get('description', '')}".rstrip(": "))
3380
+ props = (payload.get("config_schema") or {}).get("properties") or {}
3381
+ required = set((payload.get("config_schema") or {}).get("required") or [])
3382
+ if props:
3383
+ format_output(
3384
+ [{"config": key, "required": "yes" if key in required else "no",
3385
+ "default": json.dumps(spec["default"]) if "default" in spec else "",
3386
+ "description": spec.get("description", "")} for key, spec in props.items()],
3387
+ format="table", columns=["config", "required", "default", "description"], title="config")
3388
+ secrets = payload.get("secrets") or []
3389
+ if secrets and isinstance(secrets[0], dict):
3390
+ format_output(
3391
+ [{"secret": s["field"], "required": "no" if s.get("optional") else "yes", "description": s.get("description", "")}
3392
+ for s in secrets],
3393
+ format="table", columns=["secret", "required", "description"], title="secrets")
3394
+ resources = payload.get("resources") or {}
3395
+ format_output(
3396
+ [{"resource": name, "table": res.get("table", name), "when": res.get("when", "")} for name, res in resources.items()],
3397
+ format="table", columns=["resource", "table", "when"], title="resources")
3398
+ if payload.get("example"):
3399
+ typer.echo("\npipelines/<name>.yaml:\n")
3400
+ typer.echo(yaml.safe_dump(payload["example"], sort_keys=False).rstrip())
3401
+ elif payload.get("usage"):
3402
+ typer.echo(f"\n{payload['usage']}")
3403
+
3404
+
3405
+ @datacore_app.command("reference")
3406
+ def datacore_reference_cmd(
3407
+ part: Annotated[Optional[ReferencePart], typer.Argument(help="pipeline, connection or connector (that JSON schema), or catalog")] = None,
3408
+ name: Annotated[Optional[str], typer.Argument(help="With catalog: one connector or plugin")] = None,
3409
+ format: Annotated[OutputFormat, typer.Option("--format", "-f", help="Output format (a schema prints as JSON unless yaml is asked for)")] = "table",
3410
+ ):
3411
+ """What a pipeline or connection file may say: the schemas and the connector catalog.
3412
+
3413
+ The same files the runner validates against, served by the server you
3414
+ are signed in to, so they match what datacore_edit and datacore check
3415
+ accept. With no argument: the schemas and every catalog connector and
3416
+ plugin, with the secrets it needs and the tables it loads.
3417
+
3418
+ Examples:
3419
+ dataspring datacore reference # the index and the catalog
3420
+ dataspring datacore reference pipeline > pipeline.schema.json
3421
+ dataspring datacore reference connection -f yaml
3422
+ dataspring datacore reference catalog # every connector, whole
3423
+ dataspring datacore reference catalog kanpla # one, with a pipeline file that uses it
3424
+ """
3425
+ from urllib.parse import quote
3426
+
3427
+ if name and part != "catalog":
3428
+ print_error("a name goes with catalog: dataspring datacore reference catalog <name>")
3429
+ raise typer.Exit(2)
3430
+ path = DATACORE_REFERENCE_PATH
3431
+ if part:
3432
+ path += f"/{part}" + (f"/{quote(name, safe='')}" if name else "")
3433
+ require_auth()
3434
+ token = auth.get_access_token()
3435
+ try:
3436
+ payload = api_get(path, token)
3437
+ except Exception as e:
3438
+ fail(e, role_action="read the datacore reference")
3439
+ if part in ("pipeline", "connection", "connector"):
3440
+ if format == "yaml":
3441
+ format_output(payload["schema"], format="yaml")
3442
+ else:
3443
+ typer.echo(json.dumps(payload["schema"], indent=2, ensure_ascii=False))
3444
+ return
3445
+ if format != "table":
3446
+ format_output(payload, format=format)
3447
+ return
3448
+ if name:
3449
+ _print_reference_entry(payload)
3450
+ elif part == "catalog":
3451
+ _print_reference_index({
3452
+ "connectors": [
3453
+ {"name": n, "version": c.get("version"), "description": c.get("description", ""),
3454
+ "secrets": [f + ("" if not s.get("optional") else " (optional)") for f, s in (c.get("secrets") or {}).items()],
3455
+ "tables": [r.get("table", rn) for rn, r in (c.get("resources") or {}).items()]}
3456
+ for n, c in (payload.get("connectors") or {}).items()
3457
+ ],
3458
+ "plugins": [
3459
+ {"name": n, "version": p.get("version"), "secret_fields": p.get("secrets") or [],
3460
+ "tables": [r.get("table", rn) for rn, r in (p.get("resources") or {}).items()]}
3461
+ for n, p in (payload.get("plugins") or {}).items()
3462
+ ],
3463
+ "secret_names": payload.get("secret_names", ""),
3464
+ })
3465
+ else:
3466
+ _print_reference_index(payload)
3467
+
3468
+
3305
3469
  @datacore_app.command("status")
3306
3470
  def datacore_status(
3307
3471
  format: Annotated[OutputFormat, typer.Option("--format", "-f", help="Output format")] = "table",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dataspring-cli"
3
- version = "0.4.3"
3
+ version = "0.4.5"
4
4
  description = "DataSpring CLI - Query metrics and manage dashboards from the terminal"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -21,8 +21,10 @@ at once (run it in the background and hand both to the person), then
21
21
  echo '{"org": "acme"}' > .forspring.json && dataspring whoami --json
22
22
  ```
23
23
 
24
- MCP: one connection per org, `https://dataspring.app/api/mcp/?org=<org>`, or
25
- `Authorization: Bearer ds_...` for a headless agent (the key's own org).
24
+ MCP: `claude mcp add --transport http --scope user dataspring
25
+ https://dataspring.app/api/mcp/` (or the same address in any client); the
26
+ person signs in and the consent page names the org, bound to that connection.
27
+ With no person present: `Authorization: Bearer ds_...` (the key's own org).
26
28
 
27
29
  A refusal leads with a reason and ends with its fix; match the reason. The
28
30
  ones you meet while building: `role_too_low` (connections, pipelines,
@@ -237,13 +239,14 @@ production.
237
239
  ```bash
238
240
  dataspring datacore pull ./ws # deployed files, each exactly as pushed
239
241
  dataspring datacore push ./ws # changed files only, into a new draft
242
+ dataspring datacore check --validate-only # seconds: parse + semantic validation, no build
240
243
  dataspring datacore check # waits; exits 1 unless it passes
241
244
  dataspring datacore deploy --then-build # admin; rebuilds changed models too
242
245
  ```
243
246
 
244
247
  - **A new org** pulls its starting workspace (`datacore.yaml`, empty
245
- `pipelines/`, `connections/`, `models/`); `datacore
246
- status` says `deployed none yet` until the first deploy.
248
+ `pipelines/` and `connections/`, and in `models/` a starting dbt project,
249
+ see 8.7).
247
250
  - **A check** compiles every pipeline, builds the changed models and their
248
251
  tests into `<org>_dev`, and dry-runs their SQL. It runs as an identity
249
252
  that can write `<org>_dev` only: a project whose `generate_schema_name`
@@ -262,6 +265,12 @@ dataspring datacore deploy --then-build # admin; rebuilds changed models too
262
265
  type_params: {numerator: gross_margin, denominator: revenue} # two metrics
263
266
  ```
264
267
 
268
+ - **Validate-only while you edit** (`datacore_run` `check` with `build:
269
+ false`; CLI `datacore check --validate-only`): pipelines compiled,
270
+ connections checked, `dbt parse`, the per-metric semantic validation and
271
+ the import's dry run, and no `dbt build`, so a broken metric or model is
272
+ reported in well under a minute and it never waits behind a check or a
273
+ load. It is never a green check: run a full `check` before `deploy`.
265
274
  - **Deploy** needs a green check of that exact draft (any edit makes a new
266
275
  draft) and starts a run of each windowed pipeline it changed.
267
276
 
@@ -328,10 +337,41 @@ whose bytes equal the stored one's.
328
337
 
329
338
  Test an API's end bound with a one-day window before a backfill: a wrong
330
339
  guess silently drops the last day of every chunk.
340
+ - **A window is one request** unless it has a `step`. For a source that caps
341
+ rows per request and does not page (e-regnskab: 20,000 item lines), a long
342
+ re-pull is cut into steps, one request each (`7d`, `2w`, `1M`; calendar
343
+ months, as backfill chunks are cut). `end: exclusive` holds per step, and
344
+ the scheduled window, a manual run and every backfill chunk all step:
345
+
346
+ ```yaml
347
+ window: { lookback: 365d, step: 1M, param_start: createdFrom, param_end: createdTo, end: exclusive }
348
+ ```
349
+
350
+ A rest resource only, and not a `resolve` child: step its parent.
351
+ - **`resolve` fans out over the parent's own window** (its lookback, or the
352
+ run's window), not the child's. When the child's rows lack the parent's
353
+ key, carry it: `include_from_parent: [hnInvoiceSalesID]` adds the column
354
+ `_<parent resource>_<field>` (`_invoices_hnInvoiceSalesID`) to every child
355
+ row; use it in the child's `primary_key` or rename it in `shape`.
356
+ `param` fills a `{param}` placeholder in the endpoint, or else is sent as
357
+ a query parameter.
358
+ - **An endpoint that answers a list of scalars** (`["budget", "forecast"]`)
359
+ loads one row per element in a column `value`; fan out over it with
360
+ `resolve: { field: value, ... }`.
331
361
  - **A backfill** is `dataspring datacore run <pipeline> --start ... --end ...
362
+ --chunk 1M`: one child run per chunk, each started as the previous one
363
+ ends. The chunk is `<n>d`, `<n>w` or `<n>M` (`7d`, `2w`, `1M`, `3M` a
364
+ quarter, `12M` a year; no `Y`). A chunk before the last loads only the
365
+ windowed resources (and those they read rows from) and skips dbt; the last
366
+ chunk loads everything and builds the models once. A failed child fails
367
+ the backfill. One backfill per pipeline at a time.
368
+ - **One request per window.** A rest resource without a `paginator` sends
369
+ one request per window and nothing splits it, so a source that caps an
370
+ answer at N rows needs a `lookback`, and a `--chunk`, under N rows.
332
371
  --chunk 1M` (or `3M`, `7d`, `2w`): one child run per chunk, each started as
333
372
  the previous one ends. A chunk before the last loads only the windowed
334
- resources (and those they read rows from) and skips dbt; the last chunk
373
+ resources, their `resolve` children (fetched for that chunk's parent
374
+ rows) and those they read rows from, and skips dbt; the last chunk
335
375
  loads everything and builds the models once. A failed child fails the
336
376
  backfill. One backfill per pipeline at a time.
337
377
  - **Stop** a queued or running run, or a whole backfill, with `dataspring
@@ -339,6 +379,63 @@ whose bytes equal the stored one's.
339
379
  - **`reset`** drops a pipeline's raw tables and load state. It is
340
380
  irreversible: ask first.
341
381
 
382
+ ## 8.6 Writing a pipeline file
383
+
384
+ Do not read DataSpring's source for the grammar: it is served, cut from the
385
+ files the runner validates against.
386
+
387
+ ```bash
388
+ dataspring datacore reference # the schemas, every connector: secrets, tables
389
+ dataspring datacore reference catalog kanpla # one connector, whole, with a file that uses it
390
+ dataspring datacore reference pipeline # the pipeline JSON schema (also: connection)
391
+ ```
392
+
393
+ MCP: `dataspring://datacore/reference[/<part>]` or `read(kind=
394
+ "datacore_reference", id=<part>)`; REST `GET /api/datacore/reference`.
395
+ Check the catalog first: a source with a connector is `connector: <name>`
396
+ plus `config` and `secrets`. Otherwise write an inline `source` (or
397
+ `sources`) and `resources`. The rules that bite:
398
+
399
+ - **Secrets are names**, `t-<org>-<connection>-<field>`, lowercase letters,
400
+ digits and hyphens only (no underscore). Set each with `dataspring secret
401
+ set <name> --stdin` before pushing the file that names it.
402
+ - **Windows** are whole inclusive days, `lookback` before today through
403
+ `lookahead` after, in the pipeline's timezone; declare `end: exclusive`
404
+ for an API whose end date is exclusive.
405
+ - **Loads**: `replace` is a full refresh; `merge` needs `primary_key` (or
406
+ `merge_key`); `window_scope: replace` deletes by the `partition` date, so
407
+ use it only when that is the date the API filters on.
408
+ - **Checks**: `min_rows` is refused on a `cursor` resource; use `freshness`.
409
+ - **A `resolve` child of a windowed parent** needs its own `window`, and the
410
+ parent its window in `params` (`{{ window.start }}`), or backfill chunks
411
+ skip the child.
412
+
413
+ The full guide with complete examples is "Writing a pipeline" in
414
+ `dataspring docs --full`.
415
+
416
+ ## 8.7 The managed dbt project
417
+
418
+ The workspace's `models/` is a dbt project (dbt-core 1.12, BigQuery). A new
419
+ org starts with one that already keeps the contract; `dataspring datacore
420
+ pull` shows it. The contract:
421
+
422
+ - `profile: 'dataspring'` (the runner writes the profile; dataset
423
+ `<org>_marts`).
424
+ - Sources at `schema: "{{ env_var('DBT_RAW_SCHEMA') }}"` and `database:
425
+ "{{ env_var('DBT_GCP_PROJECT') }}"`, never a dataset name.
426
+ - `generate_schema_name` returns `DBT_SCHEMA_OVERRIDE` when set (a check:
427
+ `<org>_dev`), else `DBT_SCHEMA_PREFIX` + the layer (`+schema: staging` is
428
+ `<org>_staging`).
429
+ - A `metricflow_time_spine` model with `time_spine:` configured; include it
430
+ in `then.dbt_select`.
431
+ - Semantic models and metrics in YAML anywhere under `models/models/`;
432
+ `metric_edit` creates them in `models/models/semantic/metrics/`.
433
+ - `packages.yml` is fetched by `dbt deps` on every run (`dbt_utils`).
434
+
435
+ A deploy builds nothing: `deploy --then-build`, `datacore build`, or a
436
+ pipeline's `then.dbt_select` does. The full contract is "The managed dbt
437
+ project" in `dataspring docs --full`.
438
+
342
439
  ## 9. Parameter reference
343
440
 
344
441
  <!-- dispatch-params: semantic_model_edit metric_edit board_edit briefing_edit import_manifest business_context_edit warehouse_edit datacore_edit datacore_run -->
@@ -587,6 +684,7 @@ MCP tool `datacore_run`; `POST /api/dispatch/datacore_run`. The datacore's check
587
684
  | Parameter | Type | Required | Description |
588
685
  |---|---|---|---|
589
686
  | `note` | string | no | Optional note, recorded on the check run |
687
+ | `build` | boolean | no, default `True` | false = validate only: compile the pipelines, check the connections, dbt parse, the semantic validation per metric and the manifest import's dry run, with no dbt build (an answer in well under a minute, beside a running check or load). It is NEVER the green check deploy or auto_deploy needs |
590
688
 
591
689
  **`deploy`** — Promote the checked draft to deployed (admin), or roll back to an earlier deployed snapshot; then_build also rebuilds changed models.
592
690
 
@@ -602,9 +700,9 @@ MCP tool `datacore_run`; `POST /api/dispatch/datacore_run`. The datacore's check
602
700
  | Parameter | Type | Required | Description |
603
701
  |---|---|---|---|
604
702
  | `pipeline` | string | yes | Pipeline name (a deployed pipelines/<name>.yaml) |
605
- | `window` | RunWindowParams | no | A date window to load instead of the pipeline's own; wider than backfill_chunk becomes a chunked backfill. Both ends are inclusive days, and so is every chunk; a resource whose API reads its end bound as exclusive declares `window.end: exclusive` in the pipeline and is sent the day after each chunk's last day |
703
+ | `window` | RunWindowParams | no | A date window to load instead of the pipeline's own; wider than backfill_chunk becomes a chunked backfill. Both ends are inclusive days, and so is every chunk; a resource whose API reads its end bound as exclusive declares `window.end: exclusive` in the pipeline and is sent the day after each chunk's last day. A rest resource whose window has a `step` sends one request per step of the window or chunk, not one for the whole window |
606
704
  | `cursor` | RunCursorParams | no | {from: <value>}: a cursor to re-pull from |
607
- | `backfill_chunk` | string | no | Chunk size for a windowed run: 7d, 2w or 1M (calendar months, the default). Every chunk before the last loads only the windowed resources (and the ones they read rows from) and skips its dbt step, each skip recorded with its reason on the child run; the last chunk loads every resource and builds the models once |
705
+ | `backfill_chunk` | string | no | Chunk size for a windowed run: <n>d, <n>w or <n>M (days, weeks or calendar months; 1M is the default, 3M a quarter, 12M a year; there is no Y). Every chunk before the last loads only the windowed resources (with their resolve children and the ones they read rows from) and skips its dbt step, each skip recorded with its reason on the child run; the last chunk loads every resource and builds the models once |
608
706
 
609
707
  **`build`** — Rebuild the deployed dbt models on the raw tables already loaded: no resource is loaded.
610
708
 
@@ -8,8 +8,8 @@ description: How to query DataSpring correctly to answer analytical questions. U
8
8
  You are querying DataSpring. Three surfaces are available — pick the
9
9
  one the caller is using:
10
10
 
11
- - **MCP** — agent connected to a DataSpring MCP server (Claude
12
- Desktop, Claude Code via stdio, etc.). Tool calls.
11
+ - **MCP** — agent connected to DataSpring's MCP server (Claude.ai,
12
+ Claude Code, Cursor, etc.). Tool calls.
13
13
  - **CLI** — `dataspring` from the terminal. Shell commands.
14
14
  - **REST** — direct HTTP. Scripts, integrations.
15
15
 
@@ -80,11 +80,18 @@ which role, is the account's answer on every request. DataSpring has no
80
80
  parent. `DATASPRING_API_KEY=ds_...` (CI) acts in the key's own org.
81
81
  `dataspring whoami --json` says which org a command here acts in and why
82
82
  (`org_from`). There is no command that switches org for later commands.
83
- - **MCP.** One connection per org: `https://dataspring.app/api/mcp/?org=<org>`
84
- (the slash before `?` is required), signed in with the Forspring account.
85
- To work in two orgs, add two connections; no tool moves a connection. A
86
- headless agent connects to the same URL with `Authorization: Bearer ds_...`;
87
- the key acts in its own org, and `?org=` may only repeat it.
83
+ - **MCP.** The address is `https://dataspring.app/api/mcp/`, with no org
84
+ in it. Claude Code: `claude mcp add --transport http --scope user
85
+ dataspring https://dataspring.app/api/mcp/`, then `/mcp` → *Authenticate*
86
+ (`claude mcp login dataspring` with no browser); Claude.ai: *Customize →
87
+ Connectors* → *Add custom connector* with that URL. The person signs in
88
+ with their Forspring account and DataSpring's consent page names the org
89
+ the connection works in (their only one, or the one they choose); the
90
+ choice is bound to the connection. To work in two orgs, add two
91
+ connections; no tool moves a connection. With no person present (CI), the
92
+ same address takes `Authorization: Bearer ds_...` (a key made in the
93
+ console's connect page, under "Automation and CI"): the key acts in its
94
+ own org, and `?org=` may only repeat it.
88
95
  - **REST.** `Authorization: Bearer ds_...`; the key is the org.
89
96
 
90
97
  Never pick an org for the person. When the org is ambiguous you are refused
@@ -595,12 +602,12 @@ the reason, never on the prose, and pass the fix on verbatim.
595
602
  | Reason | CLI exit | What you do |
596
603
  |---|---|---|
597
604
  | `not_signed_in` | 4 | CLI: run `dataspring auth login` and hand the person its URL and code. MCP: reconnect and sign in. Never ask for a password or a key in chat. |
598
- | `org_required` | 2 | Several orgs, none named. Ask the person which one (the refusal lists them); then `--org`, `DATASPRING_ORG` or `.forspring.json`. Never guess. |
605
+ | `org_required` | 2 | Several orgs, none named. Ask the person which one (the refusal lists them); then `--org`, `DATASPRING_ORG` or `.forspring.json`. MCP: they reconnect and choose it on the consent page. Never guess. |
599
606
  | `not_a_member` | 3 | Check the org id's spelling against the list. Otherwise only an owner or admin of that org can add the person, at the Forspring account; say so and stop. |
600
607
  | `product_off` | 4 | DataSpring is not switched on for this person in that org. Tell them who can switch it on (`contacts`, `fix_url`); do not retry. |
601
608
  | `org_suspended` | 4 | Billing stopped DataSpring for the whole org. Tell them an owner can restart it on the billing page; do not retry. |
602
609
  | `role_too_low` | 4 | Their role cannot do this. Offer what it can do (a personal preference or quick metric instead of an org-wide change), or name the role needed. |
603
- | `org_mismatch` | 4 | The key or connection is for another org than the one named. Use that org's key or URL, or drop the conflicting `--org` / `?org=`. |
610
+ | `org_mismatch` | 4 | The key or connection (approved for one org) is for another org than the one named. Use that org's key or connection, or drop the conflicting `--org` / `?org=`. |
604
611
  | `account_unavailable` | 1 | Forspring's account did not answer, so access could not be checked. Retry once after a few seconds; nothing changed. |
605
612
 
606
613
  Other CLI exits: 0 success, 1 any other failure, 2 a usage error, 64 a
File without changes