aind-codeocean-dispatch 0.2.0__tar.gz → 0.2.2__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 (24) hide show
  1. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/CHANGELOG.md +12 -0
  2. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/PKG-INFO +2 -2
  3. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/pyproject.toml +2 -2
  4. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/src/aind_codeocean_dispatch/runner.py +30 -9
  5. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/tests/fakes.py +19 -7
  6. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/tests/test_runner.py +34 -2
  7. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/uv.lock +2 -2
  8. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/.copier-answers.yml +0 -0
  9. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  10. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  11. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/.github/ISSUE_TEMPLATE/user-story.md +0 -0
  12. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/.github/dependabot.yml +0 -0
  13. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/.github/workflows/bump-call.yml +0 -0
  14. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/.github/workflows/ci-call.yml +0 -0
  15. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/.github/workflows/publish-call.yml +0 -0
  16. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/.gitignore +0 -0
  17. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/.python-version +0 -0
  18. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/CLAUDE.md +0 -0
  19. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/LICENSE +0 -0
  20. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/README.md +0 -0
  21. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/scripts/run_linters_and_checks.sh +0 -0
  22. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/src/aind_codeocean_dispatch/__init__.py +0 -0
  23. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/src/aind_codeocean_dispatch/py.typed +0 -0
  24. {aind_codeocean_dispatch-0.2.0 → aind_codeocean_dispatch-0.2.2}/tests/__init__.py +0 -0
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## v0.2.2 (2026-10-15)
9
+
10
+ ### Fix
11
+
12
+ - run on a non-retrying copy of the caller's client
13
+
14
+ ## v0.2.1 (2026-10-15)
15
+
16
+ ### Fix
17
+
18
+ - support codeocean 0.13 and reuse only the caller's own captures
19
+
8
20
  ## v0.2.0 (2026-10-15)
9
21
 
10
22
  ### Feat
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: aind-codeocean-dispatch
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Launch, monitor, and capture batches of Code Ocean capsule runs
5
5
  Project-URL: Repository, https://github.com/AllenNeuralDynamics/aind-codeocean-dispatch/
6
6
  Project-URL: Issues, https://github.com/AllenNeuralDynamics/aind-codeocean-dispatch/issues
@@ -12,7 +12,7 @@ License-File: LICENSE
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Requires-Python: >=3.11
14
14
  Requires-Dist: aind-code-ocean-pipeline-utils>=0.7.1
15
- Requires-Dist: codeocean>=0.17
15
+ Requires-Dist: codeocean>=0.13
16
16
  Description-Content-Type: text/markdown
17
17
 
18
18
  # AIND Code Ocean dispatch
@@ -13,11 +13,11 @@ authors = [
13
13
  ]
14
14
  classifiers = ["Programming Language :: Python :: 3"]
15
15
  readme = "README.md"
16
- version = "0.2.0"
16
+ version = "0.2.2"
17
17
 
18
18
  dependencies = [
19
19
  "aind-code-ocean-pipeline-utils>=0.7.1",
20
- "codeocean>=0.17",
20
+ "codeocean>=0.13",
21
21
  ]
22
22
 
23
23
  [project.urls]
@@ -6,7 +6,9 @@ counters rather than locks, and no thread is parked per job.
6
6
 
7
7
  Error policy
8
8
  ------------
9
- The SDK's client retries nothing by default, so every 429 or 5xx surfaces here.
9
+ Every 429 or 5xx must surface here, so :func:`run_jobs` works on a copy of the
10
+ client with ``retries=0``: transport-level retries would resend POSTs below this
11
+ policy, and their exhausted retries raise without the status code it reads.
10
12
 
11
13
  - **429** is retried on every call, honouring ``Retry-After``: the server rejected
12
14
  the request without acting on it.
@@ -33,9 +35,13 @@ from typing import TYPE_CHECKING, Any, Literal, TypeVar
33
35
  import requests
34
36
  from aind_code_ocean_pipeline_utils.cache import input_fingerprint
35
37
  from aind_code_ocean_pipeline_utils.process import GracefulExit, check_shutdown
38
+
39
+ # Import paths that exist across supported SDK versions; 0.17 moved the models to
40
+ # ``codeocean.models`` and kept these as re-exports.
41
+ from codeocean.components import Ownership
42
+ from codeocean.computation import Computation, ComputationEndStatus, ComputationState, RunParams
43
+ from codeocean.data_asset import DataAsset, DataAssetParams, DataAssetSearchParams, DataAssetState
36
44
  from codeocean.error import Error as CodeOceanError
37
- from codeocean.models.computation import Computation, ComputationEndStatus, ComputationState, RunParams
38
- from codeocean.models.data_asset import DataAsset, DataAssetParams, DataAssetSearchParams, DataAssetState
39
45
 
40
46
  if TYPE_CHECKING:
41
47
  from codeocean import CodeOcean
@@ -88,8 +94,8 @@ class Job:
88
94
  can depend on it. ``None`` stops at a successful computation.
89
95
  key : str, optional
90
96
  Resume key (see :func:`job_key`), added to the captured asset as
91
- :func:`key_tag`. With ``reuse=True``, a Ready asset carrying it is returned
92
- instead of launching. ``None`` never reuses.
97
+ :func:`key_tag`. With ``reuse=True``, a Ready asset carrying it that the
98
+ caller created is returned instead of launching. ``None`` never reuses.
93
99
  """
94
100
 
95
101
  label: str
@@ -228,7 +234,8 @@ def run_jobs(
228
234
  Parameters
229
235
  ----------
230
236
  client : CodeOcean
231
- Authenticated client.
237
+ Authenticated client. Its own ``retries`` setting is overridden on a copy;
238
+ the caller's client is left unchanged.
232
239
  jobs : Sequence[Job]
233
240
  Launched in order, subject to the cap and pacing.
234
241
  max_in_flight : int, optional
@@ -243,7 +250,9 @@ def run_jobs(
243
250
  capture_retries : int, optional
244
251
  New capture attempts per job after a capture ends in the Failed state.
245
252
  reuse : bool, optional
246
- Return a Ready asset tagged with a job's key instead of launching it.
253
+ Return a Ready asset tagged with a job's key instead of launching it. Only
254
+ assets the caller created count: another user running identical inputs
255
+ produces the same key, and their asset is not the caller's to consume.
247
256
  poll_interval_s : float, optional
248
257
  Seconds between status checks of each running computation.
249
258
  timeout_s : float, optional
@@ -265,7 +274,7 @@ def run_jobs(
265
274
  """
266
275
  _validate(jobs, max_in_flight=max_in_flight, start_retries=start_retries, capture_retries=capture_retries)
267
276
  return _Dispatcher(
268
- client,
277
+ _without_transport_retries(client),
269
278
  jobs,
270
279
  max_in_flight=max_in_flight,
271
280
  launch_interval_s=launch_interval_s,
@@ -278,6 +287,18 @@ def run_jobs(
278
287
  ).run()
279
288
 
280
289
 
290
+ def _without_transport_retries(client: CodeOcean) -> CodeOcean:
291
+ """Return *client*, or a copy with ``retries=0`` if it retries requests itself.
292
+
293
+ The copy opens a new session, so anything mounted on the original's ``session``
294
+ by hand is not carried over.
295
+ """
296
+ if client.retries in (0, None):
297
+ return client
298
+ logger.info("using a copy of the client with retries=0 (was %r); run_jobs retries what is safe", client.retries)
299
+ return dataclasses.replace(client, retries=0)
300
+
301
+
281
302
  def _validate(jobs: Sequence[Job], *, max_in_flight: int | None, start_retries: int, capture_retries: int) -> None:
282
303
  labels = [job.label for job in jobs]
283
304
  if len(set(labels)) != len(labels):
@@ -479,7 +500,7 @@ class _Dispatcher:
479
500
  if slot.next_at > _now():
480
501
  continue
481
502
  tag = key_tag(slot.job.key)
482
- params = DataAssetSearchParams(query=f"tag:{tag}", limit=100)
503
+ params = DataAssetSearchParams(query=f"tag:{tag}", ownership=Ownership.Created, limit=100)
483
504
  try:
484
505
  results = self.client.data_assets.search_data_assets(params)
485
506
  except (CodeOceanError, requests.RequestException) as exc:
@@ -8,14 +8,14 @@ creation order.
8
8
 
9
9
  from __future__ import annotations
10
10
 
11
+ import dataclasses
11
12
  import itertools
12
13
  from dataclasses import dataclass, field
13
14
  from typing import Any
14
15
 
15
16
  import requests
16
- from codeocean.error import Error as CodeOceanError
17
- from codeocean.models.computation import Computation, ComputationEndStatus, ComputationState, RunParams
18
- from codeocean.models.data_asset import (
17
+ from codeocean.computation import Computation, ComputationEndStatus, ComputationState, RunParams
18
+ from codeocean.data_asset import (
19
19
  DataAsset,
20
20
  DataAssetParams,
21
21
  DataAssetSearchParams,
@@ -23,6 +23,16 @@ from codeocean.models.data_asset import (
23
23
  DataAssetState,
24
24
  DataAssetType,
25
25
  )
26
+ from codeocean.error import Error as CodeOceanError
27
+
28
+
29
+ def build(cls: Any, **values: Any) -> Any:
30
+ """Construct an SDK model, dropping fields this SDK version does not declare.
31
+
32
+ Required fields differ across supported versions (0.17 added ``owner``).
33
+ """
34
+ declared = {f.name for f in dataclasses.fields(cls)}
35
+ return cls(**{name: value for name, value in values.items() if name in declared})
26
36
 
27
37
 
28
38
  class FakeClock:
@@ -69,7 +79,8 @@ CRASHED = snapshot(ComputationState.Completed, end_status=ComputationEndStatus.F
69
79
 
70
80
 
71
81
  def asset(asset_id: str, state: DataAssetState, *, tags: list[str] | None = None, created: int = 0) -> DataAsset:
72
- return DataAsset(
82
+ return build(
83
+ DataAsset,
73
84
  id=asset_id,
74
85
  created=created,
75
86
  name=asset_id,
@@ -115,7 +126,7 @@ class FakeComputations:
115
126
 
116
127
  @staticmethod
117
128
  def _computation(computation_id: str, step: dict[str, Any]) -> Computation:
118
- return Computation(id=computation_id, created=0, name=computation_id, owner="owner", run_time=0, **step)
129
+ return build(Computation, id=computation_id, created=0, name=computation_id, owner="owner", run_time=0, **step)
119
130
 
120
131
 
121
132
  @dataclass
@@ -125,7 +136,7 @@ class FakeDataAssets:
125
136
  existing: list[DataAsset] = field(default_factory=list)
126
137
  search_errors: list[Exception] = field(default_factory=list)
127
138
  created: list[DataAssetParams] = field(default_factory=list)
128
- searches: list[str] = field(default_factory=list)
139
+ searches: list[DataAssetSearchParams] = field(default_factory=list)
129
140
  _scripts: dict[str, list[Any]] = field(default_factory=dict)
130
141
 
131
142
  def create_data_asset(self, data_asset_params: DataAssetParams) -> DataAsset:
@@ -145,7 +156,7 @@ class FakeDataAssets:
145
156
  return asset(data_asset_id, step)
146
157
 
147
158
  def search_data_assets(self, search_params: DataAssetSearchParams) -> DataAssetSearchResults:
148
- self.searches.append(search_params.query)
159
+ self.searches.append(search_params)
149
160
  if self.search_errors:
150
161
  raise self.search_errors.pop(0)
151
162
  return DataAssetSearchResults(has_more=False, results=list(self.existing))
@@ -155,3 +166,4 @@ class FakeDataAssets:
155
166
  class FakeClient:
156
167
  computations: FakeComputations
157
168
  data_assets: FakeDataAssets
169
+ retries: Any = 0
@@ -5,8 +5,11 @@ from typing import Any
5
5
  import pytest
6
6
  import requests
7
7
  from aind_code_ocean_pipeline_utils.process import GracefulExit
8
- from codeocean.models.computation import Computation, DataAssetsRunParam, NamedRunParam, RunParams
9
- from codeocean.models.data_asset import DataAssetParams, DataAssetState
8
+ from codeocean import CodeOcean
9
+ from codeocean.components import Ownership
10
+ from codeocean.computation import Computation, DataAssetsRunParam, NamedRunParam, RunParams
11
+ from codeocean.data_asset import DataAssetParams, DataAssetState
12
+ from urllib3.util import Retry
10
13
 
11
14
  from aind_codeocean_dispatch import (
12
15
  DispatchFailed,
@@ -255,6 +258,8 @@ def test_reuse_returns_newest_ready_asset_with_exact_tag(clock: FakeClock) -> No
255
258
  assert outcome.status == "reused"
256
259
  assert outcome.asset_id == "new"
257
260
  assert client.computations.launches == []
261
+ (search,) = client.data_assets.searches
262
+ assert search.ownership == Ownership.Created
258
263
 
259
264
 
260
265
  def test_reuse_launches_when_nothing_matches(clock: FakeClock) -> None:
@@ -312,6 +317,33 @@ def test_duplicate_labels_are_rejected(clock: FakeClock) -> None:
312
317
  run_jobs(make_client(clock, {}), [job("a"), job("a")])
313
318
 
314
319
 
320
+ @pytest.mark.parametrize("retries", [3, Retry(total=5, allowed_methods=["POST"])])
321
+ def test_client_that_retries_itself_still_runs(clock: FakeClock, retries: Any) -> None:
322
+ client = make_client(clock, {"a": [[SUCCEEDED]]})
323
+ client.retries = retries
324
+
325
+ outcomes = run_jobs(client, [job("a")])
326
+
327
+ assert [o.status for o in outcomes] == ["captured"]
328
+ assert client.retries is retries
329
+
330
+
331
+ def test_retrying_client_is_copied_without_retries() -> None:
332
+ original = CodeOcean(domain="https://codeocean.example", token="x", retries=Retry(total=5))
333
+
334
+ copy = runner._without_transport_retries(original)
335
+
336
+ assert copy is not original and copy.session is not original.session
337
+ assert (copy.retries, copy.domain, copy.token) == (0, original.domain, original.token)
338
+ assert isinstance(original.retries, Retry)
339
+
340
+
341
+ def test_non_retrying_client_is_used_as_is() -> None:
342
+ client = CodeOcean(domain="https://codeocean.example", token="x")
343
+
344
+ assert runner._without_transport_retries(client) is client
345
+
346
+
315
347
  def test_invalid_key_is_rejected() -> None:
316
348
  with pytest.raises(ValueError, match="key"):
317
349
  job("a", key="has space")
@@ -17,7 +17,7 @@ wheels = [
17
17
 
18
18
  [[package]]
19
19
  name = "aind-codeocean-dispatch"
20
- version = "0.2.0"
20
+ version = "0.2.2"
21
21
  source = { editable = "." }
22
22
  dependencies = [
23
23
  { name = "aind-code-ocean-pipeline-utils" },
@@ -50,7 +50,7 @@ types = [
50
50
  [package.metadata]
51
51
  requires-dist = [
52
52
  { name = "aind-code-ocean-pipeline-utils", specifier = ">=0.7.1" },
53
- { name = "codeocean", specifier = ">=0.17" },
53
+ { name = "codeocean", specifier = ">=0.13" },
54
54
  ]
55
55
 
56
56
  [package.metadata.requires-dev]