@@ -145,6 +145,46 @@ class AddOverwriteClassOptions(Enum):
145145OVERWRITE_CLASS_OPTIONS = Union [OverwriteOptions , AddOverwriteClassOptions ]
146146
147147
148+ def _explicitly_set_empty (entity : OswBaseModel , jsondata : dict ) -> dict :
149+ """Returns the top-level jsondata entries the caller set to an empty value.
150+
151+ remove_empty() cannot tell an empty value that was assigned on purpose from
152+ one that is merely a default, so it strips both. Assigning an empty value is
153+ how a property gets cleared, though: the merge in _apply_overwrite_policy()
154+ starts from the remote content, so a local key that was stripped leaves the
155+ remote value in place and the property can never be emptied. pydantic records
156+ the fields that were explicitly supplied in __fields_set__, which is enough to
157+ keep those and drop the rest.
158+
159+ Parameters
160+ ----------
161+ entity:
162+ The entity the jsondata was serialized from
163+ jsondata:
164+ The serialized entity, before remove_empty() has been applied
165+
166+ Returns
167+ -------
168+ result:
169+ The subset of jsondata that is empty but was explicitly set
170+ """
171+ fields_set = getattr (entity , "__fields_set__" , None )
172+ if not fields_set :
173+ return {}
174+ fields = getattr (entity , "__fields__" , {})
175+ keys = set ()
176+ for name in fields_set :
177+ # json() is called without by_alias, so the key is the field name. Accept
178+ # the alias too, so that a field declared with one (model.PrefectFlow
179+ # names 'schema_' after 'schema') is matched either way.
180+ keys .add (name )
181+ if name in fields :
182+ keys .add (fields [name ].alias )
183+ return {
184+ key : value for key , value in jsondata .items () if key in keys and is_empty (value )
185+ }
186+
187+
148188class OSW (BaseModel ):
149189 """Bundles core functionalities of OpenSemanticWorld (OSW)"""
150190
@@ -1411,6 +1451,19 @@ def _sync_per_property(self) -> None:
14111451 f"'{ self .overwrite .value } ', which acts on the entity as a "
14121452 f"whole. Use an OverwriteOptions value for 'overwrite'."
14131453 )
1454+ if self .per_property :
1455+ unknown = [
1456+ key for key in self .per_property if key not in self .model .__fields__
1457+ ]
1458+ if unknown :
1459+ # validate_per_property() only runs at construction and when
1460+ # 'per_property' itself is assigned. Reassigning 'model' would
1461+ # otherwise just drop the now-unknown keys from the rebuilt
1462+ # mapping, leaving a setting that silently never applies.
1463+ raise ValueError (
1464+ f"Property not found in model '{ self .model .__name__ } ': "
1465+ f"{ ', ' .join (unknown )} "
1466+ )
14141467 per_property_ = self .per_property or {}
14151468 self ._per_property = {
14161469 field_name : per_property_ .get (field_name , self .overwrite )
@@ -1553,7 +1606,14 @@ def set_content(content_to_set: dict) -> None:
15531606 # We want those not to be listed as keys
15541607 local_content ["jsondata" ] = json .loads (param .entity .json (exclude_none = True ))
15551608 if param .remove_empty :
1609+ # an empty value the caller assigned on purpose is how a property gets
1610+ # cleared, so it has to survive remove_empty() and reach the merge
1611+ # below, where step c) can let it win over the remote value
1612+ explicitly_set_empty = _explicitly_set_empty (
1613+ param .entity , local_content ["jsondata" ]
1614+ )
15561615 remove_empty (local_content ["jsondata" ])
1616+ local_content ["jsondata" ].update (explicitly_set_empty )
15571617 if param .debug :
15581618 print (f"'local_content': { local_content !s} " )
15591619 # Apply the overwrite logic
@@ -1622,9 +1682,28 @@ class StoreEntityParam(OswBaseModel):
16221682 overwrite_per_class : Optional [List [OSW .OverwriteClassParam ]] = None
16231683 """A list of OverwriteClassParam objects. If a class specific overwrite setting
16241684 is set, this setting is used.
1685+
1686+ Each object is copied when it is validated into this parameter, so it is a
1687+ snapshot taken here: configure a policy fully before passing it, since
1688+ mutating it afterwards no longer affects this StoreEntityParam.
1689+
1690+ A class is identified by its OSW type. A subclass that does not redefine
1691+ 'type' therefore shares its parent's policy and cannot be given one of its
1692+ own. Generated model classes always redefine it; hand-written subclasses
1693+ such as the file controllers do not.
16251694 """
16261695 remove_empty : Optional [bool ] = True
1627- """If true, remove key with an empty string value from the jsondata."""
1696+ """If true, remove key with an empty string value from the jsondata.
1697+
1698+ A top-level property that was explicitly assigned an empty value is kept
1699+ rather than stripped, so that '', [] or {} reaches the merge. Whether it
1700+ then clears the stored value still depends on the overwrite setting for
1701+ that property: 'true' clears it, 'only empty' clears it only if the stored
1702+ value is empty too, and 'false' (or 'keep existing', the default) leaves
1703+ the stored value in place.
1704+
1705+ Assign None instead of an empty value for 'not known', otherwise the
1706+ property is cleared on the wiki."""
16281707 change_id : Optional [str ] = None
16291708 """ID to document the change. Entities within the same store_entity() call will
16301709 share the same change_id. This parameter can also be used to link multiple
@@ -1639,11 +1718,9 @@ class StoreEntityParam(OswBaseModel):
16391718 offline : Optional [bool ] = False
16401719 """If set to True, the processed entities are not upload but only returned as WtPages.
16411720 Can be used to create WtPage objects from entities without uploading them."""
1642- _overwrite_per_class : Dict [str , Dict [str , OSW .OverwriteClassParam ]] = (
1643- PrivateAttr ()
1644- )
1645- """Private attribute, for internal use only. Use 'overwrite_per_class'
1646- instead."""
1721+ _overwrite_per_class : Dict [str , OSW .OverwriteClassParam ] = PrivateAttr ()
1722+ """Private attribute, for internal use only. Maps the OSW type of a model
1723+ class to its policy. Use 'overwrite_per_class' instead."""
16471724
16481725 def __init__ (self , ** data ):
16491726 super ().__init__ (** data )
@@ -1666,31 +1743,46 @@ def __init__(self, **data):
16661743 )
16671744 if self .overwrite is None :
16681745 self .overwrite = self .__fields__ ["overwrite" ].get_default ()
1669- self ._overwrite_per_class = {"by name" : {}, "by type" : {} }
1746+ self ._overwrite_per_class = {}
16701747 if self .overwrite_per_class is not None :
16711748 for param in self .overwrite_per_class :
1672- model_name = param .model .__name__
16731749 model_type = param .model .__fields__ ["type" ].get_default ()[0 ]
1674- if (
1675- model_name in self ._overwrite_per_class ["by name" ].keys ()
1676- or model_type in self ._overwrite_per_class ["by type" ].keys ()
1677- ):
1750+ declared = self ._overwrite_per_class .get (model_type )
1751+ if declared is not None :
1752+ other = declared .model
1753+ detail = ""
1754+ # issubclass(X, X) is True, so the same class declared
1755+ # twice would otherwise be blamed on inheritance
1756+ if param .model is not other and (
1757+ issubclass (param .model , other )
1758+ or issubclass (other , param .model )
1759+ ):
1760+ detail = (
1761+ " A subclass that does not redefine 'type' inherits "
1762+ "it, so store_entity() cannot tell the two apart. "
1763+ "Declare a single OverwriteClassParam for both."
1764+ )
16781765 raise ValueError (
1679- f"More than one OverwriteClassParam for the class "
1680- f"'{ model_type } ' ({ model_name } ) has been passed in the "
1681- f"list to 'overwrite_per_class'!"
1766+ f"More than one OverwriteClassParam for the type "
1767+ f"'{ model_type } ' has been passed in the list to "
1768+ f"'overwrite_per_class': '{ other .__name__ } ' and "
1769+ f"'{ param .model .__name__ } '.{ detail } "
16821770 )
1683- self ._overwrite_per_class ["by name" ][model_name ] = param
1684- self ._overwrite_per_class ["by type" ][model_type ] = param
1771+ self ._overwrite_per_class [model_type ] = param
16851772
16861773 class StoreEntityResult (OswBaseModel ):
16871774 """Result of store_entity()"""
16881775
16891776 change_id : str
16901777 """The ID of the change"""
16911778 pages : Dict [str , WtPage ]
1692- """The pages that have been successfully stored, keyed by full page title.
1693- On partial failure this contains only the successfully-stored pages."""
1779+ """The pages that have been successfully processed, keyed by full page title.
1780+ On partial failure this contains only the successfully-processed pages.
1781+ Includes the pages listed in 'skipped', which were left untouched."""
1782+ skipped : Dict [str , WtPage ] = {}
1783+ """Existing pages that were not written because the applicable overwrite
1784+ setting was 'keep existing', keyed by full page title. These also appear in
1785+ 'pages', where they are indistinguishable from pages that were written."""
16941786 failed : Dict [str , Exception ] = {}
16951787 """Entities that could not be stored, keyed by full page title and mapped to
16961788 the exception that caused the failure. Empty on full success."""
@@ -1739,6 +1831,7 @@ def store_entity(
17391831
17401832 max_index = len (param .entities )
17411833 created_pages = {}
1834+ skipped_pages = {}
17421835
17431836 meta_category_templates = {}
17441837 if param .namespace == "Category" :
@@ -1872,6 +1965,8 @@ def store_entity_(
18721965 f"'{ page .get_url ()} '."
18731966 )
18741967 created_pages [page .title ] = page
1968+ if kept_existing :
1969+ skipped_pages [page .title ] = page
18751970
18761971 sorted_entities = OSW .sort_list_of_entities_by_class (param .entities )
18771972 print (
@@ -1894,7 +1989,7 @@ class UploadObject(BaseModel):
18941989 upload_index = 0
18951990 for class_type , entities in sorted_entities .by_type .items ():
18961991 # Try to get a class specific overwrite setting
1897- class_param = param ._overwrite_per_class [ "by type" ] .get (class_type , None )
1992+ class_param = param ._overwrite_per_class .get (class_type , None )
18981993 if class_param is None :
18991994 entity_model = entities [0 ].__class__
19001995 class_param = OSW .OverwriteClassParam (
@@ -1970,7 +2065,10 @@ def failure_title_(upload_object: UploadObject) -> str:
19702065 failed [title ] = result
19712066
19722067 store_result = OSW .StoreEntityResult (
1973- change_id = param .change_id , pages = created_pages , failed = failed
2068+ change_id = param .change_id ,
2069+ pages = created_pages ,
2070+ skipped = skipped_pages ,
2071+ failed = failed ,
19742072 )
19752073 if failed :
19762074 # Surface partial/total failure so callers cannot mistake a dropped
0 commit comments