kotharcomputing 0.97.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.97.0/src/kotharcomputing.egg-info → kotharcomputing-0.98.0}/PKG-INFO +1 -1
  2. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/pyproject.toml +1 -1
  3. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/__init__.py +2 -0
  4. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/files.py +68 -3
  5. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/jobs.py +18 -0
  6. {kotharcomputing-0.97.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.97.0 → kotharcomputing-0.98.0}/tests/test_public_api.py +1 -0
  9. kotharcomputing-0.97.0/tests/test_fetch_url_building.py +0 -73
  10. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/LICENSE +0 -0
  11. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/README.md +0 -0
  12. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/setup.cfg +0 -0
  13. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/agents.py +0 -0
  14. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/ai.py +0 -0
  15. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/aleph_language_server.py +0 -0
  16. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/api_tokens.py +0 -0
  17. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/client.py +0 -0
  18. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/common.py +0 -0
  19. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/credits.py +0 -0
  20. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/errors.py +0 -0
  21. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/executions.py +0 -0
  22. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/fetch.py +0 -0
  23. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/py.typed +0 -0
  24. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/runtimes.py +0 -0
  25. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/service_prices.py +0 -0
  26. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/subscribe_user.py +0 -0
  27. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/subscribe_workspace.py +0 -0
  28. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/subscriptions.py +0 -0
  29. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/users.py +0 -0
  30. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing/workspaces.py +0 -0
  31. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing.egg-info/SOURCES.txt +0 -0
  32. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing.egg-info/dependency_links.txt +0 -0
  33. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing.egg-info/requires.txt +0 -0
  34. {kotharcomputing-0.97.0 → kotharcomputing-0.98.0}/src/kotharcomputing.egg-info/top_level.txt +0 -0
  35. {kotharcomputing-0.97.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.97.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.97.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"
@@ -113,6 +113,7 @@ from .jobs import (
113
113
  JobLaunchInstructionMethod,
114
114
  JobLaunchInstructions,
115
115
  JobStatus,
116
+ JobUploadedFile,
116
117
  UpdateJobArgs,
117
118
  )
118
119
  from .service_prices import ServicePrice
@@ -253,6 +254,7 @@ __all__ = [
253
254
  "JobLaunchInstructionMethod",
254
255
  "JobLaunchInstructions",
255
256
  "JobStatus",
257
+ "JobUploadedFile",
256
258
  "UpdateJobArgs",
257
259
  # Service Prices
258
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
@@ -35,6 +50,8 @@ class Job(WithUser, WithAgent):
35
50
  launchInstructions: NotRequired[JobLaunchInstructions]
36
51
  consumedCredits: NotRequired[float]
37
52
  data: NotRequired[JSONValue]
53
+ uploadedFiles: NotRequired[list[JobUploadedFile]]
54
+ dependencies: NotRequired[list[JobDependency]]
38
55
 
39
56
 
40
57
  class JobLaunchInstructions(TypedDict):
@@ -57,6 +74,7 @@ class JobLaunchInstructionCommand(TypedDict):
57
74
  class CreateJobArgs(TypedDict):
58
75
  agentId: str
59
76
  path: str
77
+ versionId: NotRequired[str]
60
78
  runtimeId: NotRequired[str]
61
79
  name: NotRequired[str]
62
80
  notes: NotRequired[str]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kotharcomputing
3
- Version: 0.97.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
+ }
@@ -123,6 +123,7 @@ def test_package_import_and_public_exports() -> None:
123
123
  "JobLaunchInstructionMethod",
124
124
  "JobLaunchInstructions",
125
125
  "JobStatus",
126
+ "JobUploadedFile",
126
127
  "UpdateJobArgs",
127
128
  # Service Prices
128
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
- )