pyOpenVBA 5.2.1__tar.gz → 5.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/PKG-INFO +1 -1
  2. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/docs/power_query.md +19 -0
  3. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/pyproject.toml +1 -1
  4. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/__init__.py +1 -1
  5. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/powerquery/_sheets.py +100 -15
  6. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/.gitignore +0 -0
  7. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/LICENSE.md +0 -0
  8. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/README.md +0 -0
  9. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/docs/architecture.md +0 -0
  10. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/docs/ms-ovba-implementation-guide_v2.md +0 -0
  11. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/docs/research/access_complex/README.md +0 -0
  12. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/docs/research/access_designs/README.md +0 -0
  13. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/docs/research/access_macros/README.md +0 -0
  14. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/docs/research/access_write/README.md +0 -0
  15. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/docs/research/pcode/README.md +0 -0
  16. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/docs/roadmap.md +0 -0
  17. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/__main__.py +0 -0
  18. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_deflate.py +0 -0
  19. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_host.py +0 -0
  20. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_oforms_pages.py +0 -0
  21. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_oforms_records.py +0 -0
  22. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_ppt_container.py +0 -0
  23. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/__init__.py +0 -0
  24. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/blank_files/blank_database.accdb +0 -0
  25. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/blank_files/blank_database_module.accdb +0 -0
  26. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/blank_files/blank_document.docm +0 -0
  27. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/blank_files/blank_excel_addin.xlam +0 -0
  28. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/blank_files/blank_presentation.pptm +0 -0
  29. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsb +0 -0
  30. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsm +0 -0
  31. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/blank_files/blank_workbook.xlsx +0 -0
  32. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/blank_files/engine_skeleton.accdb +0 -0
  33. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/blank_files/engine_skeleton.mdb +0 -0
  34. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/designs/form.blob +0 -0
  35. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/designs/form.lvprop +0 -0
  36. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/designs/form.propdata +0 -0
  37. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/designs/form.prototypes +0 -0
  38. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/designs/form.typeinfo +0 -0
  39. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/designs/report.blob +0 -0
  40. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/designs/report.lvprop +0 -0
  41. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/designs/report.propdata +0 -0
  42. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/designs/report.prototypes +0 -0
  43. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/_templates/designs/report.typeinfo +0 -0
  44. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/__init__.py +0 -0
  45. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_alloc.py +0 -0
  46. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_btree.py +0 -0
  47. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_collation.py +0 -0
  48. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_collation_general_legacy.py +0 -0
  49. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_compact.py +0 -0
  50. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_complex.py +0 -0
  51. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_datapage.py +0 -0
  52. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_ddl.py +0 -0
  53. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_designs.py +0 -0
  54. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_facade.py +0 -0
  55. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_format.py +0 -0
  56. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_index.py +0 -0
  57. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_lval.py +0 -0
  58. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_macros.py +0 -0
  59. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_pages.py +0 -0
  60. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_props.py +0 -0
  61. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_queries.py +0 -0
  62. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_rows.py +0 -0
  63. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_schema.py +0 -0
  64. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_sql.py +0 -0
  65. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_storage.py +0 -0
  66. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_tdef.py +0 -0
  67. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_validate.py +0 -0
  68. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/_vba.py +0 -0
  69. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access/database.py +0 -0
  70. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/access_read.py +0 -0
  71. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/cfb.py +0 -0
  72. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/excel.py +0 -0
  73. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/exceptions.py +0 -0
  74. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/forms.py +0 -0
  75. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/powerpoint.py +0 -0
  76. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/powerquery/__init__.py +0 -0
  77. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/powerquery/_binary.py +0 -0
  78. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/powerquery/_files.py +0 -0
  79. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/powerquery/_mashup.py +0 -0
  80. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/powerquery/_metadata.py +0 -0
  81. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/powerquery/_opc.py +0 -0
  82. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/powerquery/_package.py +0 -0
  83. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/powerquery/_refresh.py +0 -0
  84. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/powerquery/_section.py +0 -0
  85. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/powerquery/workbook.py +0 -0
  86. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/vba.py +0 -0
  87. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/vba_pcode.py +0 -0
  88. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/src/pyopenvba/word.py +0 -0
  89. {pyopenvba-5.2.1 → pyopenvba-5.2.3}/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.1
3
+ Version: 5.2.3
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
@@ -221,6 +221,25 @@ 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 three rules of its own, and each was learned the
225
+ hard way:
226
+
227
+ * **`localSheetId` is the zero-based position of the sheet the name
228
+ belongs to** among the workbook's sheets, not a constant, and it has to
229
+ agree with the sheet the reference names. Written as 0 it was right
230
+ only while the table landed on the first sheet; anywhere else Excel
231
+ refuses to open the workbook, which a one-sheet file cannot show.
232
+ * **A sheet name inside a reference is quoted unless it is a plain
233
+ identifier**, and an apostrophe within it is doubled: `It's` is spelled
234
+ `'It''s'`. A single apostrophe closes the quoting early and the
235
+ reference names something else. A name opening with a digit is quoted
236
+ too, since unquoted it reads as part of a cell reference.
237
+ * **A sheet name is stored XML-escaped**, so `A & B` sits in the file as
238
+ `A & B`. Reading the stored form as if it were the name meant that
239
+ asking for the sheet by the name it actually has found nothing.
240
+ Attribute values are decoded on the way in and escaped again on the way
241
+ out.
242
+
224
243
  ### Refresh control
225
244
 
226
245
  The boxes in Excel's Connection Properties dialog belong to that
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "pyOpenVBA"
7
- version = "5.2.1"
7
+ version = "5.2.3"
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" }
@@ -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.1"
294
+ __version__ = "5.2.3"
@@ -84,6 +84,28 @@ def _escape(value: str) -> str:
84
84
  )
85
85
 
86
86
 
87
+ _ENTITIES = {"amp": "&", "lt": "<", "gt": ">", "quot": '"', "apos": "'"}
88
+
89
+
90
+ def _unescape(value: str) -> str:
91
+ """The characters an XML attribute's stored text stands for.
92
+
93
+ A sheet named ``A & B`` is stored as ``A &amp; B``, and reading the
94
+ stored form as if it were the name meant that asking for the sheet by
95
+ the name it actually has found nothing.
96
+ """
97
+
98
+ def replace(match: re.Match[str]) -> str:
99
+ body = match.group(1)
100
+ if body[:2] in {"#x", "#X"}:
101
+ return chr(int(body[2:], 16))
102
+ if body.startswith("#"):
103
+ return chr(int(body[1:]))
104
+ return _ENTITIES.get(body, match.group(0))
105
+
106
+ return re.sub(r"&(#[0-9]+|#[xX][0-9a-fA-F]+|[A-Za-z]+);", replace, value)
107
+
108
+
87
109
  def _next_relationship(rels: str) -> str:
88
110
  used = {int(number) for number in re.findall(r'Id="rId(\d+)"', rels)}
89
111
  index = 1
@@ -109,8 +131,15 @@ def _attributes(element: str) -> dict[str, str]:
109
131
  a relationship with ``Id``, openpyxl closes with it. Matching them in
110
132
  a fixed order silently found nothing in the second case, which left a
111
133
  sheet looking as though it had no part behind it.
134
+
135
+ Values come back as the characters they stand for, not as stored, so
136
+ a caller comparing one against a name a user typed compares like with
137
+ like.
112
138
  """
113
- return dict(re.findall(r'([\w.:-]+)\s*=\s*"([^"]*)"', element))
139
+ return {
140
+ key: _unescape(value)
141
+ for key, value in re.findall(r'([\w.:-]+)\s*=\s*"([^"]*)"', element)
142
+ }
114
143
 
115
144
 
116
145
  def _relationship_targets(rels: str) -> dict[str, str]:
@@ -169,13 +198,15 @@ def drop_content_type(package: OpcFile, part: str) -> None:
169
198
  )
170
199
 
171
200
 
172
- def sheet_part(package: OpcFile, sheet: str | int) -> tuple[str, str]:
173
- """The worksheet part for a sheet name or one-based position, and the
174
- name of the sheet."""
175
- workbook = package.read(_WORKBOOK).decode("utf-8")
176
- rels = package.read(_WORKBOOK_RELS).decode("utf-8")
177
- targets = _relationship_targets(rels)
178
- sheets: list[tuple[str, str]] = []
201
+ def sheet_entries(workbook: str) -> list[tuple[str, str]]:
202
+ """``(name, relationship id)`` for each sheet, in the order the
203
+ workbook lists them.
204
+
205
+ The order is load-bearing: a ``definedName`` names the sheet it is
206
+ local to by its position here, so the list is the one place that
207
+ settles both which part a sheet is and what index it has.
208
+ """
209
+ out: list[tuple[str, str]] = []
179
210
  for element in re.findall(r"<sheet\b[^>]*>", workbook):
180
211
  attributes = _attributes(element)
181
212
  # The relationship attribute carries a namespace prefix, and which
@@ -184,7 +215,27 @@ def sheet_part(package: OpcFile, sheet: str | int) -> tuple[str, str]:
184
215
  (value for key, value in attributes.items() if key.endswith(":id")), None
185
216
  )
186
217
  if "name" in attributes and identifier:
187
- sheets.append((attributes["name"], identifier))
218
+ out.append((attributes["name"], identifier))
219
+ return out
220
+
221
+
222
+ def sheet_index(package: OpcFile, name: str) -> int:
223
+ """A sheet's zero-based position among the workbook's sheets."""
224
+ sheets = sheet_entries(package.read(_WORKBOOK).decode("utf-8"))
225
+ for index, (found, _identifier) in enumerate(sheets):
226
+ if found == name:
227
+ return index
228
+ known = ", ".join(pair[0] for pair in sheets)
229
+ raise PowerQueryError(f"this workbook has no sheet named {name!r}; it has: {known}")
230
+
231
+
232
+ def sheet_part(package: OpcFile, sheet: str | int) -> tuple[str, str]:
233
+ """The worksheet part for a sheet name or one-based position, and the
234
+ name of the sheet."""
235
+ workbook = package.read(_WORKBOOK).decode("utf-8")
236
+ rels = package.read(_WORKBOOK_RELS).decode("utf-8")
237
+ targets = _relationship_targets(rels)
238
+ sheets = sheet_entries(workbook)
188
239
  if not sheets:
189
240
  raise PowerQueryError("this workbook lists no worksheets")
190
241
  if isinstance(sheet, int):
@@ -419,27 +470,61 @@ def _write_header(sheet: str, cells: str, start: CellRef, width: int) -> str:
419
470
 
420
471
 
421
472
  def _widen_dimension(sheet: str, reference: str) -> str:
422
- match = re.search(r'<dimension ref="([^"]*)"/>', sheet)
473
+ """Grow the sheet's declared extent to cover the range just written.
474
+
475
+ The element is matched by name rather than by its exact spelling: it
476
+ may carry other attributes, and a writer is free to put a space
477
+ before the closing slash, which openpyxl does. Insisting on Excel's
478
+ spelling left the extent saying the sheet ended where it did before
479
+ the table was written.
480
+ """
481
+ match = re.search(r"<dimension\b[^>]*/>", sheet)
423
482
  if match is None:
424
483
  return sheet
484
+ current = _attributes(match.group(0)).get("ref")
485
+ if current is None:
486
+ return sheet
425
487
  wanted = [CellRef.parse(part) for part in reference.split(":")]
426
- have = [CellRef.parse(part) for part in match.group(1).split(":")]
488
+ have = [CellRef.parse(part) for part in current.split(":")]
427
489
  if len(have) == 1:
428
490
  have = [have[0], have[0]]
429
491
  first = CellRef(min(have[0].column, wanted[0].column), min(have[0].row, wanted[0].row))
430
492
  last = CellRef(max(have[1].column, wanted[1].column), max(have[1].row, wanted[1].row))
431
- return sheet.replace(match.group(0), f'<dimension ref="{first}:{last}"/>')
493
+ widened = match.group(0).replace(f'ref="{current}"', f'ref="{first}:{last}"', 1)
494
+ return sheet.replace(match.group(0), widened, 1)
495
+
496
+
497
+ def quote_sheet(name: str) -> str:
498
+ """A sheet's name as a formula reference spells it.
499
+
500
+ Anything but a plain identifier is wrapped in apostrophes, and an
501
+ apostrophe inside the name is doubled, because a single one would
502
+ close the quoting early and leave a reference to something else. A
503
+ name opening with a digit is quoted too: unquoted it reads as part of
504
+ a cell reference.
505
+ """
506
+ if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name):
507
+ return name
508
+ return "'" + name.replace("'", "''") + "'"
432
509
 
433
510
 
434
511
  def _add_defined_name(package: OpcFile, sheet_name: str, number: int, reference: str) -> None:
512
+ """The hidden name Excel gives an external data range.
513
+
514
+ ``localSheetId`` is the zero-based position of the sheet the name
515
+ belongs to among the workbook's sheets, so it has to be looked up
516
+ rather than assumed. Writing a constant 0 was right only while the
517
+ table landed on the first sheet, and named a different sheet than the
518
+ reference did for any other, which Excel will not open.
519
+ """
520
+ local = sheet_index(package, sheet_name)
435
521
  raw = package.read(_WORKBOOK).decode("utf-8")
436
522
  first, last = reference.split(":")
437
523
  absolute = f"${column_letter(CellRef.parse(first).column)}${CellRef.parse(first).row}"
438
524
  absolute += f":${column_letter(CellRef.parse(last).column)}${CellRef.parse(last).row}"
439
- quoted = f"'{sheet_name}'" if re.search(r"[^A-Za-z0-9_]", sheet_name) else sheet_name
440
525
  defined = (
441
- f'<definedName name="ExternalData_{number}" localSheetId="0" hidden="1">'
442
- f"{quoted}!{absolute}</definedName>"
526
+ f'<definedName name="ExternalData_{number}" localSheetId="{local}" hidden="1">'
527
+ f"{_escape(quote_sheet(sheet_name))}!{absolute}</definedName>"
443
528
  )
444
529
  empty = re.search(r"<definedNames\s*/>", raw)
445
530
  if "<definedNames>" in raw:
File without changes
File without changes
File without changes
File without changes