Code audit
A record of the defects found by screening src/cdse, what was fixed, what is still open, and
why each fix was done the way it was. It is not user documentation โ it exists so that the
state of the code is legible without reading the whole git history.
- Screening: 23 August 2026, against
5acb0db - Coverage: all 13 modules of
src/cdse. The screening is complete. - Method:
[P]= reproduced by running the code ยท[L]= read from the source, not yet run - Fixes: 21 of 31 closed, each with a regression test (entries 30 and 31 added in September 2026)
- Tests: 102 โ 188 passing with the optional extras installed; 136 in CI, which
installs only
.[dev](see the note on skipped tests at the end)
Status
| # | Sev | Module | Defect | State |
|---|---|---|---|---|
| 01 | ๐ด | downloader.py | Truncated downloads reported as successful, then never re-fetched [P] |
โ fixed |
| 02 | ๐ด | catalog.py | limit applied by the server before the local filters [P] |
โ fixed |
| 03 | ๐ด | async_client.py | Partial files kept forever, no cleanup [L] |
โ fixed |
| 04 | ๐ | client.py | The catalog never refreshes its token, searches die after ~10 min [P] |
โ fixed |
| 05 | ๐ | async_client.py | Token never refreshed during a batch [L] |
โ fixed |
| 06 | ๐ | downloader.py | A single requests.Session shared across threads [L] |
open |
| 07 | ๐ | cli.py | Exit code 0 even when nothing is downloaded [P] |
โ fixed |
| 08 | ๐ก | catalog.py | Only the first polygon of a MultiPolygon is evaluated [L] |
โ fixed |
| 09 | ๐ก | async_client.py | Async and sync return different results (s3:// guard, center-point filter) [L] |
โ fixed |
| 10 | ๐ก | downloader.py | max_retries=0 raises UnboundLocalError [P] |
โ fixed |
| 11 | ๐ก | cli.py | ValidationError uncaught, escapes as a traceback [P] |
โ fixed |
| 12 | ๐ก | downloader.py | except Exception: return None swallows every URL-resolution error [L] |
โ fixed |
| 13 | โช | downloader.py | Pointless sleep after the final retry attempt [P] |
โ fixed |
| 14 | โช | downloader.py | Streamed responses never closed between retries [P] |
โ fixed |
| 15 | ๐ด | processing.py | L1C via ZIP: no band ever extracted [P] |
โ fixed |
| 16 | ๐ด | processing.py | Missing bands: bare KeyError, or a silently shorter stack [P] |
โ fixed |
| 17 | ๐ | processing.py | crop_to_bbox outside the tile: raw rasterio ValueError [P] |
โ fixed |
| 18 | ๐ | processing.py | crop_to_bbox on a raster with no CRS: AttributeError [P] |
โ fixed |
| 19 | ๐ก | processing.py | The ZIP extractor loads each whole band into RAM [P] |
open |
| 20 | โช | processing.py | calculate_ndvi emits a numpy RuntimeWarning [P] |
โ fixed |
| 21 | ๐ | product.py | STAC results carry no size: every search reports 0.00 GB [P] |
open |
| 22 | ๐ก | converters.py | to_dataframe([]) returns a 0ร0 frame with no columns [P] |
open |
| 23 | ๐ก | converters.py | to_geojson emits bbox: [] and geometry: {}, invalid GeoJSON [P] |
open |
| 24 | ๐ก | geocoding.py | get_city_bbox returns a tuple that search() rejects [P] |
open |
| 25 | ๐ก | geocoding.py | search_by_city docstring incompatible with use_predefined=True [P] |
open |
| 26 | ๐ก | geocoding.py | Nominatim: retries with no pause, and a shared user agent [P] |
open |
| 27 | โช | geocoding.py | Near the poles the buffer produces an absurd bbox [P] |
open |
| 28 | โช | geometry.py | geojson_to_wkt on empty coordinates emits POLYGON () [P] |
open |
| 29 | โช | processing.py | crop_to_bbox discarded band descriptions [P] |
โ fixed |
| 30 | ๐ | catalog.py | The centre-point filter silently drops every tile but one for areas wider than a tile [L] |
โ fixed |
| 31 | ๐ | downloader.py | Download URL lookup appends .SAFE to Sentinel-3 (.SEN3) and -5P (.nc) names [L] |
โ fixed, live run pending |
Entries 30 and 31 were found in September 2026 while writing the persona examples, after the original screening; their detail is at the end of the Detail section.
Detail
๐ด 01 โ Truncated downloads reported as successful [P] โ โ
FIXED
downloader.py:186 through :216. No comparison between bytes written and content-length.
If the connection closes cleanly mid-stream, iter_content finishes without an exception and
the partial file is taken as good. Worse: skip_existing=True is the default, so the corrupt
ZIP is never replaced.
content-length advertised : 1,048,576 bytes
bytes actually written : 100 bytes
download() raised : No -> returned S2A_TEST.zip
second download() HTTP calls: 0 (0 = corrupt file kept)
verify_checksum() already existed in the module, but download() never called it.
Fix: after the loop, if total_size > 0 and downloaded != total_size, unlink and raise
DownloadError.
๐ด 02 โ limit applied before the filters [P] โ โ
FIXED
catalog.py:92 (query) and :120 (filtered[:limit]); identical in async_client.py:168,
:191. The cloud-cover and center-point filters run client-side on a page the server has
already truncated. The STAC response's next link is ignored: no pagination at all.
This hits every search with a cloud filter โ which is the README's headline example.
Fix: either move the cloud filter into the STAC query, or paginate until limit is
satisfied. A design choice, not a one-liner. Pagination was chosen; see
Decisions.
๐ด 03 โ Async: partial files kept forever [L] โ โ
FIXED
async_client.py:227, :239-266. No try/except (the sync path at least calls unlink()).
The partial file stays on disk, download_all:294 only logs a warning, and on the next run
:227 if output_path.exists(): return output_path hands back the corrupt file. No recovery
path exists.
๐ 04 โ The catalog never refreshes its token [P] โ โ
FIXED
client.py:73 captures the OAuth2Session once and passes it to Catalog, which keeps it
forever. No auto_refresh_url is configured anywhere.
Wrapped in CatalogError by catalog.py:128, so it is not a crash โ but it does not recover
either: it needs refresh_auth(), which no example documents. Scenario: search(), then a
20 GB download(), then search() again, which fails. This is the same problem 0.4.0 solved
for the downloader with _BearerSession.
๐ 05 โ Async: token never refreshed during a batch [L] โ โ
FIXED
async_client.py:219 calls _ensure_session() before :239 async with self._semaphore. In
download_all every coroutine starts together and calls _ensure_session() immediately, then
queues on the semaphore. From that point on nobody refreshes the token again. With
max_concurrent=4 and gigabyte products, any batch running past ~10 minutes ends in 401.
The 0.4.0 CHANGELOG promises the opposite.
๐ 06 โ Session shared across threads [L]
downloader._download_parallel (4 workers by default) hands the same session object to all of
them. requests.Session is not thread-safe. On top of that, auth.py:32-35 does
check-then-refresh with no lock, so several threads can enter _authenticate() together and
fetch tokens concurrently against an endpoint that answers 429.
๐ 07 โ CLI: exit 0 when nothing is downloaded [P] โ โ
FIXED
cli.py:cmd_search ends with an unconditional return 0. download_all returns only the
successful paths, so 10 failures out of 10 print Downloaded 0 files. and exit 0. In CI or a
scheduled script, total failure passes for success.
๐ก 08 โ MultiPolygon: only the first polygon [L]
catalog.py:232, coords = coords[0] if coords else []. Products crossing the antimeridian
(polar Sentinel-1, -3, -5P) are MultiPolygons: the second half is never evaluated and the
product is dropped by the center-point filter. The except (IndexError, TypeError): return
True fallback masks every malformed geometry. Combined with 02, the result set shrinks twice.
๐ก 09 โ Async/sync divergence [L] โ โ
FIXED
async_client.py:309returnsproduct.download_urlwithout thes3://guard the sync path has explicitly (downloader.py:377-381). Ans3://asset makes the async path fail on an unsupported URL scheme.- The async
searchdoes not apply the sync path's center-point filter, so the two return different result sets.
๐ก 10 โ max_retries=0 raises UnboundLocalError [P] โ โ
FIXED
downloader.py:129-133. If the loop never runs, response is never assigned and
last_exception stays None. max_retries is a public, documented parameter.
๐ก 11 โ CLI: ValidationError escapes as a traceback [P] โ โ
FIXED
cli.py:main catches AuthenticationError, CatalogError, DownloadError and
KeyboardInterrupt. ValidationError is a sibling under CDSEError, not a subtype of any of
those, so it is not caught. Side note: validation happens after authentication (the catalog
property triggers it), so an invalid bbox still costs a network round-trip.
๐ก 12 โ URL-resolution errors swallowed [L] โ โ
FIXED
downloader.py:398, except Exception: return None. Failed auth, a down network and malformed
JSON all become the same "Could not determine download URL for product". Diagnosis is
impossible.
โช 13 โ Pointless sleep after the final retry [P] โ โ
FIXED
downloader.py:109. Three attempts all returning 429 sleep 7s (1+2+4); the last 4 seconds
precede nothing but the exit from the loop.
โช 14 โ Streamed responses never closed between retries [P] โ โ
FIXED
downloader._request_with_retry: response.close() called 0 times across 3 retried requests.
With stream=True those are connections left open.
๐ด 15 โ L1C via ZIP: no band ever extracted [P] โ โ
FIXED
_extract_bands_from_zip filtered entry names with res_pattern = f"R{resolution}m".
Sentinel-2 L1C products have no resolution folders R10m/R20m/R60m: the JP2s sit directly
in IMG_DATA/. The pattern never matched and the function returned {}.
The folder extractor handled this explicitly, comment and all
(res_folder = img_data # L1C doesn't have resolution subfolders). The ZIP one did not โ and
the ZIP one is what matters: CDSE delivers ZIPs, and sentinel-2-l1c is one of the six
supported collections.
L2A, bands B04/B03/B02 @10m -> ['B02', 'B03', 'B04']
L1C, bands B04/B03/B02 @10m -> [] <-- L1C has no R10m folder
crop_and_stack(l1c_zip, bbox)
-> ValidationError: No bands found in L1C.zip
The error was not silent, but it was misleading: it said there were no bands when in fact every band was there.
Fix applied: _extract_bands_from_zip checks once whether the archive really contains an
R{res}m folder; when it does not (L1C) the resolution filter is not applied. This is the
same logic as the folder branch (if not res_folder.exists(): res_folder = img_data), and
deliberately not a per-band fallback: asking for B11 at 10m on an L2A product still finds
nothing, rather than quietly handing back the 20m file. A test pins that boundary.
๐ด 16 โ Missing bands: bare KeyError, or a silently shorter stack [P] โ โ
FIXED
extract_bands_from_safe returned only the bands it found, saying nothing about the rest.
Two outcomes followed, both wrong:
stack_bands(found, out, band_order=["B04","B03","B02"]) # B02 not found
-> KeyError: 'B02' # bare exception, no context
stack_bands(found, out) # no band_order
-> no error; 3 bands requested, output has 2 # silent loss
The trigger lives inside the module itself: BAND_COMBINATIONS mixes resolutions, while
crop_and_stack defaults to resolution=10.
BAND_COMBINATIONS['agriculture'] = ['B11', 'B08', 'B02'] # B11 is 20m-only
crop_and_stack(zip, bbox, bands=BAND_COMBINATIONS['agriculture'], resolution=10)
-> KeyError: 'B11'
The same holds for vegetation and all_20m. In other words: the combinations the module
advertises failed at the module's own default resolution.
Fix applied: validate against SENTINEL2_BANDS before opening the product. Unknown band
names, unsupported resolutions and bands coarser than the requested resolution are refused up
front, with a message saying which resolution to ask for instead. The information was already
in the table; nothing had been reading it.
The band/resolution check applies to L2A only: it is the only product level shipping the
resampled R10m/R20m/R60m copies, hence the only one where resolution selects anything.
On L1C every band sits at its native resolution (see 15) and the argument selects nothing, so
validation stays lenient there โ as it does whenever the product level cannot be read from the
filename.
Whatever passes validation is still re-checked after extraction: a band the product does not
contain ends in a ValidationError that names it, rather than going missing from the returned
dictionary. stack_bands does the same for band_order, and both argument checks run
before the optional rasterio import: a caller who got the band list wrong should be told
that, not that rasterio is missing.
The comment in BAND_COMBINATIONS now marks which three combinations want resolution=20,
and the processing guide explains it.
Follow-up, found by smoke-testing the fix before release. The first version of this
validation modelled availability as native_resolution <= requested, which is wrong in both
directions. The real L2A layout is not monotonic:
| Folder | Bands |
|---|---|
R10m |
B02, B03, B04, B08 |
R20m |
B01, B02, B03, B04, B05, B06, B07, B8A, B11, B12 |
R60m |
the 20m set, plus B09 |
B08 has no 20m copy โ B8A is its counterpart โ while B01 and B09 are resampled up into the
coarser folders, and B10 is dropped from L2A entirely. Under the old rule, B01 at 20m was
falsely rejected, and B08 at 20m was accepted and then failed later with a hint pointing at
resolution=20, which is precisely where it does not exist. Availability is now an explicit
set per folder (L2A_BANDS_BY_RESOLUTION).
The same check surfaced a defect in the shipped constants: agriculture (B11, B08, B02) and
vegetation (B08, B11, B04) paired a band with no 10m copy with a band with no 20m copy, so
neither could be satisfied at any resolution. Both now use B8A.
โช 29 โ crop_to_bbox discarded band descriptions [P] โ โ
FIXED
stack_bands calls set_band_description for every band, and crop_to_bbox then rebuilt the
output from src.meta, which does not carry descriptions. The end product of crop_and_stack
therefore had no band names at all, and nothing said which of the three bands was red. Found
by the same smoke test; the descriptions are now copied across, following a band subset when
bands= is given.
๐ 17 โ crop_to_bbox outside the tile: raw ValueError [P]
This comes straight from rasterio.mask, unwrapped. Sentinel-2 tiles are ~110 km, so passing
a bbox from a different area is an ordinary user error, not an edge case.
๐ 18 โ crop_to_bbox on a raster with no CRS: AttributeError [P]
src.crs.to_epsg() without checking that src.crs exists.
๐ก 19 โ The ZIP extractor loads each whole band into RAM [P]
dst.write(src.read()) in _extract_bands_from_zip. A 10 m JP2 weighs 100-150 MB, so four
bands are a half-gigabyte peak. shutil.copyfileobj does the same thing in constant memory.
โช 20 โ calculate_ndvi emits a numpy RuntimeWarning [P]
np.where(denominator > 0, (nir - red) / denominator, 0) evaluates both branches, so the
division by zero happens regardless and the NaNs are discarded afterwards. The result is
correct (verified: all finite), but every call dirties the output. Fixed by
np.divide(..., out=..., where=denominator > 0).
๐ 30 โ Centre-point filter drops tiles for wide areas [L] โ โ
FIXED
Catalog.search always applied _filter_by_center_point, keeping only products whose
footprint contains the centre of the bbox. For a field that is the right tile. For a bbox
three degrees wide it is one tile out of three, with no warning: a regional user got a third
of the coverage and no way to ask for the rest.
Fix: coverage="center" (default, unchanged) or coverage="any" on every search method
and --coverage on the CLI. Validated before any request; the async client shares the check.
๐ 31 โ .SAFE appended to every product name [L] โ โ
FIXED, live run pending
_get_download_url, download_quicklook and the async _get_download_url all did
if not name.endswith(".SAFE"): name += ".SAFE" before the exact Name eq lookup. Sentinel-3
products are .SEN3 containers and Sentinel-5P products are single .nc files, so for two of
the six advertised collections the lookup could never match and every download ended in
"Could not determine download URL for product". Not reproduced against the live API here
(no credentials); read from the source and from the CDSE naming conventions.
Fix: a single _odata_name() chooses the suffix by mission prefix (S3 โ .SEN3,
S5P โ .nc, otherwise .SAFE) and leaves a name that already carries a suffix alone. Whether
Sentinel Hub STAC ids come with or without the suffix is unknown, which is why both cases are
handled. To confirm with credentials: run examples/s5p_no2_monthly.py.
Checked and found clean
create_rgb_previewwith too few bands already raises a clear, typed error:ValidationError: Band 2 not found. File has 1 bands.create_rgb_previewon a zero-variance image neither blows up nor warns.crop_and_stackon the happy path (all bands present at the requested resolution) works.reprojectis correct. One non-defect note:Resampling.bilinearis hard-coded, so it is not suitable for categorical rasters such as SCL.
๐ 21 โ STAC results carry no size: every search reports 0.00 GB [P]
Product.size reads properties["size"] or properties["content-length"]. Sentinel Hub's
STAC response contains neither: only the OData branch populates size, from
ContentLength (catalog._odata_to_product).
STAC search result: size = None size_mb = None
products_size(5 Sentinel-2 L2A scenes) = 0.00 GB
(one L2A scene weighs 0.6-1.1 GB)
Concrete consequences:
- the CLI prints
Found N products (total: 0.00 GB)after every search; - in
to_dataframethesizeandsize_mbcolumns are allNone; to_geojsonwrites"size": nullinto every footprint.
Fix: the size is not in the STAC response, so it has to come from OData (one lookup per product, expensive) or the CLI must stop printing a total it does not know. A choice, not a one-liner.
๐ก 22 โ to_dataframe([]) returns a 0ร0 frame with no columns [P]
to_dataframe([]) -> shape=(0, 0) columns=[] index.name=None
df.sort_values("cloud_cover") -> KeyError: 'cloud_cover'
set_index("id") is skipped when the frame is empty, so not even the documented index comes
back. The example in the function's own docstring (df.sort_values(...)) fails.
to_geodataframe handles the empty case correctly, with columns and a CRS: the two siblings
behave differently.
๐ก 23 โ to_geojson produces invalid GeoJSON [P]
For products coming from OData, bbox is always [] and geometry can be {}:
feature bbox : [] <-- the standard wants 4 or 6 numbers, or the field absent
feature geometry : {} <-- should be null
This affects cdse search -f footprints.geojson and client.to_geojson(...). Some readers
(QGIS, geopandas) reject or misread these fields.
๐ก 24 โ get_city_bbox returns a tuple that search() rejects [P]
get_city_bbox("Paris, France") -> tuple (2.144638, 48.714865, 2.555362, 48.985135)
catalog.search(bbox=<tuple>) -> ValidationError: bbox must be a list of 4 values
search_by_city works because internally it does list(bbox), but composing the two public
functions directly does not. No example in the current documentation does this, so it is a
latent inconsistency rather than a broken example.
Fix: accept any sequence in _validate_bbox, or return a list.
๐ก 25 โ search_by_city docstring incompatible with use_predefined=True [P]
The docstring documents city_name as "Milano, Italia", "Paris, France". But with
use_predefined=True the lookup is an exact-key match:
get_predefined_bbox('milano') -> (9.04, 45.386, 9.278, 45.536)
get_predefined_bbox('Milano, Italia') -> None
get_predefined_bbox('Paris, France') -> None
The documentation site correctly uses "milano" for the offline mode, so the damage is
confined to the docstrings โ which nonetheless contradict each other inside the same function.
Fix: normalise by stripping everything after the comma, or align the docstrings.
๐ก 26 โ Nominatim: retries with no pause, and a shared user agent [P]
The retry loop tries 3 times with no pause at all between attempts, and the default value
is user_agent="cdse-client" for every installation of the library.
Nominatim's usage policy caps requests at 1 per second and requires an identifying, unique user agent. Violating it gets the user agent blocked โ and because it is shared, a single aggressive user can get the function blocked for everyone.
โช 27 โ Near the poles the buffer produces an absurd bbox [P]
lon_buffer = buffer_km / (111.0 * cos(radians(lat))):
lat=45.0 lon_buffer = 0.2 degrees
lat=89.0 lon_buffer = 7.7 degrees
lat=89.9999 lon_buffer = 77,426.7 degrees
lat=90.0 lon_buffer = 2.2e15 degrees
It is not silent: _validate_bbox catches it. But the message talks about longitude being out
of range instead of saying the buffer makes no sense at that latitude.
โช 28 โ geojson_to_wkt on empty coordinates emits POLYGON () [P]
Invalid WKT, returned without an error.
Checked and found clean
geometry.pyis solid: Polygon and MultiPolygon roundtrips are preserved, and typed errors are raised for an empty dict, an unknown type and missing coordinates.validate_geometryraises instead of returningFalse, but that is documented behaviour in the docstring, not a defect.search_by_cityconverts correctly withlist(bbox).Product.from_stac_featureextracts name, collection, cloud cover and datetime correctly; every ISO format tested parses on Python 3.12.to_geodataframehandles the empty list correctly, with columns and a CRS.products_countis correct.
One thing that could not be verified here
datetime.fromisoformat before Python 3.11 accepts only 3 or 6 fractional-second digits. A
timestamp such as ...T10:23:51.24Z would be discarded silently
(except ValueError: pass โ datetime = None) on 3.9 and 3.10, while it parses on 3.12, where
this was tested. It could not be reproduced here: it needs confirming on a real 3.9.
What is still open
Ten defects. None is critical. The highest are ๐ : 06 and 21.
๐ 06 โ Session shared across threads [L]
downloader._download_parallel (4 workers by default) hands the same session object to all of
them. requests.Session is not thread-safe. auth.py does check-then-refresh with no lock, so
several threads can enter _authenticate() together and fetch tokens concurrently against an
endpoint that answers 429.
This is the only open defect that touches design: the choice is between a session per thread
(threading.local), a lock around the refresh, or both. The async path already solves the same
problem with an asyncio.Lock in _refresh_token โ that pattern can be mirrored.
The remaining open defects โ 19 in processing.py, 21 in product.py, 22 and 23 in
converters.py, 24 through 27 in geocoding.py, 28 in geometry.py โ are described in full
in the Detail section above.
Areas of the code still to check
The screening of src/cdse is complete. All 13 modules have been examined.
- โ
processing.pyโ 6 defects (15-20). The suspicion was right: it is all in the ZIP/SAFE parsing and in the handling of bands that are not found. - โ
product.pyโ 1 defect (21), which nonetheless propagates into the CLI and the converters. - โ
converters.pyโ 2 defects (22, 23). - โ
geocoding.pyโ 4 defects (24-27). None severe, but 26 is operational: it can get the shared user agent blocked for everyone. - โ
geometry.pyโ 1 negligible defect (28). The most solid module of the set.
What remains unexamined in depth are only the visualisation paths of processing.py
(preview_product, quick_preview, create_thumbnail, compare_previews,
_display_in_jupyter): they depend on matplotlib and IPython, the impact is low, and a defect
there shows up immediately by eye.
Decisions taken
Recorded here because they cannot be inferred from the code or from the git history.
The 1.0.0 release
| Decision | Choice | Why |
|---|---|---|
| Version number | 1.0.0, not 0.5.0 | The API had been stable since 0.3.0. Staying in 0.x communicated "not ready" and was one of three abandonment signals |
Development Status |
5 - Production/Stable | Consistent with 1.0.0. Keeping 4 - Beta alongside 1.0.0 would have been a contradictory signal |
| A 2.7 MB notebook | Removed, not moved or cleaned | It was 92% of the tracked bytes and made the repository classify as Jupyter Notebook, excluding it from ?l=python topics. The content already existed in docs/ and examples/ |
| Git history | Not rewritten | Linguist only looks at the current tree. A rewrite would have broken clones for no benefit |
| Positioning | Lead with the task, sentinelsat second |
The package ranked first for "sentinelsat alternative CDSE" but was absent from "download Sentinel-2 python". A dying niche against real demand |
| Version in the code | Single-sourced from cdse.__version__ via dynamic |
It used to be duplicated across two files kept in sync by hand |
Fixes
| Decision | Choice | Why |
|---|---|---|
Defect 02 (limit before the filters) |
Option B: pagination | The center-point filter cannot live server-side, so pagination is needed anyway. Moving the cloud filter into the STAC query stays a possible future optimisation, to be validated against real credentials |
| Page token | Accept both forms, context.next and a STAC next link |
Which one the API actually returns could not be verified. Accepting both makes the fix independent of that assumption |
catalog.py helpers |
Made static and shared with the async path | They did not use self and were about to be duplicated |
| Defect 09 | Closed together with 02 | Aligning the async path already required touching the same function |
skip-existing on PyPI |
Kept, despite the review flagging it | A version can never be re-uploaded to PyPI: without that flag, any run failing after the upload becomes unrepeatable. A deliberate trade-off, documented in the workflow |
| Branch | fix/correctness-sync-path, not main |
Fixes get reviewed before entering a release marked Production/Stable |
| Release number for the fixes | 1.1.0, not 1.0.1 | Defect 16 changes observable behaviour: extract_bands_from_safe used to return a short dictionary and now raises ValidationError. No signature changes, so it is not major; but 1.0.1 would have told users there was nothing to read when there was |
| Defect 15 (L1C via ZIP) | Detect the R{res}m folder once, not a per-band fallback |
It mirrors the folder branch. A per-band fallback would have quietly returned the 20m file to someone asking for 10m |
| Defect 16 (missing bands) | Validate against SENTINEL2_BANDS before extracting |
The information was already in the table and nobody was reading it. The error now arrives before the archive is opened and says which resolution to ask for, instead of surfacing halfway through stacking |
| Scope of the 16 validation | Strict on L2A only | It is the only level with the resampled copies, so the only one where resolution selects anything. On L1C, rejecting B11 at 10m would have been a false positive, since the file is there |
| This document | Published under docs/ |
It was kept out of the repository while critical defects were open next to a Production/Stable release. With none left, an audit that is visible says the code has been looked at rather than the opposite |
Still to decide
- Defect 06 โ a session per thread (
threading.local), a lock around the refresh, or both. The async path already solves it with anasyncio.Lock; that pattern can be mirrored. - Defect 21 โ whether to fetch the size via an OData lookup (one request per product, expensive) or stop printing a total that is not known.
processing.pytests in CI โ whether to add rasterio to a test extra (a heavy wheel, across six matrix versions) or accept that those paths stay unverified.
Notes
- All five critical defects are closed (01, 02, 03, 15, 16). The first three shared the
worst signature โ silent loss with no error raised โ and the two in
processing.pydid too, in a different form: a missing band that reported to nobody. - A slice of the test suite does not run in CI.
ci.ymlinstalls only.[dev], so every file thatimportorskips an optional dependency is skipped across all matrix versions:test_processing.py(rasterio) andtest_async_client.py(aiohttp) entirely, plus a dozen scattered tests intest_converters.py(pandas, geopandas) andtest_geocoding.py(geopy). That is 18 skips out of 154 collected. It matters because the async tests and theprocessing.pytests cover exactly the modules the critical defects came out of. That is why the tests for 15 and 16 live intests/test_processing_bands.py, which is plainzipfilework and runs everywhere. - Bandit exits 1 on any finding, not only on High ones. The
securityjob went red over anassert(B101, severity Low) introduced by the fix for 05. Bandit's output makes this easy to misread: it prints a table by severity and a table by confidence, and the "High: 1" line at the bottom was confidence High, severity Low. Resolved by removing the assert, whichpython -Owould have stripped anyway. - Defect 21 has the same character as the silent ones: it breaks nothing, but it makes the CLI print a 0.00 GB total after every search, which is simply false.
- Two existing tests encoded defect 01 (
test_download_successandtest_download_alldeclared acontent-lengththat did not match the payload). Worth keeping in mind while looking at the other modules: a passing test does not prove the behaviour is right. - Defect 06 is the only one left that needs a design choice. Defect 08 was closed in
September 2026 together with 17, 18 and 20, while adding the pieces the persona examples
needed (see the
[Unreleased]changelog entry). - To do once real credentials are available: verify pagination (defect 02) against the live
API. The code accepts both Sentinel Hub's
context.nextand a STACnextlink, but which form the API actually returns could not be confirmed.