diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..4382613 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,23 @@ +--- +name: Bug report +about: Create a report to help us improve +title: "[Bug]" +labels: '' +assignees: '' + +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Screenshots** +If applicable, add screenshots to help explain your problem. + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..f9acdb3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "[Feature]" +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c05f572 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,12 @@ +## Summary +Describe clearly what this PR changes and why. + +## Related Issues +Fixes # or Closes # + +## Tests & Build +- [ ] `ruff format` passes +- [ ] `pytest --ignore=tests/yml_tests/test_authorized` passes (or N/A) + +## Notes +Anything unusual reviewers should know. diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..3305f42 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,18 @@ +name: Changelog updated? + +on: + pull_request: + types: + - opened + - labeled + - unlabeled + - synchronize + branches: + - prod + +jobs: + call-changelog-check-workflow: + # Docs: https://github.com/ASFHyP3/actions + uses: ASFHyP3/actions/.github/workflows/reusable-changelog-check.yml@v0.22.0 + permissions: + contents: read diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml deleted file mode 100644 index e34a0f7..0000000 --- a/.github/workflows/deploy-prod.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: HDFReaderAPI deploy action (prod) - -on: - push: - branches: - - prod - -jobs: - deploy: - runs-on: ubuntu-latest - environment: prod - permissions: - id-token: write - contents: read - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: build - uses: ./.github/workflows/hdf-reader-api-composite - with: - aws-account-id: ${{ secrets.AWS_ACCOUNT_ID }} diff --git a/.github/workflows/deploy-test.yml b/.github/workflows/deploy-test.yml deleted file mode 100644 index 28ed2e4..0000000 --- a/.github/workflows/deploy-test.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: HDFReaderAPI deploy action (test) - -on: - push: - branches: - - edc-deploy - - test - -jobs: - deploy: - runs-on: ubuntu-latest - environment: test - permissions: - id-token: write - contents: read - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: build - uses: ./.github/workflows/hdf-reader-api-composite - with: - aws-account-id: ${{ secrets.AWS_ACCOUNT_ID }} - stack-id: CdkOperaDispStack diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index eb21636..1e3f92e 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,16 +1,16 @@ name: Lint on: - pull_request: - types: [opened, edited, reopened] - push: + pull_request: + types: [opened, edited, reopened] + push: jobs: - lint: - runs-on: ubuntu-latest + lint: + runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: astral-sh/ruff-action@v4.1.0 - - run: ruff check --fix - - run: ruff format + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/ruff-action@v4.1.0 + - run: ruff check --fix + - run: ruff format diff --git a/.github/workflows/pr-labeled.yml b/.github/workflows/pr-labeled.yml new file mode 100644 index 0000000..157540b --- /dev/null +++ b/.github/workflows/pr-labeled.yml @@ -0,0 +1,17 @@ +name: Is PR labeled? + +on: + pull_request: + types: + - opened + - labeled + - unlabeled + - synchronize + branches: + - prod + +jobs: + call-labeled-pr-check-workflow: + uses: ASFHyP3/actions/.github/workflows/reusable-labeled-pr-check.yml@v0.22.0 + permissions: + contents: read diff --git a/.github/workflows/run-pytest-against-uat.yml b/.github/workflows/run-pytest-against-uat.yml index 5d46568..84eb573 100644 --- a/.github/workflows/run-pytest-against-uat.yml +++ b/.github/workflows/run-pytest-against-uat.yml @@ -1,24 +1,24 @@ name: tests against uat on: - pull_request: - branches: prod - paths: - - "**.py" + pull_request: + branches: prod + paths: + - "**.py" jobs: - run-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - name: Install Dependencies - run: | - python3 -m pip install --upgrade pip - python3 -m pip install -r src/requirements.txt - python3 -m pip install -r tests/requirements.txt + run-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Install Dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install -r src/requirements.txt + python3 -m pip install -r tests/requirements.txt - - name: Run Tests - run: python3 -m pytest --use-uat tests/yml_tests/test_authorized.yml + - name: Run Tests + run: python3 -m pytest --use-uat tests/yml_tests/test_authorized.yml diff --git a/.github/workflows/run-pytest.yml b/.github/workflows/run-pytest.yml index 061a6f3..d49babd 100644 --- a/.github/workflows/run-pytest.yml +++ b/.github/workflows/run-pytest.yml @@ -1,33 +1,33 @@ name: tests on: - pull_request: - branches: test - paths: - - "**.py" + pull_request: + branches: test + paths: + - "**.py" jobs: - run-tests: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v5 - - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - name: Install Dependencies - run: | - python3 -m pip install --upgrade pip - python3 -m pip install -r src/requirements.txt - python3 -m pip install -r tests/requirements.txt + run-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Install Dependencies + run: | + python3 -m pip install --upgrade pip + python3 -m pip install -r src/requirements.txt + python3 -m pip install -r tests/requirements.txt - - name: Run Tests - run: python3 -m pytest --df=test_authorized + - name: Run Tests + run: python3 -m pytest --df=test_authorized - # - name: Upload coverage to Codecov - # uses: codecov/codecov-action@v5 - # with: - # token: ${{ secrets.CODECOV_TOKEN }} - # fail_ci_if_error: false - # files: ./coverage.xml - # flags: unittests - # name: asf_admin pytest - # verbose: true + # - name: Upload coverage to Codecov + # uses: codecov/codecov-action@v5 + # with: + # token: ${{ secrets.CODECOV_TOKEN }} + # fail_ci_if_error: false + # files: ./coverage.xml + # flags: unittests + # name: asf_admin pytest + # verbose: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a0fee1a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +## [1.0.0] + +### Added + +- Initial Release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d263aaf --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2024, Alaska Satellite Facility + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 7697c27..995173c 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,97 @@ -# Discovery-HDFReaderAPI -- A HDF5 based file reader implemented with FastAPI, made for sampling timeseries from remote HDF5/NetCDF files in s3 containers. +# Discovery-DisplacementAPI +- A FastAPI based Lambda API for querying point based timeseries on OPERA `DISP-S1` data products, querying remote gzipped zarr json stores in S3. + +# Overview +## Endpoints + +### GET `/` +Returns a basic endpoint status message + +### POST `/timeseries` +Returns a json timeseries, mapping file names to a sampled point's `short_wavelength_displacement` value with x & y coordinates, source s3 netcdf4 uri, frame, and dates +```json +{ + "OPERA_L3_DISP-S1_IW_F16942_VV_20160707T015033Z_20160731T015034Z_v1.0_20250617T175432Z.nc": + { + "netcdf_uri": "s3:\/\/asf-cumulus-prod-opera-products\/OPERA_L3_DISP-S1_V1\/OPERA_L3_DISP-S1_IW_F16942_VV_20160707T015033Z_20160731T015034Z_v1.0_20250617T175432Z\/OPERA_L3_DISP-S1_IW_F16942_VV_20160707T015033Z_20160731T015034Z_v1.0_20250617T175432Z.nc", + "bytes": 390070272.0, + "reference_datetime": "2016-07-07T01:50:33.224", + "secondary_datetime": "2016-07-31T01:50:34.507", + "temporal_baseline": 24.0, + "short_wavelength_displacement": 0.003633976, + "x": 437565.0, + "y": 3966615.0, + "frame": "F16942", + "is_masked": false, + }, + ..., + "mean": + { + "netcdf_uri": null, + "bytes": 412385371.2757660151, + "reference_datetime": null, + "secondary_datetPime": null, + "temporal_baseline": 1879.8384401114, + "short_wavelength_displacement": 0.3834199607, + "x": 437565.0, + "y": 3966615.0, + "frame": null, + "is_masked": null, + }, + } +} +``` + +``` +params: + wkt: str + - A WKT POINT string + flightDirection: str + - `ASCENDING` or `DESCENDING` pass + bucket: str + - The bucket to query with the zarr stores, ie `asf-cumulus-prod` + polarization: Polarizations = Polarizations.VV + frameId: str | None = Field(None, pattern=r'^\d{5}$') + - Overrides which frame to search +``` + +### POST `/get_frame_id` +``` +params: + wkt: str + - A WKT POINT string + flightDirection: str + - `ASCENDING` or `DESCENDING` pass +``` # Install and Run Locally Run pip install from the project's root folder -``` - pip install -e . +``` bash +pip install -r src/requirements.txt ``` -``` python +``` bash uvicorn src.application:app --port 8080 --reload -``` \ No newline at end of file +``` + +# Install and Run Test Dependencies Locally + +[!IMPORTANT] +To run all tests locally valid AWS credentials are needed for accessing zarr reference stores in EDC + +To install test dependencies: +``` bash +pip install -r tests/requirements.txt +``` + +To run +``` bash +pytest --ignore=tests/yml_tests/test_authorized +``` + +# Release Workflow + +The intended flow from pull request to release is: +`feat/bug-branch`->`test`->`prod` + diff --git a/cdk/requirements.txt b/cdk/requirements.txt index 6b74f25..c21d595 100644 --- a/cdk/requirements.txt +++ b/cdk/requirements.txt @@ -1,2 +1,2 @@ -aws-cdk-lib==2.182.0 +aws-cdk-lib==2.253 # constructs>=10.0.0,<11.0.0 diff --git a/oidc/cdk_oidc_role_stack.py b/oidc/cdk_oidc_role_stack.py index f286c4b..ff78184 100644 --- a/oidc/cdk_oidc_role_stack.py +++ b/oidc/cdk_oidc_role_stack.py @@ -1,16 +1,9 @@ -from aws_cdk import ( - Stack, - cloudformation_include as cfn_inc -) +from aws_cdk import Stack, cloudformation_include as cfn_inc from constructs import Construct class CdkOIDCRoleStack(Stack): - - def __init__( - self, scope: Construct, construct_id: str, **kwargs - ) -> None: - + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: super().__init__(scope, construct_id, **kwargs) # Only needs to be done once per account @@ -21,7 +14,5 @@ def __init__( # ) self.oidc_role_template = cfn_inc.CfnInclude( - self, - "OIDCRoleTemplate", - template_file="github-actions-oidc.yml" + self, 'OIDCRoleTemplate', template_file='github-actions-oidc.yml' ) diff --git a/oidc/requirements.txt b/oidc/requirements.txt index 0406081..23819bd 100644 --- a/oidc/requirements.txt +++ b/oidc/requirements.txt @@ -1,2 +1,2 @@ -aws-cdk-lib==2.166.0 +aws-cdk-lib==2.253 constructs>=10.0.0,<11.0.0 diff --git a/src/HDF/data_reader.py b/src/HDF/data_reader.py index a81b67a..854c4f2 100644 --- a/src/HDF/data_reader.py +++ b/src/HDF/data_reader.py @@ -1,7 +1,6 @@ import rasterio import xarray as xr -# from fsspec.implementations.local import S3FileSystem from s3fs.core import S3FileSystem from src import api_logger diff --git a/src/application.py b/src/application.py index 6e1d091..22cfc81 100644 --- a/src/application.py +++ b/src/application.py @@ -47,14 +47,9 @@ class ReadLayer(BaseModel): @app.post( '/timeseries', - description='Returns a json response with values read at "unwrapped_phase", "interferometric_correlation", and "temporal_coherence" layers in provided files at given point', + description='Returns a json dictionary mapping netcdf4 file names to their sampled point value based off the given POINT wkt', ) def get_timeseries(series: TimeSeriesModel): - # # TODO: REMOVE BEFORE ANOTHER PRODUCTION RELEASE - # return HTTPException( - # status_code=400, - # detail='The test displacement API is temporarily disabled due to unintended usage. For programmatic queries please access the zarr stack store `displacement` layer directly. For more info or questions please contact uso@asf.alaska.edu', - # ) start = perf_counter() try: geometry, wrapped_geometry = _wrap_wkt(series.wkt) @@ -113,36 +108,6 @@ def get_frame_ids(wkt_event: GetFramesModel): ) -# @app.post( -# '/frame_intersection', -# description='Returns the frame ids for a given aoi, splitting any cross frames', -# ) -# def frame_intersection(wkt_event: GetFramesModel): -# wkt = wkt_event.wkt -# flight_direction = wkt_event.flightDirection -# overlapping_frames = _get_frames_for_wkt(wkt, flight_direction) -# frame_path = Path(__file__).parent.parent / 'data' / 'frames.geojson' -# geometry = shapely_wkt.loads(wkt_event.wkt) -# gdf = gpd.read_file(frame_path, engine='pyogrio') -# frames: gpd.GeoDataFrame = gdf[gdf['frame_id'].isin([int(p) for p in overlapping_frames])] -# intersection = frames.intersection(geometry) -# return JSONResponse( -# { -# _pad_frame_str(frame_id): _simplify_multipolygons(geom).wkt -# for frame_id, geom in zip(gdf.iloc[intersection.index]['frame_id'], intersection) -# }, -# headers=DEFAULT_HEADERS, -# ) - - -# def _simplify_multipolygons(geometry: BaseGeometry) -> BaseGeometry: -# """In case polygon intersects different sections of frame at once and get multi-polygon (swath corners for example), perform convex hull instead""" -# if geometry.geom_type == 'MultiPolygon': -# geometry = convex_hull(geometry) - -# return geometry - - def _get_file_store_for_wkt(bucket: str, frame_id: str, polarization: str): return f's3://{bucket}/OPERA_L3_DISP-S1_STACK_V1/OPERA_L3_DISP-S1_IW_F{frame_id}_{polarization}_V1/OPERA_L3_DISP-S1_IW_F{frame_id}_{polarization}_short_wavelength_displacement.zarr.json.gz' diff --git a/src/timeseries/timeseries.py b/src/timeseries/timeseries.py index c114d80..0969308 100644 --- a/src/timeseries/timeseries.py +++ b/src/timeseries/timeseries.py @@ -1,4 +1,3 @@ -# import rasterio import re from datetime import datetime, timedelta from time import perf_counter @@ -47,21 +46,6 @@ def get_timeseries_over_aoi( **fsspec_params, }, ) - # fs = SimpleCacheFileSystem( - # target_protocol='reference', - # cache_storage=f'/mnt/filesystem/{file_store.split("/")[-1]}', - # target_options={ - # 'target_options': dict(compression='gzip'), - # 'fo': f'{file_store}', - # 'remote_options': { - # 'anon': False, - # 'default_fill_cache': True, - # 'skip_instance_cache': False, - # **fsspec_params, - # }, - # }, - # asynchronous=True, - # ) except Exception as exc: api_logger.error(f'Failed to open file. Original Error: {exc}') @@ -94,8 +78,6 @@ def get_timeseries_over_aoi( start = perf_counter() if isinstance(wrapped_geometry, Point): values = _read_layers_at_pixel(values, lon=wrapped_geometry.x, lat=wrapped_geometry.y) - # elif isinstance(geometry, Polygon): - # values = _read_layers_with_aoi(values, geometry) api_logger.info(f'AOI Read Speed: {perf_counter() - start} seconds') return _pandas_output(values, wkt=geometry.wkt) @@ -109,7 +91,7 @@ def _latest_product_versions(values: xr.Dataset) -> xr.Dataset: def _minimum_version(values: xr.Dataset) -> xr.Dataset: """Only keep products of minimum version `0.9` or above""" - mask = values.coords['source_file_name'].str.match(r'.*((v1\..)|(v0\.9)).*\.nc?') + mask = values.coords['source_file_name'].str.match(r'.*(v1\..).*\.nc?') return values.isel(source_file_name=mask) @@ -125,52 +107,6 @@ def _read_layers_at_pixel(dataset: xr.Dataset, lat: float, lon: float) -> xr.Dat return dataset[_data_variables].isel(x=xx_index, y=yy_index) -# Saving this functionality for polygons in future -# def _read_layers_with_aoi(dataset: xr.Dataset, geometry: BaseGeometry): -# """ -# Read a spatial subset from the stack with a reprojection of the provided geometry -# Returns the average values for each aoi at each timestep -# """ -# crs = get_crs(dataset) -# epsg = crs.to_epsg() - -# projected = _transform_geometry(geometry=geometry, target_proj=epsg) - -# dataset.rio.set_spatial_dims(x_dim='x', y_dim='y', inplace=True) -# dataset.rio.write_crs(str(crs), inplace=True) - -# d = {'col1': ['aoi'], 'geometry': [projected]} -# gdf = gpd.GeoDataFrame(d, crs=str(epsg)) # type: ignore - -# # clip_box limits the clipped geometry to the aoi bounds, -# # otherwise rioxarray will load THE ENTIRE DATASET FOOTPRINT into memory. -# # Not a perfect solution, as any shape that isn't perfectly aligned with -# # bounding dimensions will lead to more remote reads than necessary -# # ex: the trianges between a bounding box and a rotated square will be -# # loaded by `clip()` - -# # TODO: Explore opening only one frame, clipping it, then use those -# # indexes to read the remaining timesteps without loading the extra data -# # (assuming all data in uniformly alligned over a frame id, which should be the case) -# clipped_box = dataset.rio.clip_box(*projected.bounds) -# ds = clipped_box.rio.clip(gdf.geometry.values, gdf.crs, drop=False) -# for v in list(ds.coords.keys()): -# if ds.coords[v].dtype == object: -# ds.coords[v] = ds.coords[v].astype('unicode') - -# for v in list(ds.variables.keys()): -# if ds[v].dtype == object: -# ds[v] = ds[v].astype('unicode') -# # ds.to_zarr('tests/zarr_test_store/', mode='w', consolidated=False) - -# return ds.mean(['x', 'y']) - - -# def _transform_geometry(geometry: BaseGeometry, source_proj='4326', target_proj='32610'): -# project = Transformer.from_crs(source_proj, target_proj, always_xy=True).transform -# return shapely_transform(project, geometry) - - def _to_crs(lat: float, lon: float, source_proj='4326', target_proj='32610'): source_crs = CRS.from_epsg(source_proj) target_crs = CRS.from_epsg(target_proj) @@ -226,7 +162,11 @@ def _fix_rolling_reference(data: pandas.DataFrame) -> pandas.DataFrame: def _pandas_output(data: xr.Dataset, wkt: str): start = perf_counter() data = data.sortby('secondary_datetime') - pandas_values = data.to_dataframe() + try: + pandas_values = data.to_pandas() + except Exception as e: + print(e) + raise nans = pandas_values['short_wavelength_displacement'].isna() if len(pandas_values[~nans]) == 0: diff --git a/tests/pytest-managers.py b/tests/pytest-managers.py index d589fd6..33937f7 100644 --- a/tests/pytest-managers.py +++ b/tests/pytest-managers.py @@ -38,12 +38,12 @@ def test_wkt_wrapping(**args) -> None: actual, actual_wrapped = _wrap_wkt(wkt) - assert actual.wkt == wkt, ( - f'Original geometry modified when unwrapping wkt. Expected wkt: {wkt}. Actual: {actual.wkt}' - ) - assert actual_wrapped.wkt == wrapped, ( - f'Failed to wrap wkt. Expected wkt: {wrapped}. Actual: {actual_wrapped}' - ) + assert ( + actual.wkt == wkt + ), f'Original geometry modified when unwrapping wkt. Expected wkt: {wkt}. Actual: {actual.wkt}' + assert ( + actual_wrapped.wkt == wrapped + ), f'Failed to wrap wkt. Expected wkt: {wrapped}. Actual: {actual_wrapped}' def test_endpoints(**args) -> None: @@ -60,9 +60,9 @@ def test_endpoints(**args) -> None: if status_code == 200: response.raise_for_status() else: - assert response.status_code == status_code, ( - f'Expected error code {status_code}, got response code {response.status_code}, "{response.text}"' - ) + assert ( + response.status_code == status_code + ), f'Expected error code {status_code}, got response code {response.status_code}, "{response.text}"' expected_output = expected_response.get('expected_output')