devhelm 0.5.0__tar.gz → 0.6.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.
Files changed (50) hide show
  1. {devhelm-0.5.0 → devhelm-0.6.0}/.github/workflows/spec-check.yml +27 -1
  2. {devhelm-0.5.0 → devhelm-0.6.0}/PKG-INFO +1 -1
  3. {devhelm-0.5.0 → devhelm-0.6.0}/pyproject.toml +1 -1
  4. {devhelm-0.5.0 → devhelm-0.6.0}/scripts/typegen.sh +2 -1
  5. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/_generated.py +0 -1
  6. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/_http.py +61 -1
  7. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/client.py +12 -1
  8. {devhelm-0.5.0 → devhelm-0.6.0}/tests/test_http.py +56 -0
  9. {devhelm-0.5.0 → devhelm-0.6.0}/uv.lock +1 -1
  10. {devhelm-0.5.0 → devhelm-0.6.0}/.github/workflows/ci.yml +0 -0
  11. {devhelm-0.5.0 → devhelm-0.6.0}/.github/workflows/release.yml +0 -0
  12. {devhelm-0.5.0 → devhelm-0.6.0}/.gitignore +0 -0
  13. {devhelm-0.5.0 → devhelm-0.6.0}/LICENSE +0 -0
  14. {devhelm-0.5.0 → devhelm-0.6.0}/Makefile +0 -0
  15. {devhelm-0.5.0 → devhelm-0.6.0}/README.md +0 -0
  16. {devhelm-0.5.0 → devhelm-0.6.0}/docs/openapi/monitoring-api.json +0 -0
  17. {devhelm-0.5.0 → devhelm-0.6.0}/scripts/inject_strict_config.py +0 -0
  18. {devhelm-0.5.0 → devhelm-0.6.0}/scripts/regen-from.sh +0 -0
  19. {devhelm-0.5.0 → devhelm-0.6.0}/scripts/release.sh +0 -0
  20. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/__init__.py +0 -0
  21. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/_errors.py +0 -0
  22. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/_pagination.py +0 -0
  23. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/_validation.py +0 -0
  24. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/py.typed +0 -0
  25. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/resources/__init__.py +0 -0
  26. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/resources/alert_channels.py +0 -0
  27. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/resources/api_keys.py +0 -0
  28. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/resources/dependencies.py +0 -0
  29. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/resources/deploy_lock.py +0 -0
  30. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/resources/environments.py +0 -0
  31. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/resources/forensics.py +0 -0
  32. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/resources/incidents.py +0 -0
  33. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/resources/monitors.py +0 -0
  34. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/resources/notification_policies.py +0 -0
  35. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/resources/resource_groups.py +0 -0
  36. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/resources/secrets.py +0 -0
  37. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/resources/status.py +0 -0
  38. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/resources/status_pages.py +0 -0
  39. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/resources/tags.py +0 -0
  40. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/resources/webhooks.py +0 -0
  41. {devhelm-0.5.0 → devhelm-0.6.0}/src/devhelm/types.py +0 -0
  42. {devhelm-0.5.0 → devhelm-0.6.0}/tests/__init__.py +0 -0
  43. {devhelm-0.5.0 → devhelm-0.6.0}/tests/run_sdk.py +0 -0
  44. {devhelm-0.5.0 → devhelm-0.6.0}/tests/test_client.py +0 -0
  45. {devhelm-0.5.0 → devhelm-0.6.0}/tests/test_errors.py +0 -0
  46. {devhelm-0.5.0 → devhelm-0.6.0}/tests/test_negative_validation.py +0 -0
  47. {devhelm-0.5.0 → devhelm-0.6.0}/tests/test_schemas.py +0 -0
  48. {devhelm-0.5.0 → devhelm-0.6.0}/tests/test_spec_parity.py +0 -0
  49. {devhelm-0.5.0 → devhelm-0.6.0}/tests/test_typing.py +0 -0
  50. {devhelm-0.5.0 → devhelm-0.6.0}/tests/test_validation_helpers.py +0 -0
@@ -20,19 +20,45 @@ jobs:
20
20
  - uses: actions/setup-python@v5
21
21
  with:
22
22
  python-version: '3.13'
23
+ - uses: actions/setup-node@v4
24
+ with:
25
+ node-version: '20'
23
26
 
24
27
  - name: Download latest OpenAPI spec from monorepo
25
28
  run: |
26
29
  gh api repos/devhelmhq/mono/contents/docs/openapi/monitoring-api.json \
27
30
  -H "Accept: application/vnd.github.raw+json" \
28
- -o docs/openapi/monitoring-api.json
31
+ > docs/openapi/monitoring-api.json
29
32
  env:
30
33
  GH_TOKEN: ${{ secrets.MONOREPO_DISPATCH_TOKEN }}
31
34
 
35
+ # Sparse-checkout the shared OpenAPI preprocessor from mono.
36
+ # typegen.sh prefers $OPENAPI_TOOLS → local sibling → npx; without this
37
+ # step, CI falls through to `npx @devhelm/openapi-tools` which is
38
+ # intentionally unpublished (internal-only package).
39
+ - name: Checkout @devhelm/openapi-tools from mono
40
+ uses: actions/checkout@v4
41
+ with:
42
+ repository: devhelmhq/mono
43
+ token: ${{ secrets.MONOREPO_DISPATCH_TOKEN }}
44
+ path: .mono
45
+ sparse-checkout: packages/openapi-tools
46
+ sparse-checkout-cone-mode: false
47
+
48
+ - name: Build @devhelm/openapi-tools
49
+ working-directory: .mono/packages/openapi-tools
50
+ # mono uses pnpm workspaces; we only need this one package's deps here,
51
+ # so install standalone with npm (no lockfile in this subdir).
52
+ run: |
53
+ npm install --no-package-lock --no-audit --no-fund
54
+ npm run build
55
+
32
56
  - run: uv sync
33
57
  - run: uv run pytest -v
34
58
 
35
59
  - name: Regenerate types from spec
60
+ env:
61
+ OPENAPI_TOOLS: node ${{ github.workspace }}/.mono/packages/openapi-tools/dist/cli.js
36
62
  run: ./scripts/typegen.sh
37
63
 
38
64
  - name: Check for type changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devhelm
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: DevHelm SDK for Python — typed client for monitors, incidents, alerting, and more
5
5
  Project-URL: Homepage, https://github.com/devhelmhq/sdk-python
6
6
  Project-URL: Repository, https://github.com/devhelmhq/sdk-python.git
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devhelm"
3
- version = "0.5.0"
3
+ version = "0.6.0"
4
4
  description = "DevHelm SDK for Python — typed client for monitors, incidents, alerting, and more"
5
5
  authors = [{ name = "DevHelm", email = "hello@devhelm.io" }]
6
6
  license = "MIT"
@@ -55,7 +55,8 @@ uv run datamodel-codegen \
55
55
  --enum-field-as-literal one \
56
56
  --use-one-literal-as-default \
57
57
  --input-file-type openapi \
58
- --formatters ruff-format
58
+ --formatters ruff-format \
59
+ --disable-timestamp
59
60
 
60
61
  # Why --enum-field-as-literal=one + --use-one-literal-as-default?
61
62
  #
@@ -1,6 +1,5 @@
1
1
  # generated by datamodel-codegen:
2
2
  # filename: .openapi-preprocessed.json
3
- # timestamp: 2026-04-29T17:00:31+00:00
4
3
 
5
4
  from __future__ import annotations
6
5
  from typing import Annotated, Any, Literal
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import os
4
- from dataclasses import dataclass
4
+ from dataclasses import dataclass, field
5
+ from importlib.metadata import PackageNotFoundError
6
+ from importlib.metadata import version as _pkg_version
5
7
  from typing import Any
6
8
  from urllib.parse import quote
7
9
 
@@ -17,6 +19,26 @@ from devhelm._errors import (
17
19
  DEFAULT_BASE_URL = "https://api.devhelm.io"
18
20
  DEFAULT_PAGE_SIZE = 200
19
21
 
22
+ # Default surface identifier sent on every authenticated request. Wrappers
23
+ # (e.g. the MCP server) override this at construction time so the API can
24
+ # attribute usage to the right devtool. See ``DevhelmConfig.surface``.
25
+ DEFAULT_SURFACE = "sdk-py"
26
+
27
+
28
+ def _sdk_version() -> str:
29
+ """Resolve the installed package version once at import time.
30
+
31
+ Uses ``importlib.metadata`` instead of hardcoding so a single source of
32
+ truth (``pyproject.toml``) flows through to the wire telemetry header.
33
+ Falls back to ``"unknown"`` for editable / source-tree installs that
34
+ don't yet have a dist-info directory; the API treats that as "no version
35
+ reported" rather than rejecting the request.
36
+ """
37
+ try:
38
+ return _pkg_version("devhelm")
39
+ except PackageNotFoundError:
40
+ return "unknown"
41
+
20
42
 
21
43
  @dataclass(frozen=True)
22
44
  class DevhelmConfig:
@@ -27,6 +49,19 @@ class DevhelmConfig:
27
49
  org_id: str | None = None
28
50
  workspace_id: str | None = None
29
51
  timeout: float = 30.0
52
+ # Devtool surface identifier reported to the API for adoption and
53
+ # version-distribution telemetry. Defaults to ``"sdk-py"``; wrappers
54
+ # such as the MCP server pass ``"mcp"`` instead so their traffic is
55
+ # attributed correctly. See https://devhelm.io/telemetry for the full
56
+ # contract and opt-out semantics.
57
+ surface: str = DEFAULT_SURFACE
58
+ # Surface version. Defaults to the installed ``devhelm`` package
59
+ # version; wrappers should pass their own package version.
60
+ surface_version: str | None = None
61
+ # Surface-specific metadata forwarded as ``X-DevHelm-*`` headers (e.g.
62
+ # the MCP server attaches ``mcp_client``). Keys are normalised to
63
+ # lower-kebab-case and mapped onto ``X-DevHelm-<key>`` on the wire.
64
+ surface_metadata: dict[str, str] = field(default_factory=dict)
30
65
 
31
66
 
32
67
  def _resolve(value: str | None, env_key: str, label: str) -> str:
@@ -42,6 +77,30 @@ def _resolve_optional(value: str | None, env_key: str, default: str) -> str:
42
77
  return value or os.environ.get(env_key) or default
43
78
 
44
79
 
80
+ def _telemetry_headers(config: DevhelmConfig) -> dict[str, str]:
81
+ """Build the ``X-DevHelm-Surface*`` headers for one client instance.
82
+
83
+ Returns an empty dict when ``DEVHELM_TELEMETRY=0`` so the API receives
84
+ no surface signal at all. The opt-out is intentionally a single env var
85
+ rather than a constructor flag — users opt out once for the whole
86
+ process, not per call site. See https://devhelm.io/telemetry.
87
+ """
88
+ if os.environ.get("DEVHELM_TELEMETRY", "").strip() == "0":
89
+ return {}
90
+ headers: dict[str, str] = {
91
+ "X-DevHelm-Surface": config.surface,
92
+ "X-DevHelm-Surface-Version": config.surface_version or _sdk_version(),
93
+ # Always identify the underlying SDK so the API can distinguish
94
+ # "raw SDK call" from "wrapper-on-top-of-SDK call" (the latter
95
+ # overrides ``Surface`` to e.g. ``mcp`` but the SDK fingerprint
96
+ # stays available for debugging client-version skew).
97
+ "X-DevHelm-Sdk-Name": "sdk-py",
98
+ }
99
+ for key, value in config.surface_metadata.items():
100
+ headers[f"X-DevHelm-{key}"] = value
101
+ return headers
102
+
103
+
45
104
  def build_client(config: DevhelmConfig) -> httpx.Client:
46
105
  """Create a configured httpx.Client with auth and tenant headers."""
47
106
  base_url = config.base_url.rstrip("/")
@@ -56,6 +115,7 @@ def build_client(config: DevhelmConfig) -> httpx.Client:
56
115
  "Content-Type": "application/json",
57
116
  "x-phelm-org-id": org_id,
58
117
  "x-phelm-workspace-id": workspace_id,
118
+ **_telemetry_headers(config),
59
119
  },
60
120
  timeout=config.timeout,
61
121
  )
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from devhelm._http import DevhelmConfig, build_client
5
+ from devhelm._http import DEFAULT_SURFACE, DevhelmConfig, build_client
6
6
  from devhelm.resources.alert_channels import AlertChannels
7
7
  from devhelm.resources.api_keys import ApiKeys
8
8
  from devhelm.resources.dependencies import Dependencies
@@ -62,13 +62,24 @@ class Devhelm:
62
62
  org_id: str | None = None,
63
63
  workspace_id: str | None = None,
64
64
  timeout: float = 30.0,
65
+ surface: str | None = None,
66
+ surface_version: str | None = None,
67
+ surface_metadata: dict[str, str] | None = None,
65
68
  ) -> None:
69
+ # ``surface`` / ``surface_version`` / ``surface_metadata`` are passthroughs
70
+ # for wrappers (e.g. the MCP server) that want their traffic attributed
71
+ # to a different devtool surface than the default ``sdk-py``. End users
72
+ # of the SDK should leave these unset. See
73
+ # https://devhelm.io/telemetry for the wire contract and opt-out.
66
74
  config = DevhelmConfig(
67
75
  token=token,
68
76
  base_url=base_url,
69
77
  org_id=org_id,
70
78
  workspace_id=workspace_id,
71
79
  timeout=timeout,
80
+ surface=surface if surface is not None else DEFAULT_SURFACE,
81
+ surface_version=surface_version,
82
+ surface_metadata=surface_metadata if surface_metadata is not None else {},
72
83
  )
73
84
  client = build_client(config)
74
85
 
@@ -64,6 +64,62 @@ class TestBuildClient:
64
64
  client.close()
65
65
 
66
66
 
67
+ # ---------- Surface telemetry headers ----------
68
+
69
+
70
+ class TestSurfaceTelemetry:
71
+ """The SDK reports its identity to the API on every authenticated request
72
+ so the GTM rollup can attribute usage. See https://devhelm.io/telemetry."""
73
+
74
+ def test_default_headers_announce_sdk_py(
75
+ self, monkeypatch: pytest.MonkeyPatch
76
+ ) -> None:
77
+ monkeypatch.delenv("DEVHELM_TELEMETRY", raising=False)
78
+ client = build_client(DevhelmConfig(token="t"))
79
+ assert client.headers["x-devhelm-surface"] == "sdk-py"
80
+ # version comes from importlib.metadata; its exact value is the
81
+ # SDK release, but it must always be a non-empty string.
82
+ assert client.headers["x-devhelm-surface-version"]
83
+ assert client.headers["x-devhelm-sdk-name"] == "sdk-py"
84
+ client.close()
85
+
86
+ def test_wrapper_can_override_surface(
87
+ self, monkeypatch: pytest.MonkeyPatch
88
+ ) -> None:
89
+ monkeypatch.delenv("DEVHELM_TELEMETRY", raising=False)
90
+ client = build_client(
91
+ DevhelmConfig(
92
+ token="t",
93
+ surface="mcp",
94
+ surface_version="0.5.0",
95
+ surface_metadata={"Mcp-Client": "cursor"},
96
+ )
97
+ )
98
+ assert client.headers["x-devhelm-surface"] == "mcp"
99
+ assert client.headers["x-devhelm-surface-version"] == "0.5.0"
100
+ # SDK identity is preserved alongside the wrapper surface.
101
+ assert client.headers["x-devhelm-sdk-name"] == "sdk-py"
102
+ assert client.headers["x-devhelm-mcp-client"] == "cursor"
103
+ client.close()
104
+
105
+ def test_env_opt_out_drops_all_surface_headers(
106
+ self, monkeypatch: pytest.MonkeyPatch
107
+ ) -> None:
108
+ monkeypatch.setenv("DEVHELM_TELEMETRY", "0")
109
+ client = build_client(
110
+ DevhelmConfig(token="t", surface="mcp", surface_metadata={"X": "y"})
111
+ )
112
+ # Surface, version, sdk-name, and any extras must all be absent.
113
+ assert "x-devhelm-surface" not in client.headers
114
+ assert "x-devhelm-surface-version" not in client.headers
115
+ assert "x-devhelm-sdk-name" not in client.headers
116
+ assert "x-devhelm-x" not in client.headers
117
+ # Auth + tenant headers must still be there — opt-out is for
118
+ # telemetry only, not for legitimate routing headers.
119
+ assert client.headers["x-phelm-org-id"] == "1"
120
+ client.close()
121
+
122
+
67
123
  # ---------- Pydantic validation helpers ----------
68
124
 
69
125
 
@@ -315,7 +315,7 @@ wheels = [
315
315
 
316
316
  [[package]]
317
317
  name = "devhelm"
318
- version = "0.5.0"
318
+ version = "0.6.0"
319
319
  source = { editable = "." }
320
320
  dependencies = [
321
321
  { name = "httpx" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes