stores.ficus¶
A store backend over ficus, a layered configuration service.
A config is assembled from a defaults document plus one document per scope (a computer, a
subject), deep-merged in precedence order. Two shapes of document are served:
- Config -- layered, merged from ficus' conventional
defaultdocument at each scope. See :meth:FicusClient.get_mergedand :class:MergePolicy. - Records -- flat, standalone documents named after their kind (
manipulator_position.json), belonging to exactly one scope, read and written whole. Applied to a model by :class:~clabe.modifiers.ByAnimalModifier.
Layers are fetched individually with merge=false and merged client-side; ficus' own
merge=true returns 404 for the whole request when any requested scope has no document. The
merge reproduces ficus' _deep_update: dicts recurse, everything else is replaced, and null
is a value rather than a deletion.
Writing a config back sends only the leaves that differ from the current resolution, to a single
layer. See :meth:FicusClient.plan_write_back.
ScopeRef
dataclass
¶
One ficus scope, narrowed to a single identifier.
A scope has a collection name and a parameter name. Ficus' routes use them differently::
POST|PATCH|DELETE /v1/computers/{hostname}/namespaces/{namespace}/config/{filename}
POST|PATCH|DELETE /v1/subjects/{subject_id}/namespaces/{namespace}/config/{filename}
GET /v1/namespaces/{namespace}/config?hostname=&subject_id=&filename=&merge=
Writes address a scope by path, under the collection. There is no scoped GET route -- a
GET on a write path returns 405 -- so reads address it by query parameter on the unscoped
route. An unrecognised query parameter is ignored, and such a read returns the defaults
layer.
Attributes:
| Name | Type | Description |
|---|---|---|
segment |
Segment
|
The collection a write addresses this scope under, e.g. |
param |
str
|
The identifier's parameter name, e.g. |
identifier |
str
|
The machine name or subject id. |
LayerKey
dataclass
¶
Layer
dataclass
¶
One document fetched from ficus, with the znode path it came from.
Attributes:
| Name | Type | Description |
|---|---|---|
data |
dict[str, Any]
|
The document's contents. |
source |
str
|
The path ficus reports for it, e.g.
|
key |
LayerKey | None
|
The layer as addressed, rather than as reported. |
MergeResult
dataclass
¶
MergeResult(
data: dict[str, Any],
sources: list[str],
origins: dict[LeafPath, LayerKey] = dict(),
chain: list[LayerKey] = list(),
)
A merged config together with the documents that produced it.
Attributes:
| Name | Type | Description |
|---|---|---|
data |
dict[str, Any]
|
The merged document. |
sources |
list[str]
|
The contributing paths, in the order they were applied (lowest precedence first). Empty when no layer existed at all. |
origins |
dict[LeafPath, LayerKey]
|
For each leaf in :attr: |
chain |
list[LayerKey]
|
Every layer consulted, in precedence order, including those that held no document. |
origin ¶
origin(path: LeafPath) -> LayerKey | None
Returns the layer a leaf's value came from.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
LeafPath
|
The leaf to look up. |
required |
Returns:
| Type | Description |
|---|---|
LayerKey | None
|
LayerKey | None: The winning layer, or |
Source code in src/clabe/stores/ficus.py
152 153 154 155 156 157 158 159 160 161 | |
MergePolicy
dataclass
¶
MergePolicy(
null_means: Literal["value", "delete"] = "value",
lists: Literal["replace", "concat"] = "replace",
on_type_conflict: Literal[
"override", "warn", "raise"
] = "warn",
on_missing_layer: Literal["skip", "raise"] = "skip",
)
How layers combine. The defaults reproduce ficus' _deep_update.
Attributes:
| Name | Type | Description |
|---|---|---|
null_means |
Literal['value', 'delete']
|
|
lists |
Literal['replace', 'concat']
|
|
on_type_conflict |
Literal['override', 'warn', 'raise']
|
What to do when a layer replaces a dict with a non-dict or vice versa.
|
on_missing_layer |
Literal['skip', 'raise']
|
|
WritePolicy
dataclass
¶
WritePolicy(
on_key_removal: Literal["raise", "skip"] = "raise",
on_shadowed_write: Literal[
"raise", "warn", "allow"
] = "raise",
)
How a layered-config write-back behaves.
Attributes:
| Name | Type | Description |
|---|---|---|
on_key_removal |
Literal['raise', 'skip']
|
What to do when a key present in the resolved config is absent from the
value being written back. Ficus' |
on_shadowed_write |
Literal['raise', 'warn', 'allow']
|
What to do when a changed leaf's current value comes from a layer above
the one being written to, so the next read would return the old value. |
LayerWrite
dataclass
¶
FicusClient ¶
FicusClient(
base_url: str = DEFAULT_BASE_URL,
*,
default_extension: str = ".json",
timeout: float = 10.0,
session: Session | None = None,
)
An HTTP client for ficus, independent of clabe's store vocabulary.
Example
client = FicusClient("http://eng-tools/ficus-dev")
result = client.get_merged("aind-behavior-vr-foraging", scopes=[COMPUTERS("DT201256")])
print(result.sources) # contributing documents, lowest precedence first
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base_url
|
str
|
Ficus' root URL. |
DEFAULT_BASE_URL
|
default_extension
|
str
|
Names a document ficus does not have yet, and is tried first when
reading. Reads fall back across :data: |
'.json'
|
timeout
|
float
|
Seconds to wait on any single request. |
10.0
|
session
|
Session | None
|
A |
None
|
Source code in src/clabe/stores/ficus.py
391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 | |
get_layer ¶
get_layer(
namespace: str,
*,
filename: str = DEFAULT_FILENAME,
scope: ScopeRef | None = None,
) -> Layer | None
Fetches exactly one document, without any server-side merging.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
namespace
|
str
|
The ficus namespace. |
required |
filename
|
str
|
The document to fetch. |
DEFAULT_FILENAME
|
scope
|
ScopeRef | None
|
The scope to read from. |
None
|
Returns:
| Type | Description |
|---|---|
Layer | None
|
Layer | None: The document, or |
Raises:
| Type | Description |
|---|---|
HTTPError
|
For any failure other than the document being absent. |
Source code in src/clabe/stores/ficus.py
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 | |
locate ¶
Finds which extension a document is stored under, and fetches it.
A read matches the extension exactly, so a document stored as rig.yml is absent to a
request for rig.json. :attr:default_extension is tried first, then the rest of
:data:SUPPORTED_EXTENSIONS. Ficus allows a stem only one extension at a time, so at most
one can exist.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
namespace
|
str
|
The ficus namespace. |
required |
stem
|
str
|
The document name without its extension, e.g. |
required |
scope
|
ScopeRef | None
|
The scope to look in. |
None
|
Returns:
| Type | Description |
|---|---|
tuple[str, Layer | None]
|
tuple[str, Layer | None]: The filename to address -- the extension found, or |
Raises:
| Type | Description |
|---|---|
HTTPError
|
For any failure other than a document being absent. |
Source code in src/clabe/stores/ficus.py
501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 | |
get_merged ¶
get_merged(
namespace: str,
*,
filename: str | None = None,
scopes: Iterable[ScopeRef] = (),
policy: MergePolicy | None = None,
) -> MergeResult
Fetches every layer in the chain and merges them.
The chain runs defaults first, then each scope in the order given, and within each
layer the default document before any extra filename. This is ficus' documented
precedence.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
namespace
|
str
|
The ficus namespace. |
required |
filename
|
str | None
|
An extra document to layer on top of |
None
|
scopes
|
Iterable[ScopeRef]
|
Scopes to layer, lowest precedence first. |
()
|
policy
|
MergePolicy | None
|
How to combine layers, and what to do about missing ones. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
MergeResult |
MergeResult
|
The merged document and the paths that produced it. |
Raises:
| Type | Description |
|---|---|
LookupError
|
If |
Source code in src/clabe/stores/ficus.py
528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 | |
write ¶
write(
namespace: str,
data: dict[str, Any],
*,
filename: str = DEFAULT_FILENAME,
scope: ScopeRef | None = None,
create_only: bool = False,
) -> None
Writes one document, to exactly one layer.
POST is tried first and falls back to PATCH on a 409. Ficus' PATCH deep-merges
the payload into the existing document server-side, so a partial write needs no
read-modify-write here; it cannot remove a key, which takes a delete and a rewrite.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
namespace
|
str
|
The ficus namespace. |
required |
data
|
dict[str, Any]
|
The document, or the part of it being changed. |
required |
filename
|
str
|
The document to write. |
DEFAULT_FILENAME
|
scope
|
ScopeRef | None
|
The layer to write to. |
None
|
create_only
|
bool
|
Whether to fail rather than update an existing document. |
False
|
Raises:
| Type | Description |
|---|---|
HTTPError
|
If the write fails. |
Source code in src/clabe/stores/ficus.py
611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 | |
plan_write_back
staticmethod
¶
plan_write_back(
current: MergeResult,
new_data: dict[str, Any],
*,
target: LayerKey | None = None,
policy: WritePolicy | None = None,
) -> LayerWrite | None
Works out what a write-back would do, without doing it.
Only the leaves that differ are written, to a single layer. The default target is the top
of the merge chain, which no layer can shadow. A lower target is allowed, and
policy.on_shadowed_write then governs any leaf whose current value comes from above it.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
current
|
MergeResult
|
The config as it resolves now, from :meth: |
required |
new_data
|
dict[str, Any]
|
The config as it should resolve. |
required |
target
|
LayerKey | None
|
The layer to write to. |
None
|
policy
|
WritePolicy | None
|
How to handle removals and shadowed writes. |
None
|
Returns:
| Type | Description |
|---|---|
LayerWrite | None
|
LayerWrite | None: The write to perform, or |
Raises:
| Type | Description |
|---|---|
ValueError
|
If there is no chain to write to. |
LookupError
|
If a key would have to be removed and |
PermissionError
|
If the write would be shadowed and |
Source code in src/clabe/stores/ficus.py
646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 | |
write_back ¶
write_back(
namespace: str,
current: MergeResult,
new_data: dict[str, Any],
*,
target: LayerKey | None = None,
policy: WritePolicy | None = None,
) -> LayerWrite | None
Writes the difference between a resolved config and a new one back to a single layer.
See :meth:plan_write_back for how the layer and the payload are chosen; this runs that
plan.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
namespace
|
str
|
The ficus namespace. |
required |
current
|
MergeResult
|
The config as it resolves now, from :meth: |
required |
new_data
|
dict[str, Any]
|
The config as it should resolve. |
required |
target
|
LayerKey | None
|
The layer to write to. |
None
|
policy
|
WritePolicy | None
|
How to handle removals and shadowed writes. |
None
|
Returns:
| Type | Description |
|---|---|
LayerWrite | None
|
LayerWrite | None: What was written, or |
Raises:
| Type | Description |
|---|---|
HTTPError
|
If the write fails. |
Source code in src/clabe/stores/ficus.py
743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 | |
delete ¶
delete(
namespace: str,
*,
filename: str = DEFAULT_FILENAME,
scope: ScopeRef | None = None,
) -> bool
Deletes one document.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
namespace
|
str
|
The ficus namespace. |
required |
filename
|
str
|
The document to delete. |
DEFAULT_FILENAME
|
scope
|
ScopeRef | None
|
The layer to delete from. |
None
|
Returns:
| Name | Type | Description |
|---|---|---|
bool |
bool
|
True if a document was deleted, False if there was nothing there. |
Raises:
| Type | Description |
|---|---|
HTTPError
|
If the delete fails for any reason other than absence. |
Source code in src/clabe/stores/ficus.py
777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 | |
list_files ¶
Lists the documents visible for a namespace, optionally within one scope.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
namespace
|
str
|
The ficus namespace. |
required |
scope
|
ScopeRef | None
|
Restrict to this scope. |
None
|
Returns:
| Type | Description |
|---|---|
list[str]
|
list[str]: The document paths, or an empty list if the scope holds nothing. |
Raises:
| Type | Description |
|---|---|
HTTPError
|
For any failure other than the scope being absent. |
Source code in src/clabe/stores/ficus.py
799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 | |
FicusSettings ¶
Bases: ServiceSettings
Settings for :class:FicusStore, read from the ficus section of clabe.yml.
settings_customise_sources
classmethod
¶
settings_customise_sources(
settings_cls: type[BaseSettings],
init_settings: PydanticBaseSettingsSource,
env_settings: PydanticBaseSettingsSource,
dotenv_settings: PydanticBaseSettingsSource,
file_secret_settings: PydanticBaseSettingsSource,
) -> tuple[PydanticBaseSettingsSource, ...]
Customizes the settings sources to include the safe YAML settings source.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
settings_cls
|
type[BaseSettings]
|
The settings class |
required |
init_settings
|
PydanticBaseSettingsSource
|
The initial settings source |
required |
env_settings
|
PydanticBaseSettingsSource
|
The environment settings source |
required |
dotenv_settings
|
PydanticBaseSettingsSource
|
The dotenv settings source |
required |
file_secret_settings
|
PydanticBaseSettingsSource
|
The file secret settings source |
required |
Returns:
| Type | Description |
|---|---|
tuple[PydanticBaseSettingsSource, ...]
|
Tuple[PydanticBaseSettingsSource, ...]: A tuple of settings sources |
Source code in src/clabe/services.py
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 | |
FicusStore ¶
FicusStore(
settings: FicusSettings | None = None,
*,
client: FicusClient | None = None,
serves: str | set[str] | None = None,
scope: Scope | None = None,
)
Bases: StoreBase
A store over a single ficus namespace.
Serves two shapes of document, told apart by kind name:
- A kind in
config_kinds("rig"by default) is layered config: merged acrossdefaultsand the configured scopes, from ficus'defaultdocument. The merge yields exactly one document, so :meth:resolvenever prompts. - Any other kind is a flat record: a standalone document named after the kind, living in one
scope, read and written whole. :class:
~clabe.modifiers.ByAnimalModifieruses this shape.
Example
store = FicusStore(FicusSettings(namespace="aind-behavior-vr-foraging"))
rig = store.resolve(Kind.from_rig(AindVrForagingRig))
position = store.scoped(subject="789907").resolve(Kind(ManipulatorPosition))
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
settings
|
FicusSettings | None
|
Settings for this store. Defaults to |
None
|
client
|
FicusClient | None
|
The ficus client. Defaults to |
None
|
serves
|
str | set[str] | None
|
Kind name(s) this store will answer for, or |
None
|
scope
|
Scope | None
|
Initial scope. |
None
|
Source code in src/clabe/stores/ficus.py
894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 | |
write ¶
write(
kind: KindLike[T],
value: T,
*,
scope: Scope | None = None,
) -> None
Persists a value.
A flat record is written whole, to the subject's scope when one is in scope and the rig's
otherwise. Layered config is written as a diff against its current resolution: only the
leaves that differ are sent, to the top of the merge chain. See
:meth:FicusClient.plan_write_back.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
kind
|
KindLike[T]
|
The kind being written. |
required |
value
|
T
|
The value. |
required |
scope
|
Scope | None
|
Extra scope for this call, layered over the store's own. |
None
|
Raises:
| Type | Description |
|---|---|
LookupError
|
If this store does not serve |
PermissionError
|
If a layered write would land below a layer that overrides it. |
HTTPError
|
If the write fails. |
Source code in src/clabe/stores/ficus.py
1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 | |
scoped ¶
Returns a view sharing this store's data, narrowed by the given scope.
The view is the same concrete store type, so narrowing never loses backend-specific typing.
Source code in src/clabe/stores/_base.py
218 219 220 221 222 223 224 225 226 | |
list ¶
list(
kind: KindLike[T], *, scope: Scope | None = None
) -> Sequence[T]
Returns every record of this kind in scope, validated but unprompted.
Source code in src/clabe/stores/_base.py
240 241 242 243 | |
resolve ¶
resolve(
kind: KindLike[T],
*,
scope: Scope | None = None,
pick_kwargs: PickRequestKwargs | None = None,
) -> T
Selects a single record: raises on none, auto-selects a lone candidate, prompts otherwise.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
kind
|
KindLike[T]
|
The kind of record to resolve. |
required |
scope
|
Scope | None
|
Narrows which records are considered. Layered over the store's own scope. |
None
|
pick_kwargs
|
PickRequestKwargs | None
|
Optional overrides for the :class: |
None
|
Raises:
| Type | Description |
|---|---|
LookupError
|
If no record of this kind exists in scope, or the user declined to pick one. |
NoFrontendError
|
If a choice must be made and no frontend is registered. |
Source code in src/clabe/stores/_base.py
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 | |
deep_merge ¶
deep_merge(
base: dict[str, Any],
override: dict[str, Any],
*,
policy: MergePolicy | None = None,
_path: str = "",
) -> dict[str, Any]
Merges override onto base, mirroring ficus' own _deep_update.
Dicts present on both sides recurse; everything else is replaced outright, lists included.
null on the overriding side is a value, not a deletion, unless policy says otherwise.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
base
|
dict[str, Any]
|
The lower-precedence document. |
required |
override
|
dict[str, Any]
|
The higher-precedence document, layered on top. |
required |
policy
|
MergePolicy | None
|
How to combine. |
None
|
_path
|
str
|
Internal. Dotted path to the current level, used only in messages. |
''
|
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
dict[str, Any]: A new merged document. Neither argument is modified. |
Source code in src/clabe/stores/ficus.py
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 | |
iter_leaves ¶
Walks a document, yielding every leaf and the path of keys that reaches it.
A leaf is anything :func:deep_merge replaces wholesale: scalars, None, lists and empty
dicts. Non-empty dicts are recursed into.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
dict[str, Any]
|
The document to walk. |
required |
_prefix
|
LeafPath
|
Internal. The path to the current level. |
()
|
Yields:
| Type | Description |
|---|---|
tuple[LeafPath, Any]
|
tuple[LeafPath, Any]: Each leaf's path and value. |
Source code in src/clabe/stores/ficus.py
299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 | |
diff_leaves ¶
diff_leaves(
old: dict[str, Any], new: dict[str, Any]
) -> tuple[dict[LeafPath, Any], list[LeafPath]]
Finds the leaves that differ between two documents.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
old
|
dict[str, Any]
|
The document as it currently resolves. |
required |
new
|
dict[str, Any]
|
The document as it should resolve. |
required |
Returns:
| Type | Description |
|---|---|
tuple[dict[LeafPath, Any], list[LeafPath]]
|
tuple[dict[LeafPath, Any], list[LeafPath]]: The changed leaves with their new values, and
the paths present only in |
Source code in src/clabe/stores/ficus.py
320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 | |
nest_leaves ¶
Rebuilds a nested document from flat leaf paths.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
leaves
|
dict[LeafPath, Any]
|
Leaf paths and their values. |
required |
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
dict[str, Any]: The nested document. |
Source code in src/clabe/stores/ficus.py
345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 | |