# SPDX-FileCopyrightText: 2022-present The Firebird Projects <www.firebirdsql.org>
#
# SPDX-License-Identifier: MIT
#
# PROGRAM/MODULE: firebird-uuid
# FILE: firebird/uuid/_model.py
# DESCRIPTION: Model for Firebird OID registry
# CREATED: 11.11.2022
#
# The contents of this file are subject to the MIT License
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
# Copyright (c) 2022 Firebird Project (www.firebirdsql.org)
# All Rights Reserved.
#
# Contributor(s): Pavel Císař (original code)
# ______________________________________
"""Defines the core data structures for the Firebird OID registry model.
This module contains the `OIDNode` class, which represents a single node
within the OID hierarchy, holding its attributes and relationships. It also
defines the `OIDNodeType` enumeration for classifying nodes and related
constants like `IANA_ROOT_NAME`.
"""
from __future__ import annotations
import uuid
import weakref
from collections.abc import Iterable
from enum import Enum
from typing import Any, Literal
from firebird.base.types import Distinct, Error
#: IANA name before Firebird namespace. Does not contain trailing dot.
IANA_ROOT_NAME: str = 'iso.org.dod.internet.private.enterprise'
KEY_ATTRS: str[str] = {'oid', 'name', 'number', 'description', 'contact', 'email', 'site',
'node_type', 'node_spec'}
#: Needed to work around absent support for `None` in TOML
NONE_VALUE = "None"
[docs]
class OIDNodeType(Enum):
"""Enumeration of possible types for an `OIDNode`.
"""
#: The top-level node in a specific OID specification file.
#: Only one true root (from IANA) exists in the full conceptual tree.
ROOT = 'root'
#: A terminal node in the hierarchy; it cannot have children defined
#: in a separate specification file.
LEAF = 'leaf'
#: A node reserved for private use, similar to a LEAF in that it
#: does not link to a separate specification file for children.
PRIVATE = 'private'
#: An intermediate node that references another specification file
#: (via `node_spec`) to define its children.
NODE = 'node'
[docs]
class OIDNode(Distinct):
"""Represents a single node within the Firebird OID hierarchy.
Each node encapsulates information such as its Object Identifier (OID), name,
description, contact details, and type. It maintains links to its parent
(using a weak reference) and holds a list of its direct children.
Nodes are uniquely identified by a UUID (`uid`) deterministically generated
from their OID using `uuid.uuid5` with the `NAMESPACE_OID`.
Arguments:
parent: The parent `OIDNode`. Stored as a weak reference.
`None` for the absolute root node of the entire hierarchy.
oid: The full OID string for this node. If `None`, it's constructed
from `parent.oid` and `number`. One of `oid` or (`parent` and
`number`) must be provided.
number: The node's number relative to its parent. Used to construct
the OID if `oid` is not given.
name: Node name (e.g., 'firebird').
description: Node description.
contact: Contact person/entity name.
email: Contact email address.
site: URL associated with the node owner/maintainer.
parent_spec: URL to the YAML specification of the parent node. If
`None`, it's derived from the `parent` object's `node_spec`.
node_spec: URL to this node's YAML specification file (if it's a NODE type)
or one of the keywords 'leaf' or 'private'. Used to determine
`node_type` if `node_type` argument is not provided.
node_type: Explicit node type ('root', 'node', 'leaf', 'private'). If
`None`, it's inferred from `node_spec` ('leaf'/'private' keywords
or defaults to 'node' if it looks like a URL/path).
Raises:
ValueError: If the OID cannot be determined (neither `oid` nor
`parent`+`number` are sufficient).
"""
def __init__(self, *, parent: OIDNode | None | Literal[NONE_VALUE]=None, oid: str | None=None,
number: int | None=None, name: str | None=None,
description: str | None=None, contact: str | None=None,
email: str | None=None, site: str | None=None,
parent_spec: str | None=None, node_spec: str | None=None,
node_type: str | None=None):
if parent == NONE_VALUE:
parent = None
#: Parent node (or None for ROOT)
self._parent_ref: weakref.ref[OIDNode] | None = None if parent is None else weakref.ref(parent)
self.__oid: str | None = oid
#: OID string (may be calculated)
self.oid: str | None = oid
if number is not None and parent is not None:
self.oid = parent.oid + '.' + str(number)
# Ensure OID is set for UUID generation
if self.oid is None:
raise ValueError(f"Cannot determine OID for node {name} (parent OID or direct OID needed)")
#: UUID (Universally Unique Identifier) derived from OID
self.uid: uuid.UUID = uuid.uuid5(uuid.NAMESPACE_OID, self.oid)
#: Node number (last component of OID) relative to parent
self.number: int | None = number
#: Node name (identifier part)
self.name: str | None = name
#: Node description (human-readable)
self.description: str | None = description
#: Name of node administrator/contact
self.contact: str | None = contact
#: E-mail address of node administrator/contact
self.email: str | None = email
#: URL to node administrator/owner home page
self.site: str | None = site
#: URL to parent node's specification file
self.parent_spec: str | None = parent_spec
if parent_spec is None and parent:
self.parent_spec = parent.node_spec
#: URL to this node's specification file (or None for LEAF/PRIVATE)
self.node_spec: str | None = node_spec
self.__node_type: str | None = node_type
#: Node type (enum member)
self.node_type: OIDNodeType | None = None
if node_type:
self.node_type = OIDNodeType._value2member_map_.get(node_type.lower())
elif node_type is None and node_spec:
self.node_type = OIDNodeType._value2member_map_.get(node_spec.lower(), OIDNodeType.NODE)
if self.node_type in (OIDNodeType.LEAF, OIDNodeType.PRIVATE):
self.node_spec = None
#: Direct child nodes
self.children: list[OIDNode] = []
[docs]
def get_key(self) -> uuid.UUID:
"""Returns the node's unique identifier (UUID)."""
return self.uid
[docs]
def set_parent(self, parent: OIDNode | None) -> None:
"""Sets or updates the parent node and related attributes.
Establishes a weak reference to the new parent. If the node's `number`
is set, it recalculates the node's `oid` based on the new parent's OID.
It also updates `parent_spec` from the new parent's `node_spec`.
Important:
This method ONLY updates the child (`self`). It does NOT modify the
old or new parent's `children` list. Tree modifications should
be handled externally (e.g., by `build_tree`).
Arguments:
parent: The new parent node, or `None` to detach the node.
"""
self._parent_ref = None if parent is None else weakref.ref(parent)
if self.number is not None and parent is not None:
self.oid = parent.oid + '.' + str(self.number)
if parent is not None:
self.parent_spec = parent.node_spec
[docs]
def as_toml_dict(self) -> dict[str, Any]:
"""Returns node data as a dictionary suitable for TOML serialization.
Parent is represented by its UUID string. Uses the original `oid` if
provided directly, otherwise relies on the potentially calculated `oid`.
Node type is represented by the original string (`__node_type`) if provided,
otherwise potentially derived. `node_spec` for LEAF/PRIVATE is output
as the type keyword ('leaf'/'private').
Returns:
A dictionary with string representations of complex types like UUIDs.
"""
toml_dict = {'parent': str(self.parent.uid) if self.parent else None,
'oid': self.__oid,
'number': self.number,
'name': self.name,
'description': self.description,
'contact': self.contact,
'email': self.email,
'site': self.site,
'node_spec': (self.node_type.value
if self.node_type in (OIDNodeType.LEAF, OIDNodeType.PRIVATE)
else self.node_spec),
'node_type': self.__node_type,
}
return {k: v for k, v in toml_dict.items() if v is not None}
[docs]
@classmethod
def from_spec(cls: type[OIDNode], spec_url: str, data: dict[str, Any],
parent: OIDNode | None=None) -> OIDNode:
"""Creates a new OIDNode and its direct children from parsed specification data.
Arguments:
spec_url: The URL from which the specification `data` was loaded.
Used as the `node_spec` for the created node.
data: Parsed and validated dictionary from an OID node specification
document (typically after `pythonize_spec`). Expected keys:
'node' (dict for the main node), 'children' (list of dicts).
parent: The parent node for the node being created (if applicable).
Returns:
The newly created `OIDNode` instance representing the root of the
specification defined in `data`, with its `children` list populated.
"""
# We prioritize spec_url as the node_spec value because it's guaranteed
# to be the correct source URL.
# Create a copy of the node data to avoid modifying the input 'data' dictionary.
node_init_kwargs = data['node'].copy()
# Remove 'node_spec' from the kwargs copy *if it exists* to prevent
# the TypeError: __init__() got multiple values for keyword argument 'node_spec'.
# The explicit node_spec=spec_url argument in the cls() call below will be used instead.
node_init_kwargs.pop('node_spec', None) # Use pop with default to avoid KeyError
node: OIDNode = cls(parent=parent, node_spec=spec_url, **node_init_kwargs)
# Create child nodes listed in the spec
for child_data in data.get('children', []):
# Pass the newly created node as the parent for the children
# Child data doesn't need the spec_url override logic.
node.children.append(cls(parent=node, **child_data))
return node
@property
def parent(self) -> OIDNode | None:
"""Returns the parent `OIDNode` object.
Returns `None` if this node has no parent or if the parent object
has been garbage collected (due to the weak reference).
"""
if self._parent_ref is None:
return None
return self._parent_ref() # Call the weakref.ref to get the object or None
@property
def full_name(self) -> str:
"""Returns the fully qualified node name, starting from the root.
Example: 'iso.org.dod.internet.private.enterprise.firebird.subsytem'.
Returns just the node's `name` if it has no parent.
"""
return self.name if self.parent is None else self.parent.full_name + '.' + self.name
def validate_parent_child_equality(parent: OIDNode, child: OIDNode) -> None:
"""Verifies that key attributes match between a placeholder child and its resolved node.
When building the tree, a child node might first be defined partially within its
parent's specification. Later, the full definition is loaded from the child's
own specification file (`node_spec`). This function ensures that fundamental
attributes defined in both places (parent's spec vs. child's spec) are consistent.
It compares attributes listed in `KEY_ATTRS`.
Arguments:
parent: The placeholder `OIDNode` instance as defined in the parent's
specification's 'children' list.
child: The fully loaded `OIDNode` instance created from its own
specification file.
Raises:
ValueError: If the value of any attribute listed in `KEY_ATTRS` differs
between the `parent` placeholder and the resolved `child` node.
"""
for attr in KEY_ATTRS:
if getattr(parent, attr) != getattr(child, attr):
raise ValueError(f"Parent and node spec. differ in attribute '{attr}'")
def build_tree(nodes: Iterable[OIDNode]) -> OIDNode:
"""Constructs the OID node hierarchy from a flat collection of nodes.
Takes an iterable of `OIDNode` objects (typically loaded from multiple
specification files) and assembles them into a tree structure based on
parent-child relationships defined via `node_spec` URLs.
It identifies the root node, creates a map of nodes keyed by their `node_spec`,
and then recursively traverses the tree starting from the root. During traversal,
it replaces placeholder child nodes (of type `NODE`) with the corresponding fully
loaded nodes found in the map, ensuring attribute consistency using
`validate_parent_child_equality` and setting the correct parent reference.
Arguments:
nodes: An iterable containing all `OIDNode` objects to be assembled into a tree.
Must include one node identified as `OIDNodeType.ROOT`.
Returns:
The root `OIDNode` of the fully assembled tree hierarchy.
Raises:
Error: If the input `nodes` does not contain exactly one node with
`node_type` set to `OIDNodeType.ROOT`.
ValueError: If attribute inconsistencies are found between a placeholder
child and its resolved node during validation.
KeyError: If a `node_spec` listed in a child node does not correspond
to any node found in the input `nodes` iterable.
"""
def traverse(_root: OIDNode, _node: OIDNode | None=None) -> None:
"""Recursive helper function to link nodes."""
current_node = _node if _node is not None else _root
# Iterate over a copy of children list indices for safe replacement
for i in range(len(current_node.children)):
child = current_node.children[i]
# Only process placeholder NODE types that need replacing
if child.node_type is OIDNodeType.NODE and child.node_spec in node_map:
# Get the fully loaded node corresponding to the child's spec
resolved_node = node_map[child.node_spec]
# Preserve the number assigned by the parent spec
resolved_node.number = child.number
# Validate consistency between placeholder and resolved node
validate_parent_child_equality(child, resolved_node)
# Set the correct parent on the resolved node
resolved_node.set_parent(current_node)
# Replace the placeholder child with the resolved node in the parent's list
current_node.children[i] = resolved_node
# Recurse down into the newly linked node
traverse(_root, resolved_node)
# elif child.node_type is OIDNodeType.NODE and child.node_spec not in node_map:
# This case should ideally raise an error if node_spec is expected
# pass # Or raise an error / log a warning
node_map: dict[str, OIDNode] = {}
root: OIDNode | None = None
root_count = 0
for node in nodes:
# Map nodes by their spec URL if they are roots of their own spec files
# (ROOT or NODE types typically originate from their own spec file)
if node.node_spec and node.node_type in (OIDNodeType.ROOT, OIDNodeType.NODE):
# Check for duplicate spec URLs - should not happen if specs are unique
if node.node_spec in node_map:
# Handle duplicate spec URL definitions if necessary
# raise Error(f"Duplicate node definition found for spec URL: {node.node_spec}")
pass # Or decide on merging/error strategy
node_map[node.node_spec] = node
# Identify the root node
if node.node_type is OIDNodeType.ROOT:
# This assumes the ROOT node's spec URL is also mapped correctly above
root = node
root_count += 1
# Validate root node presence
if root is None:
raise Error("Cannot build tree: ROOT node not found in the provided nodes.")
if root_count > 1:
raise Error(f"Cannot build tree: Found {root_count} ROOT nodes. Only one is allowed.")
# Start the recursive linking process from the identified root
traverse(root)
return root