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.
- ecosemantic/__init__.py +59 -0
- ecosemantic/cli/__init__.py +635 -0
- ecosemantic/cli/accounts.py +26 -0
- ecosemantic/cli/clients.py +141 -0
- ecosemantic/cli/doctor.py +86 -0
- ecosemantic/cli/progress.py +29 -0
- ecosemantic/cli/py.typed +0 -0
- ecosemantic/cli/text.py +219 -0
- ecosemantic/console/__init__.py +13 -0
- ecosemantic/console/py.typed +0 -0
- ecosemantic/installs/__init__.py +2 -0
- ecosemantic/installs/handover.py +30 -0
- ecosemantic/installs/sources.py +192 -0
- ecosemantic/installs/supervisor.py +87 -0
- ecosemantic/installs/worker.py +146 -0
- ecosemantic/local/__init__.py +112 -0
- ecosemantic/local/py.typed +0 -0
- ecosemantic/runs/__init__.py +1 -0
- ecosemantic/runs/py.typed +0 -0
- ecosemantic/runs/supervisor.py +98 -0
- ecosemantic/runs/worker.py +117 -0
- ecosemantic/serve/__init__.py +445 -0
- ecosemantic/serve/py.typed +0 -0
- ecosemantic/telemetry/__init__.py +292 -0
- ecosemantic/workspace/__init__.py +750 -0
- ecosemantic/workspace/py.typed +0 -0
- ecosemantic-0.1.8.dist-info/METADATA +75 -0
- ecosemantic-0.1.8.dist-info/RECORD +32 -0
- ecosemantic-0.1.8.dist-info/WHEEL +4 -0
- ecosemantic-0.1.8.dist-info/entry_points.txt +2 -0
- ecosemantic-0.1.8.dist-info/licenses/LICENSE +73 -0
- ecosemantic-0.1.8.dist-info/licenses/NOTICE +13 -0
ecosemantic/__init__.py
ADDED
|
@@ -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
|