synology_apm_repo.sdk.units.saas.provider module

SaasWorkloadProvider + SaasWorkloadConfig: the shared skeleton Mail/Drive/Contact/Calendar/Site all reduce to. Every SaaS provider does the same four things — open the version’s saas_obj, locate and open its service-level DB snapshot(s) by table name via the connector’s own object-name index (see object_name_index.py), build a TreeStrategy over whichever shape its tree has, and answer root()/children()/unit() purely by delegating to it — so SaasWorkloadProvider is the only place those three methods are implemented; each concrete workload supplies only a SaasWorkloadConfig (which tables to open, a tree_factory, an assemble() callback, plus optional per-row/per-group attrs hooks) plus whatever helpers its assemble() needs.

No schema-classification discovery scan, anywhere, for any table: every table is resolved by a direct object-name-index lookup (see SaasWorkloadProvider._open_table_via_index); a table simply isn’t found when the index doesn’t name it. A schema-only scan could never safely replace this anyway — Archive Mail’s mail_table is one such case: its schema is byte-for-byte identical to regular Mail’s, so only the index’s own naming (not the schema) can tell the two mailboxes apart.

TeamsChatProvider is not built on this base — its discovery mechanism (one shared channel/chat INDEX-object lookup, not per-table object_names) doesn’t fit the config-driven tables/ object_names model this class assumes.

class synology_apm_repo.sdk.units.saas.provider.SharedSaasContext(dedup_file, object_name_index)

Bases: object

What every sibling candidate for a multi-candidate Workload.sub_type (M365’s USER_EXCHANGE/GROUP_EXCHANGE — see units/dispatch.py’s _SAAS_SUB_TYPE_CANDIDATES) would otherwise each independently resolve from the exact same (repo, version): the version’s saas_obj and its object-name index. Resolved once via resolve_shared_saas_context and handed to every candidate factory (see SaasWorkloadProvider.create’s shared parameter), instead of each of the 3-4 siblings paying for its own open_saas_obj/resolve_object_name_index call for a guaranteed byte-identical result.

Neither field needs closing: DedupFile has no close() at all (a stateless, explicit-offset read view), and ObjectNameIndex is a plain, connection-free dataclass. The stream this was resolved through (the caller’s shared SaasStreamCache) is borrowed, not owned, by resolve_shared_saas_context either — there is nothing left for this dataclass itself to release.

dedup_file: DedupFile
object_name_index: ObjectNameIndex | None
async synology_apm_repo.sdk.units.saas.provider.resolve_shared_saas_context(repo, version, saas_streams)

Resolve the one (dedup_file, object_name_index) pair every candidate for a multi-candidate sub_type would otherwise resolve independently for itself (see SharedSaasContext for why neither field needs closing). saas_streams is borrowed, not owned — the caller’s shared SaasStreamCache opens (and keeps open, for reuse by other versions of the same stream) the SaasStream this resolves dedup_file through, rather than a throwaway instance this function would otherwise need to close itself.

class synology_apm_repo.sdk.units.saas.provider.SaasWorkloadConfig(root_name, leaf_kind, tables, tree_factory, assemble, object_names=<factory>, extra_attrs=<function SaasWorkloadConfig.<lambda>>, group_attrs=<function SaasWorkloadConfig.<lambda>>, leaf_size=<function SaasWorkloadConfig.<lambda>>)

Bases: object

What one workload needs beyond the shared skeleton: which tables to open (resolved via object_names — see that field for the alias-mapping shape), a tree_factory that builds the single TreeStrategy children()/unit() delegate to once every table is open, and an assemble() callback turning one leaf row into a RestorableUnit.

tree_factory/assemble are async def because some workloads’ implementations genuinely need I/O (Drive’s tree_factory looks up a config row; Drive’s/Site’s assemble read a content or META object); extra_attrs/group_attrs/ leaf_size (below) never do and stay plain functions.

root_name: str
leaf_kind: UnitKind
tables: tuple[str, ...]
tree_factory: Callable[[SaasWorkloadProvider], Awaitable[TreeStrategy]]
assemble: Callable[[SaasWorkloadProvider, dict[str, object | None], tuple[str, ...]], Awaitable[RestorableUnit]]
object_names: dict[str, tuple[str, ...]]

Maps a tables entry (a schema table name, e.g. "mail_table") to the index’s own name(s) for the service DB defining it — plural because the same schema table can live under a different index name depending on the workload’s sub_type ("mail_table" is "mail_db" for USER_EXCHANGE but "group_mail_db" for GROUP_EXCHANGE, both real). Every alias is tried in order; the first one the index has and validates wins. A table with no entry here — or a stream with no usable object-name index at all — is simply not found: UnsupportedDataFormatError, never a scan — a schema-only scan can’t safely replace direct lookup, since some tables (Archive Mail’s mail_table vs. regular Mail’s) are schema-identical and only the index’s own naming can tell them apart.

extra_attrs(row)

Reshapes a leaf’s own row into extra display-metadata fields (Drive’s hash column; GWS Mail’s label names; GWS Contact’s group names — the latter two read from provider.extras, a scratch dict tree_factory populates once, up front, with whatever prefetched data a purely-row-based function can’t compute on its own). Stays synchronous: the prefetch is what needs I/O, done once in tree_factory (already async) — reshaping an already-fetched row never does.

group_attrs(key)

Same idea as extra_attrs, for a non-leaf (group) node’s own attrs: no row exists at group level, so this reads whatever tree_factory stashed in provider.extras keyed by the group’s own key. Site uses this to flag which List-shaped groups get a spreadsheet-style overview; Calendar uses it to mark shallow “My”/”Other Calendars” category groups with their own leaf_kind. Every other workload leaves it at the default no-op.

leaf_size()

Populates a leaf listing Node’s own size (not the RestorableUnit assemble() builds separately) — only Drive’s item_table and Site’s item_version_table (document-library items’ cached value1 column; a general List row’s real size is only known once its content is assembled) have one cheaply available at listing time. Every other workload leaves this at the default (None).

class synology_apm_repo.sdk.units.saas.provider.SaasWorkloadProvider(repo, version, config)

Bases: object

UnitProvider shared by every config-driven SaaS application-layer workload — the only place root()/children()/ unit() are implemented, driven by whichever SaasWorkloadConfig a concrete workload (Mail, Drive, …) supplies.

Build one with create, never SaasWorkloadProvider(...) directly: everything create does — opening the saas_obj, resolving extents, opening each configured service DB, building the tree — is I/O and can’t happen in a synchronous constructor.

extras: dict[str, object]

Scratch space a config’s tree_factory populates once (up front, with I/O) for its extra_attrs to read later (per row, no I/O) — lets extra_attrs stay a synchronous, I/O-free function even when the data it exposes (GWS mail labels, GWS contact groups) needed an upfront async fetch.

async classmethod create(repo, version, config, saas_streams, *, shared=None)

saas_streams is borrowed, not owned — the version’s saas_obj is opened via the caller’s shared SaasStreamCache, reused across every other version of the same stream, rather than a private SaasStream this provider would otherwise need to close itself.

shared, when given, is a SharedSaasContext a caller (units/dispatch.py::saas_provider_for, for a multi-candidate sub_type) already resolved once for this exact (repo, version) — skips this instance’s own saas_streams.open_saas_obj/resolve_object_name_index calls in favor of reusing it directly.

async open_optional_table_via_index(table_name)

Best-effort sibling of _open_table_via_index: same object-name-index resolution (self._config.object_names[table_name] — the same aliases a required config.tables entry would use), but returns None instead of raising when it can’t be found or validated — for a tree_factory that wants a persistently queryable optional secondary table (repeated WHERE-filtered queries across many later children_of() calls — e.g. mail.py’s own real mail_folder_table hierarchy), not object_name_index.read_indexed_table’s one-shot full-table read.

A table this resolves is registered into the same self._sources/ self._object_dbs a required config.tables entry already uses — self.table(table_name) works from then on, and close() already covers releasing it, with no separate bookkeeping needed.

async close()

Release every sqlite connection this provider owns: _sources and _object_db_cache. Any unclosed aiosqlite connection hangs interpreter shutdown (see ARCHITECTURE.md’s “Async-native, by design”). Closes _object_db_cache rather than _object_dbs: a multi-table config can have several _object_dbs keys pointing at the same cached instance (see _open_table_via_index), and _object_db_cache’s own (offset, length) keying already de-duplicates that for us. The version’s own stream is borrowed from the caller’s SaasStreamCache, not owned here, so there’s nothing of its own to release.

table(name)
object_db(name)
property dedup_file: DedupFile
property object_name_index: ObjectNameIndex | None

The same ObjectNameIndex resolution create already did once — None under the exact same conditions documented there. A config’s own tree_factory/assemble callbacks that need to look up a secondary table via read_indexed_table (mail.py’s folder-name/label lookups, contact.py’s equivalents) should read this rather than calling resolve_object_name_index again — same repository, same version, guaranteed identical result, so a second call only pays for a repeat SQL query and JSON parse (an extra vault-key decrypt too, on an encrypted repository) for nothing.

property version: Version
property is_m365: bool

Whether this provider’s version is an M365 (Microsoft 365) workload rather than GWS (Google Workspace) — the only two target_type values a SaaS workload provider’s version can ever carry (device workloads never reach this class). Read directly by mail.py’s and contact.py’s own tree_factory implementations, and by contact.py’s assemble, to branch M365-vs-GWS behavior.

property repo: DedupRepo
ref_for(key)

Public — assemble() callbacks (the per-workload config, living outside this class) need to build a RestorableUnit with the same ref this class already built for the Node it was handed, not reach into this class’s own layout/version fields to rebuild it independently.

root()

Pure construction — no I/O, so this stays synchronous (see UnitProvider).

async children(node, offset=0, limit=None)
async unit(node)
class synology_apm_repo.sdk.units.saas.provider.RecursiveTreeSaasProvider(repo, version, config)

Bases: SaasWorkloadProvider

Built only by a config whose tree_factory returns a RecursiveTree (Drive’s flat, depth-independent item_id addressing) — the one SaaS shape whose extra_segments carries no prefix relationship for units/resolve.py’s generic descent to use, so it implements SupportsDirectRefLookup instead. Every other SaaS provider stays a plain SaasWorkloadProvider, which doesn’t define these two methods at all — they can’t live there unconditionally: isinstance(provider, SupportsDirectRefLookup) would then wrongly say yes for Mail/Contact/Calendar/Site too, none of which this applies to.

async resolve_extra(extra_segments)
async parent_of(node)
synology_apm_repo.sdk.units.saas.provider.group_display_name_resolver(names)

Builds a group_display_name callable for SyntheticGroupedTree: looks a group key up in names when one resolved, falls back to the raw key otherwise — the same degrade-to-raw-key posture mail.py’s/contact.py’s own folder/group name resolvers document. Shared since both build this identical closure around their own, differently-sourced names map.

synology_apm_repo.sdk.units.saas.provider.extras_attr(provider, extras_key, row_id, attr_name)

One SaasWorkloadConfig.extra_attrs callback’s whole body: read provider.extras[extras_key] (a tree_factory-populated {row id: [value, ...]} map — GWS mail labels, GWS contact groups, …), look row_id up in it, and return {attr_name: value} — or {} when the extras entry is missing/not a dict, or the lookup finds nothing. Shared since mail.py/contact.py each build this identical shape around their own extras_key/attr_name.

async synology_apm_repo.sdk.units.saas.provider.owning_account_user_info(repo, version)

The backed-up account’s own real profile (email, name, …), read directly off the owning workload’s workload_spec .status.entity_meta.spec.user_info – a narrow workload_config read by workload_id, skipping workloads’s unneeded joins. None, never raises, if not found. Shared since teams_chat.py (email only, for an unnamed chat’s own member-exclusion) and calendar.py (email and name, for a primary calendar’s own display name) each need the identical lookup.

synology_apm_repo.sdk.units.saas.provider.make_saas_provider(config, *, name, provider_cls=<class 'synology_apm_repo.sdk.units.saas.provider.SaasWorkloadProvider'>)

Build a constructor-style async factory over config — called exactly like a constructor (await XProvider(repo, version)), matching units/dispatch.py’s _ProviderFactory calling convention. Opens the saas_obj and resolves the service DB via the connector’s own object-name index — the same index-driven resolution every SaaS provider (including the raw diagnostic fallback) uses, never a scan of the stream’s content. shared, when given (M365 USER_EXCHANGE/GROUP_EXCHANGE’s multi-candidate dispatch — see units/dispatch.py::saas_provider_for), is passed straight through to SaasWorkloadProvider.create.

name sets the returned callable’s __name__/__qualname__, so a traceback still names the specific provider (MailProvider, …) rather than this function’s own generic inner closure — every one of mail.py’s/contact.py’s/calendar.py’s/drive.py’s/ site.py’s provider factories is one call to this function, not a hand-written 3-line async def wrapper of its own.

provider_cls defaults to plain SaasWorkloadProvider; drive.py passes RecursiveTreeSaasProvider instead, the one config whose tree needs that subclass’s extra capability.