Changeset 5c72356 in flexoentity


Ignore:
Timestamp:
11/02/25 18:49:14 (2 months ago)
Author:
Enrico Schwass <ennoausberlin@…>
Branches:
master
Children:
bf30018
Parents:
8aa20c7
Message:

fix tests due to simplifying state and type

Files:
5 edited

Legend:

Unmodified
Added
Removed
  • flexoentity/domain.py

    r8aa20c7 r5c72356  
    11from dataclasses import dataclass
    2 from flexoentity.flexo_entity import FlexoEntity, EntityType, EntityState
     2from flexoentity.flexo_entity import FlexOID, FlexoEntity, EntityType, EntityState
    33
    44
     
    1212    @classmethod
    1313    def default(cls):
    14         return cls(domain="GEN", entity_type=EntityType.DOMAIN, state=EntityState.DRAFT)
     14        return cls(domain="GEN")
    1515
    1616    def __post_init__(self):
    1717        super().__post_init__()
    1818
     19        # Generate FlexOID only if missing
     20        if not getattr(self, "flexo_id", None):
     21            domain_code = self.domain or "GEN"
     22            self.flexo_id = FlexOID.safe_generate(
     23                domain=domain_code,
     24                entity_type=EntityType.DOMAIN.value,   # 'D'
     25                estate=EntityState.DRAFT.value,      # 'D'
     26                text=self.text_seed,
     27                version=1,
     28            )
     29
     30
     31       
    1932    @property
    2033    def text_seed(self) -> str:
  • flexoentity/flexo_entity.py

    r8aa20c7 r5c72356  
    11"""
    2 versioned_entity.py — Shared base class for all Flex-O entities.
    3 Integrates with hardened id_factory.py and content fingerprint tracking.
     2flexo_entity.py - I represent the mutable counterpart of the immutable FlexOID.
     3
     4I exist to connect an entity’s immutable identity with its mutable provenance
     5and content.  My FlexOID encodes what an entity *is* - its domain, type,
     6version, and lifecycle state - while I record *who* created it, *who* owns it
     7now, and *where* it came from.
     8
     9Structure
     10──────────
     11- My `flexo_id` is the canonical source of truth.
     12  It deterministically encodes:
     13    - domain      (prefix)
     14    - entity type (single letter, e.g. I, C, M)
     15    - date + hash (prefix stability)
     16    - version     (numeric counter)
     17    - state       (single capital letter)
     18
     19- My derived properties (`state`, `entity_type`, `version`) read from that ID.
     20  They are never stored separately, so I cannot become inconsistent.
     21
     22- My attributes `origin`, `originator_id`, and `owner_id` express authorship
     23  and workflow context:
     24    - `origin` links to the FlexOID of the entity I was derived from
     25    - `originator_id` identifies the original creator (UUID)
     26    - `owner_id` identifies the current responsible user or process (UUID)
     27
     28- My optional `domain` field is a semantic hint, useful for filtering or
     29  multi-tenant setups, but the canonical domain remains encoded in my ID.
     30
     31Design principles
     32──────────────────
     33I separate immutable identity from mutable context:
     34
     35    ┌────────────────────────────┐
     36    │ FlexOID                    │
     37    │  – who I am                │
     38    │  – lifecycle state         │
     39    └────────────▲───────────────┘
     40                 │
     41    ┌────────────┴───────────────┐
     42    │ FlexoEntity (this module)  │
     43    │  – who created or owns me  │
     44    │  – where I came from       │
     45    │  – what content I hold     │
     46    └────────────────────────────┘
     47
     48This means:
     49  - My identity never changes; only my content or ownership does.
     50  - My lifecycle transitions (approve, sign, publish) are represented by new
     51    FlexOIDs, not by mutating state attributes.
     52  - My audit trail and access control live in the provenance layer, not the ID.
     53
     54I am the living object behind a FlexOID — mutable, traceable, and accountable.
    455"""
     56
    557import json
    6 import re
    758from uuid import UUID
    859from enum import Enum
    9 from dataclasses import dataclass, field
     60from dataclasses import dataclass, field, replace
    1061from typing import Optional
    1162from abc import ABC, abstractmethod
     
    67118@dataclass(kw_only=True)
    68119class FlexoEntity(ABC):
     120    """
     121    I represent a mutable entity identified by an immutable FlexOID.
     122
     123    My `flexo_id` is the single source of truth for what I am:
     124      - it encodes my domain, entity type, version, and lifecycle state.
     125    I never store those values separately, but derive them on demand.
     126
     127    I extend the raw identity with provenance and authorship data:
     128      - `origin`        → the FlexOID of the entity I was derived from
     129      - `originator_id` → the UUID of the first creator
     130      - `owner_id`      → the UUID of the current responsible person or system
     131      - `domain`        → optional semantic domain hint (for filtering or indexing)
     132
     133    I am designed to be traceable and reproducible:
     134      - My identity (`flexo_id`) never mutates.
     135      - My lifecycle transitions are represented by new FlexOIDs.
     136      - My provenance (origin, originator_id, owner_id) can change over time
     137        as the entity moves through workflows or ownership transfers.
     138
     139    Behavior
     140    ─────────
     141    - `state` and `entity_type` are read-only views derived from my FlexOID.
     142    - `to_dict()` and `from_dict()` serialize me with human-readable fields
     143      while preserving the canonical FlexOID.
     144    - Subclasses (such as questions, media items, or catalogs) extend me with
     145      domain-specific content and validation, but not with new identity rules.
     146
     147    I am the living, editable layer that connects identity with accountability.
     148    """
     149
    69150    domain: str
    70     entity_type: EntityType
    71151    subtype: str = "GENERIC"
    72     state: EntityState
    73152    flexo_id: Optional[FlexOID] = field(default=None)
    74153    fingerprint: str = field(default_factory=str)
     
    77156    origin: Optional[str] = field(default=None)
    78157
     158    def with_new_owner(self, new_owner: UUID):
     159        """Return a clone of this entity with a different owner."""
     160        copy = replace(self)
     161        copy.owner_id = new_owner
     162        return copy
     163
    79164    @property
    80165    @abstractmethod
     
    83168        raise NotImplementedError("Subclasses must define text_seed property")
    84169
     170    @property
     171    def state(self) -> EntityState:
     172        return EntityState(self.flexo_id.state_code)
     173
     174    @property
     175    def entity_type(self) -> EntityType:
     176        return EntityType(self.flexo_id.entity_type)
     177
    85178    def canonical_seed(self) -> str:
    86179        return canonical_seed(self.text_seed)
     
    98191    def __post_init__(self):
    99192        """
    100         Generate ID and content fingerprint.
    101        
    102         All entities must carry a `.domain` attribute exposing a domain code string.
    103         This may be a `Domain` instance or a temporary wrapper used by the `Domain`
    104         class itself to avoid circular initialization.
    105         """
    106 
    107         self.flexo_id = FlexOID.safe_generate(self.domain_code(),
    108                                          self.entity_type.value,
    109                                          self.state.value,
    110                                          self.text_seed,
    111                                          1)
    112         seed = canonical_seed(self.text_seed)
    113         self.fingerprint = hashlib.blake2s(seed.encode("utf-8"), digest_size=8).hexdigest().upper()
     193        Optionally generate a new FlexOID and fingerprint if none exist.
     194
     195        I check for subclass attributes `ENTITY_TYPE` and `INITIAL_STATE`.
     196        If they exist, I use them to generate a draft FlexOID.
     197        Otherwise, I skip ID generation — leaving it to the subclass.
     198        """
     199
     200        # If already has a FlexOID, do nothing
     201        if getattr(self, "flexo_id", None):
     202            return
     203
     204        # Skip if subclass doesn’t declare ENTITY_TYPE / INITIAL_STATE
     205        etype = getattr(self.__class__, "ENTITY_TYPE", None)
     206        if not etype:
     207            # No info available, do nothing
     208            return
     209
     210        # Generate a new draft FlexOID
     211        self.flexo_id = FlexOID.safe_generate(
     212            self.domain_code(),
     213            etype.value,
     214            EntityState.DRAFT.value,
     215            self.text_seed,
     216            1,
     217        )
     218
     219        # Compute fingerprint
     220        self.fingerprint = self._compute_fingerprint()
    114221
    115222    def __str__(self):
     
    135242        abbrev, fullname = (lambda p: (p[0], p[1] if len(p) > 1 else ""))(domain.split("_", 1))
    136243        domain_obj = Domain(
    137             domain=abbrev,
    138             entity_type=EntityType.DOMAIN,
    139             state=EntityState.DRAFT,  # default when reconstructing context
     244            domain=abbrev
    140245        )
    141246        obj = cls(
    142247            domain=domain_obj,
    143             entity_type=EntityType[data["entity_type"]],
    144             state=EntityState[data["state"]],
    145         )
    146         obj.flexo_id = FlexOID.from_string(data["flexo_id"])
     248        )
     249        obj.flexo_id = FlexOID(data["flexo_id"])
    147250        obj.fingerprint = data.get("fingerprint", "")
    148251        obj.origin = data.get("origin")
     
    207310        # special case: marking obsolete
    208311        if target_state == EntityState.OBSOLETE:
    209             self.flexo_id = FlexOID.with_state(self.flexo_id, "O")
    210             self.state = target_state
     312            self.flexo_id = self.flexo_id.with_state(EntityState.OBSOLETE.value)
    211313            return
    212314
     
    258360            self.origin = self.flexo_id  # optional: keep audit trail
    259361            self.flexo_id = new_fid
    260             self.state = EntityState.APPROVED
    261362            return self
    262363        raise ValueError("Only drafts can be approved")
    263364
     365
    264366    def sign(self):
    265         self._transition(EntityState.APPROVED_AND_SIGNED)
     367        """
     368        Mark entity as approved and signed without bumping version.
     369        Only changes the state letter in the FlexOID.
     370        """
     371        if self.state != EntityState.APPROVED:
     372            raise ValueError("Only approved entities can be signed.")
     373
     374        # Change only the trailing state letter (A → S)
     375        self.flexo_id = self.flexo_id.with_state(EntityState.APPROVED_AND_SIGNED.value)
     376        return self
    266377
    267378    def publish(self):
     
    279390            )
    280391
    281         new_version = self.flexo_id.version + 1
    282392        new_fid = FlexOID.safe_generate(
    283393            self.domain_code(),
     
    285395            EntityState.PUBLISHED.value,
    286396            self.text_seed,
    287             version=new_version
     397            version=self.version
    288398        )
    289399
    290400        self.origin = self.flexo_id
    291401        self.flexo_id = new_fid
    292         self.state = EntityState.PUBLISHED
    293402        return self
    294403
     
    302411        if self.state != EntityState.OBSOLETE:
    303412            self._transition(EntityState.OBSOLETE)
     413        return self
    304414
    305415    def clone_new_base(self):
     
    309419            self.domain_code(),
    310420            self.entity_type.value,
    311             self.state.value,
     421            EntityState.DRAFT.value,
    312422            self.text_seed,
    313423        )
    314         self.state = EntityState.DRAFT
     424        return self
    315425
    316426    # ───────────────────────────────────────────────────────────────
    317427    # Integrity verification
    318428    # ───────────────────────────────────────────────────────────────
     429    @staticmethod
     430    def debug_integrity(entity):
     431        """
     432        Return a dict with all values used by verify_integrity so we can see
     433        exactly why a check passes/fails.
     434        """
     435        info = {}
     436        try:
     437            fid = entity.flexo_id
     438            info["fid"] = str(fid)
     439            info["fid_domain"] = fid.domain
     440            info["fid_type"] = fid.entity_type
     441            info["fid_state"] = fid.state_code
     442            info["fid_hash_part"] = fid.hash_part
     443            info["version"] = fid.version
     444
     445            # Derived views
     446            info["derived_domain_code"] = getattr(entity, "domain_code")() if hasattr(entity, "domain_code") else None
     447            info["derived_entity_type_value"] = entity.entity_type.value
     448            info["derived_state_value"] = entity.state.value
     449
     450            # Seeds & fingerprints
     451            info["text_seed"] = entity.text_seed
     452            exp_fp = entity._compute_fingerprint()
     453            info["expected_fingerprint"] = exp_fp
     454            info["stored_fingerprint"] = getattr(entity, "fingerprint", None)
     455
     456            # Expected ID hash from current seed (should match fid.hash_part if content didn't change)
     457            exp_hash = FlexOID._blake_hash(canonical_seed(f"{fid.domain}:{fid.entity_type}:{entity.text_seed}"))
     458            info["expected_id_hash_from_seed"] = exp_hash
     459
     460            # Quick mismatches
     461            info["mismatch_fp"] = (info["stored_fingerprint"] != exp_fp)
     462            info["mismatch_hash"] = (fid.hash_part != exp_hash)
     463            info["mismatch_type"] = (entity.entity_type.value != fid.entity_type)
     464            info["mismatch_state"] = (entity.state.value != fid.state_code)
     465            info["mismatch_domain"] = (info["derived_domain_code"] is not None and info["derived_domain_code"] != fid.domain)
     466        except Exception as e:
     467            info["error"] = repr(e)
     468        return info
    319469
    320470    @staticmethod
    321471    def verify_integrity(entity) -> bool:
    322         """Verify that an entity’s content fingerprint matches its actual content."""
    323         expected_fp = hashlib.blake2s(
    324             canonical_seed(entity.text_seed).encode("utf-8"), digest_size=8
    325         ).hexdigest().upper()
    326         return expected_fp == entity.fingerprint
     472        """
     473        Verify that an entity's FlexOID, state, and fingerprint are consistent.
     474
     475        Returns False if:
     476          - flexo_id format is invalid
     477          - derived state/type mismatch the ID
     478          - fingerprint doesn't match the canonical text seed
     479        """
     480        try:
     481            if not isinstance(entity.flexo_id, FlexOID):
     482                return False
     483
     484            # Validate domain and ID coherence
     485            if entity.domain and entity.domain_code() != entity.flexo_id.domain:
     486                return False
     487            if entity.entity_type.value != entity.flexo_id.entity_type:
     488                return False
     489            if entity.state.value != entity.flexo_id.state_code:
     490                return False
     491
     492            # --- Fingerprint validation ---
     493            if hasattr(entity, "fingerprint") and entity.fingerprint:
     494                seed = canonical_seed(entity.text_seed)
     495                expected_fp = entity._compute_fingerprint()
     496                if expected_fp != entity.fingerprint:
     497                    return False
     498
     499            return True
     500
     501        except Exception as e:
     502            return False
    327503
    328504    def allowed_transitions(self) -> list[str]:
  • tests/conftest.py

    r8aa20c7 r5c72356  
    44from dataclasses import dataclass, field
    55from typing import List
    6 from flexoentity import FlexoEntity, EntityType, EntityState, Domain
     6from flexoentity import FlexOID, FlexoEntity, EntityType, EntityState, Domain
    77
    88@pytest.fixture
     
    3737class SingleChoiceQuestion(FlexoEntity):
    3838    """A minimal stub to test FlexoEntity integration."""
     39    ENTITY_TYPE = EntityType.ITEM
     40
    3941    text: str = ""
    4042    options: List[AnswerOption] = field(default_factory=list)
    4143
     44    def __post_init__(self):
     45        # If no FlexOID yet, generate a draft ID now.
     46        if not getattr(self, "flexo_id", None):
     47            domain_code = (
     48                self.domain.domain if isinstance(self.domain, Domain) else "GEN"
     49            )
     50            self.flexo_id = FlexOID.safe_generate(
     51                domain=domain_code,
     52                entity_type=EntityType.ITEM.value,     # 'I'
     53                estate=EntityState.DRAFT.value,        # 'D'
     54                text=self.text_seed or self.text,
     55                version=1,
     56            )
    4257
    4358    @classmethod
    4459    def default(cls):
    45         return cls(domain=Domain(domain="GEN",
    46                                  entity_type=EntityType.DOMAIN,
    47                                  state=EntityState.DRAFT),
    48                    state=EntityState.DRAFT, entity_type=EntityType.ITEM)
     60        return cls(domain=Domain(domain="GEN"))
    4961
    5062    def to_dict(self):
     
    6880    @classmethod
    6981    def from_dict(cls, data):
    70         obj = cls(
     82        obj = cls(domain=Domain(domain="GEN"),
    7183            text=data.get("text", ""),
    7284            options=[AnswerOption.from_dict(o) for o in data.get("options", [])],
     
    7486        # restore FlexoEntity core fields
    7587        obj.domain = data.get("domain")
    76         obj.entity_type = EntityType[data.get("etype")] if "etype" in data else EntityType.ITEM
    77         obj.state = EntityState[data.get("state")] if "state" in data else EntityState.DRAFT
    7888        if "flexo_id" in data:
    79             from flexoentity import FlexOID
    8089            obj.flexo_id = FlexOID.parsed(data["flexo_id"])
    8190        return obj
     
    8796@pytest.fixture
    8897def sample_question():
    89     return SingleChoiceQuestion(domain=Domain.default(),
    90                                text="What is 2 + 2?",
    91                                options=[],
    92                                entity_type=EntityType.ITEM,
    93                                state=EntityState.DRAFT)
     98    q = SingleChoiceQuestion(domain=Domain.default(),
     99                             text="What is 2 + 2?",
     100                             options=[])
     101    q._update_fingerprint()
     102    return q
  • tests/test_id_lifecycle.py

    r8aa20c7 r5c72356  
    2020    assert q.flexo_id.version == 1
    2121
    22 
    23 def test_signing_bumps_version(sample_question):
     22def test_signing_does_not_bump_version(sample_question):
    2423    q = sample_question
    2524    q.approve()
    26     v_before = str(q.flexo_id)
     25    before = q.flexo_id
    2726    q.sign()
     27    after = q.flexo_id
     28
     29    # state changed
    2830    assert q.state == EntityState.APPROVED_AND_SIGNED
    29     assert str(q.flexo_id) != v_before
     31
     32    # version unchanged
     33    assert before.version == after.version
     34
     35    # only suffix letter differs
     36    assert before.prefix == after.prefix
     37    assert before.state_code == "A"
     38    assert after.state_code == "S"
    3039
    3140
    32 def test_publish_bumps_version(sample_question):
     41def test_publish_does_not_bump_version(sample_question):
    3342    q = sample_question
    3443    q.approve()
     
    3746    q.publish()
    3847    assert q.state == EntityState.PUBLISHED
    39     assert q.flexo_id.version == v_before + 1
     48    assert q.flexo_id.version == v_before
    4049
    4150
     
    7079    # simulate tampering
    7180    q.text = "Tampered text"
     81    print(FlexoEntity.debug_integrity(q))
    7282    assert not FlexoEntity.verify_integrity(q)
    7383
     
    112122    for _ in range(FlexOID.MAX_VERSION - 1):
    113123        q.bump_version()
     124
     125    # Next one must raise
    114126    with pytest.raises(RuntimeError, match="mark obsolete"):
    115         q.sign()
     127        q.bump_version()
  • tests/test_id_stress.py

    r8aa20c7 r5c72356  
    8282
    8383
    84 def test_version_ceiling_enforcement(sample_question):
    85     """Simulate approaching @999 to trigger obsolescence guard."""
    86     q = sample_question
    87     q.approve()
    88 
    89     # artificially bump version number to near ceiling
    90     q.flexo_id = FlexOID.from_oid_and_version(q.flexo_id, 998)
    91 
    92     # 998 → 999 is allowed
    93     q.sign()
    94     assert q.flexo_id.version == 999
    95 
    96     # 999 → 1000 should raise RuntimeError
    97     with pytest.raises(ValueError):
    98         q.publish()
    99 
    100 
    10184def test_massive_lifecycle_simulation(sample_question):
    10285    """
Note: See TracChangeset for help on using the changeset viewer.