runspec-grafana-core 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,61 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .eggs/
12
+ .venv/
13
+ venv/
14
+ env/
15
+ .env
16
+ pip-wheel-metadata/
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ htmlcov/
21
+ .coverage
22
+ coverage.xml
23
+ *.cover
24
+
25
+ # Node
26
+ node_modules/
27
+ dist/
28
+ *.js.map
29
+ .npm
30
+
31
+ # Go
32
+ *.exe
33
+ *.test
34
+ *.out
35
+ vendor/
36
+
37
+ # IDE
38
+ .idea/
39
+ .vscode/
40
+ *.iml
41
+ *.iws
42
+ *.ipr
43
+ .DS_Store
44
+ Thumbs.db
45
+
46
+ # Docs
47
+ site/
48
+
49
+ # Misc
50
+ *.log
51
+ *.tmp
52
+
53
+ # External reference repos (cloned locally, not committed)
54
+ chainlit-docs/
55
+ .chainlit/
56
+
57
+ # Claude Code local config (machine-specific)
58
+ .claude/launch.json
59
+
60
+ # Stray committed test venv (removed from tracking)
61
+ .venv-test/
@@ -0,0 +1,43 @@
1
+ # Changelog — runspec-grafana-core
2
+
3
+ ## [0.1.0]
4
+
5
+ First release. Pure-Python helpers over the Grafana HTTP API, ported from the
6
+ read tools of the [Grafana MCP server](https://github.com/grafana/mcp-grafana)
7
+ (no runspec dependency, no runnables). Results are compact by default:
8
+
9
+ - **Identity and search:** `whoami`, `health`, `search_dashboards`,
10
+ `search_folders`.
11
+ - **Dashboards:** `get_dashboard` returns a summary (folder, tags, variables,
12
+ one row per panel, rows unpacked); `include=["queries"]` adds each panel's
13
+ query text and datasource, `panel_id` narrows to one panel, `version` loads a
14
+ saved version, `raw=True` returns the API response. `list_dashboard_versions`
15
+ handles both the list and the Grafana 12 `{"versions": [...]}` shapes.
16
+ - **Datasources:** `list_datasources`, `get_datasource` (secrets named, never
17
+ shown), `check_datasources` (health checks, failures reported not raised),
18
+ and `resolve_datasource`, which takes a UID or a name, or picks the default
19
+ (or only) datasource of a type when none is given.
20
+ - **Prometheus through Grafana:** `query_prometheus` (instant or range; range
21
+ results summarised per series unless `include_values`),
22
+ `query_prometheus_histogram` (builds the `histogram_quantile` query),
23
+ `list_prometheus_metric_names` (with a regex filter),
24
+ `list_prometheus_label_names`, `list_prometheus_label_values`,
25
+ `list_prometheus_metric_metadata`.
26
+ - **Loki through Grafana:** `query_loki_logs` (lines newest first, stream
27
+ labels listed once, each line cut to `max_line_chars`; metric queries
28
+ summarised), `list_loki_label_names`, `list_loki_label_values`,
29
+ `query_loki_stats`, `query_loki_patterns`.
30
+ - **Alerting:** `list_alert_rules` (firing first, counts by state, Grafana-managed
31
+ or a datasource's own rules, only active alert instances counted),
32
+ `get_alert_rule`, `list_contact_points`, `list_silences`.
33
+ - **Annotations:** `get_annotations`, `get_annotation_tags`, `create_annotation`.
34
+ - `api_get`: GET any `/api/` path for what the helpers don't cover.
35
+ - Queries go through the datasource proxy, retried through the plugin's
36
+ `/resources` route on 401/403/404/500/502, so only the Grafana token is needed.
37
+ - `GrafanaConfig` / `config_from_env`: `GRAFANA_URL` (default
38
+ `http://127.0.0.1:3000`), `GRAFANA_SERVICE_ACCOUNT_TOKEN` (or `TOKEN`, `PAT`,
39
+ `API_KEY`) or basic `GRAFANA_USERNAME`/`GRAFANA_PASSWORD`, `GRAFANA_ORG_ID`,
40
+ `GRAFANA_CA_BUNDLE`, `GRAFANA_TLS_VERIFY`, `GRAFANA_PROXY`, with free-form
41
+ `GRAFANA_<ENV>_*` prefixes.
42
+ - Times accept Unix seconds or milliseconds, RFC 3339, `now-1h`, or a duration
43
+ back from now.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.5
2
+ Name: runspec-grafana-core
3
+ Version: 0.1.0
4
+ Summary: Pure-Python Grafana HTTP API helpers — the importable core behind runspec-grafana (no runspec dependency, no runnables)
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx
7
+ Provides-Extra: dev
8
+ Requires-Dist: mypy; extra == 'dev'
9
+ Requires-Dist: pytest>=8.0; extra == 'dev'
10
+ Requires-Dist: ruff==0.15.20; extra == 'dev'
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runspec-grafana-core"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.10"
9
+ description = "Pure-Python Grafana HTTP API helpers — the importable core behind runspec-grafana (no runspec dependency, no runnables)"
10
+ dependencies = [
11
+ "httpx",
12
+ ]
13
+
14
+ [project.optional-dependencies]
15
+ dev = [
16
+ "ruff==0.15.20",
17
+ "mypy",
18
+ "pytest>=8.0",
19
+ ]
20
+
21
+ [tool.pytest.ini_options]
22
+ testpaths = ["tests"]
23
+
24
+ [tool.mypy]
25
+ python_version = "3.10"
26
+
27
+ [tool.ruff]
28
+ line-length = 200
29
+ target-version = "py310"
30
+
31
+ [tool.ruff.lint]
32
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,113 @@
1
+ """runspec-grafana-core — pure-Python Grafana HTTP API helpers.
2
+
3
+ This package depends only on ``httpx`` — it has **no dependency on runspec** and
4
+ ships **no runspec.toml and no entry points**, so installing it exposes the
5
+ helpers for import without surfacing any runnables (it is invisible to
6
+ ``runspec local`` / ``runspec serve`` discovery). ``runspec-grafana`` depends on
7
+ it and wraps each helper as a verb of the ``grafana`` runnable.
8
+
9
+ The helpers port the read tools of the Grafana MCP server
10
+ (https://github.com/grafana/mcp-grafana) onto the Grafana HTTP API: dashboards,
11
+ datasources, PromQL and LogQL through the datasource proxy, alert rules,
12
+ silences, contact points, and annotations. Every operation takes an explicit
13
+ :class:`GrafanaConfig` first argument, so a private wrapper package can inject
14
+ credentials, bake in an instance or org, and build narrower runnables on top.
15
+ Each function returns plain, compact data; failures raise :class:`GrafanaError`.
16
+ """
17
+
18
+ from runspec_grafana_core.alerting import RULE_INCLUDE_OPTIONS, RULE_STATES, SILENCE_STATES, get_alert_rule, list_alert_rules, list_contact_points, list_silences
19
+ from runspec_grafana_core.annotations import ANNOTATION_TYPES, create_annotation, get_annotation_tags, get_annotations
20
+ from runspec_grafana_core.api import api_get, parse_params
21
+ from runspec_grafana_core.client import (
22
+ DEFAULT_URL,
23
+ GrafanaConfig,
24
+ GrafanaError,
25
+ config_from_env,
26
+ datasource_request,
27
+ grafana_request,
28
+ parse_duration,
29
+ parse_time,
30
+ )
31
+ from runspec_grafana_core.dashboards import (
32
+ DASHBOARD_INCLUDE_OPTIONS,
33
+ dashboard_summary,
34
+ get_dashboard,
35
+ health,
36
+ list_dashboard_versions,
37
+ panel_queries,
38
+ search_dashboards,
39
+ search_folders,
40
+ whoami,
41
+ )
42
+ from runspec_grafana_core.datasources import check_datasources, fetch_datasource, get_datasource, list_datasources, resolve_datasource
43
+ from runspec_grafana_core.loki import compact_streams, list_loki_label_names, list_loki_label_values, query_loki_logs, query_loki_patterns, query_loki_stats
44
+ from runspec_grafana_core.prometheus import (
45
+ compact_series,
46
+ histogram_query,
47
+ list_prometheus_label_names,
48
+ list_prometheus_label_values,
49
+ list_prometheus_metric_metadata,
50
+ list_prometheus_metric_names,
51
+ query_prometheus,
52
+ query_prometheus_histogram,
53
+ )
54
+
55
+ __all__ = [
56
+ # client / config
57
+ "GrafanaConfig",
58
+ "GrafanaError",
59
+ "config_from_env",
60
+ "grafana_request",
61
+ "datasource_request",
62
+ "parse_time",
63
+ "parse_duration",
64
+ "DEFAULT_URL",
65
+ # identity / search / dashboards
66
+ "whoami",
67
+ "health",
68
+ "search_dashboards",
69
+ "search_folders",
70
+ "get_dashboard",
71
+ "dashboard_summary",
72
+ "panel_queries",
73
+ "list_dashboard_versions",
74
+ "DASHBOARD_INCLUDE_OPTIONS",
75
+ # datasources
76
+ "list_datasources",
77
+ "get_datasource",
78
+ "fetch_datasource",
79
+ "resolve_datasource",
80
+ "check_datasources",
81
+ # prometheus via grafana
82
+ "query_prometheus",
83
+ "query_prometheus_histogram",
84
+ "histogram_query",
85
+ "compact_series",
86
+ "list_prometheus_metric_names",
87
+ "list_prometheus_label_names",
88
+ "list_prometheus_label_values",
89
+ "list_prometheus_metric_metadata",
90
+ # loki via grafana
91
+ "query_loki_logs",
92
+ "compact_streams",
93
+ "list_loki_label_names",
94
+ "list_loki_label_values",
95
+ "query_loki_stats",
96
+ "query_loki_patterns",
97
+ # alerting
98
+ "list_alert_rules",
99
+ "get_alert_rule",
100
+ "list_contact_points",
101
+ "list_silences",
102
+ "RULE_STATES",
103
+ "RULE_INCLUDE_OPTIONS",
104
+ "SILENCE_STATES",
105
+ # annotations
106
+ "get_annotations",
107
+ "get_annotation_tags",
108
+ "create_annotation",
109
+ "ANNOTATION_TYPES",
110
+ # escape hatch
111
+ "api_get",
112
+ "parse_params",
113
+ ]
@@ -0,0 +1,192 @@
1
+ """alerting.py — alert rules with their live state, contact points, and silences.
2
+
3
+ Rule state comes from Grafana's Prometheus-compatible rules endpoint
4
+ (``/api/prometheus/grafana/api/v1/rules``, or a datasource's own rules with
5
+ ``datasource``); a rule's full definition from the provisioning API.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+ from urllib.parse import quote
12
+
13
+ from runspec_grafana_core.client import GrafanaConfig, GrafanaError, drop_empty, grafana_request, preview, truncate
14
+ from runspec_grafana_core.datasources import fetch_datasource
15
+
16
+ RULE_STATES = ("firing", "pending", "inactive", "error", "nodata")
17
+ RULE_INCLUDE_OPTIONS = ("alerts", "query", "annotations")
18
+ SILENCE_STATES = ("active", "pending", "expired", "any")
19
+
20
+
21
+ def _is_active(alert: dict[str, Any]) -> bool:
22
+ # Grafana lists every instance, Normal ones included ("Normal",
23
+ # "Normal (MissingSeries)"); Prometheus rulers list only pending/firing.
24
+ return not str(alert.get("state") or "").lower().startswith(("normal", "inactive"))
25
+
26
+
27
+ def _rule_row(rule: dict[str, Any], group: dict[str, Any], include: list[str]) -> dict[str, Any]:
28
+ alerts = [a for a in rule.get("alerts") or [] if isinstance(a, dict) and _is_active(a)]
29
+ error, _ = preview(rule.get("lastError"), 300)
30
+ row: dict[str, Any] = drop_empty(
31
+ {
32
+ "uid": rule.get("uid"),
33
+ "name": rule.get("name"),
34
+ "state": rule.get("state"),
35
+ "health": rule.get("health"),
36
+ "folder": group.get("file"),
37
+ "group": group.get("name"),
38
+ "labels": rule.get("labels"),
39
+ "last_error": error,
40
+ "last_evaluation": rule.get("lastEvaluation"),
41
+ }
42
+ )
43
+ row["active_alerts"] = len(alerts)
44
+ if "query" in include and rule.get("query"):
45
+ row["query"] = rule["query"]
46
+ if "annotations" in include and rule.get("annotations"):
47
+ row["annotations"] = rule["annotations"]
48
+ if "alerts" in include and alerts:
49
+ row["alerts"] = [drop_empty({"labels": a.get("labels"), "state": a.get("state"), "active_at": a.get("activeAt"), "value": a.get("value")}) for a in alerts[:10]]
50
+ return row
51
+
52
+
53
+ def list_alert_rules(
54
+ config: GrafanaConfig,
55
+ state: str | None = None,
56
+ search: str | None = None,
57
+ folder: str | None = None,
58
+ datasource: str | None = None,
59
+ include: list[str] | None = None,
60
+ limit: int | None = 100,
61
+ ) -> dict[str, Any]:
62
+ """Alert rules and their current state, firing first.
63
+
64
+ ``state`` keeps one of firing/pending/inactive/error/nodata (matched
65
+ against state or health); ``search`` is a case-insensitive substring of
66
+ the rule name; ``folder`` matches the folder title. ``datasource`` reads a
67
+ Prometheus/Loki datasource's own (ruler) rules instead of Grafana-managed
68
+ ones. ``include`` adds ``alerts`` (up to 10 active ones per rule), ``query``,
69
+ ``annotations``.
70
+ """
71
+ include = list(include or [])
72
+ unknown = sorted(set(include) - set(RULE_INCLUDE_OPTIONS))
73
+ if unknown:
74
+ raise GrafanaError(f"Unknown include part(s): {', '.join(unknown)} — choose from {', '.join(RULE_INCLUDE_OPTIONS)}")
75
+ if state and state not in RULE_STATES:
76
+ raise GrafanaError(f"state must be one of {', '.join(RULE_STATES)}")
77
+ source = "grafana"
78
+ if datasource:
79
+ source = str(fetch_datasource(config, datasource).get("uid"))
80
+ body = grafana_request("GET", f"/api/prometheus/{quote(source, safe='')}/api/v1/rules", config=config) or {}
81
+ if isinstance(body, dict) and body.get("status") == "error":
82
+ raise GrafanaError(f"Grafana rules: {body.get('error', 'unknown error')}")
83
+ groups = ((body.get("data") or {}) if isinstance(body, dict) else {}).get("groups") or []
84
+ rows = []
85
+ for group in groups:
86
+ if folder and (group.get("file") or "").lower() != folder.lower():
87
+ continue
88
+ for rule in group.get("rules") or []:
89
+ if not isinstance(rule, dict) or rule.get("type", "alerting") != "alerting":
90
+ continue
91
+ if search and search.lower() not in (rule.get("name") or "").lower():
92
+ continue
93
+ if state and state not in ((rule.get("state") or "").lower(), (rule.get("health") or "").lower()):
94
+ continue
95
+ rows.append(_rule_row(rule, group, include))
96
+ order = {"firing": 0, "pending": 1}
97
+ rows.sort(key=lambda r: (order.get(str(r.get("state")).lower(), 2), r.get("folder") or "", r.get("name") or ""))
98
+ counts: dict[str, int] = {}
99
+ for row in rows:
100
+ counts[str(row.get("state"))] = counts.get(str(row.get("state")), 0) + 1
101
+ kept, cut = truncate(rows, limit)
102
+ return {"count": len(rows), "by_state": counts, "truncated": cut, "rules": kept}
103
+
104
+
105
+ def get_alert_rule(config: GrafanaConfig, uid: str, raw: bool = False) -> dict[str, Any]:
106
+ """A Grafana-managed alert rule's definition by UID: condition, queries,
107
+ labels, annotations, pending period, no-data/error handling."""
108
+ rule = grafana_request("GET", f"/api/v1/provisioning/alert-rules/{quote(uid, safe='')}", config=config) or {}
109
+ if raw:
110
+ return rule
111
+ queries = []
112
+ for item in rule.get("data") or []:
113
+ model = item.get("model") or {}
114
+ queries.append(
115
+ drop_empty(
116
+ {
117
+ "ref_id": item.get("refId"),
118
+ "datasource_uid": item.get("datasourceUid"),
119
+ "query": model.get("expr") or model.get("expression") or model.get("query"),
120
+ "type": model.get("type") if item.get("datasourceUid") == "__expr__" else None,
121
+ "range": item.get("relativeTimeRange"),
122
+ }
123
+ )
124
+ )
125
+ return drop_empty(
126
+ {
127
+ "uid": rule.get("uid"),
128
+ "title": rule.get("title"),
129
+ "folder_uid": rule.get("folderUID"),
130
+ "rule_group": rule.get("ruleGroup"),
131
+ "condition": rule.get("condition"),
132
+ "for": rule.get("for"),
133
+ "labels": rule.get("labels"),
134
+ "annotations": rule.get("annotations"),
135
+ "no_data_state": rule.get("noDataState"),
136
+ "exec_err_state": rule.get("execErrState"),
137
+ "is_paused": rule.get("isPaused") or None,
138
+ "updated": rule.get("updated"),
139
+ "queries": queries,
140
+ }
141
+ )
142
+
143
+
144
+ def list_contact_points(config: GrafanaConfig, name: str | None = None, include_settings: bool = False, limit: int | None = 100) -> dict[str, Any]:
145
+ """Notification contact points: ``[{uid, name, type, settings?}]``. Grafana
146
+ redacts secret settings (webhook URLs, keys) unless the token may read them."""
147
+ items = [c for c in grafana_request("GET", "/api/v1/provisioning/contact-points", config=config, params={"name": name}) or [] if isinstance(c, dict)]
148
+ rows = []
149
+ for c in items:
150
+ row = drop_empty({"uid": c.get("uid"), "name": c.get("name"), "type": c.get("type"), "disable_resolve_message": c.get("disableResolveMessage") or None})
151
+ if include_settings and c.get("settings"):
152
+ row["settings"] = c["settings"]
153
+ rows.append(row)
154
+ kept, cut = truncate(rows, limit)
155
+ return {"count": len(rows), "truncated": cut, "contact_points": kept}
156
+
157
+
158
+ def _matchers(matchers: list[dict[str, Any]]) -> str:
159
+ """Silence matchers as PromQL-style text: ``alertname="X", job=~"api.*"``."""
160
+ ops = {(True, False): "=", (False, False): "!=", (True, True): "=~", (False, True): "!~"}
161
+ out = []
162
+ for m in matchers:
163
+ op = ops[(m.get("isEqual") is not False, bool(m.get("isRegex")))]
164
+ out.append(f'{m.get("name")}{op}"{m.get("value")}"')
165
+ return ", ".join(out)
166
+
167
+
168
+ def list_silences(config: GrafanaConfig, state: str = "active", limit: int | None = 100) -> dict[str, Any]:
169
+ """Alertmanager silences in Grafana's built-in Alertmanager:
170
+ ``[{id, state, matchers, starts_at, ends_at, created_by, comment}]``."""
171
+ if state not in SILENCE_STATES:
172
+ raise GrafanaError(f"state must be one of {', '.join(SILENCE_STATES)}")
173
+ items = [s for s in grafana_request("GET", "/api/alertmanager/grafana/api/v2/silences", config=config) or [] if isinstance(s, dict)]
174
+ if state != "any":
175
+ items = [s for s in items if (s.get("status") or {}).get("state") == state]
176
+ items.sort(key=lambda s: s.get("endsAt") or "", reverse=True)
177
+ rows = [
178
+ drop_empty(
179
+ {
180
+ "id": s.get("id"),
181
+ "state": (s.get("status") or {}).get("state"),
182
+ "matchers": _matchers(s.get("matchers") or []),
183
+ "starts_at": s.get("startsAt"),
184
+ "ends_at": s.get("endsAt"),
185
+ "created_by": s.get("createdBy"),
186
+ "comment": preview(s.get("comment"), 300)[0],
187
+ }
188
+ )
189
+ for s in items
190
+ ]
191
+ kept, cut = truncate(rows, limit)
192
+ return {"count": len(rows), "truncated": cut, "silences": kept}
@@ -0,0 +1,131 @@
1
+ """annotations.py — read and create annotations (``/api/annotations``).
2
+
3
+ Annotations mark events on dashboards: deploys, incidents, alert state changes.
4
+ Grafana stores times as epoch milliseconds; these helpers take the same time
5
+ forms as the query verbs and return RFC 3339.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time as _time
11
+ from typing import Any
12
+
13
+ from runspec_grafana_core.client import GrafanaConfig, GrafanaError, drop_empty, grafana_request, iso, parse_time, preview, truncate, window
14
+
15
+ ANNOTATION_TYPES = ("annotation", "alert")
16
+
17
+
18
+ def _ms(ts: float | None) -> int | None:
19
+ return None if ts is None else int(ts * 1000)
20
+
21
+
22
+ def get_annotations(
23
+ config: GrafanaConfig,
24
+ start: str | None = None,
25
+ end: str | None = None,
26
+ dashboard_uid: str | None = None,
27
+ panel_id: int | None = None,
28
+ tags: list[str] | None = None,
29
+ match_any: bool = False,
30
+ type: str | None = None,
31
+ limit: int | None = 100,
32
+ ) -> dict[str, Any]:
33
+ """Annotations in ``start``..``end`` (default the last 24 hours), newest first.
34
+
35
+ ``tags`` must all match unless ``match_any``; ``type`` is ``annotation``
36
+ (user-made) or ``alert`` (alert state changes). Text is cut to 300 chars
37
+ (``text_chars`` gives the full length).
38
+ """
39
+ if type and type not in ANNOTATION_TYPES:
40
+ raise GrafanaError(f"type must be one of {', '.join(ANNOTATION_TYPES)}")
41
+ start_ts, end_ts = window(start, end, default_lookback=86400.0)
42
+ params = {
43
+ "from": _ms(start_ts),
44
+ "to": _ms(end_ts),
45
+ "dashboardUID": dashboard_uid,
46
+ "panelId": panel_id,
47
+ "tags": list(tags or []),
48
+ "matchAny": True if match_any else None,
49
+ "type": type,
50
+ "limit": limit + 1 if limit and limit > 0 else 5000,
51
+ }
52
+ items = [a for a in grafana_request("GET", "/api/annotations", config=config, params=params) or [] if isinstance(a, dict)]
53
+ kept, cut = truncate(items, limit)
54
+ rows = []
55
+ for a in kept:
56
+ text, full = preview(a.get("text"), 300)
57
+ rows.append(
58
+ drop_empty(
59
+ {
60
+ "id": a.get("id"),
61
+ "time": iso(a["time"] / 1000) if a.get("time") else None,
62
+ "time_end": iso(a["timeEnd"] / 1000) if a.get("timeEnd") and a.get("timeEnd") != a.get("time") else None,
63
+ "text": text,
64
+ "text_chars": full,
65
+ "tags": a.get("tags"),
66
+ "dashboard_uid": a.get("dashboardUID"),
67
+ "panel_id": a.get("panelId") or None,
68
+ "alert_name": a.get("alertName"),
69
+ "new_state": a.get("newState"),
70
+ "by": a.get("login") or a.get("email"),
71
+ }
72
+ )
73
+ )
74
+ return {"start": iso(start_ts), "end": iso(end_ts), "count": len(rows), "truncated": cut, "annotations": rows}
75
+
76
+
77
+ def get_annotation_tags(config: GrafanaConfig, tag: str | None = None, limit: int | None = 100) -> dict[str, Any]:
78
+ """Annotation tags in use, with how many annotations carry each; ``tag`` filters by prefix."""
79
+ body = grafana_request("GET", "/api/annotations/tags", config=config, params={"tag": tag, "limit": limit if limit and limit > 0 else None}) or {}
80
+ tags = ((body.get("result") or {}) if isinstance(body, dict) else {}).get("tags") or []
81
+ rows = [{"tag": t.get("tag"), "count": t.get("count")} for t in tags if isinstance(t, dict)]
82
+ rows.sort(key=lambda r: r["count"] or 0, reverse=True)
83
+ return {"count": len(rows), "tags": rows}
84
+
85
+
86
+ def create_annotation(
87
+ config: GrafanaConfig,
88
+ text: str,
89
+ tags: list[str] | None = None,
90
+ dashboard_uid: str | None = None,
91
+ panel_id: int | None = None,
92
+ at: str | None = None,
93
+ end: str | None = None,
94
+ ) -> dict[str, Any]:
95
+ """Create an annotation at ``at`` (default now), or a region up to ``end``.
96
+
97
+ Without ``dashboard_uid`` it is an organization-wide annotation, shown on
98
+ any dashboard with a tag-filtered annotation query. Returns ``{id, time, …}``.
99
+ """
100
+ if not text.strip():
101
+ raise GrafanaError("Annotation text is empty")
102
+ if panel_id is not None and not dashboard_uid:
103
+ raise GrafanaError("panel_id needs dashboard_uid")
104
+ now = _time.time()
105
+ at_ts = parse_time(at, now=now)
106
+ start_ts = at_ts if at_ts is not None else now
107
+ end_ts = parse_time(end, now=now)
108
+ if end_ts is not None and end_ts < start_ts:
109
+ raise GrafanaError(f"end ({iso(end_ts)}) is before the annotation time ({iso(start_ts)})")
110
+ body = drop_empty(
111
+ {
112
+ "text": text,
113
+ "tags": list(tags or []),
114
+ "dashboardUID": dashboard_uid,
115
+ "panelId": panel_id,
116
+ "time": _ms(start_ts),
117
+ "timeEnd": _ms(end_ts),
118
+ }
119
+ )
120
+ result = grafana_request("POST", "/api/annotations", config=config, json_body=body) or {}
121
+ return drop_empty(
122
+ {
123
+ "id": result.get("id") if isinstance(result, dict) else None,
124
+ "time": iso(start_ts),
125
+ "time_end": iso(end_ts),
126
+ "dashboard_uid": dashboard_uid,
127
+ "panel_id": panel_id,
128
+ "tags": list(tags or []),
129
+ "message": result.get("message") if isinstance(result, dict) else None,
130
+ }
131
+ )
@@ -0,0 +1,37 @@
1
+ """api.py — a read-only escape hatch: GET any Grafana HTTP API path.
2
+
3
+ For the API surface the dedicated helpers don't cover (org settings, teams,
4
+ library panels, plugin settings …). GET only — anything that changes state
5
+ goes through a helper with its own autonomy.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from runspec_grafana_core.client import GrafanaConfig, GrafanaError, grafana_request
13
+
14
+
15
+ def parse_params(pairs: list[str] | None) -> dict[str, Any]:
16
+ """``["key=value", "tag=a", "tag=b"]`` → ``{"key": "value", "tag": ["a", "b"]}``."""
17
+ out: dict[str, Any] = {}
18
+ for pair in pairs or []:
19
+ key, sep, value = pair.partition("=")
20
+ if not sep or not key.strip():
21
+ raise GrafanaError(f"Query parameter {pair!r} is not key=value")
22
+ key = key.strip()
23
+ if key in out:
24
+ out[key] = [*(out[key] if isinstance(out[key], list) else [out[key]]), value]
25
+ else:
26
+ out[key] = value
27
+ return out
28
+
29
+
30
+ def api_get(config: GrafanaConfig, path: str, params: list[str] | None = None) -> Any:
31
+ """GET ``path`` (must start with ``/api/``) and return the decoded JSON as-is."""
32
+ path = path.strip()
33
+ if not path.startswith("/api/"):
34
+ raise GrafanaError(f"Path must start with /api/ (got {path!r})")
35
+ if ".." in path.split("?")[0].split("/"):
36
+ raise GrafanaError("Path may not contain '..'")
37
+ return grafana_request("GET", path, config=config, params=parse_params(params))