taco-eo 0.3.0__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.
- taco/__init__.py +40 -0
- taco/_graph.py +129 -0
- taco/_parquet.py +15 -0
- taco/_publish.py +97 -0
- taco/_repr.py +303 -0
- taco/_source.py +52 -0
- taco/_view.py +148 -0
- taco/contract/__init__.py +23 -0
- taco/contract/collection.py +410 -0
- taco/contract/contract.py +650 -0
- taco/contract/naming.py +215 -0
- taco/contract/sample.py +151 -0
- taco/contract/structure.py +130 -0
- taco/contract/types.py +279 -0
- taco/cozip.py +9 -0
- taco/dataset.py +88 -0
- taco/errors.py +37 -0
- taco/metadata/__init__.py +3 -0
- taco/metadata/_base.py +68 -0
- taco/metadata/asset.py +59 -0
- taco/metadata/collection.py +70 -0
- taco/metadata/derived.py +244 -0
- taco/metadata/folder.py +15 -0
- taco/metadata/sample.py +5 -0
- taco/metadata/spatiotemporal.py +207 -0
- taco/metadata/split.py +12 -0
- taco/py.typed +0 -0
- taco/reader/__init__.py +194 -0
- taco/reader/engine.py +67 -0
- taco/schema.py +287 -0
- taco/tacocat.py +157 -0
- taco/validate.py +517 -0
- taco/writer/__init__.py +73 -0
- taco/writer/archive.py +367 -0
- taco/writer/core.py +186 -0
- taco/writer/folder.py +211 -0
- taco/writer/metadata_tables.py +233 -0
- taco/writer/progress.py +46 -0
- taco/writer/staging.py +44 -0
- taco_eo-0.3.0.dist-info/METADATA +75 -0
- taco_eo-0.3.0.dist-info/RECORD +43 -0
- taco_eo-0.3.0.dist-info/WHEEL +4 -0
- taco_eo-0.3.0.dist-info/licenses/LICENSE +21 -0
taco/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from . import metadata, reader
|
|
6
|
+
from .contract import Asset, Collection, Contract, Folder, Sample
|
|
7
|
+
from .dataset import Dataset, open_dataset, read
|
|
8
|
+
from .errors import TacoError
|
|
9
|
+
from .schema import CollectionMetadata, DerivedMetadata, Level, Metadata, MetadataSchema
|
|
10
|
+
from .tacocat import consolidate
|
|
11
|
+
from .validate import validate
|
|
12
|
+
from .writer import open_writer
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
__version__ = version("taco-eo")
|
|
16
|
+
except PackageNotFoundError: # pragma: no cover - source checkout
|
|
17
|
+
__version__ = "0.0.0+unknown"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Asset",
|
|
21
|
+
"Collection",
|
|
22
|
+
"CollectionMetadata",
|
|
23
|
+
"Contract",
|
|
24
|
+
"Dataset",
|
|
25
|
+
"DerivedMetadata",
|
|
26
|
+
"Folder",
|
|
27
|
+
"Level",
|
|
28
|
+
"Metadata",
|
|
29
|
+
"MetadataSchema",
|
|
30
|
+
"Sample",
|
|
31
|
+
"TacoError",
|
|
32
|
+
"__version__",
|
|
33
|
+
"consolidate",
|
|
34
|
+
"metadata",
|
|
35
|
+
"open_dataset",
|
|
36
|
+
"open_writer",
|
|
37
|
+
"read",
|
|
38
|
+
"reader",
|
|
39
|
+
"validate",
|
|
40
|
+
]
|
taco/_graph.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from html import escape
|
|
6
|
+
from pathlib import PurePosixPath
|
|
7
|
+
|
|
8
|
+
_NODE_HEIGHT = 40
|
|
9
|
+
_LEVEL_GAP = 70
|
|
10
|
+
_SIBLING_GAP = 18
|
|
11
|
+
_PADDING = 16
|
|
12
|
+
_MIN_WIDTH = 360
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class _Node:
|
|
17
|
+
label: str
|
|
18
|
+
kind: str
|
|
19
|
+
children: list[_Node] = field(default_factory=list)
|
|
20
|
+
width: int = 0
|
|
21
|
+
span: int = 0
|
|
22
|
+
x: float = 0
|
|
23
|
+
y: float = 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def structure_graph(structure: tuple[str, ...] | None) -> str:
|
|
27
|
+
root = _tree(structure)
|
|
28
|
+
_measure(root)
|
|
29
|
+
width = max(_MIN_WIDTH, root.span + 2 * _PADDING)
|
|
30
|
+
_place(root, (width - root.span) / 2, 0)
|
|
31
|
+
height = _height(root)
|
|
32
|
+
edges = "".join(_edges(root))
|
|
33
|
+
nodes = "".join(_nodes(root))
|
|
34
|
+
return (
|
|
35
|
+
'<div class="taco-structure-graph">'
|
|
36
|
+
f'<svg viewBox="0 0 {width} {height}" width="{width}" height="{height}" '
|
|
37
|
+
'role="img" aria-label="Sample structure">'
|
|
38
|
+
f"{edges}{nodes}</svg></div>"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _tree(structure: tuple[str, ...] | None) -> _Node:
|
|
43
|
+
if structure is None:
|
|
44
|
+
return _Node("sample file", "file")
|
|
45
|
+
|
|
46
|
+
root = _Node("sample", "sample")
|
|
47
|
+
for declaration in structure:
|
|
48
|
+
parent = root
|
|
49
|
+
parts = PurePosixPath(declaration).parts
|
|
50
|
+
for index, part in enumerate(parts):
|
|
51
|
+
last = index == len(parts) - 1
|
|
52
|
+
kind = "variable" if last and "*" in part else "file" if last else "folder"
|
|
53
|
+
child = next((item for item in parent.children if item.label == part), None)
|
|
54
|
+
if child is None:
|
|
55
|
+
child = _Node(part, kind)
|
|
56
|
+
parent.children.append(child)
|
|
57
|
+
parent = child
|
|
58
|
+
return root
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _shown(label: str) -> str:
|
|
62
|
+
return label if len(label) <= 28 else label[:27] + "…"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _label(node: _Node) -> str:
|
|
66
|
+
return node.label + "/" if node.kind == "folder" else node.label
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _measure(node: _Node) -> int:
|
|
70
|
+
node.width = max(84, min(224, len(_shown(_label(node))) * 7 + 28))
|
|
71
|
+
if not node.children:
|
|
72
|
+
node.span = node.width
|
|
73
|
+
return node.span
|
|
74
|
+
children = sum(_measure(child) for child in node.children)
|
|
75
|
+
children += _SIBLING_GAP * (len(node.children) - 1)
|
|
76
|
+
node.span = max(node.width, children)
|
|
77
|
+
return node.span
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _place(node: _Node, left: float, depth: int) -> None:
|
|
81
|
+
node.x = left + node.span / 2
|
|
82
|
+
node.y = _PADDING + depth * (_NODE_HEIGHT + _LEVEL_GAP)
|
|
83
|
+
if not node.children:
|
|
84
|
+
return
|
|
85
|
+
children = sum(child.span for child in node.children)
|
|
86
|
+
children += _SIBLING_GAP * (len(node.children) - 1)
|
|
87
|
+
cursor = left + (node.span - children) / 2
|
|
88
|
+
for child in node.children:
|
|
89
|
+
_place(child, cursor, depth + 1)
|
|
90
|
+
cursor += child.span + _SIBLING_GAP
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _height(root: _Node) -> int:
|
|
94
|
+
def depth(node: _Node) -> int:
|
|
95
|
+
return 0 if not node.children else 1 + max(depth(child) for child in node.children)
|
|
96
|
+
|
|
97
|
+
return 2 * _PADDING + _NODE_HEIGHT + depth(root) * (_NODE_HEIGHT + _LEVEL_GAP)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _edges(node: _Node) -> Iterator[str]:
|
|
101
|
+
for child in node.children:
|
|
102
|
+
middle = (node.y + _NODE_HEIGHT + child.y) / 2
|
|
103
|
+
yield (
|
|
104
|
+
f'<path class="taco-graph-edge" d="M {node.x:g} {node.y + _NODE_HEIGHT:g} '
|
|
105
|
+
f'V {middle:g} H {child.x:g} V {child.y:g}"/>'
|
|
106
|
+
)
|
|
107
|
+
yield from _edges(child)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _nodes(node: _Node) -> Iterator[str]:
|
|
111
|
+
left = node.x - node.width / 2
|
|
112
|
+
display = _label(node)
|
|
113
|
+
label = escape(_shown(display))
|
|
114
|
+
full_label = escape(display)
|
|
115
|
+
kind = "root" if node.kind == "sample" else node.kind
|
|
116
|
+
yield (
|
|
117
|
+
f'<g class="taco-graph-node taco-graph-{node.kind}">'
|
|
118
|
+
f"<title>{full_label} ({kind})</title>"
|
|
119
|
+
f'<rect x="{left:g}" y="{node.y:g}" width="{node.width}" height="{_NODE_HEIGHT}" rx="7"/>'
|
|
120
|
+
f'<text class="taco-graph-label" x="{node.x:g}" y="{node.y + 17:g}" '
|
|
121
|
+
f'text-anchor="middle">{label}</text>'
|
|
122
|
+
f'<text class="taco-graph-kind" x="{node.x:g}" y="{node.y + 31:g}" '
|
|
123
|
+
f'text-anchor="middle">{kind}</text></g>'
|
|
124
|
+
)
|
|
125
|
+
for child in node.children:
|
|
126
|
+
yield from _nodes(child)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
__all__ = ["structure_graph"]
|
taco/_parquet.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
DEFAULT_PARQUET_OPTIONS: dict[str, Any] = {
|
|
7
|
+
"compression": "zstd",
|
|
8
|
+
"write_statistics": True,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parquet_writer_options(options: Mapping[str, Any] | None) -> dict[str, Any]:
|
|
13
|
+
if options and "row_group_size" in options:
|
|
14
|
+
raise ValueError("pass row_group_size as a writer argument, not in parquet_options")
|
|
15
|
+
return DEFAULT_PARQUET_OPTIONS | dict(options or {})
|
taco/_publish.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _remove(path: Path) -> None:
|
|
11
|
+
if path.is_symlink() or not path.is_dir():
|
|
12
|
+
path.unlink(missing_ok=True)
|
|
13
|
+
else:
|
|
14
|
+
shutil.rmtree(path)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _move_without_replacing(source: Path, target: Path) -> None:
|
|
18
|
+
if source.is_dir() or os.name == "nt":
|
|
19
|
+
if target.exists() or target.is_symlink():
|
|
20
|
+
raise FileExistsError(target)
|
|
21
|
+
source.rename(target)
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
target.hardlink_to(source)
|
|
25
|
+
source.unlink()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def publish_file(source: Path, target: Path, *, overwrite: bool) -> None:
|
|
29
|
+
if overwrite:
|
|
30
|
+
if source.is_dir() and (target.exists() or target.is_symlink()):
|
|
31
|
+
_remove(target)
|
|
32
|
+
source.replace(target)
|
|
33
|
+
return
|
|
34
|
+
try:
|
|
35
|
+
_move_without_replacing(source, target)
|
|
36
|
+
except FileExistsError as exc:
|
|
37
|
+
raise FileExistsError(f"output already exists (set overwrite=True): {target}") from exc
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def publish_many(replacements: Sequence[tuple[Path, Path]], *, overwrite: bool) -> None:
|
|
41
|
+
if not replacements:
|
|
42
|
+
return
|
|
43
|
+
if len({target for _, target in replacements}) != len(replacements):
|
|
44
|
+
raise ValueError("a publication target appears more than once")
|
|
45
|
+
if not overwrite:
|
|
46
|
+
existing = next(
|
|
47
|
+
(target for _, target in replacements if target.exists() or target.is_symlink()),
|
|
48
|
+
None,
|
|
49
|
+
)
|
|
50
|
+
if existing is not None:
|
|
51
|
+
raise FileExistsError(f"output already exists (set overwrite=True): {existing}")
|
|
52
|
+
|
|
53
|
+
parent = replacements[0][1].parent
|
|
54
|
+
parent.mkdir(parents=True, exist_ok=True)
|
|
55
|
+
backup = Path(tempfile.mkdtemp(prefix=".taco-backup-", dir=parent))
|
|
56
|
+
(backup / "new").mkdir()
|
|
57
|
+
previous: list[tuple[Path, Path]] = []
|
|
58
|
+
installed: list[Path] = []
|
|
59
|
+
try:
|
|
60
|
+
if overwrite:
|
|
61
|
+
for index, (_, target) in enumerate(replacements):
|
|
62
|
+
if target.exists() or target.is_symlink():
|
|
63
|
+
saved = backup / f"old-{index}"
|
|
64
|
+
target.replace(saved)
|
|
65
|
+
previous.append((saved, target))
|
|
66
|
+
for source, target in replacements:
|
|
67
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
_move_without_replacing(source, target)
|
|
69
|
+
installed.append(target)
|
|
70
|
+
except BaseException as exc:
|
|
71
|
+
failures = _restore(previous, installed, backup)
|
|
72
|
+
if failures:
|
|
73
|
+
details = "; ".join(failures)
|
|
74
|
+
raise RuntimeError(f"publication failed and could not be restored: {details}; backup: {backup}") from exc
|
|
75
|
+
shutil.rmtree(backup, ignore_errors=True)
|
|
76
|
+
raise
|
|
77
|
+
shutil.rmtree(backup, ignore_errors=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _restore(previous: list[tuple[Path, Path]], installed: list[Path], backup: Path) -> list[str]:
|
|
81
|
+
move_failures: dict[Path, str] = {}
|
|
82
|
+
restore_failures: list[str] = []
|
|
83
|
+
discarded = backup / "new"
|
|
84
|
+
for index, target in reversed(list(enumerate(installed))):
|
|
85
|
+
if not target.exists() and not target.is_symlink():
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
target.replace(discarded / str(index))
|
|
89
|
+
except OSError as exc:
|
|
90
|
+
move_failures[target] = f"could not move {target}: {exc}"
|
|
91
|
+
for saved, target in reversed(previous):
|
|
92
|
+
try:
|
|
93
|
+
saved.replace(target)
|
|
94
|
+
move_failures.pop(target, None)
|
|
95
|
+
except OSError as exc:
|
|
96
|
+
restore_failures.append(f"could not restore {target}: {exc}")
|
|
97
|
+
return [*move_failures.values(), *restore_failures]
|
taco/_repr.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import itertools
|
|
4
|
+
import json
|
|
5
|
+
from html import escape
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from ._graph import structure_graph
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .dataset import Dataset
|
|
13
|
+
from .schema import Field
|
|
14
|
+
|
|
15
|
+
_counter = itertools.count()
|
|
16
|
+
|
|
17
|
+
_CSS = """
|
|
18
|
+
#ID{font:13px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
19
|
+
color:inherit;display:block;max-width:760px}
|
|
20
|
+
#ID *{box-sizing:border-box}
|
|
21
|
+
#ID .taco-frame{border:1px solid rgba(128,128,128,.28);border-radius:9px;
|
|
22
|
+
background:rgba(128,128,128,.035)}
|
|
23
|
+
#ID .taco-head{display:grid;grid-template-columns:minmax(0,1fr) 132px;
|
|
24
|
+
gap:12px;align-items:center;padding:14px 16px 12px}
|
|
25
|
+
#ID .taco-class{opacity:.58}
|
|
26
|
+
#ID .taco-title{font-size:15px;font-weight:700;margin-left:7px}
|
|
27
|
+
#ID .taco-description{font-family:ui-sans-serif,system-ui,sans-serif;
|
|
28
|
+
opacity:.7;margin-top:5px;max-width:560px}
|
|
29
|
+
#ID .taco-facts{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
|
|
30
|
+
#ID .taco-fact{border:1px solid rgba(128,128,128,.24);border-radius:999px;
|
|
31
|
+
padding:1px 7px;font-size:11px;white-space:nowrap}
|
|
32
|
+
#ID .taco-store{width:126px;height:104px;justify-self:end}
|
|
33
|
+
#ID details{border-top:1px solid rgba(128,128,128,.22)}
|
|
34
|
+
#ID summary{display:flex;align-items:center;gap:7px;padding:7px 13px;
|
|
35
|
+
cursor:pointer;list-style:none;user-select:none}
|
|
36
|
+
#ID summary::-webkit-details-marker{display:none}
|
|
37
|
+
#ID summary:before{content:'>';display:inline-block;font-size:13px;line-height:1;
|
|
38
|
+
opacity:.55;transition:transform .12s ease}
|
|
39
|
+
#ID details[open]>summary:before{transform:rotate(90deg)}
|
|
40
|
+
#ID details:not([open])>.taco-content{display:none}
|
|
41
|
+
#ID .taco-section-name{font-weight:700}
|
|
42
|
+
#ID .taco-count{opacity:.5}
|
|
43
|
+
#ID .taco-content{padding:2px 16px 11px 35px}
|
|
44
|
+
#ID .taco-row{display:grid;grid-template-columns:115px minmax(0,1fr);gap:12px;
|
|
45
|
+
padding:2px 0}
|
|
46
|
+
#ID .taco-key{opacity:.52}
|
|
47
|
+
#ID .taco-value{min-width:0;overflow-wrap:anywhere}
|
|
48
|
+
#ID .taco-path{display:flex;gap:8px;align-items:flex-start;padding:2px 0}
|
|
49
|
+
#ID .taco-path:before{content:'●';color:#ef9f27;font-size:8px;margin-top:5px}
|
|
50
|
+
#ID .taco-structure-graph{overflow-x:auto;padding:4px 0 6px}
|
|
51
|
+
#ID .taco-structure-graph svg{display:block;margin:0 auto;max-width:none}
|
|
52
|
+
#ID .taco-graph-edge{fill:none;stroke:currentColor;stroke-opacity:.26;stroke-width:1.25}
|
|
53
|
+
#ID .taco-graph-node rect{stroke-width:1.1}
|
|
54
|
+
#ID .taco-graph-sample rect{fill:rgba(245,158,11,.16);stroke:#d97706}
|
|
55
|
+
#ID .taco-graph-folder rect{fill:rgba(59,130,246,.12);stroke:#3b82f6}
|
|
56
|
+
#ID .taco-graph-file rect{fill:rgba(128,128,128,.06);stroke:currentColor;stroke-opacity:.3}
|
|
57
|
+
#ID .taco-graph-variable rect{fill:rgba(139,92,246,.12);stroke:#8b5cf6;stroke-dasharray:4 3}
|
|
58
|
+
#ID .taco-graph-label{fill:currentColor;font-size:11px;font-weight:700}
|
|
59
|
+
#ID .taco-graph-kind{fill:currentColor;font-size:8px;opacity:.48;letter-spacing:.08em}
|
|
60
|
+
#ID .taco-level{display:grid;grid-template-columns:145px minmax(0,1fr);
|
|
61
|
+
gap:9px;padding:4px 0}
|
|
62
|
+
#ID .taco-level-name{font-weight:700;overflow-wrap:anywhere}
|
|
63
|
+
#ID .taco-fields{display:flex;gap:4px;flex-wrap:wrap}
|
|
64
|
+
#ID .taco-field{position:relative;background:rgba(59,130,246,.1);
|
|
65
|
+
border:1px solid rgba(59,130,246,.2);border-radius:4px;padding:0 5px;cursor:help}
|
|
66
|
+
#ID .taco-field-info{position:absolute;z-index:20;visibility:hidden;opacity:0;
|
|
67
|
+
top:calc(100% + 7px);left:50%;width:max-content;min-width:210px;max-width:320px;
|
|
68
|
+
padding:8px 10px;border:1px solid #374151;border-radius:6px;background:#111827;color:#f9fafb;
|
|
69
|
+
box-shadow:0 5px 18px rgba(0,0,0,.35);transform:translate(-50%,-3px);
|
|
70
|
+
transition:opacity .1s ease,transform .1s ease;pointer-events:none;font-weight:400}
|
|
71
|
+
#ID .taco-field:hover>.taco-field-info{
|
|
72
|
+
visibility:visible;opacity:1;transform:translate(-50%,0)}
|
|
73
|
+
#ID .taco-field-property{display:grid;grid-template-columns:58px minmax(0,1fr);gap:8px}
|
|
74
|
+
#ID .taco-field-property>span{opacity:.62}
|
|
75
|
+
#ID .taco-field-description{display:block;margin-top:6px;padding-top:6px;
|
|
76
|
+
border-top:1px solid rgba(255,255,255,.18);font-family:ui-sans-serif,system-ui,sans-serif;
|
|
77
|
+
line-height:1.35;white-space:normal}
|
|
78
|
+
#ID .taco-derived{background:rgba(139,92,246,.12);border-color:rgba(139,92,246,.25)}
|
|
79
|
+
#ID .taco-empty{opacity:.48;font-style:italic}
|
|
80
|
+
@media(max-width:560px){
|
|
81
|
+
#ID .taco-head{grid-template-columns:1fr}
|
|
82
|
+
#ID .taco-store{display:none}
|
|
83
|
+
#ID .taco-row,#ID .taco-level{grid-template-columns:1fr;gap:1px}
|
|
84
|
+
#ID .taco-content{padding-left:20px}
|
|
85
|
+
}
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def dataset_html(dataset: Dataset) -> str:
|
|
90
|
+
uid = f"taco-dataset-{next(_counter)}"
|
|
91
|
+
css = _CSS.replace("#ID", f"#{uid}")
|
|
92
|
+
collection = dataset.collection
|
|
93
|
+
title = escape(collection.title or collection.id)
|
|
94
|
+
description = escape(collection.description)
|
|
95
|
+
kind = _kind(dataset)
|
|
96
|
+
facts = _facts(dataset, kind)
|
|
97
|
+
return (
|
|
98
|
+
f'<div id="{uid}" class="taco-dataset-repr"><style>{css}</style>'
|
|
99
|
+
'<div class="taco-frame">'
|
|
100
|
+
'<div class="taco-head"><div>'
|
|
101
|
+
f'<div><span class="taco-class">taco.Dataset</span><span class="taco-title">{title}</span></div>'
|
|
102
|
+
f'<div class="taco-description">{description}</div>'
|
|
103
|
+
f'<div class="taco-facts">{facts}</div>'
|
|
104
|
+
"</div>"
|
|
105
|
+
f'<div class="taco-store">{_storage(kind, _source_count(dataset), uid)}</div>'
|
|
106
|
+
"</div>"
|
|
107
|
+
f"{_section('Structure', _structure_count(dataset), _structure(dataset), open_=True)}"
|
|
108
|
+
f"{_section('Metadata', _metadata_count(dataset), _metadata(dataset))}"
|
|
109
|
+
f"{_section('Collection', collection.id, _collection(dataset))}"
|
|
110
|
+
f"{_section('Sources', _source_label(dataset), _sources(dataset), open_=len(dataset.sources) > 1)}"
|
|
111
|
+
"</div></div>"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _facts(dataset: Dataset, kind: str) -> str:
|
|
116
|
+
contract = dataset.contract
|
|
117
|
+
fields = sum(len(level) for level in contract.metadata.values())
|
|
118
|
+
shape = "single file" if contract.structure is None else f"{len(contract.structure)} leaves"
|
|
119
|
+
values = [kind, shape, f"{len(contract.levels)} levels"]
|
|
120
|
+
if fields:
|
|
121
|
+
values.append(f"{fields} fields")
|
|
122
|
+
samples = _sample_count(dataset)
|
|
123
|
+
if samples is not None:
|
|
124
|
+
values.insert(1, f"{samples:,} samples")
|
|
125
|
+
return "".join(f'<span class="taco-fact">{escape(value)}</span>' for value in values)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _section(name: str, count: str, content: str, *, open_: bool = False) -> str:
|
|
129
|
+
opened = " open" if open_ else ""
|
|
130
|
+
return (
|
|
131
|
+
f'<details{opened}><summary><span class="taco-section-name">{escape(name)}</span>'
|
|
132
|
+
f'<span class="taco-count">{escape(count)}</span></summary>'
|
|
133
|
+
f'<div class="taco-content">{content}</div></details>'
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _kind(dataset: Dataset) -> str:
|
|
138
|
+
if len(dataset.sources) > 1:
|
|
139
|
+
return "PARTITIONS"
|
|
140
|
+
if dataset.collection.sources is not None:
|
|
141
|
+
return "TACOCAT"
|
|
142
|
+
path = dataset.sources[0]
|
|
143
|
+
if isinstance(path, Path):
|
|
144
|
+
return "FOLDER" if path.is_dir() else "ZIP"
|
|
145
|
+
lowered = path.rstrip("/").lower()
|
|
146
|
+
if lowered.endswith(".zip"):
|
|
147
|
+
return "ZIP"
|
|
148
|
+
if lowered.endswith(".tacocat"):
|
|
149
|
+
return "TACOCAT"
|
|
150
|
+
return "FOLDER"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _source_count(dataset: Dataset) -> int:
|
|
154
|
+
sources = dataset.collection.sources
|
|
155
|
+
if sources is not None:
|
|
156
|
+
partitions = sources.get("partitions")
|
|
157
|
+
if isinstance(partitions, list):
|
|
158
|
+
return len(partitions)
|
|
159
|
+
return len(dataset.sources)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _sample_count(dataset: Dataset) -> int | None:
|
|
163
|
+
sources = dataset.collection.sources
|
|
164
|
+
if sources is None:
|
|
165
|
+
return None
|
|
166
|
+
samples = sources.get("samples")
|
|
167
|
+
return samples if isinstance(samples, int) and not isinstance(samples, bool) else None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _source_label(dataset: Dataset) -> str:
|
|
171
|
+
count = _source_count(dataset)
|
|
172
|
+
if dataset.collection.sources is not None:
|
|
173
|
+
return f"{count} partition{'s' if count != 1 else ''}"
|
|
174
|
+
return f"{count} source{'s' if count != 1 else ''}"
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _structure_count(dataset: Dataset) -> str:
|
|
178
|
+
structure = dataset.contract.structure
|
|
179
|
+
if structure is None:
|
|
180
|
+
return "single file"
|
|
181
|
+
return f"{len(structure)} leaves"
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _metadata_count(dataset: Dataset) -> str:
|
|
185
|
+
fields = sum(len(level) for level in dataset.contract.metadata.values())
|
|
186
|
+
return f"{fields} fields"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _structure(dataset: Dataset) -> str:
|
|
190
|
+
return structure_graph(dataset.contract.structure)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _metadata(dataset: Dataset) -> str:
|
|
194
|
+
rows = []
|
|
195
|
+
for level, fields in dataset.contract.metadata.items():
|
|
196
|
+
produced = {field for group in dataset.contract.derived.get(level, {}).values() for field in group["produces"]}
|
|
197
|
+
chips = "".join(_metadata_field(name, field, derived=name in produced) for name, field in fields.items())
|
|
198
|
+
content = chips or '<span class="taco-empty">no fields</span>'
|
|
199
|
+
rows.append(
|
|
200
|
+
f'<div class="taco-level"><div class="taco-level-name">{escape(level)}</div>'
|
|
201
|
+
f'<div class="taco-fields">{content}</div></div>'
|
|
202
|
+
)
|
|
203
|
+
return "".join(rows)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _metadata_field(name: str, field: Field, *, derived: bool) -> str:
|
|
207
|
+
classes = "taco-field taco-derived" if derived else "taco-field"
|
|
208
|
+
description = field.description or "No description"
|
|
209
|
+
nullable = "true" if field.nullable else "false"
|
|
210
|
+
return (
|
|
211
|
+
f'<span class="{classes}">{escape(name)}'
|
|
212
|
+
'<span class="taco-field-info" role="tooltip">'
|
|
213
|
+
f'<span class="taco-field-property"><span>type</span><code>{escape(field.type)}</code></span>'
|
|
214
|
+
f'<span class="taco-field-property"><span>nullable</span><code>{nullable}</code></span>'
|
|
215
|
+
f'<span class="taco-field-description">{escape(description)}</span>'
|
|
216
|
+
"</span></span>"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _collection(dataset: Dataset) -> str:
|
|
221
|
+
collection = dataset.collection
|
|
222
|
+
rows: list[tuple[str, Any]] = [
|
|
223
|
+
("id", collection.id),
|
|
224
|
+
("version", collection.dataset_version),
|
|
225
|
+
("tasks", list(collection.tasks)),
|
|
226
|
+
("licenses", list(collection.licenses)),
|
|
227
|
+
("providers", [provider.name for provider in collection.providers]),
|
|
228
|
+
]
|
|
229
|
+
if collection.extent is not None:
|
|
230
|
+
rows.append(("extent", collection.extent.to_dict()))
|
|
231
|
+
if collection.metadata is not None:
|
|
232
|
+
rows.append(("metadata", sorted(collection.metadata.flatten())))
|
|
233
|
+
return "".join(
|
|
234
|
+
f'<div class="taco-row"><div class="taco-key">{escape(name)}</div>'
|
|
235
|
+
f'<div class="taco-value">{escape(_short(value))}</div></div>'
|
|
236
|
+
for name, value in rows
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _sources(dataset: Dataset) -> str:
|
|
241
|
+
rows = [str(path) for path in dataset.sources]
|
|
242
|
+
sources = dataset.collection.sources
|
|
243
|
+
if sources is not None and isinstance(sources.get("partitions"), list):
|
|
244
|
+
rows.extend(
|
|
245
|
+
str(partition["file"])
|
|
246
|
+
for partition in sources["partitions"]
|
|
247
|
+
if isinstance(partition, dict) and "file" in partition
|
|
248
|
+
)
|
|
249
|
+
shown = rows[:8]
|
|
250
|
+
content = "".join(f'<div class="taco-path"><code>{escape(path)}</code></div>' for path in shown)
|
|
251
|
+
if len(rows) > len(shown):
|
|
252
|
+
content += f'<div class="taco-empty">and {len(rows) - len(shown)} more</div>'
|
|
253
|
+
return content
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _short(value: Any) -> str:
|
|
257
|
+
text = json.dumps(value, ensure_ascii=False, separators=(",", ":")) if not isinstance(value, str) else value
|
|
258
|
+
return text if len(text) <= 140 else text[:137] + "..."
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _storage(kind: str, count: int, uid: str) -> str:
|
|
262
|
+
if kind == "FOLDER":
|
|
263
|
+
return _folder()
|
|
264
|
+
return _cylinder(kind, count, uid)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _folder() -> str:
|
|
268
|
+
return (
|
|
269
|
+
'<svg viewBox="0 0 160 125" width="100%" role="img" aria-label="TACO folder storage">'
|
|
270
|
+
'<path d="M19 38c0-6 5-11 11-11h36l12 14h52c6 0 11 5 11 11v49c0 7-5 12-12 12H31c-7 0-12-5-12-12z" '
|
|
271
|
+
'fill="#FAC775" stroke="#854F0B" stroke-width="1.4"/>'
|
|
272
|
+
'<path d="M20 50h120" fill="none" stroke="#854F0B" stroke-width="1.2" opacity=".55"/>'
|
|
273
|
+
'<text x="80" y="83" text-anchor="middle" fill="#633806" font-size="12" font-weight="700">FOLDER</text>'
|
|
274
|
+
"</svg>"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _cylinder(kind: str, count: int, uid: str) -> str:
|
|
279
|
+
label = escape(kind)
|
|
280
|
+
gradient = f"{uid}-body"
|
|
281
|
+
badge = (
|
|
282
|
+
f'<g><circle cx="130" cy="22" r="15" fill="#633806"/>'
|
|
283
|
+
f'<text x="130" y="26" text-anchor="middle" fill="#fff" font-size="11">x{count}</text></g>'
|
|
284
|
+
if count > 1
|
|
285
|
+
else ""
|
|
286
|
+
)
|
|
287
|
+
return (
|
|
288
|
+
'<svg viewBox="0 0 160 125" width="100%" role="img" '
|
|
289
|
+
f'aria-label="TACO {label.lower()} storage">'
|
|
290
|
+
f'<defs><linearGradient id="{gradient}" x1="0" x2="1">'
|
|
291
|
+
'<stop offset="0" stop-color="#FAEEDA"/><stop offset=".55" stop-color="#FAC775"/>'
|
|
292
|
+
'<stop offset="1" stop-color="#EF9F27"/></linearGradient></defs>'
|
|
293
|
+
f'<path d="M25 28v66c0 11 25 20 55 20s55-9 55-20V28" fill="url(#{gradient})" '
|
|
294
|
+
'stroke="#854F0B" stroke-width="1.4"/>'
|
|
295
|
+
'<ellipse cx="80" cy="28" rx="55" ry="19" fill="#FAEEDA" stroke="#854F0B" stroke-width="1.4"/>'
|
|
296
|
+
'<path d="M25 61c0 11 25 20 55 20s55-9 55-20M25 85c0 11 25 20 55 20s55-9 55-20" '
|
|
297
|
+
'fill="none" stroke="#854F0B" stroke-width=".7" opacity=".45"/>'
|
|
298
|
+
f'<text x="80" y="67" text-anchor="middle" fill="#633806" font-size="12" font-weight="700">{label}</text>'
|
|
299
|
+
f"{badge}</svg>"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
__all__ = ["dataset_html"]
|
taco/_source.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sequence
|
|
4
|
+
from os import PathLike, fspath
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TypeAlias
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
|
|
9
|
+
Location: TypeAlias = str | Path
|
|
10
|
+
PathInput: TypeAlias = str | PathLike[str]
|
|
11
|
+
Source: TypeAlias = PathInput | Sequence[PathInput]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize(source: Source) -> tuple[Location, ...]:
|
|
15
|
+
items: tuple[PathInput, ...]
|
|
16
|
+
if isinstance(source, (str, PathLike)):
|
|
17
|
+
items = (source,)
|
|
18
|
+
elif isinstance(source, Sequence):
|
|
19
|
+
items = tuple(source)
|
|
20
|
+
else:
|
|
21
|
+
raise TypeError("source must be a path or a sequence of paths")
|
|
22
|
+
if not items:
|
|
23
|
+
raise ValueError("source must contain at least one path")
|
|
24
|
+
|
|
25
|
+
paths = []
|
|
26
|
+
for item in items:
|
|
27
|
+
value = fspath(item)
|
|
28
|
+
if not isinstance(value, str):
|
|
29
|
+
raise TypeError("source paths must resolve to strings")
|
|
30
|
+
if not value:
|
|
31
|
+
raise ValueError("source paths must not be empty")
|
|
32
|
+
paths.append(value if "://" in value else Path(value).expanduser().resolve())
|
|
33
|
+
|
|
34
|
+
if len(paths) != len(set(paths)):
|
|
35
|
+
raise ValueError("source paths must be unique")
|
|
36
|
+
return tuple(paths)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def labels(paths: tuple[Location, ...]) -> tuple[str, ...]:
|
|
40
|
+
names = tuple(_name(path) for path in paths)
|
|
41
|
+
if len(names) == len(set(names)) and all(names):
|
|
42
|
+
return names
|
|
43
|
+
return tuple(str(path) for path in paths)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _name(path: Location) -> str:
|
|
47
|
+
if isinstance(path, Path):
|
|
48
|
+
return path.name
|
|
49
|
+
return urlsplit(path).path.rstrip("/").rsplit("/", 1)[-1]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = ["Location", "PathInput", "Source", "labels", "normalize"]
|