dataspring-cli 0.4.3__tar.gz → 0.4.4__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.4}/PKG-INFO +1 -1
  2. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/cli/bundled_manifest.py +1 -1
  3. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/cli/contract.py +15 -4
  4. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/cli/generated.py +4 -3
  5. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/cli/main.py +162 -8
  6. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/pyproject.toml +1 -1
  7. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/skills/dataspring-author/SKILL.md +101 -5
  8. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/.gitignore +0 -0
  9. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/README.md +0 -0
  10. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/cli/__init__.py +0 -0
  11. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/cli/auth.py +0 -0
  12. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/cli/output.py +0 -0
  13. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/cli/refusals.py +0 -0
  14. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/cli/runtime.py +0 -0
  15. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/cli/skills_commands.py +0 -0
  16. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/cli/skilltree.py +0 -0
  17. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/cli/upgrade.py +0 -0
  18. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/cli/version.py +0 -0
  19. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/hatch_build.py +0 -0
  20. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/settings.py +0 -0
  21. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/skills/dataspring-consume/SKILL.md +0 -0
  22. {dataspring_cli-0.4.3 → dataspring_cli-0.4.4}/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.4
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:
@@ -136,7 +136,7 @@ learned_app = typer.Typer(help="The learned trail: what DataSpring learned for t
136
136
  app.add_typer(learned_app, name="learned")
137
137
  warehouses_app = typer.Typer(help="The org's warehouses: external and managed, and which is active")
138
138
  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")
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; the reference (pipeline and connection schemas, the connector catalog)")
140
140
  app.add_typer(datacore_app, name="datacore")
141
141
  from cli.skills_commands import skills_app # noqa: E402
142
142
  app.add_typer(skills_app, name="skills")
@@ -2844,7 +2844,8 @@ def _check_finding(entry: dict) -> str:
2844
2844
  def _print_check_report(run: dict) -> None:
2845
2845
  report = run.get("check") or {}
2846
2846
  status = run.get("status")
2847
- line = f"check {run.get('id')}: {status}"
2847
+ validate_only = run.get("build") is False or report.get("build") is False
2848
+ line = f"{'validate-only check' if validate_only else 'check'} {run.get('id')}: {status}"
2848
2849
  if report.get("snapshot"):
2849
2850
  line += f" (snapshot {report['snapshot']})"
2850
2851
  (print_success if status == "succeeded" else print_error)(line)
@@ -2857,6 +2858,17 @@ def _print_check_report(run: dict) -> None:
2857
2858
  dbt = report.get("dbt") or {}
2858
2859
  if dbt.get("status") == "skipped" and dbt.get("reason"):
2859
2860
  typer.echo(f" dbt skipped: {dbt['reason']}")
2861
+ elif dbt and validate_only:
2862
+ # Tick ip2: parsed and validated, nothing built; the counts are what the parse found.
2863
+ parsed = dbt.get("parsed") or {}
2864
+ counts = ", ".join(f"{parsed[k]} {k.replace('_', ' ')}" for k in ("models", "semantic_models", "metrics") if k in parsed)
2865
+ typer.echo(f" dbt {dbt.get('status')}: parsed and validated, nothing built" + (f" ({counts})" if counts else ""))
2866
+ if dbt.get("error"):
2867
+ typer.echo(f" {dbt['error']}")
2868
+ for failure in dbt.get("failures") or []:
2869
+ typer.echo(f" FAIL {_check_finding(failure)}")
2870
+ for warning in dbt.get("warnings") or []:
2871
+ typer.echo(f" warn {_check_finding(warning)}")
2860
2872
  elif dbt:
2861
2873
  typer.echo(
2862
2874
  f" dbt {dbt.get('status')}: {dbt.get('models', 0)} models, {dbt.get('tests', 0)} tests, "
@@ -2882,6 +2894,8 @@ def _print_check_report(run: dict) -> None:
2882
2894
  if target and dbt.get("models"):
2883
2895
  typer.echo(f" read it dataspring sql \"SELECT * FROM {target}.<model> LIMIT 20\" "
2884
2896
  f"(the models this check built; {target} expires tables after 7 days)")
2897
+ if validate_only:
2898
+ typer.echo(" note validate-only: never the green check a deploy needs; `dataspring datacore check` builds and tests")
2885
2899
 
2886
2900
 
2887
2901
  def _poll_run(run_id: str, token: str, *, interval: float = 10.0, timeout: float = 1800.0) -> dict:
@@ -2905,10 +2919,25 @@ def _poll_run(run_id: str, token: str, *, interval: float = 10.0, timeout: float
2905
2919
  @datacore_app.command("check")
2906
2920
  def datacore_check_cmd(
2907
2921
  wait: Annotated[bool, typer.Option("--wait/--no-wait", help="Poll the check run to its end")] = True,
2922
+ validate_only: Annotated[
2923
+ bool,
2924
+ typer.Option(
2925
+ "--validate-only",
2926
+ help="Parse and validate only (pipelines, connections, dbt parse, semantic and import validation), no dbt "
2927
+ "build; never the green check deploy needs",
2928
+ ),
2929
+ ] = False,
2908
2930
  ):
2909
2931
  """Check the draft: schema validation here, then a check run of the
2910
2932
  tenant's job (compile, dbt build into <org>_dev, tests, dry-run bytes).
2911
2933
 
2934
+ --validate-only is the edit loop's cheap answer: the same file checks,
2935
+ pipelines compiled, connections checked, `dbt parse`, the semantic
2936
+ validation per metric and the manifest import's dry run, and NO dbt
2937
+ build, so it starts beside a running check or load and answers in well
2938
+ under a minute. It never counts as the green check `datacore deploy`
2939
+ (or auto_deploy) needs: run a full check before deploying.
2940
+
2912
2941
  The check runs as the org's check identity, which can write <org>_dev and
2913
2942
  nothing else, so it never touches production; a dbt project that would
2914
2943
  build a model elsewhere is refused, naming the models. A workspace with
@@ -2923,24 +2952,27 @@ def datacore_check_cmd(
2923
2952
 
2924
2953
  Example:
2925
2954
  dataspring datacore check
2955
+ dataspring datacore check --validate-only
2926
2956
  """
2927
2957
  require_auth()
2928
2958
  token = auth.get_access_token()
2959
+ what = "validate-only check" if validate_only else "check"
2929
2960
  try:
2930
- result = call_tool("datacore_run", "check", {}, token=token)
2961
+ result = call_tool("datacore_run", "check", {"build": False} if validate_only else {}, token=token)
2931
2962
  except Exception as e:
2932
2963
  fail(e, role_action="check the datacore")
2933
2964
  if not result.get("ok"):
2934
- print_error(f"check failed at {result.get('stage')} for snapshot {result.get('snapshot')}")
2965
+ print_error(f"{what} failed at {result.get('stage')} for snapshot {result.get('snapshot')}")
2935
2966
  for error in result.get("errors") or []:
2936
2967
  typer.echo(f" {error}")
2937
2968
  raise typer.Exit(1)
2938
2969
  run_id = result["run_id"]
2939
- print_info(f"check run {run_id} {result.get('status')} for snapshot {result.get('snapshot')}")
2970
+ print_info(f"{what} run {run_id} {result.get('status')} for snapshot {result.get('snapshot')}")
2940
2971
  if not wait:
2941
2972
  return
2942
2973
  try:
2943
- run = _poll_run(run_id, token)
2974
+ # A validate-only run is over in seconds; a build takes minutes.
2975
+ run = _poll_run(run_id, token, interval=3.0 if validate_only else 10.0)
2944
2976
  except typer.Exit:
2945
2977
  raise
2946
2978
  except Exception as e:
@@ -3069,7 +3101,7 @@ def datacore_run_cmd(
3069
3101
  pipeline: Annotated[str, typer.Argument(help="Pipeline name")],
3070
3102
  start: Annotated[Optional[str], typer.Option("--start", help="Window start, YYYY-MM-DD")] = None,
3071
3103
  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,
3104
+ 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
3105
  cursor_from: Annotated[Optional[str], typer.Option("--from", help="Cursor value to re-pull from")] = None,
3074
3106
  wait: Annotated[bool, typer.Option("--wait", help="Poll the run to its end")] = False,
3075
3107
  ):
@@ -3077,7 +3109,10 @@ def datacore_run_cmd(
3077
3109
 
3078
3110
  --start and --end are both inclusive days, and so is every chunk. A
3079
3111
  resource whose API reads its end date as exclusive declares
3080
- `end: exclusive` in its `window` and is sent the day after.
3112
+ `end: exclusive` in its `window` and is sent the day after. A rest
3113
+ resource whose `window` has a `step` (7d, 2w, 1M) sends one request
3114
+ per step of the run's window, and of every chunk, rather than one for
3115
+ the whole window (for a source with a per-request row cap).
3081
3116
 
3082
3117
  A window wider than --chunk is a backfill: one child run per chunk, each
3083
3118
  started as the previous one ends, one backfill per pipeline at a time.
@@ -3302,6 +3337,125 @@ def datacore_sample_cmd(
3302
3337
  console.print(f"[dim]{statement} as {result.get('identity')}; job {result.get('job_id')}[/]")
3303
3338
 
3304
3339
 
3340
+ DATACORE_REFERENCE_PATH = "/api/datacore/reference"
3341
+ ReferencePart = Literal["pipeline", "connection", "connector", "catalog"]
3342
+
3343
+
3344
+ def _print_reference_index(payload: dict) -> None:
3345
+ for part, info in (payload.get("schemas") or {}).items():
3346
+ typer.echo(f"{part:<11} {info.get('file')}")
3347
+ typer.echo("")
3348
+ rows = [
3349
+ {"name": c["name"], "kind": "connector", "version": c.get("version"),
3350
+ "secrets": ", ".join(c.get("secrets") or []), "tables": ", ".join(c.get("tables") or []),
3351
+ "description": c.get("description", "")}
3352
+ for c in payload.get("connectors") or []
3353
+ ] + [
3354
+ {"name": p["name"], "kind": "plugin", "version": p.get("version"),
3355
+ "secrets": ", ".join(p.get("secret_fields") or []), "tables": ", ".join(p.get("tables") or []),
3356
+ "description": ""}
3357
+ for p in payload.get("plugins") or []
3358
+ ]
3359
+ format_output(rows, format="table", columns=["name", "kind", "version", "secrets", "tables", "description"],
3360
+ title="the connector catalog")
3361
+ typer.echo("")
3362
+ typer.echo(payload.get("secret_names", ""))
3363
+ typer.echo("Whole parts: dataspring datacore reference pipeline|connection|connector|catalog [<name>]")
3364
+
3365
+
3366
+ def _print_reference_entry(payload: dict) -> None:
3367
+ import yaml
3368
+
3369
+ typer.echo(f"{payload['name']} ({payload['kind']} version {payload.get('version')}): {payload.get('description', '')}".rstrip(": "))
3370
+ props = (payload.get("config_schema") or {}).get("properties") or {}
3371
+ required = set((payload.get("config_schema") or {}).get("required") or [])
3372
+ if props:
3373
+ format_output(
3374
+ [{"config": key, "required": "yes" if key in required else "no",
3375
+ "default": json.dumps(spec["default"]) if "default" in spec else "",
3376
+ "description": spec.get("description", "")} for key, spec in props.items()],
3377
+ format="table", columns=["config", "required", "default", "description"], title="config")
3378
+ secrets = payload.get("secrets") or []
3379
+ if secrets and isinstance(secrets[0], dict):
3380
+ format_output(
3381
+ [{"secret": s["field"], "required": "no" if s.get("optional") else "yes", "description": s.get("description", "")}
3382
+ for s in secrets],
3383
+ format="table", columns=["secret", "required", "description"], title="secrets")
3384
+ resources = payload.get("resources") or {}
3385
+ format_output(
3386
+ [{"resource": name, "table": res.get("table", name), "when": res.get("when", "")} for name, res in resources.items()],
3387
+ format="table", columns=["resource", "table", "when"], title="resources")
3388
+ if payload.get("example"):
3389
+ typer.echo("\npipelines/<name>.yaml:\n")
3390
+ typer.echo(yaml.safe_dump(payload["example"], sort_keys=False).rstrip())
3391
+ elif payload.get("usage"):
3392
+ typer.echo(f"\n{payload['usage']}")
3393
+
3394
+
3395
+ @datacore_app.command("reference")
3396
+ def datacore_reference_cmd(
3397
+ part: Annotated[Optional[ReferencePart], typer.Argument(help="pipeline, connection or connector (that JSON schema), or catalog")] = None,
3398
+ name: Annotated[Optional[str], typer.Argument(help="With catalog: one connector or plugin")] = None,
3399
+ format: Annotated[OutputFormat, typer.Option("--format", "-f", help="Output format (a schema prints as JSON unless yaml is asked for)")] = "table",
3400
+ ):
3401
+ """What a pipeline or connection file may say: the schemas and the connector catalog.
3402
+
3403
+ The same files the runner validates against, served by the server you
3404
+ are signed in to, so they match what datacore_edit and datacore check
3405
+ accept. With no argument: the schemas and every catalog connector and
3406
+ plugin, with the secrets it needs and the tables it loads.
3407
+
3408
+ Examples:
3409
+ dataspring datacore reference # the index and the catalog
3410
+ dataspring datacore reference pipeline > pipeline.schema.json
3411
+ dataspring datacore reference connection -f yaml
3412
+ dataspring datacore reference catalog # every connector, whole
3413
+ dataspring datacore reference catalog kanpla # one, with a pipeline file that uses it
3414
+ """
3415
+ from urllib.parse import quote
3416
+
3417
+ if name and part != "catalog":
3418
+ print_error("a name goes with catalog: dataspring datacore reference catalog <name>")
3419
+ raise typer.Exit(2)
3420
+ path = DATACORE_REFERENCE_PATH
3421
+ if part:
3422
+ path += f"/{part}" + (f"/{quote(name, safe='')}" if name else "")
3423
+ require_auth()
3424
+ token = auth.get_access_token()
3425
+ try:
3426
+ payload = api_get(path, token)
3427
+ except Exception as e:
3428
+ fail(e, role_action="read the datacore reference")
3429
+ if part in ("pipeline", "connection", "connector"):
3430
+ if format == "yaml":
3431
+ format_output(payload["schema"], format="yaml")
3432
+ else:
3433
+ typer.echo(json.dumps(payload["schema"], indent=2, ensure_ascii=False))
3434
+ return
3435
+ if format != "table":
3436
+ format_output(payload, format=format)
3437
+ return
3438
+ if name:
3439
+ _print_reference_entry(payload)
3440
+ elif part == "catalog":
3441
+ _print_reference_index({
3442
+ "connectors": [
3443
+ {"name": n, "version": c.get("version"), "description": c.get("description", ""),
3444
+ "secrets": [f + ("" if not s.get("optional") else " (optional)") for f, s in (c.get("secrets") or {}).items()],
3445
+ "tables": [r.get("table", rn) for rn, r in (c.get("resources") or {}).items()]}
3446
+ for n, c in (payload.get("connectors") or {}).items()
3447
+ ],
3448
+ "plugins": [
3449
+ {"name": n, "version": p.get("version"), "secret_fields": p.get("secrets") or [],
3450
+ "tables": [r.get("table", rn) for rn, r in (p.get("resources") or {}).items()]}
3451
+ for n, p in (payload.get("plugins") or {}).items()
3452
+ ],
3453
+ "secret_names": payload.get("secret_names", ""),
3454
+ })
3455
+ else:
3456
+ _print_reference_index(payload)
3457
+
3458
+
3305
3459
  @datacore_app.command("status")
3306
3460
  def datacore_status(
3307
3461
  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.4"
4
4
  description = "DataSpring CLI - Query metrics and manage dashboards from the terminal"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -237,13 +237,14 @@ production.
237
237
  ```bash
238
238
  dataspring datacore pull ./ws # deployed files, each exactly as pushed
239
239
  dataspring datacore push ./ws # changed files only, into a new draft
240
+ dataspring datacore check --validate-only # seconds: parse + semantic validation, no build
240
241
  dataspring datacore check # waits; exits 1 unless it passes
241
242
  dataspring datacore deploy --then-build # admin; rebuilds changed models too
242
243
  ```
243
244
 
244
245
  - **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.
246
+ `pipelines/` and `connections/`, and in `models/` a starting dbt project,
247
+ see 8.7).
247
248
  - **A check** compiles every pipeline, builds the changed models and their
248
249
  tests into `<org>_dev`, and dry-runs their SQL. It runs as an identity
249
250
  that can write `<org>_dev` only: a project whose `generate_schema_name`
@@ -262,6 +263,12 @@ dataspring datacore deploy --then-build # admin; rebuilds changed models too
262
263
  type_params: {numerator: gross_margin, denominator: revenue} # two metrics
263
264
  ```
264
265
 
266
+ - **Validate-only while you edit** (`datacore_run` `check` with `build:
267
+ false`; CLI `datacore check --validate-only`): pipelines compiled,
268
+ connections checked, `dbt parse`, the per-metric semantic validation and
269
+ the import's dry run, and no `dbt build`, so a broken metric or model is
270
+ reported in well under a minute and it never waits behind a check or a
271
+ load. It is never a green check: run a full `check` before `deploy`.
265
272
  - **Deploy** needs a green check of that exact draft (any edit makes a new
266
273
  draft) and starts a run of each windowed pipeline it changed.
267
274
 
@@ -328,10 +335,41 @@ whose bytes equal the stored one's.
328
335
 
329
336
  Test an API's end bound with a one-day window before a backfill: a wrong
330
337
  guess silently drops the last day of every chunk.
338
+ - **A window is one request** unless it has a `step`. For a source that caps
339
+ rows per request and does not page (e-regnskab: 20,000 item lines), a long
340
+ re-pull is cut into steps, one request each (`7d`, `2w`, `1M`; calendar
341
+ months, as backfill chunks are cut). `end: exclusive` holds per step, and
342
+ the scheduled window, a manual run and every backfill chunk all step:
343
+
344
+ ```yaml
345
+ window: { lookback: 365d, step: 1M, param_start: createdFrom, param_end: createdTo, end: exclusive }
346
+ ```
347
+
348
+ A rest resource only, and not a `resolve` child: step its parent.
349
+ - **`resolve` fans out over the parent's own window** (its lookback, or the
350
+ run's window), not the child's. When the child's rows lack the parent's
351
+ key, carry it: `include_from_parent: [hnInvoiceSalesID]` adds the column
352
+ `_<parent resource>_<field>` (`_invoices_hnInvoiceSalesID`) to every child
353
+ row; use it in the child's `primary_key` or rename it in `shape`.
354
+ `param` fills a `{param}` placeholder in the endpoint, or else is sent as
355
+ a query parameter.
356
+ - **An endpoint that answers a list of scalars** (`["budget", "forecast"]`)
357
+ loads one row per element in a column `value`; fan out over it with
358
+ `resolve: { field: value, ... }`.
331
359
  - **A backfill** is `dataspring datacore run <pipeline> --start ... --end ...
360
+ --chunk 1M`: one child run per chunk, each started as the previous one
361
+ ends. The chunk is `<n>d`, `<n>w` or `<n>M` (`7d`, `2w`, `1M`, `3M` a
362
+ quarter, `12M` a year; no `Y`). A chunk before the last loads only the
363
+ windowed resources (and those they read rows from) and skips dbt; the last
364
+ chunk loads everything and builds the models once. A failed child fails
365
+ the backfill. One backfill per pipeline at a time.
366
+ - **One request per window.** A rest resource without a `paginator` sends
367
+ one request per window and nothing splits it, so a source that caps an
368
+ answer at N rows needs a `lookback`, and a `--chunk`, under N rows.
332
369
  --chunk 1M` (or `3M`, `7d`, `2w`): one child run per chunk, each started as
333
370
  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
371
+ resources, their `resolve` children (fetched for that chunk's parent
372
+ rows) and those they read rows from, and skips dbt; the last chunk
335
373
  loads everything and builds the models once. A failed child fails the
336
374
  backfill. One backfill per pipeline at a time.
337
375
  - **Stop** a queued or running run, or a whole backfill, with `dataspring
@@ -339,6 +377,63 @@ whose bytes equal the stored one's.
339
377
  - **`reset`** drops a pipeline's raw tables and load state. It is
340
378
  irreversible: ask first.
341
379
 
380
+ ## 8.6 Writing a pipeline file
381
+
382
+ Do not read DataSpring's source for the grammar: it is served, cut from the
383
+ files the runner validates against.
384
+
385
+ ```bash
386
+ dataspring datacore reference # the schemas, every connector: secrets, tables
387
+ dataspring datacore reference catalog kanpla # one connector, whole, with a file that uses it
388
+ dataspring datacore reference pipeline # the pipeline JSON schema (also: connection)
389
+ ```
390
+
391
+ MCP: `dataspring://datacore/reference[/<part>]` or `read(kind=
392
+ "datacore_reference", id=<part>)`; REST `GET /api/datacore/reference`.
393
+ Check the catalog first: a source with a connector is `connector: <name>`
394
+ plus `config` and `secrets`. Otherwise write an inline `source` (or
395
+ `sources`) and `resources`. The rules that bite:
396
+
397
+ - **Secrets are names**, `t-<org>-<connection>-<field>`, lowercase letters,
398
+ digits and hyphens only (no underscore). Set each with `dataspring secret
399
+ set <name> --stdin` before pushing the file that names it.
400
+ - **Windows** are whole inclusive days, `lookback` before today through
401
+ `lookahead` after, in the pipeline's timezone; declare `end: exclusive`
402
+ for an API whose end date is exclusive.
403
+ - **Loads**: `replace` is a full refresh; `merge` needs `primary_key` (or
404
+ `merge_key`); `window_scope: replace` deletes by the `partition` date, so
405
+ use it only when that is the date the API filters on.
406
+ - **Checks**: `min_rows` is refused on a `cursor` resource; use `freshness`.
407
+ - **A `resolve` child of a windowed parent** needs its own `window`, and the
408
+ parent its window in `params` (`{{ window.start }}`), or backfill chunks
409
+ skip the child.
410
+
411
+ The full guide with complete examples is "Writing a pipeline" in
412
+ `dataspring docs --full`.
413
+
414
+ ## 8.7 The managed dbt project
415
+
416
+ The workspace's `models/` is a dbt project (dbt-core 1.12, BigQuery). A new
417
+ org starts with one that already keeps the contract; `dataspring datacore
418
+ pull` shows it. The contract:
419
+
420
+ - `profile: 'dataspring'` (the runner writes the profile; dataset
421
+ `<org>_marts`).
422
+ - Sources at `schema: "{{ env_var('DBT_RAW_SCHEMA') }}"` and `database:
423
+ "{{ env_var('DBT_GCP_PROJECT') }}"`, never a dataset name.
424
+ - `generate_schema_name` returns `DBT_SCHEMA_OVERRIDE` when set (a check:
425
+ `<org>_dev`), else `DBT_SCHEMA_PREFIX` + the layer (`+schema: staging` is
426
+ `<org>_staging`).
427
+ - A `metricflow_time_spine` model with `time_spine:` configured; include it
428
+ in `then.dbt_select`.
429
+ - Semantic models and metrics in YAML anywhere under `models/models/`;
430
+ `metric_edit` creates them in `models/models/semantic/metrics/`.
431
+ - `packages.yml` is fetched by `dbt deps` on every run (`dbt_utils`).
432
+
433
+ A deploy builds nothing: `deploy --then-build`, `datacore build`, or a
434
+ pipeline's `then.dbt_select` does. The full contract is "The managed dbt
435
+ project" in `dataspring docs --full`.
436
+
342
437
  ## 9. Parameter reference
343
438
 
344
439
  <!-- dispatch-params: semantic_model_edit metric_edit board_edit briefing_edit import_manifest business_context_edit warehouse_edit datacore_edit datacore_run -->
@@ -587,6 +682,7 @@ MCP tool `datacore_run`; `POST /api/dispatch/datacore_run`. The datacore's check
587
682
  | Parameter | Type | Required | Description |
588
683
  |---|---|---|---|
589
684
  | `note` | string | no | Optional note, recorded on the check run |
685
+ | `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
686
 
591
687
  **`deploy`** — Promote the checked draft to deployed (admin), or roll back to an earlier deployed snapshot; then_build also rebuilds changed models.
592
688
 
@@ -602,9 +698,9 @@ MCP tool `datacore_run`; `POST /api/dispatch/datacore_run`. The datacore's check
602
698
  | Parameter | Type | Required | Description |
603
699
  |---|---|---|---|
604
700
  | `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 |
701
+ | `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
702
  | `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 |
703
+ | `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
704
 
609
705
  **`build`** — Rebuild the deployed dbt models on the raw tables already loaded: no resource is loaded.
610
706
 
File without changes