pipekit-sdk 2.0.0__tar.gz → 2.1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pipekit-sdk
3
- Version: 2.0.0
3
+ Version: 2.1.0
4
4
  Summary: Pipekit Python SDK
5
5
  Home-page: https://pipekit.io
6
6
  License: BSD-3-Clause
@@ -16,8 +16,10 @@ Classifier: Programming Language :: Python :: 3.9
16
16
  Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
19
20
  Requires-Dist: hera (>=5.5.2)
20
21
  Requires-Dist: pydantic (<3)
22
+ Requires-Dist: pyjwt (>=2.9.0,<3.0.0)
21
23
  Requires-Dist: responses (>=0.25.3,<0.26.0)
22
24
  Project-URL: Documentation, https://docs.pipekit.io/
23
25
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pipekit-sdk"
3
- version = "2.0.0"
3
+ version = "2.1.0"
4
4
  description = "Pipekit Python SDK"
5
5
  authors = ["Pipekit <ci@pipekit.io>"]
6
6
  license = "BSD-3-Clause"
@@ -27,6 +27,7 @@ python = ">=3.8,<4"
27
27
  hera = ">=5.5.2"
28
28
  pydantic = "<3"
29
29
  responses = "^0.25.3"
30
+ pyjwt = "^2.9.0"
30
31
 
31
32
  [tool.poetry.group.dev.dependencies]
32
33
  ruff = "*"
@@ -4,8 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any
6
6
 
7
- from pipekit_sdk.models._config import BaseModel
7
+ from pydantic import RootModel
8
8
 
9
9
 
10
- class Model(BaseModel):
11
- __root__: Any
10
+ class Model(RootModel[Any]):
11
+ root: Any
@@ -0,0 +1,9 @@
1
+ from pydantic import BaseModel as PydanticBaseModel
2
+ from pydantic import ConfigDict
3
+
4
+
5
+ class BaseModel(PydanticBaseModel):
6
+ model_config = ConfigDict(
7
+ populate_by_name=True, # support populating object by Field alias
8
+ use_enum_values=True, # supports using enums, which are then unpacked to obtain the actual `.value`
9
+ )
@@ -2,10 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from datetime import datetime
6
5
  from typing import Dict, List, Optional
7
6
 
8
- from pydantic import Field
7
+ from pydantic import AwareDatetime, Field
9
8
  from typing_extensions import Annotated
10
9
 
11
10
  from pipekit_sdk.models._config import BaseModel
@@ -13,7 +12,7 @@ from pipekit_sdk.models._config import BaseModel
13
12
 
14
13
  class Cluster(BaseModel):
15
14
  api_key: Annotated[Optional[str], Field(alias="apiKey")] = None
16
- created_at: Annotated[datetime, Field(alias="createdAt")]
15
+ created_at: Annotated[AwareDatetime, Field(alias="createdAt")]
17
16
  description: str
18
17
  identity_uuid: Annotated[str, Field(alias="identityUUID")]
19
18
  is_active: Annotated[bool, Field(alias="isActive")]
@@ -23,7 +22,7 @@ class Cluster(BaseModel):
23
22
  pipekit_agent_version: Annotated[str, Field(alias="pipekitAgentVersion")]
24
23
  port: Optional[int] = None
25
24
  type: List[str]
26
- updated_at: Annotated[datetime, Field(alias="updatedAt")]
25
+ updated_at: Annotated[AwareDatetime, Field(alias="updatedAt")]
27
26
  uuid: str
28
27
  workflow_group_priorities: Annotated[Optional[Dict[str, int]], Field(alias="workflowGroupPriorities")] = None
29
28
 
@@ -43,7 +42,7 @@ class GitInfo(BaseModel):
43
42
 
44
43
  class Logs(BaseModel):
45
44
  container_name: Annotated[str, Field(alias="containerName")]
46
- created_at: Annotated[datetime, Field(alias="createdAt")]
45
+ created_at: Annotated[AwareDatetime, Field(alias="createdAt")]
47
46
  identity_uuid: Annotated[str, Field(alias="identityUUID")]
48
47
  log_type: Annotated[str, Field(alias="logType")]
49
48
  node_id: Annotated[Optional[str], Field(alias="nodeId")] = None
@@ -52,7 +51,7 @@ class Logs(BaseModel):
52
51
  pod_name: Annotated[str, Field(alias="podName")]
53
52
  run_uuid: Annotated[str, Field(alias="runUUID")]
54
53
  stream_cursor: Annotated[str, Field(alias="streamCursor")]
55
- updated_at: Annotated[datetime, Field(alias="updatedAt")]
54
+ updated_at: Annotated[AwareDatetime, Field(alias="updatedAt")]
56
55
 
57
56
 
58
57
  class Pipe(BaseModel):
@@ -77,7 +76,7 @@ class PipeRun(BaseModel):
77
76
  check_id: Annotated[Optional[int], Field(alias="checkId")] = None
78
77
  cluster_name: Annotated[Optional[str], Field(alias="clusterName")] = None
79
78
  cluster_uuid: Annotated[Optional[str], Field(alias="clusterUUID")] = None
80
- created_at: Annotated[Optional[datetime], Field(alias="createdAt")] = None
79
+ created_at: Annotated[Optional[AwareDatetime], Field(alias="createdAt")] = None
81
80
  git_info: Annotated[Optional[GitInfo], Field(alias="gitInfo")] = None
82
81
  identity_uuid: Annotated[Optional[str], Field(alias="identityUUID")] = None
83
82
  message: Optional[str] = None
@@ -92,6 +91,6 @@ class PipeRun(BaseModel):
92
91
  source_name: Annotated[Optional[str], Field(alias="sourceName")] = None
93
92
  source_type: Annotated[Optional[str], Field(alias="sourceType")] = None
94
93
  status: Optional[str] = None
95
- updated_at: Annotated[Optional[datetime], Field(alias="updatedAt")] = None
94
+ updated_at: Annotated[Optional[AwareDatetime], Field(alias="updatedAt")] = None
96
95
  user_name: Annotated[Optional[str], Field(alias="userName")] = None
97
96
  uuid: Optional[str] = None
@@ -2,10 +2,9 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from datetime import datetime
6
5
  from typing import Any, Dict, List, Optional
7
6
 
8
- from pydantic import Field
7
+ from pydantic import AwareDatetime, Field, RootModel
9
8
  from typing_extensions import Annotated
10
9
 
11
10
  from pipekit_sdk.models._config import BaseModel
@@ -276,8 +275,8 @@ class FCVolumeSource(BaseModel):
276
275
  ] = None
277
276
 
278
277
 
279
- class FieldsV1(BaseModel):
280
- __root__: Annotated[
278
+ class FieldsV1(RootModel[Any]):
279
+ root: Annotated[
281
280
  Any,
282
281
  Field(
283
282
  description=(
@@ -1317,7 +1316,7 @@ class TCPSocketAction(BaseModel):
1317
1316
 
1318
1317
 
1319
1318
  class Time(BaseModel):
1320
- time: Annotated[datetime, Field(alias="Time")]
1319
+ time: Annotated[AwareDatetime, Field(alias="Time")]
1321
1320
 
1322
1321
 
1323
1322
  class Toleration(BaseModel):
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any, Dict, List, Optional
6
6
 
7
- from pydantic import Field
7
+ from pydantic import Field, RootModel
8
8
  from typing_extensions import Annotated
9
9
 
10
10
  from pipekit_sdk.models._config import BaseModel
@@ -12,8 +12,8 @@ from pipekit_sdk.models._config import BaseModel
12
12
  from . import intstr, v1
13
13
 
14
14
 
15
- class Amount(BaseModel):
16
- __root__: Any
15
+ class Amount(RootModel[Any]):
16
+ root: Any
17
17
 
18
18
 
19
19
  class ArtGCStatus(BaseModel):
@@ -190,8 +190,8 @@ class Histogram(BaseModel):
190
190
  value: str
191
191
 
192
192
 
193
- class Item(BaseModel):
194
- __root__: Any
193
+ class Item(RootModel[Any]):
194
+ root: Any
195
195
 
196
196
 
197
197
  class LabelValueFrom(BaseModel):
@@ -244,8 +244,8 @@ class NodeSynchronizationStatus(BaseModel):
244
244
  waiting: Optional[str] = None
245
245
 
246
246
 
247
- class NoneStrategy(BaseModel):
248
- __root__: Any
247
+ class NoneStrategy(RootModel[Any]):
248
+ root: Any
249
249
 
250
250
 
251
251
  class OAuth2EndpointParam(BaseModel):
@@ -258,12 +258,12 @@ class OSSLifecycleRule(BaseModel):
258
258
  mark_infrequent_access_after_days: Annotated[Optional[int], Field(alias="markInfrequentAccessAfterDays")] = None
259
259
 
260
260
 
261
- class ParallelSteps(BaseModel):
262
- __root__: Any
261
+ class ParallelSteps(RootModel[Any]):
262
+ root: Any
263
263
 
264
264
 
265
- class Plugin(BaseModel):
266
- __root__: Any
265
+ class Plugin(RootModel[Any]):
266
+ root: Any
267
267
 
268
268
 
269
269
  class Prometheus(BaseModel):
@@ -280,8 +280,8 @@ class RawArtifact(BaseModel):
280
280
  data: str
281
281
 
282
282
 
283
- class RetryNodeAntiAffinity(BaseModel):
284
- __root__: Any
283
+ class RetryNodeAntiAffinity(RootModel[Any]):
284
+ root: Any
285
285
 
286
286
 
287
287
  class S3EncryptionOptions(BaseModel):
@@ -315,8 +315,8 @@ class Sequence(BaseModel):
315
315
  start: Optional[intstr.IntOrString] = None
316
316
 
317
317
 
318
- class SuppliedValueFrom(BaseModel):
319
- __root__: Any
318
+ class SuppliedValueFrom(RootModel[Any]):
319
+ root: Any
320
320
 
321
321
 
322
322
  class SuspendTemplate(BaseModel):
@@ -388,8 +388,8 @@ class WorkflowTemplateRef(BaseModel):
388
388
  name: Optional[str] = None
389
389
 
390
390
 
391
- class ZipStrategy(BaseModel):
392
- __root__: Any
391
+ class ZipStrategy(RootModel[Any]):
392
+ root: Any
393
393
 
394
394
 
395
395
  class ArchiveStrategy(BaseModel):
@@ -1689,7 +1689,7 @@ class WorkflowStatus(BaseModel):
1689
1689
  ] = None
1690
1690
 
1691
1691
 
1692
- CronWorkflow.update_forward_refs()
1693
- CronWorkflowSpec.update_forward_refs()
1694
- DAGTask.update_forward_refs()
1695
- Workflow.update_forward_refs()
1692
+ CronWorkflow.model_rebuild()
1693
+ CronWorkflowSpec.model_rebuild()
1694
+ DAGTask.model_rebuild()
1695
+ Workflow.model_rebuild()
@@ -2,19 +2,71 @@
2
2
 
3
3
  import json
4
4
  import os
5
- from typing import Any, Generator, Optional, cast
5
+ from datetime import datetime
6
+ from enum import Enum
7
+ from pathlib import Path
8
+ from typing import Any, Generator, List, Optional, cast
6
9
 
10
+ import jwt
7
11
  import requests
8
12
  from hera.workflows import CronWorkflow, Workflow
9
13
  from hera.workflows.models import WorkflowCreateRequest
14
+ from pydantic import BaseModel, ValidationError
10
15
 
11
16
  from pipekit_sdk._helpers import is_valid_uuid
12
17
  from pipekit_sdk.models.model import Cluster, Logs, Pipe, PipeRun
13
18
 
14
19
 
20
+ class _LoginResponse(BaseModel):
21
+ """LoginResponse is a model for Login responses."""
22
+
23
+ token_type: str
24
+ access_token: str
25
+ expires_in: int
26
+ refresh_token: str
27
+
28
+
29
+ class _AccessToken(BaseModel):
30
+ """AccessToken is a model for decoded access tokens."""
31
+
32
+ identityUUID: str
33
+ ClusterUUID: str
34
+ OrgUUID: str
35
+ isActive: bool
36
+ products: List[str]
37
+ iss: str
38
+ sub: str
39
+ exp: int
40
+
41
+
42
+ class _ValidationResult(Enum):
43
+ OK = 0
44
+ INVALID_TOKEN = 1
45
+ EXPIRED_TOKEN = 2
46
+
47
+
15
48
  class PipekitService:
16
49
  """PipekitService is a wrapper around the Pipekit API. It provides a simple interface to interact with Pipekit."""
17
50
 
51
+ @staticmethod
52
+ def __from_home_dir() -> Optional[_LoginResponse]:
53
+ home = Path.home()
54
+ pipekit_token_file_path = os.path.join(home, ".pipekit", "token")
55
+ try:
56
+ with open(pipekit_token_file_path, "r") as f:
57
+ raw_data = f.read()
58
+ return _LoginResponse.model_validate_json(raw_data)
59
+ except ValidationError:
60
+ return None
61
+
62
+ @staticmethod
63
+ def __write_home_dir(login_resp: _LoginResponse):
64
+ home = Path.home()
65
+ pipekit_token_file_path = os.path.join(home, ".pipekit", "token")
66
+ with open(pipekit_token_file_path, "w") as f:
67
+ payload = login_resp.model_dump_json(indent=2)
68
+ f.write(payload)
69
+
18
70
  def __init__(
19
71
  self,
20
72
  username: str = "",
@@ -38,8 +90,60 @@ class PipekitService:
38
90
  self.access_token = token
39
91
  return
40
92
 
93
+ maybe_resp = self.__from_home_dir()
94
+ self.token_data = maybe_resp
95
+
96
+ if self.token_data is not None:
97
+ self.__token_login_flow()
98
+ return
99
+
41
100
  self.__login()
42
101
 
102
+ def __update_token(self) -> None:
103
+ login_url = f"{self.id_url}/api/id/v1/oauth/token"
104
+ assert self.token_data is not None, "Token data was None, this implies a bug"
105
+ login_request = {"grant_type": "refresh_token", "refresh_token": self.token_data.refresh_token}
106
+ login_response = requests.post(
107
+ login_url,
108
+ data=json.dumps(login_request),
109
+ headers={"Content-Type": "application/json"},
110
+ timeout=10,
111
+ )
112
+ if login_response.status_code // 100 != 2:
113
+ raise Exception(
114
+ "Couldn't refresh access token, obtained non 2xx status code, try logging in again via the cli"
115
+ )
116
+
117
+ login_response_model = _LoginResponse.model_validate(login_response.json())
118
+ self.__write_home_dir(login_response_model)
119
+ self.access_token = login_response_model.access_token
120
+
121
+ def __validate_token(self) -> _ValidationResult:
122
+ assert self.token_data is not None
123
+ try:
124
+ decoded_payload = jwt.decode(self.token_data.access_token, options={"verify_signature": False})
125
+ acc_tk = _AccessToken.model_validate(decoded_payload)
126
+ exp_time = datetime.fromtimestamp(acc_tk.exp)
127
+ now_time = datetime.now()
128
+
129
+ if exp_time <= now_time:
130
+ return _ValidationResult.EXPIRED_TOKEN
131
+ except jwt.PyJWTError:
132
+ return _ValidationResult.INVALID_TOKEN
133
+ return _ValidationResult.OK
134
+
135
+ def __token_login_flow(self) -> None:
136
+ assert self.token_data is not None, "token was None when it was expected to be valid"
137
+ res = self.__validate_token()
138
+ if res == _ValidationResult.EXPIRED_TOKEN:
139
+ self.__update_token()
140
+ elif res == _ValidationResult.INVALID_TOKEN:
141
+ raise RuntimeError(
142
+ "Token was invalid, if the `.pipekit/token` file is corrupted, please delete the file and login again"
143
+ )
144
+ else:
145
+ self.access_token = self.token_data.access_token
146
+
43
147
  def __login(self) -> None:
44
148
  login_request = {
45
149
  "username": self.username,
@@ -80,7 +184,7 @@ class PipekitService:
80
184
  if clusters_response.status_code // 100 != 2:
81
185
  raise Exception(f"List clusters failed: {clusters_response.text}")
82
186
 
83
- clusters = [Cluster.parse_obj(cluster) for cluster in clusters_response.json()]
187
+ clusters = [Cluster.model_validate(cluster) for cluster in clusters_response.json()]
84
188
  return clusters
85
189
 
86
190
  def list_pipes(
@@ -107,7 +211,7 @@ class PipekitService:
107
211
  if pipes_response.status_code // 100 != 2:
108
212
  raise Exception(f"List pipes failed: {pipes_response.text}")
109
213
 
110
- pipes_page = [Pipe.parse_obj(item) for item in pipes_response.json()["items"]]
214
+ pipes_page = [Pipe.model_validate(item) for item in pipes_response.json()["items"]]
111
215
  if len(pipes_page) == 0:
112
216
  break
113
217
 
@@ -135,7 +239,7 @@ class PipekitService:
135
239
  if pipe_response.status_code // 100 != 2:
136
240
  raise Exception(f"Get pipe failed: {pipe_response.text}")
137
241
 
138
- return Pipe.parse_obj(pipe_response.json())
242
+ return Pipe.model_validate(pipe_response.json())
139
243
 
140
244
  def list_runs_by_cluster(
141
245
  self,
@@ -160,7 +264,7 @@ class PipekitService:
160
264
  if runs_response.status_code // 100 != 2:
161
265
  raise Exception(f"List runs failed: {runs_response.text}")
162
266
 
163
- runs_page = [PipeRun.parse_obj(item) for item in runs_response.json()["items"]]
267
+ runs_page = [PipeRun.model_validate(item) for item in runs_response.json()["items"]]
164
268
  if len(runs_page) == 0:
165
269
  break
166
270
 
@@ -196,7 +300,7 @@ class PipekitService:
196
300
  if runs_response.status_code // 100 != 2:
197
301
  raise Exception(f"List runs failed: {runs_response.text}")
198
302
 
199
- runs_page = [PipeRun.parse_obj(item) for item in runs_response.json()["items"]]
303
+ runs_page = [PipeRun.model_validate(item) for item in runs_response.json()["items"]]
200
304
  if len(runs_page) == 0:
201
305
  break
202
306
 
@@ -224,7 +328,7 @@ class PipekitService:
224
328
  if run_response.status_code // 100 != 2:
225
329
  raise Exception(f"Get run failed: {run_response.text}")
226
330
 
227
- return PipeRun.parse_obj(run_response.json())
331
+ return PipeRun.model_validate(run_response.json())
228
332
 
229
333
  def get_cluster(
230
334
  self,
@@ -241,7 +345,7 @@ class PipekitService:
241
345
  if cluster_response.status_code // 100 != 2:
242
346
  raise Exception(f"Get cluster failed: {cluster_response.text}")
243
347
 
244
- return Cluster.parse_obj(cluster_response.json())
348
+ return Cluster.model_validate(cluster_response.json())
245
349
 
246
350
  def get_cluster_by_name(
247
351
  self,
@@ -293,7 +397,7 @@ class PipekitService:
293
397
  if submit_response.status_code // 100 != 2:
294
398
  raise Exception(f"Submit failed: {submit_response.text}")
295
399
 
296
- return PipeRun.parse_obj(submit_response.json())
400
+ return PipeRun.model_validate(submit_response.json())
297
401
 
298
402
  def create(self, cron_workflow: CronWorkflow, cluster: str, pipe_name: str = "") -> PipeRun:
299
403
  """Create a CronWorkflow on the cluster, under an existing or new pipe with the given name."""
@@ -328,7 +432,7 @@ class PipekitService:
328
432
  if create_response.status_code // 100 != 2:
329
433
  raise Exception(f"Create failed: {create_response.text}")
330
434
 
331
- return PipeRun.parse_obj(create_response.json())
435
+ return PipeRun.model_validate(create_response.json())
332
436
 
333
437
  def __apply_run_action(
334
438
  self,
@@ -348,7 +452,7 @@ class PipekitService:
348
452
  if action_response.status_code // 100 != 2:
349
453
  raise Exception(f"Action failed: {action_response.text}")
350
454
 
351
- return PipeRun.parse_obj(action_response.json())
455
+ return PipeRun.model_validate(action_response.json())
352
456
 
353
457
  def restart(
354
458
  self,
@@ -388,7 +492,7 @@ class PipekitService:
388
492
  if logs_response.status_code // 100 != 2:
389
493
  raise Exception(f"Get logs failed: {logs_response.text}")
390
494
 
391
- return [Logs.parse_obj(log) for log in list(logs_response.json())]
495
+ return [Logs.model_validate(log) for log in list(logs_response.json())]
392
496
 
393
497
  def __get_logs_stream(
394
498
  self,
@@ -464,7 +568,7 @@ class PipekitService:
464
568
  # bye
465
569
  return
466
570
  else:
467
- yield Logs.parse_obj(json.loads(line))
571
+ yield Logs.model_validate(json.loads(line))
468
572
 
469
573
  except requests.exceptions.Timeout:
470
574
  pass # we'll ignore timeout errors and reconnect
@@ -486,3 +590,7 @@ class PipekitService:
486
590
 
487
591
  for log in self.get_logs(run_uuid, pod_name=pod_name, container_name=container_name):
488
592
  self.__print_log_message(log)
593
+
594
+
595
+ if __name__ == "__main__":
596
+ p = PipekitService()
@@ -1,15 +0,0 @@
1
- from pydantic.v1 import BaseModel as PydanticBaseModel
2
-
3
-
4
- class BaseModel(PydanticBaseModel):
5
- class Config:
6
- """General config for generated models."""
7
-
8
- allow_population_by_field_name = True
9
- """support populating object by Field alias"""
10
-
11
- allow_mutation = True
12
- """allow mutation of objects post instantiation"""
13
-
14
- use_enum_values = True
15
- """supports using enums, which are then unpacked to obtain the actual `.value`"""
File without changes
File without changes