ebookerr-sdk 3.7.1__tar.gz → 3.7.2__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 (108) hide show
  1. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/PKG-INFO +9 -1
  2. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/README.md +8 -0
  3. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/__init__.py +1 -1
  4. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/readpos.py +15 -4
  5. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/types.py +5 -3
  6. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/wire.py +162 -66
  7. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk.egg-info/PKG-INFO +9 -1
  8. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/pyproject.toml +1 -1
  9. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/LICENSE +0 -0
  10. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/__init__.py +0 -0
  11. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/adoption.py +0 -0
  12. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/authors.py +0 -0
  13. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/chapter_naming.py +0 -0
  14. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/chapter_number.py +0 -0
  15. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/dates.py +0 -0
  16. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/epub_hash.py +0 -0
  17. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/epub_metadata.py +0 -0
  18. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/ids.py +0 -0
  19. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/metadata.py +0 -0
  20. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/series_rules.py +0 -0
  21. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/story_url.py +0 -0
  22. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/survivor.py +0 -0
  23. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/domain/text_encoding.py +0 -0
  24. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/__init__.py +0 -0
  25. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/availability.py +0 -0
  26. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/check.py +0 -0
  27. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/disposition.py +0 -0
  28. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/document.py +0 -0
  29. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/identity.py +0 -0
  30. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/paths.py +0 -0
  31. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/download/routing.py +0 -0
  32. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/__init__.py +0 -0
  33. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/archive.py +0 -0
  34. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/assets.py +0 -0
  35. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/build.py +0 -0
  36. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/builder.py +0 -0
  37. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/chapter_numbers.py +0 -0
  38. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/chapters.py +0 -0
  39. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/document.py +0 -0
  40. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/errors.py +0 -0
  41. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/fonts.py +0 -0
  42. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/nav.py +0 -0
  43. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/ncx.py +0 -0
  44. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/opf.py +0 -0
  45. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/roles.py +0 -0
  46. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/safe_xml.py +0 -0
  47. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/serialize.py +0 -0
  48. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/text.py +0 -0
  49. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/text_convert.py +0 -0
  50. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/titles.py +0 -0
  51. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/epub/xhtml.py +0 -0
  52. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/generation/__init__.py +0 -0
  53. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/generation/client.py +0 -0
  54. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/generation/output.py +0 -0
  55. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/generation/prompt_template.py +0 -0
  56. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/generation/support.py +0 -0
  57. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/host.py +0 -0
  58. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/__init__.py +0 -0
  59. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/__main__.py +0 -0
  60. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/build.py +0 -0
  61. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/changelog.py +0 -0
  62. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/check.py +0 -0
  63. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/cli.py +0 -0
  64. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/files.py +0 -0
  65. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/imports.py +0 -0
  66. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/index.py +0 -0
  67. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/pack/scaffold.py +0 -0
  68. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/__init__.py +0 -0
  69. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/anchor_types.py +0 -0
  70. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/anchoring.py +0 -0
  71. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/capture_warnings.py +0 -0
  72. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/connection.py +0 -0
  73. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/link_attempt.py +0 -0
  74. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/link_refusal.py +0 -0
  75. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/restore_marker.py +0 -0
  76. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/scan_ledger.py +0 -0
  77. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/providers/urls.py +0 -0
  78. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/py.typed +0 -0
  79. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/registry_index.py +0 -0
  80. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/__init__.py +0 -0
  81. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/context.py +0 -0
  82. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/declarations.py +0 -0
  83. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/errors.py +0 -0
  84. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/glyphs.py +0 -0
  85. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/manifest.py +0 -0
  86. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/material_symbols.txt +0 -0
  87. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/protocols.py +0 -0
  88. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/settings.py +0 -0
  89. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/summary_template.py +0 -0
  90. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/spi/views.py +0 -0
  91. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/testing/__init__.py +0 -0
  92. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/testing/context.py +0 -0
  93. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/testing/views.py +0 -0
  94. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/testing/wire.py +0 -0
  95. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/__init__.py +0 -0
  96. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/external.py +0 -0
  97. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/model.py +0 -0
  98. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/rules_css.py +0 -0
  99. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/rules_html.py +0 -0
  100. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/rules_nav.py +0 -0
  101. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/rules_opf.py +0 -0
  102. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/rules_package.py +0 -0
  103. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk/validate/validate.py +0 -0
  104. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk.egg-info/SOURCES.txt +0 -0
  105. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk.egg-info/dependency_links.txt +0 -0
  106. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk.egg-info/requires.txt +0 -0
  107. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/ebookerr_sdk.egg-info/top_level.txt +0 -0
  108. {ebookerr_sdk-3.7.1 → ebookerr_sdk-3.7.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ebookerr-sdk
3
- Version: 3.7.1
3
+ Version: 3.7.2
4
4
  Summary: The ebookerr plugin SDK: the plugin contract, the plugin host, a test kit, the pack tool and helper kits.
5
5
  Author: ArcCdr
6
6
  License-Expression: MIT
@@ -98,6 +98,14 @@ ebookerr's; otherwise it shows the plugin as incompatible and offers a compatibl
98
98
 
99
99
  ## Release notes
100
100
 
101
+ ### 3.7.2
102
+
103
+ The wire decoders fail closed: a flag (`delete`, `upsert`, `emit_followup`, `relinked`, an asset's
104
+ or custom value's `delete`, `needs_update`, a view item's `selected`/`locked`/`fixed`) is true only
105
+ for the JSON literal `true`; a patch's `book_id` must be a non-empty string and its `fields` an
106
+ object with string keys; malformed custom values and assets are dropped; a story entry that is not
107
+ an object, and a read position whose progress is not a finite number between 0 and 1, are refused.
108
+
101
109
  ### 3.7.1
102
110
 
103
111
  A title page's summary keeps its paragraphs: `TitlePageDocument.description()` — and so
@@ -69,6 +69,14 @@ ebookerr's; otherwise it shows the plugin as incompatible and offers a compatibl
69
69
 
70
70
  ## Release notes
71
71
 
72
+ ### 3.7.2
73
+
74
+ The wire decoders fail closed: a flag (`delete`, `upsert`, `emit_followup`, `relinked`, an asset's
75
+ or custom value's `delete`, `needs_update`, a view item's `selected`/`locked`/`fixed`) is true only
76
+ for the JSON literal `true`; a patch's `book_id` must be a non-empty string and its `fields` an
77
+ object with string keys; malformed custom values and assets are dropped; a story entry that is not
78
+ an object, and a read position whose progress is not a finite number between 0 and 1, are refused.
79
+
72
80
  ### 3.7.1
73
81
 
74
82
  A title page's summary keeps its paragraphs: `TitlePageDocument.description()` — and so
@@ -44,4 +44,4 @@ ruff D, pdoc, mutmut and the traceability gate all cover the SDK; it has its own
44
44
  ``pack/``, so a fetched index and a built one are validated by exactly the same rules.
45
45
  """
46
46
 
47
- __version__ = "3.7.1"
47
+ __version__ = "3.7.2"
@@ -17,6 +17,7 @@ bookmark back stay in each provider plugin (``RPH-ARCH-3``).
17
17
  from __future__ import annotations
18
18
 
19
19
  import logging
20
+ import math
20
21
  import re
21
22
  from collections.abc import Mapping, Sequence
22
23
  from dataclasses import dataclass
@@ -102,8 +103,9 @@ def read_position_from_dict(data: Mapping[str, Any] | None) -> ReadPosition | No
102
103
  Returns:
103
104
  The reconstructed ``ReadPosition``, or ``None`` when ``data`` is ``None``,
104
105
  not a mapping, or missing/mistyped one of the required keys
105
- (``captured_at``, ``chapter_index``, ``chapter_progress``). Optional keys
106
- default per the ``ReadPosition`` dataclass when absent.
106
+ (``captured_at``, ``chapter_index``, ``chapter_progress``), or ``chapter_index`` is
107
+ a bool or negative, or ``chapter_progress`` is a bool, not finite or outside 0..1.
108
+ Optional keys default per the ``ReadPosition`` dataclass when absent.
107
109
  """
108
110
  if data is None or not isinstance(data, Mapping):
109
111
  return None
@@ -117,10 +119,19 @@ def read_position_from_dict(data: Mapping[str, Any] | None) -> ReadPosition | No
117
119
  # Check required fields exist and have correct types
118
120
  if captured_at is None or not isinstance(captured_at, str):
119
121
  return None
120
- if chapter_index is None or not isinstance(chapter_index, int):
122
+ if (
123
+ not isinstance(chapter_index, int)
124
+ or isinstance(chapter_index, bool)
125
+ or chapter_index < 0
126
+ ):
121
127
  return None
122
128
  # Accept int for float (will be coerced by dataclass)
123
- if chapter_progress is None or not isinstance(chapter_progress, (int, float)):
129
+ if (
130
+ not isinstance(chapter_progress, (int, float))
131
+ or isinstance(chapter_progress, bool)
132
+ or not math.isfinite(chapter_progress)
133
+ or not 0 <= chapter_progress <= 1
134
+ ):
124
135
  return None
125
136
 
126
137
  # Extract optional fields with defaults
@@ -592,11 +592,13 @@ class BookPatch:
592
592
 
593
593
  Attributes:
594
594
  book_id: For a Source, derived from the URL (the upsert key).
595
- fields: Any native ``Book`` column, including every user-editable field (``title``,
596
- ``author``, ``author_url``, ``series``, ``series_url``, ``category``,
595
+ fields: Any native ``Book`` column the core does not own, including every user-editable
596
+ field (``title``, ``author``, ``author_url``, ``series``, ``series_url``, ``category``,
597
597
  ``tags``, ``status``, ``site``, ``language``, ``date_published``,
598
598
  ``description``, ``story_url``, ``rating``, ``auto_pull``, ``synopsis``) — no
599
- whitelist (``DEC-4``). A write lands in the ``books`` column, except for a field the
599
+ whitelist (``DEC-4``). A key naming a core-owned or unknown column, or a value that
600
+ does not fit its column, is left out with one WARNING and the rest of the patch
601
+ applies (``TR-DATA-9``). A write lands in the ``books`` column, except for a field the
600
602
  user has locked by editing it: their value stays and the incoming value is recorded
601
603
  as the lock's source value, what a revert restores (``LIB-D2``).
602
604
  custom_values: This plugin's own custom-value writes, namespaced to its manifest id at
@@ -16,6 +16,7 @@ from __future__ import annotations
16
16
  import base64
17
17
  import binascii
18
18
  import logging
19
+ import math
19
20
  from collections.abc import Mapping, Sequence
20
21
  from datetime import datetime
21
22
  from pathlib import Path
@@ -226,20 +227,65 @@ def _wire_meta(raw: Any) -> dict[str, str]:
226
227
  return {str(k): str(v) for k, v in raw.items() if v is not None}
227
228
 
228
229
 
230
+ def _flag(value: object, name: str) -> bool:
231
+ """A wire boolean: ``True`` only for the JSON literal ``true`` (``value is True``).
232
+
233
+ Args:
234
+ value: The decoded wire value (``None`` when the key was absent).
235
+ name: The wire key, used only for the DEBUG line.
236
+
237
+ Returns:
238
+ ``True`` only when *value* is the boolean ``True``.
239
+ """
240
+ if value is not True and value is not False and value is not None:
241
+ logger.debug("Wire flag %s=%r is not a JSON boolean; read as false", name, value)
242
+ return value is True
243
+
244
+
245
+ def _wire_int(value: object, default: int) -> int:
246
+ """A wire integer: a non-bool int, or a finite float with an integral value; else default.
247
+
248
+ Args:
249
+ value: The decoded wire value.
250
+ default: The result when *value* is not a usable integer.
251
+
252
+ Returns:
253
+ The integer, or *default*.
254
+ """
255
+ if isinstance(value, bool):
256
+ return default
257
+ if isinstance(value, int):
258
+ return value
259
+ if isinstance(value, float) and math.isfinite(value) and value.is_integer():
260
+ return int(value)
261
+ return default
262
+
263
+
264
+ def _str_or_none(value: object) -> str | None:
265
+ """Return *value* when it is a string, else ``None``."""
266
+ return value if isinstance(value, str) else None
267
+
268
+
229
269
  def _wire_patch_assets(assets_raw: Any) -> list[AssetWrite]:
230
270
  """Decode patch assets from the wire, dropping malformed entries and invalid base64 data.
231
271
 
272
+ An entry whose ``kind`` or ``name`` is not a non-empty string is dropped; ``path`` and
273
+ ``media_type`` are kept only when strings; ``delete`` is true only for JSON ``true``.
274
+
232
275
  Args:
233
- assets_raw: The raw assets list from the patch dict.
276
+ assets_raw: The raw assets list from the patch dict (anything but a list is empty).
234
277
 
235
278
  Returns:
236
279
  A list of decoded `AssetWrite` objects, with invalid entries dropped.
237
280
  """
281
+ if not isinstance(assets_raw, list):
282
+ return []
238
283
  assets = []
239
- for asset_dict in assets_raw or []:
284
+ for asset_dict in assets_raw:
240
285
  if not isinstance(asset_dict, dict):
241
286
  continue
242
- if "kind" not in asset_dict or "name" not in asset_dict:
287
+ kind, name = asset_dict.get("kind"), asset_dict.get("name")
288
+ if not isinstance(kind, str) or not kind or not isinstance(name, str) or not name:
243
289
  continue
244
290
  data_decoded: bytes | None = None
245
291
  data_raw = asset_dict.get("data")
@@ -249,17 +295,17 @@ def _wire_patch_assets(assets_raw: Any) -> list[AssetWrite]:
249
295
  except (binascii.Error, ValueError):
250
296
  logger.warning(
251
297
  "Dropped an exec plugin asset with invalid base64 data: kind=%s name=%s",
252
- asset_dict["kind"],
253
- asset_dict["name"],
298
+ kind,
299
+ name,
254
300
  )
255
301
  continue
256
302
  assets.append(
257
303
  AssetWrite(
258
- kind=asset_dict["kind"],
259
- name=asset_dict["name"],
260
- path=asset_dict.get("path"),
261
- media_type=asset_dict.get("media_type"),
262
- delete=bool(asset_dict.get("delete", False)),
304
+ kind=kind,
305
+ name=name,
306
+ path=_str_or_none(asset_dict.get("path")),
307
+ media_type=_str_or_none(asset_dict.get("media_type")),
308
+ delete=_flag(asset_dict.get("delete"), "asset delete"),
263
309
  data=data_decoded,
264
310
  meta=_wire_meta(asset_dict.get("meta")),
265
311
  )
@@ -530,23 +576,24 @@ def decode_book_patch(data: Mapping[str, Any]) -> BookPatch:
530
576
 
531
577
  | Wire key | Notes |
532
578
  |---|---|
533
- | `book_id` | Required; raises `WireError` if absent |
534
- | `fields` | Any native `Book` column |
535
- | `custom_values` | Bare keys (no prefix); `{value, value_type, updated_at?, delete?}` |
536
- | `delete`, `upsert`, `emit_followup` | Boolean, default `False` |
579
+ | `book_id` | Required string (`""` for a conversion patch); `WireError` if absent or not one |
580
+ | `fields` | Object with string keys, any native `Book` column; else `WireError` |
581
+ | `custom_values` | Bare keys (no prefix); `{value, value_type, updated_at?, delete?}`; |
582
+ | | | malformed entries are dropped |
583
+ | `delete`, `upsert`, `emit_followup` | JSON `true` only, default `False` |
537
584
  | `assets` | Top-level (never inside `fields`); `{kind, name, path?, media_type?, |
538
585
  | | | delete?, data? (base64, SPI 2.25), meta? (object of strings, SPI 2.25)}` |
539
586
  | `chapters` | Top-level; `{url, title?, ordinal?}` (SPI 2.22) |
540
587
  | `read_position` | Via `read_position_from_dict` |
541
- | `relinked` | Boolean, default False (SPI 2.19) |
588
+ | `relinked` | JSON `true` only, default False (SPI 2.19) |
542
589
  | `superseded_by` | Optional non-empty string (SPI 2.23); any other value is dropped to |
543
590
  | | | `None` |
544
591
  | `logs` | Optional; entries `{"level", "message"}` bridged by `parse_response`, |
545
592
  | | | never stored |
546
593
 
547
- An `assets` entry missing `kind`/`name`, or a `chapters` entry with a missing or
548
- blank `url`, is silently dropped rather than raising. For `ordinal`: kept as int when ≥0,
549
- dropped (set to `None`) for non-int values, bools, or negatives.
594
+ An `assets` entry whose `kind`/`name` is not a non-empty string, or a `chapters` entry with a
595
+ missing or blank `url`, is silently dropped rather than raising. For `ordinal`: kept as int
596
+ when ≥0, dropped (set to `None`) for non-int values, bools, or negatives.
550
597
  An asset whose `data` is not valid base64 is dropped with a WARNING.
551
598
 
552
599
  Args:
@@ -556,58 +603,96 @@ def decode_book_patch(data: Mapping[str, Any]) -> BookPatch:
556
603
  The parsed `BookPatch`.
557
604
 
558
605
  Raises:
559
- WireError: If `book_id` is missing.
606
+ WireError: If `book_id` is missing or not a string, or `fields` is not an
607
+ object with string keys.
560
608
  """
561
609
  if "book_id" not in data:
562
610
  raise WireError("book_id is required")
611
+ book_id = data["book_id"]
612
+ # A conversion patch carries ``""`` (no book exists yet): only a non-string is refused.
613
+ if not isinstance(book_id, str):
614
+ raise WireError("book_id must be a string")
563
615
 
564
- custom_values_raw = data.get("custom_values", {})
565
- custom_values = {
566
- k: CustomValueWrite(
567
- value=v["value"],
568
- value_type=v["value_type"],
569
- updated_at=v.get("updated_at"),
570
- delete=bool(v.get("delete", False)),
571
- )
572
- for k, v in custom_values_raw.items()
573
- }
574
-
616
+ fields = _wire_fields(data.get("fields"))
617
+ custom_values = _wire_custom_values(data.get("custom_values"))
575
618
  assets = _wire_patch_assets(data.get("assets", []))
576
-
577
- chapters_raw = data.get("chapters", [])
578
- chapters = []
579
- if isinstance(chapters_raw, list):
580
- for entry in chapters_raw:
581
- if not isinstance(entry, dict):
582
- continue
583
- url = entry.get("url")
584
- if not isinstance(url, str) or not url:
585
- logger.debug("Dropped invalid chapter entry: %r", entry)
586
- continue
587
- title = entry.get("title")
588
- if title is not None:
589
- title = str(title)
590
- ordinal = _parse_chapter_ordinal(entry.get("ordinal"))
591
- chapters.append(ChapterLink(url=url, title=title, ordinal=ordinal))
619
+ chapters = _wire_chapters(data.get("chapters", []))
592
620
 
593
621
  superseded_raw = data.get("superseded_by")
594
622
  superseded_by = superseded_raw if isinstance(superseded_raw, str) and superseded_raw else None
595
623
 
596
624
  return BookPatch(
597
- book_id=data["book_id"],
598
- fields=dict(data.get("fields", {})),
625
+ book_id=book_id,
626
+ fields=fields,
599
627
  custom_values=custom_values,
600
- delete=bool(data.get("delete", False)),
601
- upsert=bool(data.get("upsert", False)),
602
- emit_followup=bool(data.get("emit_followup", False)),
628
+ delete=_flag(data.get("delete"), "delete"),
629
+ upsert=_flag(data.get("upsert"), "upsert"),
630
+ emit_followup=_flag(data.get("emit_followup"), "emit_followup"),
603
631
  assets=tuple(assets),
604
632
  chapters=tuple(chapters),
605
633
  read_position=read_position_from_dict(data.get("read_position")),
606
- relinked=bool(data.get("relinked", False)),
634
+ relinked=_flag(data.get("relinked"), "relinked"),
607
635
  superseded_by=superseded_by,
608
636
  )
609
637
 
610
638
 
639
+ def _wire_fields(raw: object) -> dict[str, Any]:
640
+ """Decode a patch's ``fields``: absent or null is empty, else an object with string keys."""
641
+ if raw is None:
642
+ return {}
643
+ if not isinstance(raw, Mapping):
644
+ raise WireError("fields must be an object")
645
+ if not all(isinstance(k, str) for k in raw):
646
+ raise WireError("fields keys must be strings")
647
+ return dict(raw)
648
+
649
+
650
+ def _wire_custom_values(raw: object) -> dict[str, CustomValueWrite]:
651
+ """Decode a patch's ``custom_values``, dropping every malformed entry."""
652
+ if not isinstance(raw, Mapping):
653
+ if raw is not None:
654
+ logger.debug("Dropped invalid custom_values: %r", raw)
655
+ return {}
656
+ custom_values: dict[str, CustomValueWrite] = {}
657
+ for key, entry in raw.items():
658
+ if (
659
+ not isinstance(key, str)
660
+ or not key
661
+ or not isinstance(entry, Mapping)
662
+ or "value" not in entry
663
+ or "value_type" not in entry
664
+ ):
665
+ logger.debug("Dropped invalid custom value %r", key)
666
+ continue
667
+ custom_values[key] = CustomValueWrite(
668
+ value=entry["value"],
669
+ value_type=entry["value_type"],
670
+ updated_at=entry.get("updated_at"),
671
+ delete=_flag(entry.get("delete"), "custom value delete"),
672
+ )
673
+ return custom_values
674
+
675
+
676
+ def _wire_chapters(raw: object) -> list[ChapterLink]:
677
+ """Decode a patch's ``chapters``, dropping entries without a usable url."""
678
+ chapters: list[ChapterLink] = []
679
+ if not isinstance(raw, list):
680
+ return chapters
681
+ for entry in raw:
682
+ if not isinstance(entry, dict):
683
+ continue
684
+ url = entry.get("url")
685
+ if not isinstance(url, str) or not url:
686
+ logger.debug("Dropped invalid chapter entry: %r", entry)
687
+ continue
688
+ title = entry.get("title")
689
+ if title is not None:
690
+ title = str(title)
691
+ ordinal = _parse_chapter_ordinal(entry.get("ordinal"))
692
+ chapters.append(ChapterLink(url=url, title=title, ordinal=ordinal))
693
+ return chapters
694
+
695
+
611
696
  def encode_book_patch(patch: BookPatch) -> dict[str, Any]:
612
697
  """Serialize a ``BookPatch`` to the wire object :func:`decode_book_patch` reads.
613
698
 
@@ -688,7 +773,7 @@ def decode_update_check(data: Mapping[str, Any]) -> UpdateCheck:
688
773
 
689
774
  | Wire key | Type | Notes |
690
775
  |---|---|---|
691
- | `needs_update` | bool | Coerced with `bool()`; absent means `False` |
776
+ | `needs_update` | bool | JSON `true` only; anything else, or absent, means `False` |
692
777
  | `meta` | object or null | Kept only when it is a mapping; anything else becomes `None` |
693
778
  | `book_id` | string or null | — |
694
779
  | `error` | string or null | Set by the plugin when its own check failed |
@@ -707,7 +792,7 @@ def decode_update_check(data: Mapping[str, Any]) -> UpdateCheck:
707
792
  fields_raw = data.get("fields")
708
793
  fields = dict(fields_raw) if isinstance(fields_raw, Mapping) else {}
709
794
  return UpdateCheck(
710
- needs_update=bool(data.get("needs_update", False)),
795
+ needs_update=_flag(data.get("needs_update"), "needs_update"),
711
796
  meta=meta,
712
797
  book_id=str(book_id_raw) if book_id_raw is not None else None,
713
798
  error=str(error_raw) if error_raw is not None else None,
@@ -729,13 +814,18 @@ def encode_read_state_changes(changes: ReadStateChanges) -> dict[str, Any]:
729
814
 
730
815
 
731
816
  def decode_read_state_changes(data: Mapping[str, Any]) -> ReadStateChanges:
732
- """Rebuild ReadStateChanges; a missing list is empty; a non-string marker is ``None``."""
817
+ """Rebuild ReadStateChanges; a missing or non-list list is empty, a non-string marker ``None``.
818
+
819
+ ``more`` is true only for the JSON literal ``true``.
820
+ """
733
821
  marker = data.get("marker")
822
+ items = data.get("items")
823
+ collections = data.get("collections")
734
824
  return ReadStateChanges(
735
- items=tuple(str(i) for i in data.get("items") or ()),
736
- collections=tuple(str(c) for c in data.get("collections") or ()),
825
+ items=tuple(str(i) for i in items) if isinstance(items, list) else (),
826
+ collections=tuple(str(c) for c in collections) if isinstance(collections, list) else (),
737
827
  marker=marker if isinstance(marker, str) else None,
738
- more=bool(data.get("more", False)),
828
+ more=_flag(data.get("more"), "more"),
739
829
  )
740
830
 
741
831
 
@@ -797,8 +887,10 @@ def decode_story_patch(data: Mapping[str, Any]) -> StoryPatch: # noqa: C901
797
887
  The parsed `StoryPatch`.
798
888
 
799
889
  Raises:
800
- WireError: If ``url`` is missing or blank.
890
+ WireError: If ``data`` is not an object, or ``url`` is missing or blank.
801
891
  """
892
+ if not isinstance(data, Mapping):
893
+ raise WireError("story entry must be an object")
802
894
  url = data.get("url")
803
895
  if not isinstance(url, str) or not url:
804
896
  raise WireError("story url is required")
@@ -900,9 +992,9 @@ def _decode_view_items(items_data: Any) -> tuple[ViewItem, ...]:
900
992
  item_label = str(item_raw.get("label", ""))
901
993
  sublabel = str(item_raw.get("sublabel", ""))
902
994
  badges = tuple(str(b) for b in item_raw.get("badges", []))
903
- selected = bool(item_raw.get("selected", True))
904
- locked = bool(item_raw.get("locked", False))
905
- fixed = bool(item_raw.get("fixed", False))
995
+ selected = True if "selected" not in item_raw else _flag(item_raw["selected"], "selected")
996
+ locked = _flag(item_raw.get("locked"), "locked")
997
+ fixed = _flag(item_raw.get("fixed"), "fixed")
906
998
  fixed_reason = str(item_raw.get("fixed_reason", ""))
907
999
 
908
1000
  items.append(
@@ -944,6 +1036,10 @@ def _decode_view_fields(fields_data: Any) -> tuple[SettingsField, ...]:
944
1036
  def decode_plugin_view(raw: Mapping[str, Any]) -> PluginView:
945
1037
  """Rebuild a ``PluginView`` from an exec plugin's ``op: "view"`` frame (``PV-FR-4``).
946
1038
 
1039
+ Fail-closed: a section or item flag is true only for JSON ``true`` (an item without
1040
+ ``selected`` is selected), and ``min_selected`` that is not a usable integer is ``0``.
1041
+ A truthy non-boolean ``danger`` is read as dangerous (the safe direction).
1042
+
947
1043
  Args:
948
1044
  raw: The frame's ``view`` object.
949
1045
 
@@ -980,10 +1076,10 @@ def decode_plugin_view(raw: Mapping[str, Any]) -> PluginView:
980
1076
 
981
1077
  label = str(section_raw.get("label", ""))
982
1078
  text = str(section_raw.get("text", ""))
983
- selectable = bool(section_raw.get("selectable", False))
984
- reorderable = bool(section_raw.get("reorderable", False))
985
- min_selected = int(section_raw.get("min_selected", 0))
986
- select_all = bool(section_raw.get("select_all", False))
1079
+ selectable = _flag(section_raw.get("selectable"), "selectable")
1080
+ reorderable = _flag(section_raw.get("reorderable"), "reorderable")
1081
+ min_selected = _wire_int(section_raw.get("min_selected", 0), 0)
1082
+ select_all = _flag(section_raw.get("select_all"), "select_all")
987
1083
  item_verb = str(section_raw.get("item_verb", "Select"))
988
1084
 
989
1085
  items = _decode_view_items(section_raw.get("items", []))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ebookerr-sdk
3
- Version: 3.7.1
3
+ Version: 3.7.2
4
4
  Summary: The ebookerr plugin SDK: the plugin contract, the plugin host, a test kit, the pack tool and helper kits.
5
5
  Author: ArcCdr
6
6
  License-Expression: MIT
@@ -98,6 +98,14 @@ ebookerr's; otherwise it shows the plugin as incompatible and offers a compatibl
98
98
 
99
99
  ## Release notes
100
100
 
101
+ ### 3.7.2
102
+
103
+ The wire decoders fail closed: a flag (`delete`, `upsert`, `emit_followup`, `relinked`, an asset's
104
+ or custom value's `delete`, `needs_update`, a view item's `selected`/`locked`/`fixed`) is true only
105
+ for the JSON literal `true`; a patch's `book_id` must be a non-empty string and its `fields` an
106
+ object with string keys; malformed custom values and assets are dropped; a story entry that is not
107
+ an object, and a read position whose progress is not a finite number between 0 and 1, are refused.
108
+
101
109
  ### 3.7.1
102
110
 
103
111
  A title page's summary keeps its paragraphs: `TitlePageDocument.description()` — and so
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ebookerr-sdk"
7
- version = "3.7.1"
7
+ version = "3.7.2"
8
8
  description = "The ebookerr plugin SDK: the plugin contract, the plugin host, a test kit, the pack tool and helper kits."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
File without changes
File without changes