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.
Files changed (120) hide show
  1. libre_devops_helpers/__init__.py +23 -0
  2. libre_devops_helpers/__main__.py +5 -0
  3. libre_devops_helpers/cli/__init__.py +5 -0
  4. libre_devops_helpers/cli/app.py +147 -0
  5. libre_devops_helpers/cli/commands/__init__.py +1 -0
  6. libre_devops_helpers/cli/commands/automation.py +321 -0
  7. libre_devops_helpers/cli/commands/az.py +93 -0
  8. libre_devops_helpers/cli/commands/azure.py +258 -0
  9. libre_devops_helpers/cli/commands/config.py +54 -0
  10. libre_devops_helpers/cli/commands/devices.py +548 -0
  11. libre_devops_helpers/cli/commands/entra.py +554 -0
  12. libre_devops_helpers/cli/commands/graph.py +358 -0
  13. libre_devops_helpers/cli/commands/incidents.py +420 -0
  14. libre_devops_helpers/cli/commands/intune.py +79 -0
  15. libre_devops_helpers/cli/commands/keyvault.py +142 -0
  16. libre_devops_helpers/cli/commands/logicapp.py +489 -0
  17. libre_devops_helpers/cli/commands/logs.py +69 -0
  18. libre_devops_helpers/cli/commands/pim.py +381 -0
  19. libre_devops_helpers/cli/commands/pretty.py +141 -0
  20. libre_devops_helpers/cli/commands/profiles.py +153 -0
  21. libre_devops_helpers/cli/commands/snow.py +268 -0
  22. libre_devops_helpers/cli/commands/token.py +222 -0
  23. libre_devops_helpers/cli/commands/welcome.py +43 -0
  24. libre_devops_helpers/cli/commands/xdr.py +353 -0
  25. libre_devops_helpers/cli/exits.py +12 -0
  26. libre_devops_helpers/cli/options.py +146 -0
  27. libre_devops_helpers/cli/render.py +360 -0
  28. libre_devops_helpers/cli/runtime.py +348 -0
  29. libre_devops_helpers/cli/servicenow_runtime.py +170 -0
  30. libre_devops_helpers/core/__init__.py +94 -0
  31. libre_devops_helpers/core/auth.py +103 -0
  32. libre_devops_helpers/core/brand.py +67 -0
  33. libre_devops_helpers/core/browser.py +21 -0
  34. libre_devops_helpers/core/config.py +159 -0
  35. libre_devops_helpers/core/dpapi.py +60 -0
  36. libre_devops_helpers/core/errors.py +90 -0
  37. libre_devops_helpers/core/http.py +412 -0
  38. libre_devops_helpers/core/inputs.py +193 -0
  39. libre_devops_helpers/core/log.py +246 -0
  40. libre_devops_helpers/core/poll.py +88 -0
  41. libre_devops_helpers/core/process.py +106 -0
  42. libre_devops_helpers/core/sheets.py +330 -0
  43. libre_devops_helpers/core/tables.py +49 -0
  44. libre_devops_helpers/core/timewindow.py +127 -0
  45. libre_devops_helpers/core/token_store.py +266 -0
  46. libre_devops_helpers/core/util.py +120 -0
  47. libre_devops_helpers/core/yaml_text.py +142 -0
  48. libre_devops_helpers/microsoft/__init__.py +94 -0
  49. libre_devops_helpers/microsoft/auth/__init__.py +43 -0
  50. libre_devops_helpers/microsoft/auth/azure_cli.py +91 -0
  51. libre_devops_helpers/microsoft/auth/delegated.py +391 -0
  52. libre_devops_helpers/microsoft/auth/entra.py +241 -0
  53. libre_devops_helpers/microsoft/auth/factory.py +114 -0
  54. libre_devops_helpers/microsoft/auth/lapse.py +42 -0
  55. libre_devops_helpers/microsoft/auth/managed_identity.py +84 -0
  56. libre_devops_helpers/microsoft/automation/__init__.py +24 -0
  57. libre_devops_helpers/microsoft/automation/client.py +241 -0
  58. libre_devops_helpers/microsoft/automation/models.py +131 -0
  59. libre_devops_helpers/microsoft/azcli/__init__.py +27 -0
  60. libre_devops_helpers/microsoft/azcli/client.py +81 -0
  61. libre_devops_helpers/microsoft/azcli/context.py +95 -0
  62. libre_devops_helpers/microsoft/azure/__init__.py +34 -0
  63. libre_devops_helpers/microsoft/azure/client.py +260 -0
  64. libre_devops_helpers/microsoft/azure/models.py +198 -0
  65. libre_devops_helpers/microsoft/clouds.py +83 -0
  66. libre_devops_helpers/microsoft/config.py +244 -0
  67. libre_devops_helpers/microsoft/devices/__init__.py +45 -0
  68. libre_devops_helpers/microsoft/devices/antivirus.py +149 -0
  69. libre_devops_helpers/microsoft/devices/check.py +286 -0
  70. libre_devops_helpers/microsoft/devices/inspect.py +146 -0
  71. libre_devops_helpers/microsoft/devices/models.py +148 -0
  72. libre_devops_helpers/microsoft/entra/__init__.py +41 -0
  73. libre_devops_helpers/microsoft/entra/client.py +422 -0
  74. libre_devops_helpers/microsoft/entra/models.py +334 -0
  75. libre_devops_helpers/microsoft/entra/permissions.py +51 -0
  76. libre_devops_helpers/microsoft/graph/__init__.py +38 -0
  77. libre_devops_helpers/microsoft/graph/client.py +292 -0
  78. libre_devops_helpers/microsoft/incidents/__init__.py +51 -0
  79. libre_devops_helpers/microsoft/incidents/client.py +217 -0
  80. libre_devops_helpers/microsoft/incidents/models.py +165 -0
  81. libre_devops_helpers/microsoft/incidents/permissions.py +15 -0
  82. libre_devops_helpers/microsoft/intune/__init__.py +18 -0
  83. libre_devops_helpers/microsoft/intune/client.py +94 -0
  84. libre_devops_helpers/microsoft/intune/models.py +63 -0
  85. libre_devops_helpers/microsoft/intune/permissions.py +16 -0
  86. libre_devops_helpers/microsoft/keyvault/__init__.py +34 -0
  87. libre_devops_helpers/microsoft/keyvault/client.py +185 -0
  88. libre_devops_helpers/microsoft/loganalytics/__init__.py +17 -0
  89. libre_devops_helpers/microsoft/loganalytics/client.py +117 -0
  90. libre_devops_helpers/microsoft/logicapps/__init__.py +79 -0
  91. libre_devops_helpers/microsoft/logicapps/checks.py +432 -0
  92. libre_devops_helpers/microsoft/logicapps/client.py +162 -0
  93. libre_devops_helpers/microsoft/logicapps/document.py +202 -0
  94. libre_devops_helpers/microsoft/pim/__init__.py +39 -0
  95. libre_devops_helpers/microsoft/pim/azure.py +238 -0
  96. libre_devops_helpers/microsoft/pim/entra.py +294 -0
  97. libre_devops_helpers/microsoft/pim/models.py +93 -0
  98. libre_devops_helpers/microsoft/pim/permissions.py +98 -0
  99. libre_devops_helpers/microsoft/pim/rules.py +70 -0
  100. libre_devops_helpers/microsoft/process.py +70 -0
  101. libre_devops_helpers/microsoft/resources.py +137 -0
  102. libre_devops_helpers/microsoft/tokens.py +269 -0
  103. libre_devops_helpers/microsoft/xdr/__init__.py +33 -0
  104. libre_devops_helpers/microsoft/xdr/client.py +246 -0
  105. libre_devops_helpers/microsoft/xdr/models.py +181 -0
  106. libre_devops_helpers/microsoft/xdr/permissions.py +24 -0
  107. libre_devops_helpers/py.typed +0 -0
  108. libre_devops_helpers/servicenow/__init__.py +50 -0
  109. libre_devops_helpers/servicenow/auth.py +409 -0
  110. libre_devops_helpers/servicenow/config.py +261 -0
  111. libre_devops_helpers/servicenow/instance/__init__.py +32 -0
  112. libre_devops_helpers/servicenow/instance/client.py +91 -0
  113. libre_devops_helpers/servicenow/instance/models.py +131 -0
  114. libre_devops_helpers/servicenow/roles.py +23 -0
  115. libre_devops_helpers/servicenow/tables.py +133 -0
  116. libre_devops_helpers-0.4.1.dist-info/METADATA +153 -0
  117. libre_devops_helpers-0.4.1.dist-info/RECORD +120 -0
  118. libre_devops_helpers-0.4.1.dist-info/WHEEL +4 -0
  119. libre_devops_helpers-0.4.1.dist-info/entry_points.txt +2 -0
  120. 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)