ecosemantic-databases 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/databases/__init__.py +1 -0
- ecosemantic/databases/bafu/__init__.py +1 -0
- ecosemantic/databases/bafu/build.py +44 -0
- ecosemantic/databases/bafu/cli.py +124 -0
- ecosemantic/databases/bafu/differences.py +52 -0
- ecosemantic/databases/bafu/download.py +142 -0
- ecosemantic/databases/bafu/probe.py +168 -0
- ecosemantic/databases/bafu/profile.py +91 -0
- ecosemantic/databases/bafu/releases/2026-v1.yaml +163 -0
- ecosemantic/databases/bafu/releases/differences-2026-v1.csv +12403 -0
- ecosemantic/databases/bafu/releases/unlinked-2026-v1.csv +1 -0
- ecosemantic/databases/bafu/setup.py +146 -0
- ecosemantic/databases/bafu/validate.py +323 -0
- ecosemantic/databases/bafu/workbook.py +186 -0
- ecosemantic/databases/checks.py +27 -0
- ecosemantic/databases/cli.py +33 -0
- ecosemantic/databases/command.py +57 -0
- ecosemantic/databases/ecoinvent/__init__.py +1 -0
- ecosemantic/databases/ecoinvent/build.py +63 -0
- ecosemantic/databases/ecoinvent/cli.py +161 -0
- ecosemantic/databases/ecoinvent/datasets.py +290 -0
- ecosemantic/databases/ecoinvent/download.py +69 -0
- ecosemantic/databases/ecoinvent/lcia.py +83 -0
- ecosemantic/databases/ecoinvent/masterdata.py +119 -0
- ecosemantic/databases/ecoinvent/overview.py +39 -0
- ecosemantic/databases/ecoinvent/probe.py +125 -0
- ecosemantic/databases/ecoinvent/profile.py +139 -0
- ecosemantic/databases/ecoinvent/py.typed +0 -0
- ecosemantic/databases/ecoinvent/release.py +52 -0
- ecosemantic/databases/ecoinvent/releases/3.10-cutoff.yaml +67 -0
- ecosemantic/databases/ecoinvent/releases/3.10.1-cutoff.yaml +69 -0
- ecosemantic/databases/ecoinvent/releases/3.11-cutoff.yaml +67 -0
- ecosemantic/databases/ecoinvent/releases/3.12-cutoff.yaml +71 -0
- ecosemantic/databases/ecoinvent/setup.py +85 -0
- ecosemantic/databases/ecoinvent/validate.py +732 -0
- ecosemantic/databases/lists.py +219 -0
- ecosemantic/databases/profiles.py +43 -0
- ecosemantic/databases/py.typed +0 -0
- ecosemantic/databases/scores.py +136 -0
- ecosemantic/databases/uslci/__init__.py +1 -0
- ecosemantic/databases/uslci/build.py +554 -0
- ecosemantic/databases/uslci/cli.py +124 -0
- ecosemantic/databases/uslci/download.py +92 -0
- ecosemantic/databases/uslci/probe.py +216 -0
- ecosemantic/databases/uslci/profile.py +68 -0
- ecosemantic/databases/uslci/releases/1.2026-09.0.yaml +24 -0
- ecosemantic/databases/uslci/releases/left-out-1.2026-09.0.csv +50 -0
- ecosemantic/databases/uslci/releases/unlinked-1.2026-09.0.csv +3230 -0
- ecosemantic/databases/uslci/setup.py +98 -0
- ecosemantic/databases/uslci/validate.py +292 -0
- ecosemantic/databases/zolca/__init__.py +1 -0
- ecosemantic/databases/zolca/derby/__init__.py +1 -0
- ecosemantic/databases/zolca/derby/catalog.py +272 -0
- ecosemantic/databases/zolca/derby/database.py +95 -0
- ecosemantic/databases/zolca/derby/log.py +116 -0
- ecosemantic/databases/zolca/derby/numbers.py +66 -0
- ecosemantic/databases/zolca/derby/pages.py +269 -0
- ecosemantic/databases/zolca/derby/records.py +210 -0
- ecosemantic/databases/zolca/derby/values.py +201 -0
- ecosemantic/databases/zolca/export/__init__.py +1 -0
- ecosemantic/databases/zolca/export/entities.py +527 -0
- ecosemantic/databases/zolca/export/geometry.py +146 -0
- ecosemantic/databases/zolca/export/refs.py +102 -0
- ecosemantic/databases/zolca/export/render.py +79 -0
- ecosemantic/databases/zolca/export/tables.py +77 -0
- ecosemantic/databases/zolca/export/write.py +174 -0
- ecosemantic_databases-0.1.8.dist-info/METADATA +81 -0
- ecosemantic_databases-0.1.8.dist-info/RECORD +72 -0
- ecosemantic_databases-0.1.8.dist-info/WHEEL +4 -0
- ecosemantic_databases-0.1.8.dist-info/entry_points.txt +2 -0
- ecosemantic_databases-0.1.8.dist-info/licenses/LICENSE +73 -0
- ecosemantic_databases-0.1.8.dist-info/licenses/NOTICE +13 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Native EcoSemantic bundles built from a source database's own files."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Build a native EcoSemantic bundle from BAFU's own files."""
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Build a BAFU zolca into one openLCA JSON-LD bundle, read back by the core's importer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ecosemantic.databases.zolca.export.write import NotCarried, export
|
|
10
|
+
from ecosemantic.errors import Conflict
|
|
11
|
+
from ecosemantic.package.background import read_background
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Built:
|
|
16
|
+
"""What a build wrote: the bundle, the records it holds by folder, and the rows its package does
|
|
17
|
+
not carry."""
|
|
18
|
+
|
|
19
|
+
bundle: Path
|
|
20
|
+
records: dict[str, int]
|
|
21
|
+
not_carried: tuple[NotCarried, ...]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build(zolca: Path, *, version: str, out_dir: Path) -> Built:
|
|
25
|
+
"""Write a zolca as `<out_dir>/bafu-<version>.zip` and import it under `bafu-<version>`. The
|
|
26
|
+
package is exported to a staging file beside the destination and moved into place only once the
|
|
27
|
+
core reads it, so a bundle the core refuses leaves nothing under the final name; a destination
|
|
28
|
+
that exists is `Conflict`."""
|
|
29
|
+
destination = out_dir / f"bafu-{version}.zip"
|
|
30
|
+
staging = out_dir / f".bafu-{version}.staging.zip"
|
|
31
|
+
if destination.exists():
|
|
32
|
+
raise Conflict(f"{destination.name} exists; name another destination")
|
|
33
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
written = export(zolca, staging)
|
|
35
|
+
try:
|
|
36
|
+
read_background(written.bundle, f"bafu-{version}", foreground=frozenset())
|
|
37
|
+
os.replace(staging, destination)
|
|
38
|
+
finally:
|
|
39
|
+
staging.unlink(missing_ok=True)
|
|
40
|
+
return Built(
|
|
41
|
+
bundle=destination,
|
|
42
|
+
records=written.records,
|
|
43
|
+
not_carried=written.not_carried,
|
|
44
|
+
)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""The command line: the subcommands a BAFU source answers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import cast
|
|
8
|
+
|
|
9
|
+
from lca_data_interface import NexusSession
|
|
10
|
+
|
|
11
|
+
from ecosemantic.databases import command
|
|
12
|
+
from ecosemantic.databases.bafu.build import Built, build
|
|
13
|
+
from ecosemantic.databases.bafu.download import Downloads, Terms, download, translating
|
|
14
|
+
from ecosemantic.databases.bafu.probe import Probe, probe
|
|
15
|
+
from ecosemantic.databases.bafu.profile import RELEASES, read_profile, recorded
|
|
16
|
+
from ecosemantic.databases.bafu.validate import Counted, record, validate
|
|
17
|
+
from ecosemantic.databases.command import Command
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _download(args: argparse.Namespace) -> dict[str, object] | Terms:
|
|
21
|
+
"""Fetch a release's packs through Nexus and answer each file's path and the hash the profile
|
|
22
|
+
pins; while the terms are not accepted, answer the licence file and the citation instead."""
|
|
23
|
+
profile = read_profile(args.version)
|
|
24
|
+
with translating(), NexusSession.from_environment(base=profile.server) as session:
|
|
25
|
+
fetched = download(profile, session, accept_terms=args.accept_terms, results=args.results)
|
|
26
|
+
if isinstance(fetched, Terms):
|
|
27
|
+
return fetched
|
|
28
|
+
return {
|
|
29
|
+
"inventory": {"path": str(fetched.inventory), "sha256": profile.inventory.sha256},
|
|
30
|
+
"results": (
|
|
31
|
+
None
|
|
32
|
+
if fetched.results is None
|
|
33
|
+
else {"path": str(fetched.results), "sha256": profile.results.sha256}
|
|
34
|
+
),
|
|
35
|
+
"licence": str(fetched.licence),
|
|
36
|
+
"citation": fetched.citation,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _probe(args: argparse.Namespace) -> Probe:
|
|
41
|
+
"""Read a zolca and answer what it holds and what the core would not take of it."""
|
|
42
|
+
return probe(Path(args.zolca))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fetched(version: str) -> Path:
|
|
46
|
+
"""The zolca a release's profile pins, fetched through Nexus with the pack's terms accepted, so
|
|
47
|
+
the answer is always the downloaded file."""
|
|
48
|
+
profile = read_profile(version)
|
|
49
|
+
with translating(), NexusSession.from_environment(base=profile.server) as session:
|
|
50
|
+
fetched = cast(Downloads, download(profile, session, accept_terms=True, results=False))
|
|
51
|
+
return fetched.inventory
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _build(args: argparse.Namespace) -> Built:
|
|
55
|
+
"""Build the zolca given, or the one the release's profile pins, into one bundle under
|
|
56
|
+
`--out`."""
|
|
57
|
+
zolca = Path(args.zolca) if args.zolca is not None else _fetched(args.version)
|
|
58
|
+
return build(zolca, version=args.version, out_dir=Path(args.out))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _validate(args: argparse.Namespace) -> Counted:
|
|
62
|
+
"""Compare a bundle with its release's workbook and the release's two lists, record the run
|
|
63
|
+
when asked, and answer the differences counted by column."""
|
|
64
|
+
profile = read_profile(args.version)
|
|
65
|
+
validation = validate(
|
|
66
|
+
Path(args.bundle),
|
|
67
|
+
results=Path(args.results),
|
|
68
|
+
profile=profile,
|
|
69
|
+
unlinked=recorded(profile.unlinked),
|
|
70
|
+
differences=recorded(profile.differences),
|
|
71
|
+
compiled=Path(args.compiled) if args.compiled is not None else None,
|
|
72
|
+
)
|
|
73
|
+
if args.record:
|
|
74
|
+
record(profile, validation, directory=RELEASES)
|
|
75
|
+
return validation.counted()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _code(answer: object) -> int:
|
|
79
|
+
"""2 for the answer that asks for the pack's terms to be accepted, 1 for a validation that
|
|
80
|
+
found a mismatch, and 0 for an answer that stands."""
|
|
81
|
+
if isinstance(answer, Terms):
|
|
82
|
+
return 2
|
|
83
|
+
return 1 if isinstance(answer, Counted) and bool(answer.mismatches) else 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
COMMANDS: dict[str, Command] = {
|
|
87
|
+
"download": Command("Fetch a release's pinned packs from Nexus.", _download),
|
|
88
|
+
"probe": Command("Read a zolca and list what it holds.", _probe),
|
|
89
|
+
"build": Command("Write a release's bundle and import it.", _build),
|
|
90
|
+
"validate": Command("Compare a bundle with its release's workbook.", _validate),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parser() -> argparse.ArgumentParser:
|
|
95
|
+
parser = argparse.ArgumentParser(
|
|
96
|
+
prog="ecosemantic-databases bafu",
|
|
97
|
+
description="Build a native EcoSemantic bundle from a BAFU release.",
|
|
98
|
+
)
|
|
99
|
+
subcommands = parser.add_subparsers(dest="command", required=True)
|
|
100
|
+
for name, each in COMMANDS.items():
|
|
101
|
+
sub = subcommands.add_parser(name, help=each.help)
|
|
102
|
+
if name == "download":
|
|
103
|
+
sub.add_argument("version")
|
|
104
|
+
sub.add_argument("--accept-terms", action="store_true")
|
|
105
|
+
sub.add_argument("--results", action="store_true")
|
|
106
|
+
elif name == "probe":
|
|
107
|
+
sub.add_argument("zolca")
|
|
108
|
+
elif name == "build":
|
|
109
|
+
sub.add_argument("version")
|
|
110
|
+
sub.add_argument("--out", required=True)
|
|
111
|
+
sub.add_argument("--zolca")
|
|
112
|
+
elif name == "validate":
|
|
113
|
+
sub.add_argument("version")
|
|
114
|
+
sub.add_argument("--bundle", required=True)
|
|
115
|
+
sub.add_argument("--results", required=True)
|
|
116
|
+
sub.add_argument("--compiled")
|
|
117
|
+
sub.add_argument("--record", action="store_true")
|
|
118
|
+
return parser
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main(argv: list[str] | None = None) -> int:
|
|
122
|
+
"""Run one command; 0 on success, 1 on a typed error or a validation that found a mismatch,
|
|
123
|
+
and 2 when the terms are not accepted."""
|
|
124
|
+
return command.main(_parser(), COMMANDS, argv, code=_code)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The (process, column) pairs a BAFU validation knows differ beyond its tolerance, keyed by the
|
|
2
|
+
process's id and the workbook column's header so neither BAFU's names nor its values are written
|
|
3
|
+
into the package."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import csv
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ecosemantic.errors import Invalid
|
|
13
|
+
|
|
14
|
+
COLUMNS = ("process_id", "column")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Difference:
|
|
19
|
+
"""One pair whose two scores differ beyond the tolerance."""
|
|
20
|
+
|
|
21
|
+
process_id: str
|
|
22
|
+
column: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _order(row: Difference) -> tuple[str, str]:
|
|
26
|
+
"""A row's sort position: its process, then its column."""
|
|
27
|
+
return (row.process_id, row.column)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def read_differences(path: Path) -> tuple[Difference, ...]:
|
|
31
|
+
"""The pairs a list holds, in process and column order; a pair the list holds twice is
|
|
32
|
+
`Invalid`."""
|
|
33
|
+
with path.open(newline="", encoding="utf-8") as read:
|
|
34
|
+
rows = [
|
|
35
|
+
Difference(process_id=record["process_id"], column=record["column"])
|
|
36
|
+
for record in csv.DictReader(read)
|
|
37
|
+
]
|
|
38
|
+
seen: set[tuple[str, str]] = set()
|
|
39
|
+
for row in rows:
|
|
40
|
+
if _order(row) in seen:
|
|
41
|
+
raise Invalid(f"{path} holds {row.process_id} under {row.column!r} twice")
|
|
42
|
+
seen.add(_order(row))
|
|
43
|
+
return tuple(sorted(rows, key=_order))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def write_differences(path: Path, rows: Iterable[Difference]) -> None:
|
|
47
|
+
"""Write the pairs as CSV, in process and column order."""
|
|
48
|
+
with path.open("w", newline="", encoding="utf-8") as written:
|
|
49
|
+
writer = csv.writer(written, lineterminator="\n")
|
|
50
|
+
writer.writerow(COLUMNS)
|
|
51
|
+
for row in sorted(rows, key=_order):
|
|
52
|
+
writer.writerow((row.process_id, row.column))
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Fetch BAFU's openLCA pack and its LCIA results pack from Nexus, refusing a file whose hash is not
|
|
2
|
+
the one its profile pins."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Final, Protocol
|
|
11
|
+
|
|
12
|
+
from lca_data_interface import Downloaded
|
|
13
|
+
from lca_data_interface import Pack as NexusPack
|
|
14
|
+
from lca_data_interface.errors import (
|
|
15
|
+
CredentialsMissing,
|
|
16
|
+
LcaDataInterfaceError,
|
|
17
|
+
LicenceNotAccepted,
|
|
18
|
+
LoginRefused,
|
|
19
|
+
MissingField,
|
|
20
|
+
NotInCatalog,
|
|
21
|
+
NotOnServer,
|
|
22
|
+
ServiceError,
|
|
23
|
+
ShortDownload,
|
|
24
|
+
TokenRequired,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from ecosemantic.databases.bafu.profile import Pack, Profile
|
|
28
|
+
from ecosemantic.errors import Conflict, EcoSemanticError, Invalid, NotFound, Unavailable
|
|
29
|
+
|
|
30
|
+
_TRANSLATIONS: Final[dict[type[LcaDataInterfaceError], type[EcoSemanticError]]] = {
|
|
31
|
+
CredentialsMissing: Unavailable,
|
|
32
|
+
LoginRefused: Unavailable,
|
|
33
|
+
TokenRequired: Unavailable,
|
|
34
|
+
ShortDownload: Unavailable,
|
|
35
|
+
ServiceError: Unavailable,
|
|
36
|
+
NotInCatalog: NotFound,
|
|
37
|
+
NotOnServer: NotFound,
|
|
38
|
+
MissingField: Invalid,
|
|
39
|
+
LicenceNotAccepted: Invalid,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Nexus(Protocol):
|
|
44
|
+
"""The three calls a download makes on a Nexus session; `NexusSession` satisfies it and a test
|
|
45
|
+
doubles it."""
|
|
46
|
+
|
|
47
|
+
def pack(self, uuid: str) -> NexusPack: ...
|
|
48
|
+
|
|
49
|
+
def licence(self, pack: NexusPack) -> Path: ...
|
|
50
|
+
|
|
51
|
+
def download(
|
|
52
|
+
self,
|
|
53
|
+
pack: NexusPack,
|
|
54
|
+
*,
|
|
55
|
+
version: int,
|
|
56
|
+
file_format: str,
|
|
57
|
+
accept_licence: bool,
|
|
58
|
+
) -> Downloaded: ...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Terms:
|
|
63
|
+
"""What a download answers while a pack's terms are not accepted: the licence file to read and
|
|
64
|
+
the citation to carry."""
|
|
65
|
+
|
|
66
|
+
licence: Path
|
|
67
|
+
citation: str
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Downloads:
|
|
72
|
+
"""The files a download fetched, the pack's licence file, and the release's citation."""
|
|
73
|
+
|
|
74
|
+
inventory: Path
|
|
75
|
+
results: Path | None
|
|
76
|
+
licence: Path
|
|
77
|
+
citation: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@contextmanager
|
|
81
|
+
def translating() -> Iterator[None]:
|
|
82
|
+
"""A client call whose errors leave as the core's, of the kind and with the names the spec's
|
|
83
|
+
*Errors* table gives them."""
|
|
84
|
+
try:
|
|
85
|
+
yield
|
|
86
|
+
except LcaDataInterfaceError as error:
|
|
87
|
+
raise _translated(error) from error
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def download(
|
|
91
|
+
profile: Profile, session: Nexus, *, accept_terms: bool, results: bool
|
|
92
|
+
) -> Downloads | Terms:
|
|
93
|
+
"""Fetch a release's packs through Nexus, each file's SHA-256 compared with the profile's.
|
|
94
|
+
|
|
95
|
+
Without `accept_terms` the pack's licence file is fetched and nothing is downloaded: the answer
|
|
96
|
+
is the file a person reads to accept the terms. `results` also fetches the LCIA results pack,
|
|
97
|
+
which only a validation reads.
|
|
98
|
+
"""
|
|
99
|
+
with translating():
|
|
100
|
+
inventory_pack = session.pack(profile.inventory.pack)
|
|
101
|
+
licence = session.licence(inventory_pack)
|
|
102
|
+
if not accept_terms:
|
|
103
|
+
return Terms(licence=licence, citation=profile.citation)
|
|
104
|
+
inventory = session.download(
|
|
105
|
+
inventory_pack,
|
|
106
|
+
version=profile.inventory.version,
|
|
107
|
+
file_format=profile.inventory.format,
|
|
108
|
+
accept_licence=True,
|
|
109
|
+
)
|
|
110
|
+
results_file = _fetched(profile.results, session) if results else None
|
|
111
|
+
return Downloads(
|
|
112
|
+
inventory=_checked(profile.inventory, inventory),
|
|
113
|
+
results=None if results_file is None else _checked(profile.results, results_file),
|
|
114
|
+
licence=licence,
|
|
115
|
+
citation=profile.citation,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _fetched(pinned: Pack, session: Nexus) -> Downloaded:
|
|
120
|
+
"""One pack's file at the version and the format the profile pins, its terms accepted."""
|
|
121
|
+
return session.download(
|
|
122
|
+
session.pack(pinned.pack),
|
|
123
|
+
version=pinned.version,
|
|
124
|
+
file_format=pinned.format,
|
|
125
|
+
accept_licence=True,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _checked(pinned: Pack, downloaded: Downloaded) -> Path:
|
|
130
|
+
"""The path of a file whose SHA-256 is the one the profile pins; a different hash is a conflict
|
|
131
|
+
naming the file and both hashes."""
|
|
132
|
+
if downloaded.sha256 != pinned.sha256:
|
|
133
|
+
raise Conflict(
|
|
134
|
+
f"{downloaded.path.name} hashes to {downloaded.sha256}, the profile pins "
|
|
135
|
+
f"{pinned.sha256}"
|
|
136
|
+
)
|
|
137
|
+
return downloaded.path
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _translated(error: LcaDataInterfaceError) -> EcoSemanticError:
|
|
141
|
+
"""The core's error for one the client raised."""
|
|
142
|
+
return _TRANSLATIONS[type(error)](str(error))
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""What a BAFU zolca holds and what of it the core's reader takes: its schema version, its tables,
|
|
2
|
+
the counts its export becomes, the inputs that stay unlinked, the exchanges the core refuses and
|
|
3
|
+
the rows its package does not carry."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import tempfile
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from collections.abc import Iterable, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import olca_schema as o
|
|
14
|
+
from olca_schema.zipio import ZipReader
|
|
15
|
+
|
|
16
|
+
from ecosemantic.databases.lists import distinct_unlinked
|
|
17
|
+
from ecosemantic.databases.zolca.derby.database import Database
|
|
18
|
+
from ecosemantic.databases.zolca.export.write import NotCarried, export
|
|
19
|
+
from ecosemantic.errors import Invalid, NotSupported
|
|
20
|
+
from ecosemantic.model import units
|
|
21
|
+
from ecosemantic.package.background import Import, exchange_refusal, read_background
|
|
22
|
+
from ecosemantic.package.olca import uncertainty_refusal
|
|
23
|
+
|
|
24
|
+
BUNDLE = "probe.zip"
|
|
25
|
+
DATABASE = "bafu-probe"
|
|
26
|
+
VERSION_TABLE = "OPENLCA_VERSION"
|
|
27
|
+
VERSION_COLUMN = "VERSION"
|
|
28
|
+
PREFIX = "ecosemantic-bafu-probe-"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class TableProbe:
|
|
33
|
+
"""One table of the database: its rows, and the SQL type each column is stored as."""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
rows: int
|
|
37
|
+
columns: dict[str, str]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Probe:
|
|
42
|
+
"""What a zolca holds and what the core would take of it: its schema version, every table with
|
|
43
|
+
its rows and column types, what its export becomes in the core, the unlinked inputs by their
|
|
44
|
+
flow's top category, the exchanges the core refuses by its own reason, the rows the package
|
|
45
|
+
does not carry, and the factors method categories set apart."""
|
|
46
|
+
|
|
47
|
+
schema_version: str
|
|
48
|
+
tables: tuple[TableProbe, ...]
|
|
49
|
+
processes: int
|
|
50
|
+
flows: int
|
|
51
|
+
methods: int
|
|
52
|
+
categories: int
|
|
53
|
+
unlinked: dict[str, int]
|
|
54
|
+
refused: dict[str, int]
|
|
55
|
+
not_carried: tuple[NotCarried, ...]
|
|
56
|
+
set_apart: int
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _schema_version(database: Database) -> str:
|
|
60
|
+
"""The openLCA schema version the database stores."""
|
|
61
|
+
stored = list(database.rows(VERSION_TABLE))
|
|
62
|
+
if len(stored) != 1:
|
|
63
|
+
raise Invalid(f"{database.path} stores {len(stored)} {VERSION_TABLE} rows, not one")
|
|
64
|
+
return str(stored[0][VERSION_COLUMN])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _tables(database: Database) -> tuple[TableProbe, ...]:
|
|
68
|
+
"""Every table of schema `APP`, its rows counted and each column's SQL type named."""
|
|
69
|
+
return tuple(
|
|
70
|
+
TableProbe(
|
|
71
|
+
name=name,
|
|
72
|
+
rows=sum(1 for _ in database.rows(table)),
|
|
73
|
+
columns={column.name: column.type.name for column in table.columns},
|
|
74
|
+
)
|
|
75
|
+
for name, table in sorted(database.tables().items())
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _read(bundle: Path) -> tuple[tuple[o.Process, ...], dict[str, o.Flow]]:
|
|
80
|
+
"""A package's processes, and its flows by id."""
|
|
81
|
+
with ZipReader(str(bundle)) as reader:
|
|
82
|
+
processes = tuple(reader.read_each(o.Process))
|
|
83
|
+
flows = {flow.id: flow for flow in reader.read_each(o.Flow)}
|
|
84
|
+
return processes, flows
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _factors(bundle: Path) -> list[o.ImpactFactor]:
|
|
88
|
+
"""Every impact factor the package's categories carry."""
|
|
89
|
+
with ZipReader(str(bundle)) as reader:
|
|
90
|
+
return [
|
|
91
|
+
factor
|
|
92
|
+
for category in reader.read_each(o.ImpactCategory)
|
|
93
|
+
for factor in category.impact_factors or []
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _symbols(records: Iterable[o.Exchange | o.ImpactFactor]) -> set[str]:
|
|
98
|
+
"""The unit symbols a run of exchanges or impact factors names."""
|
|
99
|
+
return {
|
|
100
|
+
record.unit.name
|
|
101
|
+
for record in records
|
|
102
|
+
if record.unit is not None and record.unit.name is not None
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _known(symbol: str) -> bool:
|
|
107
|
+
"""Whether the core's unit table holds a symbol."""
|
|
108
|
+
try:
|
|
109
|
+
units.by_symbol(symbol)
|
|
110
|
+
except NotSupported:
|
|
111
|
+
return False
|
|
112
|
+
return True
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _lacking(bundle: Path, processes: Sequence[o.Process]) -> tuple[str, ...]:
|
|
116
|
+
"""Every unit symbol the package's exchanges and impact factors name that the core's table
|
|
117
|
+
lacks, in one sorted tuple."""
|
|
118
|
+
named = _symbols(
|
|
119
|
+
exchange for process in processes for exchange in process.exchanges or []
|
|
120
|
+
) | _symbols(_factors(bundle))
|
|
121
|
+
return tuple(sorted(symbol for symbol in named if not _known(symbol)))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _unlinked(imported: Import, flows: dict[str, o.Flow]) -> dict[str, int]:
|
|
125
|
+
"""The import's distinct unlinked inputs, counted by the top category of their flow."""
|
|
126
|
+
tops = {flow_id: (flow.category or "").split("/")[0] for flow_id, flow in flows.items()}
|
|
127
|
+
return dict(Counter(row.flow_category for row in distinct_unlinked(imported, tops)))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _refused(processes: Sequence[o.Process], flows: dict[str, o.Flow]) -> Counter[str]:
|
|
131
|
+
"""What the core's two refusals answer over the package's exchanges, counted by reason."""
|
|
132
|
+
return Counter(
|
|
133
|
+
reason
|
|
134
|
+
for process in processes
|
|
135
|
+
for exchange in process.exchanges or []
|
|
136
|
+
if exchange.flow is not None
|
|
137
|
+
and exchange.flow.id in flows
|
|
138
|
+
and (
|
|
139
|
+
reason := uncertainty_refusal(exchange.uncertainty, exchange.amount or 0.0)
|
|
140
|
+
or exchange_refusal(exchange, flows[exchange.flow.id])
|
|
141
|
+
)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def probe(zolca: Path) -> Probe:
|
|
146
|
+
"""Read a zolca through the export and the core's importer in a temporary directory, and answer
|
|
147
|
+
what it holds, what the core refuses of it and what its package does not carry; a unit symbol
|
|
148
|
+
the core's table lacks is `NotSupported`, every lacking symbol named at once."""
|
|
149
|
+
database = Database(zolca)
|
|
150
|
+
with tempfile.TemporaryDirectory(prefix=PREFIX) as directory:
|
|
151
|
+
written = export(zolca, Path(directory) / BUNDLE)
|
|
152
|
+
processes, flows = _read(written.bundle)
|
|
153
|
+
lacking = _lacking(written.bundle, processes)
|
|
154
|
+
if lacking:
|
|
155
|
+
raise NotSupported(f"{zolca}: the core's unit table lacks {', '.join(lacking)}")
|
|
156
|
+
imported = read_background(written.bundle, DATABASE, foreground=frozenset())
|
|
157
|
+
return Probe(
|
|
158
|
+
schema_version=_schema_version(database),
|
|
159
|
+
tables=_tables(database),
|
|
160
|
+
processes=imported.processes,
|
|
161
|
+
flows=len(imported.flows),
|
|
162
|
+
methods=len({method.key[1] for method in imported.methods}),
|
|
163
|
+
categories=len(imported.methods),
|
|
164
|
+
unlinked=_unlinked(imported, flows),
|
|
165
|
+
refused=dict(_refused(processes, flows)),
|
|
166
|
+
not_carried=written.not_carried,
|
|
167
|
+
set_apart=sum(method.set_apart for method in imported.methods),
|
|
168
|
+
)
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""The facts a BAFU release pins: the two packs Nexus serves, the workbook's columns mapped to the
|
|
2
|
+
method and category each scores, its total column, the lists its validation holds it to, and what a
|
|
3
|
+
validated run recorded."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
|
|
11
|
+
from ecosemantic.databases import profiles
|
|
12
|
+
|
|
13
|
+
RELEASES = Path(__file__).parent / "releases"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Pack(BaseModel):
|
|
17
|
+
"""One file a release is read from: the datafile Nexus serves, its version, its format, and the
|
|
18
|
+
SHA-256 of the bytes the profile pins."""
|
|
19
|
+
|
|
20
|
+
model_config = ConfigDict(frozen=True)
|
|
21
|
+
|
|
22
|
+
pack: str
|
|
23
|
+
version: int
|
|
24
|
+
format: str
|
|
25
|
+
sha256: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Column(BaseModel):
|
|
29
|
+
"""One workbook column that scores a category, and the method and category it scores."""
|
|
30
|
+
|
|
31
|
+
model_config = ConfigDict(frozen=True)
|
|
32
|
+
|
|
33
|
+
header: str
|
|
34
|
+
method: str
|
|
35
|
+
category: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Total(BaseModel):
|
|
39
|
+
"""The workbook column that sums one method's categories."""
|
|
40
|
+
|
|
41
|
+
model_config = ConfigDict(frozen=True)
|
|
42
|
+
|
|
43
|
+
header: str
|
|
44
|
+
method: str
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Validated(BaseModel):
|
|
48
|
+
"""What a validated run measured: the processes and columns it compared, how many known
|
|
49
|
+
differences it holds, and the largest relative difference inside the tolerance."""
|
|
50
|
+
|
|
51
|
+
model_config = ConfigDict(frozen=True)
|
|
52
|
+
|
|
53
|
+
processes: int
|
|
54
|
+
columns: int
|
|
55
|
+
differences: int
|
|
56
|
+
largest_rel_diff_within_tolerance: float
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Profile(BaseModel):
|
|
60
|
+
"""One BAFU release's pinned facts: its inventory and results packs, the workbook's columns and
|
|
61
|
+
total, the file names of its two lists, and the tolerance and counts a validated run
|
|
62
|
+
recorded."""
|
|
63
|
+
|
|
64
|
+
model_config = ConfigDict(frozen=True)
|
|
65
|
+
|
|
66
|
+
version: str
|
|
67
|
+
server: str
|
|
68
|
+
citation: str
|
|
69
|
+
inventory: Pack
|
|
70
|
+
results: Pack
|
|
71
|
+
columns: tuple[Column, ...]
|
|
72
|
+
total: Total
|
|
73
|
+
unlinked: str
|
|
74
|
+
differences: str
|
|
75
|
+
tolerance: float
|
|
76
|
+
validated: Validated | None = None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def versions() -> tuple[str, ...]:
|
|
80
|
+
"""The release labels the profiles directory carries, newest first."""
|
|
81
|
+
return profiles.versions(RELEASES)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def recorded(name: str) -> Path | None:
|
|
85
|
+
"""The list file a profile names, or nothing when the release has not recorded it yet."""
|
|
86
|
+
return profiles.recorded(RELEASES, name)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def read_profile(version: str) -> Profile:
|
|
90
|
+
"""The profile named for a release label; a label without one is not found."""
|
|
91
|
+
return profiles.read_profile(Profile, RELEASES, version)
|