@@ -245,6 +245,46 @@ class AddOverwriteClassOptions(Enum):
245245OVERWRITE_CLASS_OPTIONS = Union [OverwriteOptions , AddOverwriteClassOptions ]
246246
247247
248+ def _explicitly_set_empty (entity : OswBaseModel , jsondata : dict ) -> dict :
249+ """Returns the top-level jsondata entries the caller set to an empty value.
250+
251+ remove_empty() cannot tell an empty value that was assigned on purpose from
252+ one that is merely a default, so it strips both. Assigning an empty value is
253+ how a property gets cleared, though: the merge in _apply_overwrite_policy()
254+ starts from the remote content, so a local key that was stripped leaves the
255+ remote value in place and the property can never be emptied. pydantic records
256+ the fields that were explicitly supplied in __fields_set__, which is enough to
257+ keep those and drop the rest.
258+
259+ Parameters
260+ ----------
261+ entity:
262+ The entity the jsondata was serialized from
263+ jsondata:
264+ The serialized entity, before remove_empty() has been applied
265+
266+ Returns
267+ -------
268+ result:
269+ The subset of jsondata that is empty but was explicitly set
270+ """
271+ fields_set = getattr (entity , "__fields_set__" , None )
272+ if not fields_set :
273+ return {}
274+ fields = getattr (entity , "__fields__" , {})
275+ keys = set ()
276+ for name in fields_set :
277+ # json() is called without by_alias, so the key is the field name. Accept
278+ # the alias too, so that a field declared with one (model.PrefectFlow
279+ # names 'schema_' after 'schema') is matched either way.
280+ keys .add (name )
281+ if name in fields :
282+ keys .add (fields [name ].alias )
283+ return {
284+ key : value for key , value in jsondata .items () if key in keys and is_empty (value )
285+ }
286+
287+
248288class OSW (BaseModel ):
249289 """Bundles core functionalities of OpenSemanticWorld (OSW)"""
250290
@@ -1653,6 +1693,19 @@ def _sync_per_property(self) -> None:
16531693 f"'{ self .overwrite .value } ', which acts on the entity as a "
16541694 f"whole. Use an OverwriteOptions value for 'overwrite'."
16551695 )
1696+ if self .per_property :
1697+ unknown = [
1698+ key for key in self .per_property if key not in self .model .__fields__
1699+ ]
1700+ if unknown :
1701+ # validate_per_property() only runs at construction and when
1702+ # 'per_property' itself is assigned. Reassigning 'model' would
1703+ # otherwise just drop the now-unknown keys from the rebuilt
1704+ # mapping, leaving a setting that silently never applies.
1705+ raise ValueError (
1706+ f"Property not found in model '{ self .model .__name__ } ': "
1707+ f"{ ', ' .join (unknown )} "
1708+ )
16561709 per_property_ = self .per_property or {}
16571710 self ._per_property = {
16581711 field_name : per_property_ .get (field_name , self .overwrite )
@@ -1795,7 +1848,14 @@ def set_content(content_to_set: dict) -> None:
17951848 # We want those not to be listed as keys
17961849 local_content ["jsondata" ] = json .loads (param .entity .json (exclude_none = True ))
17971850 if param .remove_empty :
1851+ # an empty value the caller assigned on purpose is how a property gets
1852+ # cleared, so it has to survive remove_empty() and reach the merge
1853+ # below, where step c) can let it win over the remote value
1854+ explicitly_set_empty = _explicitly_set_empty (
1855+ param .entity , local_content ["jsondata" ]
1856+ )
17981857 remove_empty (local_content ["jsondata" ])
1858+ local_content ["jsondata" ].update (explicitly_set_empty )
17991859 if param .debug :
18001860 _logger .debug (f"'local_content': { local_content !s} " )
18011861 # Apply the overwrite logic
@@ -1864,9 +1924,28 @@ class StoreEntityParam(OswBaseModel):
18641924 overwrite_per_class : Optional [List [OSW .OverwriteClassParam ]] = None
18651925 """A list of OverwriteClassParam objects. If a class specific overwrite setting
18661926 is set, this setting is used.
1927+
1928+ Each object is copied when it is validated into this parameter, so it is a
1929+ snapshot taken here: configure a policy fully before passing it, since
1930+ mutating it afterwards no longer affects this StoreEntityParam.
1931+
1932+ A class is identified by its OSW type. A subclass that does not redefine
1933+ 'type' therefore shares its parent's policy and cannot be given one of its
1934+ own. Generated model classes always redefine it; hand-written subclasses
1935+ such as the file controllers do not.
18671936 """
18681937 remove_empty : Optional [bool ] = True
1869- """If true, remove key with an empty string value from the jsondata."""
1938+ """If true, remove key with an empty string value from the jsondata.
1939+
1940+ A top-level property that was explicitly assigned an empty value is kept
1941+ rather than stripped, so that '', [] or {} reaches the merge. Whether it
1942+ then clears the stored value still depends on the overwrite setting for
1943+ that property: 'true' clears it, 'only empty' clears it only if the stored
1944+ value is empty too, and 'false' (or 'keep existing', the default) leaves
1945+ the stored value in place.
1946+
1947+ Assign None instead of an empty value for 'not known', otherwise the
1948+ property is cleared on the wiki."""
18701949 change_id : Optional [str ] = None
18711950 """ID to document the change. Entities within the same store_entity() call will
18721951 share the same change_id. This parameter can also be used to link multiple
@@ -1889,11 +1968,9 @@ class StoreEntityParam(OswBaseModel):
18891968 seconds later if a page is reported as missing. If the query itself fails,
18901969 the pages are reported as stored and an error is logged. Has no effect if
18911970 'offline' is True."""
1892- _overwrite_per_class : Dict [str , Dict [str , OSW .OverwriteClassParam ]] = (
1893- PrivateAttr ()
1894- )
1895- """Private attribute, for internal use only. Use 'overwrite_per_class'
1896- instead."""
1971+ _overwrite_per_class : Dict [str , OSW .OverwriteClassParam ] = PrivateAttr ()
1972+ """Private attribute, for internal use only. Maps the OSW type of a model
1973+ class to its policy. Use 'overwrite_per_class' instead."""
18971974
18981975 def __init__ (self , ** data ):
18991976 super ().__init__ (** data )
@@ -1919,31 +1996,46 @@ def __init__(self, **data):
19191996 )
19201997 if self .overwrite is None :
19211998 self .overwrite = self .__fields__ ["overwrite" ].get_default ()
1922- self ._overwrite_per_class = {"by name" : {}, "by type" : {} }
1999+ self ._overwrite_per_class = {}
19232000 if self .overwrite_per_class is not None :
19242001 for param in self .overwrite_per_class :
1925- model_name = param .model .__name__
19262002 model_type = param .model .__fields__ ["type" ].get_default ()[0 ]
1927- if (
1928- model_name in self ._overwrite_per_class ["by name" ].keys ()
1929- or model_type in self ._overwrite_per_class ["by type" ].keys ()
1930- ):
2003+ declared = self ._overwrite_per_class .get (model_type )
2004+ if declared is not None :
2005+ other = declared .model
2006+ detail = ""
2007+ # issubclass(X, X) is True, so the same class declared
2008+ # twice would otherwise be blamed on inheritance
2009+ if param .model is not other and (
2010+ issubclass (param .model , other )
2011+ or issubclass (other , param .model )
2012+ ):
2013+ detail = (
2014+ " A subclass that does not redefine 'type' inherits "
2015+ "it, so store_entity() cannot tell the two apart. "
2016+ "Declare a single OverwriteClassParam for both."
2017+ )
19312018 raise ValueError (
1932- f"More than one OverwriteClassParam for the class "
1933- f"'{ model_type } ' ({ model_name } ) has been passed in the "
1934- f"list to 'overwrite_per_class'!"
2019+ f"More than one OverwriteClassParam for the type "
2020+ f"'{ model_type } ' has been passed in the list to "
2021+ f"'overwrite_per_class': '{ other .__name__ } ' and "
2022+ f"'{ param .model .__name__ } '.{ detail } "
19352023 )
1936- self ._overwrite_per_class ["by name" ][model_name ] = param
1937- self ._overwrite_per_class ["by type" ][model_type ] = param
2024+ self ._overwrite_per_class [model_type ] = param
19382025
19392026 class StoreEntityResult (OswBaseModel ):
19402027 """Result of store_entity()"""
19412028
19422029 change_id : str
19432030 """The ID of the change"""
19442031 pages : Dict [str , WtPage ]
1945- """The pages that have been successfully stored, keyed by full page title.
1946- On partial failure this contains only the successfully-stored pages."""
2032+ """The pages that have been successfully processed, keyed by full page title.
2033+ On partial failure this contains only the successfully-processed pages.
2034+ Includes the pages listed in 'skipped', which were left untouched."""
2035+ skipped : Dict [str , WtPage ] = {}
2036+ """Existing pages that were not written because the applicable overwrite
2037+ setting was 'keep existing', keyed by full page title. These also appear in
2038+ 'pages'; subtract them to get the pages that were actually written."""
19472039 failed : Dict [str , Exception ] = {}
19482040 """Entities that could not be stored, mapped to the exception that caused the
19492041 failure. Empty on full success. The key is the full page title where one could
@@ -1958,13 +2050,20 @@ class StoreEntityPartialError(Exception):
19582050 """Raised by store_entity() when one or more entities could not be stored.
19592051
19602052 Carries the partial ``StoreEntityResult`` so callers can learn exactly which
1961- entities were written (``stored`` / ``result.pages``) and which failed
1962- (``failed`` / ``result.failed``) without a separate existence query.
2053+ entities were written (``stored``), which existed already and were left
2054+ untouched by 'keep existing' (``skipped``), and which failed (``failed`` /
2055+ ``result.failed``) without a separate existence query.
19632056 """
19642057
19652058 def __init__ (self , result : OSW .StoreEntityResult ):
19662059 self .result = result
1967- self .stored = list (result .pages .keys ())
2060+ # a page kept by 'keep existing' was not written, so it does not
2061+ # belong in 'stored'. 'result.pages' still lists it, for callers
2062+ # that only care about which entities are on the wiki afterwards.
2063+ self .skipped = list (result .skipped .keys ())
2064+ self .stored = [
2065+ title for title in result .pages if title not in result .skipped
2066+ ]
19682067 self .failed = result .failed
19692068 total = len (result .pages ) + len (result .failed )
19702069 failed_titles = ", " .join (result .failed .keys ())
@@ -2062,6 +2161,7 @@ def store_entity(
20622161
20632162 max_index = len (param .entities )
20642163 created_pages = {}
2164+ skipped_pages = {}
20652165 edited_titles = set ()
20662166 """Titles of the pages an edit was sent for, to be verified below."""
20672167
@@ -2211,6 +2311,8 @@ def store_entity_(
22112311 f"'{ page .get_url ()} '."
22122312 )
22132313 created_pages [page .title ] = page
2314+ if kept_existing :
2315+ skipped_pages [page .title ] = page
22142316
22152317 sorted_entities = OSW .sort_list_of_entities_by_class (param .entities )
22162318 _logger .info (
@@ -2233,7 +2335,7 @@ class UploadObject(BaseModel):
22332335 upload_index = 0
22342336 for class_type , entities in sorted_entities .by_type .items ():
22352337 # Try to get a class specific overwrite setting
2236- class_param = param ._overwrite_per_class [ "by type" ] .get (class_type , None )
2338+ class_param = param ._overwrite_per_class .get (class_type , None )
22372339 if class_param is None :
22382340 entity_model = entities [0 ].__class__
22392341 class_param = OSW .OverwriteClassParam (
@@ -2336,7 +2438,10 @@ def failure_title_(upload_object: UploadObject) -> str:
23362438 del created_pages [title ]
23372439
23382440 store_result = OSW .StoreEntityResult (
2339- change_id = param .change_id , pages = created_pages , failed = failed
2441+ change_id = param .change_id ,
2442+ pages = created_pages ,
2443+ skipped = skipped_pages ,
2444+ failed = failed ,
23402445 )
23412446 if failed :
23422447 # Surface partial/total failure so callers cannot mistake a dropped
0 commit comments