ecosemantic 0.1.8__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,59 @@
1
+ """Open a workspace with `ecosemantic.open()`; every MCP tool is a method on it."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from ecosemantic.model.entities import (
7
+ Case,
8
+ DemandItem,
9
+ ExchangeOverride,
10
+ Parameter,
11
+ ParameterOverride,
12
+ Uncertainty,
13
+ )
14
+ from ecosemantic.model.refs import BackgroundRef, ElementaryFlowRef, ForegroundRef, SystemRef
15
+ from ecosemantic.ports.types import MonteCarloConfig
16
+ from ecosemantic.workspace import Workspace, open
17
+
18
+ __path__ = __import__("pkgutil").extend_path(__path__, __name__)
19
+
20
+ PUBLIC = {
21
+ "open": "ecosemantic.workspace",
22
+ "Workspace": "ecosemantic.workspace",
23
+ "Parameter": "ecosemantic.model.entities",
24
+ "Case": "ecosemantic.model.entities",
25
+ "Uncertainty": "ecosemantic.model.entities",
26
+ "ExchangeOverride": "ecosemantic.model.entities",
27
+ "ParameterOverride": "ecosemantic.model.entities",
28
+ "DemandItem": "ecosemantic.model.entities",
29
+ "BackgroundRef": "ecosemantic.model.refs",
30
+ "ForegroundRef": "ecosemantic.model.refs",
31
+ "ElementaryFlowRef": "ecosemantic.model.refs",
32
+ "SystemRef": "ecosemantic.model.refs",
33
+ "MonteCarloConfig": "ecosemantic.ports.types",
34
+ }
35
+
36
+ __all__ = [
37
+ "BackgroundRef",
38
+ "Case",
39
+ "DemandItem",
40
+ "ElementaryFlowRef",
41
+ "ExchangeOverride",
42
+ "ForegroundRef",
43
+ "MonteCarloConfig",
44
+ "Parameter",
45
+ "ParameterOverride",
46
+ "SystemRef",
47
+ "Uncertainty",
48
+ "Workspace",
49
+ "open",
50
+ ]
51
+
52
+
53
+ def __getattr__(name: str) -> object:
54
+ """The public name from the module that defines it, imported on first use."""
55
+ if name not in PUBLIC:
56
+ raise AttributeError(f"module 'ecosemantic' has no attribute {name!r}")
57
+ from importlib import import_module
58
+
59
+ return getattr(import_module(PUBLIC[name]), name)
@@ -0,0 +1,635 @@
1
+ """The commands: serve the server and its view, write a package, read one back, score a table
2
+ folder, set up data, configure a chatbot and report on the install."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import asyncio
8
+ import contextlib
9
+ import json
10
+ import logging
11
+ import os
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ from collections.abc import Mapping
16
+ from datetime import UTC, datetime
17
+ from pathlib import Path
18
+ from typing import cast
19
+
20
+ import fastmcp
21
+ from pydantic import JsonValue
22
+
23
+ from ecosemantic.backends.filesystem.storage import FilesystemStorage
24
+ from ecosemantic.backends.native.calculation import NativeCalculation
25
+ from ecosemantic.backends.native.install import install
26
+ from ecosemantic.backends.native.inventory import NativeInventory
27
+ from ecosemantic.backends.native.store import NativeStore
28
+ from ecosemantic.cli.accounts import account_for
29
+ from ecosemantic.cli.clients import CLIENTS, connect, found
30
+ from ecosemantic.cli.doctor import directories, report
31
+ from ecosemantic.cli.progress import printer
32
+ from ecosemantic.cli.text import (
33
+ Renderer,
34
+ compute_text,
35
+ connect_text,
36
+ database_text,
37
+ doctor_text,
38
+ init_text,
39
+ refusal_text,
40
+ setup_text,
41
+ telemetry_text,
42
+ terms_text,
43
+ )
44
+ from ecosemantic.compute.links import pinned_documents
45
+ from ecosemantic.compute.results import ContributionResult
46
+ from ecosemantic.engine.solver import SOLVERS
47
+ from ecosemantic.errors import (
48
+ Conflict,
49
+ EcoSemanticError,
50
+ Invalid,
51
+ Remedies,
52
+ StaleScenario,
53
+ Unavailable,
54
+ error_payload,
55
+ )
56
+ from ecosemantic.installs.sources import install_source
57
+ from ecosemantic.local import build_context, default_directories, defaulted
58
+ from ecosemantic.mcp.calculation import impact_of, methods_for
59
+ from ecosemantic.mcp.context import ServerContext
60
+ from ecosemantic.mcp.envelopes import as_uuid
61
+ from ecosemantic.mcp.tables import result_key
62
+ from ecosemantic.mcp.treatments import treatments_in
63
+ from ecosemantic.model.entities import Actor
64
+ from ecosemantic.package.background import Import, read_background
65
+ from ecosemantic.package.manifest import BaseDatabase, Manifest, read_manifest
66
+ from ecosemantic.package.model_package import export_model_package, import_model_package, stated_at
67
+ from ecosemantic.package.release import build_release
68
+ from ecosemantic.package.resolver import InventoryResolver
69
+ from ecosemantic.package.working import (
70
+ build_working,
71
+ foreground_processes,
72
+ restore_systems,
73
+ write_base_part,
74
+ )
75
+ from ecosemantic.ports.types import (
76
+ InstallDraft,
77
+ InstallProgress,
78
+ InstallTerms,
79
+ InventoryRow,
80
+ Page,
81
+ Reporter,
82
+ SourceName,
83
+ System,
84
+ source_credential,
85
+ )
86
+ from ecosemantic.serve import TRANSPORTS, run, settings_from
87
+ from ecosemantic.tables.reader import assemble
88
+ from ecosemantic.tables.results import (
89
+ write_contribution,
90
+ write_impact,
91
+ write_inventory,
92
+ )
93
+ from ecosemantic.telemetry import CHOICES, begin, record, serving, status
94
+
95
+
96
+ def _parser() -> argparse.ArgumentParser:
97
+ parser = argparse.ArgumentParser(prog="ecosemantic", description="EcoSemantic local core.")
98
+ subcommands = parser.add_subparsers(dest="command", required=True)
99
+
100
+ def data(sub: argparse.ArgumentParser) -> None:
101
+ sub.add_argument("--data-dir", type=Path)
102
+
103
+ def answered(sub: argparse.ArgumentParser) -> None:
104
+ sub.add_argument("--json", dest="as_json", action="store_true")
105
+
106
+ def solved(sub: argparse.ArgumentParser) -> None:
107
+ sub.add_argument("--solver", choices=SOLVERS, default="superlu")
108
+
109
+ def shared(name: str, help_text: str) -> argparse.ArgumentParser:
110
+ sub = subcommands.add_parser(name, help=help_text)
111
+ data(sub)
112
+ solved(sub)
113
+ sub.add_argument("--systems-dir", type=Path)
114
+ return sub
115
+
116
+ serve = shared("serve", "Run the MCP server, with the browser view beside it.")
117
+ serve.add_argument("--transport", choices=TRANSPORTS)
118
+ serve.add_argument("--view-host")
119
+ serve.add_argument("--view-port", type=int)
120
+ serve.add_argument("--view-url")
121
+ serve.add_argument("--token", dest="token", action="store_true", default=None)
122
+ serve.add_argument("--no-token", dest="token", action="store_false", default=None)
123
+ serve.add_argument("--no-view", action="store_true")
124
+ serve.add_argument("--no-open", action="store_true")
125
+ export = shared("export", "Write a stored product system as a portable package.")
126
+ export.add_argument("--id", required=True)
127
+ export.add_argument("--to", required=True, type=Path)
128
+ answered(export)
129
+ importer = shared("import", "Read a package and store the product system it carries.")
130
+ importer.add_argument("--from", dest="source", required=True, type=Path)
131
+ answered(importer)
132
+ compute = subcommands.add_parser(
133
+ "compute", help="Compute a table folder's results, with no store and no server."
134
+ )
135
+ data(compute)
136
+ solved(compute)
137
+ answered(compute)
138
+ compute.add_argument("--folder", required=True, type=Path)
139
+ compute.add_argument("--method")
140
+ compute.add_argument("--impact-method", dest="impact_method")
141
+ compute.add_argument("--variants", default="")
142
+ compute.add_argument("--contributions", action="store_true")
143
+ compute.add_argument("--inventory", action="store_true")
144
+ setup = subcommands.add_parser("setup", help="Install a background database.")
145
+ kinds = setup.add_subparsers(dest="kind", required=True)
146
+ openlca = kinds.add_parser("openlca", help="Install an openLCA JSON-LD package.")
147
+ data(openlca)
148
+ openlca.add_argument("--package", required=True, type=Path)
149
+ openlca.add_argument("--name", required=True)
150
+ openlca.add_argument("--version", required=True)
151
+ openlca.add_argument("--system-model", default="unit process")
152
+ openlca.add_argument("--replace", action="store_true")
153
+ openlca.add_argument("--drop-unlinked", action="store_true")
154
+ answered(openlca)
155
+ uslci = kinds.add_parser("uslci", help="Install the USLCI database a profile pins.")
156
+ data(uslci)
157
+ uslci.add_argument("--version")
158
+ uslci.add_argument("--replace", action="store_true")
159
+ answered(uslci)
160
+ bafu = kinds.add_parser("bafu", help="Install the BAFU database a profile pins.")
161
+ data(bafu)
162
+ bafu.add_argument("--version")
163
+ bafu.add_argument("--replace", action="store_true")
164
+ bafu.add_argument("--accept-terms", dest="accept_terms", action="store_true")
165
+ answered(bafu)
166
+ ecoinvent = kinds.add_parser("ecoinvent", help="Install an ecoinvent release it builds.")
167
+ data(ecoinvent)
168
+ answered(ecoinvent)
169
+ ecoinvent.add_argument("version", help="The release to install, as a profile names it.")
170
+ ecoinvent.add_argument("--system-model", default="cutoff")
171
+ ecoinvent.add_argument("--replace", action="store_true")
172
+ database = subcommands.add_parser("database", help="Save, release or load a database bundle.")
173
+ actions = database.add_subparsers(dest="action", required=True)
174
+ for action, text in (
175
+ ("save", "Write every stored system whole beside the base it reaches."),
176
+ ("release", "Render every stored system's cases to numbers beside the base it reaches."),
177
+ ):
178
+ builder = actions.add_parser(action, help=text)
179
+ data(builder)
180
+ builder.add_argument("--systems-dir", type=Path)
181
+ builder.add_argument("--base", required=True)
182
+ builder.add_argument("--name", required=True)
183
+ builder.add_argument("--version", required=True)
184
+ builder.add_argument("--to", required=True, type=Path)
185
+ if action == "release":
186
+ builder.add_argument("--previous", type=Path)
187
+ answered(builder)
188
+ loader = actions.add_parser("load", help="Install a database bundle and restore its systems.")
189
+ data(loader)
190
+ loader.add_argument("--systems-dir", type=Path)
191
+ loader.add_argument("--from", dest="source", required=True, type=Path)
192
+ answered(loader)
193
+ connecting = subcommands.add_parser(
194
+ "connect", help="Write this server into a chatbot's MCP configuration."
195
+ )
196
+ connecting.add_argument("client", choices=CLIENTS)
197
+ connecting.add_argument("--workspace", type=Path)
198
+ connecting.add_argument("--command", dest="command_path", type=Path)
199
+ answered(connecting)
200
+ doctor = subcommands.add_parser("doctor", help="Report what this install holds and lacks.")
201
+ doctor.add_argument("--data-dir", dest="data", type=Path)
202
+ doctor.add_argument("--systems-dir", dest="systems", type=Path)
203
+ answered(doctor)
204
+ telemetry = subcommands.add_parser(
205
+ "telemetry", help="Show or set whether this install counts server starts and tool calls."
206
+ )
207
+ telemetry.add_argument("choice", nargs="?", choices=CHOICES)
208
+ telemetry.add_argument("--data-dir", dest="data", type=Path)
209
+ answered(telemetry)
210
+ initializing = subcommands.add_parser(
211
+ "init", help="Connect the chatbots found and say what comes next."
212
+ )
213
+ initializing.add_argument("--client", dest="clients", action="append", choices=CLIENTS)
214
+ answered(initializing)
215
+ return parser
216
+
217
+
218
+ def _defaulted(args: argparse.Namespace) -> None:
219
+ """Give a command that names no data directory, or no product systems directory, this
220
+ platform's default for the one it lacks."""
221
+ lacking = [name for name in ("data_dir", "systems_dir") if getattr(args, name, False) is None]
222
+ if lacking:
223
+ defaults = dict(zip(("data_dir", "systems_dir"), default_directories(), strict=True))
224
+ for name in lacking:
225
+ setattr(args, name, defaulted(None, defaults[name]))
226
+
227
+
228
+ async def _compute(args: argparse.Namespace, progress: Reporter) -> dict[str, object]:
229
+ """Score a folder's document against the named method and write one run folder."""
230
+ if not args.folder.is_dir():
231
+ raise Invalid(f"{str(args.folder)!r} is not a directory")
232
+ system = assemble(args.folder)
233
+ store = NativeStore(args.data_dir, args.solver)
234
+ try:
235
+ progress(InstallProgress(step="scoring"))
236
+ inventory = NativeInventory(store)
237
+ calculation = NativeCalculation(store)
238
+ methods, _ = await methods_for(
239
+ inventory, system.database.name, args.method, args.impact_method
240
+ )
241
+ names = [name for name in args.variants.split(",") if name]
242
+ scored = System.of(system)
243
+ treatments = await treatments_in(inventory, [system])
244
+ result = await impact_of(
245
+ calculation, system, methods, variants=names, treatments=treatments
246
+ )
247
+ key = result_key(system, args.method, args.impact_method, None, names)
248
+ items = key.demand
249
+ at = datetime.now(UTC)
250
+ progress(InstallProgress(step="writing"))
251
+ written = [str(write_impact(args.folder, key, result, at))]
252
+ if args.contributions:
253
+ hotspots = await calculation.hotspots(scored, items, methods[0])
254
+ decomposed = ContributionResult(
255
+ database=system.database,
256
+ by="process",
257
+ total=hotspots.total,
258
+ processes=hotspots.items,
259
+ credit_loops=result.credit_loops,
260
+ linked_credit_loops=result.linked_credit_loops,
261
+ treatments_run_backwards=result.treatments_run_backwards,
262
+ )
263
+ written.append(str(write_contribution(args.folder, key, decomposed, at)))
264
+ if args.inventory:
265
+ found = await calculation.inventory(scored, items)
266
+ page = Page[InventoryRow](items=found.rows, has_more=False)
267
+ written.append(
268
+ str(
269
+ write_inventory(
270
+ args.folder,
271
+ key,
272
+ system.database,
273
+ page,
274
+ result.credit_loops,
275
+ result.linked_credit_loops,
276
+ result.treatments_run_backwards,
277
+ at,
278
+ )
279
+ )
280
+ )
281
+ finally:
282
+ store.close()
283
+ return {"folder": written[0], "written": written, "hash": key.hash, "method": key.method}
284
+
285
+
286
+ def _setup(args: argparse.Namespace, progress: Reporter) -> dict[str, JsonValue] | InstallTerms:
287
+ """Install a background database: an openLCA package, or a source this package builds."""
288
+ package = getattr(args, "package", None)
289
+ draft = InstallDraft(
290
+ source=cast(SourceName, args.kind),
291
+ actor=Actor(kind="person", name="command line"),
292
+ version=getattr(args, "version", None),
293
+ system_model=getattr(args, "system_model", None),
294
+ replace=getattr(args, "replace", False),
295
+ accept_terms=getattr(args, "accept_terms", False),
296
+ package=None if package is None else str(package),
297
+ name=getattr(args, "name", None),
298
+ drop_unlinked=getattr(args, "drop_unlinked", False),
299
+ )
300
+ kind = source_credential(draft.source)
301
+ account = None if kind is None else account_for(kind, os.environ, sys.stdin.isatty())
302
+ return install_source(args.data_dir, draft, account, progress)
303
+
304
+
305
+ def serve_offline() -> None:
306
+ """Keep the stdio server's own network use to EcoSemantic's counts, when telemetry is on: no
307
+ update check against PyPI and no FastMCP tracing."""
308
+ fastmcp.settings.check_for_updates = "off"
309
+ fastmcp.settings.telemetry_mode = "off"
310
+
311
+
312
+ def _context(args: argparse.Namespace) -> ServerContext:
313
+ return build_context(
314
+ args.data_dir,
315
+ args.systems_dir,
316
+ agent=os.environ.get("ECOSEMANTIC_AGENT"),
317
+ solver=args.solver,
318
+ )
319
+
320
+
321
+ def _export(context: ServerContext, product_system_id: str, target: Path) -> None:
322
+ system = asyncio.run(context.storage.get_product_system(as_uuid(product_system_id)))
323
+ linked = asyncio.run(pinned_documents(system, context.storage.get_version))
324
+ at = stated_at(system, asyncio.run(context.storage.log(system.id)))
325
+ export_model_package(system, target, context.actor, context.resolver, linked=linked, at=at)
326
+
327
+
328
+ def _import(context: ServerContext, source: Path) -> None:
329
+ asyncio.run(import_model_package(source, context.storage, context.actor))
330
+
331
+
332
+ REMEDIES: Remedies = {
333
+ StaleScenario: (
334
+ "a folder applies a scenario only at the inputs it was exported with: restore them, or "
335
+ "store the inputs with import_tables, rebase the scenario and export the folder again"
336
+ )
337
+ }
338
+
339
+
340
+ def _refused(exc: EcoSemanticError, as_json: bool) -> int:
341
+ """Name the refusal on standard error: the error line under `--json`, the message and its
342
+ hint otherwise."""
343
+ envelope = error_payload(exc, REMEDIES)
344
+ print(envelope.model_dump_json() if as_json else refusal_text(envelope), file=sys.stderr)
345
+ return 1
346
+
347
+
348
+ def _answered(answer: Mapping[str, object], as_json: bool, render: Renderer) -> None:
349
+ """Print a command's answer: the JSON object main prints under `--json`, and the text the
350
+ renderer builds otherwise."""
351
+ print(json.dumps(_printed(answer)) if as_json else render(answer))
352
+
353
+
354
+ def _printed(answer: object) -> object:
355
+ """A command's answer as the one JSON document main prints: the terms of a source to accept, and
356
+ any other answer as it stands."""
357
+ if isinstance(answer, InstallTerms):
358
+ return {"licence": answer.licence, "citation": answer.citation}
359
+ return answer
360
+
361
+
362
+ def _code(answer: object) -> int:
363
+ """2 for the answer that asks for a source's terms to be accepted, and 0 for one that stands."""
364
+ return 2 if isinstance(answer, InstallTerms) else 0
365
+
366
+
367
+ ALL = "all"
368
+ NO_CHATBOT = "no chatbot is installed"
369
+ INSTALL_CHATBOT = (
370
+ "install Claude Desktop from https://claude.ai/download, then run: ecosemantic init"
371
+ )
372
+ NEXT = "Restart your chatbot; the EcoSemantic page opens in your browser."
373
+
374
+
375
+ def _choice(answer: str, clients: list[str]) -> list[str] | None:
376
+ """The chatbots one answer names: one of them by its number, and every one of them for `all`;
377
+ None for an answer that names neither."""
378
+ text = answer.strip().lower()
379
+ if text == ALL:
380
+ return clients
381
+ numbered = {str(number): client for number, client in enumerate(clients, start=1)}
382
+ if text in numbered:
383
+ return [numbered[text]]
384
+ return None
385
+
386
+
387
+ def _asked(clients: list[str]) -> list[str]:
388
+ """The chatbots the person picks at a terminal: the ones a number names, or all of them,
389
+ asked again until the answer names one of the choices."""
390
+ question = "\n".join(
391
+ [
392
+ *(f"{number}. {client}" for number, client in enumerate(clients, start=1)),
393
+ f"{len(clients) + 1}. {ALL}",
394
+ "Which chatbots should I connect?",
395
+ ]
396
+ )
397
+ chosen: list[str] | None = None
398
+ while chosen is None:
399
+ print(question, file=sys.stderr)
400
+ chosen = _choice(input(), clients)
401
+ return chosen
402
+
403
+
404
+ def _init(args: argparse.Namespace) -> dict[str, object]:
405
+ """Connect every chatbot the person names, or every one this platform holds, asking which of
406
+ them at a terminal when it holds several; answer with the ones connected, the install as it
407
+ now stands and the step that comes next."""
408
+ clients = list(args.clients) if args.clients else found()
409
+ if not clients:
410
+ raise Unavailable(NO_CHATBOT, INSTALL_CHATBOT)
411
+ if args.clients is None and len(clients) > 1 and sys.stdin.isatty():
412
+ clients = _asked(clients)
413
+ data, systems = directories(None, None)
414
+ return {
415
+ "connected": [connect(client, None, None) for client in clients],
416
+ "doctor": report(data, systems, _databases(data)),
417
+ "next": NEXT,
418
+ }
419
+
420
+
421
+ def _databases(data: Path) -> object:
422
+ """The names of the databases a data directory holds, none when it does not exist, or the
423
+ error its registry answers."""
424
+ if not data.is_dir():
425
+ return []
426
+ try:
427
+ store = NativeStore(data)
428
+ except EcoSemanticError as exc:
429
+ return error_payload(exc).model_dump(mode="json")
430
+ try:
431
+ return [database.name for database in store.databases()]
432
+ finally:
433
+ store.close()
434
+
435
+
436
+ def _parts(data: Path, systems: Path) -> tuple[NativeStore, FilesystemStorage, InventoryResolver]:
437
+ store = NativeStore(data)
438
+ resolver = InventoryResolver(NativeInventory(store))
439
+ return store, FilesystemStorage(systems, resolver), resolver
440
+
441
+
442
+ def _held(data: Path, manifest: Manifest, imported: Import) -> bool:
443
+ """Whether the data directory holds a working bundle's base: Conflict for another version
444
+ of it, for one that lacks a process the bundle carries, or for another build of it than the
445
+ one the bundle's systems were stamped with."""
446
+ store = NativeStore(data)
447
+ try:
448
+ if manifest.base.name not in {d.name for d in store.databases()}:
449
+ return False
450
+ version = store.registered(manifest.base.name).database.version
451
+ if version != manifest.base.version:
452
+ raise Conflict(
453
+ f"{manifest.base.name!r} is held at version {version!r}; the bundle was built "
454
+ f"on version {manifest.base.version!r}"
455
+ )
456
+ lacking = {code for _, code in imported.datasets} - store.native_ids(manifest.base.name)
457
+ if lacking:
458
+ raise Conflict(
459
+ f"{manifest.base.name!r} lacks {len(lacking)} processes the bundle carries"
460
+ )
461
+ held = store.registered(manifest.base.name).database.fingerprint
462
+ built_on = manifest.base.fingerprint
463
+ if built_on is not None and held != built_on:
464
+ raise Conflict(
465
+ f"{manifest.base.name!r} is held at build {str(held)[:12]}; the bundle was built "
466
+ f"on build {built_on[:12]}"
467
+ )
468
+ return True
469
+ finally:
470
+ store.close()
471
+
472
+
473
+ def _load(data: Path, systems: Path, bundle: Path, progress: Reporter) -> dict[str, object]:
474
+ progress(InstallProgress(step="reading"))
475
+ manifest = read_manifest(bundle)
476
+ if manifest.mode == "release":
477
+ imported = read_background(bundle, manifest.name, foreground=frozenset())
478
+ progress(InstallProgress(step="installing"))
479
+ install(data, imported, bundle, version=manifest.version, system_model="unit process")
480
+ return {"database": manifest.name, "datasets": len(imported.datasets)}
481
+ imported = read_background(bundle, manifest.base.name, foreground=foreground_processes(bundle))
482
+ base = "kept"
483
+ if not _held(data, manifest, imported):
484
+ with tempfile.TemporaryDirectory() as work:
485
+ part = Path(work) / f"{manifest.base.name}.zip"
486
+ write_base_part(bundle, part)
487
+ progress(InstallProgress(step="installing"))
488
+ install(
489
+ data,
490
+ read_background(part, manifest.base.name, foreground=frozenset()),
491
+ part,
492
+ version=manifest.base.version,
493
+ system_model="unit process",
494
+ )
495
+ base = "installed"
496
+ store, storage, resolver = _parts(data, systems)
497
+ try:
498
+ restored = asyncio.run(restore_systems(bundle, storage))
499
+ finally:
500
+ resolver.close()
501
+ store.close()
502
+ return {"base": base, "systems": {str(k): v for k, v in restored.items()}}
503
+
504
+
505
+ def _build(args: argparse.Namespace) -> dict[str, object]:
506
+ store, storage, resolver = _parts(args.data_dir, args.systems_dir)
507
+ try:
508
+ held = store.registered(args.base).database
509
+ base = BaseDatabase(name=held.name, version=held.version, fingerprint=held.fingerprint)
510
+ bundle = store.bundle_of(args.base)
511
+ now = datetime.now(UTC)
512
+ if args.action == "save":
513
+ manifest = asyncio.run(
514
+ build_working(storage, bundle, base, args.name, args.version, args.to, now)
515
+ )
516
+ else:
517
+ manifest = asyncio.run(
518
+ build_release(
519
+ storage, bundle, base, args.name, args.version, args.to, args.previous, now
520
+ )
521
+ )
522
+ finally:
523
+ resolver.close()
524
+ store.close()
525
+ return {
526
+ "bundle": str(args.to),
527
+ "mode": manifest.mode,
528
+ "systems": len(manifest.systems),
529
+ "datasets": len(manifest.datasets),
530
+ }
531
+
532
+
533
+ def _database(args: argparse.Namespace, progress: Reporter) -> dict[str, object]:
534
+ if args.action == "load":
535
+ return _load(args.data_dir, args.systems_dir, args.source, progress)
536
+ return _build(args)
537
+
538
+
539
+ def main(argv: list[str] | None = None) -> int:
540
+ """Run one command; 0 on success, 1 on a typed error, 2 on a usage error or a source whose
541
+ terms are not accepted."""
542
+ parser = _parser()
543
+ if argv is not None and not argv:
544
+ parser.print_usage(sys.stderr)
545
+ print(
546
+ "a subcommand is required: serve, export, import, compute, setup, database, "
547
+ "connect, doctor, init, telemetry",
548
+ file=sys.stderr,
549
+ )
550
+ return 2
551
+ args = parser.parse_args(argv)
552
+ as_json = bool(getattr(args, "as_json", False))
553
+ progress = printer(sys.stderr, time.monotonic())
554
+ try:
555
+ _defaulted(args)
556
+ except EcoSemanticError as exc:
557
+ return _refused(exc, as_json)
558
+ if args.command in ("connect", "doctor", "init", "telemetry"):
559
+ try:
560
+ if args.command == "connect":
561
+ answered = connect(args.client, args.workspace, args.command_path)
562
+ _answered(answered, as_json, connect_text)
563
+ elif args.command == "init":
564
+ answered = _init(args)
565
+ _answered(answered, as_json, init_text)
566
+ elif args.command == "telemetry":
567
+ data = directories(args.data, None)[0]
568
+ if args.choice is not None:
569
+ record(os.environ, data, args.choice == "on")
570
+ _answered(status(os.environ, data), as_json, telemetry_text)
571
+ else:
572
+ data, systems = directories(args.data, args.systems)
573
+ answered = report(data, systems, _databases(data))
574
+ _answered(answered, as_json, doctor_text)
575
+ except EcoSemanticError as exc:
576
+ return _refused(exc, as_json)
577
+ return 0
578
+ if args.command in ("setup", "database"):
579
+ try:
580
+ with contextlib.redirect_stdout(sys.stderr):
581
+ summary = (
582
+ _setup(args, progress) if args.command == "setup" else _database(args, progress)
583
+ )
584
+ if isinstance(summary, InstallTerms):
585
+ if as_json:
586
+ print(json.dumps(_printed(summary)))
587
+ else:
588
+ print(terms_text(summary.licence, summary.citation))
589
+ elif args.command == "setup":
590
+ _answered(summary, as_json, setup_text)
591
+ else:
592
+ _answered(summary, as_json, database_text)
593
+ except EcoSemanticError as exc:
594
+ return _refused(exc, as_json)
595
+ return _code(summary)
596
+ if args.command == "compute":
597
+ try:
598
+ with contextlib.redirect_stdout(sys.stderr):
599
+ answered = asyncio.run(_compute(args, progress))
600
+ _answered(answered, as_json, compute_text)
601
+ except EcoSemanticError as exc:
602
+ return _refused(exc, as_json)
603
+ return 0
604
+ try:
605
+ with contextlib.redirect_stdout(sys.stderr):
606
+ context = _context(args)
607
+ except EcoSemanticError as exc:
608
+ return _refused(exc, as_json)
609
+ try:
610
+ if args.command == "serve":
611
+ serve_offline()
612
+ logging.basicConfig(stream=sys.stderr, level=logging.INFO)
613
+ settings = settings_from(
614
+ os.environ,
615
+ args.systems_dir,
616
+ enabled=False if args.no_view else None,
617
+ host=args.view_host,
618
+ port=args.view_port,
619
+ url=args.view_url,
620
+ transport=args.transport,
621
+ token=args.token,
622
+ opening=False if args.no_open else None,
623
+ )
624
+ provider = begin(os.environ, args.data_dir, settings.transport, sys.stderr)
625
+ with serving(provider):
626
+ asyncio.run(run(context, settings, args.systems_dir, meter_provider=provider))
627
+ elif args.command == "export":
628
+ _export(context, args.id, args.to)
629
+ else:
630
+ _import(context, args.source)
631
+ except EcoSemanticError as exc:
632
+ return _refused(exc, as_json)
633
+ finally:
634
+ context.close()
635
+ return 0