Skip to content

yggdrasil.io.io_stats

io_stats

Unified I/O stats — backend stat quad plus content-level media_type.

Every :class:yggdrasil.io.holder.Holder (concretely :class:Memory and :class:yggdrasil.io.fs.Path) exposes :meth:stats returning an :class:IOStats. It's the single shape downstream code reads when it needs "what kind, how big, how fresh, what is it" without caring whether the backing is a local file, an S3 object, an in-memory buffer, or something else.

The size / mtime / kind / mode quad mirrors :class:os.stat_result so existing callers can keep using the familiar st_size / st_mtime / st_mode aliases or the positional tuple shape. media_type extends the picture with the single most useful piece for content-level dispatch — codec selection, format inference, content-negotiation.

IOKind

Bases: IntEnum

What a backend reports a path/holder entry is.

IOStats dataclass

IOStats(
    size: int = 0,
    mtime: float = 0.0,
    kind: IOKind = IOKind.MISSING,
    mode: int = 0,
    media_type: "Optional[MediaType]" = None,
    metadata: "Optional[dict[str, str]]" = None,
    field: "Optional[Field]" = None,
)

Stat-like quad (size / mtime / kind / mode) + media_type.

All fields are best-effort:

  • size — visible byte count. 0 is a legitimate value for a freshly-created empty holder or a directory.
  • mtime — last modification time as a Unix timestamp. 0.0 when the backing has no meaningful mtime (memory holders, newly-minted spills) — callers that need "now" should use :attr:mtime_or_now.
  • kind — :class:IOKind enum classifying the entry. Defaults to :attr:IOKind.MISSING so an "empty" stats object reads as "nothing here".
  • mode — POSIX permission bits, 0 when the backend has no meaningful concept of mode (S3, Databricks REST).
  • media_type — :class:MediaType inferred from the holder's identity (URL extension, registered mime, sniffed magic bytes). None when no honest answer is available; never guess :class:MimeTypes.OCTET_STREAM here — let the caller decide.
  • metadata — free-form backend metadata as a flat dict[str, str] (S3 response + x-amz-meta-* headers, Databricks Files headers, ETag, version id, …). None when the backend exposes nothing extra; a single home for "everything else the backend told us" without subclassing :class:IOStats.

Backends with richer metadata (ETag, content-type, owner…) populate :attr:metadata rather than cramming extras into mode.

mtime_or_now property

mtime_or_now: float

mtime if set, else :func:time.time — for callers that always want a non-zero timestamp without sprinkling fallbacks.

has_mtime property

has_mtime: bool

Whether the backing reported a real modification time.

False for memory holders, freshly-minted spills, and any backend that doesn't expose mtime — the sentinel 0.0 means "unknown", not "epoch".

copy

copy(
    *,
    size: Any = ...,
    mtime: Any = ...,
    kind: Any = ...,
    mode: Any = ...,
    media_type: Any = ...,
    metadata: Any = ...,
    field: Any = ...
) -> "IOStats"

Return a fresh :class:IOStats with selected fields overridden.

Each kwarg uses the ... sentinel so the caller can pass None (or any other value) to override without colliding with "leave unchanged". Any field left at ... carries the value over from self.

with_

with_(
    *,
    size: Any = ...,
    mtime: Any = ...,
    kind: Any = ...,
    mode: Any = ...,
    media_type: Any = ...,
    metadata: Any = ...,
    field: Any = ...,
    inplace: bool = False
) -> "IOStats"

Return a stats object with the given fields overridden.

inplace=False (default) returns a fresh :class:IOStats via :meth:copy. inplace=True mutates self and returns self for chaining. Each field kwarg uses the ... sentinel — pass any other value (including None) to override.

normalize_timestamp staticmethod

normalize_timestamp(when: TimeLike | None, default: Any = ...) -> float

Resolve a flexible time spec to a Unix timestamp.

None propagates as None (caller uses it as "no bound"). Numbers pass through as raw Unix timestamps.

:class:datetime.timedelta is resolved as now(UTC) - delta — a past wall-clock moment. This matches how callers naturally phrase mtime filters: stats .is_fresher_than(timedelta(hours=1)) reads as "modified in the last hour", and modified_between(start=timedelta(days =7)) as "modified in the last week". A zero/negative timedelta is allowed and resolves to "now" or a future instant respectively — useful as a sentinel but uncommon in practice.

Everything else (datetime, ISO string) goes through :func:any_to_datetime for consistent parsing.

is_fresher_than

is_fresher_than(mtime: TimeLike) -> bool

Whether this holder was modified strictly after mtime.

Unknown mtime (self.mtime == 0.0) returns False.

is_older_than

is_older_than(mtime: TimeLike) -> bool

Whether this holder was modified strictly before mtime.

Unknown mtime (self.mtime == 0.0) returns False.

modified_between

modified_between(
    start: TimeLike | None = None, end: TimeLike | None = None
) -> bool

Whether mtime falls in the half-open window [start, end).

Either bound may be None for "unbounded on that side", so modified_between(start=yesterday) reads "modified since yesterday" and modified_between(end=cutoff) reads "modified before cutoff". Both None is allowed and accepts any known mtime (still rejects unknown).

Bounds accept anything :func:any_to_datetime understands — datetime, ISO strings, raw timestamps, or a :class:datetime.timedelta interpreted relative to now.

Unknown mtime (self.mtime == 0.0) always returns False — there's no honest answer.

is_between_timestamp

is_between_timestamp(start: float | None, end: float | None) -> bool

Raw-timestamp variant of :meth:modified_between.

Half-open [start, end). None on either side means unbounded. Unknown mtime returns False.

format_bytes

format_bytes(n: 'int | float | None', *, binary: bool = True) -> str

Human-readable byte count for logs / messages — 1536'1.5 KiB'.

Binary (IEC, 1024-step KiB/MiB/…) by default, matching how the codebase sizes its buffers and limits; pass binary=False for SI (1000-step kB/MB/…). None renders as 'unknown' and sub-step counts stay exact bytes ('512 B'); larger values get one decimal.

Cheap (a handful of divisions), but callers that log on a hot path should still gate the call behind logger.isEnabledFor(...) so it's skipped when the level is disabled.