pyOpenVBA 5.2.0__tar.gz → 5.2.1__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 (89) hide show
  1. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/PKG-INFO +3 -2
  2. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/README.md +1 -1
  3. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/docs/power_query.md +41 -0
  4. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/pyproject.toml +5 -1
  5. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/__init__.py +1 -1
  6. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/powerquery/_opc.py +25 -0
  7. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/powerquery/_sheets.py +75 -7
  8. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/powerquery/workbook.py +15 -0
  9. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/.gitignore +0 -0
  10. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/LICENSE.md +0 -0
  11. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/docs/architecture.md +0 -0
  12. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/docs/ms-ovba-implementation-guide_v2.md +0 -0
  13. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/docs/research/access_complex/README.md +0 -0
  14. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/docs/research/access_designs/README.md +0 -0
  15. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/docs/research/access_macros/README.md +0 -0
  16. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/docs/research/access_write/README.md +0 -0
  17. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/docs/research/pcode/README.md +0 -0
  18. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/docs/roadmap.md +0 -0
  19. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/__main__.py +0 -0
  20. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_deflate.py +0 -0
  21. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_host.py +0 -0
  22. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_oforms_pages.py +0 -0
  23. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_oforms_records.py +0 -0
  24. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_ppt_container.py +0 -0
  25. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/__init__.py +0 -0
  26. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/blank_files/blank_database.accdb +0 -0
  27. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/blank_files/blank_database_module.accdb +0 -0
  28. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/blank_files/blank_document.docm +0 -0
  29. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/blank_files/blank_excel_addin.xlam +0 -0
  30. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/blank_files/blank_presentation.pptm +0 -0
  31. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsb +0 -0
  32. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsm +0 -0
  33. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsx +0 -0
  34. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/blank_files/engine_skeleton.accdb +0 -0
  35. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/blank_files/engine_skeleton.mdb +0 -0
  36. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/designs/form.blob +0 -0
  37. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/designs/form.lvprop +0 -0
  38. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/designs/form.propdata +0 -0
  39. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/designs/form.prototypes +0 -0
  40. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/designs/form.typeinfo +0 -0
  41. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/designs/report.blob +0 -0
  42. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/designs/report.lvprop +0 -0
  43. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/designs/report.propdata +0 -0
  44. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/designs/report.prototypes +0 -0
  45. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/_templates/designs/report.typeinfo +0 -0
  46. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/__init__.py +0 -0
  47. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_alloc.py +0 -0
  48. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_btree.py +0 -0
  49. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_collation.py +0 -0
  50. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_collation_general_legacy.py +0 -0
  51. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_compact.py +0 -0
  52. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_complex.py +0 -0
  53. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_datapage.py +0 -0
  54. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_ddl.py +0 -0
  55. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_designs.py +0 -0
  56. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_facade.py +0 -0
  57. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_format.py +0 -0
  58. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_index.py +0 -0
  59. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_lval.py +0 -0
  60. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_macros.py +0 -0
  61. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_pages.py +0 -0
  62. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_props.py +0 -0
  63. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_queries.py +0 -0
  64. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_rows.py +0 -0
  65. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_schema.py +0 -0
  66. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_sql.py +0 -0
  67. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_storage.py +0 -0
  68. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_tdef.py +0 -0
  69. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_validate.py +0 -0
  70. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/_vba.py +0 -0
  71. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access/database.py +0 -0
  72. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/access_read.py +0 -0
  73. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/cfb.py +0 -0
  74. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/excel.py +0 -0
  75. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/exceptions.py +0 -0
  76. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/forms.py +0 -0
  77. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/powerpoint.py +0 -0
  78. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/powerquery/__init__.py +0 -0
  79. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/powerquery/_binary.py +0 -0
  80. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/powerquery/_files.py +0 -0
  81. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/powerquery/_mashup.py +0 -0
  82. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/powerquery/_metadata.py +0 -0
  83. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/powerquery/_package.py +0 -0
  84. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/powerquery/_refresh.py +0 -0
  85. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/powerquery/_section.py +0 -0
  86. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/vba.py +0 -0
  87. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/vba_pcode.py +0 -0
  88. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/src/pyopenvba/word.py +0 -0
  89. {pyopenvba-5.2.0 → pyopenvba-5.2.1}/tests/fuzz_corpus/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pyOpenVBA
3
- Version: 5.2.0
3
+ Version: 5.2.1
4
4
  Summary: Read and write VBA macros and Excel Power Query in pure Python, no dependencies.
5
5
  Project-URL: Homepage, https://github.com/WilliamSmithEdward/pyOpenVBA
6
6
  Project-URL: Repository, https://github.com/WilliamSmithEdward/pyOpenVBA
@@ -29,6 +29,7 @@ Classifier: Typing :: Typed
29
29
  Requires-Python: >=3.10
30
30
  Provides-Extra: dev
31
31
  Requires-Dist: build>=1.2; extra == 'dev'
32
+ Requires-Dist: openpyxl>=3.1; extra == 'dev'
32
33
  Requires-Dist: pyright>=1.1.350; extra == 'dev'
33
34
  Requires-Dist: pytest>=8; extra == 'dev'
34
35
  Requires-Dist: twine>=5; extra == 'dev'
@@ -55,7 +56,7 @@ VBA, four hosts and one API:
55
56
  * PowerPoint (`.pptm`, `.potm`, `.ppt`)
56
57
  * Access (`.accdb`, `.mdb`)
57
58
 
58
- Power Query, in any Excel package (`.xlsx` included):
59
+ Power Query, in any Excel package:
59
60
 
60
61
  ```python
61
62
  from pyopenvba import PowerQueryWorkbook
@@ -19,7 +19,7 @@ VBA, four hosts and one API:
19
19
  * PowerPoint (`.pptm`, `.potm`, `.ppt`)
20
20
  * Access (`.accdb`, `.mdb`)
21
21
 
22
- Power Query, in any Excel package (`.xlsx` included):
22
+ Power Query, in any Excel package:
23
23
 
24
24
  ```python
25
25
  from pyopenvba import PowerQueryWorkbook
@@ -277,6 +277,47 @@ this file exists because nothing here is written on a guess.
277
277
 
278
278
  ---
279
279
 
280
+ ## Workbooks another tool wrote
281
+
282
+ Excel is not the only writer of `.xlsx`, and the parts it produces are
283
+ one legal spelling among several. Three assumptions here came from
284
+ reading only Excel's output, and each one broke on openpyxl's:
285
+
286
+ * **Attribute order carries no meaning.** Excel opens a relationship with
287
+ `Id`, openpyxl closes with it. Matching in a fixed order found nothing,
288
+ so a sheet looked as though it had no part behind it and loading to it
289
+ failed outright.
290
+ * **An empty element may be written closed.** `<definedNames />` is the
291
+ same element as `<definedNames></definedNames>`. Appending a second
292
+ block beside it left two in the workbook, which Excel refuses.
293
+ * **A namespace prefix is declared where it is used.** Excel puts
294
+ `xmlns:r` on every worksheet; openpyxl puts it on a worksheet that
295
+ needs one, and a sheet with no table does not. Adding a `tablePart`
296
+ that used the prefix made the part not well formed.
297
+
298
+ Loading a query onto a sheet of a workbook openpyxl wrote works, and
299
+ Excel opens and refreshes the result.
300
+
301
+ **The other direction does not, and cannot be fixed here.** openpyxl
302
+ rebuilds the package from the parts it models and drops the rest, custom
303
+ XML included, so saving a workbook through it removes the Power Query
304
+ package and the queries with it. Nothing signals this: the file opens and
305
+ simply has no queries. Put pyOpenVBA last in the pipeline, or carry the
306
+ queries across with `pull_queries()` and `push_queries()`.
307
+
308
+ ## `[trash]` parts
309
+
310
+ Excel's own file recovery leaves the parts it threw out under
311
+ `[trash]/NNNN.dat`, beside the real ones. An OPC part name cannot open a
312
+ segment with a bracket, and Excel holds itself to that when reading: the
313
+ same workbook opens before such an entry is added and fails to open at
314
+ all after, measured both ways.
315
+
316
+ The container preserves every entry as it arrived, which would hand back
317
+ a file that stays broken, so `save()` drops these and warns. Nothing in
318
+ the document depends on them: they carry no content type and no
319
+ relationship points at them.
320
+
280
321
  ## What Excel refuses
281
322
 
282
323
  * A query name containing a dot. `Queries.Add` rejects it with
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyOpenVBA"
7
- version = "5.2.0"
7
+ version = "5.2.1"
8
8
  description = "Read and write VBA macros and Excel Power Query in pure Python, no dependencies."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -61,6 +61,10 @@ dev = [
61
61
  "pyright>=1.1.350",
62
62
  "build>=1.2",
63
63
  "twine>=5",
64
+ # Test-time only, and the runtime keeps its promise of no dependencies.
65
+ # openpyxl writes the same parts a different legal way, which is what
66
+ # the interop tests are for.
67
+ "openpyxl>=3.1",
64
68
  ]
65
69
 
66
70
  [project.urls]
@@ -291,4 +291,4 @@ def push_power_query(
291
291
  """
292
292
  return _push_queries(src_dir, workbook, out=out, encoding=encoding, remove_missing=remove_missing)
293
293
 
294
- __version__ = "5.2.0"
294
+ __version__ = "5.2.1"
@@ -29,6 +29,9 @@ _STORED = 0
29
29
  _FLAGS = 0x0006
30
30
  _MADE_BY = 45
31
31
  _NEEDED = 20
32
+ #: Where Excel's file recovery parks the parts it threw out. Not a legal
33
+ #: OPC part name, and Excel will not open a package holding one.
34
+ _RESERVED = "[trash]/"
32
35
 
33
36
 
34
37
  @dataclass
@@ -174,6 +177,28 @@ class OpcFile:
174
177
  self.entries = kept
175
178
  self.source = None
176
179
 
180
+ def drop_reserved(self) -> list[str]:
181
+ """Take out entries whose names are not part names, and say which.
182
+
183
+ Excel's own file recovery leaves `[trash]/NNNN.dat` beside the
184
+ real parts. An OPC part name is built of segments that cannot
185
+ open with a bracket, and Excel holds itself to that when reading:
186
+ a workbook that opens cleanly stops opening at all once such an
187
+ entry is added, which is measured both ways in
188
+ `tests/test_powerquery_opc.py`.
189
+
190
+ Nothing in the document depends on them. They carry no content
191
+ type and no relationship points at them, so a package is repaired
192
+ by dropping them rather than damaged.
193
+ """
194
+ doomed = [entry.name for entry in self.entries if entry.name.startswith(_RESERVED)]
195
+ if doomed:
196
+ self.entries = [
197
+ entry for entry in self.entries if not entry.name.startswith(_RESERVED)
198
+ ]
199
+ self.source = None
200
+ return doomed
201
+
177
202
  def serialize(self) -> bytes:
178
203
  if self.source is not None:
179
204
  return self.source
@@ -102,6 +102,38 @@ def _relationships(package: OpcFile, part: str) -> str:
102
102
  )
103
103
 
104
104
 
105
+ def _attributes(element: str) -> dict[str, str]:
106
+ """The attributes of one element, in whatever order they were written.
107
+
108
+ XML gives attribute order no meaning, and writers differ: Excel opens
109
+ a relationship with ``Id``, openpyxl closes with it. Matching them in
110
+ a fixed order silently found nothing in the second case, which left a
111
+ sheet looking as though it had no part behind it.
112
+ """
113
+ return dict(re.findall(r'([\w.:-]+)\s*=\s*"([^"]*)"', element))
114
+
115
+
116
+ def _relationship_targets(rels: str) -> dict[str, str]:
117
+ """``{relationship id: target}`` for one ``.rels`` part."""
118
+ out: dict[str, str] = {}
119
+ for element in re.findall(r"<Relationship\b[^>]*>", rels):
120
+ attributes = _attributes(element)
121
+ identifier, target = attributes.get("Id"), attributes.get("Target")
122
+ if identifier and target is not None:
123
+ out[identifier] = target
124
+ return out
125
+
126
+
127
+ def _relationship_for(rels: str, target: str) -> str | None:
128
+ """The id pointing at ``target``, comparing the part it names rather
129
+ than the spelling: a target may be relative or absolute."""
130
+ wanted = target.lstrip("/").removeprefix("../")
131
+ for identifier, found in _relationship_targets(rels).items():
132
+ if found.lstrip("/").removeprefix("../") == wanted:
133
+ return identifier
134
+ return None
135
+
136
+
105
137
  def add_relationship(package: OpcFile, part: str, kind: str, target: str) -> str:
106
138
  raw = _relationships(package, part)
107
139
  identifier = _next_relationship(raw)
@@ -142,8 +174,17 @@ def sheet_part(package: OpcFile, sheet: str | int) -> tuple[str, str]:
142
174
  name of the sheet."""
143
175
  workbook = package.read(_WORKBOOK).decode("utf-8")
144
176
  rels = package.read(_WORKBOOK_RELS).decode("utf-8")
145
- targets = dict(re.findall(r'<Relationship Id="([^"]+)"[^>]*Target="([^"]+)"', rels))
146
- sheets = re.findall(r'<sheet\b[^>]*name="([^"]*)"[^>]*r:id="([^"]+)"', workbook)
177
+ targets = _relationship_targets(rels)
178
+ sheets: list[tuple[str, str]] = []
179
+ for element in re.findall(r"<sheet\b[^>]*>", workbook):
180
+ attributes = _attributes(element)
181
+ # The relationship attribute carries a namespace prefix, and which
182
+ # prefix is the writer's to choose.
183
+ identifier = next(
184
+ (value for key, value in attributes.items() if key.endswith(":id")), None
185
+ )
186
+ if "name" in attributes and identifier:
187
+ sheets.append((attributes["name"], identifier))
147
188
  if not sheets:
148
189
  raise PowerQueryError("this workbook lists no worksheets")
149
190
  if isinstance(sheet, int):
@@ -306,6 +347,7 @@ def _add_table_to_sheet(
306
347
  for index, column in enumerate(columns)
307
348
  )
308
349
  raw = _write_header(raw, header, start, len(columns))
350
+ raw = _declare_relationship_namespace(raw)
309
351
  if "<tableParts" in raw:
310
352
  raw = re.sub(
311
353
  r'<tableParts count="(\d+)">',
@@ -321,6 +363,28 @@ def _add_table_to_sheet(
321
363
  package.write(part, raw.encode("utf-8"))
322
364
 
323
365
 
366
+ #: The namespace a `<tablePart r:id=...>` reference lives in.
367
+ _RELATIONSHIPS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
368
+
369
+
370
+ def _declare_relationship_namespace(sheet: str) -> str:
371
+ """Make sure the worksheet element declares the `r:` prefix.
372
+
373
+ Excel always writes it on the root element, so the reference added
374
+ below resolved. openpyxl writes the prefix only where it uses one,
375
+ and a worksheet with no table has no use for it, which left the
376
+ `r:id` added here pointing at a prefix nothing declared. That is not
377
+ well-formed XML, and Excel would not open the workbook at all.
378
+ """
379
+ opening = re.search(r"<worksheet\b[^>]*>", sheet)
380
+ if opening is None or 'xmlns:r=' in opening.group(0):
381
+ return sheet
382
+ fixed = opening.group(0).replace(
383
+ "<worksheet", f'<worksheet xmlns:r="{_RELATIONSHIPS_NS}"', 1
384
+ )
385
+ return sheet.replace(opening.group(0), fixed, 1)
386
+
387
+
324
388
  def _write_header(sheet: str, cells: str, start: CellRef, width: int) -> str:
325
389
  """Put the header cells on the sheet.
326
390
 
@@ -377,8 +441,14 @@ def _add_defined_name(package: OpcFile, sheet_name: str, number: int, reference:
377
441
  f'<definedName name="ExternalData_{number}" localSheetId="0" hidden="1">'
378
442
  f"{quoted}!{absolute}</definedName>"
379
443
  )
444
+ empty = re.search(r"<definedNames\s*/>", raw)
380
445
  if "<definedNames>" in raw:
381
446
  raw = raw.replace("<definedNames>", "<definedNames>" + defined)
447
+ elif empty is not None:
448
+ # An empty element written closed, which openpyxl does and Excel
449
+ # does not. Appending a second block beside it puts two in the
450
+ # workbook, and Excel refuses that outright.
451
+ raw = raw.replace(empty.group(0), f"<definedNames>{defined}</definedNames>", 1)
382
452
  elif "<calcPr" in raw:
383
453
  raw = raw.replace("<calcPr", f"<definedNames>{defined}</definedNames><calcPr", 1)
384
454
  else:
@@ -407,16 +477,14 @@ def unload_from_sheet(package: OpcFile, query: str) -> bool:
407
477
  if not package.has(rels_part):
408
478
  continue
409
479
  rels = package.read(rels_part).decode("utf-8")
410
- found = re.search(
411
- rf'<Relationship Id="([^"]+)"[^>]*Target="\.\./tables/table{number}\.xml"[^>]*/>', rels
412
- )
413
- if found is None:
480
+ identifier = _relationship_for(rels, f"../tables/table{number}.xml")
481
+ if identifier is None:
414
482
  continue
415
483
  drop_relationship(package, rels_part, f"../tables/table{number}.xml")
416
484
  if "<Relationship " not in package.read(rels_part).decode("utf-8"):
417
485
  package.remove(rels_part)
418
486
  sheet = package.read(part).decode("utf-8")
419
- sheet = re.sub(rf'<tablePart r:id="{found.group(1)}"\s*/>', "", sheet)
487
+ sheet = re.sub(rf'<tablePart\b[^>]*:id="{re.escape(identifier)}"[^>]*/>', "", sheet)
420
488
  sheet = re.sub(
421
489
  r'<tableParts count="(\d+)">',
422
490
  lambda match: f'<tableParts count="{max(int(match.group(1)) - 1, 0)}">',
@@ -25,6 +25,7 @@ from __future__ import annotations
25
25
  import base64
26
26
  import re
27
27
  import uuid
28
+ import warnings
28
29
  from pathlib import Path
29
30
  from typing import ClassVar
30
31
  from xml.etree import ElementTree
@@ -564,6 +565,20 @@ class PowerQueryWorkbook:
564
565
 
565
566
  def save(self, path: str | Path | None = None) -> Path:
566
567
  out = Path(path) if path is not None else self.path
568
+ # A workbook Excel has recovered carries the parts it threw out
569
+ # under `[trash]`, and Excel will not open a package holding one.
570
+ # Preserving those faithfully would hand back a file that stays
571
+ # broken, so they go, and the caller is told.
572
+ dropped = self._opc.drop_reserved()
573
+ if dropped:
574
+ warnings.warn(
575
+ "dropped "
576
+ + ", ".join(repr(name) for name in dropped)
577
+ + ": Excel's file recovery leaves those behind and will not "
578
+ "open a workbook that carries them",
579
+ UserWarning,
580
+ stacklevel=2,
581
+ )
567
582
  raw = self.to_bytes()
568
583
  out.write_bytes(raw)
569
584
  if path is None:
File without changes
File without changes
File without changes