libre-devops-helpers 0.4.1__py3-none-any.whl
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.
- libre_devops_helpers/__init__.py +23 -0
- libre_devops_helpers/__main__.py +5 -0
- libre_devops_helpers/cli/__init__.py +5 -0
- libre_devops_helpers/cli/app.py +147 -0
- libre_devops_helpers/cli/commands/__init__.py +1 -0
- libre_devops_helpers/cli/commands/automation.py +321 -0
- libre_devops_helpers/cli/commands/az.py +93 -0
- libre_devops_helpers/cli/commands/azure.py +258 -0
- libre_devops_helpers/cli/commands/config.py +54 -0
- libre_devops_helpers/cli/commands/devices.py +548 -0
- libre_devops_helpers/cli/commands/entra.py +554 -0
- libre_devops_helpers/cli/commands/graph.py +358 -0
- libre_devops_helpers/cli/commands/incidents.py +420 -0
- libre_devops_helpers/cli/commands/intune.py +79 -0
- libre_devops_helpers/cli/commands/keyvault.py +142 -0
- libre_devops_helpers/cli/commands/logicapp.py +489 -0
- libre_devops_helpers/cli/commands/logs.py +69 -0
- libre_devops_helpers/cli/commands/pim.py +381 -0
- libre_devops_helpers/cli/commands/pretty.py +141 -0
- libre_devops_helpers/cli/commands/profiles.py +153 -0
- libre_devops_helpers/cli/commands/snow.py +268 -0
- libre_devops_helpers/cli/commands/token.py +222 -0
- libre_devops_helpers/cli/commands/welcome.py +43 -0
- libre_devops_helpers/cli/commands/xdr.py +353 -0
- libre_devops_helpers/cli/exits.py +12 -0
- libre_devops_helpers/cli/options.py +146 -0
- libre_devops_helpers/cli/render.py +360 -0
- libre_devops_helpers/cli/runtime.py +348 -0
- libre_devops_helpers/cli/servicenow_runtime.py +170 -0
- libre_devops_helpers/core/__init__.py +94 -0
- libre_devops_helpers/core/auth.py +103 -0
- libre_devops_helpers/core/brand.py +67 -0
- libre_devops_helpers/core/browser.py +21 -0
- libre_devops_helpers/core/config.py +159 -0
- libre_devops_helpers/core/dpapi.py +60 -0
- libre_devops_helpers/core/errors.py +90 -0
- libre_devops_helpers/core/http.py +412 -0
- libre_devops_helpers/core/inputs.py +193 -0
- libre_devops_helpers/core/log.py +246 -0
- libre_devops_helpers/core/poll.py +88 -0
- libre_devops_helpers/core/process.py +106 -0
- libre_devops_helpers/core/sheets.py +330 -0
- libre_devops_helpers/core/tables.py +49 -0
- libre_devops_helpers/core/timewindow.py +127 -0
- libre_devops_helpers/core/token_store.py +266 -0
- libre_devops_helpers/core/util.py +120 -0
- libre_devops_helpers/core/yaml_text.py +142 -0
- libre_devops_helpers/microsoft/__init__.py +94 -0
- libre_devops_helpers/microsoft/auth/__init__.py +43 -0
- libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
- libre_devops_helpers/microsoft/auth/delegated.py +391 -0
- libre_devops_helpers/microsoft/auth/entra.py +241 -0
- libre_devops_helpers/microsoft/auth/factory.py +114 -0
- libre_devops_helpers/microsoft/auth/lapse.py +42 -0
- libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
- libre_devops_helpers/microsoft/automation/__init__.py +24 -0
- libre_devops_helpers/microsoft/automation/client.py +241 -0
- libre_devops_helpers/microsoft/automation/models.py +131 -0
- libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
- libre_devops_helpers/microsoft/azcli/client.py +81 -0
- libre_devops_helpers/microsoft/azcli/context.py +95 -0
- libre_devops_helpers/microsoft/azure/__init__.py +34 -0
- libre_devops_helpers/microsoft/azure/client.py +260 -0
- libre_devops_helpers/microsoft/azure/models.py +198 -0
- libre_devops_helpers/microsoft/clouds.py +83 -0
- libre_devops_helpers/microsoft/config.py +244 -0
- libre_devops_helpers/microsoft/devices/__init__.py +45 -0
- libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
- libre_devops_helpers/microsoft/devices/check.py +286 -0
- libre_devops_helpers/microsoft/devices/inspect.py +146 -0
- libre_devops_helpers/microsoft/devices/models.py +148 -0
- libre_devops_helpers/microsoft/entra/__init__.py +41 -0
- libre_devops_helpers/microsoft/entra/client.py +422 -0
- libre_devops_helpers/microsoft/entra/models.py +334 -0
- libre_devops_helpers/microsoft/entra/permissions.py +51 -0
- libre_devops_helpers/microsoft/graph/__init__.py +38 -0
- libre_devops_helpers/microsoft/graph/client.py +292 -0
- libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
- libre_devops_helpers/microsoft/incidents/client.py +217 -0
- libre_devops_helpers/microsoft/incidents/models.py +165 -0
- libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
- libre_devops_helpers/microsoft/intune/__init__.py +18 -0
- libre_devops_helpers/microsoft/intune/client.py +94 -0
- libre_devops_helpers/microsoft/intune/models.py +63 -0
- libre_devops_helpers/microsoft/intune/permissions.py +16 -0
- libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
- libre_devops_helpers/microsoft/keyvault/client.py +185 -0
- libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
- libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
- libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
- libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
- libre_devops_helpers/microsoft/logicapps/client.py +162 -0
- libre_devops_helpers/microsoft/logicapps/document.py +202 -0
- libre_devops_helpers/microsoft/pim/__init__.py +39 -0
- libre_devops_helpers/microsoft/pim/azure.py +238 -0
- libre_devops_helpers/microsoft/pim/entra.py +294 -0
- libre_devops_helpers/microsoft/pim/models.py +93 -0
- libre_devops_helpers/microsoft/pim/permissions.py +98 -0
- libre_devops_helpers/microsoft/pim/rules.py +70 -0
- libre_devops_helpers/microsoft/process.py +70 -0
- libre_devops_helpers/microsoft/resources.py +137 -0
- libre_devops_helpers/microsoft/tokens.py +269 -0
- libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
- libre_devops_helpers/microsoft/xdr/client.py +246 -0
- libre_devops_helpers/microsoft/xdr/models.py +181 -0
- libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
- libre_devops_helpers/py.typed +0 -0
- libre_devops_helpers/servicenow/__init__.py +50 -0
- libre_devops_helpers/servicenow/auth.py +409 -0
- libre_devops_helpers/servicenow/config.py +261 -0
- libre_devops_helpers/servicenow/instance/__init__.py +32 -0
- libre_devops_helpers/servicenow/instance/client.py +91 -0
- libre_devops_helpers/servicenow/instance/models.py +131 -0
- libre_devops_helpers/servicenow/roles.py +23 -0
- libre_devops_helpers/servicenow/tables.py +133 -0
- libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
- libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
- libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
- libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
- libre_devops_helpers-0.4.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Read the cells of an Excel workbook (.xlsx, .xlsm, .xltx, .xltm), with the standard library.
|
|
2
|
+
|
|
3
|
+
An Office Open XML workbook is a zip of XML parts: the workbook lists its sheets,
|
|
4
|
+
relationship parts say which file holds each one, and most text lives once in a shared
|
|
5
|
+
strings table that cells point into. Only cell values are read. Formulas are never
|
|
6
|
+
evaluated (the value Excel saved with the file is used), and macros in an ``.xlsm`` are
|
|
7
|
+
never touched.
|
|
8
|
+
|
|
9
|
+
A workbook is untrusted input, so each part is streamed with a size cap (a zip can claim
|
|
10
|
+
any size, and a small file can inflate to gigabytes) and a document type declaration is
|
|
11
|
+
refused before the parser sees it, which rules out entity expansion attacks. Office never
|
|
12
|
+
writes one. Parts must be UTF-8, as Office writes them, so that check cannot be dodged by
|
|
13
|
+
declaring another encoding.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import posixpath
|
|
19
|
+
import re
|
|
20
|
+
import zipfile
|
|
21
|
+
import zlib
|
|
22
|
+
from collections.abc import Iterator
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import IO, NamedTuple
|
|
26
|
+
from xml.etree import ElementTree
|
|
27
|
+
|
|
28
|
+
from libre_devops_helpers.core.errors import InputError
|
|
29
|
+
|
|
30
|
+
SUFFIXES = frozenset({".xlsx", ".xlsm", ".xltx", ".xltm"})
|
|
31
|
+
# Spreadsheet formats this module cannot read, and what to do instead.
|
|
32
|
+
OTHER_FORMATS = {
|
|
33
|
+
".xls": "the old binary Excel format",
|
|
34
|
+
".xlsb": "the binary Excel format",
|
|
35
|
+
".ods": "an OpenDocument spreadsheet",
|
|
36
|
+
".numbers": "a Numbers spreadsheet",
|
|
37
|
+
}
|
|
38
|
+
SAVE_AS_HINT = "save it as .xlsx or .csv"
|
|
39
|
+
# Any one part may inflate to this much. A sheet of a million short rows fits.
|
|
40
|
+
MAX_PART_BYTES = 128 * 1024 * 1024
|
|
41
|
+
MAX_COLUMNS = 16_384 # Excel's own limit, column XFD
|
|
42
|
+
_CHUNK = 64 * 1024
|
|
43
|
+
# The first read is at least this long, so it holds the whole XML declaration.
|
|
44
|
+
_PROLOG = 1024
|
|
45
|
+
_DOCTYPE = b"<!DOCTYPE"
|
|
46
|
+
_UTF8_BOM = b"\xef\xbb\xbf"
|
|
47
|
+
_ENCODING = re.compile(rb"""^<\?xml[^>]*?\sencoding\s*=\s*["']([^"']*)["']""")
|
|
48
|
+
# Legacy .xls files and password-protected workbooks are both OLE compound files.
|
|
49
|
+
_OLE_MAGIC = bytes.fromhex("d0cf11e0a1b11ae1")
|
|
50
|
+
_CELL_COLUMN = re.compile(r"^([A-Za-z]{1,3})")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class SheetRow(NamedTuple):
|
|
54
|
+
"""The cell values of one row, left to right, and whether Excel hides the row.
|
|
55
|
+
|
|
56
|
+
Rows are hidden by hand or by a filter; either way they are still in the file.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
cells: list[str]
|
|
60
|
+
hidden: bool
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class Sheet:
|
|
65
|
+
"""One worksheet: its tab name, whether the tab is hidden, and its part in the zip."""
|
|
66
|
+
|
|
67
|
+
name: str
|
|
68
|
+
hidden: bool
|
|
69
|
+
part: str
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Workbook:
|
|
73
|
+
"""An open workbook. Use it as a context manager, or call :meth:`close`."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, path: Path) -> None:
|
|
76
|
+
self.path = path
|
|
77
|
+
self._archive = _open_archive(path)
|
|
78
|
+
try:
|
|
79
|
+
self.sheets, strings_part = self._read_structure()
|
|
80
|
+
self._strings = self._read_strings(strings_part) if strings_part else []
|
|
81
|
+
except BaseException:
|
|
82
|
+
self._archive.close()
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
def __enter__(self) -> Workbook:
|
|
86
|
+
return self
|
|
87
|
+
|
|
88
|
+
def __exit__(self, *exc: object) -> None:
|
|
89
|
+
self.close()
|
|
90
|
+
|
|
91
|
+
def close(self) -> None:
|
|
92
|
+
self._archive.close()
|
|
93
|
+
|
|
94
|
+
def sheet(self, name: str) -> Sheet:
|
|
95
|
+
"""The sheet with this tab name, matched case-insensitively. Hidden ones count."""
|
|
96
|
+
wanted = name.strip().casefold()
|
|
97
|
+
for sheet in self.sheets:
|
|
98
|
+
if sheet.name.casefold() == wanted:
|
|
99
|
+
return sheet
|
|
100
|
+
raise InputError(
|
|
101
|
+
f"{self.path} has no sheet {name!r}",
|
|
102
|
+
hint=f"sheets: {', '.join(sheet.name for sheet in self.sheets)}",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def rows(self, sheet: Sheet) -> Iterator[SheetRow]:
|
|
106
|
+
"""Every row the sheet stores, in order. Empty rows Excel left out stay out."""
|
|
107
|
+
for element in _elements(self._archive, sheet.part, self.path, "row"):
|
|
108
|
+
yield SheetRow(self._row_cells(element), element.get("hidden") in {"1", "true"})
|
|
109
|
+
element.clear()
|
|
110
|
+
|
|
111
|
+
def _row_cells(self, row: ElementTree.Element) -> list[str]:
|
|
112
|
+
values: dict[int, str] = {}
|
|
113
|
+
position = -1
|
|
114
|
+
for cell in row:
|
|
115
|
+
if _local(cell.tag) != "c":
|
|
116
|
+
continue
|
|
117
|
+
reference = _CELL_COLUMN.match(cell.get("r") or "")
|
|
118
|
+
position = _column_index(reference.group(1)) if reference else position + 1
|
|
119
|
+
if position < MAX_COLUMNS:
|
|
120
|
+
values[position] = self._cell_value(cell)
|
|
121
|
+
if not values:
|
|
122
|
+
return []
|
|
123
|
+
cells = [""] * (max(values) + 1)
|
|
124
|
+
for index, value in values.items():
|
|
125
|
+
cells[index] = value
|
|
126
|
+
return cells
|
|
127
|
+
|
|
128
|
+
def _cell_value(self, cell: ElementTree.Element) -> str:
|
|
129
|
+
kind = cell.get("t", "n")
|
|
130
|
+
if kind == "inlineStr":
|
|
131
|
+
inline = _child(cell, "is")
|
|
132
|
+
return _rich_text(inline) if inline is not None else ""
|
|
133
|
+
value = _child(cell, "v")
|
|
134
|
+
text = (value.text or "") if value is not None else ""
|
|
135
|
+
if kind == "s":
|
|
136
|
+
try:
|
|
137
|
+
return self._strings[int(text)]
|
|
138
|
+
except (ValueError, IndexError):
|
|
139
|
+
raise InputError(
|
|
140
|
+
f"{self.path} is damaged: a cell points at a missing shared string"
|
|
141
|
+
) from None
|
|
142
|
+
if kind == "b":
|
|
143
|
+
return "TRUE" if text == "1" else "FALSE"
|
|
144
|
+
if kind == "e":
|
|
145
|
+
return "" # #N/A, #REF! and the like hold no value worth reading
|
|
146
|
+
return text
|
|
147
|
+
|
|
148
|
+
def _read_structure(self) -> tuple[list[Sheet], str | None]:
|
|
149
|
+
if "_rels/.rels" not in self._archive.namelist():
|
|
150
|
+
raise InputError(f"{self.path} is not an Excel workbook", hint=SAVE_AS_HINT)
|
|
151
|
+
workbook_part = _target(self._relationships("_rels/.rels", ""), "officeDocument")
|
|
152
|
+
if workbook_part is None:
|
|
153
|
+
raise InputError(f"{self.path} is not an Excel workbook", hint=SAVE_AS_HINT)
|
|
154
|
+
folder = posixpath.dirname(workbook_part)
|
|
155
|
+
rels_part = posixpath.join(folder, "_rels", posixpath.basename(workbook_part) + ".rels")
|
|
156
|
+
relationships = self._relationships(rels_part, folder)
|
|
157
|
+
sheets = []
|
|
158
|
+
for element in _elements(self._archive, workbook_part, self.path, "sheet"):
|
|
159
|
+
# The relationship id is r:id, in a namespace that differs between the
|
|
160
|
+
# transitional and strict flavours of the format, so match it by local name.
|
|
161
|
+
rel_id = next((v for k, v in element.attrib.items() if _local(k) == "id"), None)
|
|
162
|
+
kind, part = relationships.get(rel_id or "", ("", ""))
|
|
163
|
+
if kind == "worksheet": # chart sheets and dialog sheets hold no cells
|
|
164
|
+
hidden = element.get("state", "visible") != "visible"
|
|
165
|
+
sheets.append(Sheet(element.get("name", ""), hidden, part))
|
|
166
|
+
if not sheets:
|
|
167
|
+
raise InputError(f"{self.path} has no worksheets")
|
|
168
|
+
return sheets, _target(relationships, "sharedStrings")
|
|
169
|
+
|
|
170
|
+
def _relationships(self, part: str, folder: str) -> dict[str, tuple[str, str]]:
|
|
171
|
+
"""Relationship id -> (type, part path), for the relationships part given."""
|
|
172
|
+
found = {}
|
|
173
|
+
for element in _elements(self._archive, part, self.path, "Relationship"):
|
|
174
|
+
if element.get("TargetMode") == "External":
|
|
175
|
+
continue
|
|
176
|
+
target = element.get("Target", "")
|
|
177
|
+
path = target[1:] if target.startswith("/") else posixpath.join(folder, target)
|
|
178
|
+
kind = element.get("Type", "").rsplit("/", 1)[-1]
|
|
179
|
+
found[element.get("Id", "")] = (kind, posixpath.normpath(path))
|
|
180
|
+
return found
|
|
181
|
+
|
|
182
|
+
def _read_strings(self, part: str) -> list[str]:
|
|
183
|
+
strings = []
|
|
184
|
+
for element in _elements(self._archive, part, self.path, "si"):
|
|
185
|
+
strings.append(_rich_text(element))
|
|
186
|
+
element.clear()
|
|
187
|
+
return strings
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def open_workbook(path: Path) -> Workbook:
|
|
191
|
+
"""Open ``path`` as a workbook, or raise :class:`InputError` saying why it cannot be."""
|
|
192
|
+
return Workbook(path)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def is_workbook(path: Path) -> bool:
|
|
196
|
+
"""True when the file's suffix says it is a workbook this module reads."""
|
|
197
|
+
return path.suffix.lower() in SUFFIXES
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def check_readable(path: Path) -> None:
|
|
201
|
+
"""Refuse, with advice, a spreadsheet format this module cannot read."""
|
|
202
|
+
kind = OTHER_FORMATS.get(path.suffix.lower())
|
|
203
|
+
if kind:
|
|
204
|
+
raise InputError(f"{path} is {kind}, which cannot be read", hint=SAVE_AS_HINT)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _open_archive(path: Path) -> zipfile.ZipFile:
|
|
208
|
+
try:
|
|
209
|
+
return zipfile.ZipFile(path)
|
|
210
|
+
except zipfile.BadZipFile:
|
|
211
|
+
try:
|
|
212
|
+
with path.open("rb") as handle:
|
|
213
|
+
protected = handle.read(len(_OLE_MAGIC)) == _OLE_MAGIC
|
|
214
|
+
except OSError:
|
|
215
|
+
protected = False
|
|
216
|
+
if protected:
|
|
217
|
+
raise InputError(
|
|
218
|
+
f"{path} is protected with a password, or in the old binary format",
|
|
219
|
+
hint=f"remove the password, or {SAVE_AS_HINT}",
|
|
220
|
+
) from None
|
|
221
|
+
raise InputError(f"{path} is not an Excel workbook", hint=SAVE_AS_HINT) from None
|
|
222
|
+
except OSError as exc:
|
|
223
|
+
raise InputError(f"cannot read {path}: {exc}") from None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _elements(
|
|
227
|
+
archive: zipfile.ZipFile, part: str, path: Path, name: str
|
|
228
|
+
) -> Iterator[ElementTree.Element]:
|
|
229
|
+
"""Each complete element called ``name`` (any namespace) in ``part``, streamed."""
|
|
230
|
+
try:
|
|
231
|
+
stream = archive.open(part)
|
|
232
|
+
except KeyError:
|
|
233
|
+
raise InputError(f"{path} is damaged: it has no {part}") from None
|
|
234
|
+
except (RuntimeError, NotImplementedError, zipfile.BadZipFile) as exc:
|
|
235
|
+
# An encrypted zip entry, or a compression method zipfile lacks.
|
|
236
|
+
raise InputError(f"cannot read {part} in {path}: {exc}") from None
|
|
237
|
+
parser = ElementTree.XMLPullParser(events=("end",))
|
|
238
|
+
with stream:
|
|
239
|
+
for chunk in _guarded(stream, part, path):
|
|
240
|
+
yield from _parsed(parser, chunk, name, part, path)
|
|
241
|
+
yield from _parsed(parser, None, name, part, path)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _parsed(
|
|
245
|
+
parser: ElementTree.XMLPullParser, chunk: bytes | None, name: str, part: str, path: Path
|
|
246
|
+
) -> list[ElementTree.Element]:
|
|
247
|
+
"""Feed ``chunk`` (``None`` at the end) and take the elements it completed."""
|
|
248
|
+
try:
|
|
249
|
+
if chunk is None:
|
|
250
|
+
parser.close()
|
|
251
|
+
else:
|
|
252
|
+
parser.feed(chunk)
|
|
253
|
+
# A parse error surfaces here, not in feed().
|
|
254
|
+
return [item for _, item in parser.read_events() if _local(item.tag) == name]
|
|
255
|
+
except ElementTree.ParseError as exc:
|
|
256
|
+
raise InputError(f"{path} is damaged: {part}: {exc}") from None
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _guarded(stream: IO[bytes], part: str, path: Path) -> Iterator[bytes]:
|
|
260
|
+
"""The part's bytes, capped in size, refusing any document type declaration."""
|
|
261
|
+
total = 0
|
|
262
|
+
tail = b""
|
|
263
|
+
while True:
|
|
264
|
+
try:
|
|
265
|
+
chunk = stream.read(_CHUNK if total else max(_CHUNK, _PROLOG))
|
|
266
|
+
except (zipfile.BadZipFile, zlib.error, EOFError) as exc:
|
|
267
|
+
raise InputError(f"{path} is damaged: {part}: {exc}") from None
|
|
268
|
+
if not chunk:
|
|
269
|
+
return
|
|
270
|
+
if not total:
|
|
271
|
+
_check_utf8(chunk, part, path)
|
|
272
|
+
total += len(chunk)
|
|
273
|
+
if total > MAX_PART_BYTES:
|
|
274
|
+
raise InputError(
|
|
275
|
+
f"{path} is too large to read: {part} inflates past "
|
|
276
|
+
f"{MAX_PART_BYTES // (1024 * 1024)} MiB"
|
|
277
|
+
)
|
|
278
|
+
# Keep the end of the previous chunk, so a declaration split across two is seen.
|
|
279
|
+
if _DOCTYPE in tail + chunk:
|
|
280
|
+
raise InputError(f"{path} is not a workbook Office wrote: {part} declares a DTD")
|
|
281
|
+
tail = chunk[-(len(_DOCTYPE) - 1) :]
|
|
282
|
+
yield chunk
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _check_utf8(start: bytes, part: str, path: Path) -> None:
|
|
286
|
+
"""Refuse a part that is not UTF-8, so the byte check for a DTD means what it says."""
|
|
287
|
+
text = start.removeprefix(_UTF8_BOM)
|
|
288
|
+
declared = _ENCODING.match(text)
|
|
289
|
+
unfinished = text.startswith(b"<?xml") and b"?>" not in text
|
|
290
|
+
if (
|
|
291
|
+
not text.startswith(b"<")
|
|
292
|
+
or unfinished
|
|
293
|
+
or (declared is not None and declared.group(1).lower() not in {b"utf-8", b"utf8"})
|
|
294
|
+
):
|
|
295
|
+
raise InputError(f"{path} is not a workbook Office wrote: {part} is not UTF-8")
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _target(relationships: dict[str, tuple[str, str]], kind: str) -> str | None:
|
|
299
|
+
return next((part for rel_kind, part in relationships.values() if rel_kind == kind), None)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _rich_text(element: ElementTree.Element) -> str:
|
|
303
|
+
"""The text of a string item: a plain ``t``, or the ``t`` of each run, in order.
|
|
304
|
+
|
|
305
|
+
Phonetic guides (``rPh``) are not part of the text, so they are left out.
|
|
306
|
+
"""
|
|
307
|
+
parts = []
|
|
308
|
+
for child in element:
|
|
309
|
+
tag = _local(child.tag)
|
|
310
|
+
if tag == "t":
|
|
311
|
+
parts.append(child.text or "")
|
|
312
|
+
elif tag == "r":
|
|
313
|
+
parts.extend(t.text or "" for t in child if _local(t.tag) == "t")
|
|
314
|
+
return "".join(parts)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _child(element: ElementTree.Element, name: str) -> ElementTree.Element | None:
|
|
318
|
+
return next((child for child in element if _local(child.tag) == name), None)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _local(tag: str) -> str:
|
|
322
|
+
return tag.rsplit("}", 1)[-1]
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _column_index(letters: str) -> int:
|
|
326
|
+
"""Zero-based column: ``A`` -> 0, ``Z`` -> 25, ``AA`` -> 26."""
|
|
327
|
+
index = 0
|
|
328
|
+
for letter in letters.upper():
|
|
329
|
+
index = index * 26 + ord(letter) - ord("A") + 1
|
|
330
|
+
return index - 1
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Tabular query results: one shape for Advanced Hunting, Resource Graph and Log Analytics.
|
|
2
|
+
|
|
3
|
+
Each of those APIs returns rows and columns in its own layout; each module converts
|
|
4
|
+
its reply into a ``QueryResult`` so the CLI renders all three the same way.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class QueryResult:
|
|
16
|
+
"""Rows keyed by column name, with the columns in the order the query produced them.
|
|
17
|
+
|
|
18
|
+
``truncated`` is true when more rows existed than were fetched. ``warnings`` carries
|
|
19
|
+
anything the service said about a partial result.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
columns: tuple[str, ...]
|
|
23
|
+
rows: tuple[Mapping[str, Any], ...]
|
|
24
|
+
truncated: bool = False
|
|
25
|
+
warnings: tuple[str, ...] = ()
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_records(
|
|
29
|
+
cls, records: Iterable[Mapping[str, Any]], *, truncated: bool = False
|
|
30
|
+
) -> QueryResult:
|
|
31
|
+
"""Build from a list of objects, taking columns in first-seen order."""
|
|
32
|
+
rows = tuple(dict(record) for record in records)
|
|
33
|
+
columns: dict[str, None] = {}
|
|
34
|
+
for row in rows:
|
|
35
|
+
columns.update(dict.fromkeys(row))
|
|
36
|
+
return cls(tuple(columns), rows, truncated)
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def from_columns(
|
|
40
|
+
cls,
|
|
41
|
+
columns: Sequence[str],
|
|
42
|
+
values: Iterable[Sequence[Any]],
|
|
43
|
+
*,
|
|
44
|
+
truncated: bool = False,
|
|
45
|
+
) -> QueryResult:
|
|
46
|
+
"""Build from column names plus rows of values in that column order."""
|
|
47
|
+
names = tuple(columns)
|
|
48
|
+
rows = tuple(dict(zip(names, row, strict=False)) for row in values)
|
|
49
|
+
return cls(names, rows, truncated)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Time windows for "what happened when": today, yesterday, the last 7d, or between days.
|
|
2
|
+
|
|
3
|
+
Days are local days: "today" starts at midnight where you are, and ``--from 2026-09-01
|
|
4
|
+
--to 2026-09-24`` covers both days whole. A window's ``start`` and ``end`` are timezone
|
|
5
|
+
aware, so they convert cleanly to the UTC an API filter wants.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import date, datetime, time, timedelta, tzinfo
|
|
14
|
+
|
|
15
|
+
from libre_devops_helpers.core.errors import InputError
|
|
16
|
+
from libre_devops_helpers.core.util import format_duration
|
|
17
|
+
|
|
18
|
+
_DAY = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def local_now() -> datetime:
|
|
22
|
+
return datetime.now().astimezone()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Window:
|
|
27
|
+
"""From ``start`` (inclusive) to ``end`` (exclusive); None is open-ended."""
|
|
28
|
+
|
|
29
|
+
start: datetime | None
|
|
30
|
+
end: datetime | None
|
|
31
|
+
label: str
|
|
32
|
+
|
|
33
|
+
def contains(self, when: datetime | None) -> bool:
|
|
34
|
+
if when is None:
|
|
35
|
+
return False
|
|
36
|
+
if self.start is not None and when < self.start:
|
|
37
|
+
return False
|
|
38
|
+
return not (self.end is not None and when >= self.end)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_day(text: str, *, today: date) -> date:
|
|
42
|
+
"""``today``, ``yesterday`` or ``YYYY-MM-DD``."""
|
|
43
|
+
value = text.strip().lower()
|
|
44
|
+
if value == "today":
|
|
45
|
+
return today
|
|
46
|
+
if value == "yesterday":
|
|
47
|
+
return today - timedelta(days=1)
|
|
48
|
+
if _DAY.match(value):
|
|
49
|
+
try:
|
|
50
|
+
return date.fromisoformat(value)
|
|
51
|
+
except ValueError:
|
|
52
|
+
pass
|
|
53
|
+
raise InputError(f"{text!r} is not a day", hint="use YYYY-MM-DD, today or yesterday")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def choose_window(
|
|
57
|
+
*,
|
|
58
|
+
today: bool = False,
|
|
59
|
+
yesterday: bool = False,
|
|
60
|
+
since: timedelta | None = None,
|
|
61
|
+
start_day: str | None = None,
|
|
62
|
+
end_day: str | None = None,
|
|
63
|
+
default: Callable[[datetime], Window] | None = None,
|
|
64
|
+
now: datetime | None = None,
|
|
65
|
+
) -> Window:
|
|
66
|
+
"""The window the options name. Only one kind may be given; ``default`` otherwise.
|
|
67
|
+
|
|
68
|
+
``start_day`` and ``end_day`` are inclusive, and either may be left out.
|
|
69
|
+
"""
|
|
70
|
+
# With the real clock, midnights come from the system's own rules, so a window that
|
|
71
|
+
# spans a clocks-change day still starts and ends at local midnight.
|
|
72
|
+
zone = now.tzinfo if now is not None else None
|
|
73
|
+
now = now or local_now()
|
|
74
|
+
kinds = [today, yesterday, since is not None, bool(start_day or end_day)]
|
|
75
|
+
if sum(kinds) > 1:
|
|
76
|
+
raise InputError(
|
|
77
|
+
"choose one time window",
|
|
78
|
+
hint="--today, --yesterday, --since, or --from and --to",
|
|
79
|
+
)
|
|
80
|
+
if today:
|
|
81
|
+
return day_window(now.date(), zone, "today")
|
|
82
|
+
if yesterday:
|
|
83
|
+
return day_window(now.date() - timedelta(days=1), zone, "yesterday")
|
|
84
|
+
if since is not None:
|
|
85
|
+
return last(since, now)
|
|
86
|
+
if start_day or end_day:
|
|
87
|
+
first = parse_day(start_day, today=now.date()) if start_day else None
|
|
88
|
+
final = parse_day(end_day, today=now.date()) if end_day else None
|
|
89
|
+
if first and final and first > final:
|
|
90
|
+
raise InputError(f"--from {first} is after --to {final}")
|
|
91
|
+
start = _midnight(first, zone) if first else None
|
|
92
|
+
end = _midnight(final + timedelta(days=1), zone) if final else None
|
|
93
|
+
if first and final:
|
|
94
|
+
label = f"{first}" if first == final else f"{first} to {final}"
|
|
95
|
+
else:
|
|
96
|
+
label = f"from {first}" if first else f"up to {final}"
|
|
97
|
+
return Window(start, end, label)
|
|
98
|
+
return default(now) if default else Window(None, None, "all time")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def day_window(day: date, zone: tzinfo | None, label: str | None = None) -> Window:
|
|
102
|
+
"""The whole of ``day``, midnight to midnight, in ``zone``."""
|
|
103
|
+
return Window(_midnight(day, zone), _midnight(day + timedelta(days=1), zone), label or f"{day}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def last(span: timedelta, now: datetime) -> Window:
|
|
107
|
+
"""The ``span`` up to ``now``."""
|
|
108
|
+
return Window(now - span, None, f"the last {_span(span)}")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def today_window(now: datetime) -> Window:
|
|
112
|
+
return day_window(now.date(), now.tzinfo, "today")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _midnight(day: date, zone: tzinfo | None) -> datetime:
|
|
116
|
+
if zone is None:
|
|
117
|
+
return datetime.combine(day, time.min).astimezone() # local rules for that day
|
|
118
|
+
return datetime.combine(day, time.min, tzinfo=zone)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _span(delta: timedelta) -> str:
|
|
122
|
+
"""``7d``, ``12h`` or ``30m`` when it is a whole number of them."""
|
|
123
|
+
seconds = int(delta.total_seconds())
|
|
124
|
+
for unit, size in (("d", 86400), ("h", 3600), ("m", 60)):
|
|
125
|
+
if seconds and seconds % size == 0:
|
|
126
|
+
return f"{seconds // size}{unit}"
|
|
127
|
+
return format_duration(delta)
|