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 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"]