sfapi-client 0.4.0__tar.gz → 0.5.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 (98) hide show
  1. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/.github/workflows/black.yml +2 -2
  2. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/PKG-INFO +2 -2
  3. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/quickstart/index.md +2 -4
  4. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/pyproject.toml +1 -1
  5. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/scripts/run.py +7 -3
  6. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/__init__.py +1 -0
  7. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_async/client.py +15 -2
  8. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_async/compute.py +110 -36
  9. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_async/groups.py +2 -14
  10. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_async/paths.py +38 -13
  11. sfapi_client-0.5.0/src/sfapi_client/_async/storage.py +210 -0
  12. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_async/users.py +6 -16
  13. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_compute.py +7 -0
  14. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_models/__init__.py +31 -13
  15. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_sync/client.py +15 -2
  16. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_sync/compute.py +110 -35
  17. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_sync/groups.py +2 -14
  18. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_sync/paths.py +38 -13
  19. sfapi_client-0.5.0/src/sfapi_client/_sync/storage.py +210 -0
  20. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_sync/users.py +6 -16
  21. sfapi_client-0.5.0/src/sfapi_client/_utils.py +30 -0
  22. sfapi_client-0.5.0/src/sfapi_client/compute.py +8 -0
  23. sfapi_client-0.5.0/src/sfapi_client/storage.py +2 -0
  24. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/conftest.py +4 -7
  25. sfapi_client-0.5.0/tests/test_compute.py +196 -0
  26. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_compute_async.py +104 -0
  27. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_paths_async.py +51 -0
  28. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_resources.py +2 -6
  29. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_resources_async.py +2 -6
  30. sfapi_client-0.5.0/tests/test_storage.py +44 -0
  31. sfapi_client-0.5.0/tests/test_storage_async.py +46 -0
  32. sfapi_client-0.4.0/src/sfapi_client/_utils.py +0 -6
  33. sfapi_client-0.4.0/src/sfapi_client/compute.py +0 -3
  34. sfapi_client-0.4.0/tests/test_compute.py +0 -99
  35. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/.github/dependabot.yml +0 -0
  36. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/.github/workflows/mkdocs.yml +0 -0
  37. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/.github/workflows/pypi.yml +0 -0
  38. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/.github/workflows/pytest.yml +0 -0
  39. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/.github/workflows/ruff.yml +0 -0
  40. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/.gitignore +0 -0
  41. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/.pre-commit-config.yaml +0 -0
  42. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/LEGAL +0 -0
  43. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/LICENSE +0 -0
  44. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/README.md +0 -0
  45. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/community/contributing.md +0 -0
  46. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/examples/check_current_and_past_jobs.ipynb +0 -0
  47. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/examples/getting_bearer_token.ipynb +0 -0
  48. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/examples/run_job_and_check_status.ipynb +0 -0
  49. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/gen_examples.py +0 -0
  50. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/gen_sync.py +0 -0
  51. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/index.md +0 -0
  52. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/javascript/apiselector.js +0 -0
  53. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/reference/SUMMARY.txt +0 -0
  54. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/reference/async/client/index.md +0 -0
  55. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/reference/async/compute/index.md +0 -0
  56. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/reference/async/exceptions/index.md +0 -0
  57. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/reference/async/groups/index.md +0 -0
  58. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/reference/async/jobs/index.md +0 -0
  59. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/reference/async/paths/index.md +0 -0
  60. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/reference/async/projects/index.md +0 -0
  61. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/reference/async/resources/index.md +0 -0
  62. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/docs/reference/async/users/index.md +0 -0
  63. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/mkdocs.yml +0 -0
  64. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/pytest.ini +0 -0
  65. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/requirements-dev.txt +0 -0
  66. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_async/__init__.py +0 -0
  67. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_async/jobs.py +0 -0
  68. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_async/projects.py +0 -0
  69. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_jobs.py +0 -0
  70. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_models/job_status_response_sacct.py +0 -0
  71. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_models/job_status_response_squeue.py +0 -0
  72. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_models/resources.py +0 -0
  73. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_monitor.py +0 -0
  74. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_sync/__init__.py +0 -0
  75. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_sync/jobs.py +0 -0
  76. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/_sync/projects.py +0 -0
  77. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/client.py +0 -0
  78. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/exceptions.py +0 -0
  79. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/groups.py +0 -0
  80. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/jobs.py +0 -0
  81. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/paths.py +0 -0
  82. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/projects.py +0 -0
  83. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/resources.py +0 -0
  84. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/src/sfapi_client/users.py +0 -0
  85. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_api.py +0 -0
  86. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_api_async.py +0 -0
  87. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_client.py +0 -0
  88. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_client_async.py +0 -0
  89. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_groups.py +0 -0
  90. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_groups_async.py +0 -0
  91. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_jobs.py +0 -0
  92. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_jobs_async.py +0 -0
  93. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_key.py +0 -0
  94. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_paths.py +0 -0
  95. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_projects.py +0 -0
  96. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_projects_async.py +0 -0
  97. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_users.py +0 -0
  98. {sfapi_client-0.4.0 → sfapi_client-0.5.0}/tests/test_users_async.py +0 -0
@@ -11,7 +11,7 @@ jobs:
11
11
  runs-on: ubuntu-latest
12
12
  steps:
13
13
  - uses: actions/checkout@v4
14
- - uses: psf/black@23.9.1
14
+ - uses: psf/black@26.3.0
15
15
  with:
16
16
  options: "--check --verbose"
17
- jupyter: false
17
+ jupyter: false
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sfapi_client
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: Python client for NERSC SF API
5
5
  Author-email: Chris Harris <cjh@lbl.gov>, Nicholas Tyler <tylern@lbl.gov>
6
6
  License-File: LICENSE
7
7
  Classifier: License :: OSI Approved :: BSD License
8
8
  Classifier: Operating System :: OS Independent
9
9
  Classifier: Programming Language :: Python :: 3
10
- Requires-Python: >=3.7
10
+ Requires-Python: >=3.8
11
11
  Requires-Dist: authlib
12
12
  Requires-Dist: httpx
13
13
  Requires-Dist: pydantic-settings
@@ -95,13 +95,12 @@ export SFAPI_SECRET='{"kty": "RSA", "n": ...}'
95
95
  ```pycon
96
96
  >>> from sfapi_client import AsyncClient
97
97
  >>> from sfapi_client.compute import Machine
98
- >>> from authlib.jose import JsonWebKey
99
98
  >>> import json
100
99
  >>> import os
101
100
  >>>
102
101
  >>> client_id = os.getenv("SFAPI_CLIENT_ID")
103
102
  >>> sfapi_secret = os.getenv("SFAPI_SECRET")
104
- >>> client_secret = JsonWebKey.import_key(json.loads(sfapi_secret))
103
+ >>> client_secret = json.loads(sfapi_secret)
105
104
  >>>
106
105
  >>> async with AsyncClient(client_id, client_secret) as client:
107
106
  ... perlmutter = await client.compute(Machine.perlmutter)
@@ -110,13 +109,12 @@ export SFAPI_SECRET='{"kty": "RSA", "n": ...}'
110
109
  ```pycon
111
110
  >>> from sfapi_client import Client
112
111
  >>> from sfapi_client.compute import Machine
113
- >>> from authlib.jose import JsonWebKey
114
112
  >>> import json
115
113
  >>> import os
116
114
  >>>
117
115
  >>> client_id = os.getenv("SFAPI_CLIENT_ID")
118
116
  >>> sfapi_secret = os.getenv("SFAPI_SECRET")
119
- >>> client_secret = JsonWebKey.import_key(json.loads(sfapi_secret))
117
+ >>> client_secret = json.loads(sfapi_secret)
120
118
  >>>
121
119
  >>> with Client(client_id, client_secret) as client:
122
120
  ... perlmutter = client.compute(Machine.perlmutter)
@@ -14,7 +14,7 @@ authors = [
14
14
  ]
15
15
  description = "Python client for NERSC SF API"
16
16
  readme = "README.md"
17
- requires-python = ">=3.7"
17
+ requires-python = ">=3.8"
18
18
  dependencies = [
19
19
  "authlib",
20
20
  "httpx",
@@ -10,7 +10,6 @@ from typing import List, Optional
10
10
 
11
11
  from datamodel_code_generator import InputFileType, generate
12
12
 
13
-
14
13
  cli = typer.Typer()
15
14
 
16
15
 
@@ -24,6 +23,8 @@ def run_unasync():
24
23
  "AsyncApi": "Api",
25
24
  "AsyncResources": "Resources",
26
25
  "AsyncCompute": "Compute",
26
+ "AsyncCommandTask": "CommandTask",
27
+ "AsyncCommandTaskResult": "CommandTaskResult",
27
28
  "AsyncGroup": "Group",
28
29
  "AsyncGroupMember": "GroupMember",
29
30
  "AsyncRemotePath": "RemotePath",
@@ -34,6 +35,9 @@ def run_unasync():
34
35
  "AsyncJobSacct": "JobSacct",
35
36
  "AsyncJobSqueue": "JobSqueue",
36
37
  "AsyncOAuth2Client": "OAuth2Client",
38
+ "AsyncStorage": "Storage",
39
+ "AsyncGlobusStorage": "GlobusStorage",
40
+ "AsyncGlobusTransfer": "GlobusTransfer",
37
41
  "aclose": "close",
38
42
  "_ASYNC_SLEEP": "_SLEEP",
39
43
  }
@@ -100,7 +104,7 @@ def _to_str_enum(code: str) -> str:
100
104
  def _fix_date_import(code: str) -> str:
101
105
  replacements = {
102
106
  "import date": "import date as date_",
103
- "date: Optional\[date\]": "date: Optional[date_]",
107
+ "date: Optional\\[date\\]": "date: Optional[date_]",
104
108
  }
105
109
 
106
110
  for target, replacement in replacements.items():
@@ -193,7 +197,7 @@ def resources_codegen(
193
197
  name = name if name != "int" else f"_{name}"
194
198
  name = name.replace("-", "_")
195
199
  names.append(f' {name} = "{value}"')
196
- names.append(f" \"\"\" {s['system_type']}: {s['full_name']}\"\"\"")
200
+ names.append(f' """ {s["system_type"]}: {s["full_name"]}"""')
197
201
 
198
202
  resources = "\n".join(names)
199
203
  now = datetime.datetime.now()
@@ -1,5 +1,6 @@
1
1
  from .client import AsyncClient # noqa: F401
2
2
  from .client import Client # noqa: F401
3
3
  from .exceptions import SfApiError # noqa: F401
4
+ from ._models import OutageStatusValue # noqa: F401
4
5
  from ._models import StatusValue # noqa: F401
5
6
  from ._models.resources import Resource # noqa: F401
@@ -7,7 +7,6 @@ from authlib.integrations.httpx_client.oauth2_client import AsyncOAuth2Client
7
7
  from authlib.oauth2.rfc7523 import PrivateKeyJWT
8
8
  import httpx
9
9
  import tenacity
10
- from authlib.jose import JsonWebKey
11
10
 
12
11
  from .compute import Machine, AsyncCompute
13
12
  from ..exceptions import ClientKeyError
@@ -21,6 +20,7 @@ from .._models import (
21
20
  from .._models.resources import Resource
22
21
  from .groups import AsyncGroup, AsyncGroupMember
23
22
  from .users import AsyncUser
23
+ from .storage import AsyncGlobusStorage, AsyncStorage
24
24
  from .projects import AsyncProject, AsyncRole
25
25
  from .paths import AsyncRemotePath
26
26
 
@@ -252,6 +252,7 @@ class AsyncClient:
252
252
  self.__http_client = None
253
253
  self._api = None
254
254
  self._resources = None
255
+ self._storage = None
255
256
  self._wait_interval = wait_interval
256
257
  self._access_token = access_token
257
258
 
@@ -350,7 +351,7 @@ class AsyncClient:
350
351
  if key_path.suffix == ".json":
351
352
  # Json file in the format {"client_id": "", "secret": ""}
352
353
  json_web_key = json.loads(secret.read())
353
- self._secret = JsonWebKey.import_key(json_web_key["secret"])
354
+ self._secret = json_web_key["secret"]
354
355
  self._client_id = json_web_key["client_id"]
355
356
  else:
356
357
  self._secret = secret.read()
@@ -504,8 +505,19 @@ class AsyncClient:
504
505
 
505
506
  return self._resources
506
507
 
508
+ @property
509
+ def storage(self) -> AsyncStorage:
510
+ """Storage related objects and methods
511
+
512
+ :return AsyncStorage: Object with storage related objects and methods
513
+ """
514
+ if self._storage is None:
515
+ self._storage = AsyncStorage(self)
516
+ return self._storage
517
+
507
518
 
508
519
  AsyncCompute.model_rebuild()
520
+ AsyncGlobusStorage.model_rebuild()
509
521
  AsyncGroup.model_rebuild()
510
522
  AsyncUser.model_rebuild()
511
523
  AsyncProject.model_rebuild()
@@ -513,6 +525,7 @@ AsyncRemotePath.model_rebuild()
513
525
  AsyncRole.model_rebuild()
514
526
  AsyncGroupMember.model_rebuild()
515
527
 
528
+
516
529
  # Ensure that the job models are built, we need to import here to
517
530
  # avoid circular imports
518
531
  from .jobs import AsyncJobSacct, AsyncJobSqueue # noqa: E402
@@ -1,13 +1,11 @@
1
- from typing import Dict, List, Optional, Union, Callable
2
- import json
3
- from pydantic import PrivateAttr, ConfigDict
1
+ from typing import Dict, List, Optional, Union
2
+ from pydantic import PrivateAttr, ConfigDict, BaseModel
4
3
  from ..exceptions import SfApiError
5
4
  from .._utils import _ASYNC_SLEEP
6
5
  from .jobs import AsyncJobSacct, AsyncJobSqueue, JobCommand
7
6
  from .._models import (
8
7
  AppRoutersStatusModelsStatus as ComputeBase,
9
- Task,
10
- StatusValue,
8
+ Task as TaskResponse,
11
9
  PublicHost as Machine,
12
10
  BodyRunCommandUtilitiesCommandMachinePost as RunCommandBody,
13
11
  AppRoutersComputeModelsCommandOutput as RunCommandResponse,
@@ -15,25 +13,85 @@ from .._models import (
15
13
  )
16
14
  from .paths import AsyncRemotePath
17
15
  from .._monitor import AsyncJobMonitor
18
- from .._compute import CommandResult, SubmitJobResponse, SubmitJobResponseStatus
16
+ from .._compute import SubmitJobResponse, SubmitJobResponseStatus, TaskStatus
17
+ from .._utils import check_auth
19
18
 
20
19
  # Patch to return str names from Enum of py3.11
21
20
  Machine.__str__ = lambda self: self.value
22
21
 
23
22
 
24
- def check_auth(method: Callable):
25
- def wrapper(self, *args, **kwargs):
26
- if self.client._client_id is None and self.client._access_token is None:
27
- raise SfApiError(
28
- f"Cannot call {self.__class__.__name__}.{method.__name__}() with an unauthenticated client." # noqa: E501
29
- )
30
- elif self.status in [StatusValue.unavailable]:
31
- raise SfApiError(
32
- f"Compute resource {self.name} is {self.status.name}, {self.notes}"
33
- )
34
- return method(self, *args, **kwargs)
23
+ class AsyncCommandTaskResult(BaseModel):
24
+ """
25
+ Result data returned by a command execution task.
26
+ """
27
+
28
+ model_config = ConfigDict(extra="allow")
29
+
30
+ status: Optional[str] = None
31
+ output: Optional[str] = None
32
+ error: Optional[str] = None
33
+ exit_code: Optional[int] = None
34
+
35
35
 
36
- return wrapper
36
+ class AsyncCommandTask(BaseModel):
37
+ """
38
+ Models an asynchronous command execution task.
39
+ """
40
+
41
+ compute: "AsyncCompute"
42
+ id: str
43
+ status: Optional[TaskStatus] = None
44
+ result: Optional[AsyncCommandTaskResult] = None
45
+
46
+ async def _fetch(self) -> TaskResponse:
47
+ """
48
+ Fetch the latest task state from the API.
49
+
50
+ :return: The raw task response returned by the API.
51
+ """
52
+ r = await self.compute.client.get(f"tasks/{self.id}")
53
+ json_response = r.json()
54
+
55
+ return TaskResponse.model_validate(json_response)
56
+
57
+ async def update(self):
58
+ """
59
+ Refresh the task state.
60
+
61
+ :return: The updated task instance.
62
+ """
63
+ task = await self._fetch()
64
+ if task.status is not None:
65
+ # Map the status return by the API (lowercase string) to our TaskStatus enum ( uppercase )
66
+ # We do this for consistency as all our enums are uppercase
67
+ self.status = TaskStatus[task.status.upper()]
68
+
69
+ if task.result is not None:
70
+ self.result = AsyncCommandTaskResult.model_validate_json(task.result)
71
+
72
+ return self
73
+
74
+ async def _wait(self):
75
+ while True:
76
+ await self.update()
77
+
78
+ if self.status in [
79
+ TaskStatus.COMPLETED,
80
+ TaskStatus.CANCELLED,
81
+ TaskStatus.FAILED,
82
+ ]:
83
+ return self
84
+
85
+ await _ASYNC_SLEEP(1)
86
+
87
+ def __await__(self):
88
+ return self._wait().__await__()
89
+
90
+ async def cancel(self):
91
+ """
92
+ Cancel the running task.
93
+ """
94
+ await self.compute.client.delete(f"tasks/{self.id}")
37
95
 
38
96
 
39
97
  class AsyncCompute(ComputeBase):
@@ -51,18 +109,17 @@ class AsyncCompute(ComputeBase):
51
109
  kwargs["exclude"] = {"client"}
52
110
  return super().dict(*args, **kwargs)
53
111
 
54
- async def _wait_for_task(self, task_id) -> str:
55
- while True:
56
- r = await self.client.get(f"tasks/{task_id}")
112
+ async def _wait_for_task(self, task_id) -> AsyncCommandTaskResult:
113
+
114
+ task = AsyncCommandTask(compute=self, id=task_id)
57
115
 
58
- json_response = r.json()
59
- task = Task.model_validate(json_response)
116
+ while True:
117
+ await task.update()
60
118
 
61
- status = task.status.lower()
62
- if status in ["error", "failed"]:
119
+ if task.status is TaskStatus.FAILED:
63
120
  raise SfApiError(task.result)
64
121
 
65
- if status not in ["completed", "cancelled"]:
122
+ if task.status not in [TaskStatus.COMPLETED, TaskStatus.CANCELLED]:
66
123
  await _ASYNC_SLEEP(1)
67
124
  continue
68
125
 
@@ -107,11 +164,11 @@ class AsyncCompute(ComputeBase):
107
164
 
108
165
  # We now need waiting for the task to complete!
109
166
  task_result = await self._wait_for_task(task_id)
110
- result = json.loads(task_result)
111
- if result.get("status") == "error":
112
- raise SfApiError(result["error"])
167
+ if task_result.status == "error":
168
+ raise SfApiError(task_result.error)
113
169
 
114
- jobid = result.get("jobid")
170
+ # Get the jobid from the extra fields of the result
171
+ jobid = task_result.__pydantic_extra__.get("jobid")
115
172
  if jobid is None:
116
173
  raise SfApiError(f"Unable to extract jobid if for task: {task_id}")
117
174
 
@@ -155,6 +212,17 @@ class AsyncCompute(ComputeBase):
155
212
 
156
213
  @check_auth
157
214
  async def run(self, args: Union[str, AsyncRemotePath, List[str]]):
215
+ task = await self._run_task(args)
216
+ await task._wait()
217
+
218
+ if task.result.error:
219
+ raise SfApiError(task.result.error)
220
+
221
+ return task.result.output
222
+
223
+ async def _run_task(
224
+ self, args: Union[str, AsyncRemotePath, List[str]]
225
+ ) -> AsyncCommandTask:
158
226
  body: RunCommandBody = {
159
227
  "executable": args if not isinstance(args, list) else " ".join(args)
160
228
  }
@@ -165,13 +233,19 @@ class AsyncCompute(ComputeBase):
165
233
  if run_response.status == RunCommandResponseStatus.ERROR:
166
234
  raise SfApiError(run_response.error)
167
235
 
168
- task_id = run_response.task_id
169
- task_result = await self._wait_for_task(task_id)
170
- command_result = CommandResult.model_validate_json(task_result)
171
- if command_result.status == "error":
172
- raise SfApiError(command_result.error)
236
+ return AsyncCommandTask(compute=self, id=run_response.task_id)
173
237
 
174
- return command_result.output
238
+ @check_auth
239
+ async def run_task(
240
+ self, args: Union[str, AsyncRemotePath, List[str]]
241
+ ) -> AsyncCommandTask:
242
+ """
243
+ Submit a command to the compute resource and return a task.
244
+
245
+ :param args: Command to execute.
246
+ :return: Task object that can be awaited ( async version ), updated, or cancelled.
247
+ """
248
+ return await self._run_task(args)
175
249
 
176
250
  async def outages(self):
177
251
  return await self.client.resources.outages(self.name)
@@ -1,21 +1,9 @@
1
- from typing import Optional, Union, List, Any, Callable
2
- from functools import wraps
1
+ from typing import Optional, Union, List, Any
3
2
  from pydantic import ValidationError, Field, BaseModel, ConfigDict
4
3
  from .._models import BatchGroupAction as GroupAction, UserStats as GroupMemberBase
5
4
  from ..exceptions import SfApiError
6
5
  from .users import AsyncUser
7
-
8
-
9
- def check_auth(method: Callable):
10
- @wraps(method)
11
- def wrapper(self, *args, **kwargs):
12
- if self._client_id is None:
13
- raise SfApiError(
14
- f"Cannot call {self.__class__.__name__}.{method.__name__}() with an unauthenticated client." # noqa: E501
15
- )
16
- return method(self, *args, **kwargs)
17
-
18
- return wrapper
6
+ from .._utils import check_auth
19
7
 
20
8
 
21
9
  class AsyncGroupMember(GroupMemberBase):
@@ -5,6 +5,7 @@ from io import StringIO, BytesIO
5
5
  from base64 import b64decode
6
6
  from contextlib import asynccontextmanager
7
7
  import tempfile
8
+ import re
8
9
 
9
10
  from .._models import (
10
11
  DirectoryEntry as PathBase,
@@ -22,6 +23,9 @@ def _is_no_such(error: SfApiError):
22
23
  return "No such file or directory" in error.message
23
24
 
24
25
 
26
+ SYMLINK_REGEX = r"^(\/.*) ->"
27
+
28
+
25
29
  class AsyncRemotePath(PathBase):
26
30
  """
27
31
  RemotePath is used to model a remote path, it takes inspiration from
@@ -133,7 +137,16 @@ class AsyncRemotePath(PathBase):
133
137
  """
134
138
  :return: Returns True if path is a file, False otherwise.
135
139
  """
136
- return not await self.is_dir()
140
+ if self.perms is None:
141
+ await self.update()
142
+
143
+ return self.perms[0] == "-"
144
+
145
+ async def is_symlink(self) -> bool:
146
+ if self.perms is None:
147
+ await self.update()
148
+
149
+ return self.perms[0] == "l"
137
150
 
138
151
  async def download(self, binary=False) -> IO[AnyStr]:
139
152
  """
@@ -148,7 +161,8 @@ class AsyncRemotePath(PathBase):
148
161
  raise IsADirectoryError(self._path)
149
162
 
150
163
  r = await self.compute.client.get(
151
- f"utilities/download/{self.compute.name}/{self._path}?binary={binary}"
164
+ f"utilities/download/{self.compute.name}/{self._path}",
165
+ params={"binary": binary},
152
166
  )
153
167
  json_response = r.json()
154
168
  download_response = FileDownloadResponse.model_validate(json_response)
@@ -188,15 +202,22 @@ class AsyncRemotePath(PathBase):
188
202
 
189
203
  return p
190
204
 
191
- # Special case for listing file
205
+ # Special case for listing file or symlink
192
206
  if len(directory_listing_response.entries) == 1:
193
207
  entry = directory_listing_response.entries[0]
194
- # The API can add an extra /
195
- path = entry.name
196
- if entry.name.startswith("//"):
197
- path = path[1:]
198
- filename = PurePosixPath(path).name
199
- entry.name = filename
208
+ # symlink
209
+ if entry.perms[0] == "l":
210
+ if match := re.search(SYMLINK_REGEX, entry.name):
211
+ entry.name = match.group(1)
212
+ # file
213
+ else:
214
+ # The API can add an extra /
215
+ path = entry.name
216
+ if entry.name.startswith("//"):
217
+ path = path[1:]
218
+ filename = PurePosixPath(path).name
219
+ entry.name = filename
220
+
200
221
  paths.append(_to_remote_path(path, entry))
201
222
  else:
202
223
  for entry in directory_listing_response.entries:
@@ -209,6 +230,10 @@ class AsyncRemotePath(PathBase):
209
230
  elif directory and entry.name == ".":
210
231
  entry.name = PurePosixPath(path).name
211
232
  return [_to_remote_path(path, entry)]
233
+ # Special case for symlink
234
+ elif entry.perms[0] == "l":
235
+ if match := re.search(SYMLINK_REGEX, entry.name):
236
+ entry.name = match.group(1)
212
237
 
213
238
  paths.append(_to_remote_path(f"{path}/{entry.name}", entry))
214
239
 
@@ -260,9 +285,9 @@ class AsyncRemotePath(PathBase):
260
285
  if not _is_no_such(ex):
261
286
  raise
262
287
 
263
- # Check if the parent is a directory ( as in we are creating a new file ),
288
+ # Check if the parent is a directory or symlink ( as in we are creating a new file ),
264
289
  # if not re raise the original exception
265
- if not await self.parent.is_dir():
290
+ if not (await self.parent.is_dir() or await self.parent.is_symlink()):
266
291
  raise
267
292
  else:
268
293
  upload_path = str(self._path)
@@ -301,9 +326,9 @@ class AsyncRemotePath(PathBase):
301
326
  if not _is_no_such(ex):
302
327
  raise
303
328
 
304
- # Check if the parent is a directory ( as in we are creating a new file ),
329
+ # Check if the parent is a directory or symlink ( as in we are creating a new file ),
305
330
  # if not re raise the original exception
306
- if not await self.parent.is_dir():
331
+ if not (await self.parent.is_dir() or await self.parent.is_symlink()):
307
332
  raise
308
333
 
309
334
  valid_modes_chars = set("rwb")