synology_apm_repo.sdk.units.node_ref module

NodeRef — a stable, round-trippable string address for any browsable node. CLI arguments, TUI breadcrumbs/bookmarks, and error messages all share this one addressing scheme instead of a pile of mutually-exclusive flags.

Three segment shapes share the same <repo-path>#<seg>/<seg>/... structure — which shape a ref is is entirely a property of its segments’ content, not a separate stored field:

shape

example

canonical (cat:/wl:/ver: prefixed)

repository#cat:1/wl:2/ver:abc-uid/Inbox/Subject

raw (file_map fallback axis, diagnostic-mode only)

repository#raw/VM-uid/.../disk.img

human (display names, everything else)

repository#Test-Workload-01/CORP-PC-001/2026-08-07 09:00

Canonical refs are stable across display-name changes — the right form for scripts and bookmarks. Human refs are what breadcrumbs and ls show; disambiguate appends a hash suffix when two same-level nodes would otherwise show the same name.

Escaping: a segment may itself contain #, /, %, or control characters — each is percent-encoded before segments join with /, decoded back on split, so round-tripping through str(ref) / NodeRef.parse is always safe. repo_path itself is never encoded (split off at the first #), so a literal # inside it can’t round-trip — accepted, since real filesystem paths essentially never contain one. A lone empty-string segment (NodeRef(path, ("",))) also isn’t representable, since it encodes identically to no segments at all — no real display name or path component is ever the empty string, so this never arises.

class synology_apm_repo.sdk.units.node_ref.RefKind(value)

Bases: Enum

Which of the three segment shapes a NodeRef is: canonical (cat:/wl:/ver: prefixed), raw (file_map fallback axis, diagnostic-mode only), or human (display names, everything else).

CANONICAL = 'canonical'
RAW = 'raw'
HUMAN = 'human'
class synology_apm_repo.sdk.units.node_ref.NodeRef(repo_path, segments=())

Bases: object

repo_path is a store-relative or filesystem path to the repository root (unencoded — it is a path, not ref content); segments are the decoded logical path components after the #.

repo_path: str
segments: tuple[str, ...] = ()
classmethod parse(text)

Inverse of __str__.

Raises:

ValueError – text has no # at all (not a ref).

property kind: RefKind
property canonical_ids: tuple[CatalogId, WorkloadId, VersionUid] | None

(catalog_id, workload_id, version_uid) if this is a canonical ref with a well-formed prefix, else None. catalog_id is a plain string, never parsed as an int here — an object-storage CatalogId is a repo-id string, not always an integer the way a vault’s connection_config_id is.

property extra_segments: tuple[str, ...]

Segments beyond the fixed prefix — the provider-internal path (mail folder, mail subject, …) for canonical refs; the file_map path’s own components (one per path segment — join with "/" to recover the literal path) for raw; everything, for human refs.

classmethod canonical(repo_path, *, catalog_id, workload_id, version_uid, extra=())
classmethod raw(repo_path, file_map_path)
classmethod human(repo_path, *names)
child(*extra)

Extends this ref’s own segments by extra — for a provider appending one or more segments to an already-resolved node’s own ref (a diagnostic leaf, a synthetic partition/entry node, …), not the version root (canonical_ref_for covers that case) — every call site uses this instead of hand-rolling NodeRef(node.ref.repo_path, (*node.ref.segments, "x")).

synology_apm_repo.sdk.units.node_ref.canonical_ref_for(repo, version, extra=())

NodeRef.canonical for version, within repo — the (repo.layout.repo_root, catalog_id, workload_id, version_uid) prefix every provider’s own ref_for/_ref method shares; only extra differs per call site.

catalog_id (identifiers.resolve_catalog_id’s shared formula) is repo.layout.repo_id when set — object storage’s own repo-id, unique per bucket by construction — falling back to version.connection_config_id otherwise: always correct for a vault (whose connection_config_id is already unique within it, and whose RepoLayout.repo_id is always None), and also correct for the one object-storage edge case with no derivable repo-id (see storage.layout’s _data_dir_ancestor) — that case only arises when this DedupRepo is the sole catalog reachable from here anyway, so nothing else could collide with it.

synology_apm_repo.sdk.units.node_ref.catalog_pairs(catalogs)

(display_name, catalog_id) pairs, in catalogs’ own order — feeds disambiguate()/match_display_name(), shared by Repository.walk_human_ref and the CLI’s ls/tree.

synology_apm_repo.sdk.units.node_ref.workload_pairs(workloads)

Same shape as catalog_pairs, for Workload, plus each workload’s type_hint — feeds disambiguate()’s hints alongside its pairs.

synology_apm_repo.sdk.units.node_ref.version_pairs(versions)

Same shape as catalog_pairs, for Version.

synology_apm_repo.sdk.units.node_ref.disambiguate(names_and_ids, *, hints=None)

Append a disambiguating suffix to every name that collides with another at the same tree level (the same call’s list); names with no collision are returned unchanged. names_and_ids is (display_name, canonical_id) — the stable id, never the display name, is what the hash fallback is derived from, so a suffix stays the same across renames.

hints (parallel, None where inapplicable) is a human differentiator such as sub_type: one GWS/M365 account can produce several Workload rows sharing one display_name (the account’s name/email), so a hash alone reads as duplicates. A hint that resolves the collision is used instead ("Alice Example <...> · MAIL"); the hash still applies, appended after the hint, to whatever the hint doesn’t resolve.

synology_apm_repo.sdk.units.node_ref.disambiguate_catalogs(catalogs)

Disambiguated display names for catalogs, in the same order — the catalog_pairs() + disambiguate() sequence the CLI’s own catalog-listing helpers and the browser’s own catalog-tree selector each built independently before this helper existed. Zipping the result back against catalogs (or any other parallel sequence) is left to the caller — consumers zip against different shapes (live domain objects, index pairs).

synology_apm_repo.sdk.units.node_ref.disambiguate_workloads(workloads, *, use_type_hint=True)

Same idea as disambiguate_catalogs, for Workload, folding in workload_pairs()’s own type_hint disambiguation hint by default. use_type_hint=False skips it — for a caller whose own workloads are already grouped by type_hint before reaching here (the browser’s own per-sub_type leaf list): every sibling there already shares one type_hint, so the hint could never actually differentiate any of them, and showing it again in the display name would just repeat what the grouping already conveys.

synology_apm_repo.sdk.units.node_ref.disambiguate_versions(versions)

Same idea as disambiguate_catalogs, for Version.

synology_apm_repo.sdk.units.node_ref.match_display_name(target, pairs, objects, *, hints=None)

Match target against the displayed form of pairs ((display_name, stable_id)) after running the collision suffix through disambiguate — the same transform the CLI/TUI apply before showing these names, so a name copied straight out of a breadcrumb resolves back exactly, suffix included when one collided.

hints must match whatever the display side passed to its own disambiguate call for these same pairs — workload resolution passes each workload’s own type_hint here for exactly that reason, so a ref showing "Alice Example <...> · MAIL" resolves back to the Mail workload, not just any workload with that name.

synology_apm_repo.sdk.units.node_ref.ambiguous_matches(target, pairs, *, hints=None)

The disambiguated names among pairs whose pre-suffix display name equals target. Meant to be called only after match_display_name already returned None for the same target/pairs/hints — under that precondition, a non-empty result means target collided at this level and needed a suffix (real ambiguity), while an empty result means target genuinely doesn’t exist here at all. Called on its own, outside that precondition, it’s a plain filter with no such guarantee: a target that matches exactly one row’s raw name comes back as a one-element list even though match_display_name would have already resolved it cleanly.