lorebase-cli 0.2.3__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.
@@ -0,0 +1,523 @@
1
+ """`schema get|validate|apply|diff|export|import` and `schema suggestions list|accept|reject|merge`:
2
+ the schema of a Domain as a YAML document, and the changes document extraction suggests."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+ from enum import StrEnum
9
+ from pathlib import Path
10
+ from typing import Annotated
11
+
12
+ import typer
13
+
14
+ from lorebase_cli.context import AppContext, app_of
15
+ from lorebase_cli.failures import EXIT_INVALID, CliError
16
+ from lorebase_cli.linkml import (
17
+ Conversion,
18
+ LinkmlError,
19
+ LinkmlOptions,
20
+ dump_yaml,
21
+ from_linkml,
22
+ load_yaml,
23
+ merge_into_current,
24
+ to_linkml,
25
+ )
26
+ from lorebase_cli.options import AllOpt, AtOpt, BranchOpt, DomainOpt, JsonOpt, LimitOpt
27
+ from lorebase_cli.output import Column
28
+ from lorebase_client import NotFoundError
29
+ from lorebase_client import models as m
30
+
31
+ schema_app = typer.Typer(
32
+ help="Read, check and change the schema of a Domain (YAML documents).", no_args_is_help=True
33
+ )
34
+
35
+ FileArg = Annotated[
36
+ str, typer.Argument(help="Schema YAML document; `-` reads standard input.", metavar="FILE")
37
+ ]
38
+ CHANGE_COLUMNS = [
39
+ Column("change", "op"),
40
+ Column("section", "section"),
41
+ Column("element", "api_name"),
42
+ Column("id", "id"),
43
+ ]
44
+
45
+
46
+ def read_document(file: str) -> str:
47
+ """Text of a file argument (`-` is standard input)."""
48
+ if file == "-":
49
+ return sys.stdin.read()
50
+ try:
51
+ return Path(file).read_text(encoding="utf-8")
52
+ except OSError as error:
53
+ raise CliError(f"cannot read {file}: {error.strerror or error}") from error
54
+
55
+
56
+ def _issue_lines(issues: list[dict[str, object]], count: int) -> list[str]:
57
+ lines = []
58
+ for issue in issues:
59
+ path = ".".join(str(p) for p in issue.get("path", []) or []) # type: ignore[attr-defined]
60
+ lines.append(f" {path}: {issue.get('message', '')} [{issue.get('code', '')}]")
61
+ if count > len(issues):
62
+ lines.append(f" ... and {count - len(issues)} more")
63
+ return lines
64
+
65
+
66
+ def _print_changes(app: AppContext, changes: list[m.ElementChange]) -> None:
67
+ if changes:
68
+ app.out.table([c.model_dump() for c in changes], CHANGE_COLUMNS)
69
+ else:
70
+ app.out.text("no changes")
71
+
72
+
73
+ def _default_branch(app: AppContext, domain: str, given: str | None) -> str:
74
+ chosen = app.branch(given)
75
+ return chosen or app.client().domains.get(domain).default_branch
76
+
77
+
78
+ @schema_app.command("get")
79
+ def schema_get(
80
+ ctx: typer.Context,
81
+ domain: DomainOpt = None,
82
+ branch: BranchOpt = None,
83
+ at: AtOpt = None,
84
+ output: Annotated[
85
+ Path | None, typer.Option("--output", "-o", help="Write the document to this file.")
86
+ ] = None,
87
+ json_out: JsonOpt = False,
88
+ ) -> None:
89
+ """Print the schema as YAML (or as JSON with --json)."""
90
+ app = app_of(ctx, json_out)
91
+ client = app.client()
92
+ name = app.domain(domain)
93
+ if app.json_mode:
94
+ document = client.schema.get(name, branch=app.branch(branch), at=at)
95
+ if output:
96
+ output.write_text(_as_json(document))
97
+ else:
98
+ app.out.data(document)
99
+ return
100
+ text = client.schema.get_yaml(name, branch=app.branch(branch), at=at)
101
+ if output:
102
+ output.write_text(text, encoding="utf-8")
103
+ app.out.info(f"wrote {output}")
104
+ else:
105
+ app.out.text(text)
106
+
107
+
108
+ def _as_json(document: m.SchemaDocument) -> str:
109
+ return json.dumps(document.model_dump(mode="json", by_alias=True), indent=2) + "\n"
110
+
111
+
112
+ @schema_app.command("validate")
113
+ def schema_validate(
114
+ ctx: typer.Context,
115
+ file: FileArg,
116
+ domain: DomainOpt = None,
117
+ branch: BranchOpt = None,
118
+ at: AtOpt = None,
119
+ json_out: JsonOpt = False,
120
+ ) -> None:
121
+ """Check a schema document against the schema of a branch. Nothing is written.
122
+
123
+ Exits with 2 when the document is not valid.
124
+ """
125
+ app = app_of(ctx, json_out)
126
+ result = app.client().schema.validate(
127
+ app.domain(domain), yaml=read_document(file), branch=app.branch(branch), at=at
128
+ )
129
+ app.out.emit(result, lambda: _report_validation(app, result))
130
+ if not result.valid:
131
+ raise typer.Exit(EXIT_INVALID)
132
+
133
+
134
+ def _report_validation(app: AppContext, result: m.SchemaValidation) -> None:
135
+ if result.valid:
136
+ app.out.text(f"valid: {len(result.changes)} element changes")
137
+ _print_changes(app, result.changes)
138
+ return
139
+ app.out.text(f"not valid: {result.issue_count} issue(s)")
140
+ for line in _issue_lines(result.issues, result.issue_count):
141
+ app.out.text(line)
142
+
143
+
144
+ @schema_app.command("diff")
145
+ def schema_diff(
146
+ ctx: typer.Context,
147
+ file: FileArg,
148
+ domain: DomainOpt = None,
149
+ branch: BranchOpt = None,
150
+ at: AtOpt = None,
151
+ json_out: JsonOpt = False,
152
+ ) -> None:
153
+ """Show which elements applying a schema document would create, update or delete."""
154
+ app = app_of(ctx, json_out)
155
+ result = app.client().schema.validate(
156
+ app.domain(domain), yaml=read_document(file), branch=app.branch(branch), at=at
157
+ )
158
+ app.out.emit(result, lambda: _report_diff(app, result))
159
+ if not result.valid:
160
+ raise typer.Exit(EXIT_INVALID)
161
+
162
+
163
+ def _report_diff(app: AppContext, result: m.SchemaValidation) -> None:
164
+ if not result.valid:
165
+ _report_validation(app, result)
166
+ return
167
+ _print_changes(app, result.changes)
168
+
169
+
170
+ @schema_app.command("apply")
171
+ def schema_apply(
172
+ ctx: typer.Context,
173
+ file: FileArg,
174
+ domain: DomainOpt = None,
175
+ branch: BranchOpt = None,
176
+ message: Annotated[
177
+ str | None, typer.Option("--message", "-m", help="Revision message.")
178
+ ] = None,
179
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Plan only; write nothing.")] = False,
180
+ cascade: Annotated[
181
+ bool,
182
+ typer.Option("--cascade", help="Allow removing Kinds or Relations that hold data."),
183
+ ] = False,
184
+ expected_head: Annotated[
185
+ str | None,
186
+ typer.Option("--expected-head", help="Fail unless the branch is at this Revision."),
187
+ ] = None,
188
+ json_out: JsonOpt = False,
189
+ ) -> None:
190
+ """Apply a schema document to a branch as one Revision (data migrations included).
191
+
192
+ Elements the document leaves out are removed. Protected branches (the default one) refuse
193
+ direct changes: apply to another branch.
194
+ """
195
+ app = app_of(ctx, json_out)
196
+ name = app.domain(domain)
197
+ target = _default_branch(app, name, branch)
198
+ result = app.client().schema.apply(
199
+ name,
200
+ target,
201
+ yaml=read_document(file),
202
+ message=message,
203
+ expected_head=expected_head,
204
+ dry_run=dry_run,
205
+ cascade=cascade,
206
+ )
207
+ app.out.emit(result, lambda: _report_apply(app, result))
208
+
209
+
210
+ def _report_apply(app: AppContext, result: m.SchemaChangeResult) -> None:
211
+ app.out.pairs(
212
+ [
213
+ ("status", result.status),
214
+ ("branch", result.branch),
215
+ ("revision", result.revision),
216
+ ("element changes", result.change_count),
217
+ ("data changes", result.data_changes),
218
+ ]
219
+ )
220
+ if result.changes:
221
+ app.out.table([c.model_dump() for c in result.changes], CHANGE_COLUMNS)
222
+
223
+
224
+ class InterchangeFormat(StrEnum):
225
+ """Schema languages `schema export` and `schema import` translate to and from."""
226
+
227
+ LINKML = "linkml"
228
+
229
+
230
+ OutputOpt = Annotated[
231
+ Path | None, typer.Option("--output", "-o", help="Write the document to this file.")
232
+ ]
233
+
234
+
235
+ def _write(app: AppContext, text: str, output: Path | None) -> None:
236
+ if output:
237
+ output.write_text(text, encoding="utf-8")
238
+ app.out.info(f"wrote {output}")
239
+ else:
240
+ app.out.text(text)
241
+
242
+
243
+ @schema_app.command("export")
244
+ def schema_export(
245
+ ctx: typer.Context,
246
+ format_: Annotated[
247
+ InterchangeFormat, typer.Option("--format", help="Schema language to write.")
248
+ ],
249
+ domain: DomainOpt = None,
250
+ branch: BranchOpt = None,
251
+ at: AtOpt = None,
252
+ file: Annotated[
253
+ str | None,
254
+ typer.Option(
255
+ "--file",
256
+ help="Convert this Lorebase schema document (`-` reads standard input) instead of "
257
+ "the schema of the Domain.",
258
+ ),
259
+ ] = None,
260
+ uri: Annotated[
261
+ str | None,
262
+ typer.Option("--uri", help="Id of the LinkML schema; `{domain}` is the Domain id."),
263
+ ] = None,
264
+ output: OutputOpt = None,
265
+ ) -> None:
266
+ """Translate a schema into LinkML YAML.
267
+
268
+ What LinkML cannot express (element ids, sensitivity, search settings, Operations,
269
+ Derivations, Sources) travels as `lorebase.*` annotations.
270
+ """
271
+ app = app_of(ctx, False)
272
+ if file is not None:
273
+ text = read_document(file)
274
+ else:
275
+ name = app.domain(domain)
276
+ text = app.client().schema.get_yaml(name, branch=app.branch(branch), at=at)
277
+ options = LinkmlOptions(schema_uri=uri) if uri else LinkmlOptions()
278
+ try:
279
+ schema = to_linkml(load_yaml(text), options)
280
+ except LinkmlError as error:
281
+ raise CliError(str(error), exit_code=EXIT_INVALID) from error
282
+ _write(app, dump_yaml(schema), output)
283
+
284
+
285
+ @schema_app.command("import")
286
+ def schema_import(
287
+ ctx: typer.Context,
288
+ file: Annotated[
289
+ str, typer.Argument(help="LinkML schema; `-` reads standard input.", metavar="FILE")
290
+ ],
291
+ source: Annotated[InterchangeFormat, typer.Option("--from", help="Schema language of FILE.")],
292
+ output: OutputOpt = None,
293
+ apply: Annotated[
294
+ bool,
295
+ typer.Option("--apply", help="Apply the result to a branch instead of printing it."),
296
+ ] = False,
297
+ dry_run: Annotated[
298
+ bool, typer.Option("--dry-run", help="Plan applying the result; write nothing.")
299
+ ] = False,
300
+ domain: DomainOpt = None,
301
+ branch: BranchOpt = None,
302
+ message: Annotated[
303
+ str | None, typer.Option("--message", "-m", help="Revision message.")
304
+ ] = None,
305
+ cascade: Annotated[
306
+ bool,
307
+ typer.Option("--cascade", help="Allow removing Kinds or Relations that hold data."),
308
+ ] = False,
309
+ expected_head: Annotated[
310
+ str | None,
311
+ typer.Option("--expected-head", help="Fail unless the branch is at this Revision."),
312
+ ] = None,
313
+ prune: Annotated[
314
+ bool,
315
+ typer.Option(
316
+ "--prune",
317
+ help="With --apply or --dry-run: also remove the Kinds, Attributes, Relations and "
318
+ "Traits of the Domain that the LinkML schema leaves out (otherwise kept, with a "
319
+ "warning).",
320
+ ),
321
+ ] = False,
322
+ json_out: JsonOpt = False,
323
+ ) -> None:
324
+ """Translate a LinkML schema into a Lorebase schema document.
325
+
326
+ Prints the document (or writes it with --output); warnings about what it leaves out go to
327
+ standard error. --apply sends it to a branch like `schema apply`, --dry-run plans that. The
328
+ import is merged into the current schema of the Domain: the LinkML schema owns Kinds,
329
+ Attributes, Relations, Traits, enums and path Derivations; Operations, other Derivations,
330
+ Sources and the fields LinkML cannot carry (`derivation`, `authority`, ...) are kept.
331
+ Elements the LinkML schema leaves out stay, with a warning, unless --prune removes them.
332
+ """
333
+ app = app_of(ctx, json_out)
334
+ if prune and not (apply or dry_run):
335
+ raise CliError("--prune needs --apply or --dry-run: nothing is merged otherwise")
336
+ try:
337
+ conversion = from_linkml(load_yaml(read_document(file)))
338
+ except LinkmlError as error:
339
+ raise CliError(str(error), exit_code=EXIT_INVALID) from error
340
+ if not (apply or dry_run):
341
+ for warning in conversion.warnings:
342
+ app.out.info(f"warning: {warning}")
343
+ _print_conversion(app, conversion, output)
344
+ return
345
+ name = app.domain(domain)
346
+ conversion.document["domain"]["id"] = name
347
+ target = _default_branch(app, name, branch)
348
+ try:
349
+ current = load_yaml(app.client().schema.get_yaml(name, branch=target))
350
+ except NotFoundError:
351
+ current = None # a new Domain or branch: nothing to keep
352
+ if current:
353
+ try:
354
+ conversion = merge_into_current(current, conversion, prune=prune)
355
+ except LinkmlError as error:
356
+ raise CliError(str(error), exit_code=EXIT_INVALID) from error
357
+ for warning in conversion.warnings:
358
+ app.out.info(f"warning: {warning}")
359
+ result = app.client().schema.apply(
360
+ name,
361
+ target,
362
+ yaml=dump_yaml(conversion.document),
363
+ message=message,
364
+ expected_head=expected_head,
365
+ dry_run=dry_run,
366
+ cascade=cascade,
367
+ )
368
+ app.out.emit(result, lambda: _report_apply(app, result))
369
+
370
+
371
+ def _print_conversion(app: AppContext, conversion: Conversion, output: Path | None) -> None:
372
+ if app.json_mode and not output:
373
+ app.out.data(
374
+ {
375
+ "document": conversion.document,
376
+ "warnings": [vars(warning) for warning in conversion.warnings],
377
+ }
378
+ )
379
+ return
380
+ _write(app, dump_yaml(conversion.document), output)
381
+
382
+
383
+ # Schema suggestions -----------------------------------------------------------------------
384
+
385
+ suggestions_app = typer.Typer(
386
+ help="Review the Kinds, Attributes and Relations document extraction suggests.",
387
+ no_args_is_help=True,
388
+ )
389
+ schema_app.add_typer(suggestions_app, name="suggestions")
390
+
391
+ SUGGESTION_COLUMNS = [
392
+ Column("id", "id"),
393
+ Column("type", "type"),
394
+ Column("name", "name"),
395
+ Column("of", "kind", lambda v: v or ""),
396
+ Column("support", "support"),
397
+ Column("docs", "documents"),
398
+ Column("status", "status"),
399
+ Column("samples", "samples", lambda v: ", ".join(str(s) for s in (v or [])[:3])),
400
+ ]
401
+
402
+
403
+ class SuggestionStatus(StrEnum):
404
+ PENDING = "pending"
405
+ ACCEPTED = "accepted"
406
+ REJECTED = "rejected"
407
+
408
+
409
+ class SuggestionKind(StrEnum):
410
+ KIND = "kind"
411
+ ATTRIBUTE = "attribute"
412
+ RELATION = "relation"
413
+
414
+
415
+ IdsArg = Annotated[list[str], typer.Argument(help="Suggestion ids (`ssg_...`).", metavar="ID...")]
416
+
417
+
418
+ @suggestions_app.command("list")
419
+ def suggestions_list(
420
+ ctx: typer.Context,
421
+ domain: DomainOpt = None,
422
+ status: Annotated[
423
+ list[SuggestionStatus] | None,
424
+ typer.Option("--status", help="Only this status. Repeatable."),
425
+ ] = None,
426
+ type_: Annotated[
427
+ list[SuggestionKind] | None,
428
+ typer.Option("--type", help="Only this kind of suggestion. Repeatable."),
429
+ ] = None,
430
+ weak: Annotated[
431
+ bool,
432
+ typer.Option("--weak", help="Also pending ones below `schema.suggestions.min_support`."),
433
+ ] = False,
434
+ limit: LimitOpt = None,
435
+ everything: AllOpt = False,
436
+ json_out: JsonOpt = False,
437
+ ) -> None:
438
+ """List schema suggestions from extraction, the most supported first."""
439
+ app = app_of(ctx, json_out)
440
+ items, more = app.take(
441
+ app.client().schema.iterate_suggestions(
442
+ app.domain(domain),
443
+ status=[x.value for x in status or []] or None,
444
+ type=[x.value for x in type_ or []] or None,
445
+ all=weak or None,
446
+ ),
447
+ limit,
448
+ everything,
449
+ )
450
+ app.out.emit(items, lambda: app.out.table([i.model_dump() for i in items], SUGGESTION_COLUMNS))
451
+ if more:
452
+ app.note_truncated(len(items))
453
+
454
+
455
+ def _decide(
456
+ app: AppContext,
457
+ domain: str | None,
458
+ ids: list[str],
459
+ decision: m.ItemDecisionValue,
460
+ name: str | None,
461
+ ) -> None:
462
+ result = app.client().schema.decide_suggestions(
463
+ app.domain(domain),
464
+ [m.SuggestionDecision(suggestion=i, decision=decision, name=name) for i in ids],
465
+ )
466
+
467
+ def human() -> None:
468
+ app.out.table([s.model_dump() for s in result.suggestions], SUGGESTION_COLUMNS)
469
+ if result.proposal is not None:
470
+ app.out.pairs(
471
+ [
472
+ ("proposal", result.proposal.id),
473
+ ("branch", result.proposal.branch),
474
+ ("title", result.proposal.title),
475
+ ]
476
+ )
477
+ _print_changes(app, result.proposal.changes)
478
+
479
+ app.out.emit(result, human)
480
+
481
+
482
+ @suggestions_app.command("accept")
483
+ def suggestions_accept(
484
+ ctx: typer.Context,
485
+ ids: IdsArg,
486
+ domain: DomainOpt = None,
487
+ name: Annotated[
488
+ str | None,
489
+ typer.Option("--name", help="Api name to use instead (one suggestion only)."),
490
+ ] = None,
491
+ json_out: JsonOpt = False,
492
+ ) -> None:
493
+ """Accept suggestions: together they become one schema change set on a new branch,
494
+ proposed into the default branch (nothing changes there until the Proposal merges)."""
495
+ if name is not None and len(ids) != 1:
496
+ raise CliError("--name needs exactly one suggestion", exit_code=EXIT_INVALID)
497
+ _decide(app_of(ctx, json_out), domain, ids, "accepted", name)
498
+
499
+
500
+ @suggestions_app.command("reject")
501
+ def suggestions_reject(
502
+ ctx: typer.Context,
503
+ ids: IdsArg,
504
+ domain: DomainOpt = None,
505
+ json_out: JsonOpt = False,
506
+ ) -> None:
507
+ """Reject suggestions."""
508
+ _decide(app_of(ctx, json_out), domain, ids, "rejected", None)
509
+
510
+
511
+ @suggestions_app.command("merge")
512
+ def suggestions_merge(
513
+ ctx: typer.Context, domain: DomainOpt = None, json_out: JsonOpt = False
514
+ ) -> None:
515
+ """Fold stored Attribute suggestions that are variants of one another (names sharing
516
+ `schema.suggestions.merge_similarity` of their words, compatible samples) into the best
517
+ supported one. Safe to repeat."""
518
+ app = app_of(ctx, json_out)
519
+ result = app.client().schema.merge_suggestions(app.domain(domain))
520
+ app.out.emit(
521
+ result,
522
+ lambda: app.out.text(f"merged {result.merged} suggestions into {result.kept}"),
523
+ )
@@ -0,0 +1,69 @@
1
+ """`sources sync|runs`: Source synchronizations run by Scribe."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from lorebase_cli.context import app_of
10
+ from lorebase_cli.options import AllOpt, DomainOpt, JsonOpt, LimitOpt
11
+ from lorebase_cli.output import Column
12
+
13
+ sources_app = typer.Typer(help="Synchronize the Sources of a Domain.", no_args_is_help=True)
14
+
15
+ SourceArg = Annotated[
16
+ str, typer.Argument(help="Source id or api name.", metavar="SOURCE", show_default=False)
17
+ ]
18
+
19
+ RUN_COLUMNS = [
20
+ Column("id", "id"),
21
+ Column("status", "status"),
22
+ Column("started", "started_at"),
23
+ Column("finished", "finished_at"),
24
+ Column("revision", "revision"),
25
+ Column("proposal", "proposal_id"),
26
+ ]
27
+
28
+
29
+ @sources_app.command("sync")
30
+ def sources_sync(
31
+ ctx: typer.Context,
32
+ source: SourceArg,
33
+ domain: DomainOpt = None,
34
+ dry_run: Annotated[
35
+ bool, typer.Option("--dry-run", help="Record the delta without writing to any branch.")
36
+ ] = False,
37
+ json_out: JsonOpt = False,
38
+ ) -> None:
39
+ """Queue a synchronization of a Source; `sources runs` shows its outcome."""
40
+ app = app_of(ctx, json_out)
41
+ queued = app.client().sources.sync(app.domain(domain), source, dry_run=dry_run)
42
+ app.out.emit(
43
+ queued,
44
+ lambda: app.out.pairs(
45
+ [
46
+ ("job", queued.job_id),
47
+ ("source", f"{queued.api_name} ({queued.source})"),
48
+ ("dry run", queued.dry_run),
49
+ ]
50
+ ),
51
+ )
52
+
53
+
54
+ @sources_app.command("runs")
55
+ def sources_runs(
56
+ ctx: typer.Context,
57
+ source: SourceArg,
58
+ domain: DomainOpt = None,
59
+ limit: LimitOpt = None,
60
+ everything: AllOpt = False,
61
+ json_out: JsonOpt = False,
62
+ ) -> None:
63
+ """List the runs of a Source, newest first."""
64
+ app = app_of(ctx, json_out)
65
+ runs = app.client().sources.iterate_runs(app.domain(domain), source)
66
+ items, more = app.take(runs, limit, everything)
67
+ app.out.emit(items, lambda: app.out.table([r.model_dump() for r in items], RUN_COLUMNS))
68
+ if more:
69
+ app.note_truncated(len(items))