Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ asyncio client, and typed errors per category.
resolve the import closure. No Node, no JavaScript parsing.
`devhelm.bundle_digest(files)` exposes the digest.
- `include` adds files nothing imports (fixtures, data) to the default set
and names them to the server so the closure keeps them.
and sends them as `CreateBundleRequest.include` (JSON) or the `include`
query parameter (zip), so the server keeps them in the closure.
- `environment=` (slug or id) on create, update, restore, `test_adhoc`,
`monitors.list` and `secrets.list`.
- Monitors: `test` (validation run), `probe` (uptime result), `test_adhoc`,
Expand Down Expand Up @@ -58,9 +59,12 @@ asyncio client, and typed errors per category.

### Changed

- `monitors.test(id)` now runs a code check and returns its `RunDto`; on an
uptime monitor it raises `ValidationError` `USE_PROBE`. The probe is
`monitors.probe(id)`.
- `monitors.test(id)` runs a code check and returns its `RunDto`. On an
uptime monitor it still returns the probe result with a one-time
deprecation warning until 2027-01-31, then raises `ValidationError`
`USE_PROBE`. The result type is `MonitorTestOutcome`
(`RunDto | MonitorTestResultDto`); narrow it with `devhelm.is_run` before
reading run fields (a typing change for mypy users of `test()`). The probe is `monitors.probe(id)`.
- Free-form JSON fields (`capturePolicySnapshot`, `details`, …) are typed
`JsonValue` instead of `dict[str, dict[str, Any]]`, which also fixes
decoding scalar values in them.
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,10 @@ result = client.monitors.probe(monitor.id)
print(result.passed, result.status_code)
```

`monitors.test()` on an uptime monitor raises `ValidationError` with code
`USE_PROBE`; `monitors.probe()` on a code monitor raises
`monitors.test()` returns `MonitorTestOutcome` (narrow it with
`devhelm.is_run`). On an uptime monitor it still returns the probe result,
with a one-time deprecation warning, until 2027-01-31; after that it raises
`ValidationError` with code `USE_PROBE`. `monitors.probe()` on a code monitor raises
`DevhelmMonitorTestQueuedError` with the queued run on `err.run`.

## Configuration
Expand Down Expand Up @@ -285,6 +287,7 @@ Removed on 2027-01-31; each warns once per process:
| `monitors.versions(id)` | `monitors.versions.list(id)` |
| `monitors.results(id)` | `monitors.history.list(id)` |
| `monitors.test_run(id)` | `monitors.test(id)` |
| `monitors.test(id)` on an uptime monitor (returns the probe result) | `monitors.probe(id)`; from 2027-01-31 `test()` raises `USE_PROBE` there |

## Development

Expand Down
60 changes: 57 additions & 3 deletions docs/contracts/bundle-vectors.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"version" : 1,
"digest" : "sha256: + lowercase hex of SHA-256 over the files sorted by unsigned UTF-8 path bytes; each file is int32 BE path byte length, path bytes, int64 BE content length, content. Text extensions (.ts .tsx .js .jsx .mjs .cjs .json .yml .yaml .md .txt .html .css) have CRLF replaced by LF; other bytes are hashed as sent. Paths are NFC, forward-slash, relative.",
"closure" : "From the entrypoint (or the only source file when none is given; the whole set when there are several): relative imports, tsconfig.json paths/baseUrl imports (with the config chain), JSON imports and the sibling auth.setup.ts. 'digest' covers the closure, 'uploadDigest' every file sent.",
"format" : "files: path to UTF-8 text; binaryFiles: path to base64 bytes; symlinks: path to link target. expect is {entrypoint, digest, uploadDigest, files (sorted by UTF-8 bytes)} or {refusal: {code}}.",
"format" : "include: paths kept in the closure even when no import reaches them (their own imports follow). files: path to UTF-8 text; binaryFiles: path to base64 bytes; symlinks: path to link target. expect is {entrypoint, digest, uploadDigest, files (sorted by UTF-8 bytes)} or {refusal: {code, field?}}.",
"vectors" : [ {
"name" : "crlf-text",
"description" : "CRLF in a text file hashes as LF",
Expand Down Expand Up @@ -115,7 +115,8 @@
},
"expect" : {
"refusal" : {
"code" : "MONITOR_PACKAGE_PATH_INVALID"
"code" : "MONITOR_PACKAGE_PATH_INVALID",
"field" : "checks/link.ts"
}
}
}, {
Expand All @@ -129,7 +130,8 @@
},
"expect" : {
"refusal" : {
"code" : "IMPORT_NOT_IN_BUNDLE"
"code" : "IMPORT_NOT_IN_BUNDLE",
"field" : "checks/a.spec.ts"
}
}
}, {
Expand All @@ -151,5 +153,57 @@
"uploadDigest" : "sha256:df206a284251626ff4db4e12c56db1662fa278b24eac26503fea252973895ae8",
"files" : [ "checks/checkout.spec.ts", "checks/helpers/cart.ts", "checks/helpers/price.ts" ]
}
}, {
"name" : "include-kept",
"description" : "An include path joins the closure although no import reaches it (a fixture read at runtime)",
"request" : {
"entrypoint" : "checks/a.spec.ts",
"include" : [ "checks/fixtures/order.json" ],
"files" : {
"checks/a.spec.ts" : "import { test, expect } from '@playwright/test';\ntest('order', async () => {});\n",
"checks/fixtures/order.json" : "{\"id\": 1}\n"
}
},
"expect" : {
"entrypoint" : "checks/a.spec.ts",
"digest" : "sha256:9fb1db0845c14f5610fa063c35c76ed8ef42e91b4431faa63f82f1efc949face",
"uploadDigest" : "sha256:9fb1db0845c14f5610fa063c35c76ed8ef42e91b4431faa63f82f1efc949face",
"files" : [ "checks/a.spec.ts", "checks/fixtures/order.json" ]
}
}, {
"name" : "include-set-with-include",
"description" : "Include-set request: the directory, the entrypoint and an include; unrelated files stay out",
"request" : {
"entrypoint" : "checks/checkout.spec.ts",
"include" : [ "checks/fixtures/order.json" ],
"files" : {
"checks/checkout.spec.ts" : "import { test, expect } from '@playwright/test';\nimport { cart } from './helpers/cart';\ntest('checkout', async () => { cart(); });\n",
"checks/fixtures/order.json" : "{\"id\": 1}\n",
"checks/helpers/cart.ts" : "export const cart = () => 1;\n",
"checks/other.spec.ts" : "import { test, expect } from '@playwright/test';\ntest('other', async () => {});\n"
}
},
"expect" : {
"entrypoint" : "checks/checkout.spec.ts",
"digest" : "sha256:ffb63814c6a125e5206a490874eab4de2cc7b7a0d97e113f13178201280172ee",
"uploadDigest" : "sha256:406a9dba733b405679804fe319e35025c99e6a88b0274f00006ea3547879fa00",
"files" : [ "checks/checkout.spec.ts", "checks/fixtures/order.json", "checks/helpers/cart.ts" ]
}
}, {
"name" : "include-missing-refused",
"description" : "An include path that is not in the files is refused",
"request" : {
"entrypoint" : "checks/a.spec.ts",
"include" : [ "checks/fixtures/missing.json" ],
"files" : {
"checks/a.spec.ts" : "import { test, expect } from '@playwright/test';\ntest('a', async () => {});\n"
}
},
"expect" : {
"refusal" : {
"code" : "MONITOR_PACKAGE_PATH_INVALID",
"field" : "include[0]"
}
}
} ]
}
25 changes: 25 additions & 0 deletions docs/openapi/monitoring-api.json
Original file line number Diff line number Diff line change
Expand Up @@ -4120,6 +4120,18 @@
"type": "string"
}
},
{
"name": "include",
"in": "query",
"description": "Files to keep even when no import reaches them, e.g. fixtures",
"required": false,
"schema": {
"type": "array",
"items": {
"type": "string"
}
}
},
{
"name": "Idempotency-Key",
"in": "header",
Expand Down Expand Up @@ -61475,6 +61487,19 @@
"type": "string",
"description": "Runtime used to refuse imports. Omit for the newest runtime that can run",
"nullable": true
},
"include": {
"maxItems": 256,
"minItems": 0,
"type": "array",
"description": "Files to keep even when no import reaches them, e.g. fixtures",
"nullable": true,
"items": {
"maxLength": 512,
"minLength": 0,
"type": "string",
"description": "Files to keep even when no import reaches them, e.g. fixtures"
}
}
}
},
Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ classifiers = [
dependencies = [
"httpx>=0.27",
"pydantic[email]>=2.0",
# TypeAliasType (RequestValue) before Python 3.12.
"typing_extensions>=4.6",
]

[project.urls]
Expand Down
9 changes: 8 additions & 1 deletion src/devhelm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
from pydantic import JsonValue

from devhelm._async.client import AsyncDevhelm
from devhelm._authoring import CodeMonitorType, UptimeMonitorType
from devhelm._authoring import (
CodeMonitorType,
MonitorTestOutcome,
UptimeMonitorType,
is_run,
)
from devhelm._bundle import (
CodeInput,
CollectedBundle,
Expand Down Expand Up @@ -337,6 +342,8 @@
"CodeMonitorType",
"UptimeMonitorType",
"TriggeredRuns",
"MonitorTestOutcome",
"is_run",
# Resource classes
"Monitors",
"Runs",
Expand Down
10 changes: 7 additions & 3 deletions src/devhelm/_async/resources/bundles.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,8 @@ async def upload_collected(
"entrypoint": bundle.entrypoint,
"runtimeId": runtime_id,
}
if bundle.include and "include" in CreateBundleRequest.model_fields:
# Files nothing imports that the closure must keep (W0-13).
if bundle.include:
# Files nothing imports that the closure must keep.
fields["include"] = list(bundle.include)
body = validate_request(CreateBundleRequest, fields, "bundles.create")
raw = await api_post(client, _PATH, body)
Expand All @@ -47,7 +47,11 @@ async def upload_collected(
_PATH,
bundle.as_zip(),
"application/zip",
params={"entrypoint": bundle.entrypoint, "runtimeId": runtime_id},
params={
"entrypoint": bundle.entrypoint,
"runtimeId": runtime_id,
"include": list(bundle.include) or None,
},
)
uploaded = parse_single(BundleDto, raw, f"POST {_PATH}")
if uploaded.upload_digest != bundle.digest:
Expand Down
26 changes: 22 additions & 4 deletions src/devhelm/_async/resources/monitors.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,12 @@
from devhelm._async.wait import DEFAULT_WAIT_TIMEOUT_MS, wait_for_run
from devhelm._authoring import (
CodeMonitorType,
MonitorTestOutcome,
UptimeMonitorType,
check_config,
deprecated,
is_code_type,
removal_date_passed,
)
from devhelm._bundle import CodeInput
from devhelm._errors import (
Expand Down Expand Up @@ -876,29 +878,45 @@ async def test(
root: Root = None,
wait: bool = False,
timeout_ms: int = DEFAULT_WAIT_TIMEOUT_MS,
) -> RunDto:
) -> MonitorTestOutcome:
"""Queue a validation run of a code monitor in its own environment.

With ``code`` it tests that code (as an unpublished revision) instead
of the live one. Billed, saved, never signals. ``wait=True`` returns
the finished run.

On an uptime monitor this raises ``ValidationError`` with code
``USE_PROBE``: call :meth:`probe` for those.
On an uptime monitor the API answers with a probe result. Until
2027-01-31 that result is returned with a one-time deprecation
warning (use :meth:`probe`); from then on this raises
``ValidationError`` ``USE_PROBE``. Narrow the result with
:func:`devhelm.is_run`.
"""
spec = None if code is None else await code_spec(self._client, code, root=root)
body = CodeTestTargetRequest(
region=region, code=spec, session=_session(session)
)
path = f"{_monitor_path(id)}/test"
status, data = await api_post_with_status(self._client, path, body)
if status != 202:
if status == 200:
# An uptime monitor: the API ran the probe (wait does not apply).
probe = parse_single(MonitorTestResultDto, data, f"POST {path}")
if not removal_date_passed():
deprecated(
"monitors.test(id) on an uptime monitor", "monitors.probe(id)"
)
return probe
raise client_error(
"validation",
"USE_PROBE",
"This is an uptime monitor: monitors.test() runs code checks. "
"Use monitors.probe(id) for a probe result.",
next_command="monitors.probe(id)",
details={"passed": probe.passed},
)
if status != 202:
raise DevhelmValidationError(
f"Response validation failed (POST {path}): expected 202 with a "
f"queued run or 200 with a probe result, got {status}."
)
run = parse_single(RunDto, data, f"POST {path}")
return await _maybe_wait(self._client, run, wait, timeout_ms)
Expand Down
22 changes: 21 additions & 1 deletion src/devhelm/_authoring.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@

import warnings
from collections.abc import Mapping
from typing import Literal
from datetime import datetime, timezone
from typing import Literal, TypeAlias, TypeGuard

from pydantic import BaseModel
from pydantic import ValidationError as PydanticValidationError
Expand All @@ -22,6 +23,8 @@
HttpMonitorConfig,
IcmpMonitorConfig,
McpServerMonitorConfig,
MonitorTestResultDto,
RunDto,
ScriptMonitorConfig,
TcpMonitorConfig,
)
Expand Down Expand Up @@ -112,6 +115,23 @@ def check_config(monitor_type: str, config: RequestValue) -> RequestValue:
_warned: set[str] = set()

REMOVAL_DATE = "2027-01-31"
_REMOVAL_AT = datetime.fromisoformat(REMOVAL_DATE).replace(tzinfo=timezone.utc)


def removal_date_passed() -> bool:
"""True from 2027-01-31 (UTC), when the deprecated aliases stop working."""
return datetime.now(timezone.utc) >= _REMOVAL_AT


# What ``monitors.test()`` returns: a run for code monitors; the probe result
# for uptime monitors (deprecated, until 2027-01-31).
MonitorTestOutcome: TypeAlias = RunDto | MonitorTestResultDto


def is_run(result: MonitorTestOutcome) -> TypeGuard[RunDto]:
"""Whether a ``monitors.test()`` result is a run (code monitor) rather
than a probe result."""
return isinstance(result, RunDto)


def deprecated(name: str, replacement: str, since: str = "1.13") -> None:
Expand Down
20 changes: 9 additions & 11 deletions src/devhelm/_bundle/collect.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,7 @@
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import TypedDict, final

from typing_extensions import NotRequired, TypeGuard
from typing import NotRequired, TypedDict, TypeGuard, final

from devhelm._bundle.digest import bundle_digest, normalize_path, path_error
from devhelm._bundle.ignore import IgnoreRules
Expand Down Expand Up @@ -128,14 +126,14 @@ def from_memory(code: FilesCode) -> CollectedBundle:
field="code.entrypoint",
)
include = tuple(normalize_path(p) for p in code.get("include", []))
unknown = [p for p in include if p not in files]
if unknown:
raise client_error(
"validation",
"MONITOR_PACKAGE_PATH_INVALID",
f"include names {unknown[0]}, which is not one of the files",
field="code.include",
)
for index, path in enumerate(include):
if path not in files:
raise client_error(
"validation",
"MONITOR_PACKAGE_PATH_INVALID",
f"include names {path}, which is not one of the files",
field=f"include[{index}]",
)
check_limits(files)
return CollectedBundle(
entrypoint=entrypoint,
Expand Down
Loading
Loading