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.
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/PKG-INFO +3 -2
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/README.md +1 -1
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/power_query.md +48 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/pyproject.toml +5 -1
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/__init__.py +1 -1
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_opc.py +25 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_sheets.py +107 -8
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/workbook.py +15 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/.gitignore +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/LICENSE.md +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/architecture.md +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/ms-ovba-implementation-guide_v2.md +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/research/access_complex/README.md +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/research/access_designs/README.md +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/research/access_macros/README.md +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/research/access_write/README.md +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/research/pcode/README.md +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/docs/roadmap.md +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/__main__.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_deflate.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_host.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_oforms_pages.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_oforms_records.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_ppt_container.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/__init__.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_database.accdb +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_database_module.accdb +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_document.docm +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_excel_addin.xlam +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_presentation.pptm +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsb +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsm +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsx +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/engine_skeleton.accdb +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/engine_skeleton.mdb +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/form.blob +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/form.lvprop +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/form.propdata +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/form.prototypes +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/form.typeinfo +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/report.blob +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/report.lvprop +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/report.propdata +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/report.prototypes +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/designs/report.typeinfo +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/__init__.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_alloc.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_btree.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_collation.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_collation_general_legacy.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_compact.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_complex.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_datapage.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_ddl.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_designs.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_facade.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_format.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_index.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_lval.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_macros.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_pages.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_props.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_queries.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_rows.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_schema.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_sql.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_storage.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_tdef.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_validate.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/_vba.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access/database.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/access_read.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/cfb.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/excel.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/exceptions.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/forms.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerpoint.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/__init__.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_binary.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_files.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_mashup.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_metadata.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_package.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_refresh.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/powerquery/_section.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/vba.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/vba_pcode.py +0 -0
- {pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/word.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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]
|
|
@@ -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 =
|
|
146
|
-
sheets =
|
|
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="
|
|
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
|
-
|
|
411
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_database.accdb
RENAMED
|
File without changes
|
{pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_database_module.accdb
RENAMED
|
File without changes
|
{pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_document.docm
RENAMED
|
File without changes
|
{pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_excel_addin.xlam
RENAMED
|
File without changes
|
{pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_presentation.pptm
RENAMED
|
File without changes
|
{pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsb
RENAMED
|
File without changes
|
{pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsm
RENAMED
|
File without changes
|
{pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsx
RENAMED
|
File without changes
|
{pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/engine_skeleton.accdb
RENAMED
|
File without changes
|
{pyopenvba-5.2.0 → pyopenvba-5.2.2}/src/pyopenvba/_templates/blank_files/engine_skeleton.mdb
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|