Skip to content
Merged
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [PEP 440](https://www.python.org/dev/peps/pep-0440/)
and uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [7.7.6]

### Changed
* The implementation of `HyP3.update_jobs` has been refactored to use the new `PATCH /jobs` endpoint that was added with [HyP3 v10.12.0](https://github.com/ASFHyP3/hyp3/releases/tag/v10.12.0), resulting in a significant performance improvement when renaming large batches of jobs.
* `HyP3.update_jobs` now requires the `name` parameter and no longer accepts arbitrary keyword arguments.

> [!WARNING]
> If one of your jobs fails to update for any reason, `HyP3.update_jobs` will raise an exception and all of the jobs that were updated before the failure will have the new name, while all of the remaining jobs will be left with the old name. Additionally, because `HyP3.update_jobs` returns a new copy of your updated jobs rather than updating them in-place, you will need to manually refresh your local copy of the jobs if you want to see which jobs were successfully updated. You can refresh your jobs with the `HyP3.refresh` method, e.g:
> ```python
> >>> jobs = hyp3.refresh(jobs)
> ```

### Fixed
* `Batch` is now a subclass of `collections.abc.Sequence`, which allows `hyp3_sdk.util.chunk` to accept a `Batch` object without triggering warnings from static type checkers such as `mypy`.

## [7.7.5]

### Changed
Expand Down
46 changes: 29 additions & 17 deletions src/hyp3_sdk/hyp3.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import math
import time
import warnings
from copy import deepcopy
from datetime import datetime, timezone
from functools import singledispatchmethod
from getpass import getpass
from typing import Literal
from urllib.parse import SplitResult, urlsplit, urlunsplit
from warnings import warn

import hyp3_sdk
import hyp3_sdk.util
from hyp3_sdk.exceptions import HyP3Error, _raise_for_hyp3_status
from hyp3_sdk.exceptions import HyP3Error, HyP3SDKError, _raise_for_hyp3_status
from hyp3_sdk.jobs import Batch, Job


Expand Down Expand Up @@ -693,7 +693,7 @@ def check_quota(self) -> float | int | None:
Returns:
Your remaining processing credits, or None if you have no processing limit
"""
warn(
warnings.warn(
'This method is deprecated and will be removed in a future release.\n'
'Please use `HyP3.check_credits` instead.',
DeprecationWarning,
Expand All @@ -709,27 +709,39 @@ def costs(self) -> dict:
_raise_for_hyp3_status(response)
return response.json()

def update_jobs(self, jobs: Batch | Job, **kwargs: object) -> Batch | Job:
def update_jobs(self, jobs: Batch | Job, name: str | None) -> Batch | Job:
"""Update the name of one or more previously-submitted jobs.

Args:
jobs: The job(s) to update
kwargs:
name: The new name, or None to remove the name
name: The new name, or None to remove the name

Returns:
The updated job(s)
"""
if isinstance(jobs, Batch):
batch = hyp3_sdk.Batch()
tqdm = hyp3_sdk.util.get_tqdm_progress_bar()
for job in tqdm(jobs):
batch += self.update_jobs(job, **kwargs)
return batch

if not isinstance(jobs, Job):
if not isinstance(jobs, Job) and not isinstance(jobs, Batch):
raise TypeError(f"'jobs' has type {type(jobs)}, must be {Batch} or {Job}")

response = self.session.patch(self._get_endpoint_url(f'/jobs/{jobs.job_id}'), json=kwargs)
_raise_for_hyp3_status(response)
return Job.from_dict(response.json())
jobs = deepcopy(jobs)
batch = jobs if isinstance(jobs, Batch) else Batch([jobs])

tqdm = hyp3_sdk.util.get_tqdm_progress_bar()
for jobs_chunk in tqdm(list(hyp3_sdk.util.chunk(batch, n=100))):
payload = {'job_ids': [job.job_id for job in jobs_chunk], 'name': name}
response = self.session.patch(self._get_endpoint_url('/jobs'), json=payload)
try:
_raise_for_hyp3_status(response)
except HyP3SDKError as e:
warnings.warn(
'Something went wrong while updating your jobs. '
'The local state of your jobs may be out-of-date. '
'You can refresh your jobs with the HyP3.refresh method, e.g: '
'jobs = hyp3.refresh(jobs)',
UserWarning,
)
raise e

for job in batch:
job.name = name

return jobs
5 changes: 3 additions & 2 deletions src/hyp3_sdk/jobs.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from collections import Counter
from collections.abc import Sequence
from datetime import datetime
from pathlib import Path
from typing import Union
Expand Down Expand Up @@ -150,7 +151,7 @@ def download_files(self, location: Path | str = '.', create: bool = True) -> lis
return downloaded_files


class Batch:
class Batch(Sequence):
def __init__(self, jobs: list[Job] | None = None):
if jobs is None:
jobs = []
Expand Down Expand Up @@ -179,7 +180,7 @@ def __iter__(self):
def __len__(self):
return len(self.jobs)

def __contains__(self, job: Job):
def __contains__(self, job: object):
return job in self.jobs

def __eq__(self, other: object) -> bool:
Expand Down
62 changes: 27 additions & 35 deletions tests/test_hyp3.py
Original file line number Diff line number Diff line change
Expand Up @@ -529,56 +529,48 @@ def test_costs(get_mock_hyp3):
@responses.activate
def test_update_jobs(get_mock_hyp3, get_mock_job):
api = get_mock_hyp3()
job1_api_response = {
'job_id': 'job1',
'job_type': 'FOO',
'request_time': '2020-06-04T18:00:03+00:00',
'status_code': 'SUCCEEDED',
'user_id': 'foo',
'name': 'new_name',
}
job2_api_response = {
'job_id': 'job2',
'job_type': 'FOO',
'request_time': '2020-06-04T18:00:03+00:00',
'status_code': 'SUCCEEDED',
'user_id': 'foo',
'name': 'new_name',
}

responses.add(
responses.PATCH,
urljoin(api.url, '/jobs/job1'),
match=[responses.matchers.json_params_matcher({'name': 'new_name'})],
json=job1_api_response,
urljoin(api.url, '/jobs'),
match=[responses.matchers.json_params_matcher({'job_ids': ['job1'], 'name': 'new'})],
)
job = get_mock_job(job_id='job1', name='old')

assert api.update_jobs(job, name='new') == get_mock_job(job_id='job1', name='new')
assert job.name == 'old'

responses.add(
responses.PATCH,
urljoin(api.url, '/jobs/job2'),
match=[responses.matchers.json_params_matcher({'name': 'new_name'})],
json=job2_api_response,
urljoin(api.url, '/jobs'),
match=[responses.matchers.json_params_matcher({'job_ids': [str(i) for i in range(100)], 'name': 'new'})],
)
responses.add(
responses.PATCH,
urljoin(api.url, '/jobs/job1'),
match=[responses.matchers.json_params_matcher({'foo': 'bar'})],
json={'detail': 'test error message'},
status=400,
urljoin(api.url, '/jobs'),
match=[responses.matchers.json_params_matcher({'job_ids': [str(i) for i in range(100, 200)], 'name': 'new'})],
)
jobs = Batch([get_mock_job(job_id=str(i), name='old') for i in range(200)])

job1 = get_mock_job(job_id='job1')
job2 = get_mock_job(job_id='job2')
assert api.update_jobs(jobs, name='new') == Batch([get_mock_job(job_id=str(i), name='new') for i in range(200)])
assert {job.name for job in jobs} == {'old'}

assert api.update_jobs(job1, name='new_name') == Job.from_dict(job1_api_response)
with pytest.raises(TypeError):
api.update_jobs(1, name='new')

assert api.update_jobs(Batch([job1, job2]), name='new_name') == Batch(
[Job.from_dict(job1_api_response), Job.from_dict(job2_api_response)]
responses.add(
responses.PATCH,
urljoin(api.url, '/jobs'),
match=[responses.matchers.json_params_matcher({'job_ids': ['bad-job'], 'name': 'new'})],
json={'detail': 'job does not exist'},
status=404,
)
bad_job = get_mock_job(job_id='bad-job', name='old')

with pytest.raises(TypeError):
api.update_jobs(1, name='new_name')
with pytest.raises(HyP3Error, match=r'^<Response \[404\]> job does not exist$'):
api.update_jobs(bad_job, name='new')

with pytest.raises(HyP3Error, match=r'^<Response \[400\]> test error message$'):
api.update_jobs(job1, foo='bar')
assert bad_job.name == 'old'


def test_get_endpoint_url(get_mock_hyp3):
Expand Down