Changeset 3389960 in flexoentity
- Timestamp:
- 02/27/26 13:47:23 (3 days ago)
- Branches:
- unify_backends
- Children:
- c1144fd
- Parents:
- 54941b4
- Files:
-
- 17 edited
-
flexoentity/composite_backend.py (modified) (3 diffs)
-
flexoentity/domain.py (modified) (1 diff)
-
flexoentity/entity_manager.py (modified) (8 diffs)
-
flexoentity/flexo_entity.py (modified) (7 diffs)
-
flexoentity/flexo_signature.py (modified) (4 diffs)
-
flexoentity/id_factory.py (modified) (5 diffs)
-
flexoentity/in_memory_backend.py (modified) (3 diffs)
-
flexoentity/json_file_backend.py (modified) (3 diffs)
-
flexoentity/persistance_backend.py (modified) (2 diffs)
-
flexoentity/runtime_backend.py (modified) (1 diff)
-
flexoentity/sqlite_entity_backend.py (modified) (5 diffs)
-
tests/test_composite_backend.py (modified) (3 diffs)
-
tests/test_flexoid.py (modified) (2 diffs)
-
tests/test_id_stress.py (modified) (1 diff)
-
tests/test_in_memory_backend.py (modified) (2 diffs)
-
tests/test_json_file_backend.py (modified) (2 diffs)
-
tests/test_sqlite_backend.py (modified) (2 diffs)
Legend:
- Unmodified
- Added
- Removed
-
flexoentity/composite_backend.py
r54941b4 r3389960 1 1 from .persistance_backend import PersistanceBackend 2 2 from .flexo_entity import FlexoEntity 3 from .in_memory_backend import InMemoryBackend 3 4 4 5 5 class CompositeBackend(PersistanceBackend): 6 6 """ 7 A backend that wraps multiple real backends.7 Backend wrapper. 8 8 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. 11 13 """ 12 14 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) 16 25 17 26 self._primary = authoritative_backend 27 self.sync_backends = list(sync_backends or []) 18 28 19 self.read_backend = InMemoryBackend(self.primary.entity_class)20 # Default: create an in-memory backend as the primary backend21 22 if sync_backends is None:23 self.sync_backends = []24 else:25 self.sync_backends = sync_backends26 27 # Validate all backends28 29 for b in self.sync_backends: 29 if b.entity_class != self. primary.entity_class:30 if b.entity_class != self._primary.entity_class: 30 31 raise TypeError( 31 32 f"Backend {b} does not match entity_class={self.entity_class.__name__}" … … 34 35 @property 35 36 def primary(self): 36 """The backend used for all read operations."""37 37 return self._primary 38 38 39 @primary.setter40 def primary(self, new_primary):41 self._primary = new_primary42 43 39 def add_sync_backend(self, backend, clear=False): 44 """45 Append an additional backend.46 If clear=True, backend is wiped before syncing.47 """48 40 if backend.entity_class != self.primary.entity_class: 49 41 raise TypeError("Backend entity_class mismatch") 50 42 51 43 if clear: 52 backend. delete_all()44 backend.clear() 53 45 54 46 # Sync current data into backend 55 for entityin self.primary.load_all():56 backend.save( entity)47 for d in self.primary.load_all(): 48 backend.save(d) 57 49 58 50 self.sync_backends.append(backend) 59 51 60 52 def remove_backend(self, backend): 61 """62 Remove a backend. Primary backend cannot be removed.63 """64 53 if backend is self.primary: 65 54 raise ValueError("Cannot remove the primary backend") 66 55 self.sync_backends.remove(backend) 56 67 57 # --------------------------------------------------------- 68 # Write operations propagate to *all* backends 58 # Write operations propagate to *all* backends (dicts) 69 59 # --------------------------------------------------------- 70 60 71 def save(self, entity ):72 self.primary.save(entity )61 def save(self, entity_dict: dict): 62 self.primary.save(entity_dict) 73 63 for b in self.sync_backends: 74 b.save(entity )64 b.save(entity_dict) 75 65 76 def update(self, entity): 66 def update(self, entity_dict: dict): 67 self.primary.update(entity_dict) 77 68 for b in self.sync_backends: 78 b.update(entity )69 b.update(entity_dict) 79 70 80 71 def delete(self, flexo_id: str): 72 self.primary.delete(flexo_id) 81 73 for b in self.sync_backends: 82 74 b.delete(flexo_id) 83 75 84 76 # --------------------------------------------------------- 85 # Read operations use only the *primary* backend77 # Read operations from primary only 86 78 # --------------------------------------------------------- 87 79 … … 93 85 94 86 # --------------------------------------------------------- 95 # Optional: flush from primary backend to all others87 # Sync helpers 96 88 # --------------------------------------------------------- 97 89 98 def sync_all(self ):90 def sync_all(self, clear_targets=False): 99 91 """ 100 Push all data from theprimary 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. 102 94 """ 103 for entity in primary.load_all():95 if clear_targets: 104 96 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) 106 102 107 103 def clear(self): 108 104 self.primary.clear() 109 105 for b in self.sync_backends: 110 if hasattr(b, "clear"): 111 b.clear() 112 113 # --------------------------------------------------------- 106 b.clear() 114 107 115 108 def __repr__(self): 116 109 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 dataclass2 1 from flexoentity import FlexoEntity, EntityType 3 2 4 5 @dataclass6 3 class Domain(FlexoEntity): 7 """8 I am a helper class to provide more information than just a9 domain abbreviation in FlexOID, doing mapping and management10 """11 4 12 5 ENTITY_TYPE = EntityType.DOMAIN 13 6 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 ) 17 22 18 23 @classmethod -
flexoentity/entity_manager.py
r54941b4 r3389960 17 17 class EntityManager: 18 18 """ 19 Backend-agnostic manager for any Flex OEntity subclass.19 Backend-agnostic manager for any FlexoEntity subclass. 20 20 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 27 24 """ 28 25 … … 34 31 35 32 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 37 35 self.local_backend = local_backend 38 36 self.staging_backend = staging_backend 39 37 self.permanent_backend = permanent_backend 40 38 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 41 59 def backend_of_domain_id(self, domain_id: str): 60 # Note: this converts dict->entity because domain_id is a FlexoEntity property 42 61 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), 46 65 ]: 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: 49 69 return backend_name 50 70 return None … … 52 72 def backend_of_flexo_id(self, flexo_id: str): 53 73 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), 57 77 ]: 58 78 if backend.load(flexo_id) is not None: … … 61 81 62 82 # ------------------------------------------------------------------ 63 # CRUD operations 83 # CRUD operations (entity API) 64 84 # ------------------------------------------------------------------ 65 85 66 86 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)) 70 88 71 89 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)) 75 91 76 92 def delete(self, flexo_id: str): 77 """Remove entity by string flexo_id."""78 93 self.local_backend.delete(flexo_id) 79 94 80 95 # ------------------------------------------------------------------ 81 # Retrieval 96 # Retrieval (entity API) 82 97 # ------------------------------------------------------------------ 83 98 84 99 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) 90 102 91 103 # FIXME: Readd staging backend later 92 104 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) 96 111 97 112 def promote_to_staging(self, flexo_id): 98 entity= self.local_backend.load(flexo_id)99 if entityis 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) 102 117 self.local_backend.delete(flexo_id) 103 118 104 119 def promote_to_permanent(self, flexo_id): 105 entity= self.staging_backend.load(flexo_id)106 if entityis 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) 109 124 self.staging_backend.delete(flexo_id) 110 125 111 126 def sync_all(self): 112 127 # sync staging → local 113 for ein 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) 115 130 116 131 # 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() 120 139 121 140 # ------------------------------------------------------------------ … … 124 143 125 144 def exists(self, flexo_id: str) -> bool: 126 return self. get(flexo_id) is not None145 return self.local_backend.load(flexo_id) is not None 127 146 128 147 def count(self) -> int: … … 130 149 131 150 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): 137 153 self.update(entity) 138 154 else: … … 140 156 141 157 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()) 143 160 144 161 # ------------------------------------------------------------------ … … 149 166 if not isinstance(entity, self.ENTITY_CLASS): 150 167 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__}" 153 169 ) 154 170 155 # ------------------------------------------------------------------156 157 171 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 129 129 130 130 131 @dataclass()132 131 class FlexoEntity(ABC): 133 132 """ … … 160 159 I am the living, editable layer that connects identity with accountability. 161 160 """ 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)169 161 170 162 def with_new_owner(self, new_owner: UUID): … … 174 166 return copy 175 167 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 176 181 @staticmethod 177 182 def canonicalize_content_dict(data) -> str: … … 183 188 """ 184 189 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) 185 198 186 199 @property … … 211 224 raise NotImplementedError("Subclasses must implement default()") 212 225 226 213 227 @classmethod 214 228 def with_domain_id(cls, domain_id: str, **kwargs): 215 # from .domain_manager import DomainManager216 229 entity_type = getattr(cls, "ENTITY_TYPE", None) 217 230 if not entity_type: 218 231 raise ValueError(f"{cls.__name__} must define ENTITY_TYPE") 219 232 220 flexo_id = FlexOID.safe_generate(233 oid = FlexOID.safe_generate( 221 234 domain_id=domain_id, 222 235 entity_type=entity_type.value, 223 236 state=EntityState.DRAFT.value, 224 text= kwargs.get("text_seed", ""),237 text="", 225 238 version=1, 226 239 ) 227 240 228 obj = cls(flexo_id= flexo_id, _in_factory=True, **kwargs)241 obj = cls(flexo_id=oid, **kwargs) 229 242 obj.fingerprint = obj._compute_fingerprint() 230 243 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__250 244 251 245 def __str__(self): … … 279 273 280 274 @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"]) 321 280 322 281 obj = cls( 323 282 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"), 330 288 ) 331 obj._deserialize_content(data.get("content", {})) 289 290 obj._deserialize_content(content) 291 332 292 return obj 333 293 … … 472 432 old_id = self.flexo_id 473 433 self.flexo_id = self.flexo_id.with_state(EntityState.APPROVED_AND_SIGNED.value) 474 self. flexo_id.origin = old_id434 self.origin = old_id 475 435 return self 476 436 -
flexoentity/flexo_signature.py
r54941b4 r3389960 56 56 57 57 58 @dataclass59 58 class 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 verified69 - Optionally moves to PUBLISHED if distributed externally70 """71 59 ENTITY_TYPE = EntityType.ATTESTATION 72 60 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 75 86 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 ) 84 96 85 97 def _serialize_content(self): 86 98 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, 89 101 "signature_data": self.signature_data, 90 102 "signature_type": self.signature_type, 91 # "certificate_reference": self.certificate_reference.to_dict(),92 103 "certificate_thumbprint": self.certificate_thumbprint, 93 "comment": self.comment 104 "comment": self.comment, 94 105 } 95 106 … … 100 111 @classmethod 101 112 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") 104 118 105 119 @classmethod 106 120 def create_signed(cls, data: bytes, entity: FlexOID, signer_id: UUID, backend): 107 121 sig = backend.sign(data) 122 108 123 return cls.with_domain_id( 109 124 domain_id=entity.domain_id, … … 112 127 signature_data=base64.b64encode(sig).decode(), 113 128 certificate_reference=backend.cert_ref, 114 certificate_thumbprint=backend.certificate_thumbprint 129 certificate_thumbprint=backend.certificate_thumbprint, 115 130 ) 116 131 … … 118 133 raw = base64.b64decode(self.signature_data) 119 134 return backend.verify(data, raw) 120 -
flexoentity/id_factory.py
r54941b4 r3389960 17 17 ENTITY_TYPE — a compact entity type single letter code (e.g., "I" for ITEM) 18 18 YYMMDD — the UTC creation date 19 HASH — a 12-hex BLAKE2s digest derived from canonical content19 HASH — a random cryptographic nonce ensuring global uniqueness 20 20 VERSION — a three-digit lineage counter (001-999) 21 21 STATE — a single capital letter indicating lifecycle state … … 28 28 29 29 • Draft → Approved: 30 I generate a fully new FlexOID whose hash and version reflect31 the new, stable content. This step marks the transition from32 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. 33 33 34 34 • Approved → Signed → Published → Obsolete: … … 144 144 145 145 version_int = int(version) 146 if not (1 <= version_int <= cls.MAX_VERSION):146 if not 1 <= version_int <= cls.MAX_VERSION: 147 147 raise ValueError(f"Version {version} out of range.") 148 148 … … 255 255 256 256 @staticmethod 257 def generate(domain: str, entity_type: str, state: str, text: str , version: int = 1):258 """ 259 I create a new deterministicFlex-O ID.260 261 I combine the domain, entity type, and canonicalized *text*262 into a stable BLAKE2s hash. My prefix therefore remains263 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: 266 266 raise ValueError(f"Version {version} exceeds limit; mark obsolete.") 267 267 … … 270 270 271 271 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}") 275 277 276 278 @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 """ 286 283 287 284 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 309 292 310 293 @classmethod -
flexoentity/in_memory_backend.py
r54941b4 r3389960 1 1 import json 2 from .flexo_entity import FlexoEntity3 2 from .persistance_backend import PersistanceBackend 4 3 … … 6 5 class InMemoryBackend(PersistanceBackend): 7 6 """ 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). 13 8 """ 14 9 15 10 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) 20 12 self._storage = storage if storage is not None else {} 21 13 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 25 17 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) 34 20 35 21 def delete(self, flexo_id: str): … … 37 23 38 24 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) 42 26 43 27 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()) 49 29 50 30 def clear(self): 51 31 self._storage.clear() 52 32 53 # --------------------------------------------------------- 54 # Optional: JSON file persistence 55 # --------------------------------------------------------- 56 33 # Optional file helpers still fine (dicts in/out) 57 34 def save_to_file(self, path): 58 35 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) 65 37 66 38 def load_from_file(self, path): 67 39 with open(path, "r", encoding="utf-8") as f: 68 40 data = json.load(f) 69 # Replace storage with file content70 41 self._storage = {d["meta"]["flexo_id"]: d for d in data} -
flexoentity/json_file_backend.py
r54941b4 r3389960 9 9 10 10 Uses an internal InMemoryBackend and syncs to a single JSON file on disk. 11 Dict-only contract (Option A). 11 12 """ 12 13 13 14 def __init__(self, entity_class, path): 15 super().__init__(entity_class=entity_class) 14 16 self._mem = InMemoryBackend(entity_class) 15 17 self._path = path 16 18 17 @property 18 def entity_class(self): 19 return self._mem.entity_class 19 # core API delegates (dicts) 20 20 21 # core API just delegates to memory backend 21 def save(self, entity_dict: dict): 22 self._mem.save(entity_dict) 22 23 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) 28 26 29 27 def delete(self, flexo_id: str): … … 39 37 self._mem.clear() 40 38 41 # file sync 39 # file sync (dicts) 42 40 43 41 def flush_to_file(self): 44 data = [e.to_dict() for e in self._mem.load_all()]42 data = self._mem.load_all() 45 43 with open(self._path, "w", encoding="utf-8") as f: 46 44 json.dump(data, f, ensure_ascii=False, indent=2) … … 55 53 self._mem.clear() 56 54 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 3 3 Interface for all persistence backends. 4 4 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) 6 14 """ 7 15 … … 17 25 self._entity_class = a_class 18 26 19 def save(self, flexo_entity) -> None: 27 # ------------------------- 28 # Write API (dict) 29 # ------------------------- 30 31 def save(self, entity_dict: dict) -> None: 20 32 raise NotImplementedError 21 33 22 def update(self, flexo_entity) -> None:34 def update(self, entity_dict: dict) -> None: 23 35 raise NotImplementedError 24 36 25 37 def delete(self, flexo_id: str) -> None: 26 38 raise NotImplementedError 39 40 # ------------------------- 41 # Read API (dict) 42 # ------------------------- 27 43 28 44 def load(self, flexo_id: str) -> dict | None: -
flexoentity/runtime_backend.py
r54941b4 r3389960 1 1 from .persistance_backend import PersistanceBackend 2 from .flexo_entity import FlexoEntity3 2 4 3 5 4 class RuntimeBackend(PersistanceBackend): 6 5 """ 7 Runtime backend .6 Runtime backend (Option A). 8 7 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 13 10 """ 14 11 15 12 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] = {} 18 15 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 21 18 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) 27 21 28 22 def delete(self, flexo_id: str): -
flexoentity/sqlite_entity_backend.py
r54941b4 r3389960 5 5 class SQLiteEntityBackend(PersistanceBackend): 6 6 """ 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(). 9 12 """ 10 13 11 14 def __init__(self, entity_class, conn, table_name): 12 15 super().__init__(entity_class) 13 self.entity_class = entity_class14 16 self.conn = conn 15 17 self.table = table_name 16 18 self._init_schema() 17 18 19 19 20 def _init_schema(self): … … 26 27 self.conn.commit() 27 28 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) 31 58 self.conn.execute( 32 59 f"INSERT OR REPLACE INTO {self.table} (flexo_id, json) VALUES (?, ?)", 33 (fid, json.dumps( entity_dict))60 (fid, json.dumps(d)) 34 61 ) 35 62 self.conn.commit() 36 63 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) 40 67 self.conn.execute( 41 68 f"UPDATE {self.table} SET json = ? WHERE flexo_id = ?", 42 (json.dumps( entity_dict), fid)69 (json.dumps(d), fid) 43 70 ) 44 71 self.conn.commit() … … 51 78 self.conn.commit() 52 79 80 # ------------------------- 81 # Reads (dict) 82 # ------------------------- 83 53 84 def load(self, flexo_id): 54 85 row = self.conn.execute( … … 60 91 return None 61 92 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) 63 98 64 99 def load_all(self): … … 67 102 ).fetchall() 68 103 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 70 111 71 112 def clear(self): -
tests/test_composite_backend.py
r54941b4 r3389960 10 10 backend = CompositeBackend(authoritative_backend=primary, sync_backends=[secondary]) 11 11 12 backend.save(sample_domain )12 backend.save(sample_domain.to_dict()) 13 13 14 14 fid = sample_domain.flexo_id … … 25 25 backend = CompositeBackend(authoritative_backend=primary, sync_backends=[secondary]) 26 26 27 primary.save(sample_domain )27 primary.save(sample_domain.to_dict()) 28 28 29 29 # secondary has nothing, but composite should still load from primary 30 30 fid = sample_domain.flexo_id 31 loaded = backend.load(fid)31 loaded = Domain.from_dict(backend.load(fid)) 32 32 assert isinstance(loaded, Domain) 33 33 … … 39 39 backend = CompositeBackend(authoritative_backend=primary, sync_backends=[secondary]) 40 40 41 backend.save(sample_domain )41 backend.save(sample_domain.to_dict()) 42 42 43 43 backend.clear() -
tests/test_flexoid.py
r54941b4 r3389960 13 13 import pytest 14 14 from logging import Logger 15 from flexoentity import FlexOID, canonical_seed 15 from flexoentity import FlexOID, canonical_seed, Domain 16 16 17 17 … … 51 51 # Generation and deterministic hashing 52 52 # ────────────────────────────────────────────── 53 54 def test_generate_and_hash_stability(fixed_datetime): 55 # Fix the date so test is stable 53 def test_generate_is_not_deterministic(fixed_datetime): 56 54 fid1 = FlexOID.generate("GEN", "I", "D", "test content") 57 55 fid2 = FlexOID.generate("GEN", "I", "D", "test content") 58 assert fid1 == fid2 # deterministic59 assert fid1.hash_part == fid2.hash_part60 assert fid1.domain_id == "GEN"61 assert fid1.entity_type == "I"62 56 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 63 def 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 63 68 64 69 def 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 68 77 69 78 repo = DummyRepo() 79 70 80 fid = FlexOID.safe_generate("GEN", "I", "D", "abc", repo=repo) 81 71 82 assert isinstance(fid, FlexOID) 83 assert fid != first 72 84 assert fid.state_code == "D" 73 74 85 75 86 # ────────────────────────────────────────────── -
tests/test_id_stress.py
r54941b4 r3389960 52 52 assert all("@" in id_str for id_str in ids) 53 53 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.ITEM60 estate = EntityState.DRAFT61 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" 62 62 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 deterministic66 assert id1 == id263 # 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 67 67 68 68 def test_massive_lifecycle_simulation(cert_ref_linux, sample_domain): -
tests/test_in_memory_backend.py
r54941b4 r3389960 7 7 8 8 def test_save_and_load_roundtrip(local_backend, sample_domain): 9 local_backend.save(sample_domain )9 local_backend.save(sample_domain.to_dict()) 10 10 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 12 14 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 17 17 def test_update_overwrites_entity(local_backend, sample_domain): 18 local_backend.save(sample_domain )18 local_backend.save(sample_domain.to_dict()) 19 19 20 20 # change something 21 21 sample_domain.description = "UPDATED DESC" 22 local_backend.update(sample_domain )22 local_backend.update(sample_domain.to_dict()) 23 23 24 loaded = local_backend.load(sample_domain.flexo_id)24 loaded = Domain.from_dict(local_backend.load(sample_domain.flexo_id)) 25 25 assert loaded.description == "UPDATED DESC" 26 26 27 27 28 28 def test_delete_removes_entity(local_backend, sample_domain): 29 local_backend.save(sample_domain )29 local_backend.save(sample_domain.to_dict()) 30 30 local_backend.delete(sample_domain.flexo_id) 31 31 … … 35 35 36 36 def test_clear_removes_all(local_backend, sample_domain): 37 local_backend.save(sample_domain )37 local_backend.save(sample_domain.to_dict()) 38 38 local_backend.clear() 39 39 -
tests/test_json_file_backend.py
r54941b4 r3389960 22 22 dom2 = make_domain("PY_STRINGS") 23 23 24 backend.save(dom1 )25 backend.save(dom2 )24 backend.save(dom1.to_dict()) 25 backend.save(dom2.to_dict()) 26 26 backend.flush_to_file() 27 27 … … 29 29 backend2 = JsonFileBackend(Domain, path) 30 30 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) 34 34 assert ids == ["PY_ARITHM", "PY_STRINGS"] -
tests/test_sqlite_backend.py
r54941b4 r3389960 4 4 5 5 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): 6 def test_sqlite_roundtrip(tmp_path, sample_domain): 17 7 db_path = tmp_path / "domains.db" 18 8 conn = sqlite3.connect(db_path) … … 21 11 backend = SQLiteEntityBackend(Domain, conn, table_name="domains") 22 12 23 dom = make_domain() 24 backend.save(dom) 13 backend.save(sample_domain.to_dict()) 25 14 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() 28 17 29 18 all_loaded = backend.load_all() 30 19 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.
