pyOpenVBA 5.2.0__tar.gz → 5.2.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 (89) hide show
  1. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/PKG-INFO +3 -2
  2. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/README.md +1 -1
  3. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/power_query.md +48 -0
  4. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/pyproject.toml +5 -1
  5. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/__init__.py +1 -1
  6. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_opc.py +25 -0
  7. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_sheets.py +107 -8
  8. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/workbook.py +15 -0
  9. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/.gitignore +0 -0
  10. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/LICENSE.md +0 -0
  11. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/architecture.md +0 -0
  12. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/ms-ovba-implementation-guide_v2.md +0 -0
  13. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/research/access_complex/README.md +0 -0
  14. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/research/access_designs/README.md +0 -0
  15. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/research/access_macros/README.md +0 -0
  16. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/research/access_write/README.md +0 -0
  17. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/research/pcode/README.md +0 -0
  18. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/roadmap.md +0 -0
  19. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/__main__.py +0 -0
  20. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_deflate.py +0 -0
  21. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_host.py +0 -0
  22. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_oforms_pages.py +0 -0
  23. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_oforms_records.py +0 -0
  24. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_ppt_container.py +0 -0
  25. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/__init__.py +0 -0
  26. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_database.accdb +0 -0
  27. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_database_module.accdb +0 -0
  28. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_document.docm +0 -0
  29. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_excel_addin.xlam +0 -0
  30. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_presentation.pptm +0 -0
  31. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsb +0 -0
  32. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsm +0 -0
  33. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsx +0 -0
  34. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/engine_skeleton.accdb +0 -0
  35. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/engine_skeleton.mdb +0 -0
  36. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/form.blob +0 -0
  37. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/form.lvprop +0 -0
  38. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/form.propdata +0 -0
  39. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/form.prototypes +0 -0
  40. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/form.typeinfo +0 -0
  41. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/report.blob +0 -0
  42. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/report.lvprop +0 -0
  43. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/report.propdata +0 -0
  44. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/report.prototypes +0 -0
  45. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/report.typeinfo +0 -0
  46. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/__init__.py +0 -0
  47. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_alloc.py +0 -0
  48. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_btree.py +0 -0
  49. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_collation.py +0 -0
  50. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_collation_general_legacy.py +0 -0
  51. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_compact.py +0 -0
  52. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_complex.py +0 -0
  53. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_datapage.py +0 -0
  54. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_ddl.py +0 -0
  55. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_designs.py +0 -0
  56. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_facade.py +0 -0
  57. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_format.py +0 -0
  58. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_index.py +0 -0
  59. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_lval.py +0 -0
  60. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_macros.py +0 -0
  61. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_pages.py +0 -0
  62. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_props.py +0 -0
  63. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_queries.py +0 -0
  64. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_rows.py +0 -0
  65. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_schema.py +0 -0
  66. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_sql.py +0 -0
  67. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_storage.py +0 -0
  68. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_tdef.py +0 -0
  69. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_validate.py +0 -0
  70. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_vba.py +0 -0
  71. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/database.py +0 -0
  72. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access_read.py +0 -0
  73. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/cfb.py +0 -0
  74. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/excel.py +0 -0
  75. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/exceptions.py +0 -0
  76. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/forms.py +0 -0
  77. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerpoint.py +0 -0
  78. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/__init__.py +0 -0
  79. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_binary.py +0 -0
  80. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_files.py +0 -0
  81. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_mashup.py +0 -0
  82. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_metadata.py +0 -0
  83. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_package.py +0 -0
  84. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_refresh.py +0 -0
  85. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_section.py +0 -0
  86. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/vba.py +0 -0
  87. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/vba_pcode.py +0 -0
  88. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/word.py +0 -0
  89. {pyopenvba-5.2.0 → pyopenvba-5.2.2}/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.2
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
@@ -221,6 +221,13 @@ wrong: **a connections part holding no connections is one Excel refuses**,
221
221
  so removing the last connection removes the part, its content type and
222
222
  its relationship as well.
223
223
 
224
+ The hidden name has a rule of its own. **`localSheetId` is the zero-based
225
+ position of the sheet the name belongs to** among the workbook's sheets,
226
+ not a constant, and it has to agree with the sheet the reference names.
227
+ Written as 0 it was right only while the table landed on the first sheet;
228
+ anywhere else Excel refuses to open the workbook, which is invisible in a
229
+ one-sheet file and so went unnoticed.
230
+
224
231
  ### Refresh control
225
232
 
226
233
  The boxes in Excel's Connection Properties dialog belong to that
@@ -277,6 +284,47 @@ this file exists because nothing here is written on a guess.
277
284
 
278
285
  ---
279
286
 
287
+ ## Workbooks another tool wrote
288
+
289
+ Excel is not the only writer of `.xlsx`, and the parts it produces are
290
+ one legal spelling among several. Three assumptions here came from
291
+ reading only Excel's output, and each one broke on openpyxl's:
292
+
293
+ * **Attribute order carries no meaning.** Excel opens a relationship with
294
+ `Id`, openpyxl closes with it. Matching in a fixed order found nothing,
295
+ so a sheet looked as though it had no part behind it and loading to it
296
+ failed outright.
297
+ * **An empty element may be written closed.** `<definedNames />` is the
298
+ same element as `<definedNames></definedNames>`. Appending a second
299
+ block beside it left two in the workbook, which Excel refuses.
300
+ * **A namespace prefix is declared where it is used.** Excel puts
301
+ `xmlns:r` on every worksheet; openpyxl puts it on a worksheet that
302
+ needs one, and a sheet with no table does not. Adding a `tablePart`
303
+ that used the prefix made the part not well formed.
304
+
305
+ Loading a query onto a sheet of a workbook openpyxl wrote works, and
306
+ Excel opens and refreshes the result.
307
+
308
+ **The other direction does not, and cannot be fixed here.** openpyxl
309
+ rebuilds the package from the parts it models and drops the rest, custom
310
+ XML included, so saving a workbook through it removes the Power Query
311
+ package and the queries with it. Nothing signals this: the file opens and
312
+ simply has no queries. Put pyOpenVBA last in the pipeline, or carry the
313
+ queries across with `pull_queries()` and `push_queries()`.
314
+
315
+ ## `[trash]` parts
316
+
317
+ Excel's own file recovery leaves the parts it threw out under
318
+ `[trash]/NNNN.dat`, beside the real ones. An OPC part name cannot open a
319
+ segment with a bracket, and Excel holds itself to that when reading: the
320
+ same workbook opens before such an entry is added and fails to open at
321
+ all after, measured both ways.
322
+
323
+ The container preserves every entry as it arrived, which would hand back
324
+ a file that stays broken, so `save()` drops these and warns. Nothing in
325
+ the document depends on them: they carry no content type and no
326
+ relationship points at them.
327
+
280
328
  ## What Excel refuses
281
329
 
282
330
  * 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.2"
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.2"
@@ -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)
@@ -137,13 +169,44 @@ def drop_content_type(package: OpcFile, part: str) -> None:
137
169
  )
138
170
 
139
171
 
172
+ def sheet_entries(workbook: str) -> list[tuple[str, str]]:
173
+ """``(name, relationship id)`` for each sheet, in the order the
174
+ workbook lists them.
175
+
176
+ The order is load-bearing: a ``definedName`` names the sheet it is
177
+ local to by its position here, so the list is the one place that
178
+ settles both which part a sheet is and what index it has.
179
+ """
180
+ out: list[tuple[str, str]] = []
181
+ for element in re.findall(r"<sheet\b[^>]*>", workbook):
182
+ attributes = _attributes(element)
183
+ # The relationship attribute carries a namespace prefix, and which
184
+ # prefix is the writer's to choose.
185
+ identifier = next(
186
+ (value for key, value in attributes.items() if key.endswith(":id")), None
187
+ )
188
+ if "name" in attributes and identifier:
189
+ out.append((attributes["name"], identifier))
190
+ return out
191
+
192
+
193
+ def sheet_index(package: OpcFile, name: str) -> int:
194
+ """A sheet's zero-based position among the workbook's sheets."""
195
+ sheets = sheet_entries(package.read(_WORKBOOK).decode("utf-8"))
196
+ for index, (found, _identifier) in enumerate(sheets):
197
+ if found == name:
198
+ return index
199
+ known = ", ".join(pair[0] for pair in sheets)
200
+ raise PowerQueryError(f"this workbook has no sheet named {name!r}; it has: {known}")
201
+
202
+
140
203
  def sheet_part(package: OpcFile, sheet: str | int) -> tuple[str, str]:
141
204
  """The worksheet part for a sheet name or one-based position, and the
142
205
  name of the sheet."""
143
206
  workbook = package.read(_WORKBOOK).decode("utf-8")
144
207
  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)
208
+ targets = _relationship_targets(rels)
209
+ sheets = sheet_entries(workbook)
147
210
  if not sheets:
148
211
  raise PowerQueryError("this workbook lists no worksheets")
149
212
  if isinstance(sheet, int):
@@ -306,6 +369,7 @@ def _add_table_to_sheet(
306
369
  for index, column in enumerate(columns)
307
370
  )
308
371
  raw = _write_header(raw, header, start, len(columns))
372
+ raw = _declare_relationship_namespace(raw)
309
373
  if "<tableParts" in raw:
310
374
  raw = re.sub(
311
375
  r'<tableParts count="(\d+)">',
@@ -321,6 +385,28 @@ def _add_table_to_sheet(
321
385
  package.write(part, raw.encode("utf-8"))
322
386
 
323
387
 
388
+ #: The namespace a `<tablePart r:id=...>` reference lives in.
389
+ _RELATIONSHIPS_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
390
+
391
+
392
+ def _declare_relationship_namespace(sheet: str) -> str:
393
+ """Make sure the worksheet element declares the `r:` prefix.
394
+
395
+ Excel always writes it on the root element, so the reference added
396
+ below resolved. openpyxl writes the prefix only where it uses one,
397
+ and a worksheet with no table has no use for it, which left the
398
+ `r:id` added here pointing at a prefix nothing declared. That is not
399
+ well-formed XML, and Excel would not open the workbook at all.
400
+ """
401
+ opening = re.search(r"<worksheet\b[^>]*>", sheet)
402
+ if opening is None or 'xmlns:r=' in opening.group(0):
403
+ return sheet
404
+ fixed = opening.group(0).replace(
405
+ "<worksheet", f'<worksheet xmlns:r="{_RELATIONSHIPS_NS}"', 1
406
+ )
407
+ return sheet.replace(opening.group(0), fixed, 1)
408
+
409
+
324
410
  def _write_header(sheet: str, cells: str, start: CellRef, width: int) -> str:
325
411
  """Put the header cells on the sheet.
326
412
 
@@ -368,17 +454,32 @@ def _widen_dimension(sheet: str, reference: str) -> str:
368
454
 
369
455
 
370
456
  def _add_defined_name(package: OpcFile, sheet_name: str, number: int, reference: str) -> None:
457
+ """The hidden name Excel gives an external data range.
458
+
459
+ ``localSheetId`` is the zero-based position of the sheet the name
460
+ belongs to among the workbook's sheets, so it has to be looked up
461
+ rather than assumed. Writing a constant 0 was right only while the
462
+ table landed on the first sheet, and named a different sheet than the
463
+ reference did for any other, which Excel will not open.
464
+ """
465
+ local = sheet_index(package, sheet_name)
371
466
  raw = package.read(_WORKBOOK).decode("utf-8")
372
467
  first, last = reference.split(":")
373
468
  absolute = f"${column_letter(CellRef.parse(first).column)}${CellRef.parse(first).row}"
374
469
  absolute += f":${column_letter(CellRef.parse(last).column)}${CellRef.parse(last).row}"
375
470
  quoted = f"'{sheet_name}'" if re.search(r"[^A-Za-z0-9_]", sheet_name) else sheet_name
376
471
  defined = (
377
- f'<definedName name="ExternalData_{number}" localSheetId="0" hidden="1">'
472
+ f'<definedName name="ExternalData_{number}" localSheetId="{local}" hidden="1">'
378
473
  f"{quoted}!{absolute}</definedName>"
379
474
  )
475
+ empty = re.search(r"<definedNames\s*/>", raw)
380
476
  if "<definedNames>" in raw:
381
477
  raw = raw.replace("<definedNames>", "<definedNames>" + defined)
478
+ elif empty is not None:
479
+ # An empty element written closed, which openpyxl does and Excel
480
+ # does not. Appending a second block beside it puts two in the
481
+ # workbook, and Excel refuses that outright.
482
+ raw = raw.replace(empty.group(0), f"<definedNames>{defined}</definedNames>", 1)
382
483
  elif "<calcPr" in raw:
383
484
  raw = raw.replace("<calcPr", f"<definedNames>{defined}</definedNames><calcPr", 1)
384
485
  else:
@@ -407,16 +508,14 @@ def unload_from_sheet(package: OpcFile, query: str) -> bool:
407
508
  if not package.has(rels_part):
408
509
  continue
409
510
  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:
511
+ identifier = _relationship_for(rels, f"../tables/table{number}.xml")
512
+ if identifier is None:
414
513
  continue
415
514
  drop_relationship(package, rels_part, f"../tables/table{number}.xml")
416
515
  if "<Relationship " not in package.read(rels_part).decode("utf-8"):
417
516
  package.remove(rels_part)
418
517
  sheet = package.read(part).decode("utf-8")
419
- sheet = re.sub(rf'<tablePart r:id="{found.group(1)}"\s*/>', "", sheet)
518
+ sheet = re.sub(rf'<tablePart\b[^>]*:id="{re.escape(identifier)}"[^>]*/>', "", sheet)
420
519
  sheet = re.sub(
421
520
  r'<tableParts count="(\d+)">',
422
521
  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