tracktolib 0.67.0__py3-none-any.whl → 0.69.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.
tracktolib/api.py CHANGED
@@ -1,32 +1,31 @@
1
1
  import json
2
2
  import warnings
3
- from collections.abc import Mapping
4
- from dataclasses import field, dataclass
3
+ from dataclasses import dataclass, field
5
4
  from inspect import getdoc
6
5
  from typing import (
7
- Callable,
8
6
  Any,
9
- Literal,
10
- Sequence,
11
7
  AsyncIterator,
8
+ Callable,
9
+ ClassVar,
12
10
  Coroutine,
13
- get_type_hints,
14
- get_args,
15
- TypedDict,
11
+ Literal,
12
+ Mapping,
13
+ Sequence,
16
14
  TypeAlias,
17
- Type,
18
- ClassVar,
15
+ TypedDict,
16
+ get_args,
19
17
  get_origin,
18
+ get_type_hints,
20
19
  )
21
20
 
22
- from .utils import json_serial, get_first_line
21
+ from .utils import get_first_line, json_serial
23
22
 
24
23
  try:
25
- from fastapi import params, APIRouter
24
+ import starlette.status
25
+ from fastapi import APIRouter, params
26
26
  from fastapi.responses import JSONResponse
27
- from pydantic.alias_generators import to_camel
28
27
  from pydantic import BaseModel, ConfigDict
29
- import starlette.status
28
+ from pydantic.alias_generators import to_camel
30
29
  except ImportError:
31
30
  raise ImportError('Please install fastapi, pydantic or tracktolib with "api" to use this module')
32
31
 
@@ -61,7 +60,7 @@ class MethodMeta(TypedDict):
61
60
  status_code: StatusCode
62
61
  dependencies: Dependencies
63
62
  path: str | None
64
- response_model: Type[BaseModel | None | Sequence[BaseModel]] | None
63
+ response_model: type[BaseModel | None | Sequence[BaseModel]] | None
65
64
  openapi_extra: dict[str, Any] | None
66
65
  name: str | None
67
66
  summary: str | None
@@ -82,7 +81,7 @@ class Endpoint:
82
81
  status_code: StatusCode = None,
83
82
  dependencies: Dependencies = None,
84
83
  path: str | None = None,
85
- model: Type[B] | None = None,
84
+ model: type[B] | None = None,
86
85
  openapi_extra: dict[str, Any] | None = None,
87
86
  name: str | None = None,
88
87
  summary: str | None = None,
@@ -109,7 +108,7 @@ class Endpoint:
109
108
  status_code: StatusCode = None,
110
109
  dependencies: Dependencies = None,
111
110
  path: str | None = None,
112
- model: Type[B] | None = None,
111
+ model: type[B] | None = None,
113
112
  openapi_extra: dict[str, Any] | None = None,
114
113
  name: str | None = None,
115
114
  summary: str | None = None,
@@ -135,7 +134,7 @@ class Endpoint:
135
134
  status_code: StatusCode = None,
136
135
  dependencies: Dependencies = None,
137
136
  path: str | None = None,
138
- model: Type[B] | None = None,
137
+ model: type[B] | None = None,
139
138
  openapi_extra: dict[str, Any] | None = None,
140
139
  name: str | None = None,
141
140
  summary: str | None = None,
@@ -161,7 +160,7 @@ class Endpoint:
161
160
  status_code: StatusCode = None,
162
161
  dependencies: Dependencies = None,
163
162
  path: str | None = None,
164
- model: Type[B] | None = None,
163
+ model: type[B] | None = None,
165
164
  openapi_extra: dict[str, Any] | None = None,
166
165
  name: str | None = None,
167
166
  summary: str | None = None,
@@ -187,7 +186,7 @@ class Endpoint:
187
186
  status_code: StatusCode = None,
188
187
  dependencies: Dependencies = None,
189
188
  path: str | None = None,
190
- model: Type[B] | None = None,
189
+ model: type[B] | None = None,
191
190
  openapi_extra: dict[str, Any] | None = None,
192
191
  name: str | None = None,
193
192
  summary: str | None = None,
@@ -216,7 +215,7 @@ def _get_method_wrapper[B: _BaseModelBound](
216
215
  status_code: StatusCode = None,
217
216
  dependencies: Dependencies = None,
218
217
  path: str | None = None,
219
- model: Type[B] | None = None,
218
+ model: type[B] | None = None,
220
219
  openapi_extra: dict[str, Any] | None = None,
221
220
  name: str | None = None,
222
221
  summary: str | None = None,
@@ -335,7 +334,7 @@ def check_status(resp, status: int = starlette.status.HTTP_200_OK):
335
334
  raise AssertionError(json.dumps(resp.json(), indent=4))
336
335
 
337
336
 
338
- def generate_list_name_model[B: _BaseModelBound](model: Type[B], status: int | None = None) -> dict:
337
+ def generate_list_name_model[B: _BaseModelBound](model: type[B], status: int | None = None) -> dict:
339
338
  _status = "200" if status is None else str(status)
340
339
  if get_origin(model) and get_origin(model) is list:
341
340
  _title = f"Array[{get_args(model)[0].__name__}]"
@@ -0,0 +1,8 @@
1
+ from .client import CloudflareDNSClient, CloudflareError
2
+ from .types import DnsRecord
3
+
4
+ __all__ = [
5
+ "CloudflareDNSClient",
6
+ "CloudflareError",
7
+ "DnsRecord",
8
+ ]
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, Any, cast
6
+
7
+ try:
8
+ import niquests
9
+ except ImportError:
10
+ raise ImportError('Please install niquests or tracktolib with "cf" to use this module')
11
+
12
+ if TYPE_CHECKING:
13
+ from urllib3.util.retry import Retry
14
+
15
+ from tracktolib.cf.types import DnsRecord
16
+
17
+
18
+ class CloudflareError(Exception):
19
+ """Error raised when a Cloudflare API call fails."""
20
+
21
+ def __init__(self, message: str, status_code: int | None = None, errors: list | None = None):
22
+ self.status_code = status_code
23
+ self.errors = errors or []
24
+ super().__init__(message)
25
+
26
+
27
+ @dataclass
28
+ class CloudflareDNSClient:
29
+ """
30
+ Async Cloudflare DNS API client for managing DNS records.
31
+
32
+ Requires CLOUDFLARE_API_TOKEN and CLOUDFLARE_ZONE_ID environment variables,
33
+ or pass them directly to the constructor.
34
+ """
35
+
36
+ zone_id: str | None = field(default_factory=lambda: os.environ.get("CLOUDFLARE_ZONE_ID"))
37
+ token: str | None = field(default_factory=lambda: os.environ.get("CLOUDFLARE_API_TOKEN"))
38
+ base_url: str = "https://api.cloudflare.com/client/v4"
39
+ retries: int | Retry = 0
40
+ hooks: Any = None
41
+ session: niquests.AsyncSession = field(init=False, repr=False)
42
+
43
+ def __post_init__(self) -> None:
44
+ if not self.token:
45
+ raise ValueError("CLOUDFLARE_API_TOKEN environment variable is required")
46
+ if not self.zone_id:
47
+ raise ValueError("CLOUDFLARE_ZONE_ID environment variable is required")
48
+
49
+ self.session = niquests.AsyncSession(
50
+ base_url=self.base_url,
51
+ retries=self.retries,
52
+ hooks=self.hooks,
53
+ headers={
54
+ "Authorization": f"Bearer {self.token}",
55
+ "Content-Type": "application/json",
56
+ },
57
+ )
58
+
59
+ async def __aenter__(self) -> "CloudflareDNSClient":
60
+ return self
61
+
62
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
63
+ await self.aclose()
64
+
65
+ async def aclose(self) -> None:
66
+ """Close the underlying session."""
67
+ await self.session.close()
68
+
69
+ def _handle_response(self, response: niquests.Response) -> dict:
70
+ """Handle Cloudflare API response and raise on errors."""
71
+ data = response.json()
72
+ if not data.get("success", False):
73
+ errors = data.get("errors", [])
74
+ error_messages = [e.get("message", str(e)) for e in errors]
75
+ raise CloudflareError(
76
+ f"Cloudflare API error: {', '.join(error_messages)}",
77
+ status_code=response.status_code,
78
+ errors=errors,
79
+ )
80
+ return data
81
+
82
+ # DNS Records
83
+
84
+ async def get_dns_record(self, name: str, record_type: str = "CNAME") -> DnsRecord | None:
85
+ """
86
+ Get a DNS record by name and type.
87
+
88
+ Returns None if the record doesn't exist.
89
+ """
90
+ params = {"type": record_type, "name": name}
91
+ response = await self.session.get(f"/zones/{self.zone_id}/dns_records", params=params)
92
+ data = self._handle_response(response)
93
+
94
+ results = data.get("result", [])
95
+ if not results:
96
+ return None
97
+
98
+ return cast("DnsRecord", results[0])
99
+
100
+ async def create_dns_record(
101
+ self,
102
+ name: str,
103
+ content: str,
104
+ record_type: str = "CNAME",
105
+ *,
106
+ ttl: int = 1,
107
+ proxied: bool = False,
108
+ comment: str | None = None,
109
+ ) -> DnsRecord:
110
+ """
111
+ Create a DNS record.
112
+
113
+ The ttl parameter defaults to 1 (automatic). Set to a value between 60-86400 for manual TTL.
114
+ """
115
+ payload: dict = {
116
+ "type": record_type,
117
+ "name": name,
118
+ "content": content,
119
+ "ttl": ttl,
120
+ "proxied": proxied,
121
+ }
122
+ if comment:
123
+ payload["comment"] = comment
124
+
125
+ response = await self.session.post(f"/zones/{self.zone_id}/dns_records", json=payload)
126
+ data = self._handle_response(response)
127
+ return cast("DnsRecord", data["result"])
128
+
129
+ async def delete_dns_record(self, record_id: str) -> None:
130
+ """Delete a DNS record by ID."""
131
+ response = await self.session.delete(f"/zones/{self.zone_id}/dns_records/{record_id}")
132
+ self._handle_response(response)
133
+
134
+ async def delete_dns_record_by_name(self, name: str, record_type: str = "CNAME") -> bool:
135
+ """
136
+ Delete a DNS record by name and type.
137
+
138
+ Returns True if deleted, False if the record didn't exist.
139
+ """
140
+ record = await self.get_dns_record(name, record_type)
141
+ if record is None:
142
+ return False
143
+
144
+ await self.delete_dns_record(record["id"])
145
+ return True
146
+
147
+ async def dns_record_exists(self, name: str, record_type: str = "CNAME") -> bool:
148
+ """Check if a DNS record exists."""
149
+ return await self.get_dns_record(name, record_type) is not None
tracktolib/cf/types.py ADDED
@@ -0,0 +1,17 @@
1
+ from typing import NotRequired, TypedDict
2
+
3
+
4
+ class DnsRecord(TypedDict):
5
+ """Cloudflare DNS record response."""
6
+
7
+ id: str
8
+ name: str
9
+ type: str
10
+ content: str
11
+ ttl: int
12
+ proxied: bool
13
+ proxiable: NotRequired[bool]
14
+ created_on: NotRequired[str]
15
+ modified_on: NotRequired[str]
16
+ comment: NotRequired[str]
17
+ tags: NotRequired[list[str]]
@@ -0,0 +1,11 @@
1
+ from .client import GitHubClient, ProgressCallback
2
+ from .types import Deployment, DeploymentStatus, IssueComment, Label
3
+
4
+ __all__ = [
5
+ "Deployment",
6
+ "DeploymentStatus",
7
+ "GitHubClient",
8
+ "IssueComment",
9
+ "Label",
10
+ "ProgressCallback",
11
+ ]
@@ -0,0 +1,206 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass, field
5
+ from typing import TYPE_CHECKING, Any, Callable, cast
6
+ from urllib.parse import quote
7
+
8
+ try:
9
+ import niquests
10
+ except ImportError:
11
+ raise ImportError('Please install niquests or tracktolib with "gh" to use this module')
12
+
13
+ if TYPE_CHECKING:
14
+ from urllib3.util.retry import Retry
15
+
16
+ from tracktolib.gh.types import Deployment, DeploymentStatus, IssueComment, Label
17
+
18
+
19
+ ProgressCallback = Callable[[int, int], None]
20
+
21
+
22
+ @dataclass
23
+ class GitHubClient:
24
+ """
25
+ Async GitHub API client for issues, labels, and deployments.
26
+ """
27
+
28
+ token: str | None = field(default_factory=lambda: os.environ.get("GITHUB_TOKEN"))
29
+ base_url: str = "https://api.github.com"
30
+ retries: int | Retry = 0
31
+ hooks: Any = None
32
+ session: niquests.AsyncSession = field(init=False, repr=False)
33
+
34
+ def __post_init__(self) -> None:
35
+ if not self.token:
36
+ raise ValueError("GITHUB_TOKEN environment variable is required")
37
+
38
+ self.session = niquests.AsyncSession(
39
+ base_url=self.base_url,
40
+ retries=self.retries,
41
+ hooks=self.hooks,
42
+ headers={
43
+ "Authorization": f"Bearer {self.token}",
44
+ "Accept": "application/vnd.github+json",
45
+ "X-GitHub-Api-Version": "2022-11-28",
46
+ },
47
+ )
48
+
49
+ async def __aenter__(self) -> GitHubClient:
50
+ return self
51
+
52
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
53
+ await self.aclose()
54
+
55
+ async def aclose(self) -> None:
56
+ """Close the underlying session."""
57
+ await self.session.close()
58
+
59
+ # Issue Comments
60
+
61
+ async def get_issue_comments(self, repository: str, issue_number: int) -> list[IssueComment]:
62
+ """Get all comments on an issue or PR."""
63
+ response = await self.session.get(f"/repos/{repository}/issues/{issue_number}/comments")
64
+ response.raise_for_status()
65
+ return cast("list[IssueComment]", response.json())
66
+
67
+ async def create_issue_comment(self, repository: str, issue_number: int, body: str) -> IssueComment:
68
+ """Create a comment on an issue or PR."""
69
+ response = await self.session.post(f"/repos/{repository}/issues/{issue_number}/comments", json={"body": body})
70
+ response.raise_for_status()
71
+ return cast("IssueComment", response.json())
72
+
73
+ async def delete_issue_comment(self, repository: str, comment_id: int) -> None:
74
+ """Delete a comment by ID."""
75
+ response = await self.session.delete(f"/repos/{repository}/issues/comments/{comment_id}")
76
+ response.raise_for_status()
77
+
78
+ async def find_comments_with_marker(self, repository: str, issue_number: int, marker: str) -> list[int]:
79
+ """Find comment IDs containing a specific marker string."""
80
+ comments = await self.get_issue_comments(repository, issue_number)
81
+ return [c["id"] for c in comments if marker in c.get("body", "")]
82
+
83
+ async def delete_comments_with_marker(
84
+ self,
85
+ repository: str,
86
+ issue_number: int,
87
+ marker: str,
88
+ *,
89
+ on_progress: ProgressCallback | None = None,
90
+ ) -> int:
91
+ """Delete all comments containing a specific marker. Returns count deleted."""
92
+ comment_ids = await self.find_comments_with_marker(repository, issue_number, marker)
93
+ total = len(comment_ids)
94
+ for i, comment_id in enumerate(comment_ids):
95
+ await self.delete_issue_comment(repository, comment_id)
96
+ if on_progress:
97
+ on_progress(i + 1, total)
98
+ return total
99
+
100
+ async def create_idempotent_comment(
101
+ self, repository: str, issue_number: int, body: str, marker: str
102
+ ) -> IssueComment | None:
103
+ """
104
+ Create a comment only if one with the marker doesn't already exist.
105
+
106
+ The marker should be included in the body (e.g., an HTML comment like
107
+ '<!-- my-marker -->'). Returns the created comment, or None if skipped.
108
+ """
109
+ if await self.find_comments_with_marker(repository, issue_number, marker):
110
+ return None
111
+ return await self.create_issue_comment(repository, issue_number, body)
112
+
113
+ # Labels
114
+
115
+ async def get_issue_labels(self, repository: str, issue_number: int) -> list[Label]:
116
+ """Get all labels on an issue or PR."""
117
+ response = await self.session.get(f"/repos/{repository}/issues/{issue_number}/labels")
118
+ response.raise_for_status()
119
+ return cast("list[Label]", response.json())
120
+
121
+ async def add_labels(self, repository: str, issue_number: int, labels: list[str]) -> list[Label]:
122
+ """Add labels to an issue or PR."""
123
+ response = await self.session.post(f"/repos/{repository}/issues/{issue_number}/labels", json={"labels": labels})
124
+ response.raise_for_status()
125
+ return cast("list[Label]", response.json())
126
+
127
+ async def remove_label(self, repository: str, issue_number: int, label: str) -> bool:
128
+ """Remove a label from an issue/PR. Returns True if removed, False if not found."""
129
+ response = await self.session.delete(
130
+ f"/repos/{repository}/issues/{issue_number}/labels/{quote(label, safe='')}"
131
+ )
132
+ if response.status_code == 404:
133
+ return False
134
+ response.raise_for_status()
135
+ return True
136
+
137
+ # Deployments
138
+
139
+ async def get_deployments(self, repository: str, *, environment: str | None = None) -> list[Deployment]:
140
+ """Get deployments, optionally filtered by environment."""
141
+ params = {"environment": environment} if environment else {}
142
+ response = await self.session.get(f"/repos/{repository}/deployments", params=params)
143
+ response.raise_for_status()
144
+ return cast("list[Deployment]", response.json())
145
+
146
+ async def create_deployment_status(
147
+ self,
148
+ repository: str,
149
+ deployment_id: int,
150
+ state: str,
151
+ *,
152
+ description: str | None = None,
153
+ environment_url: str | None = None,
154
+ ) -> DeploymentStatus:
155
+ """
156
+ Create a deployment status.
157
+
158
+ State can be: error, failure, inactive, in_progress, queued, pending, success.
159
+ """
160
+ payload: dict = {"state": state}
161
+ if description:
162
+ payload["description"] = description
163
+ if environment_url:
164
+ payload["environment_url"] = environment_url
165
+ response = await self.session.post(f"/repos/{repository}/deployments/{deployment_id}/statuses", json=payload)
166
+ response.raise_for_status()
167
+ return cast("DeploymentStatus", response.json())
168
+
169
+ async def get_deployment_statuses(
170
+ self,
171
+ repository: str,
172
+ deployment_id: int,
173
+ ) -> list[DeploymentStatus]:
174
+ """Get all statuses for a deployment, most recent first."""
175
+ response = await self.session.get(f"/repos/{repository}/deployments/{deployment_id}/statuses")
176
+ response.raise_for_status()
177
+ return cast("list[DeploymentStatus]", response.json())
178
+
179
+ async def get_latest_deployment_status(
180
+ self,
181
+ repository: str,
182
+ environment: str,
183
+ ) -> DeploymentStatus | None:
184
+ """Get the latest deployment status for an environment."""
185
+ deployments = await self.get_deployments(repository, environment=environment)
186
+ if not deployments:
187
+ return None
188
+ statuses = await self.get_deployment_statuses(repository, deployments[0]["id"])
189
+ return statuses[0] if statuses else None
190
+
191
+ async def mark_deployment_inactive(
192
+ self,
193
+ repository: str,
194
+ environment: str,
195
+ *,
196
+ description: str = "Environment removed",
197
+ on_progress: ProgressCallback | None = None,
198
+ ) -> int:
199
+ """Mark all deployments for an environment as inactive. Returns count updated."""
200
+ deployments = await self.get_deployments(repository, environment=environment)
201
+ total = len(deployments)
202
+ for i, deployment in enumerate(deployments):
203
+ await self.create_deployment_status(repository, deployment["id"], "inactive", description=description)
204
+ if on_progress:
205
+ on_progress(i + 1, total)
206
+ return total