uptimer-python-sdk 0.3.0__py3-none-any.whl → 1.5.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.
uptimer/__init__.py CHANGED
@@ -1,3 +1,12 @@
1
- """Uptimer Python SDK."""
1
+ """
2
+ Uptimer Python SDK.
2
3
 
3
- __version__ = "0.1.0"
4
+ Targets Uptimer API v2 only. Code written against 0.4.x keeps working against
5
+ the server — API v1 is unchanged and supported — but must stay on the 0.4.x SDK.
6
+
7
+ The version tracks the uptimer release this SDK targets: 1.5.x speaks to
8
+ uptimer 1.5.0 and later. Patch numbers are independent, so an SDK fix can ship
9
+ without a server release. See product Decision 0013.
10
+ """
11
+
12
+ __version__ = "1.5.0"
uptimer/client.py CHANGED
@@ -2,25 +2,68 @@ from __future__ import annotations
2
2
 
3
3
  from typing import cast
4
4
 
5
- from uptimer.endpoints.v1 import V1Endpoint
5
+ from uptimer.compat import ensure_v2_supported
6
+ from uptimer.endpoints.v2 import V2Endpoint
6
7
  from uptimer.http import UptimerHttpLib
7
8
 
8
9
 
9
10
  class UptimerClient:
10
- v1: V1Endpoint
11
+ """
12
+ The Uptimer API client.
13
+
14
+ Resources are reached through the API version that serves them:
15
+ `client.v2.workspaces`, `client.v2.locations`, `client.v2.incidents` and
16
+ `client.v2.monitoring.websites`. This SDK does not speak API v1 — see the
17
+ migration note in the README if you are coming from 0.4.x.
18
+
19
+ `version()` and the compatibility helpers stay here rather than under a
20
+ version namespace, because `/version` is shared and unversioned.
21
+ """
22
+
23
+ v2: V2Endpoint
11
24
 
12
25
  def __init__(self, api_key: str, base_url: str):
13
26
  self._http_lib = UptimerHttpLib(api_key, base_url)
14
- self.v1 = V1Endpoint(self._http_lib)
27
+ self._checked_compat = False
28
+ self._wire()
29
+
30
+ def _wire(self) -> None:
31
+ self.v2 = V2Endpoint(self._http_lib)
15
32
 
16
33
  def version(self) -> str:
34
+ """
35
+ Return the server version.
36
+
37
+ `/version` is a shared global endpoint, not a versioned one, so this
38
+ works against any server — including one too old for the rest of this
39
+ SDK.
40
+ """
17
41
  response = self._http_lib.client.get(self._http_lib.build_url("version"))
18
42
  return cast("str", self._http_lib.parse_response(response=response))
19
43
 
44
+ def check_compatibility(self) -> str:
45
+ """
46
+ Verify the server provides API v2, and return its version.
47
+
48
+ Raises IncompatibleServerError if it does not. Called once per client
49
+ by ensure_compatible(); call it directly to fail fast at startup.
50
+ """
51
+ version = self.version()
52
+ ensure_v2_supported(version)
53
+ self._checked_compat = True
54
+ return version
55
+
56
+ def ensure_compatible(self) -> None:
57
+ """Run the compatibility check once, then never again."""
58
+ if not self._checked_compat:
59
+ self.check_compatibility()
60
+
20
61
  def set_uptimer_http_lib(self, http_lib: UptimerHttpLib) -> None:
21
62
  self._http_lib = http_lib
63
+ self._checked_compat = False
64
+ self._wire()
22
65
 
23
66
 
24
67
  class UptimerCloudClient(UptimerClient):
25
68
  def __init__(self, api_key: str):
26
- super().__init__(api_key, "https://api.myuptime.info")
69
+ super().__init__(api_key, "https://myuptime.info/api")
uptimer/compat.py ADDED
@@ -0,0 +1,58 @@
1
+ """
2
+ Server compatibility.
3
+
4
+ This SDK targets API v2 only. A server that predates v2 answers /v2 with 404,
5
+ which on its own tells a user nothing — so the version is checked first and the
6
+ 404 is translated as a fallback.
7
+
8
+ Why both: `uptimer` and `myuptime.info` version independently over the same
9
+ shared API (1.5.x and 15.x). A numeric minimum catches the self-hosted case
10
+ cleanly, but cannot express "hosted 14.0.3 has no v2" — 14 is greater than 1.
11
+ The 404 path catches what the number cannot.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ from uptimer import __version__
19
+ from uptimer.errors import IncompatibleServerError
20
+
21
+
22
+ def _minimum_from_own_version() -> tuple[int, int, int]:
23
+ """
24
+ Return the oldest server version this SDK speaks to.
25
+
26
+ The SDK's major.minor tracks the uptimer release it targets (Decision
27
+ 0013), so 1.5.x requires a 1.5.0 server. Deriving it here rather than
28
+ writing the number down twice means the two cannot drift apart.
29
+ """
30
+ parts = __version__.split(".")
31
+ return (int(parts[0]), int(parts[1]), 0)
32
+
33
+
34
+ MINIMUM_UPTIMER_VERSION = _minimum_from_own_version()
35
+
36
+ _VERSION_RE = re.compile(r"^v?(\d+)\.(\d+)\.(\d+)")
37
+
38
+
39
+ def parse_version(version: str) -> tuple[int, int, int] | None:
40
+ """
41
+ Parse a server version, or None if it is not a release number.
42
+
43
+ "dev" and anything unparseable return None and are treated as usable: a
44
+ developer running from source must not be locked out by a version string.
45
+ """
46
+ match = _VERSION_RE.match(version.strip())
47
+ if not match:
48
+ return None
49
+ return (int(match.group(1)), int(match.group(2)), int(match.group(3)))
50
+
51
+
52
+ def ensure_v2_supported(version: str) -> None:
53
+ """Raise IncompatibleServerError if this server is too old for API v2."""
54
+ parsed = parse_version(version)
55
+ if parsed is None:
56
+ return
57
+ if parsed < MINIMUM_UPTIMER_VERSION:
58
+ raise IncompatibleServerError(version)
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from uptimer.endpoints.endpoint import BaseEndpoint
6
+ from uptimer.models.v2 import from_api_incident
7
+
8
+ if TYPE_CHECKING:
9
+ from uptimer.http import UptimerHttpLib
10
+ from uptimer.models.v2 import Incident
11
+
12
+
13
+ class IncidentsEndpoint(BaseEndpoint):
14
+ def __init__(
15
+ self,
16
+ http: UptimerHttpLib,
17
+ parent_segments: str | list[str] | None = None,
18
+ ):
19
+ super().__init__(http, "incidents", parent_segments)
20
+
21
+ def all(
22
+ self,
23
+ workspace_id: str,
24
+ monitor_id: str | None = None,
25
+ ) -> list[Incident]:
26
+ """
27
+ Open incidents in a workspace, newest trouble first.
28
+
29
+ Only open ones: this answers "what is wrong now". Pass monitor_id to
30
+ narrow it to a single monitor.
31
+ """
32
+ params = {"workspace_id": workspace_id}
33
+ if monitor_id:
34
+ params["monitor_id"] = monitor_id
35
+ response = self.http.client.get(self.url, params=params)
36
+ result = self.http.parse_response(response=response)
37
+ return [from_api_incident(item) for item in result]
@@ -3,22 +3,23 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING
4
4
 
5
5
  from uptimer.endpoints.endpoint import BaseEndpoint
6
- from uptimer.models import from_api_region
6
+ from uptimer.models.v2 import from_api_location
7
7
 
8
8
  if TYPE_CHECKING:
9
9
  from uptimer.http import UptimerHttpLib
10
- from uptimer.models.region import Region
10
+ from uptimer.models.v2 import Location
11
11
 
12
12
 
13
- class RegionsEndpoint(BaseEndpoint):
13
+ class LocationsEndpoint(BaseEndpoint):
14
14
  def __init__(
15
15
  self,
16
16
  http: UptimerHttpLib,
17
17
  parent_segments: str | list[str] | None = None,
18
18
  ):
19
- super().__init__(http, "regions", parent_segments)
19
+ super().__init__(http, "locations", parent_segments)
20
20
 
21
- def all(self) -> list[Region]:
21
+ def all(self) -> list[Location]:
22
+ """Every location checks can run from."""
22
23
  response = self.http.client.get(self.url)
23
24
  result = self.http.parse_response(response=response)
24
- return [from_api_region(region) for region in result]
25
+ return [from_api_location(item) for item in result]
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from uptimer.endpoints.endpoint import BaseEndpoint
6
+ from uptimer.endpoints.incidents import IncidentsEndpoint
7
+ from uptimer.endpoints.locations import LocationsEndpoint
8
+ from uptimer.endpoints.websites import MonitoringEndpoint
9
+ from uptimer.endpoints.workspaces import WorkspacesEndpoint
10
+
11
+ if TYPE_CHECKING:
12
+ from uptimer.http import UptimerHttpLib
13
+
14
+
15
+ class V2Endpoint(BaseEndpoint):
16
+ """
17
+ API v2, the whole of it: `client.v2`.
18
+
19
+ The API is versioned by path, so the SDK keeps that version visible rather
20
+ than hiding it behind bare attributes (Decision 0012). Every resource here
21
+ builds its URL from this namespace's segment, which is why `/v2` appears in
22
+ exactly one place.
23
+
24
+ `/version` is shared and unversioned, so it stays on the client itself.
25
+ """
26
+
27
+ workspaces: WorkspacesEndpoint
28
+ locations: LocationsEndpoint
29
+ incidents: IncidentsEndpoint
30
+ monitoring: MonitoringEndpoint
31
+
32
+ def __init__(self, http: UptimerHttpLib):
33
+ super().__init__(http, "v2")
34
+ parent = [self.segment]
35
+ self.workspaces = WorkspacesEndpoint(http, parent)
36
+ self.locations = LocationsEndpoint(http, parent)
37
+ self.incidents = IncidentsEndpoint(http, parent)
38
+ self.monitoring = MonitoringEndpoint(http, parent)
@@ -0,0 +1,116 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import asdict
4
+ from typing import TYPE_CHECKING
5
+
6
+ from uptimer.endpoints.endpoint import BaseEndpoint
7
+ from uptimer.models.v2 import DeleteWebsiteMonitorResponse, from_api_website_monitor
8
+
9
+ if TYPE_CHECKING:
10
+ from uptimer.http import UptimerHttpLib
11
+ from uptimer.models.v2 import (
12
+ CreateWebsiteMonitorRequest,
13
+ UpdateWebsiteMonitorRequest,
14
+ WebsiteMonitor,
15
+ )
16
+
17
+
18
+ def _strip_kinds(value: object) -> object:
19
+ """
20
+ Drop every `kind` from a request body, at any depth.
21
+
22
+ `kind` is what the server tells a client an object is; it is not something a
23
+ client sets. Echoing it back suggests otherwise, and the server ignores it
24
+ either way.
25
+ """
26
+ if isinstance(value, dict):
27
+ return {k: _strip_kinds(v) for k, v in value.items() if k != "kind"}
28
+ if isinstance(value, list):
29
+ return [_strip_kinds(v) for v in value]
30
+ return value
31
+
32
+
33
+ def _payload(data: object) -> dict:
34
+ """
35
+ Serialize a request dataclass, dropping fields the server fills in.
36
+
37
+ An empty agreement means "leave it alone", which the server expresses by
38
+ omission, so it is stripped rather than sent as "" — those are different
39
+ requests.
40
+ """
41
+ body = asdict(data) # type: ignore[call-overload]
42
+ body.pop("id", None)
43
+ if not body.get("agreement"):
44
+ body.pop("agreement", None)
45
+ return _strip_kinds(body) # type: ignore[return-value]
46
+
47
+
48
+ class WebsitesEndpoint(BaseEndpoint):
49
+ """
50
+ Website monitoring: the built-in template that watches a URL.
51
+
52
+ Nested under /v2/monitoring because it is one template among the several
53
+ coming later, not the general monitor model.
54
+ """
55
+
56
+ def __init__(
57
+ self,
58
+ http: UptimerHttpLib,
59
+ parent_segments: str | list[str] | None = None,
60
+ ):
61
+ super().__init__(http, "websites", parent_segments)
62
+
63
+ def all(self, workspace_id: str) -> list[WebsiteMonitor]:
64
+ """Every website monitor in a workspace."""
65
+ params = {"workspace_id": workspace_id}
66
+ response = self.http.client.get(self.url, params=params)
67
+ result = self.http.parse_response(response=response)
68
+ return [from_api_website_monitor(item) for item in result]
69
+
70
+ def get(self, monitor_id: str) -> WebsiteMonitor:
71
+ """One website monitor by id."""
72
+ response = self.http.client.get(f"{self.url}/{monitor_id}")
73
+ result = self.http.parse_response(response=response)
74
+ return from_api_website_monitor(result)
75
+
76
+ def create(self, monitor: CreateWebsiteMonitorRequest) -> WebsiteMonitor:
77
+ """Create a website monitor, with its signal and rule."""
78
+ response = self.http.client.post(self.url, json=_payload(monitor))
79
+ result = self.http.parse_response(response=response)
80
+ return from_api_website_monitor(result)
81
+
82
+ def update(
83
+ self,
84
+ monitor_id: str,
85
+ monitor: UpdateWebsiteMonitorRequest,
86
+ ) -> WebsiteMonitor:
87
+ """Replace a website monitor's configuration."""
88
+ response = self.http.client.post(
89
+ f"{self.url}/{monitor_id}",
90
+ json=_payload(monitor),
91
+ )
92
+ result = self.http.parse_response(response=response)
93
+ return from_api_website_monitor(result)
94
+
95
+ def delete(self, monitor_id: str) -> DeleteWebsiteMonitorResponse:
96
+ """Delete a website monitor and everything under it."""
97
+ response = self.http.client.delete(f"{self.url}/{monitor_id}")
98
+ result = self.http.parse_response(response=response)
99
+ return DeleteWebsiteMonitorResponse(
100
+ message=result["message"],
101
+ monitor_id=result["monitor_id"],
102
+ )
103
+
104
+
105
+ class MonitoringEndpoint(BaseEndpoint):
106
+ """The monitoring templates. Today there is one: websites."""
107
+
108
+ websites: WebsitesEndpoint
109
+
110
+ def __init__(
111
+ self,
112
+ http: UptimerHttpLib,
113
+ parent_segments: str | list[str] | None = None,
114
+ ):
115
+ super().__init__(http, "monitoring", parent_segments)
116
+ self.websites = WebsitesEndpoint(http, [*self._parent_segments, "monitoring"])
@@ -3,11 +3,11 @@ from __future__ import annotations
3
3
  from typing import TYPE_CHECKING
4
4
 
5
5
  from uptimer.endpoints.endpoint import BaseEndpoint
6
- from uptimer.models import from_api_workspace
6
+ from uptimer.models.v2 import from_api_workspace
7
7
 
8
8
  if TYPE_CHECKING:
9
9
  from uptimer.http import UptimerHttpLib
10
- from uptimer.models.workspace import Workspace
10
+ from uptimer.models.v2 import Workspace
11
11
 
12
12
 
13
13
  class WorkspacesEndpoint(BaseEndpoint):
@@ -19,6 +19,7 @@ class WorkspacesEndpoint(BaseEndpoint):
19
19
  super().__init__(http, "workspaces", parent_segments)
20
20
 
21
21
  def all(self) -> list[Workspace]:
22
+ """Every workspace this API key can reach."""
22
23
  response = self.http.client.get(self.url)
23
24
  result = self.http.parse_response(response=response)
24
- return [from_api_workspace(workspace) for workspace in result]
25
+ return [from_api_workspace(item) for item in result]
uptimer/errors.py CHANGED
@@ -23,3 +23,21 @@ class DefaultUptimerApiError(UptimerError):
23
23
  self.message = error.get("message", "")
24
24
  self.details = error.get("details", "")
25
25
  super().__init__(f"API error: {self.code} {self.message}")
26
+
27
+
28
+ class IncompatibleServerError(UptimerError):
29
+ """
30
+ The server does not provide API v2, which this SDK requires.
31
+
32
+ Carries the server's own version so the message names the situation rather
33
+ than surfacing a bare 404.
34
+ """
35
+
36
+ def __init__(self, server_version: str):
37
+ self.server_version = server_version
38
+ super().__init__(
39
+ f"This server reports version {server_version}, which does not "
40
+ "provide API v2. uptimer-python-sdk 1.5.x requires API v2 "
41
+ "(uptimer 1.5.0+ or myuptime.info 15.1.0+). For API v1, use "
42
+ "uptimer-python-sdk 0.4.x.",
43
+ )
uptimer/http.py CHANGED
@@ -5,7 +5,11 @@ from typing import Any
5
5
  import httpx
6
6
 
7
7
  from uptimer.endpoints.endpoint import BaseEndpoint
8
- from uptimer.errors import DefaultUptimerApiError, UptimerInvalidHttpCodeError
8
+ from uptimer.errors import (
9
+ DefaultUptimerApiError,
10
+ IncompatibleServerError,
11
+ UptimerInvalidHttpCodeError,
12
+ )
9
13
 
10
14
 
11
15
  class WorkspacesEndpoint(BaseEndpoint):
@@ -38,6 +42,13 @@ class UptimerHttpLib:
38
42
  def parse_response(
39
43
  response: httpx.Response,
40
44
  ) -> Any: # noqa: ANN401
45
+ if response.status_code == 404 and "/v2/" in str(response.request.url):
46
+ # A server without API v2 has no /v2 routes at all. Saying "404"
47
+ # here would leave the user guessing; the version gate in
48
+ # client.check_compatibility() catches this earlier when the
49
+ # version number can express it, and this catches the rest.
50
+ unknown = "unknown (no /v2 routes)"
51
+ raise IncompatibleServerError(unknown)
41
52
  if response.status_code != 200:
42
53
  raise UptimerInvalidHttpCodeError(
43
54
  response.request.url,
@@ -1,9 +1,15 @@
1
- from .deserialize import (
2
- from_api,
3
- from_api_region,
4
- from_api_rule,
5
- from_api_workspace,
6
- )
1
+ """
2
+ Model exceptions, and the versioned model namespaces beneath this one.
3
+
4
+ **No API type is exported here.** v2's types live in `uptimer.models.v2`:
5
+
6
+ from uptimer.models.v2 import CreateWebsiteMonitorRequest, Location
7
+
8
+ Only the deserialization exceptions sit at this level, because they are
9
+ version-independent — the same `TypeMismatchError` is raised whichever API
10
+ version produced the payload.
11
+ """
12
+
7
13
  from .errors import (
8
14
  DeserializationError,
9
15
  InvalidDataTypeError,
@@ -12,36 +18,12 @@ from .errors import (
12
18
  TypeMismatchError,
13
19
  UnknownKindError,
14
20
  )
15
- from .region import Region
16
- from .rule import (
17
- BaseRule,
18
- CreateRuleRequest,
19
- DeleteRuleResponse,
20
- Rule,
21
- RuleRequest,
22
- RuleResponse,
23
- RuleResponseBody,
24
- )
25
- from .workspace import Workspace
26
21
 
27
22
  __all__ = [
28
- "BaseRule",
29
- "CreateRuleRequest",
30
- "DeleteRuleResponse",
31
23
  "DeserializationError",
32
24
  "InvalidDataTypeError",
33
25
  "MissingKindError",
34
26
  "ModelError",
35
- "Region",
36
- "Rule",
37
- "RuleRequest",
38
- "RuleResponse",
39
- "RuleResponseBody",
40
27
  "TypeMismatchError",
41
28
  "UnknownKindError",
42
- "Workspace",
43
- "from_api",
44
- "from_api_region",
45
- "from_api_rule",
46
- "from_api_workspace",
47
29
  ]
@@ -0,0 +1,70 @@
1
+ """
2
+ Types for API v2.
3
+
4
+ The API is versioned by path, so its types are versioned too: import them from
5
+ `uptimer.models.v2`, the way resources are reached through `client.v2`. Nothing
6
+ here is re-exported from `uptimer.models` — a v2 name is only ever a v2 import.
7
+
8
+ Version-independent deserialization exceptions live in `uptimer.models.errors`,
9
+ because they say nothing about which API version raised them.
10
+ """
11
+
12
+ from .deserialize import (
13
+ from_api,
14
+ from_api_incident,
15
+ from_api_location,
16
+ from_api_website_monitor,
17
+ from_api_workspace,
18
+ )
19
+ from .incident import (
20
+ STATUS_NO_DATA,
21
+ STATUS_OK,
22
+ STATUS_PENDING,
23
+ STATUS_PROBLEM,
24
+ STATUS_RECOVERING,
25
+ Incident,
26
+ IncidentLocations,
27
+ )
28
+ from .location import Location
29
+ from .monitor import (
30
+ AGREEMENT_ALL,
31
+ AGREEMENT_ANY,
32
+ AGREEMENT_MAJORITY,
33
+ BaseWebsiteMonitor,
34
+ CreateWebsiteMonitorRequest,
35
+ DeleteWebsiteMonitorResponse,
36
+ UpdateWebsiteMonitorRequest,
37
+ WebsiteMonitor,
38
+ WebsiteMonitorRequest,
39
+ WebsiteMonitorResponse,
40
+ WebsiteMonitorResponseBody,
41
+ )
42
+ from .workspace import Workspace
43
+
44
+ __all__ = [
45
+ "AGREEMENT_ALL",
46
+ "AGREEMENT_ANY",
47
+ "AGREEMENT_MAJORITY",
48
+ "STATUS_NO_DATA",
49
+ "STATUS_OK",
50
+ "STATUS_PENDING",
51
+ "STATUS_PROBLEM",
52
+ "STATUS_RECOVERING",
53
+ "BaseWebsiteMonitor",
54
+ "CreateWebsiteMonitorRequest",
55
+ "DeleteWebsiteMonitorResponse",
56
+ "Incident",
57
+ "IncidentLocations",
58
+ "Location",
59
+ "UpdateWebsiteMonitorRequest",
60
+ "WebsiteMonitor",
61
+ "WebsiteMonitorRequest",
62
+ "WebsiteMonitorResponse",
63
+ "WebsiteMonitorResponseBody",
64
+ "Workspace",
65
+ "from_api",
66
+ "from_api_incident",
67
+ "from_api_location",
68
+ "from_api_website_monitor",
69
+ "from_api_workspace",
70
+ ]