view-scheduler 0.4.0__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.
@@ -0,0 +1,34 @@
1
+ """View materialization scheduler.
2
+
3
+ Two tools for a Unity Catalog view:
4
+
5
+ * `advise` — suggest the time each day the view can run with every upstream
6
+ job already finished. Computed from history. ADVISORY.
7
+ * `can_refresh` — a runtime GATE. Right now, has every dependency of this
8
+ view refreshed recently enough? READY or BLOCKED.
9
+
10
+ READ ONLY. This package never writes, alters, optimizes, vacuums or drops a
11
+ user table. Metadata and history reads only.
12
+
13
+ `advise` predicts. `can_refresh` checks. Use the first to set the schedule,
14
+ the second to guard the run. See README.md.
15
+ """
16
+
17
+ from .advice import Advice, DependencyRun, advise
18
+ from .client import Workspace
19
+ from .config import Config
20
+ from .gate import Dependency, GateResult, ViewResult, can_refresh
21
+ from .run import analyze
22
+
23
+ __all__ = [
24
+ "advise",
25
+ "Advice",
26
+ "analyze",
27
+ "can_refresh",
28
+ "Config",
29
+ "Dependency",
30
+ "DependencyRun",
31
+ "GateResult",
32
+ "ViewResult",
33
+ "Workspace",
34
+ ]
@@ -0,0 +1,162 @@
1
+ """Advise when a view can run, as an object you can read or log.
2
+
3
+ `advise()` is the easy front door to the analyzer. It prints a plain report and
4
+ returns an `Advice` that carries the same numbers as fields.
5
+
6
+ from view_scheduler import advise
7
+
8
+ advice = advise("sales.gold.customer_360") # WAREHOUSE_ID from the env
9
+ print(advice.safe_time) # 06:40 or None
10
+
11
+ `analyze()` is the low-level call. It returns a tuple of raw pieces. Use it
12
+ only when you need the per-table history objects.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from datetime import date, datetime, time
19
+
20
+ from . import report
21
+ from .client import Workspace, workspace_from_env
22
+ from .config import DEFAULT, Config
23
+ from .models import Skipped
24
+ from .recommend import Recommendation
25
+ from .run import collect
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class DependencyRun:
30
+ """One upstream job, and what its history looked like."""
31
+
32
+ name: str
33
+ runs: str # "daily", "Mondays", "never", ...
34
+ usual: time | None # its usual finish time
35
+ latest: time | None # its latest finish time
36
+ days: int # days it ran in the period
37
+ missed: int # days it did not run
38
+ note: str = ""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class Advice:
43
+ """When a view can safely run. One object, ready to read or log."""
44
+
45
+ view: str
46
+ safe_time: time | None
47
+ earlier_time: time | None
48
+ next_day: bool
49
+ earlier_next_day: bool
50
+ days_used: int
51
+ period_days: int
52
+ timezone: str
53
+ dependencies: tuple[DependencyRun, ...] = ()
54
+ unreadable: tuple[Skipped, ...] = ()
55
+ finished_by_day: tuple[tuple[date, time], ...] = ()
56
+ warnings: tuple[str, ...] = ()
57
+ text: str = field(default="")
58
+
59
+ def render(self) -> str:
60
+ return self.text
61
+
62
+ def to_dict(self) -> dict:
63
+ """JSON-ready. Safe to log from a job."""
64
+ return {
65
+ "view": self.view,
66
+ "safe_time": _clock(self.safe_time),
67
+ "earlier_time": _clock(self.earlier_time),
68
+ "next_day": self.next_day,
69
+ "days_used": self.days_used,
70
+ "period_days": self.period_days,
71
+ "timezone": self.timezone,
72
+ "dependencies": [
73
+ {
74
+ "name": row.name,
75
+ "runs": row.runs,
76
+ "usual": _clock(row.usual),
77
+ "latest": _clock(row.latest),
78
+ "days": row.days,
79
+ "missed": row.missed,
80
+ }
81
+ for row in self.dependencies
82
+ ],
83
+ "unreadable": [
84
+ {"name": item.name, "reason": item.reason}
85
+ for item in self.unreadable
86
+ ],
87
+ "finished_by_day": [
88
+ {"day": day.isoformat(), "time": moment.strftime("%H:%M")}
89
+ for day, moment in self.finished_by_day
90
+ ],
91
+ "warnings": list(self.warnings),
92
+ }
93
+
94
+ def __str__(self) -> str:
95
+ return self.text
96
+
97
+
98
+ def advise(
99
+ view: str,
100
+ ws: Workspace | None = None,
101
+ config: Config | None = None,
102
+ *,
103
+ today: date | None = None,
104
+ quiet: bool = False,
105
+ ) -> Advice:
106
+ """Suggest the time each day this view can run with every input ready.
107
+
108
+ `ws` defaults to a Workspace built from the WAREHOUSE_ID environment
109
+ variable. Pass `today` to make a test reproducible. `quiet=True` suppresses
110
+ the printed report; the result is returned either way.
111
+ """
112
+ settings = config or DEFAULT
113
+ workspace = ws or workspace_from_env()
114
+ moment = today or datetime.now(settings.tz).date()
115
+
116
+ result, _, skipped = collect(view, workspace, settings, moment)
117
+ advice = _build(view, result, skipped, settings)
118
+
119
+ if not quiet:
120
+ print(advice)
121
+ return advice
122
+
123
+
124
+ def _build(view: str, result: Recommendation, skipped: list[Skipped],
125
+ config: Config) -> Advice:
126
+ rows = tuple(
127
+ DependencyRun(
128
+ name=row.name,
129
+ runs=row.refreshes,
130
+ usual=row.usual,
131
+ latest=row.worst,
132
+ days=row.observed_days,
133
+ missed=row.missed,
134
+ )
135
+ for row in sorted(result.rows, key=lambda r: r.name)
136
+ )
137
+ earlier = (result.earlier
138
+ if result.earlier and result.earlier != result.safe else None)
139
+ finished = tuple(
140
+ (day, moment)
141
+ for day, moment in sorted(result.all_clear.items(),
142
+ key=lambda kv: kv[1].time(), reverse=True)
143
+ )
144
+ return Advice(
145
+ view=view,
146
+ safe_time=result.safe,
147
+ earlier_time=earlier,
148
+ next_day=bool(result.safe and result.safe_next_day),
149
+ earlier_next_day=bool(earlier and result.earlier_next_day),
150
+ days_used=result.n_days,
151
+ period_days=result.span_days,
152
+ timezone=config.timezone,
153
+ dependencies=rows,
154
+ unreadable=tuple(skipped),
155
+ finished_by_day=finished,
156
+ warnings=tuple(report.warnings(result, skipped)),
157
+ text=report.render(view, result, skipped, config.timezone),
158
+ )
159
+
160
+
161
+ def _clock(value: time | None) -> str | None:
162
+ return value.strftime("%H:%M") if value else None
view_scheduler/cli.py ADDED
@@ -0,0 +1,71 @@
1
+ """Command line entry point: `view-scheduler <catalog.schema.view>`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import os
8
+ import sys
9
+
10
+ from . import obs
11
+ from .advice import advise
12
+ from .client import Workspace
13
+ from .config import Config
14
+
15
+
16
+ def build_parser() -> argparse.ArgumentParser:
17
+ parser = argparse.ArgumentParser(
18
+ prog="view-scheduler",
19
+ description="Suggest the time each day a Unity Catalog view can run with "
20
+ "every upstream job already finished.",
21
+ epilog="ADVISORY: this suggests a time from history. It does not check "
22
+ "today. For a runtime check, use can_refresh() from the library.",
23
+ )
24
+ parser.add_argument("view", help="fully qualified view, e.g. catalog.schema.view")
25
+ parser.add_argument(
26
+ "--warehouse-id", default=os.environ.get("WAREHOUSE_ID"),
27
+ help="SQL warehouse to run history reads against "
28
+ "(default: $WAREHOUSE_ID)")
29
+ # The three analysis settings. The gate's fresh_hours is library-only.
30
+ parser.add_argument("--days", type=int, default=Config.days_history,
31
+ metavar="N", help="days of history to read")
32
+ parser.add_argument("--buffer", type=int, default=Config.buffer_min,
33
+ metavar="MIN", help="safety gap added to the safe time")
34
+ parser.add_argument("--timezone", default=Config.timezone,
35
+ help="timezone the advice is expressed in")
36
+ parser.add_argument("--json", action="store_true",
37
+ help="print the machine-readable JSON instead of the report")
38
+ parser.add_argument("--quiet", action="store_true",
39
+ help="suppress the per-call log; print only the report")
40
+ return parser
41
+
42
+
43
+ def main(argv: list[str] | None = None) -> int:
44
+ args = build_parser().parse_args(argv)
45
+
46
+ if not args.warehouse_id:
47
+ # Fail on the missing input rather than deep inside the first SQL call.
48
+ print("error: no warehouse. Pass --warehouse-id or set $WAREHOUSE_ID.",
49
+ file=sys.stderr)
50
+ return 2
51
+
52
+ obs.setup(level=40 if args.quiet else 20)
53
+ config = Config(days_history=args.days, buffer_min=args.buffer,
54
+ timezone=args.timezone)
55
+ workspace = Workspace(warehouse_id=args.warehouse_id)
56
+
57
+ try:
58
+ advice = advise(args.view, workspace, config, quiet=True)
59
+ except Exception as exc:
60
+ print(f"error: {type(exc).__name__}: {exc}", file=sys.stderr)
61
+ return 1
62
+
63
+ if args.json:
64
+ print(json.dumps(advice.to_dict(), indent=2))
65
+ else:
66
+ print("\n" + str(advice) + "\n")
67
+ return 0
68
+
69
+
70
+ if __name__ == "__main__":
71
+ raise SystemExit(main())
@@ -0,0 +1,222 @@
1
+ """I/O layer. Read-only: metadata lookups, DESCRIBE HISTORY, event log queries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from datetime import datetime
8
+ from typing import Any
9
+ from urllib.parse import quote
10
+
11
+ from databricks.sdk import WorkspaceClient
12
+ from databricks.sdk.service.sql import ExecuteStatementRequestOnWaitTimeout
13
+
14
+ from . import obs
15
+ from .history import qualifying
16
+
17
+
18
+ def quote_name(full_name: str) -> str:
19
+ """Backtick each part of a dotted Unity Catalog name.
20
+
21
+ A hyphen, a space, or a reserved word is a syntax error unless the name is
22
+ quoted, and an unquoted name is an injection surface. An internal backtick
23
+ is doubled so it cannot close the identifier early.
24
+ """
25
+ parts = [part.strip() for part in str(full_name).split(".")]
26
+ if not parts or any(not part for part in parts):
27
+ raise ValueError(f"not a Unity Catalog name: {full_name!r}")
28
+ return ".".join("`" + part.replace("`", "``") + "`" for part in parts)
29
+
30
+
31
+ class Workspace:
32
+ """Thin wrapper over the SDK. One method per external call we make."""
33
+
34
+ def __init__(self, client: WorkspaceClient | None = None,
35
+ warehouse_id: str | None = None):
36
+ self.w = client or WorkspaceClient()
37
+ self.warehouse_id = warehouse_id
38
+
39
+ # --- Unity Catalog -------------------------------------------------------
40
+
41
+ def get_table(self, full_name: str) -> dict:
42
+ """Raw GET for one table's metadata, including view_dependencies."""
43
+ path = quote(full_name, safe=".")
44
+ with obs.call("uc.tables.get", full_name):
45
+ return self.w.api_client.do(
46
+ "GET", f"/api/2.1/unity-catalog/tables/{path}")
47
+
48
+ # --- SQL -----------------------------------------------------------------
49
+
50
+ def sql(self, statement: str, target: str) -> list[dict[str, Any]]:
51
+ """Run a read-only statement. Return every row as a dict of strings.
52
+
53
+ Follows result chunking to the end, and refuses a truncated result, so a
54
+ caller never silently sees only the first page of a large result.
55
+ """
56
+ if self.warehouse_id is None:
57
+ raise ValueError("warehouse_id is required for SQL calls")
58
+
59
+ with obs.call("sql", target):
60
+ resp = self.w.statement_execution.execute_statement(
61
+ warehouse_id=self.warehouse_id,
62
+ statement=statement,
63
+ wait_timeout="50s",
64
+ on_wait_timeout=ExecuteStatementRequestOnWaitTimeout.CANCEL,
65
+ )
66
+ state = resp.status.state.value if resp.status and resp.status.state else "?"
67
+ if state != "SUCCEEDED":
68
+ message = (resp.status.error.message
69
+ if resp.status and resp.status.error else state)
70
+ raise RuntimeError(message)
71
+
72
+ if resp.manifest and resp.manifest.truncated:
73
+ raise RuntimeError(
74
+ f"the warehouse truncated the result for {target}; "
75
+ f"the answer would be incomplete")
76
+
77
+ columns = [c.name for c in resp.manifest.schema.columns]
78
+ rows = list((resp.result.data_array if resp.result else None) or [])
79
+ chunk = resp.result.next_chunk_index if resp.result else None
80
+ while chunk is not None:
81
+ page = self.w.statement_execution.get_statement_result_chunk_n(
82
+ resp.statement_id, chunk)
83
+ rows.extend(page.data_array or [])
84
+ chunk = page.next_chunk_index
85
+
86
+ return [dict(zip(columns, row)) for row in rows]
87
+
88
+
89
+ def workspace_from_env() -> "Workspace":
90
+ """Build a Workspace from $WAREHOUSE_ID, or say exactly what is missing."""
91
+ warehouse = os.environ.get("WAREHOUSE_ID")
92
+ if not warehouse:
93
+ raise ValueError(
94
+ "no warehouse: pass Workspace(warehouse_id='...') "
95
+ "or set the WAREHOUSE_ID environment variable")
96
+ return Workspace(warehouse_id=warehouse)
97
+
98
+
99
+ def failure_category(exc: Exception) -> str:
100
+ """Classify an SDK/SQL error as 'permission', 'not_found', 'mv_no_history', or 'other'."""
101
+ text = str(exc).lower()
102
+ if ("permission" in text or "does not have" in text or "403" in text
103
+ or "401" in text or "unauthorized" in text):
104
+ return "permission"
105
+ if "does not exist" in text or "not found" in text or "404" in text:
106
+ return "not_found"
107
+ if "expect_table_not_view" in text:
108
+ return "mv_no_history"
109
+ return "other"
110
+
111
+
112
+ def parse_json_field(value: Any) -> dict | None:
113
+ """Parse a JSON string or pass through a dict. None if absent or unparseable."""
114
+ if value is None:
115
+ return None
116
+ if isinstance(value, dict):
117
+ return value
118
+ try:
119
+ parsed = json.loads(value)
120
+ except (TypeError, ValueError):
121
+ return None
122
+ return parsed if isinstance(parsed, dict) else None
123
+
124
+
125
+ # --- history readers ---------------------------------------------------------
126
+
127
+ def _from_epoch(value):
128
+ """Seconds since the epoch → an aware UTC datetime.
129
+
130
+ Timestamps are read as epochs rather than as the API's rendered string
131
+ because a Spark TIMESTAMP is an instant, and `unix_timestamp` returns that
132
+ instant regardless of the warehouse's session timezone. The rendered string
133
+ carries a "Z" suffix whose accuracy depends on that session setting, which
134
+ is warehouse configuration this tool does not control. Verified identical to
135
+ the string path on a UTC warehouse; correct on any other.
136
+ """
137
+ from datetime import timezone
138
+ return datetime.fromtimestamp(int(value), tz=timezone.utc)
139
+
140
+
141
+ def read_history(ws: "Workspace", table: str, *,
142
+ with_metrics: bool = True) -> list:
143
+ """Every commit for a physical Delta table, newest first.
144
+
145
+ `with_metrics=False` drops the wide operationMetrics column. The gate only
146
+ classifies commits, so it never needs it; analyze() does.
147
+ """
148
+ from .models import Commit
149
+ metrics = ", operationMetrics" if with_metrics else ""
150
+ rows = ws.sql(
151
+ "SELECT version, unix_timestamp(timestamp) AS ts_epoch, operation, "
152
+ f"operationParameters{metrics} "
153
+ f"FROM (DESCRIBE HISTORY {quote_name(table)}) "
154
+ "ORDER BY version DESC",
155
+ target=table)
156
+ return [
157
+ Commit(
158
+ version=int(r.get("version") or 0),
159
+ timestamp=_from_epoch(r["ts_epoch"]),
160
+ operation=str(r.get("operation") or ""),
161
+ params=parse_json_field(r.get("operationParameters")) or {},
162
+ metrics=(parse_json_field(r.get("operationMetrics"))
163
+ if with_metrics else None),
164
+ )
165
+ for r in rows
166
+ ]
167
+
168
+
169
+ def read_mv_refreshes(ws: "Workspace", mv: str) -> list:
170
+ """Completed MV refreshes from the pipeline event log.
171
+
172
+ DESCRIBE HISTORY is rejected on a materialized view, so this is the only
173
+ way to see when it last refreshed.
174
+ """
175
+ from .models import Commit
176
+ statement = (
177
+ "SELECT unix_timestamp(timestamp) AS ts_epoch "
178
+ f"FROM event_log(TABLE({quote_name(mv)})) "
179
+ "WHERE event_type = 'update_progress' "
180
+ "AND get_json_object(details, '$.update_progress.state') = 'COMPLETED'"
181
+ )
182
+ rows = ws.sql(statement, target=mv)
183
+ return [
184
+ Commit(version=i, timestamp=_from_epoch(r["ts_epoch"]),
185
+ operation="MV REFRESH", params={}, metrics=None)
186
+ for i, r in enumerate(rows)
187
+ ]
188
+
189
+
190
+ def read_commits(ws: "Workspace", table: str, *,
191
+ with_metrics: bool = True) -> list:
192
+ """History for any physical object: a table, streaming table, or MV.
193
+
194
+ DESCRIBE HISTORY covers tables and streaming tables. A materialized view
195
+ rejects it, so fall back to the pipeline event log.
196
+ """
197
+ try:
198
+ return read_history(ws, table, with_metrics=with_metrics)
199
+ except Exception as exc:
200
+ if "expect_table_not_view" in str(exc).lower():
201
+ with obs.call("mv.event_log", table):
202
+ return read_mv_refreshes(ws, table)
203
+ raise
204
+
205
+
206
+ def read_latest_commit(ws: "Workspace", table: str, *,
207
+ not_after: int | None = None) -> datetime | None:
208
+ """The newest qualifying write for a table or an MV, or None.
209
+
210
+ This is the gate's one question. A write dated after `not_after` (epoch
211
+ seconds) is ignored, so a clock-skewed future commit cannot make a stale
212
+ table look fresh.
213
+ """
214
+ newest: datetime | None = None
215
+ for commit in read_commits(ws, table, with_metrics=False):
216
+ if not qualifying(commit):
217
+ continue
218
+ if not_after is not None and commit.timestamp.timestamp() > not_after:
219
+ continue
220
+ if newest is None or commit.timestamp > newest:
221
+ newest = commit.timestamp
222
+ return newest
@@ -0,0 +1,29 @@
1
+ """Configuration. Keep it small — ask before adding a field."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from zoneinfo import ZoneInfo
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Config:
11
+
12
+ days_history: int = 30
13
+ buffer_min: int = 15
14
+ timezone: str = "Asia/Kolkata"
15
+ #: Gate only. A dependency is fresh when it wrote within this many hours.
16
+ fresh_hours: int = 24
17
+
18
+ def __post_init__(self) -> None:
19
+ if self.fresh_hours <= 0:
20
+ raise ValueError(f"fresh_hours must be positive, got {self.fresh_hours}")
21
+ if self.days_history <= 0:
22
+ raise ValueError(f"days_history must be positive, got {self.days_history}")
23
+
24
+ @property
25
+ def tz(self) -> ZoneInfo:
26
+ return ZoneInfo(self.timezone)
27
+
28
+
29
+ DEFAULT = Config()