Skip to content

Commit 10808a0

Browse files
committed
fix(core): address follow-up issues in the overwrite policy
- clear a property by assigning an empty value, honouring __fields_set__ - report pages kept by 'keep existing' in StoreEntityResult.skipped - re-validate per_property when OverwriteClassParam.model is reassigned - drop the unused 'by name' half of the class dispatch map - give file controllers the OSW category of the model they wrap
1 parent a867b65 commit 10808a0

6 files changed

Lines changed: 416 additions & 23 deletions

File tree

‎src/osw/controller/file/local.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
class LocalFileController(FileController, model.LocalFile):
1212
"""File controller for local files"""
1313

14+
type: Optional[List[str]] = model.LocalFile.__fields__["type"].get_default()
15+
"""LocalFile's category. FileController precedes model.LocalFile in the MRO, so
16+
without this the field would resolve to model.File's category instead."""
1417
label: Optional[List[model.Label]] = [model.Label(text="Unnamed file")]
1518
"""The label of the file, defaults to 'Unnamed file'"""
1619
path: Path

‎src/osw/controller/file/memory.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
class InMemoryController(FileController, model.LocalFile):
1212
"""File controller for in-memory streams"""
1313

14+
type: Optional[List[str]] = model.LocalFile.__fields__["type"].get_default()
15+
"""LocalFile's category. FileController precedes model.LocalFile in the MRO, so
16+
without this the field would resolve to model.File's category instead."""
1417
label: Optional[List[model.Label]] = [model.Label(text="Unnamed stream")]
1518
"""the label of the stream, e.g., the name of the file the stream
1619
originates from. Defaults to 'Unnamed stream'."""

‎src/osw/core.py‎

Lines changed: 119 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,46 @@ class AddOverwriteClassOptions(Enum):
145145
OVERWRITE_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+
148188
class 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

‎src/osw/utils/workflow.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,10 @@ async def register_flow(
560560
description=[model.Description(text=flow.description or "")],
561561
flow_id=str(flow_uuid),
562562
hosted_software=[get_full_title(this_tool)],
563-
domain=prefect_domain,
563+
# None rather than "" when the hostname is unknown, like url_path above:
564+
# an explicitly assigned empty value is written and would clear a domain
565+
# already stored on the wiki
566+
domain=prefect_domain or None,
564567
schema_=prefect_schema,
565568
network_port=prefect_port,
566569
url_path=prefect_path,
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Pins that every file controller keeps the OSW category of its wrapped model.
2+
3+
A controller's 'type' field decides which wiki category an entity is stored
4+
under. If a controller does not redefine 'type', it can silently inherit a
5+
different category from earlier in its MRO, filing pages under the wrong
6+
class.
7+
"""
8+
9+
import osw.model.entity as model
10+
from osw.controller.file.base import FileController
11+
from osw.controller.file.local import LocalFileController
12+
from osw.controller.file.memory import InMemoryController
13+
from osw.controller.file.wiki import WikiFileController
14+
15+
16+
def test_file_controller_keeps_files_category():
17+
assert (
18+
FileController.__fields__["type"].get_default()
19+
== model.File.__fields__["type"].get_default()
20+
)
21+
22+
23+
def test_local_file_controller_keeps_local_files_category():
24+
assert (
25+
LocalFileController.__fields__["type"].get_default()
26+
== model.LocalFile.__fields__["type"].get_default()
27+
)
28+
29+
30+
def test_in_memory_controller_keeps_local_files_category():
31+
"""Same base order as LocalFileController, so the same category applies."""
32+
assert (
33+
InMemoryController.__fields__["type"].get_default()
34+
== model.LocalFile.__fields__["type"].get_default()
35+
)
36+
37+
38+
def test_wiki_file_controller_keeps_wiki_files_category():
39+
assert (
40+
WikiFileController.__fields__["type"].get_default()
41+
== model.WikiFile.__fields__["type"].get_default()
42+
)

0 commit comments

Comments
 (0)