Changeset 3389960 in flexoentity


Ignore:
Timestamp:
02/27/26 13:47:23 (3 days ago)
Author:
Enrico Schwass <ennoausberlin@…>
Branches:
unify_backends
Children:
c1144fd
Parents:
54941b4
Message:

redesign of Identity and PersistanceBackends - this is a breaking change.

Files:
17 edited

Legend:

Unmodified
Added
Removed
  • flexoentity/composite_backend.py

    r54941b4 r3389960  
    11from .persistance_backend import PersistanceBackend
    22from .flexo_entity import FlexoEntity
    3 from .in_memory_backend import InMemoryBackend
     3
    44
    55class CompositeBackend(PersistanceBackend):
    66    """
    7     A backend that wraps multiple real backends.
     7    Backend wrapper.
    88
    9     - Reads always come from the first backend (e.g., InMemoryBackend).
    10     - Writes propagate to all backends.
     9    Option A semantics:
     10      - Reads come from the primary backend only.
     11      - Writes propagate to primary and all sync backends.
     12      - All backends store/return dicts.
    1113    """
    1214
    13     def __init__(self, authoritative_backend, sync_backends):
    14         if not issubclass(authoritative_backend.entity_class, FlexoEntity):
    15             raise TypeError("entity_class must be a subclass of FlexOEntity")
     15    def __init__(self, authoritative_backend, sync_backends=None):
     16        # Validate entity_class existence and compatibility
     17        entity_class = getattr(authoritative_backend, "entity_class", None)
     18        if entity_class is None:
     19            raise TypeError("primary_backend must expose .entity_class")
     20
     21        if not issubclass(entity_class, FlexoEntity):
     22            raise TypeError("entity_class must be a subclass of FlexoEntity")
     23
     24        super().__init__(entity_class)
    1625
    1726        self._primary = authoritative_backend
     27        self.sync_backends = list(sync_backends or [])
    1828
    19         self.read_backend = InMemoryBackend(self.primary.entity_class)
    20         # Default: create an in-memory backend as the primary backend
    21 
    22         if sync_backends is None:
    23             self.sync_backends = []
    24         else:
    25             self.sync_backends = sync_backends
    26 
    27         # Validate all backends
    2829        for b in self.sync_backends:
    29             if b.entity_class != self.primary.entity_class:
     30            if b.entity_class != self._primary.entity_class:
    3031                raise TypeError(
    3132                    f"Backend {b} does not match entity_class={self.entity_class.__name__}"
     
    3435    @property
    3536    def primary(self):
    36         """The backend used for all read operations."""
    3737        return self._primary
    3838
    39     @primary.setter
    40     def primary(self, new_primary):
    41         self._primary = new_primary
    42 
    4339    def add_sync_backend(self, backend, clear=False):
    44         """
    45         Append an additional backend.
    46         If clear=True, backend is wiped before syncing.
    47         """
    4840        if backend.entity_class != self.primary.entity_class:
    4941            raise TypeError("Backend entity_class mismatch")
    5042
    5143        if clear:
    52             backend.delete_all()
     44            backend.clear()
    5345
    5446        # Sync current data into backend
    55         for entity in self.primary.load_all():
    56             backend.save(entity)
     47        for d in self.primary.load_all():
     48            backend.save(d)
    5749
    5850        self.sync_backends.append(backend)
    5951
    6052    def remove_backend(self, backend):
    61         """
    62         Remove a backend. Primary backend cannot be removed.
    63         """
    6453        if backend is self.primary:
    6554            raise ValueError("Cannot remove the primary backend")
    6655        self.sync_backends.remove(backend)
     56
    6757    # ---------------------------------------------------------
    68     # Write operations propagate to *all* backends
     58    # Write operations propagate to *all* backends (dicts)
    6959    # ---------------------------------------------------------
    7060
    71     def save(self, entity):
    72         self.primary.save(entity)
     61    def save(self, entity_dict: dict):
     62        self.primary.save(entity_dict)
    7363        for b in self.sync_backends:
    74             b.save(entity)
     64            b.save(entity_dict)
    7565
    76     def update(self, entity):
     66    def update(self, entity_dict: dict):
     67        self.primary.update(entity_dict)
    7768        for b in self.sync_backends:
    78             b.update(entity)
     69            b.update(entity_dict)
    7970
    8071    def delete(self, flexo_id: str):
     72        self.primary.delete(flexo_id)
    8173        for b in self.sync_backends:
    8274            b.delete(flexo_id)
    8375
    8476    # ---------------------------------------------------------
    85     # Read operations use only the *primary* backend
     77    # Read operations from primary only
    8678    # ---------------------------------------------------------
    8779
     
    9385
    9486    # ---------------------------------------------------------
    95     # Optional: flush from primary backend to all others
     87    # Sync helpers
    9688    # ---------------------------------------------------------
    9789
    98     def sync_all(self):
     90    def sync_all(self, clear_targets=False):
    9991        """
    100         Push all data from the primary backend to the other backends.
    101         Useful if secondary backends were empty initially.
     92        Push all data from primary backend to the other backends.
     93        If clear_targets=True, wipe sync backends first.
    10294        """
    103         for entity in primary.load_all():
     95        if clear_targets:
    10496            for b in self.sync_backends:
    105                 b.save(entity)
     97                b.clear()
     98
     99        for d in self.primary.load_all():
     100            for b in self.sync_backends:
     101                b.save(d)
    106102
    107103    def clear(self):
    108104        self.primary.clear()
    109105        for b in self.sync_backends:
    110             if hasattr(b, "clear"):
    111                 b.clear()
    112 
    113     # ---------------------------------------------------------
     106            b.clear()
    114107
    115108    def __repr__(self):
    116109        names = ", ".join(b.__class__.__name__ for b in self.sync_backends)
    117         return f"<CompositeBackend [{names}]>"
     110        return f"<CompositeBackend primary={self.primary.__class__.__name__} sync=[{names}]>"
  • flexoentity/domain.py

    r54941b4 r3389960  
    1 from dataclasses import dataclass
    21from flexoentity import FlexoEntity, EntityType
    32
    4 
    5 @dataclass
    63class Domain(FlexoEntity):
    7     """
    8     I am a helper class to provide more information than just a
    9     domain abbreviation in FlexOID, doing mapping and management
    10     """
    114
    125    ENTITY_TYPE = EntityType.DOMAIN
    136
    14     fullname: str = ""
    15     description: str = ""
    16     classification: str = "UNCLASSIFIED"
     7    def __init__(self, *, flexo_id, fullname="", description="", subtype=None, fingerprint=None,
     8                 classification="UNCLASSIFIED", origin=None, originator_id=None, owner_id=None,):
     9
     10        self.fullname = fullname
     11        self.description = description
     12        self.classification = classification
     13
     14        super().__init__(
     15            flexo_id=flexo_id,
     16            subtype=subtype,
     17            fingerprint=fingerprint,
     18            origin=origin,
     19            originator_id=originator_id,
     20            owner_id=owner_id,
     21        )
    1722
    1823    @classmethod
  • flexoentity/entity_manager.py

    r54941b4 r3389960  
    1717class EntityManager:
    1818    """
    19     Backend-agnostic manager for any FlexOEntity subclass.
     19    Backend-agnostic manager for any FlexoEntity subclass.
    2020
    21     Responsibilities:
    22       - enforce ENTITY_CLASS (e.g., FlexoUser, Domain, Question)
    23       - convert entities <-> dicts
    24       - delegate persistence to backend
    25       - provide a consistent CRUD API for GUIs and CLI
    26       - avoid backend-specific code in subclasses
     21    Option A contract:
     22      - backends store/return dicts only
     23      - manager converts dict <-> entity
    2724    """
    2825
     
    3431
    3532        if not issubclass(self.ENTITY_CLASS, FlexoEntity):
    36             raise TypeError("ENTITY_CLASS must be a subclass of FlexOEntity")
     33            raise TypeError("ENTITY_CLASS must be a subclass of FlexoEntity")
     34
    3735        self.local_backend = local_backend
    3836        self.staging_backend = staging_backend
    3937        self.permanent_backend = permanent_backend
    4038
     39    # ------------------------------------------------------------------
     40    # Conversion helpers
     41    # ------------------------------------------------------------------
     42
     43    def _to_dict(self, entity) -> dict:
     44        self._ensure_type(entity)
     45        return entity.to_dict()
     46
     47    def _to_entity(self, entity_dict: dict):
     48        if entity_dict is None:
     49            return None
     50        return self.ENTITY_CLASS.from_dict(entity_dict)
     51
     52    def _to_entities(self, dicts: list[dict]):
     53        return [self._to_entity(d) for d in dicts]
     54
     55    # ------------------------------------------------------------------
     56    # Locators
     57    # ------------------------------------------------------------------
     58
    4159    def backend_of_domain_id(self, domain_id: str):
     60        # Note: this converts dict->entity because domain_id is a FlexoEntity property
    4261        for backend_name, backend in [
    43                 ("local", self.local_backend),
    44                 ("staging", self.staging_backend),
    45                 ("permanent", self.permanent_backend),
     62            ("local", self.local_backend),
     63            ("staging", self.staging_backend),
     64            ("permanent", self.permanent_backend),
    4665        ]:
    47             for dom in backend.load_all():
    48                 if dom.domain_id == domain_id:
     66            for d in backend.load_all():
     67                e = self._to_entity(d)
     68                if e.domain_id == domain_id:
    4969                    return backend_name
    5070        return None
     
    5272    def backend_of_flexo_id(self, flexo_id: str):
    5373        for backend_name, backend in [
    54                 ("local", self.local_backend),
    55                 ("staging", self.staging_backend),
    56                 ("permanent", self.permanent_backend),
     74            ("local", self.local_backend),
     75            ("staging", self.staging_backend),
     76            ("permanent", self.permanent_backend),
    5777        ]:
    5878            if backend.load(flexo_id) is not None:
     
    6181
    6282    # ------------------------------------------------------------------
    63     # CRUD operations
     83    # CRUD operations (entity API)
    6484    # ------------------------------------------------------------------
    6585
    6686    def add(self, entity):
    67         """Insert a new entity."""
    68         self._ensure_type(entity)
    69         self.local_backend.save(entity)
     87        self.local_backend.save(self._to_dict(entity))
    7088
    7189    def update(self, entity):
    72         """Replace an existing entity (same flexo_id)."""
    73         self._ensure_type(entity)
    74         self.local_backend.update(entity)
     90        self.local_backend.update(self._to_dict(entity))
    7591
    7692    def delete(self, flexo_id: str):
    77         """Remove entity by string flexo_id."""
    7893        self.local_backend.delete(flexo_id)
    7994
    8095    # ------------------------------------------------------------------
    81     # Retrieval
     96    # Retrieval (entity API)
    8297    # ------------------------------------------------------------------
    8398
    8499    def get(self, flexo_id: str):
    85         """
    86         Load entity by flexo_id str.
    87         Returns ENTITY_CLASS instance or None.
    88         """
    89         return self.local_backend.load(flexo_id)
     100        d = self.local_backend.load(flexo_id)
     101        return self._to_entity(d)
    90102
    91103    # FIXME: Readd staging backend later
    92104    def all(self):
    93         """Load all entities as a list of instances."""
    94         return (self.local_backend.load_all() + # self.staging_backend.load_all() +
    95                 self.permanent_backend.load_all())
     105        dicts = (
     106            self.local_backend.load_all()
     107            # + self.staging_backend.load_all()
     108            + self.permanent_backend.load_all()
     109        )
     110        return self._to_entities(dicts)
    96111
    97112    def promote_to_staging(self, flexo_id):
    98         entity = self.local_backend.load(flexo_id)
    99         if entity is None:
    100             raise EntityNotFoundError
    101         self.staging_backend.save(entity)
     113        d = self.local_backend.load(flexo_id)
     114        if d is None:
     115            raise EntityNotFoundError(flexo_id)
     116        self.staging_backend.save(d)
    102117        self.local_backend.delete(flexo_id)
    103118
    104119    def promote_to_permanent(self, flexo_id):
    105         entity = self.staging_backend.load(flexo_id)
    106         if entity is None:
    107             raise EntityNotFoundError
    108         self.permanent_backend.save(entity)
     120        d = self.staging_backend.load(flexo_id)
     121        if d is None:
     122            raise EntityNotFoundError(flexo_id)
     123        self.permanent_backend.save(d)
    109124        self.staging_backend.delete(flexo_id)
    110125
    111126    def sync_all(self):
    112127        # sync staging → local
    113         for e in self.staging_backend.load_all():
    114             self.local_backend.save(e)
     128        for d in self.staging_backend.load_all():
     129            self.local_backend.save(d)
    115130
    116131        # sync permanent → local
    117         for e in self.permanent_backend.load_all():
    118             self.local_backend.save(e)
    119         self.refresh()
     132        for d in self.permanent_backend.load_all():
     133            self.local_backend.save(d)
     134
     135        # NOTE: refresh() is not defined here.
     136        # If you want a hook, define it explicitly, otherwise remove this call.
     137        if hasattr(self, "refresh"):
     138            self.refresh()
    120139
    121140    # ------------------------------------------------------------------
     
    124143
    125144    def exists(self, flexo_id: str) -> bool:
    126         return self.get(flexo_id) is not None
     145        return self.local_backend.load(flexo_id) is not None
    127146
    128147    def count(self) -> int:
     
    130149
    131150    def add_or_update(self, entity):
    132         """
    133         Convenience for GUIs:
    134         Insert or overwrite based on whether flexo_id exists.
    135         """
    136         if self.exists(entity.flexo_id):
     151        fid = str(entity.flexo_id)
     152        if self.exists(fid):
    137153            self.update(entity)
    138154        else:
     
    140156
    141157    def as_collection(self):
    142         return TypedCollection(self.ENTITY_CLASS, items=self.local_backend.load_all())
     158        # Collection expects entities (your current TypedCollection usage suggests this)
     159        return TypedCollection(self.ENTITY_CLASS, items=self.all())
    143160
    144161    # ------------------------------------------------------------------
     
    149166        if not isinstance(entity, self.ENTITY_CLASS):
    150167            raise TypeError(
    151                 f"Expected {self.ENTITY_CLASS.__name__}, "
    152                 f"got {type(entity).__name__}"
     168                f"Expected {self.ENTITY_CLASS.__name__}, got {type(entity).__name__}"
    153169            )
    154170
    155     # ------------------------------------------------------------------
    156 
    157171    def __repr__(self):
    158         return f"<FlexoEntityManager for {self.ENTITY_CLASS.__name__}>"
     172        return f"<EntityManager for {self.ENTITY_CLASS.__name__}>"
  • flexoentity/flexo_entity.py

    r54941b4 r3389960  
    129129
    130130
    131 @dataclass()
    132131class FlexoEntity(ABC):
    133132    """
     
    160159    I am the living, editable layer that connects identity with accountability.
    161160    """
    162     _in_factory: bool = field(default=False, repr=False)
    163     subtype: str = ""
    164     flexo_id: Optional[FlexOID] = field(default=None)
    165     fingerprint: str = field(default_factory=str)
    166     originator_id: UUID = field(default=UUID(int=0))
    167     owner_id: UUID = field(default=UUID(int=0))
    168     origin: Optional[str] = field(default=None)
    169161
    170162    def with_new_owner(self, new_owner: UUID):
     
    174166        return copy
    175167
     168    def __init__(self, *, flexo_id, subtype=None, fingerprint=None,
     169                 origin=None, originator_id=None, owner_id=None):
     170
     171        if flexo_id is None:
     172            raise ValueError("flexo_id must be provided")
     173
     174        self.flexo_id = flexo_id
     175        self.subtype = subtype or self.__class__.__name__
     176        self.origin = origin
     177        self.originator_id = originator_id
     178        self.owner_id = owner_id
     179        self.fingerprint = fingerprint or self._compute_fingerprint()
     180
    176181    @staticmethod
    177182    def canonicalize_content_dict(data) -> str:
     
    183188        """
    184189        return json.dumps(data, sort_keys=True, separators=(",", ":"))
     190
     191    def __eq__(self, other):
     192        if not isinstance(other, FlexoEntity):
     193            return NotImplemented
     194        return self.flexo_id == other.flexo_id
     195
     196    def __hash__(self):
     197        return hash(self.flexo_id)
    185198
    186199    @property
     
    211224        raise NotImplementedError("Subclasses must implement default()")
    212225
     226
    213227    @classmethod
    214228    def with_domain_id(cls, domain_id: str, **kwargs):
    215         # from .domain_manager import DomainManager
    216229        entity_type = getattr(cls, "ENTITY_TYPE", None)
    217230        if not entity_type:
    218231            raise ValueError(f"{cls.__name__} must define ENTITY_TYPE")
    219232
    220         flexo_id = FlexOID.safe_generate(
     233        oid = FlexOID.safe_generate(
    221234            domain_id=domain_id,
    222235            entity_type=entity_type.value,
    223236            state=EntityState.DRAFT.value,
    224             text=kwargs.get("text_seed", ""),
     237            text="",
    225238            version=1,
    226239        )
    227240
    228         obj = cls(flexo_id=flexo_id, _in_factory=True, **kwargs)
     241        obj = cls(flexo_id=oid, **kwargs)
    229242        obj.fingerprint = obj._compute_fingerprint()
    230243        return obj
    231 
    232     def __post_init__(self):
    233         if not self._in_factory:
    234             raise RuntimeError(
    235                 f"{self.__class__.__name__} must be created via "
    236                 f"with_domain_id() or from_dict()."
    237             )
    238 
    239         if not self.flexo_id:
    240             raise RuntimeError(
    241                 f"{self.__class__.__name__} created without flexo_id. "
    242                 f"Factory must assign it before __post_init__."
    243             )
    244 
    245         if not self.fingerprint:
    246             self.fingerprint = self._compute_fingerprint()
    247 
    248         if not self.subtype:
    249             self.subtype = self.__class__.__name__
    250244
    251245    def __str__(self):
     
    279273
    280274    @classmethod
    281     def from_dict(cls, data):
    282         """
    283         FINAL VERSION:
    284         - NEVER generates a new FlexOID.
    285         - NEVER calls with_domain().
    286         - ALWAYS restores the canonical Domain via DomainManager.
    287         """
    288         meta = data.get("meta", "")
    289 
    290         if not meta:
    291             raise ValueError("Serialized entity must include 'meta' object.")
    292 
    293         schema = meta.get("schema", {"name": "flexograder-entity",
    294                                      "version": "1.0"})
    295         name = schema.get("name")
    296         version = schema.get("version")
    297 
    298         if name != SCHEMA_NAME:
    299             raise ValueError(f"Unsupported schema name: {name}")
    300 
    301         if version != SCHEMA_VERSION:
    302             raise ValueError(
    303                 f"Unsupported schema version {version}, expected {SCHEMA_VERSION}"
    304             )
    305 
    306         flexo_id = FlexOID(meta.get("flexo_id"))
    307         subtype = meta.get("subtype")
    308         if not subtype:
    309             raise ValueError("Serialized entity must include 'subtype'.")
    310 
    311         try:
    312             owner_id = UUID(meta.get("owner_id"))
    313         except ValueError as e:
    314             logger.warning(f"Missing or wrong owner_id {e}")
    315             owner_id = UUID(int=0)
    316         try:
    317             originator_id = UUID(meta.get("originator_id"))
    318         except ValueError as e:
    319             logger.warning(f"Missing or wrong originator_id {e}")
    320             originator_id = UUID(int=0)
     275    def from_dict(cls, data: dict):
     276        meta = data["meta"]
     277        content = data["content"]
     278
     279        flexo_id = FlexOID(meta["flexo_id"])
    321280
    322281        obj = cls(
    323282            flexo_id=flexo_id,
    324             subtype=subtype,
    325             origin=meta.get("origin", ""),
    326             fingerprint=meta.get("fingerprint", ""),
    327             owner_id=owner_id,
    328             originator_id=originator_id,
    329             _in_factory=True
     283            subtype=meta.get("subtype"),
     284            fingerprint=meta.get("fingerprint"),
     285            origin=meta.get("origin"),
     286            originator_id=meta.get("originator_id"),
     287            owner_id=meta.get("owner_id"),
    330288        )
    331         obj._deserialize_content(data.get("content", {}))
     289
     290        obj._deserialize_content(content)
     291
    332292        return obj
    333293
     
    472432        old_id = self.flexo_id
    473433        self.flexo_id = self.flexo_id.with_state(EntityState.APPROVED_AND_SIGNED.value)
    474         self.flexo_id.origin = old_id
     434        self.origin = old_id
    475435        return self
    476436
  • flexoentity/flexo_signature.py

    r54941b4 r3389960  
    5656
    5757
    58 @dataclass
    5958class FlexoSignature(FlexoEntity):
    60     """
    61     I represent a digital or procedural signature for another entity.
    62 
    63     This is a stub version: I only carry logical metadata, not cryptographic proof.
    64     Later, platform-specific implementations (e.g. Windows certificate signing)
    65     can fill the 'signature_data' field with real data.
    66 
    67     Lifecycle:
    68       - Created in DRAFT → becomes APPROVED once verified
    69       - Optionally moves to PUBLISHED if distributed externally
    70     """
    7159    ENTITY_TYPE = EntityType.ATTESTATION
    7260
    73     signed_entity: Optional[FlexOID] = None
    74     signer_id: Optional[UUID] = None
     61    def __init__(
     62        self,
     63        *,
     64        flexo_id,
     65        signed_entity: Optional[FlexOID] = None,
     66        signer_id: Optional[UUID] = None,
     67        signature_data: str = "",
     68        signature_type: str = "PKCS7-DER",
     69        certificate_reference: CertificateReference | None = None,
     70        certificate_thumbprint: str = "",
     71        comment: Optional[str] = None,
     72        subtype=None,
     73        fingerprint=None,
     74        origin=None,
     75        originator_id=None,
     76        owner_id=None,
     77    ):
     78        # content fields
     79        self.signed_entity = signed_entity
     80        self.signer_id = signer_id
     81        self.signature_data = signature_data
     82        self.signature_type = signature_type
     83        self.certificate_reference = certificate_reference
     84        self.certificate_thumbprint = certificate_thumbprint
     85        self.comment = comment
    7586
    76     # PKCS#7 DER, base64-encoded
    77     signature_data: str = ""
    78     signature_type: str = "PKCS7-DER"
    79 
    80     certificate_reference: CertificateReference | None = None
    81     certificate_thumbprint: str = ""
    82 
    83     comment: Optional[str] = None
     87        # meta fields
     88        super().__init__(
     89            flexo_id=flexo_id,
     90            subtype=subtype,
     91            fingerprint=fingerprint,
     92            origin=origin,
     93            originator_id=originator_id,
     94            owner_id=owner_id,
     95        )
    8496
    8597    def _serialize_content(self):
    8698        return {
    87             "signed_entity": str(self.signed_entity),
    88             "signer_id": str(self.signer_id),
     99            "signed_entity": str(self.signed_entity) if self.signed_entity else None,
     100            "signer_id": str(self.signer_id) if self.signer_id else None,
    89101            "signature_data": self.signature_data,
    90102            "signature_type": self.signature_type,
    91             # "certificate_reference": self.certificate_reference.to_dict(),
    92103            "certificate_thumbprint": self.certificate_thumbprint,
    93             "comment": self.comment
     104            "comment": self.comment,
    94105        }
    95106
     
    100111    @classmethod
    101112    def default(cls):
    102         """Required by FlexoEntity. Returns an empty draft signature."""
    103         return cls()
     113        """
     114        Required by FlexoEntity.
     115        Returns an empty draft signature.
     116        """
     117        return cls.with_domain_id(domain_id="GEN")
    104118
    105119    @classmethod
    106120    def create_signed(cls, data: bytes, entity: FlexOID, signer_id: UUID, backend):
    107121        sig = backend.sign(data)
     122
    108123        return cls.with_domain_id(
    109124            domain_id=entity.domain_id,
     
    112127            signature_data=base64.b64encode(sig).decode(),
    113128            certificate_reference=backend.cert_ref,
    114             certificate_thumbprint=backend.certificate_thumbprint
     129            certificate_thumbprint=backend.certificate_thumbprint,
    115130        )
    116131
     
    118133        raw = base64.b64decode(self.signature_data)
    119134        return backend.verify(data, raw)
    120 
  • flexoentity/id_factory.py

    r54941b4 r3389960  
    1717    ENTITY_TYPE — a compact entity type single letter code (e.g., "I" for ITEM)
    1818    YYMMDD      — the UTC creation date
    19     HASH        — a 12-hex BLAKE2s digest derived from canonical content
     19    HASH        — a random cryptographic nonce ensuring global uniqueness
    2020    VERSION     — a three-digit lineage counter (001-999)
    2121    STATE       — a single capital letter indicating lifecycle state
     
    2828
    2929• Draft → Approved:
    30       I generate a fully new FlexOID whose hash and version reflect
    31       the new, stable content.  This step marks the transition from
    32       provisional to permanent identity.  No extra version bump is required.
     30      I generate a fully new FlexOID
     31      This step marks the transition from provisional to permanent identity.
     32      No extra version bump is required.
    3333
    3434• Approved → Signed → Published → Obsolete:
     
    144144
    145145        version_int = int(version)
    146         if not (1 <= version_int <= cls.MAX_VERSION):
     146        if not 1 <= version_int <= cls.MAX_VERSION:
    147147            raise ValueError(f"Version {version} out of range.")
    148148
     
    255255
    256256    @staticmethod
    257     def generate(domain: str, entity_type: str, state: str, text: str, version: int = 1):
    258         """
    259         I create a new deterministic Flex-O ID.
    260 
    261         I combine the domain, entity type, and canonicalized *text*
    262         into a stable BLAKE2s hash.  My prefix therefore remains
    263         unchanged when only the state or version changes.
    264         """
    265         if not (1 <= version <= FlexOID.MAX_VERSION):
     257    def generate(domain: str, entity_type: str, state: str, text: str = "", version: int = 1):
     258        """
     259        I create a new Flex-O ID.
     260       
     261        Identity is independent from content.
     262        The hash part is now a cryptographic random nonce.
     263        """
     264
     265        if not 1 <= version <= FlexOID.MAX_VERSION:
    266266            raise ValueError(f"Version {version} exceeds limit; mark obsolete.")
    267267
     
    270270
    271271        date_part = datetime.now(timezone.utc).strftime("%y%m%d")
    272         hash_seed = canonical_seed(f"{domain}:{entity_type}:{canonical_seed(text)}")
    273         base_hash = FlexOID._blake_hash(hash_seed)
    274         return FlexOID(f"{domain}-{entity_type}{date_part}-{base_hash}@{version:03d}{state}")
     272
     273        # 12 hex characters = 48-bit nonce (same visible size as before)
     274        nonce = secrets.token_hex(6).upper()
     275
     276        return FlexOID(f"{domain}-{entity_type}{date_part}-{nonce}@{version:03d}{state}")
    275277
    276278    @staticmethod
    277     def safe_generate(domain_id, entity_type, state, text, version=1, repo=None):
    278         """
    279         I create a new deterministic ID like `generate`,
    280         but I also consult an optional *repo* to avoid hash collisions.
    281 
    282         If a different seed has already produced the same prefix,
    283         I deterministically salt my seed and regenerate a unique ID.
    284         """
    285         oid = FlexOID.generate(domain_id, entity_type, state, text, version=version)
     279    def safe_generate(domain_id, entity_type, state, text="", version=1, repo=None):
     280        """
     281        Generate a random identity and retry only if it already exists in repo.
     282        """
    286283
    287284        if repo is None:
    288             return oid
    289 
    290         existing = repo.get(str(oid)) if hasattr(repo, "get") else repo.get(oid)
    291         if not existing:
    292             return oid
    293 
    294         try:
    295             same_seed = (
    296                 getattr(existing, "text_seed", None) == text
    297                 or getattr(existing, "canonical_seed", lambda: None)() == canonical_seed(text)
    298             )
    299         except Exception:
    300             same_seed = False
    301 
    302         if same_seed:
    303             return oid
    304 
    305         logger.warning(f"FlexOID collision detected for {oid}")
    306         salt = secrets.token_hex(1)
    307         salted_text = f"{text}|salt:{salt}"
    308         return FlexOID.generate(domain_id, entity_type, state, salted_text, version=version)
     285            return FlexOID.generate(domain_id, entity_type, state, text="", version=version)
     286
     287        while True:
     288            oid = FlexOID.generate(domain_id, entity_type, state, text="", version=version)
     289            existing = repo.get(str(oid)) if hasattr(repo, "get") else repo.get(oid)
     290            if not existing:
     291                return oid
    309292
    310293    @classmethod
  • flexoentity/in_memory_backend.py

    r54941b4 r3389960  
    11import json
    2 from .flexo_entity import FlexoEntity
    32from .persistance_backend import PersistanceBackend
    43
     
    65class InMemoryBackend(PersistanceBackend):
    76    """
    8     Persistence backend for a *typed* collection of FlexOEntities.
    9 
    10     - Stores dicts internally (no entities)
    11     - Reconstructs entities using ENTITY_CLASS.from_dict() on load
    12     - Accepts entity_class injected at construction time
     7    Persistence backend storing and returning dicts only (Option A).
    138    """
    149
    1510    def __init__(self, entity_class, storage=None):
    16         if not issubclass(entity_class, FlexoEntity):
    17             raise TypeError("entity_class must be a subclass of FlexOEntity")
    18 
    19         super().__init__(entity_class = entity_class)
     11        super().__init__(entity_class=entity_class)
    2012        self._storage = storage if storage is not None else {}
    2113
    22     # ---------------------------------------------------------
    23     # Core persistence API (dict-based internally)
    24     # ---------------------------------------------------------
     14    def save(self, entity_dict: dict):
     15        flexo_id = entity_dict["meta"]["flexo_id"]
     16        self._storage[flexo_id] = entity_dict
    2517
    26     def save(self, entity):
    27         """Store serialized entity dict by flexo_id."""
    28         data = entity.to_dict()
    29         flexo_id = data["meta"]["flexo_id"]
    30         self._storage[flexo_id] = data
    31 
    32     def update(self, entity):
    33         self.save(entity)
     18    def update(self, entity_dict: dict):
     19        self.save(entity_dict)
    3420
    3521    def delete(self, flexo_id: str):
     
    3723
    3824    def load(self, flexo_id: str):
    39         """Return an entity instance or None."""
    40         d = self._storage.get(flexo_id)
    41         return None if d is None else self.entity_class.from_dict(d)
     25        return self._storage.get(flexo_id)
    4226
    4327    def load_all(self):
    44         """Return a list of entity instances."""
    45         return [
    46             self.entity_class.from_dict(d)
    47             for d in self._storage.values()
    48         ]
     28        return list(self._storage.values())
    4929
    5030    def clear(self):
    5131        self._storage.clear()
    5232
    53     # ---------------------------------------------------------
    54     # Optional: JSON file persistence
    55     # ---------------------------------------------------------
    56 
     33    # Optional file helpers still fine (dicts in/out)
    5734    def save_to_file(self, path):
    5835        with open(path, "w", encoding="utf-8") as f:
    59             json.dump(
    60                 list(self._storage.values()),
    61                 f,
    62                 ensure_ascii=False,
    63                 indent=2
    64             )
     36            json.dump(list(self._storage.values()), f, ensure_ascii=False, indent=2)
    6537
    6638    def load_from_file(self, path):
    6739        with open(path, "r", encoding="utf-8") as f:
    6840            data = json.load(f)
    69         # Replace storage with file content
    7041        self._storage = {d["meta"]["flexo_id"]: d for d in data}
  • flexoentity/json_file_backend.py

    r54941b4 r3389960  
    99
    1010    Uses an internal InMemoryBackend and syncs to a single JSON file on disk.
     11    Dict-only contract (Option A).
    1112    """
    1213
    1314    def __init__(self, entity_class, path):
     15        super().__init__(entity_class=entity_class)
    1416        self._mem = InMemoryBackend(entity_class)
    1517        self._path = path
    1618
    17     @property
    18     def entity_class(self):
    19         return self._mem.entity_class
     19    # core API delegates (dicts)
    2020
    21     # core API just delegates to memory backend
     21    def save(self, entity_dict: dict):
     22        self._mem.save(entity_dict)
    2223
    23     def save(self, entity):
    24         self._mem.save(entity)
    25 
    26     def update(self, entity):
    27         self._mem.update(entity)
     24    def update(self, entity_dict: dict):
     25        self._mem.update(entity_dict)
    2826
    2927    def delete(self, flexo_id: str):
     
    3937        self._mem.clear()
    4038
    41     # file sync
     39    # file sync (dicts)
    4240
    4341    def flush_to_file(self):
    44         data = [e.to_dict() for e in self._mem.load_all()]
     42        data = self._mem.load_all()
    4543        with open(self._path, "w", encoding="utf-8") as f:
    4644            json.dump(data, f, ensure_ascii=False, indent=2)
     
    5553        self._mem.clear()
    5654        for d in data:
    57             self._mem.save(self.entity_class.from_dict(d))
     55            self._mem.save(d)
  • flexoentity/persistance_backend.py

    r54941b4 r3389960  
    33    Interface for all persistence backends.
    44
    5     Stores serialized FlexOEntity dicts with a flexo_id key.
     5    Contract (Option A):
     6      - Backends store serialized entity dictionaries.
     7      - Backends return dictionaries (never FlexoEntity instances).
     8      - EntityManager is responsible for dict <-> entity conversion.
     9
     10    Stored dict format must include:
     11      - meta.flexo_id (string)
     12      - meta.schema.name / meta.schema.version
     13      - content (entity-specific)
    614    """
    715
     
    1725        self._entity_class = a_class
    1826
    19     def save(self, flexo_entity) -> None:
     27    # -------------------------
     28    # Write API (dict)
     29    # -------------------------
     30
     31    def save(self, entity_dict: dict) -> None:
    2032        raise NotImplementedError
    2133
    22     def update(self, flexo_entity) -> None:
     34    def update(self, entity_dict: dict) -> None:
    2335        raise NotImplementedError
    2436
    2537    def delete(self, flexo_id: str) -> None:
    2638        raise NotImplementedError
     39
     40    # -------------------------
     41    # Read API (dict)
     42    # -------------------------
    2743
    2844    def load(self, flexo_id: str) -> dict | None:
  • flexoentity/runtime_backend.py

    r54941b4 r3389960  
    11from .persistance_backend import PersistanceBackend
    2 from .flexo_entity import FlexoEntity
    32
    43
    54class RuntimeBackend(PersistanceBackend):
    65    """
    7     Runtime backend.
     6    Runtime backend (Option A).
    87
    9     - Stores entity *instances*
    10     - Guarantees identity stability
    11     - No serialization
    12     - No reconstruction
     8    - Stores dicts only (no entity instances)
     9    - Useful as an in-process cache
    1310    """
    1411
    1512    def __init__(self, entity_class):
    16         if not issubclass(entity_class, FlexoEntity):
    17             raise TypeError("entity_class must be a subclass of FlexoEntity")
     13        super().__init__(entity_class=entity_class)
     14        self._store: dict[str, dict] = {}
    1815
    19         super().__init__(entity_class=entity_class)
    20         self._store: dict[str, FlexoEntity] = {}
     16    def save(self, entity_dict: dict):
     17        self._store[entity_dict["meta"]["flexo_id"]] = entity_dict
    2118
    22     def save(self, entity):
    23         self._store[entity.flexo_id] = entity
    24 
    25     def update(self, entity):
    26         self._store[entity.flexo_id] = entity
     19    def update(self, entity_dict: dict):
     20        self.save(entity_dict)
    2721
    2822    def delete(self, flexo_id: str):
  • flexoentity/sqlite_entity_backend.py

    r54941b4 r3389960  
    55class SQLiteEntityBackend(PersistanceBackend):
    66    """
    7     SQLite backend storing **dicts**, not entities.
    8     Managers do the entity conversions.
     7    SQLite backend storing and returning **dicts only**.
     8
     9    Note:
     10      - EntityManager performs dict <-> entity conversion.
     11      - This backend does not call entity_class.from_dict().
    912    """
    1013
    1114    def __init__(self, entity_class, conn, table_name):
    1215        super().__init__(entity_class)
    13         self.entity_class = entity_class
    1416        self.conn = conn
    1517        self.table = table_name
    1618        self._init_schema()
    17 
    1819
    1920    def _init_schema(self):
     
    2627        self.conn.commit()
    2728
    28     def save(self, entity):
    29         entity_dict = entity.to_dict()
    30         fid = entity_dict["meta"]["flexo_id"]
     29    # -------------------------
     30    # Helpers
     31    # -------------------------
     32
     33    def _ensure_dict(self, entity_or_dict) -> dict:
     34        """
     35        Transitional helper:
     36          - Accept dict (preferred)
     37          - Accept FlexoEntity-like objects with .to_dict() (legacy)
     38        """
     39        if isinstance(entity_or_dict, dict):
     40            return entity_or_dict
     41        if hasattr(entity_or_dict, "to_dict"):
     42            return entity_or_dict.to_dict()
     43        raise TypeError(f"Expected dict or object with to_dict(), got {type(entity_or_dict).__name__}")
     44
     45    def _flexo_id_from_dict(self, entity_dict: dict) -> str:
     46        try:
     47            return entity_dict["meta"]["flexo_id"]
     48        except Exception as e:
     49            raise KeyError("entity_dict must contain meta.flexo_id") from e
     50
     51    # -------------------------
     52    # Writes (dict)
     53    # -------------------------
     54
     55    def save(self, entity_dict):
     56        d = self._ensure_dict(entity_dict)
     57        fid = self._flexo_id_from_dict(d)
    3158        self.conn.execute(
    3259            f"INSERT OR REPLACE INTO {self.table} (flexo_id, json) VALUES (?, ?)",
    33             (fid, json.dumps(entity_dict))
     60            (fid, json.dumps(d))
    3461        )
    3562        self.conn.commit()
    3663
    37     def update(self, entity):
    38         entity_dict = entity.to_dict()
    39         fid = entity_dict["meta"]["flexo_id"]
     64    def update(self, entity_dict):
     65        d = self._ensure_dict(entity_dict)
     66        fid = self._flexo_id_from_dict(d)
    4067        self.conn.execute(
    4168            f"UPDATE {self.table} SET json = ? WHERE flexo_id = ?",
    42             (json.dumps(entity_dict), fid)
     69            (json.dumps(d), fid)
    4370        )
    4471        self.conn.commit()
     
    5178        self.conn.commit()
    5279
     80    # -------------------------
     81    # Reads (dict)
     82    # -------------------------
     83
    5384    def load(self, flexo_id):
    5485        row = self.conn.execute(
     
    6091            return None
    6192
    62         return self.entity_class.from_dict(json.loads(row["json"]))
     93        # row indexing depends on connection row_factory; support both
     94        raw = row["json"] if isinstance(row, dict) or hasattr(row, "__getitem__") else row[0]
     95        if not isinstance(raw, str):
     96            raw = raw[0]
     97        return json.loads(raw)
    6398
    6499    def load_all(self):
     
    67102        ).fetchall()
    68103
    69         return [self.entity_class.from_dict(json.loads(r["json"])) for r in rows]
     104        result = []
     105        for r in rows:
     106            raw = r["json"] if isinstance(r, dict) or hasattr(r, "__getitem__") else r[0]
     107            if not isinstance(raw, str):
     108                raw = raw[0]
     109            result.append(json.loads(raw))
     110        return result
    70111
    71112    def clear(self):
  • tests/test_composite_backend.py

    r54941b4 r3389960  
    1010    backend = CompositeBackend(authoritative_backend=primary, sync_backends=[secondary])
    1111
    12     backend.save(sample_domain)
     12    backend.save(sample_domain.to_dict())
    1313
    1414    fid = sample_domain.flexo_id
     
    2525    backend = CompositeBackend(authoritative_backend=primary, sync_backends=[secondary])
    2626
    27     primary.save(sample_domain)
     27    primary.save(sample_domain.to_dict())
    2828
    2929    # secondary has nothing, but composite should still load from primary
    3030    fid = sample_domain.flexo_id
    31     loaded = backend.load(fid)
     31    loaded = Domain.from_dict(backend.load(fid))
    3232    assert isinstance(loaded, Domain)
    3333
     
    3939    backend = CompositeBackend(authoritative_backend=primary, sync_backends=[secondary])
    4040
    41     backend.save(sample_domain)
     41    backend.save(sample_domain.to_dict())
    4242
    4343    backend.clear()
  • tests/test_flexoid.py

    r54941b4 r3389960  
    1313import pytest
    1414from logging import Logger
    15 from flexoentity import FlexOID, canonical_seed
     15from flexoentity import FlexOID, canonical_seed, Domain
    1616
    1717
     
    5151# Generation and deterministic hashing
    5252# ──────────────────────────────────────────────
    53 
    54 def test_generate_and_hash_stability(fixed_datetime):
    55     # Fix the date so test is stable
     53def test_generate_is_not_deterministic(fixed_datetime):
    5654    fid1 = FlexOID.generate("GEN", "I", "D", "test content")
    5755    fid2 = FlexOID.generate("GEN", "I", "D", "test content")
    58     assert fid1 == fid2  # deterministic
    59     assert fid1.hash_part == fid2.hash_part
    60     assert fid1.domain_id == "GEN"
    61     assert fid1.entity_type == "I"
    6256
     57    assert fid1 != fid2
     58    assert fid1.domain_id == fid2.domain_id == "GEN"
     59    assert fid1.entity_type == fid2.entity_type == "I"
     60    assert fid1.version == fid2.version == 1
     61    assert fid1.state_code == fid2.state_code == "D"
     62
     63def test_fingerprint_is_stable():
     64    d1 = Domain.with_domain_id("GEN", fullname="A", description="B")
     65    d2 = Domain.from_dict(d1.to_dict())
     66
     67    assert d1.fingerprint == d2.fingerprint
    6368
    6469def test_safe_generate_collision(monkeypatch):
    65     # Fake repo that always returns a conflicting item with different seed
    66     class DummyRepo(dict):
    67         def get(self, key): return True
     70    first = FlexOID.generate("GEN", "I", "D", "abc")
     71
     72    class DummyRepo:
     73        def get(self, key):
     74            if key == str(first):
     75                return True
     76            return None
    6877
    6978    repo = DummyRepo()
     79
    7080    fid = FlexOID.safe_generate("GEN", "I", "D", "abc", repo=repo)
     81
    7182    assert isinstance(fid, FlexOID)
     83    assert fid != first
    7284    assert fid.state_code == "D"
    73 
    7485
    7586# ──────────────────────────────────────────────
  • tests/test_id_stress.py

    r54941b4 r3389960  
    5252    assert all("@" in id_str for id_str in ids)
    5353
    54 def test_id_generation_is_deterministic(sample_domain):
    55     """
    56     Generating the same entity twice with same inputs yields identical ID.
    57     (No runtime disambiguation; IDs are deterministic by design.)
    58     """
    59     entity_type = EntityType.ITEM
    60     estate = EntityState.DRAFT
    61     text = "identical question text"
     54# def test_id_generation_is_deterministic(sample_domain):
     55#     """
     56#     Generating the same entity twice with same inputs yields identical ID.
     57#     (No runtime disambiguation; IDs are deterministic by design.)
     58#     """
     59#     entity_type = EntityType.ITEM
     60#     estate = EntityState.DRAFT
     61#     text = "identical question text"
    6262
    63     id1 = FlexOID.generate(sample_domain.domain_id, entity_type.value, estate.value, text)
    64     id2 = FlexOID.generate(sample_domain.domain_id, entity_type.value, estate.value, text)
    65     # IDs must be identical because generation is deterministic
    66     assert id1 == id2
     63#     id1 = FlexOID.generate(sample_domain.domain_id, entity_type.value, estate.value, text)
     64#     id2 = FlexOID.generate(sample_domain.domain_id, entity_type.value, estate.value, text)
     65#     # IDs must be identical because generation is deterministic
     66#     assert id1 == id2
    6767
    6868def test_massive_lifecycle_simulation(cert_ref_linux, sample_domain):
  • tests/test_in_memory_backend.py

    r54941b4 r3389960  
    77
    88def test_save_and_load_roundtrip(local_backend, sample_domain):
    9     local_backend.save(sample_domain)
     9    local_backend.save(sample_domain.to_dict())
    1010
    11     loaded = local_backend.load(sample_domain.flexo_id)
     11    loaded_dict = local_backend.load(sample_domain.flexo_id)
     12    loaded = Domain.from_dict(loaded_dict)
     13
    1214    assert isinstance(loaded, Domain)
    13     # important: entity equality is probably identity-based, so compare dicts:
    14     assert loaded.to_dict() == sample_domain.to_dict()
    15 
    16 
     15    assert loaded == sample_domain
     16   
    1717def test_update_overwrites_entity(local_backend, sample_domain):
    18     local_backend.save(sample_domain)
     18    local_backend.save(sample_domain.to_dict())
    1919
    2020    # change something
    2121    sample_domain.description = "UPDATED DESC"
    22     local_backend.update(sample_domain)
     22    local_backend.update(sample_domain.to_dict())
    2323
    24     loaded = local_backend.load(sample_domain.flexo_id)
     24    loaded = Domain.from_dict(local_backend.load(sample_domain.flexo_id))
    2525    assert loaded.description == "UPDATED DESC"
    2626
    2727
    2828def test_delete_removes_entity(local_backend, sample_domain):
    29     local_backend.save(sample_domain)
     29    local_backend.save(sample_domain.to_dict())
    3030    local_backend.delete(sample_domain.flexo_id)
    3131
     
    3535
    3636def test_clear_removes_all(local_backend, sample_domain):
    37     local_backend.save(sample_domain)
     37    local_backend.save(sample_domain.to_dict())
    3838    local_backend.clear()
    3939
  • tests/test_json_file_backend.py

    r54941b4 r3389960  
    2222    dom2 = make_domain("PY_STRINGS")
    2323
    24     backend.save(dom1)
    25     backend.save(dom2)
     24    backend.save(dom1.to_dict())
     25    backend.save(dom2.to_dict())
    2626    backend.flush_to_file()
    2727
     
    2929    backend2 = JsonFileBackend(Domain, path)
    3030    backend2.load_from_file()
    31 
    32     loaded = backend2.load_all()
    33     ids = sorted(d.domain_id for d in loaded)
     31    loaded_dicts = backend2.load_all()
     32    domains = [Domain.from_dict(d) for d in loaded_dicts]
     33    ids = sorted(d.domain_id for d in domains)
    3434    assert ids == ["PY_ARITHM", "PY_STRINGS"]
  • tests/test_sqlite_backend.py

    r54941b4 r3389960  
    44
    55
    6 def make_domain(domain_id="PY_ARITHM"):
    7     return Domain.with_domain_id(
    8         subtype="Domain",
    9         domain_id=domain_id,
    10         fullname="PYTHON_ARITHMETIC",
    11         description="ALL ABOUT ARITHMETIC IN PYTHON",
    12         classification="UNCLASSIFIED",
    13     )
    14 
    15 
    16 def test_sqlite_roundtrip(tmp_path):
     6def test_sqlite_roundtrip(tmp_path, sample_domain):
    177    db_path = tmp_path / "domains.db"
    188    conn = sqlite3.connect(db_path)
     
    2111    backend = SQLiteEntityBackend(Domain, conn, table_name="domains")
    2212
    23     dom = make_domain()
    24     backend.save(dom)
     13    backend.save(sample_domain.to_dict())
    2514
    26     loaded = backend.load(dom.flexo_id)
    27     assert loaded.to_dict() == dom.to_dict()
     15    loaded = backend.load(sample_domain.flexo_id)
     16    assert loaded == sample_domain.to_dict()
    2817
    2918    all_loaded = backend.load_all()
    3019    assert len(all_loaded) == 1
    31     assert all_loaded[0].domain_id == "PY_ARITHM"
     20    assert Domain.from_dict(all_loaded[0]).domain_id == "PY_ARITHM"
Note: See TracChangeset for help on using the changeset viewer.