File size: 20,375 Bytes
a36238b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
"""External artifact references, and the per-scheme handlers that resolve and
fetch them without staging any bytes at log time.

Each supported scheme is a `ReferenceHandler`: `matches` claims a URI, `resolve`
probes it for size + a checksum (expanding a directory or object prefix into one
entry per object), and `fetch` downloads a single object. `_select` returns the
first handler whose `matches` is true, raising for a scheme no handler claims.
The built-in handlers cover `file://`, `http(s)://`, and `hf://` β€” all served
by trackio's existing dependencies. Any other scheme (`s3://`, `gs://`, Azure
blob URLs, ...) is supported by registering a custom `ReferenceHandler` via
`register_reference_handler`; the artifacts documentation includes complete,
copy-pasteable handlers for the major cloud object stores.

Object keys are carried through URIs percent-encoded (object-store keys may
contain spaces, which `validate_reference_uri` rejects): `resolve` encodes the
keys it discovers when synthesizing per-object ref URIs, and `fetch` decodes the
key back before reading the source, so a manifest ref always round-trips.

This module also defines the dependency-light reference primitives β€”
`validate_reference_uri`, `local_path_from_file_uri`, `default_reference_name`,
the `looks_signed` credential heuristic, and the `is_reference_entry` manifest
discriminator β€” shared by the artifact, storage, server, and CLI layers.
"""

import importlib
import shutil
from abc import ABC, abstractmethod
from pathlib import Path
from typing import NamedTuple
from urllib.parse import parse_qsl, quote, unquote, urlsplit
from urllib.request import url2pathname

import httpx

from trackio import cas
from trackio.typehints import ETag, Sha256Digest

DEFAULT_MAX_OBJECTS = 10_000


def validate_reference_uri(uri: str) -> tuple[str, str]:
    """Validate an external reference URI and return `(scheme, uri)`.

    The URI must be a non-empty string carrying a scheme and free of NUL and
    whitespace (spaces and other special characters must be percent-encoded).
    The scheme is not restricted here β€” this validates only the URI's shape;
    whether a scheme can actually be resolved and fetched is decided by the
    registered `ReferenceHandler`s. The URI is returned unchanged. Use
    `local_path_from_file_uri` to resolve a `file://` URI to a local
    filesystem path.
    """
    if not isinstance(uri, str) or not uri:
        raise ValueError(f"Invalid reference URI: {uri!r} (must be non-empty)")
    if "\x00" in uri:
        raise ValueError(f"Invalid reference URI {uri!r}: NUL is not allowed")
    if any(char.isspace() for char in uri):
        raise ValueError(
            f"Invalid reference URI {uri!r}: whitespace is not allowed "
            "(percent-encode spaces and special characters)"
        )
    scheme = urlsplit(uri).scheme.lower()
    if not scheme:
        raise ValueError(
            f"Invalid reference URI {uri!r}: a scheme is required "
            "(e.g. file://, s3://, https://); use add_file for local paths."
        )
    return scheme, uri


def local_path_from_file_uri(uri: str) -> Path:
    """Best-effort resolution of a `file://` URI to a local `Path`.

    The returned path is not guaranteed to exist; callers should check
    `.is_file()` before reading it.
    """
    parts = urlsplit(uri)
    local = url2pathname(parts.path)
    host = parts.netloc
    if host and host.lower() != "localhost":
        return Path(f"//{host}{local}")
    return Path(local)


def default_reference_name(uri: str) -> str:
    """Derive a default logical name for a reference from its URI: the last
    non-empty path segment, falling back to the host."""
    parts = urlsplit(uri)
    tail = parts.path.rstrip("/").rsplit("/", 1)[-1]
    return tail or parts.netloc


def is_reference_entry(entry: dict) -> bool:
    """True if a manifest entry is an external reference (carries a `ref` URI)
    rather than a blob staged into content-addressed storage."""
    return "ref" in entry


_SIGNED_QUERY_KEYS = frozenset(
    {"x-amz-signature", "x-goog-signature", "sig", "token", "signature"}
)


def looks_signed(uri: str) -> bool:
    """Heuristic: True when a URI's query string appears to embed a credential β€”
    a presigned S3/GCS URL (`X-Amz-Signature`, `X-Goog-Signature`), a CloudFront
    or S3 v2 `Signature`, an Azure SAS token (`sig`), or a bearer-style `token`
    parameter. Bare `s3://`/`gs://` URIs carry no query and never match."""
    query = urlsplit(uri).query
    if not query:
        return False
    return any(
        key.lower() in _SIGNED_QUERY_KEYS
        for key, _ in parse_qsl(query, keep_blank_values=True)
    )


class ResolvedReference(NamedTuple):
    """One object a reference URI resolves to. `relkey` is None for a single
    object (the caller's `name`/basename is used) or the object's path relative
    to the expanded prefix. `digest` is the object's checksum β€” a content sha256
    for `file://` and LFS `hf://`, or the provider's opaque ETag for HTTP and
    object-store references; None when unavailable (the caller then falls back
    to the URI)."""

    relkey: str | None
    uri: str
    size: int | None = None
    digest: Sha256Digest | ETag | None = None


class ReferenceHandler(ABC):
    """Resolves and fetches references for one family of URI schemes.

    This is the extension point for schemes trackio does not handle natively:
    subclass it and register an instance with `register_reference_handler`.
    Concrete handlers are stateless; the built-ins are instantiated once in
    `_HANDLERS`.
    """

    @abstractmethod
    def matches(self, scheme: str, uri: str) -> bool:
        """Whether this handler owns `uri`. `_select` tries handlers in order and
        takes the first match; handlers registered via
        `register_reference_handler` are consulted before the built-ins, so a
        specialization (e.g. an object store addressed by `https://` URLs) wins
        over the generic HTTP handler."""

    @abstractmethod
    def resolve(
        self, uri: str, checksum: bool, max_objects: int
    ) -> list[ResolvedReference]:
        """Probe `uri` for its size and a checksum, expanding a directory or
        prefix into one entry per object. Returns a single un-checksummed entry
        (`size`/`digest` None) when the source cannot be probed, so the caller
        can fall back to the URI as the digest. `checksum` False skips only the
        per-object checksum: a prefix still expands (one entry per object,
        `digest` None) while a single object stays an un-probed pointer."""

    @abstractmethod
    def fetch(self, uri: str, dest: Path) -> None:
        """Download the single object at `uri` to `dest`."""

    @abstractmethod
    def hint(self) -> str:
        """Actionable hint for enabling content-based versioning when `resolve`
        could not produce a checksum."""


def resolve_reference(
    uri: str, scheme: str, checksum: bool, max_objects: int
) -> list[ResolvedReference]:
    """Resolve `uri` to one or more objects. Never raises for an unreachable
    remote (those resolve un-checksummed); raises for a scheme no registered
    handler claims, a missing local `file://` path, or an expansion exceeding
    `max_objects`."""
    return _select(scheme, uri).resolve(uri, checksum, max_objects)


def fetch_reference(uri: str, scheme: str, dest: Path) -> None:
    """Download the single object at `uri` to `dest`, creating `dest`'s parent
    directories if needed. Raises if no registered handler claims the scheme or
    the source cannot be read."""
    dest.parent.mkdir(parents=True, exist_ok=True)
    _select(scheme, uri).fetch(uri, dest)


def checksum_hint(scheme: str, uri: str) -> str:
    """A short, actionable hint for checksumming a reference trackio could not."""
    return _select(scheme, uri).hint()


class FileHandler(ReferenceHandler):
    """Local files (`file://`).

    A file is stream-hashed to a content sha256 with its size read from disk; a
    directory is expanded into one entry per file (symlinks skipped). Unlike the
    remote handlers, a path that exists as neither a file nor a directory raises
    rather than being recorded β€” a missing local file is almost always a typo."""

    def matches(self, scheme, uri):
        """Owns `file://` URIs."""
        return scheme == "file"

    def resolve(self, uri, checksum, max_objects):
        """Hash a single file, or walk a directory into one entry per file; raise
        if the path is neither."""
        local = local_path_from_file_uri(uri)
        if local.is_dir():
            entries: list[ResolvedReference] = []
            for path in local.rglob("*"):
                if path.is_symlink() or not path.is_file():
                    continue
                digest, size = self._digest_and_size(path, checksum)
                entries.append(
                    ResolvedReference(
                        relkey=path.relative_to(local).as_posix(),
                        uri=path.resolve().as_uri(),
                        size=size,
                        digest=digest,
                    )
                )
                _within_max(len(entries), max_objects, uri)
            return entries
        if local.is_file():
            digest, size = self._digest_and_size(local, checksum)
            return [ResolvedReference(None, uri, size=size, digest=digest)]
        raise ValueError(
            f"Local reference {uri!r} is not an existing file or directory; check "
            "the path, or use add_file to copy a local file into the artifact."
        )

    def fetch(self, uri, dest):
        """Copy the local file's bytes into `dest`."""
        local = local_path_from_file_uri(uri)
        if not local.is_file():
            raise FileNotFoundError(f"Reference source not found locally: {uri}")
        shutil.copyfile(local, dest)

    def hint(self):
        """Shown when a `file://` object was recorded without a content hash."""
        return "Ensure the file exists locally so it can be checksummed by content."

    @staticmethod
    def _digest_and_size(path: Path, checksum: bool) -> tuple[Sha256Digest | None, int]:
        """The file's content sha256 and size when `checksum`, else None and the
        size read from disk. `cas.hash_file` stats the file while hashing, so its
        returned size is reused instead of a second `stat`."""
        if checksum:
            return cas.hash_file(path)
        return None, path.stat().st_size


class HttpHandler(ReferenceHandler):
    """Arbitrary HTTP(S) URLs (`http://`, `https://`).

    Size and a checksum come from the `Content-Length` and `ETag` headers of a
    `HEAD` request; both are optional, so a server that omits them (or is
    unreachable) resolves the reference un-checksummed. A custom handler
    registered via `register_reference_handler` can claim specific `https://`
    hosts (e.g. an object store's blob endpoints) ahead of this one."""

    def matches(self, scheme, uri):
        """Owns `http(s)://` URLs not claimed by a more specific handler."""
        return scheme in ("http", "https")

    def resolve(self, uri, checksum, max_objects):
        """HEAD the URL for `Content-Length` and `ETag`; degrade to an
        un-checksummed entry if the request fails."""
        return _http_resolve(uri, checksum)

    def fetch(self, uri, dest):
        """Stream the URL body into `dest`."""
        _http_fetch(uri, dest)

    def hint(self):
        """Shown when the server exposed no usable ETag."""
        return "Check that the URL is reachable and that its server exposes an ETag header."


class HfHandler(ReferenceHandler):
    """Hugging Face Hub files (`hf://...`), via `huggingface_hub`'s filesystem.

    huggingface_hub is a core dependency, so no extra install is needed. The
    checksum is the LFS sha256 when present, else the git blob id or xet hash; a
    directory is expanded into one entry per file."""

    def matches(self, scheme, uri):
        """Owns `hf://` URIs."""
        return scheme == "hf"

    def resolve(self, uri, checksum, max_objects):
        """Read file info for a single object, or expand a directory into one
        entry per file. Degrades to an un-checksummed pointer when
        huggingface_hub is unavailable or the path cannot be read (a private or
        nonexistent repo)."""
        if not checksum:
            return [ResolvedReference(None, uri)]
        fs = self._fs()
        if fs is None:
            return [ResolvedReference(None, uri)]
        path = self._path(uri)
        try:
            info = fs.info(path)
        except Exception:
            return [ResolvedReference(None, uri)]
        if info.get("type") == "directory":
            prefix = path if path.endswith("/") else f"{path}/"
            try:
                children = sorted(fs.find(path, detail=True).items())
            except Exception:
                return [ResolvedReference(None, uri)]
            entries: list[ResolvedReference] = []
            for child_path, child in children:
                if child.get("type") != "file":
                    continue
                entries.append(
                    ResolvedReference(
                        relkey=_relative_key(child_path, prefix),
                        uri=f"hf://{quote(child_path, safe='/')}",
                        size=child.get("size"),
                        digest=self._checksum(child),
                    )
                )
                _within_max(len(entries), max_objects, uri)
            return entries
        return [
            ResolvedReference(
                None, uri, size=info.get("size"), digest=self._checksum(info)
            )
        ]

    def fetch(self, uri, dest):
        """Download the Hub file into `dest`; raise if huggingface_hub is
        unavailable."""
        fs = self._fs()
        if fs is None:
            raise RuntimeError(
                "Downloading an hf:// reference needs huggingface_hub with fsspec."
            )
        fs.get_file(self._path(uri), str(dest))

    def hint(self):
        """Shown when the Hub file exposed no LFS sha256, blob id, or xet hash."""
        return "Check that the hf:// path exists and is readable (log in for a private repo)."

    def _fs(self):
        """An `HfFileSystem`, or None if huggingface_hub lacks fsspec support."""
        hub = _import("huggingface_hub")
        if hub is None:
            return None
        try:
            return hub.HfFileSystem()
        except AttributeError:
            return None

    @staticmethod
    def _path(uri: str) -> str:
        """Strip the `hf://` scheme and percent-decode the remainder to the
        repo-relative path HfFileSystem uses."""
        raw = uri[len("hf://") :] if uri.startswith("hf://") else uri
        return unquote(raw)

    @staticmethod
    def _checksum(info: dict) -> Sha256Digest | ETag | None:
        """Pick a stable checksum from an HfFileSystem info dict: the LFS sha256
        if present, else the git blob id or xet hash (opaque provider tokens,
        typed like an ETag)."""
        lfs = info.get("lfs")
        if isinstance(lfs, dict) and lfs.get("sha256"):
            return Sha256Digest(lfs["sha256"])
        for key in ("blob_id", "xet_hash"):
            if info.get(key):
                return ETag(str(info[key]))
        return None


def _import(module: str):
    """Import an optional module by name, returning None if it is not installed."""
    try:
        return importlib.import_module(module)
    except ImportError:
        return None


def _http_resolve(uri: str, checksum: bool) -> list[ResolvedReference]:
    """Resolve an HTTP(S) URL to a single entry via a `HEAD` request, taking its
    size from `Content-Length` and checksum from `ETag`. Returns an
    un-checksummed entry when `checksum` is False or the request fails."""
    if not checksum:
        return [ResolvedReference(None, uri)]
    try:
        response = httpx.head(uri, follow_redirects=True, timeout=httpx.Timeout(10.0))
        response.raise_for_status()
    except httpx.HTTPError:
        return [ResolvedReference(None, uri)]
    return [
        ResolvedReference(
            None,
            uri,
            size=_parse_size(response.headers.get("content-length")),
            digest=_etag_digest(response.headers.get("etag")),
        )
    ]


def _http_fetch(uri: str, dest: Path) -> None:
    """Stream an HTTP(S) URL's body into `dest`."""
    with httpx.stream(
        "GET",
        uri,
        follow_redirects=True,
        timeout=httpx.Timeout(connect=10.0, read=300.0, write=10.0, pool=10.0),
    ) as response:
        response.raise_for_status()
        _write_chunks(response.iter_bytes(), dest)


def _etag_digest(raw: str | None) -> ETag | None:
    """Normalize a provider ETag (stripping its surrounding quotes) into a
    reference digest, or None when there is no usable value."""
    cleaned = raw.strip('"') if raw else None
    return ETag(cleaned) if cleaned else None


def _within_max(count: int, max_objects: int, uri: str) -> None:
    """Raise once a prefix/directory expansion exceeds `max_objects` entries."""
    if count > max_objects:
        raise ValueError(
            f"Reference {uri!r} expands to more than {max_objects} objects; "
            "pass max_objects= to add_reference to raise the limit."
        )


def _parse_size(raw: str | None) -> int | None:
    """Parse a `Content-Length`-style header into a non-negative int, or None."""
    if raw is None:
        return None
    try:
        size = int(raw)
    except ValueError:
        return None
    return size if size >= 0 else None


def _relative_key(key: str, prefix: str) -> str:
    """The logical name of an object within an expanded prefix.

    The prefix's *directory* portion β€” everything up to and including its last
    "/" β€” is stripped from `key`, preserving any remaining subdirectory
    structure. A prefix with no "/" (a whole-bucket listing, or a leading
    filename fragment such as `logs` that also matches `logs.txt`) has no
    directory portion, so the key is kept in full. Thus a bucket-root reference
    preserves `train/data.csv`, prefix `logs` maps object `logs.txt` to
    `logs.txt` (never `.txt`), and prefix `data/` maps `data/sub/b.csv` to
    `sub/b.csv`. Falls back to the last path segment for a key that does not lie
    under the prefix's directory."""
    base = prefix[: prefix.rfind("/") + 1]
    if key.startswith(base):
        return key[len(base) :]
    return key.rsplit("/", 1)[-1]


def _write_chunks(chunks, dest: Path) -> None:
    """Write an iterable of byte chunks to `dest`."""
    with dest.open("wb") as f:
        for chunk in chunks:
            if chunk:
                f.write(chunk)


_HANDLERS: list[ReferenceHandler] = [
    FileHandler(),
    HttpHandler(),
    HfHandler(),
]


def register_reference_handler(handler: ReferenceHandler) -> None:
    """Register a custom `ReferenceHandler` for `Artifact.add_reference` URIs.

    The handler is consulted before the built-in `file://`/`http(s)://`/`hf://`
    handlers, so it can own a scheme trackio does not handle natively (`s3://`,
    `gs://`, `dvc://`, ...) or specialize a built-in one (e.g. claim an object
    store's `https://` blob endpoints ahead of the generic HTTP handler). The
    artifacts documentation includes complete handlers for the major cloud
    object stores.
    """
    if not isinstance(handler, ReferenceHandler):
        raise TypeError(
            "register_reference_handler expects a ReferenceHandler instance, "
            f"got {handler!r}."
        )
    _HANDLERS.insert(0, handler)


def _select(scheme: str, uri: str) -> ReferenceHandler:
    """Return the first registered handler that claims `uri`, raising when
    none do."""
    for handler in _HANDLERS:
        if handler.matches(scheme, uri):
            return handler
    raise ValueError(
        f"No reference handler for URI {uri!r} (scheme {scheme!r}). Built-in "
        "handlers cover file://, http(s)://, and hf://; for other schemes, "
        "register a custom ReferenceHandler with "
        "trackio.register_reference_handler() (see the artifacts documentation "
        "for S3/GCS/Azure examples)."
    )