rowmonitor-airflow 0.1.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,3 @@
1
+ """Row Monitor listener for Airflow 2.6+ and 3.x. Beta. Metadata only."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,60 @@
1
+ """Non-blocking HTTP. Failures are swallowed. The token is never logged."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import threading
8
+ import urllib.error
9
+ import urllib.request
10
+
11
+ _warned = False
12
+
13
+
14
+ def load_config():
15
+ url = os.environ.get("ROWMONITOR_URL", "").strip().rstrip("/")
16
+ token = os.environ.get("ROWMONITOR_TOKEN", "").strip()
17
+ try:
18
+ timeout = float(os.environ.get("ROWMONITOR_TIMEOUT_SECONDS", "2"))
19
+ except ValueError:
20
+ timeout = 2.0
21
+ timeout = min(max(timeout, 0.2), 5.0)
22
+ return url, token, timeout
23
+
24
+
25
+ def post_json(path, payload, opener=None):
26
+ global _warned
27
+ url, token, timeout = load_config()
28
+ if not url or not token:
29
+ if not _warned:
30
+ _warned = True
31
+ return
32
+ body = json.dumps(payload).encode("utf-8")
33
+ if len(body) > 250_000:
34
+ return
35
+ request = urllib.request.Request(
36
+ url + path,
37
+ data=body,
38
+ headers={
39
+ "Authorization": "Bearer " + token,
40
+ "Content-Type": "application/json",
41
+ "User-Agent": "rowmonitor-airflow/0.1.0",
42
+ },
43
+ method="POST",
44
+ )
45
+ open_url = opener or urllib.request.urlopen
46
+ try:
47
+ with open_url(request, timeout=timeout) as response:
48
+ response.read(2048)
49
+ except Exception:
50
+ return
51
+
52
+
53
+ def emit_async(path, payload, sender=post_json):
54
+ def _safe():
55
+ try:
56
+ sender(path, payload)
57
+ except Exception:
58
+ return
59
+
60
+ threading.Thread(target=_safe, name="rowmonitor-emit", daemon=True).start()
@@ -0,0 +1,36 @@
1
+ """Read unpaused DAG schedules from the Airflow metadata database."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def query_unpaused():
7
+ from airflow.models.dag import DagModel
8
+ from airflow.utils.session import create_session
9
+
10
+ rows = []
11
+ with create_session() as session:
12
+ query = session.query(DagModel)
13
+ if hasattr(DagModel, "is_active"):
14
+ query = query.filter(DagModel.is_active.is_(True))
15
+ if hasattr(DagModel, "is_paused"):
16
+ query = query.filter(DagModel.is_paused.is_(False))
17
+ for row in query.all():
18
+ rows.append(
19
+ SimpleDag(
20
+ dag_id=row.dag_id,
21
+ is_paused=bool(getattr(row, "is_paused", False)),
22
+ timetable_description=getattr(row, "timetable_description", None),
23
+ schedule_interval=getattr(row, "schedule_interval", None),
24
+ next_dagrun=getattr(row, "next_dagrun", None),
25
+ )
26
+ )
27
+ return rows
28
+
29
+
30
+ class SimpleDag:
31
+ def __init__(self, dag_id, is_paused, timetable_description, schedule_interval, next_dagrun):
32
+ self.dag_id = dag_id
33
+ self.is_paused = is_paused
34
+ self.timetable_description = timetable_description
35
+ self.schedule_interval = schedule_interval
36
+ self.next_dagrun = next_dagrun
@@ -0,0 +1,84 @@
1
+ """Scheduler heartbeat.
2
+
3
+ A daemon thread is started from the scheduler job, not a DAG.
4
+
5
+ A heartbeat DAG would wait for a worker slot and would go silent if that one
6
+ DAG were paused, while the rest of the deployment kept running. The thread
7
+ sends even when task slots are full, and it dies with the scheduler process.
8
+ That is the outage Row Monitor reports as one missing heartbeat. Duplicate
9
+ schedulers send duplicate heartbeats. The ingest API treats that as a state
10
+ update, not a second deployment.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import threading
17
+
18
+ from rowmonitor_airflow.client import emit_async
19
+ from rowmonitor_airflow.metadata import heartbeat_body
20
+
21
+ _lock = threading.Lock()
22
+ _started = False
23
+ _stop = threading.Event()
24
+
25
+
26
+ def heartbeat_interval():
27
+ try:
28
+ seconds = float(os.environ.get("ROWMONITOR_HEARTBEAT_SECONDS", "60"))
29
+ except ValueError:
30
+ seconds = 60.0
31
+ return max(30.0, seconds)
32
+
33
+
34
+ def heartbeat_enabled():
35
+ mode = os.environ.get("ROWMONITOR_HEARTBEAT", "scheduler").strip().lower()
36
+ return mode not in {"0", "off", "false", "no"}
37
+
38
+
39
+ def collect_unpaused():
40
+ from rowmonitor_airflow.collect import query_unpaused
41
+
42
+ return query_unpaused()
43
+
44
+
45
+ def heartbeat_loop(stop_event, collect, send, interval):
46
+ while not stop_event.is_set():
47
+ try:
48
+ rows = collect()
49
+ body = heartbeat_body(rows)
50
+ send("/api/airflow/v1/heartbeat", body)
51
+ except Exception:
52
+ pass
53
+ if stop_event.wait(interval):
54
+ return
55
+
56
+
57
+ def start_heartbeat(collect=None, send=None):
58
+ global _started
59
+ if not heartbeat_enabled():
60
+ return False
61
+ with _lock:
62
+ if _started:
63
+ return False
64
+ _started = True
65
+ threading.Thread(
66
+ target=heartbeat_loop,
67
+ kwargs={
68
+ "stop_event": _stop,
69
+ "collect": collect or collect_unpaused,
70
+ "send": send or emit_async,
71
+ "interval": heartbeat_interval(),
72
+ },
73
+ name="rowmonitor-heartbeat",
74
+ daemon=True,
75
+ ).start()
76
+ return True
77
+
78
+
79
+ def reset_heartbeat_for_tests():
80
+ global _started
81
+ _stop.set()
82
+ with _lock:
83
+ _started = False
84
+ _stop.clear()
@@ -0,0 +1,72 @@
1
+ """Listener hooks for Airflow 2.6+ and 3.x.
2
+
3
+ Airflow 2.10 task-failure hooks take (previous_state, task_instance, error, session).
4
+ Earlier 2.x hooks take (previous_state, task_instance, session) and have no error.
5
+ Airflow 3 dropped the session argument and kept error. **kwargs covers that.
6
+ The exception text is not sent. It can contain query text or row values.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+
13
+ from rowmonitor_airflow.client import emit_async
14
+ from rowmonitor_airflow.heartbeat import start_heartbeat
15
+ from rowmonitor_airflow.metadata import dag_run_payload, task_failure_payload
16
+
17
+
18
+ def _mark(func):
19
+ try:
20
+ from airflow.listeners import hookimpl
21
+ except ImportError:
22
+ return func
23
+ return hookimpl(func)
24
+
25
+
26
+ def _guard(fn):
27
+ try:
28
+ fn()
29
+ except Exception:
30
+ return
31
+
32
+
33
+ def _argv_is_scheduler():
34
+ command = " ".join(sys.argv).lower()
35
+ return "scheduler" in command
36
+
37
+
38
+ def _on_dag_run_running(dag_run, msg="", **kwargs):
39
+ _guard(lambda: emit_async("/api/airflow/v1/events", dag_run_payload(dag_run, "running")))
40
+
41
+
42
+ def _on_dag_run_success(dag_run, msg="", **kwargs):
43
+ _guard(lambda: emit_async("/api/airflow/v1/events", dag_run_payload(dag_run, "success")))
44
+
45
+
46
+ def _on_dag_run_failed(dag_run, msg="", **kwargs):
47
+ _guard(lambda: emit_async("/api/airflow/v1/events", dag_run_payload(dag_run, "failed")))
48
+
49
+
50
+ def _on_task_instance_failed(previous_state, task_instance, **kwargs):
51
+ def _send():
52
+ payload = task_failure_payload(task_instance)
53
+ if payload is not None:
54
+ emit_async("/api/airflow/v1/events", payload)
55
+
56
+ _guard(_send)
57
+
58
+
59
+ def _on_starting(component=None, **kwargs):
60
+ try:
61
+ name = type(component).__name__.lower() if component is not None else ""
62
+ if "scheduler" in name or _argv_is_scheduler():
63
+ start_heartbeat()
64
+ except Exception:
65
+ return
66
+
67
+
68
+ on_dag_run_running = _mark(_on_dag_run_running)
69
+ on_dag_run_success = _mark(_on_dag_run_success)
70
+ on_dag_run_failed = _mark(_on_dag_run_failed)
71
+ on_task_instance_failed = _mark(_on_task_instance_failed)
72
+ on_starting = _mark(_on_starting)
@@ -0,0 +1,131 @@
1
+ """Build ingest payloads. The allowlist is the whole contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+
7
+ DAG_RUN_KEYS = ("type", "dag_id", "run_id", "run_type", "logical_date", "state", "start_date", "end_date", "duration_ms")
8
+ TASK_KEYS = ("type", "dag_id", "run_id", "task_id", "state", "start_date", "end_date", "try_number")
9
+ HEARTBEAT_KEYS = ("dag_id", "schedule", "next_run", "paused")
10
+
11
+
12
+ def iso(value):
13
+ if value is None:
14
+ return None
15
+ if isinstance(value, datetime):
16
+ if value.tzinfo is None:
17
+ value = value.replace(tzinfo=timezone.utc)
18
+ return value.isoformat()
19
+ isoformat = getattr(value, "isoformat", None)
20
+ if callable(isoformat):
21
+ try:
22
+ text = isoformat()
23
+ except Exception:
24
+ return None
25
+ if isinstance(text, str) and text and "T" in text:
26
+ if text.endswith("Z") or "+" in text[10:] or "-" in text[10:]:
27
+ return text
28
+ return text + "Z" if "+" not in text else text
29
+ return None
30
+
31
+
32
+ def duration_ms(start, end):
33
+ if start is None or end is None:
34
+ return None
35
+ try:
36
+ seconds = (end - start).total_seconds()
37
+ except Exception:
38
+ return None
39
+ if seconds < 0:
40
+ return None
41
+ return int(seconds * 1000)
42
+
43
+
44
+ def _state(value, fallback):
45
+ raw = getattr(value, "value", value)
46
+ text = str(raw or fallback).lower()
47
+ if "." in text:
48
+ text = text.split(".")[-1]
49
+ return text
50
+
51
+
52
+ def dag_run_payload(dag_run, state):
53
+ logical = getattr(dag_run, "logical_date", None)
54
+ if logical is None:
55
+ logical = getattr(dag_run, "execution_date", None)
56
+ start = getattr(dag_run, "start_date", None)
57
+ end = getattr(dag_run, "end_date", None)
58
+ run_type = getattr(dag_run, "run_type", None)
59
+ if run_type is not None:
60
+ run_type = str(getattr(run_type, "value", run_type))[:64]
61
+ payload = {
62
+ "type": "dag_run",
63
+ "dag_id": str(getattr(dag_run, "dag_id", ""))[:250],
64
+ "run_id": str(getattr(dag_run, "run_id", ""))[:250],
65
+ "run_type": run_type,
66
+ "logical_date": iso(logical),
67
+ "state": _state(state, "running"),
68
+ "start_date": iso(start),
69
+ "end_date": iso(end),
70
+ "duration_ms": duration_ms(start, end),
71
+ }
72
+ return {key: payload[key] for key in DAG_RUN_KEYS}
73
+
74
+
75
+ def task_failure_payload(task_instance):
76
+ dag_id = getattr(task_instance, "dag_id", None)
77
+ task_id = getattr(task_instance, "task_id", None)
78
+ run_id = getattr(task_instance, "run_id", None)
79
+ if not run_id:
80
+ dag_run = getattr(task_instance, "dag_run", None)
81
+ run_id = getattr(dag_run, "run_id", None) if dag_run is not None else None
82
+ if not dag_id or not task_id or not run_id:
83
+ return None
84
+ try_number = getattr(task_instance, "try_number", None)
85
+ if try_number is not None:
86
+ try:
87
+ try_number = int(try_number)
88
+ except (TypeError, ValueError):
89
+ try_number = None
90
+ payload = {
91
+ "type": "task_run",
92
+ "dag_id": str(dag_id)[:250],
93
+ "run_id": str(run_id)[:250],
94
+ "task_id": str(task_id)[:250],
95
+ "state": "failed",
96
+ "start_date": iso(getattr(task_instance, "start_date", None)),
97
+ "end_date": iso(getattr(task_instance, "end_date", None)),
98
+ "try_number": try_number,
99
+ }
100
+ return {key: payload[key] for key in TASK_KEYS}
101
+
102
+
103
+ def snapshot_dag(row):
104
+ dag_id = getattr(row, "dag_id", None)
105
+ if not dag_id or getattr(row, "is_paused", False):
106
+ return None
107
+ schedule = None
108
+ for attr in ("timetable_description", "schedule_interval", "timetable_summary"):
109
+ value = getattr(row, attr, None)
110
+ if value is None:
111
+ continue
112
+ text = str(value).strip()
113
+ if text and text.lower() not in {"none", "null"}:
114
+ schedule = text[:500]
115
+ break
116
+ return {
117
+ "dag_id": str(dag_id)[:250],
118
+ "schedule": schedule,
119
+ "next_run": iso(getattr(row, "next_dagrun", None)),
120
+ "paused": False,
121
+ }
122
+
123
+
124
+ def heartbeat_body(rows, limit=500):
125
+ dags = []
126
+ for row in rows:
127
+ snap = snapshot_dag(row)
128
+ if snap is not None:
129
+ dags.append({key: snap[key] for key in HEARTBEAT_KEYS})
130
+ truncated = len(dags) > limit
131
+ return {"dags": dags[:limit], "truncated": truncated}
@@ -0,0 +1,21 @@
1
+ """Airflow plugin entry point. Importing this module requires Airflow."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def _load():
7
+ from airflow.plugins_manager import AirflowPlugin
8
+
9
+ from rowmonitor_airflow import listener
10
+
11
+ class RowMonitorPlugin(AirflowPlugin):
12
+ name = "rowmonitor_airflow"
13
+ listeners = [listener]
14
+
15
+ return RowMonitorPlugin
16
+
17
+
18
+ try:
19
+ RowMonitorPlugin = _load()
20
+ except ImportError:
21
+ RowMonitorPlugin = None # type: ignore[misc, assignment]
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: rowmonitor-airflow
3
+ Version: 0.1.0
4
+ Summary: Beta Airflow listener that sends DAG run metadata to Row Monitor.
5
+ Author: Row Monitor
6
+ License-Expression: LicenseRef-RowMonitor-Proprietary
7
+ Project-URL: Homepage, https://rowmonitor.com/help/airflow
8
+ Project-URL: Repository, https://github.com/kharipara/row-monitor
9
+ Project-URL: Documentation, https://rowmonitor.com/help/airflow
10
+ Keywords: airflow,rowmonitor,monitoring
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Framework :: Apache Airflow
20
+ Classifier: Topic :: System :: Monitoring
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # rowmonitor-airflow
29
+
30
+ Beta listener for Airflow 2.6+ and 3.x. It sends DAG run metadata and a heartbeat of unpaused DAG schedules to [Row Monitor](https://rowmonitor.com/help/airflow).
31
+
32
+ Version `0.1.0`. Not published yet. Not a general-availability release.
33
+
34
+ Late runs are flagged within about 15 minutes. Row Monitor's check runs every 15 minutes. A run is late when it has not started within 5 minutes of the expected time.
35
+
36
+ ## What is sent
37
+
38
+ Metadata only:
39
+
40
+ - DAG id, run id, run type, logical date, start, end, state, duration
41
+ - On task failure: DAG id, run id, task id, try number, start, end. Not the exception text
42
+ - Heartbeat: unpaused DAG id, schedule, next run time when the scheduler has stored one
43
+
44
+ Not sent: connections, variables, XComs, logs, DAG source, or row data.
45
+
46
+ The HTTP call runs on a daemon thread with a short timeout (default 2 seconds). A failure does not fail the Airflow task.
47
+
48
+ ## Why the heartbeat is a thread
49
+
50
+ The thread starts when the scheduler job starts (`on_starting`, and only in the scheduler process). It posts about once a minute.
51
+
52
+ A small heartbeat DAG would wait for a free worker slot, and it would go silent if that DAG were paused, while every other DAG kept running. The thread still sends when slots are full, and it stops when the scheduler process stops. Row Monitor then opens one "heartbeat missing" issue after 15 minutes of silence, and it does not open a late or never-arrived issue per DAG for that outage.
53
+
54
+ ## Install
55
+
56
+ The GitHub repository may be private. A plain git URL then fails. Do not commit a token.
57
+
58
+ From a checkout of this repo (works for a private checkout you already have):
59
+
60
+ ```bash
61
+ pip install plugins/rowmonitor-airflow
62
+ ```
63
+
64
+ From git, when the repo is reachable:
65
+
66
+ ```bash
67
+ pip install "git+https://github.com/kharipara/row-monitor.git#subdirectory=plugins/rowmonitor-airflow"
68
+ ```
69
+
70
+ Private git, token only in the environment:
71
+
72
+ ```bash
73
+ pip install "git+https://${GITHUB_TOKEN}@github.com/kharipara/row-monitor.git#subdirectory=plugins/rowmonitor-airflow"
74
+ ```
75
+
76
+ Wheel, built from the checkout. CI also uploads this wheel as an artifact. It is not published.
77
+
78
+ ```bash
79
+ python -m pip install build
80
+ python -m build plugins/rowmonitor-airflow
81
+ pip install plugins/rowmonitor-airflow/dist/rowmonitor_airflow-0.1.0-py3-none-any.whl
82
+ ```
83
+
84
+ Install into the scheduler environment and every worker environment, then restart those processes.
85
+
86
+ Airflow 2.6 through 2.10 and Airflow 3.x load `airflow.plugins`. Task-failure hook arguments differ (2.10 has `error` and `session`, earlier 2.x has `session`, Airflow 3 drops `session`). The listener accepts those shapes.
87
+
88
+ ## Environment
89
+
90
+ | Variable | Required | Default | Purpose |
91
+ | --- | --- | --- | --- |
92
+ | `ROWMONITOR_URL` | yes | | Row Monitor origin, no path. Use the preview URL while testing. |
93
+ | `ROWMONITOR_TOKEN` | yes | | Ingest token shown once when the deployment is created. |
94
+ | `ROWMONITOR_HEARTBEAT` | no | `scheduler` | `scheduler`, `always`, or `off`. |
95
+ | `ROWMONITOR_HEARTBEAT_SECONDS` | no | `60` | Minimum 30. |
96
+ | `ROWMONITOR_TIMEOUT_SECONDS` | no | `2` | Capped at 5. |
97
+
98
+ In the app: Airflow → create a deployment → copy the token into `ROWMONITOR_TOKEN`.
99
+
100
+ ## Local Docker test
101
+
102
+ One compose file, `examples/docker/docker-compose.yml`. It is a sample, not a production Airflow. Run steps, the 3.x image, and cleanup are in `examples/docker/README.md`.
103
+
104
+ ```bash
105
+ cd plugins/rowmonitor-airflow/examples/docker
106
+ cp .env.example .env
107
+ # Edit ROWMONITOR_TOKEN. Never commit the real token.
108
+ # .env.example sets ROWMONITOR_URL=https://rowmonitor.com
109
+
110
+ # Airflow 2.10.5. UI on http://localhost:8080 when standalone is ready.
111
+ docker compose up
112
+
113
+ # Airflow 3.4.0. Same port. airflow standalone also starts the dag-processor and api-server.
114
+ AIRFLOW_IMAGE=apache/airflow:3.4.0 docker compose up
115
+ ```
116
+
117
+ Airflow 2.10.5 was tested by a user end to end against https://rowmonitor.com with this compose file. All checks passed. Airflow 3.4.0 is covered by the plugin unit tests. It was not part of that end-to-end run. Run one image at a time, and `docker compose down -v` before switching so the database is empty.
118
+
119
+ The container copies the read-only checkout to `/tmp` and runs `pip install` there, then `airflow standalone`. The admin user is `admin`. The password is printed in the service logs. Sample DAGs are mounted from `examples/dags/`:
120
+
121
+ | DAG | What it does | What to expect after the check |
122
+ | --- | --- | --- |
123
+ | `rowmonitor_failed` | Raises every 2 minutes | FAILED |
124
+ | `rowmonitor_late` | `max_active_runs=1`, sleeps 8 minutes, catchup on for a few minutes | LATE once the blocked run starts (start is more than 5 minutes after the logical date) |
125
+ | `rowmonitor_slow` | Five 20-second runs, then a 15-minute run | SLOW after the long run succeeds. Needs the five successes first |
126
+ | `rowmonitor_never_arrived` | `catchup=False`, one run sleeps 40 minutes, schedule every minute | NEVER_ARRIVED for minutes the scheduler did not create. The heartbeat keeps listing the DAG |
127
+
128
+ Pausing a DAG removes it from the unpaused heartbeat. That is not the never-arrived test.
129
+
130
+ Heartbeat missing: from `examples/docker`, `docker compose stop airflow` for at least 15 minutes. Leave the deployment token in place, then run the check. You should see one heartbeat-missing issue, not a late issue for every DAG. Cleanup is `docker compose down -v`.
131
+
132
+ ### How long to wait
133
+
134
+ The production cron is every 15 minutes and starts only after this change is merged. Preview deployments do not run that schedule by themselves.
135
+
136
+ After the DAG has done the thing you care about, trigger the check if `CRON_SECRET` is set on the preview:
137
+
138
+ ```bash
139
+ curl -sS -X POST \
140
+ -H "Authorization: Bearer $CRON_SECRET" \
141
+ "https://YOUR-PREVIEW.vercel.app/api/cron/airflow"
142
+ ```
143
+
144
+ GET works the same way. Do not print the secret. If the preview has no `CRON_SECRET`, the route returns 401 and the check has to wait until production cron, or be run against a local app that has the secret.
145
+
146
+ Rough waits before that manual check is worth calling:
147
+
148
+ - Failed: the first `rowmonitor_failed` run, usually under 2 minutes, then the check
149
+ - Late: about 8 minutes for the sleep, then the check
150
+ - Never arrived: about 6 minutes after the blocker starts (the 5-minute grace), then the check
151
+ - Slow: about 6 short minutes plus the 15-minute sleep, then the check
152
+ - Heartbeat missing: 15 minutes with the scheduler stopped, then the check
153
+
154
+ In the app, open Airflow. You should see the deployment's DAGs, last run, state, duration, next expected time, and open issues. Those times, and the alert emails and Slack messages, use the workspace time zone. It starts as the browser's zone at signup. The owner can change it in Settings, and daylight saving is included. Airflow schedules are still evaluated the way Airflow defines them. The time zone does not change detection. Over the Free limit (5 DAGs) the extra DAGs stay listed as over the limit and are not checked.
155
+
156
+ ## Tests
157
+
158
+ ```bash
159
+ cd plugins/rowmonitor-airflow
160
+ python -m pip install pytest
161
+ python -m pip install -e .
162
+ python -m pytest -q
163
+ ```
164
+
165
+ `tests/test_airflow_install.py` runs only when `apache-airflow` is installed in that interpreter. Unit tests do not need Airflow.
166
+
167
+ ## Publishing to PyPI later
168
+
169
+ Do not publish from this change. After merge, publish with Actions, not an API token.
170
+
171
+ `.github/workflows/publish-airflow-plugin.yml` is `workflow_dispatch` only. It builds the sdist and wheel, runs `twine check`, and the `publish` job uses `pypa/gh-action-pypi-publish` with OIDC against the `pypi` environment. That workflow name appears in the Actions tab only after the file is on `main`.
172
+
173
+ Until that upload, install from git or from a wheel. Then `pip install rowmonitor-airflow`.
@@ -0,0 +1,13 @@
1
+ rowmonitor_airflow/__init__.py,sha256=IKMuL_9ZK5UrOXZHC2QamS7FeJytplQBK2OfC8PBJTg,97
2
+ rowmonitor_airflow/client.py,sha256=pArhL9R6MFIhTmLTMAjd90Qo3rkt6ctH_lpJzLAkRFU,1562
3
+ rowmonitor_airflow/collect.py,sha256=yyuEp7OD_GulrzD0KxoizLrmnaMhvTrecobIrgA74fY,1337
4
+ rowmonitor_airflow/heartbeat.py,sha256=sgFuNH0OcrL7zH0SBCI_Y7z1nOZzk-ARoE2TwBlKo6M,2209
5
+ rowmonitor_airflow/listener.py,sha256=E-xdpy4FPFOhnga_OfgYbtFpCYo5jDHXqjPgEWI5pMI,2116
6
+ rowmonitor_airflow/metadata.py,sha256=mbyiY7thwN3r53pLRMl0GRr8vJj3IoXxF5ehmsPlK_0,4363
7
+ rowmonitor_airflow/plugin.py,sha256=komkf43AZkjz8sYnkcnXDkweJC9335RyUGnnvQjxD3I,484
8
+ rowmonitor_airflow-0.1.0.dist-info/licenses/LICENSE,sha256=5XUP8_dTtIzGMut7ZjLUxuibu_VnyMUNaIwdTTdrBPs,218
9
+ rowmonitor_airflow-0.1.0.dist-info/METADATA,sha256=DklUjK_W08n8N_80zKXXu2jLBPZ-iCCP26P7kNPH4h8,8782
10
+ rowmonitor_airflow-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
11
+ rowmonitor_airflow-0.1.0.dist-info/entry_points.txt,sha256=dK73UUC8195a0cXKAYAla6-FlYfKi9gRgMdBSgEv4zc,82
12
+ rowmonitor_airflow-0.1.0.dist-info/top_level.txt,sha256=7bq_k914X--IRQEAFyuyTQIp5N_PUHZHQFtwKHrBKgA,19
13
+ rowmonitor_airflow-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [airflow.plugins]
2
+ rowmonitor_airflow = rowmonitor_airflow.plugin:RowMonitorPlugin
@@ -0,0 +1,4 @@
1
+ Copyright (c) 2026 Row Monitor (Eklavyai). All rights reserved.
2
+
3
+ This package is proprietary. It is not published. Do not upload it to PyPI
4
+ until the maintainer chooses a public license and an account to publish from.
@@ -0,0 +1 @@
1
+ rowmonitor_airflow