kotharcomputing 0.96.0__tar.gz → 0.98.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 (35) hide show
  1. {kotharcomputing-0.96.0/src/kotharcomputing.egg-info → kotharcomputing-0.98.0}/PKG-INFO +1 -1
  2. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/pyproject.toml +1 -1
  3. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/__init__.py +8 -0
  4. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/files.py +68 -3
  5. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/jobs.py +36 -0
  6. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0/src/kotharcomputing.egg-info}/PKG-INFO +1 -1
  7. kotharcomputing-0.98.0/tests/test_fetch_url_building.py +215 -0
  8. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/tests/test_public_api.py +4 -0
  9. kotharcomputing-0.96.0/tests/test_fetch_url_building.py +0 -73
  10. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/LICENSE +0 -0
  11. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/README.md +0 -0
  12. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/setup.cfg +0 -0
  13. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/agents.py +0 -0
  14. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/ai.py +0 -0
  15. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/aleph_language_server.py +0 -0
  16. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/api_tokens.py +0 -0
  17. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/client.py +0 -0
  18. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/common.py +0 -0
  19. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/credits.py +0 -0
  20. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/errors.py +0 -0
  21. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/executions.py +0 -0
  22. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/fetch.py +0 -0
  23. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/py.typed +0 -0
  24. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/runtimes.py +0 -0
  25. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/service_prices.py +0 -0
  26. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/subscribe_user.py +0 -0
  27. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/subscribe_workspace.py +0 -0
  28. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/subscriptions.py +0 -0
  29. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/users.py +0 -0
  30. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing/workspaces.py +0 -0
  31. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing.egg-info/SOURCES.txt +0 -0
  32. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing.egg-info/dependency_links.txt +0 -0
  33. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing.egg-info/requires.txt +0 -0
  34. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/src/kotharcomputing.egg-info/top_level.txt +0 -0
  35. {kotharcomputing-0.96.0 → kotharcomputing-0.98.0}/tests/test_fetch_auth_headers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kotharcomputing
3
- Version: 0.96.0
3
+ Version: 0.98.0
4
4
  Summary: Python SDK for the Kothar API
5
5
  Author: Kothar Computing
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kotharcomputing"
7
- version = "0.96.0"
7
+ version = "0.98.0"
8
8
  description = "Python SDK for the Kothar API"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -109,7 +109,11 @@ from .files import (
109
109
  from .jobs import (
110
110
  CreateJobArgs,
111
111
  Job,
112
+ JobLaunchInstructionCommand,
113
+ JobLaunchInstructionMethod,
114
+ JobLaunchInstructions,
112
115
  JobStatus,
116
+ JobUploadedFile,
113
117
  UpdateJobArgs,
114
118
  )
115
119
  from .service_prices import ServicePrice
@@ -246,7 +250,11 @@ __all__ = [
246
250
  # Jobs
247
251
  "CreateJobArgs",
248
252
  "Job",
253
+ "JobLaunchInstructionCommand",
254
+ "JobLaunchInstructionMethod",
255
+ "JobLaunchInstructions",
249
256
  "JobStatus",
257
+ "JobUploadedFile",
250
258
  "UpdateJobArgs",
251
259
  # Service Prices
252
260
  "ServicePrice",
@@ -51,6 +51,7 @@ class FileContent:
51
51
  data: bytes
52
52
  file_name: str
53
53
  etag: str | None = None
54
+ version_id: str | None = None
54
55
  mime_type: str | None = None
55
56
 
56
57
  def text(self, encoding: str = "utf-8") -> str:
@@ -63,6 +64,11 @@ class FileByPathClient:
63
64
  self._workspace_id = workspace_id
64
65
  self._path = path
65
66
 
67
+ def version(self, version_id: str) -> "FileByPathVersionClient":
68
+ return FileByPathVersionClient(
69
+ self._transport, self._workspace_id, self._path, version_id
70
+ )
71
+
66
72
  def get(self, *, etag: str | None = None) -> FileContent | None:
67
73
  response = self._transport.raw(
68
74
  "get",
@@ -80,6 +86,7 @@ class FileByPathClient:
80
86
  data=response.body,
81
87
  file_name=file_name,
82
88
  etag=response.headers.get("etag"),
89
+ version_id=response.headers.get("x-kothar-file-version-id"),
83
90
  mime_type=response.headers.get("content-type"),
84
91
  )
85
92
 
@@ -93,11 +100,14 @@ class FileByPathClient:
93
100
  search_params={"asFolder": as_folder},
94
101
  body=content,
95
102
  )
96
- return {"etag": response.headers.get("etag")}
103
+ return {
104
+ "etag": response.headers.get("etag"),
105
+ "version_id": response.headers.get("x-kothar-file-version-id"),
106
+ }
97
107
 
98
108
  def update(
99
109
  self, *, content: bytes | str, etag: str | None = None
100
- ) -> dict[str, str]:
110
+ ) -> dict[str, str | None]:
101
111
  response = self._transport.raw(
102
112
  "put",
103
113
  "v1/workspaces/:workspaceId/files/:filePath",
@@ -114,7 +124,10 @@ class FileByPathClient:
114
124
  "status": response.status,
115
125
  },
116
126
  )
117
- return {"etag": response_etag}
127
+ return {
128
+ "etag": response_etag,
129
+ "version_id": response.headers.get("x-kothar-file-version-id"),
130
+ }
118
131
 
119
132
  def download_url(self) -> str:
120
133
  payload = cast(
@@ -127,6 +140,15 @@ class FileByPathClient:
127
140
  )
128
141
  return payload["url"]
129
142
 
143
+ def restore(self, version_id: str) -> dict[str, str | None]:
144
+ response = self._transport.raw(
145
+ "post",
146
+ "v1/workspaces/:workspaceId/files/$restore",
147
+ path_params={"workspaceId": self._workspace_id},
148
+ json_data={"path": self._path, "versionId": version_id},
149
+ )
150
+ return {"version_id": response.headers.get("x-kothar-file-version-id")}
151
+
130
152
  def move(self, target_path: str) -> None:
131
153
  self._transport.raw(
132
154
  "post",
@@ -155,6 +177,49 @@ class FileByPathClient:
155
177
  )
156
178
 
157
179
 
180
+ class FileByPathVersionClient:
181
+ def __init__(
182
+ self, transport: ApiTransport, workspace_id: str, path: str, version_id: str
183
+ ) -> None:
184
+ self._transport = transport
185
+ self._workspace_id = workspace_id
186
+ self._path = path
187
+ self._version_id = version_id
188
+
189
+ def get(self, *, etag: str | None = None) -> FileContent | None:
190
+ response = self._transport.raw(
191
+ "get",
192
+ "v1/workspaces/:workspaceId/files/:filePath",
193
+ path_params={"workspaceId": self._workspace_id, "filePath": self._path},
194
+ search_params={"versionId": self._version_id},
195
+ headers={"if-none-match": etag} if etag else None,
196
+ )
197
+
198
+ if response.status == 304:
199
+ return None
200
+
201
+ content_disposition = response.headers.get("content-disposition", "")
202
+ file_name = extract_filename(content_disposition) or self._path.split("/")[-1]
203
+ return FileContent(
204
+ data=response.body,
205
+ file_name=file_name,
206
+ etag=response.headers.get("etag"),
207
+ version_id=response.headers.get("x-kothar-file-version-id"),
208
+ mime_type=response.headers.get("content-type"),
209
+ )
210
+
211
+ def download_url(self) -> str:
212
+ payload = cast(
213
+ dict[str, str],
214
+ self._transport.get_json(
215
+ "v1/workspaces/:workspaceId/files/$downloadUrl",
216
+ path_params={"workspaceId": self._workspace_id},
217
+ search_params={"path": self._path, "versionId": self._version_id},
218
+ ),
219
+ )
220
+ return payload["url"]
221
+
222
+
158
223
  class FilesClient:
159
224
  def __init__(self, transport: ApiTransport, workspace_id: str) -> None:
160
225
  self._transport = transport
@@ -15,10 +15,25 @@ JobStatus: TypeAlias = Literal[
15
15
  ]
16
16
 
17
17
 
18
+ class JobDependency(TypedDict):
19
+ path: str
20
+ versionId: NotRequired[str]
21
+ source: Literal["explicit", "detected"]
22
+
23
+
24
+ class JobUploadedFile(TypedDict):
25
+ path: str
26
+ kind: Literal["file", "archive"]
27
+ action: Literal["created", "modified"]
28
+ hash: str
29
+ versionId: NotRequired[str]
30
+
31
+
18
32
  class Job(WithUser, WithAgent):
19
33
  id: str
20
34
  workspaceId: str
21
35
  path: str
36
+ versionId: NotRequired[str]
22
37
  status: JobStatus
23
38
  createdAt: str
24
39
  isTerminated: bool
@@ -32,13 +47,34 @@ class Job(WithUser, WithAgent):
32
47
  finishedAt: NotRequired[str]
33
48
  duration: NotRequired[str]
34
49
  lastPing: NotRequired[str]
50
+ launchInstructions: NotRequired[JobLaunchInstructions]
35
51
  consumedCredits: NotRequired[float]
36
52
  data: NotRequired[JSONValue]
53
+ uploadedFiles: NotRequired[list[JobUploadedFile]]
54
+ dependencies: NotRequired[list[JobDependency]]
55
+
56
+
57
+ class JobLaunchInstructions(TypedDict):
58
+ launchJobBefore: str
59
+ methods: list[JobLaunchInstructionMethod]
60
+
61
+
62
+ class JobLaunchInstructionMethod(TypedDict):
63
+ id: str
64
+ label: str
65
+ instructionMarkdown: str
66
+ commands: list[JobLaunchInstructionCommand]
67
+
68
+
69
+ class JobLaunchInstructionCommand(TypedDict):
70
+ label: str
71
+ command: str
37
72
 
38
73
 
39
74
  class CreateJobArgs(TypedDict):
40
75
  agentId: str
41
76
  path: str
77
+ versionId: NotRequired[str]
42
78
  runtimeId: NotRequired[str]
43
79
  name: NotRequired[str]
44
80
  notes: NotRequired[str]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kotharcomputing
3
- Version: 0.96.0
3
+ Version: 0.98.0
4
4
  Summary: Python SDK for the Kothar API
5
5
  Author: Kothar Computing
6
6
  License-Expression: Apache-2.0
@@ -0,0 +1,215 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ import sys
5
+ from urllib import parse as urllib_parse
6
+
7
+ import pytest
8
+
9
+ SRC_DIR = Path(__file__).resolve().parents[1] / "src"
10
+ if str(SRC_DIR) not in sys.path:
11
+ sys.path.insert(0, str(SRC_DIR))
12
+
13
+ from kotharcomputing.fetch import ApiTransport, RawResponse
14
+ from kotharcomputing.files import FileByPathClient
15
+
16
+
17
+ def test_build_url_encodes_path_params_and_bool_query_values() -> None:
18
+ transport = ApiTransport("https://api.example.com", access_token=None, timeout=30.0)
19
+
20
+ url = transport.build_url(
21
+ "v1/workspaces/:workspaceId/files/:filePath",
22
+ path_params={"workspaceId": "personal", "filePath": "folder/hello world.txt"},
23
+ search_params={"withUsernames": True, "limit": 25},
24
+ )
25
+
26
+ assert (
27
+ url
28
+ == "https://api.example.com/v1/workspaces/personal/files/folder%2Fhello%20world.txt?withUsernames=true&limit=25"
29
+ )
30
+
31
+
32
+ def test_build_url_expands_list_query_params_and_skips_none_values() -> None:
33
+ transport = ApiTransport("https://api.example.com", access_token=None, timeout=30.0)
34
+
35
+ url = transport.build_url(
36
+ "v1/users/me/credits/transactions",
37
+ search_params={
38
+ "references": ["job-1", "job-2"],
39
+ "active": False,
40
+ "next": None,
41
+ },
42
+ )
43
+
44
+ query_pairs = urllib_parse.parse_qsl(
45
+ urllib_parse.urlsplit(url).query, keep_blank_values=True
46
+ )
47
+ assert query_pairs == [
48
+ ("references", "job-1"),
49
+ ("references", "job-2"),
50
+ ("active", "false"),
51
+ ]
52
+
53
+
54
+ def test_build_url_preserves_existing_query_and_appends_new_params() -> None:
55
+ transport = ApiTransport("https://api.example.com", access_token=None, timeout=30.0)
56
+
57
+ url = transport.build_url(
58
+ "v1/jobs?next=cursor-1",
59
+ search_params={"withAgents": True},
60
+ )
61
+
62
+ assert (
63
+ url == "https://api.example.com/v1/jobs?next=cursor-1&withAgents=true"
64
+ )
65
+
66
+
67
+ def test_build_url_rejects_absolute_url_outside_base_url() -> None:
68
+ transport = ApiTransport("https://api.example.com", access_token=None, timeout=30.0)
69
+
70
+ with pytest.raises(ValueError, match="does not start with the base URL"):
71
+ transport.build_url(
72
+ "https://malicious.example.com/v1/jobs",
73
+ absolute_url=True,
74
+ )
75
+
76
+
77
+ def test_files_client_sends_version_id_for_versioned_download_url() -> None:
78
+ class Transport:
79
+ def __init__(self) -> None:
80
+ self.calls: list[object] = []
81
+
82
+ def get_json(
83
+ self,
84
+ templated_url: str,
85
+ *,
86
+ path_params: object | None = None,
87
+ search_params: object | None = None,
88
+ headers: object | None = None,
89
+ ) -> object:
90
+ self.calls.append(
91
+ {
92
+ "templated_url": templated_url,
93
+ "path_params": path_params,
94
+ "search_params": search_params,
95
+ "headers": headers,
96
+ }
97
+ )
98
+ return {"url": "https://api.example.com/download"}
99
+
100
+ transport = Transport()
101
+ client = FileByPathClient(transport, "workspace", "folder/file.txt") # type: ignore[arg-type]
102
+
103
+ url = client.version("version+1").download_url()
104
+
105
+ assert url == "https://api.example.com/download"
106
+ assert transport.calls == [
107
+ {
108
+ "templated_url": "v1/workspaces/:workspaceId/files/$downloadUrl",
109
+ "path_params": {"workspaceId": "workspace"},
110
+ "search_params": {
111
+ "path": "folder/file.txt",
112
+ "versionId": "version+1",
113
+ },
114
+ "headers": None,
115
+ }
116
+ ]
117
+
118
+
119
+ def test_files_client_exposes_resolved_version_id_from_get_header() -> None:
120
+ class Transport:
121
+ def raw(
122
+ self,
123
+ method: str,
124
+ templated_url: str,
125
+ *,
126
+ path_params: object | None = None,
127
+ headers: object | None = None,
128
+ ) -> RawResponse:
129
+ return RawResponse(
130
+ status=200,
131
+ headers={
132
+ "content-disposition": "inline; filename=file.txt",
133
+ "x-kothar-file-version-id": "version-1",
134
+ },
135
+ body=b"content",
136
+ )
137
+
138
+ client = FileByPathClient(Transport(), "workspace", "file.txt") # type: ignore[arg-type]
139
+
140
+ file = client.get()
141
+
142
+ assert file is not None
143
+ assert file.version_id == "version-1"
144
+
145
+
146
+ def test_files_client_restores_versions_from_base_path_client() -> None:
147
+ class Transport:
148
+ def __init__(self) -> None:
149
+ self.call: object | None = None
150
+
151
+ def raw(
152
+ self,
153
+ method: str,
154
+ templated_url: str,
155
+ *,
156
+ path_params: object | None = None,
157
+ json_data: object | None = None,
158
+ ) -> RawResponse:
159
+ self.call = {
160
+ "method": method,
161
+ "templated_url": templated_url,
162
+ "path_params": path_params,
163
+ "json_data": json_data,
164
+ }
165
+ return RawResponse(
166
+ status=204,
167
+ headers={"x-kothar-file-version-id": "restored-version"},
168
+ body=b"",
169
+ )
170
+
171
+ transport = Transport()
172
+ client = FileByPathClient(transport, "workspace", "file.txt") # type: ignore[arg-type]
173
+
174
+ result = client.restore("version-1")
175
+
176
+ assert transport.call == {
177
+ "method": "post",
178
+ "templated_url": "v1/workspaces/:workspaceId/files/$restore",
179
+ "path_params": {"workspaceId": "workspace"},
180
+ "json_data": {"path": "file.txt", "versionId": "version-1"},
181
+ }
182
+ assert result == {"version_id": "restored-version"}
183
+
184
+
185
+ def test_files_client_exposes_write_version_headers() -> None:
186
+ class Transport:
187
+ def raw(
188
+ self,
189
+ method: str,
190
+ templated_url: str,
191
+ *,
192
+ path_params: object | None = None,
193
+ search_params: object | None = None,
194
+ headers: object | None = None,
195
+ body: object | None = None,
196
+ ) -> RawResponse:
197
+ return RawResponse(
198
+ status=204,
199
+ headers={
200
+ "etag": "etag-1",
201
+ "x-kothar-file-version-id": f"{method}-version",
202
+ },
203
+ body=b"",
204
+ )
205
+
206
+ client = FileByPathClient(Transport(), "workspace", "file.txt") # type: ignore[arg-type]
207
+
208
+ assert client.create(content=b"content") == {
209
+ "etag": "etag-1",
210
+ "version_id": "post-version",
211
+ }
212
+ assert client.update(content=b"content") == {
213
+ "etag": "etag-1",
214
+ "version_id": "put-version",
215
+ }
@@ -119,7 +119,11 @@ def test_package_import_and_public_exports() -> None:
119
119
  # Jobs
120
120
  "CreateJobArgs",
121
121
  "Job",
122
+ "JobLaunchInstructionCommand",
123
+ "JobLaunchInstructionMethod",
124
+ "JobLaunchInstructions",
122
125
  "JobStatus",
126
+ "JobUploadedFile",
123
127
  "UpdateJobArgs",
124
128
  # Service Prices
125
129
  "ServicePrice",
@@ -1,73 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from pathlib import Path
4
- import sys
5
- from urllib import parse as urllib_parse
6
-
7
- import pytest
8
-
9
- SRC_DIR = Path(__file__).resolve().parents[1] / "src"
10
- if str(SRC_DIR) not in sys.path:
11
- sys.path.insert(0, str(SRC_DIR))
12
-
13
- from kotharcomputing.fetch import ApiTransport
14
-
15
-
16
- def test_build_url_encodes_path_params_and_bool_query_values() -> None:
17
- transport = ApiTransport("https://api.example.com", access_token=None, timeout=30.0)
18
-
19
- url = transport.build_url(
20
- "v1/workspaces/:workspaceId/files/:filePath",
21
- path_params={"workspaceId": "personal", "filePath": "folder/hello world.txt"},
22
- search_params={"withUsernames": True, "limit": 25},
23
- )
24
-
25
- assert (
26
- url
27
- == "https://api.example.com/v1/workspaces/personal/files/folder%2Fhello%20world.txt?withUsernames=true&limit=25"
28
- )
29
-
30
-
31
- def test_build_url_expands_list_query_params_and_skips_none_values() -> None:
32
- transport = ApiTransport("https://api.example.com", access_token=None, timeout=30.0)
33
-
34
- url = transport.build_url(
35
- "v1/users/me/credits/transactions",
36
- search_params={
37
- "references": ["job-1", "job-2"],
38
- "active": False,
39
- "next": None,
40
- },
41
- )
42
-
43
- query_pairs = urllib_parse.parse_qsl(
44
- urllib_parse.urlsplit(url).query, keep_blank_values=True
45
- )
46
- assert query_pairs == [
47
- ("references", "job-1"),
48
- ("references", "job-2"),
49
- ("active", "false"),
50
- ]
51
-
52
-
53
- def test_build_url_preserves_existing_query_and_appends_new_params() -> None:
54
- transport = ApiTransport("https://api.example.com", access_token=None, timeout=30.0)
55
-
56
- url = transport.build_url(
57
- "v1/jobs?next=cursor-1",
58
- search_params={"withAgents": True},
59
- )
60
-
61
- assert (
62
- url == "https://api.example.com/v1/jobs?next=cursor-1&withAgents=true"
63
- )
64
-
65
-
66
- def test_build_url_rejects_absolute_url_outside_base_url() -> None:
67
- transport = ApiTransport("https://api.example.com", access_token=None, timeout=30.0)
68
-
69
- with pytest.raises(ValueError, match="does not start with the base URL"):
70
- transport.build_url(
71
- "https://malicious.example.com/v1/jobs",
72
- absolute_url=True,
73
- )