osw 2.3.2__tar.gz → 2.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. {osw-2.3.2 → osw-2.3.3}/CHANGELOG.md +13 -0
  2. {osw-2.3.2 → osw-2.3.3}/CITATION.cff +1 -1
  3. {osw-2.3.2 → osw-2.3.3}/PKG-INFO +3 -3
  4. {osw-2.3.2 → osw-2.3.3}/examples/gui_local_slot_editing.py +188 -75
  5. {osw-2.3.2 → osw-2.3.3}/pyproject.toml +6 -2
  6. {osw-2.3.2 → osw-2.3.3}/src/osw/model/page_package.py +4 -0
  7. {osw-2.3.2 → osw-2.3.3}/src/osw/wiki_tools.py +4 -0
  8. {osw-2.3.2 → osw-2.3.3}/src/osw/wtsite.py +23 -10
  9. {osw-2.3.2 → osw-2.3.3}/tests/test_wiki_tools.py +35 -0
  10. osw-2.3.3/tests/test_wtsite_create_page_package.py +86 -0
  11. osw-2.3.3/tests/test_wtsite_read_page_package.py +78 -0
  12. {osw-2.3.2 → osw-2.3.3}/uv.lock +5 -4
  13. {osw-2.3.2 → osw-2.3.3}/.github/actions/setup-python-env/action.yml +0 -0
  14. {osw-2.3.2 → osw-2.3.3}/.github/workflows/docs.yml +0 -0
  15. {osw-2.3.2 → osw-2.3.3}/.github/workflows/integration.yml +0 -0
  16. {osw-2.3.2 → osw-2.3.3}/.github/workflows/main.yml +0 -0
  17. {osw-2.3.2 → osw-2.3.3}/.github/workflows/on-release-main.yml +0 -0
  18. {osw-2.3.2 → osw-2.3.3}/.github/workflows/validate-codecov-config.yml +0 -0
  19. {osw-2.3.2 → osw-2.3.3}/.gitignore +0 -0
  20. {osw-2.3.2 → osw-2.3.3}/.gitmodules +0 -0
  21. {osw-2.3.2 → osw-2.3.3}/.pre-commit-config.yaml +0 -0
  22. {osw-2.3.2 → osw-2.3.3}/CONTRIBUTING.md +0 -0
  23. {osw-2.3.2 → osw-2.3.3}/LICENSE.txt +0 -0
  24. {osw-2.3.2 → osw-2.3.3}/Makefile +0 -0
  25. {osw-2.3.2 → osw-2.3.3}/README.md +0 -0
  26. {osw-2.3.2 → osw-2.3.3}/codecov.yaml +0 -0
  27. {osw-2.3.2 → osw-2.3.3}/conftest.py +0 -0
  28. {osw-2.3.2 → osw-2.3.3}/docs/about.md +0 -0
  29. {osw-2.3.2 → osw-2.3.3}/docs/api/auth.md +0 -0
  30. {osw-2.3.2 → osw-2.3.3}/docs/api/controller.md +0 -0
  31. {osw-2.3.2 → osw-2.3.3}/docs/api/core.md +0 -0
  32. {osw-2.3.2 → osw-2.3.3}/docs/api/index.md +0 -0
  33. {osw-2.3.2 → osw-2.3.3}/docs/api/model.md +0 -0
  34. {osw-2.3.2 → osw-2.3.3}/docs/api/wiki-tools.md +0 -0
  35. {osw-2.3.2 → osw-2.3.3}/docs/dev.md +0 -0
  36. {osw-2.3.2 → osw-2.3.3}/docs/get-started.md +0 -0
  37. {osw-2.3.2 → osw-2.3.3}/docs/index.md +0 -0
  38. {osw-2.3.2 → osw-2.3.3}/docs/stylesheets/extra.css +0 -0
  39. {osw-2.3.2 → osw-2.3.3}/docs/tutorials/basics.ipynb +0 -0
  40. {osw-2.3.2 → osw-2.3.3}/docs/tutorials/img/osw_intro_data_model.png +0 -0
  41. {osw-2.3.2 → osw-2.3.3}/docs/tutorials/img/osw_intro_technology_stack.png +0 -0
  42. {osw-2.3.2 → osw-2.3.3}/examples/.env_example +0 -0
  43. {osw-2.3.2 → osw-2.3.3}/examples/clean_demo_platform.py +0 -0
  44. {osw-2.3.2 → osw-2.3.3}/examples/controller_logic.py +0 -0
  45. {osw-2.3.2 → osw-2.3.3}/examples/create_entity.py +0 -0
  46. {osw-2.3.2 → osw-2.3.3}/examples/create_page_package.py +0 -0
  47. {osw-2.3.2 → osw-2.3.3}/examples/data/example_ontology.ttl +0 -0
  48. {osw-2.3.2 → osw-2.3.3}/examples/data_processing/local_jsonpath_queries.py +0 -0
  49. {osw-2.3.2 → osw-2.3.3}/examples/data_processing/regex_matching.py +0 -0
  50. {osw-2.3.2 → osw-2.3.3}/examples/database_access.py +0 -0
  51. {osw-2.3.2 → osw-2.3.3}/examples/device_inventory.py +0 -0
  52. {osw-2.3.2 → osw-2.3.3}/examples/file_download_minimal.py +0 -0
  53. {osw-2.3.2 → osw-2.3.3}/examples/file_upload_download.py +0 -0
  54. {osw-2.3.2 → osw-2.3.3}/examples/generate_rdf.py +0 -0
  55. {osw-2.3.2 → osw-2.3.3}/examples/get_page_content.py +0 -0
  56. {osw-2.3.2 → osw-2.3.3}/examples/inter_osw_copy_page.py +0 -0
  57. {osw-2.3.2 → osw-2.3.3}/examples/load_entity.py +0 -0
  58. {osw-2.3.2 → osw-2.3.3}/examples/offline_content_package.py +0 -0
  59. {osw-2.3.2 → osw-2.3.3}/examples/ontology_import.py +0 -0
  60. {osw-2.3.2 → osw-2.3.3}/examples/ontology_import_emmo.py +0 -0
  61. {osw-2.3.2 → osw-2.3.3}/examples/ontology_import_mwo.py +0 -0
  62. {osw-2.3.2 → osw-2.3.3}/examples/oold_integration.py +0 -0
  63. {osw-2.3.2 → osw-2.3.3}/examples/page_manipulation.py +0 -0
  64. {osw-2.3.2 → osw-2.3.3}/examples/prefect/deploy_flow.py +0 -0
  65. {osw-2.3.2 → osw-2.3.3}/examples/prefect/hello_world.py +0 -0
  66. {osw-2.3.2 → osw-2.3.3}/examples/prefect/notify_teams.py +0 -0
  67. {osw-2.3.2 → osw-2.3.3}/examples/query_minimal.py +0 -0
  68. {osw-2.3.2 → osw-2.3.3}/examples/register_model.py +0 -0
  69. {osw-2.3.2 → osw-2.3.3}/examples/settings.example.json +0 -0
  70. {osw-2.3.2 → osw-2.3.3}/examples/store_entity.py +0 -0
  71. {osw-2.3.2 → osw-2.3.3}/examples/update_local_model.py +0 -0
  72. {osw-2.3.2 → osw-2.3.3}/examples/use_express_functions.py +0 -0
  73. {osw-2.3.2 → osw-2.3.3}/scripts/migration/file_page_migration.py +0 -0
  74. {osw-2.3.2 → osw-2.3.3}/scripts/migration/legacy_articles +0 -0
  75. {osw-2.3.2 → osw-2.3.3}/src/osw/__init__.py +0 -0
  76. {osw-2.3.2 → osw-2.3.3}/src/osw/auth.py +0 -0
  77. {osw-2.3.2 → osw-2.3.3}/src/osw/controller/__init__.py +0 -0
  78. {osw-2.3.2 → osw-2.3.3}/src/osw/controller/database.py +0 -0
  79. {osw-2.3.2 → osw-2.3.3}/src/osw/controller/entity.py +0 -0
  80. {osw-2.3.2 → osw-2.3.3}/src/osw/controller/file/__init__.py +0 -0
  81. {osw-2.3.2 → osw-2.3.3}/src/osw/controller/file/base.py +0 -0
  82. {osw-2.3.2 → osw-2.3.3}/src/osw/controller/file/local.py +0 -0
  83. {osw-2.3.2 → osw-2.3.3}/src/osw/controller/file/memory.py +0 -0
  84. {osw-2.3.2 → osw-2.3.3}/src/osw/controller/file/remote.py +0 -0
  85. {osw-2.3.2 → osw-2.3.3}/src/osw/controller/file/s3.py +0 -0
  86. {osw-2.3.2 → osw-2.3.3}/src/osw/controller/file/wiki.py +0 -0
  87. {osw-2.3.2 → osw-2.3.3}/src/osw/controller/page_package.py +0 -0
  88. {osw-2.3.2 → osw-2.3.3}/src/osw/core.py +0 -0
  89. {osw-2.3.2 → osw-2.3.3}/src/osw/custom_types.py +0 -0
  90. {osw-2.3.2 → osw-2.3.3}/src/osw/data/__init__.py +0 -0
  91. {osw-2.3.2 → osw-2.3.3}/src/osw/data/import_utility.py +0 -0
  92. {osw-2.3.2 → osw-2.3.3}/src/osw/data/mining.py +0 -0
  93. {osw-2.3.2 → osw-2.3.3}/src/osw/defaults.py +0 -0
  94. {osw-2.3.2 → osw-2.3.3}/src/osw/express.py +0 -0
  95. {osw-2.3.2 → osw-2.3.3}/src/osw/model/__init__.py +0 -0
  96. {osw-2.3.2 → osw-2.3.3}/src/osw/model/entity.py +0 -0
  97. {osw-2.3.2 → osw-2.3.3}/src/osw/ontology.py +0 -0
  98. {osw-2.3.2 → osw-2.3.3}/src/osw/params.py +0 -0
  99. {osw-2.3.2 → osw-2.3.3}/src/osw/sparql_client_smw.py +0 -0
  100. {osw-2.3.2 → osw-2.3.3}/src/osw/utils/__init__.py +0 -0
  101. {osw-2.3.2 → osw-2.3.3}/src/osw/utils/_httpx_gateway.py +0 -0
  102. {osw-2.3.2 → osw-2.3.3}/src/osw/utils/code_postprocessing.py +0 -0
  103. {osw-2.3.2 → osw-2.3.3}/src/osw/utils/oold.py +0 -0
  104. {osw-2.3.2 → osw-2.3.3}/src/osw/utils/regex.py +0 -0
  105. {osw-2.3.2 → osw-2.3.3}/src/osw/utils/regex_pattern.py +0 -0
  106. {osw-2.3.2 → osw-2.3.3}/src/osw/utils/strings.py +0 -0
  107. {osw-2.3.2 → osw-2.3.3}/src/osw/utils/templates.py +0 -0
  108. {osw-2.3.2 → osw-2.3.3}/src/osw/utils/util.py +0 -0
  109. {osw-2.3.2 → osw-2.3.3}/src/osw/utils/wiki.py +0 -0
  110. {osw-2.3.2 → osw-2.3.3}/src/osw/utils/wikitext.py +0 -0
  111. {osw-2.3.2 → osw-2.3.3}/src/osw/utils/workflow.py +0 -0
  112. {osw-2.3.2 → osw-2.3.3}/tests/conftest.py +0 -0
  113. {osw-2.3.2 → osw-2.3.3}/tests/controller_mixin.py +0 -0
  114. {osw-2.3.2 → osw-2.3.3}/tests/data_import_utility_test.py +0 -0
  115. {osw-2.3.2 → osw-2.3.3}/tests/integration/conftest.py +0 -0
  116. {osw-2.3.2 → osw-2.3.3}/tests/integration/db_test.py +0 -0
  117. {osw-2.3.2 → osw-2.3.3}/tests/integration/example_ontology.ttl +0 -0
  118. {osw-2.3.2 → osw-2.3.3}/tests/integration/file_page_migration_dependencies.py +0 -0
  119. {osw-2.3.2 → osw-2.3.3}/tests/integration/login_test.py +0 -0
  120. {osw-2.3.2 → osw-2.3.3}/tests/integration/ontology_import_test.py +0 -0
  121. {osw-2.3.2 → osw-2.3.3}/tests/integration/performance_test.py +0 -0
  122. {osw-2.3.2 → osw-2.3.3}/tests/integration/store_and_load_test.py +0 -0
  123. {osw-2.3.2 → osw-2.3.3}/tests/integration/test.png +0 -0
  124. {osw-2.3.2 → osw-2.3.3}/tests/integration/test.svg +0 -0
  125. {osw-2.3.2 → osw-2.3.3}/tests/integration/test_express.py +0 -0
  126. {osw-2.3.2 → osw-2.3.3}/tests/integration/test_express_init.py +0 -0
  127. {osw-2.3.2 → osw-2.3.3}/tests/integration/test_file_controller.py +0 -0
  128. {osw-2.3.2 → osw-2.3.3}/tests/integration/test_file_page_migration.py +0 -0
  129. {osw-2.3.2 → osw-2.3.3}/tests/integration/test_page_export_import.py +0 -0
  130. {osw-2.3.2 → osw-2.3.3}/tests/integration/test_page_package.py +0 -0
  131. {osw-2.3.2 → osw-2.3.3}/tests/integration/test_rdf_generation.py +0 -0
  132. {osw-2.3.2 → osw-2.3.3}/tests/integration/test_wtsite.py +0 -0
  133. {osw-2.3.2 → osw-2.3.3}/tests/sparql_client_test.py +0 -0
  134. {osw-2.3.2 → osw-2.3.3}/tests/test_credential_manager.py +0 -0
  135. {osw-2.3.2 → osw-2.3.3}/tests/test_download_filename_modes.py +0 -0
  136. {osw-2.3.2 → osw-2.3.3}/tests/test_in_memory_upload.py +0 -0
  137. {osw-2.3.2 → osw-2.3.3}/tests/test_osl.py +0 -0
  138. {osw-2.3.2 → osw-2.3.3}/tests/test_overwrite_policy.py +0 -0
  139. {osw-2.3.2 → osw-2.3.3}/tests/test_page_package_search.py +0 -0
  140. {osw-2.3.2 → osw-2.3.3}/tests/test_schema_ref_stub.py +0 -0
  141. {osw-2.3.2 → osw-2.3.3}/tests/test_store_entity_failure.py +0 -0
  142. {osw-2.3.2 → osw-2.3.3}/tests/test_wtpage_update_dict.py +0 -0
  143. {osw-2.3.2 → osw-2.3.3}/tests/test_wtsite_edit.py +0 -0
  144. {osw-2.3.2 → osw-2.3.3}/tests/test_wtsite_modify_search_results.py +0 -0
  145. {osw-2.3.2 → osw-2.3.3}/tests/test_wtsite_session_lock.py +0 -0
  146. {osw-2.3.2 → osw-2.3.3}/tests/test_wtsite_upload_page.py +0 -0
  147. {osw-2.3.2 → osw-2.3.3}/tests/utils/code_postprocessing_test.py +0 -0
  148. {osw-2.3.2 → osw-2.3.3}/tests/utils/oold_test.py +0 -0
  149. {osw-2.3.2 → osw-2.3.3}/tests/utils/parallelize_test.py +0 -0
  150. {osw-2.3.2 → osw-2.3.3}/tests/utils/templates_test.py +0 -0
  151. {osw-2.3.2 → osw-2.3.3}/tests/utils/utils_test.py +0 -0
  152. {osw-2.3.2 → osw-2.3.3}/tests/utils/workflow_test.py +0 -0
  153. {osw-2.3.2 → osw-2.3.3}/zensical.toml +0 -0
@@ -10,6 +10,19 @@
10
10
 
11
11
  <!-- version list -->
12
12
 
13
+ ## v2.3.3 (2026-09-08)
14
+
15
+ ### Bug Fixes
16
+
17
+ - **gui**: Improvements to gui_local_slot_editing
18
+ ([`a84e73a`](https://github.com/OpenSemanticLab/osw-python/commit/a84e73ad1e2f3a3f7c20eabce748d288bbb531a8))
19
+
20
+ ### Build System
21
+
22
+ - **deps**: Require pysimplegui>=6 for the UI extra
23
+ ([`4ca7ab9`](https://github.com/OpenSemanticLab/osw-python/commit/4ca7ab97ad94ad53629db2db513769af648acf5a))
24
+
25
+
13
26
  ## v2.3.2 (2026-09-07)
14
27
 
15
28
  ### Bug Fixes
@@ -3,7 +3,7 @@ message: "If you use this software, please cite it as below."
3
3
  type: software
4
4
  title: "OpenSemanticWorld Python Package (osw-python)"
5
5
  # Managed by python-semantic-release (version_variables). Do not edit by hand.
6
- version: 2.3.2
6
+ version: 2.3.3
7
7
  abstract: "Python toolset for data processing, queries, wikicode generation and page manipulation within OpenSemanticLab: work with OpenSemanticLab instances as typed pydantic entities, query with semantic search, generate models from wiki schemas, and write changes back."
8
8
  repository-code: "https://github.com/OpenSemanticLab/osw-python"
9
9
  url: "https://opensemanticlab.github.io/osw-python/"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: osw
3
- Version: 2.3.2
3
+ Version: 2.3.3
4
4
  Summary: Python toolset for data processing, queries, wikicode generation and page manipulation
5
5
  Project-URL: Homepage, https://github.com/OpenSemanticLab/osw-python
6
6
  Project-URL: Documentation, https://opensemanticlab.github.io/osw-python/
@@ -49,7 +49,7 @@ Requires-Dist: geopy; extra == 'all'
49
49
  Requires-Dist: mwparserfromhell; extra == 'all'
50
50
  Requires-Dist: openpyxl; extra == 'all'
51
51
  Requires-Dist: psycopg2; extra == 'all'
52
- Requires-Dist: pysimplegui; extra == 'all'
52
+ Requires-Dist: pysimplegui>=6; extra == 'all'
53
53
  Requires-Dist: sqlalchemy; extra == 'all'
54
54
  Provides-Extra: dataimport
55
55
  Requires-Dist: deepl; extra == 'dataimport'
@@ -65,7 +65,7 @@ Requires-Dist: deepl; extra == 'tutorial'
65
65
  Requires-Dist: geopy; extra == 'tutorial'
66
66
  Requires-Dist: openpyxl; extra == 'tutorial'
67
67
  Provides-Extra: ui
68
- Requires-Dist: pysimplegui; extra == 'ui'
68
+ Requires-Dist: pysimplegui>=6; extra == 'ui'
69
69
  Provides-Extra: wikitext
70
70
  Requires-Dist: mwparserfromhell; extra == 'wikitext'
71
71
  Provides-Extra: workflow
@@ -3,6 +3,7 @@ them locally and uploading them again."""
3
3
 
4
4
  import json
5
5
  import os
6
+ import re
6
7
 
7
8
  # import yaml
8
9
  from pathlib import Path
@@ -52,6 +53,33 @@ def str_or_none(value):
52
53
  return str(value)
53
54
 
54
55
 
56
+ def compute_page_storage_path(
57
+ working_dir: str,
58
+ domain_str: str,
59
+ page_name_as_filename: bool,
60
+ label_str: str = None,
61
+ top_level: str = None,
62
+ ) -> str:
63
+ """Compute the folder that a page's slots are downloaded to / uploaded from.
64
+
65
+ Mirrors the folder-naming logic previously inlined in save_as_page_package: the
66
+ page label is used when 'page_name_as_filename' is set and a label is known,
67
+ otherwise the top-level page title is used. A domain sub folder is inserted so
68
+ that pages from different OSW instances don't collide inside the same local
69
+ working directory.
70
+
71
+ Both the '-DL-' (download) and '-UL-' (upload) event handlers call this helper
72
+ so they always agree on where a given page's content is stored on disk.
73
+ """
74
+ if page_name_as_filename and label_str is not None:
75
+ page_dir_name = label_str
76
+ else:
77
+ page_dir_name = top_level
78
+ # Sanitize the domain for use as a directory name (invalid on Windows: \/:*?"<>|)
79
+ domain_dir = re.sub(r'[\\/:*?"<>|]', "_", domain_str)
80
+ return os.path.join(working_dir, domain_dir, page_dir_name)
81
+
82
+
55
83
  def create_config_from_setting(settings_: dict) -> WtPage.PageDumpConfig:
56
84
  config_ = WtPage.PageDumpConfig(
57
85
  target_dir=settings_["local_working_directory"],
@@ -66,6 +94,7 @@ def save_as_page_package(
66
94
  full_page_name_str,
67
95
  wtsite_inst: WtSite,
68
96
  dump_config_inst: WtPage.PageDumpConfig,
97
+ domain_str: str,
69
98
  label_str: str = None,
70
99
  top_level: str = None,
71
100
  sub_level: str = None, # "content"
@@ -81,10 +110,13 @@ def save_as_page_package(
81
110
  package_branch = "deleteme"
82
111
  publisher = "Open Semantic World"
83
112
  working_dir = dump_config_inst.target_dir
84
- if dump_config_inst.page_name_as_filename and label_str is not None:
85
- target_dir = os.path.join(working_dir, label_str)
86
- else:
87
- target_dir = os.path.join(working_dir, top_level)
113
+ target_dir = compute_page_storage_path(
114
+ working_dir,
115
+ domain_str,
116
+ dump_config_inst.page_name_as_filename,
117
+ label_str,
118
+ top_level,
119
+ )
88
120
  if isinstance(author, list):
89
121
  author_list = author
90
122
  elif isinstance(author, str):
@@ -156,24 +188,46 @@ if __name__ == "__main__":
156
188
  }
157
189
  settings_read_from_file = False
158
190
 
159
- domains, accounts = read_domains_from_credentials_file(settings["cred_filepath"])
160
- if "wiki-dev.open-semantic-lab.org" in domains:
161
- domain = "wiki-dev.open-semantic-lab.org"
191
+ # An empty/missing accounts.pwd.yaml must not prevent the window from opening.
192
+ # The user can still type/select a domain once the window is up.
193
+ domains, accounts, cred_load_error = [], {}, None
194
+ try:
195
+ domains, accounts = read_domains_from_credentials_file(
196
+ settings["cred_filepath"]
197
+ )
198
+ except (FileNotFoundError, ValueError) as e:
199
+ cred_load_error = f"{type(e).__name__}: {e}"
200
+
201
+ if domains:
202
+ if "wiki-dev.open-semantic-lab.org" in domains:
203
+ domain = "wiki-dev.open-semantic-lab.org"
204
+ else:
205
+ domain = domains[0]
206
+ if settings_read_from_file:
207
+ settings["domain"] = domain
162
208
  else:
163
- domain = domains[0]
164
- if settings_read_from_file:
165
- settings["domain"] = domain
209
+ domain = settings.get("domain", "")
166
210
 
167
211
  cm = CredentialManager(cred_filepath=settings["cred_filepath"])
168
- osw_obj = OswExpress(domain=domain, cred_mngr=cm)
169
- wtsite_obj = osw_obj.site
212
+ osw_obj = None
213
+ wtsite_obj = None
214
+ if domain:
215
+ osw_obj = OswExpress(domain=domain, cred_mngr=cm)
216
+ wtsite_obj = osw_obj.site
170
217
 
171
218
  full_page_name = settings["target_page"].split("/")[-1].replace("_", " ")
172
- page = wtsite_obj.get_page(WtSite.GetPageParam(titles=[full_page_name])).pages[0]
219
+ page = None
220
+ if wtsite_obj is not None:
221
+ page = wtsite_obj.get_page(WtSite.GetPageParam(titles=[full_page_name])).pages[
222
+ 0
223
+ ]
173
224
  label_set = False
174
225
  label = None
175
226
  slots_downloaded = False
176
227
  dump_config = create_config_from_setting(settings)
228
+ label_default_text = ""
229
+ if cred_load_error is not None:
230
+ label_default_text = f"Could not load credentials: {cred_load_error}"
177
231
 
178
232
  # ----- GUI Definition -----
179
233
  # Setting the theme of the GUI
@@ -253,7 +307,12 @@ if __name__ == "__main__":
253
307
  ],
254
308
  [
255
309
  # A display element that will show the label of the OSW page
256
- psg.Multiline(size=(50, 1), key="-LABEL-", no_scrollbar=True)
310
+ psg.Multiline(
311
+ size=(50, 1),
312
+ key="-LABEL-",
313
+ no_scrollbar=True,
314
+ default_text=label_default_text,
315
+ )
257
316
  ],
258
317
  ]),
259
318
  ],
@@ -354,10 +413,17 @@ if __name__ == "__main__":
354
413
  json.dump(settings, f, indent=4)
355
414
  elif event == "-CREDENTIALS-":
356
415
  settings["cred_filepath"] = values["-CREDENTIALS-"]
357
- domains, accounts = read_domains_from_credentials_file(
358
- settings["cred_filepath"]
359
- )
360
- window["-DOMAIN-"].update(values=domains)
416
+ try:
417
+ domains, accounts = read_domains_from_credentials_file(
418
+ settings["cred_filepath"]
419
+ )
420
+ window["-DOMAIN-"].update(values=domains)
421
+ except (FileNotFoundError, ValueError) as e:
422
+ # Keep the previously loaded domains/accounts, the user can retry
423
+ # with a different file.
424
+ window["-LABEL-"].update(
425
+ f"Could not load credentials: {type(e).__name__}: {e}"
426
+ )
361
427
  elif event == "-LWD-":
362
428
  settings["local_working_directory"] = values["-LWD-"]
363
429
  elif event == "-DOMAIN-":
@@ -368,51 +434,82 @@ if __name__ == "__main__":
368
434
  elif event == "Load page":
369
435
  window["-LABEL-"].update("Loading page...")
370
436
  window["-DL_RES-"].update("")
371
- full_page_name = values["-ADDRESS-"].split("/")[-1].replace("_", " ")
372
- if (values["-ADDRESS-"].find("/wiki/") != -1) or (
373
- values["-ADDRESS-"].find("/w/") != -1
374
- ):
375
- settings["target_page"] = values["-ADDRESS-"]
376
- else:
377
- settings["target_page"] = (
378
- "https://" + domain + "/wiki/" + full_page_name
437
+ if wtsite_obj is None:
438
+ window["-LABEL-"].update(
439
+ "No domain selected. Load a credentials file or select a "
440
+ "domain first."
379
441
  )
380
- if values["-ADDRESS-"].find(settings["domain"]) == -1:
381
- window["-LABEL-"].update("Page not on selected domain!")
382
442
  label_set = False
383
443
  else:
384
- # use connection
385
- page = wtsite_obj.get_page(
386
- WtSite.GetPageParam(titles=[full_page_name])
387
- ).pages[0]
388
- if page.exists:
389
- jsondata = page.get_slot_content("jsondata")
390
- if jsondata is None:
391
- window["-LABEL-"].update("Slot 'jsondata' is empty!")
392
- label_set = False
393
- else:
394
- label = jsondata["label"][0]["text"]
395
- window["-LABEL-"].update(label)
396
- label_set = True
444
+ full_page_name = values["-ADDRESS-"].split("/")[-1].replace("_", " ")
445
+ if (values["-ADDRESS-"].find("/wiki/") != -1) or (
446
+ values["-ADDRESS-"].find("/w/") != -1
447
+ ):
448
+ settings["target_page"] = values["-ADDRESS-"]
397
449
  else:
398
- window["-LABEL-"].update("Page does not exist!")
450
+ settings["target_page"] = (
451
+ "https://" + domain + "/wiki/" + full_page_name
452
+ )
453
+ if values["-ADDRESS-"].find(settings["domain"]) == -1:
454
+ window["-LABEL-"].update("Page not on selected domain!")
399
455
  label_set = False
456
+ else:
457
+ # use connection
458
+ page = wtsite_obj.get_page(
459
+ WtSite.GetPageParam(titles=[full_page_name])
460
+ ).pages[0]
461
+ if page.exists:
462
+ jsondata = page.get_slot_content("jsondata")
463
+ if jsondata is None:
464
+ window["-LABEL-"].update("Slot 'jsondata' is empty!")
465
+ label_set = False
466
+ else:
467
+ label = jsondata["label"][0]["text"]
468
+ window["-LABEL-"].update(label)
469
+ label_set = True
470
+ else:
471
+ window["-LABEL-"].update("Page does not exist!")
472
+ label_set = False
400
473
  elif event == "-EXC_EMPTY-" or event == "-INC_EMPTY-":
401
474
  settings["dump_empty_slots"] = values["-INC_EMPTY-"]
402
475
  elif event == "-DL-":
403
476
  window["-DL_RES-"].update("Downloading slots...")
404
477
  if label_set:
405
- dump_config = create_config_from_setting(settings)
406
- _ = save_as_page_package(
407
- full_page_name_str=full_page_name,
408
- wtsite_inst=wtsite_obj,
409
- dump_config_inst=dump_config,
410
- label_str=label,
411
- sub_level=SUB_LEVEL,
412
- author=accounts[domains[0]]["username"],
413
- )
414
- slots_downloaded = True
415
- window["-DL_RES-"].update("Slots downloaded!")
478
+ try:
479
+ dump_config = create_config_from_setting(settings)
480
+ target_dir = compute_page_storage_path(
481
+ settings["local_working_directory"],
482
+ domain,
483
+ settings["page_name_as_filename"],
484
+ label,
485
+ full_page_name.split(":")[-1],
486
+ )
487
+ window["-DL_RES-"].update(
488
+ f"Clearing '{target_dir}' and downloading slots..."
489
+ )
490
+ # Look up the author for the selected domain, not the first
491
+ # one in the list, and degrade gracefully if there is no
492
+ # credential entry for it (e.g. empty accounts.pwd.yaml).
493
+ author = accounts.get(domain, {}).get("username")
494
+ if author is None:
495
+ window["-DL_RES-"].update(
496
+ f"No credentials found for domain '{domain}'; "
497
+ "using default author."
498
+ )
499
+ author = "Open Semantic World"
500
+ _ = save_as_page_package(
501
+ full_page_name_str=full_page_name,
502
+ wtsite_inst=wtsite_obj,
503
+ dump_config_inst=dump_config,
504
+ domain_str=domain,
505
+ label_str=label,
506
+ sub_level=SUB_LEVEL,
507
+ author=author,
508
+ )
509
+ slots_downloaded = True
510
+ window["-DL_RES-"].update("Slots downloaded!")
511
+ except Exception as e:
512
+ window["-DL_RES-"].update(f"{type(e).__name__}: {e}")
416
513
  else:
417
514
  window["-DL_RES-"].update("No page loaded!")
418
515
  elif event == "-UL_SEL-":
@@ -434,29 +531,45 @@ if __name__ == "__main__":
434
531
  elif len(slots_to_upload) == 0:
435
532
  window["-UL_RES-"].update("No slots selected!")
436
533
  else:
437
- if not slots_downloaded:
438
- window["-UL_RES-"].update("No slots downloaded!")
439
- dump_config = create_config_from_setting(settings)
440
- window["-UL_RES-"].update("Uploading slots...")
441
- if SUB_LEVEL is None:
442
- storage_path = Path(dump_config.target_dir)
443
- else:
444
- storage_path = Path(dump_config.target_dir).parent
445
- pages = wtsite_obj.read_page_package(
446
- WtSite.ReadPagePackageParam(
447
- storage_path=storage_path,
448
- packages_info_file_name=PACKAGE_INFO_FILE_NAME,
449
- selected_slots=slots_to_upload,
450
- debug=False,
534
+ try:
535
+ # Recompute the storage path the same way the download path
536
+ # does, instead of trusting a possibly stale dump_config.
537
+ # This also makes upload work against a folder written by a
538
+ # previous run, even if no download happened this session.
539
+ storage_path = Path(
540
+ compute_page_storage_path(
541
+ settings["local_working_directory"],
542
+ domain,
543
+ settings["page_name_as_filename"],
544
+ label,
545
+ full_page_name.split(":")[-1],
546
+ )
451
547
  )
452
- ).pages
453
- param = wtsite_obj.UploadPageParam(pages=pages, parallel=False)
454
- wtsite_obj.upload_page(param)
455
- # Success:
456
- window["-UL_RES-"].update("Slots uploaded!")
457
- # Report in the download section that the slots have been uploaded to
458
- # remind the user that he eventually has to re-download the slots
459
- window["-DL_RES-"].update("Slots uploaded!")
548
+ if not slots_downloaded:
549
+ window["-UL_RES-"].update(
550
+ "No slots downloaded this session, trying folder from a "
551
+ f"previous run at '{storage_path}'..."
552
+ )
553
+ else:
554
+ window["-UL_RES-"].update("Uploading slots...")
555
+ pages = wtsite_obj.read_page_package(
556
+ WtSite.ReadPagePackageParam(
557
+ storage_path=storage_path,
558
+ packages_info_file_name=PACKAGE_INFO_FILE_NAME,
559
+ selected_slots=slots_to_upload,
560
+ debug=False,
561
+ )
562
+ ).pages
563
+ param = wtsite_obj.UploadPageParam(pages=pages, parallel=False)
564
+ wtsite_obj.upload_page(param)
565
+ # Success:
566
+ window["-UL_RES-"].update("Slots uploaded!")
567
+ # Report in the download section that the slots have been
568
+ # uploaded to remind the user that he eventually has to
569
+ # re-download the slots
570
+ window["-DL_RES-"].update("Slots uploaded!")
571
+ except Exception as e:
572
+ window["-UL_RES-"].update(f"{type(e).__name__}: {e}")
460
573
 
461
574
  # Some debugging output functionality
462
575
  if DEBUG:
@@ -8,7 +8,7 @@ license-files = ["LICENSE.txt"]
8
8
  requires-python = ">=3.10,<3.14"
9
9
  # Managed by python-semantic-release (see [tool.semantic_release]). Do not
10
10
  # edit by hand; it is bumped automatically on merge to main.
11
- version = "2.3.2"
11
+ version = "2.3.3"
12
12
  classifiers = [
13
13
  "Development Status :: 3 - Alpha",
14
14
  "Intended Audience :: Developers",
@@ -68,7 +68,11 @@ dataimport = [
68
68
  "deepl",
69
69
  "openpyxl",
70
70
  ]
71
- UI = ["pysimplegui"]
71
+ UI = [
72
+ # PySimpleGUI 5 was commercial and its releases are yanked from PyPI.
73
+ # The LGPLv3 line resumed at version 6, which the GUI examples target.
74
+ "pysimplegui>=6",
75
+ ]
72
76
  workflow = [
73
77
  "prefect>=2.20.25,<3.0",
74
78
  # prefect 2.20.25 is the final 2.x release (no backports). Its
@@ -171,6 +171,10 @@ class PagePackageConfig(BaseModel):
171
171
  content_path: Optional[Union[str, Path]] = ""
172
172
  """The directory where the content (pages, files) is stored.
173
173
  Will be created automatically if not existing."""
174
+ clear_content_dir: Optional[bool] = True
175
+ """If True (default), the content directory is deleted before the page
176
+ package is created. Set to False to keep any existing content, e.g. when
177
+ reusing a directory that already holds locally edited slot files."""
174
178
  titles: List[str]
175
179
  """List of page titles."""
176
180
  ignore_titles: Optional[List[str]] = None
@@ -48,6 +48,10 @@ def read_domains_from_credentials_file(
48
48
  with open(cred_filepath, encoding="utf-8") as stream_:
49
49
  try:
50
50
  accounts_dict = yaml.safe_load(stream_)
51
+ # An empty file is parsed as None by yaml.safe_load, which would
52
+ # otherwise raise an AttributeError on the .keys() call below
53
+ if accounts_dict is None:
54
+ accounts_dict = {}
51
55
  domains_list = list(accounts_dict.keys())
52
56
  if len(domains_list) == 0:
53
57
  raise ValueError("No domain found in accounts.pwd.yaml!")
@@ -885,14 +885,15 @@ class WtSite:
885
885
  config = param.config
886
886
 
887
887
  # Clear the content directory
888
- try:
889
- if debug:
890
- print(f"Delete dir '{config.content_path}'")
891
- if os.path.exists(config.content_path):
892
- shutil.rmtree(config.content_path)
893
- except OSError as e:
894
- if debug:
895
- print(f"Error: {e.filename} - {e.strerror}.")
888
+ if config.clear_content_dir:
889
+ try:
890
+ if debug:
891
+ print(f"Delete dir '{config.content_path}'")
892
+ if os.path.exists(config.content_path):
893
+ shutil.rmtree(config.content_path)
894
+ except OSError as e:
895
+ if debug:
896
+ print(f"Error: {e.filename} - {e.strerror}.")
896
897
  # Create a dump config
897
898
  if dump_config is None:
898
899
  dump_config = WtPage.PageDumpConfig(
@@ -1096,7 +1097,12 @@ class WtSite:
1096
1097
  )
1097
1098
  # Read packages info file
1098
1099
  with open(pi_fp, encoding="utf-8") as f:
1099
- packages_json = json.load(f)
1100
+ try:
1101
+ packages_json = json.load(f)
1102
+ except json.JSONDecodeError as e:
1103
+ raise json.JSONDecodeError(
1104
+ f"Malformed JSON in '{pi_fp}': {e.msg}", e.doc, e.pos
1105
+ ) from e
1100
1106
  # Assume that the pages files are located in the subdir
1101
1107
  storage_path_content = ut.list_files_and_directories(
1102
1108
  search_path=storage_path, recursive=True
@@ -1130,7 +1136,14 @@ class WtSite:
1130
1136
  if len(file_content) > 0:
1131
1137
  if url_path.endswith(".json"):
1132
1138
  with open(slot_path, encoding="utf-8") as f:
1133
- slot_data = json.load(f)
1139
+ try:
1140
+ slot_data = json.load(f)
1141
+ except json.JSONDecodeError as e:
1142
+ raise json.JSONDecodeError(
1143
+ f"Malformed JSON in '{slot_path}': {e.msg}",
1144
+ e.doc,
1145
+ e.pos,
1146
+ ) from e
1134
1147
  return slot_data
1135
1148
  elif url_path.endswith(".wikitext"):
1136
1149
  slot_data = file_content
@@ -13,6 +13,41 @@ def test_create_flat_content_structure_from_wikitext():
13
13
  assert result == expected
14
14
 
15
15
 
16
+ def test_read_domains_from_credentials_file_empty_file_raises_value_error(tmp_path):
17
+ """An empty file is parsed as None by yaml.safe_load. Before the fix this
18
+ raised an unhandled AttributeError from accounts_dict.keys(); it must now
19
+ raise the same clear ValueError as an empty mapping."""
20
+ cred_file = tmp_path / "accounts.pwd.yaml"
21
+ cred_file.write_text("", encoding="utf-8")
22
+
23
+ with pytest.raises(ValueError, match="No domain found"):
24
+ wt.read_domains_from_credentials_file(cred_file)
25
+
26
+
27
+ def test_read_domains_from_credentials_file_empty_mapping_raises_value_error(
28
+ tmp_path,
29
+ ):
30
+ cred_file = tmp_path / "accounts.pwd.yaml"
31
+ cred_file.write_text("{}", encoding="utf-8")
32
+
33
+ with pytest.raises(ValueError, match="No domain found"):
34
+ wt.read_domains_from_credentials_file(cred_file)
35
+
36
+
37
+ def test_read_domains_from_credentials_file_valid_file_returns_domains_and_accounts(
38
+ tmp_path,
39
+ ):
40
+ cred_file = tmp_path / "accounts.pwd.yaml"
41
+ cred_file.write_text(
42
+ "example.org:\n username: user\n password: pass\n", encoding="utf-8"
43
+ )
44
+
45
+ domains, accounts = wt.read_domains_from_credentials_file(cred_file)
46
+
47
+ assert domains == ["example.org"]
48
+ assert accounts == {"example.org": {"username": "user", "password": "pass"}}
49
+
50
+
16
51
  def _ask_result(*titles):
17
52
  """Build a minimal SMW ``ask`` API result dict for the given page titles."""
18
53
  return {
@@ -0,0 +1,86 @@
1
+ """Unit tests for the PagePackageConfig.clear_content_dir purge flag.
2
+
3
+ Regression guard for #42: WtSite.create_page_package used to unconditionally
4
+ shutil.rmtree() the content directory. clear_content_dir defaults to True (no
5
+ behaviour change), but setting it to False must keep any existing content.
6
+ """
7
+
8
+ import threading
9
+
10
+ import pytest
11
+
12
+ import osw.model.page_package as package
13
+ from osw.wtsite import WtPage, WtSite
14
+
15
+
16
+ class _FakeSite:
17
+ """Stands in for mwclient.Site, only used if a re-login is attempted."""
18
+
19
+ host = "example.org"
20
+
21
+
22
+ def _make_fake_wtsite():
23
+ """A WtSite that performs no network calls."""
24
+ ws = WtSite.__new__(WtSite)
25
+ ws._session_lock = threading.RLock()
26
+ ws._site = _FakeSite()
27
+ return ws
28
+
29
+
30
+ def _make_config(tmp_path, clear_content_dir):
31
+ bundle = package.PagePackageBundle(
32
+ packages={
33
+ "TestPkg": package.PagePackage(
34
+ globalID="org.test.TestPkg",
35
+ description="test package",
36
+ version="0.0.1",
37
+ baseURL="https://example.org/",
38
+ )
39
+ }
40
+ )
41
+ return package.PagePackageConfig(
42
+ name="TestPkg",
43
+ config_path=tmp_path / "packages.json",
44
+ content_path=tmp_path / "content",
45
+ bundle=bundle,
46
+ titles=["Item:OSW123"],
47
+ include_files=False,
48
+ clear_content_dir=clear_content_dir,
49
+ )
50
+
51
+
52
+ def test_clear_content_dir_defaults_to_true(tmp_path):
53
+ bundle = package.PagePackageBundle(packages={})
54
+ config = package.PagePackageConfig(
55
+ name="TestPkg",
56
+ config_path=tmp_path / "packages.json",
57
+ titles=["Item:OSW123"],
58
+ bundle=bundle,
59
+ )
60
+ assert config.clear_content_dir is True
61
+
62
+
63
+ @pytest.mark.parametrize("clear_content_dir", [True, False])
64
+ def test_create_page_package_honours_clear_content_dir(tmp_path, clear_content_dir):
65
+ ws = _make_fake_wtsite()
66
+ page = WtPage(wtSite=ws, title="Item:OSW123", do_init=False)
67
+
68
+ content_path = tmp_path / "content"
69
+ content_path.mkdir()
70
+ marker = content_path / "marker.txt"
71
+ marker.write_text("pre-existing content", encoding="utf-8")
72
+
73
+ config = _make_config(tmp_path, clear_content_dir=clear_content_dir)
74
+
75
+ ws.create_page_package(
76
+ WtSite.CreatePagePackageParam(
77
+ config=config,
78
+ offline_pages={"Item:OSW123": page},
79
+ debug=False,
80
+ )
81
+ )
82
+
83
+ if clear_content_dir:
84
+ assert not marker.exists()
85
+ else:
86
+ assert marker.exists()