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.
Bases:
objectWhat every sibling candidate for a multi-candidate
Workload.sub_type(M365’sUSER_EXCHANGE/GROUP_EXCHANGE— seeunits/dispatch.py’s_SAAS_SUB_TYPE_CANDIDATES) would otherwise each independently resolve from the exact same(repo, version): the version’ssaas_objand its object-name index. Resolved once viaresolve_shared_saas_contextand handed to every candidate factory (seeSaasWorkloadProvider.create’ssharedparameter), instead of each of the 3-4 siblings paying for its ownopen_saas_obj/resolve_object_name_indexcall for a guaranteed byte-identical result.Neither field needs closing:
DedupFilehas noclose()at all (a stateless, explicit-offset read view), andObjectNameIndexis a plain, connection-free dataclass. The stream this was resolved through (the caller’s sharedSaasStreamCache) is borrowed, not owned, byresolve_shared_saas_contexteither — there is nothing left for this dataclass itself to release.
Resolve the one
(dedup_file, object_name_index)pair every candidate for a multi-candidatesub_typewould otherwise resolve independently for itself (seeSharedSaasContextfor why neither field needs closing).saas_streamsis borrowed, not owned — the caller’s sharedSaasStreamCacheopens (and keeps open, for reuse by other versions of the same stream) theSaasStreamthis resolvesdedup_filethrough, 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:
objectWhat one workload needs beyond the shared skeleton: which
tablesto open (resolved viaobject_names— see that field for the alias-mapping shape), atree_factorythat builds the singleTreeStrategychildren()/unit()delegate to once every table is open, and anassemble()callback turning one leaf row into aRestorableUnit.tree_factory/assembleareasync defbecause some workloads’ implementations genuinely need I/O (Drive’stree_factorylooks up a config row; Drive’s/Site’sassembleread a content or META object);extra_attrs/group_attrs/leaf_size(below) never do and stay plain functions.- 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
tablesentry (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’ssub_type("mail_table"is"mail_db"forUSER_EXCHANGEbut"group_mail_db"forGROUP_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’smail_tablevs. 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
hashcolumn; GWS Mail’s label names; GWS Contact’s group names — the latter two read fromprovider.extras, a scratch dicttree_factorypopulates 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 intree_factory(alreadyasync) — reshaping an already-fetched row never does.
- group_attrs(key)¶
Same idea as
extra_attrs, for a non-leaf (group) node’s ownattrs: no row exists at group level, so this reads whatevertree_factorystashed inprovider.extraskeyed 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 ownleaf_kind. Every other workload leaves it at the default no-op.
- leaf_size()¶
Populates a leaf listing
Node’s ownsize(not theRestorableUnitassemble()builds separately) — only Drive’sitem_tableand Site’sitem_version_table(document-library items’ cachedvalue1column; 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:
objectUnitProvidershared by every config-driven SaaS application-layer workload — the only placeroot()/children()/unit()are implemented, driven by whicheverSaasWorkloadConfiga concrete workload (Mail, Drive, …) supplies.Build one with
create, neverSaasWorkloadProvider(...)directly: everythingcreatedoes — opening thesaas_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_factorypopulates once (up front, with I/O) for itsextra_attrsto read later (per row, no I/O) — letsextra_attrsstay 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_streamsis borrowed, not owned — the version’ssaas_objis opened via the caller’s sharedSaasStreamCache, reused across every other version of the same stream, rather than a privateSaasStreamthis provider would otherwise need to close itself.shared, when given, is aSharedSaasContexta caller (units/dispatch.py::saas_provider_for, for a multi-candidatesub_type) already resolved once for this exact(repo, version)— skips this instance’s ownsaas_streams.open_saas_obj/resolve_object_name_indexcalls 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 requiredconfig.tablesentry would use), but returnsNoneinstead of raising when it can’t be found or validated — for atree_factorythat wants a persistently queryable optional secondary table (repeatedWHERE-filtered queries across many laterchildren_of()calls — e.g. mail.py’s own realmail_folder_tablehierarchy), notobject_name_index.read_indexed_table’s one-shot full-table read.A table this resolves is registered into the same
self._sources/self._object_dbsa requiredconfig.tablesentry already uses —self.table(table_name)works from then on, andclose()already covers releasing it, with no separate bookkeeping needed.
- async close()¶
Release every sqlite connection this provider owns:
_sourcesand_object_db_cache. Any unclosed aiosqlite connection hangs interpreter shutdown (seeARCHITECTURE.md’s “Async-native, by design”). Closes_object_db_cacherather than_object_dbs: a multi-table config can have several_object_dbskeys 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’sSaasStreamCache, not owned here, so there’s nothing of its own to release.
- table(name)¶
- object_db(name)¶
- property object_name_index: ObjectNameIndex | None¶
The same
ObjectNameIndexresolutioncreatealready did once —Noneunder the exact same conditions documented there. A config’s owntree_factory/assemblecallbacks that need to look up a secondary table viaread_indexed_table(mail.py’s folder-name/label lookups,contact.py’s equivalents) should read this rather than callingresolve_object_name_indexagain — 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 is_m365: bool¶
Whether this provider’s version is an M365 (Microsoft 365) workload rather than GWS (Google Workspace) — the only two
target_typevalues a SaaS workload provider’sversioncan ever carry (device workloads never reach this class). Read directly bymail.py’s andcontact.py’s owntree_factoryimplementations, and bycontact.py’sassemble, to branch M365-vs-GWS behavior.
- ref_for(key)¶
Public —
assemble()callbacks (the per-workload config, living outside this class) need to build aRestorableUnitwith the same ref this class already built for theNodeit 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:
SaasWorkloadProviderBuilt only by a config whose
tree_factoryreturns aRecursiveTree(Drive’s flat, depth-independentitem_idaddressing) — the one SaaS shape whoseextra_segmentscarries no prefix relationship forunits/resolve.py’s generic descent to use, so it implementsSupportsDirectRefLookupinstead. Every other SaaS provider stays a plainSaasWorkloadProvider, 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_namecallable forSyntheticGroupedTree: looks a group key up innameswhen one resolved, falls back to the raw key otherwise — the same degrade-to-raw-key posturemail.py’s/contact.py’s own folder/group name resolvers document. Shared since both build this identical closure around their own, differently-sourcednamesmap.
- synology_apm_repo.sdk.units.saas.provider.extras_attr(provider, extras_key, row_id, attr_name)¶
One
SaasWorkloadConfig.extra_attrscallback’s whole body: readprovider.extras[extras_key](atree_factory-populated{row id: [value, ...]}map — GWS mail labels, GWS contact groups, …), lookrow_idup in it, and return{attr_name: value}— or{}when the extras entry is missing/not a dict, or the lookup finds nothing. Shared sincemail.py/contact.pyeach build this identical shape around their ownextras_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’sworkload_spec .status.entity_meta.spec.user_info– a narrowworkload_configread byworkload_id, skippingworkloads’s unneeded joins.None, never raises, if not found. Shared sinceteams_chat.py(email only, for an unnamed chat’s own member-exclusion) andcalendar.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)), matchingunits/dispatch.py’s_ProviderFactorycalling convention. Opens thesaas_objand 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 (M365USER_EXCHANGE/GROUP_EXCHANGE’s multi-candidate dispatch — seeunits/dispatch.py::saas_provider_for), is passed straight through toSaasWorkloadProvider.create.namesets 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 ofmail.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-lineasync defwrapper of its own.provider_clsdefaults to plainSaasWorkloadProvider;drive.pypassesRecursiveTreeSaasProviderinstead, the one config whose tree needs that subclass’s extra capability.