On this page
Workspace data layer for monorepo support handling discovery, loading, saving, and resolution of workspaces from workspace.toml config.
#rlsbl.workspace
#rlsbl.workspace
Workspace data layer for monorepo support handling discovery, loading, saving, and resolution of workspaces from workspace.toml config.
#read_releasable_version
def read_releasable_version(workspace_root, releasable_name)Read the version string from a releasable's version file.
Args:
workspace_root: path to the monorepo root.releasable_name: name of the releasable.
Returns:
- The version string (stripped of whitespace).
Raises:
WorkspaceError: if the version file does not exist or is empty.
#write_releasable_version
def write_releasable_version(workspace_root, releasable_name, version)Write a version string to a releasable's version file atomically.
Creates the releasable directory if it does not exist. Writes to a temporary file in the same directory and then atomically replaces the target file via os.replace().
Args:
workspace_root: path to the monorepo root.releasable_name: name of the releasable.version: the version string to write.
#find_workspace_root
def find_workspace_root(start_path='.')Walk up from start_path looking for a .rlsbl-monorepo/workspace.toml.
Returns the directory containing .rlsbl-monorepo/, or None if not found.
#load_workspace
def load_workspace(root)Read and validate workspace.toml, returning a list of WorkspaceProject.
Each project has at least 'path' (str) and 'name' (str, defaults to basename of path). The returned WorkspaceProject instances support dict-like access for backward compatibility.
Raises FileNotFoundError if workspace.toml doesn't exist. Raises WorkspaceError on invalid structure.
#releasable_keys
def releasable_keys() -> frozensetEvery key a [[releasables]] table may carry.
Derived from :class:~rlsbl.workspace_types.Releasable's own fields rather than restated, so a field added to the model stops being unknown on the same edit that adds it.
#_unknown_keys
def _unknown_keys(table, known)The keys of table outside known, in the order they were written.
#_refuse_unknown_keys
def _refuse_unknown_keys(table, known, *, surface, file_label)Refuse a table carrying a key rlsbl does not know.
The message names the surface (which table, of what kind), the offending key, and the file it was read from -- everything an operator needs to find and delete the line. A tolerated unknown key is a line that was written and never read: the file would say something the tools never do.
#_refuse_releasable_unknown_keys
def _refuse_releasable_unknown_keys(index, raw)Refuse a [[releasables]] table carrying a key the model lacks.
#is_root_path
def is_root_path(path) -> boolDoes path spell the repository root ("", ".", "./")?
#_declared_paths
def _declared_paths(data, projects)The path spellings as written, aligned with projects by index.
#_validate_member_paths
def _validate_member_paths(data, projects)Refuse a member list that does not give each path exactly one member.
Two members claiming one territory make ownership depend on declaration order, so both spellings of the collision are refused at load: identical paths, and paths that differ only in spelling (a and ./a are the same directory, and normalize to the same member path).
#validate_workspace_model
def validate_workspace_model(data, projects)Enforce the workspace ownership model on a parsed workspace.toml.
Each condition below is a hard error carrying its own remedy, and they are reported in the order an operator can act on them:
- a key at the top level that is neither section -- first, because a
misspelled section header is why the sections below look wrong;
- an implicit-mode workspace (no
[[releasables]]) -- because
every other remedy below is written for an explicit-mode workspace;
- more than one root member;
- two members whose paths normalize to the same territory;
- no root member;
- a
watchkey on any member; - a
subtree_remotekey on any member; - a
dev_nodekey on any member; - any other unknown key on a member table;
- an unknown key on a
[[releasables]]table; - a root member named anything but
root; - a non-root member named
root; - a releasable owning the root member with no explicit
tag_format.
Structural facts about the member list (3-5) precede per-member key errors (6-9): a remedy for a stray key presumes the member list itself is sound. The retired keys (6-8) precede the generic unknown-key refusal (9) so each keeps its own remedy.
data is the raw parsed document (needed for the releasables section and for the paths as the operator spelled them), projects the already-built :class:WorkspaceProject list, whose paths are normalized.
#load_releasables
def load_releasables(root, projects=None)Load releasable definitions from workspace.toml.
Reads and validates the [[releasables]] section, then validates that every releasable project has a valid releasable field referencing a defined releasable name (or false).
Args:
root: path to the monorepo root (containing .rlsbl-monorepo/).projects: optional pre-loaded project list. If None, loads via
load_workspace(root).
Returns:
- A list of Releasable instances.
Raises:
- WorkspaceError if
[[releasables]]is missing, or on invalid - releasable definitions or missing/invalid project releasable fields.
#_load_explicit_releasables
def _load_explicit_releasables(raw_releasables, projects)Parse [[releasables]] section and validate project membership.
Every releasable project must have a releasable field that is either a string referencing a defined releasable name, or false.
#members_of
def members_of(releasable_name, projects)Return the list of projects that belong to a given releasable.
Projects with releasable = "<name>" matching the given name are returned as members.
Args:
releasable_name: the releasable name to look up.projects: list of WorkspaceProject or dict instances.
Returns:
- List of projects that are members of the releasable.
#resolve_releasable_for_project
def resolve_releasable_for_project(proj, releasables)Return the Releasable that a project belongs to, or None.
Looks up the project's releasable field and matches it against the list of releasables.
Args:
proj: WorkspaceProject or dict with at leastnameand optionally
releasable.
releasables: list of Releasable instances.
Returns:
- The matching Releasable, or None if the project is not releasable
- (
releasable = false) or no match is found.
#mirror_remote_for
def mirror_remote_for(proj, releasables) -> strThe mirror destination a project's releasable declares, or "".
The ONE resolution of "is this member mirrored, and where": the binding lives on the releasable, so every reader that used to read a member's own subtree_remote asks this instead. A member outside every releasable, or one whose releasable declares no mirror, answers the empty string.
#mirrored_releasable_for
def mirrored_releasable_for(proj, releasables)The mirrored :class:Releasable proj belongs to, or None.
#_get_releasable_value
def _get_releasable_value(proj)Extract the releasable value from a project (WorkspaceProject or dict).
Returns str, False, or None. Does not validate -- just reads the raw value.
#_build_project_table
def _build_project_table(d)Build a fresh tomlkit table for a project dict.
Key order: path, name, then all remaining keys sorted. This matches the layout used when scaffolding a brand-new workspace.toml.
#_build_releasable_table
def _build_releasable_table(d)Build a fresh tomlkit table for a releasable desired-dict.
d carries name and (optionally) tag_format and subtree_remote.
#_update_table_fields
def _update_table_fields(table, desired)Update a tomlkit table in place to match desired (a plain dict).
- Existing keys are reassigned only when their value actually changed
(so untouched keys keep their original formatting and inline comments).
- New keys are appended (preserving the order of already-present keys).
- Keys absent from
desiredare removed.
Intra-table comments attached to surviving keys are preserved by tomlkit.
#_sync_aot_in_place
def _sync_aot_in_place(aot, desired_list, id_key, build_fn)Reconcile an existing tomlkit array-of-tables with a desired list.
Items are matched by identity (id_key: path for projects, name for releasables). Matched tables are updated field-by-field in place (preserving comments and key order). Tables whose identity is not in desired_list are removed. Desired items with no matching table are appended (in desired order) as fresh tables with a leading blank line so they read like the surrounding array-of-tables.
#_separate_releasables_from_projects
def _separate_releasables_from_projects(doc)Keep a blank line between the two sections when one follows the other.
An appended array-of-tables entry carries a blank line BEFORE itself (trivia.indent), which separates it from its own siblings but not from whatever section comes next: a newcomer appended as the last [[releasables]] entry otherwise butts straight against the [[projects]] header. tomlkit holds the blank line AFTER a table as a trailing whitespace element inside that table, which is how a file that already reads well is recognized and left byte-for-byte alone -- a no-op save must not perturb a single byte.
#_member_to_write
def _member_to_write(proj)The member dict save_workspace serializes, minus runtime bookkeeping.
Two things a load->save cycle must never do: persist a key a caller hung off the member at runtime (monorepo sync attaches its inlined-CI bookkeeping to the member dicts it walks), and write a key the loader would then refuse. The first is stripped -- those keys are the tools' own and were never part of the file -- and the second is a hard error here rather than an unreadable workspace.toml discovered on the next load.
#save_workspace
def save_workspace(root, projects, releasables=None)Write workspace.toml atomically, editing the existing document in place.
When the file already exists it is parsed with tomlkit and the [[projects]] (and, when requested, [[releasables]]) arrays-of-tables are reconciled surgically: matched items (by path for projects and name for releasables) are updated field-by-field, absent items are removed, and new items are appended. Untouched tables, intra-table comments, key order, and every other top-level section are preserved byte-for-byte. When the file does not yet exist, a fresh document is created.
When releasables is passed (a list of Releasable instances), the [[releasables]] section is reconciled in place. When releasables is None, any existing [[releasables]] section is preserved untouched. Pass an empty list to write an explicitly empty section (releasables = []): a workspace with no releasables yet is still an explicit-mode workspace, and removing the section entirely would make it unreadable.
Creates .rlsbl-monorepo/ directory if it doesn't exist.
#resolve_project
def resolve_project(root, cwd='.')Determine which project cwd is inside, returning a WorkspaceProject or None.
Uses the one path rule (:func:rlsbl.ownership.member_for_directory): the most specific declared member path wins, and the root member answers for every directory no other member claims -- including the repository root itself, which is exactly what a member declared at path = "." always matched.
None only when cwd is outside the workspace tree entirely. Inside a workspace, some member always answers, because a workspace always declares a root member.
#_derive_standalone_name
def _derive_standalone_name(project_root, detected_targets=None, targets_map=None)Derive a project name for the standalone releasable.
Tries target read_name (first detected target), then falls back to the directory basename.
Args:
project_root: path to the project root (str or Path).detected_targets: pre-detected list of TargetEntry instances.targets_map: dict mapping target names to target objects.
Returns:
- A non-empty name string.
#load_standalone_releasable
def load_standalone_releasable(project_root)Load an explicit releasable definition from .rlsbl/releasable.toml.
If the file exists, reads name and tag_format from it. If absent, returns None (caller should use create_standalone_releasable).
Args:
project_root: path to the project root (str or Path).
Returns:
- A Releasable instance, or None if the file does not exist.
Raises:
- WorkspaceError on invalid file contents.
#save_standalone_releasable
def save_standalone_releasable(project_root, releasable)Write .rlsbl/releasable.toml for releasable.
tag_format is written only when the releasable declares one, so a load -> save cycle neither invents the key on a file that stated none nor drops it from a file that did.
#create_standalone_releasable
def create_standalone_releasable(project_root)Return a Releasable representing a single-project repo.
If .rlsbl/releasable.toml exists, uses its explicit configuration. Otherwise, derives the name from the project's target metadata (e.g., pyproject.toml [project].name) or the directory basename, and uses the standalone tag format (v{version}).
This function does NOT create any files on disk -- the releasable is purely an internal abstraction.
Args:
project_root: path to the project root (str or Path).
Returns:
- A Releasable instance.