SDSS data product paths, without the environment variables
Project description
sloppy-sdss-access
A prototype replacement for the SDSS-5 half of sdss_access, built on modern tooling.
Legacy SDSS-1..4 (DR7–DR17) is deliberately out of scope — that stays with
sdss_access + sdss-tree, where the SVN/module/plate machinery earns its keep.
from sloppy_sdss_access import SDSS
dr19 = SDSS("dr19")
dr19.path("astraAllStarASPCAP")
# 'dr19/spectro/astra/0.6.0/summary/astraAllStarASPCAP-0.6.0.fits.gz'
dr19.url("specLite", fieldid=15000, mjd=59146, catalogid=4375924756)
# 'https://data.sdss5.org/sas/dr19/spectro/boss/redux/v6_1_3/spectra/lite/015000/59146/spec-015000-59146-4375924756.fits'
The core idea
Every $ENVVAR in a tree path template is itself defined inside the same tree
config file, as %(...)s interpolations rooted at FILESYSTEM. So they can all
be resolved once, at build time, and each template stored as a plain path
relative to the SAS root.
That single change removes the need for environment variables entirely:
sdss_access |
sloppy-sdss-access |
|
|---|---|---|
env vars injected into os.environ |
+97 | +0 |
| two releases in one session | mutate shared global state | independent frozen objects |
| dependencies for path resolution | sdss-tree, requests, six, … |
none |
| transports | rsync, curl, http | anything fsspec speaks (https, s3, file, …) |
Resolution speed (5,000 iterations each, same machine, DR19):
| product | sdss5 |
sdss_access |
speedup |
|---|---|---|---|
astraAllStarASPCAP (0 derivations) |
0.66 µs | 10.8 µs | 16× |
apStar (1 derivation) |
1.56 µs | 55.1 µs | 35× |
specLite (2 derivations) |
2.41 µs | 149.5 µs | 62× |
The gap widens with the number of derivations, because sdss_access AST-parses
its own special-function source code and stats the filesystem on every call.
No environment variables required
Path resolution consults the environment not at all. With no SAS_BASE_DIR
and no ~/.netrc, path() and url() work unchanged, and public DR data needs
no credentials whatsoever.
local() is the one method that needs a local root, and it takes the first of:
explicit SDSS(root=...), then $SAS_BASE_DIR if set (read-only, purely so an
existing sdss_access machine works out of the box), then ~/sas.
For collaboration data without a netrc, credentials come from $SDSS_USER /
$SDSS_PASSWORD, from explicit arguments, or from an opt-in prompt:
Access(SDSS("sdsswork"), username="...", password="...") # explicit
Access(SDSS("sdsswork")) # $SDSS_USER/$SDSS_PASSWORD
Access(SDSS("sdsswork"), allow_prompt=True) # ask on a terminal
If none are available the AuthError spells out all four options.
Install
uv add sloppy-sdss-access # or: pip install sloppy-sdss-access
uv add "sloppy-sdss-access[s3]" # + s3fs for cloud buckets
Installs as sloppy_sdss_access:
from sloppy_sdss_access import SDSS, Access
fsspec and aiohttp are required rather than optional: a package whose point is
fetching data should be able to fetch data straight after install. Resolving a path
still imports nothing beyond the standard library.
What it does
Paths. 1,958 product definitions across all 8 SDSS-5 releases
(sdsswork, ipl1–ipl4, dr18–dr20), compiled to a static registry.
dr19.search("astraAllStar*") # glob the product list
dr19.keys("apStar") # ('apred', 'healpix', 'obj', 'telescope')
print(dr19.describe("specFull")) # template, required/optional keys, derivations
Release-implied versions — #73, #98. DR19 knows its own pipeline versions, so you don't restate them:
dr19.defaults # {'run2d': 'v6_1_3', 'apred': '1.4', 'v_astra': '0.6.0'}
Only DR19 is seeded — see Not done.
Required vs optional keys — #100,
partly. Required-ness is declared per product; omitting a required key raises an
error naming it. A key is optional only where absence has a defined meaning (a
blank component, a merged-observatory obs, ftype defaulting to FITS).
Per-key default values are not implemented — only release-wide defaults.
Multiple releases at once — #34,
#97. SDSS is a frozen
dataclass holding its own registry. Nothing is global, so the interference bug
in #34 cannot occur within SDSS-5. Mixing DR17 and DR19 still means running
legacy sdss_access alongside, which does replant the tree.
Authentication — #95.
Public DRs need no credentials. sdsswork, the IPLs, and any DR whose release
date has not yet passed (DR20 is dated 2026-07-30) sit behind HTTP Basic auth.
Credentials resolve from the first source that supplies them:
- passed explicitly —
Access(..., username=..., password=...) $SDSS_USER/$SDSS_PASSWORD— CI, containers~/.netrckeyed by host — the existingsdss_accesssetup- an interactive prompt — opt-in via
allow_prompt=True, skipped without a TTY
Passwords are kept out of repr/str, and a world-readable netrc warns. Unlike
sdss_access.sync.auth, nothing prompts implicitly — a library that blocks on
input() inside a batch job is a bug.
Streaming — #96. Read a header without transferring the file:
from sloppy_sdss_access import Access
a = Access(dr19)
with a.open("astraAllStarASPCAP") as fp:
header = fp.read(1024)
fsspec fetches one 5 MiB block to serve that read, not the full 1.17 GB file.
Compression probing — #66.
Tree templates and the SAS disagree about compression more often than you would
hope: sdsswork's astraAllStarASPCAP is templated .fits but stored
.fits.gz (DR19's template has it right). Access returns the real variant:
a.uri("astraAllStarASPCAP", v_astra="0.6.0") # ...-0.6.0.fits (as templated)
a.resolve_uri("astraAllStarASPCAP", v_astra="0.6.0") # ...-0.6.0.fits.gz (as stored)
Probing is declared in the registry, not attempted blindly. Each product
carries compression (the suffix its template already names) and
may_be_compressed:
| products | probes | |
|---|---|---|
template already names .gz/.bz2/.fz |
142 | none |
extension never compressed (.png, .parquet, .h5, .apz) |
452 | none |
may_be_compressed |
1,364 | one, then cached |
and the correction is learned once per species, not per file — the SAS does
not compress one mwmStar and not the next. Measured against the live SAS:
mwmStar, 5 files -> 1 exists() call (learned {'mwmStar': ''})
sdsswork astraAllStar… -> 2 calls, then 0 (learned '.gz')
dr19 astraAllStar… -> 0 calls (.gz already in template)
probe_compression=False -> 0 calls
So a loop over 10,000 stars costs one extra request, not 10,000.
sdss_access does this by stat-ing the local filesystem, so it only
self-corrects on a machine with a SAS mount — remotely it returns the broken
.fits URL just as this did before.
Async downloads — #99.
a.fetch_many(items, concurrency=8) # blocking
await a.afetch_many(items) # inside an event loop
a.fetch_many(items, skip_missing=True) # missing files -> None, #89
Genuinely async (fsspec asynchronous=True + asyncio.gather under a
semaphore), not merely named so. Downloads are content-cached, so a repeat call
is ~1 ms rather than a re-transfer.
Cloud — #101.
Access(dr19, protocol="s3", bucket=...) constructs s3:// URIs. Untested —
SDSS data is not on MAST yet, no bucket exists to check against, and S3
credentials are not wired.
SDSS-4 (DR13–DR17)
Added as an experiment. 3,712 products across 13 releases; parity is 16,820 comparisons with zero divergences.
SDSS-4 is not separable from SDSS-III. DR13–DR17 all chain back to DR8, and the leaf configs are nearly empty on their own (DR14 defines 3 path entries; DR8 defines 164). Ingesting SDSS-4 means ingesting DR8–DR12 by inheritance. DR13–DR17 are exposed as releases; DR7–DR12 are inherited-from but not offered.
The $PRODUCT_ROOT problem — why some SDSS-4 paths cannot be compiled
The whole approach here rests on one property: every $ENVVAR in a tree
template is defined inside the tree config itself, rooted at FILESYSTEM, so
it can be resolved at build time into a path relative to the SAS root.
A large minority of SDSS-4 templates break that property. They do not point at SAS data at all — they point at svn/git software product checkouts on the user's own machine:
mangaslitmap = $PRODUCT_ROOT/repo/manga/mangacore/tags/v1_2_3/slitmaps/@plategrp|/{plate}/slitmap-{plate}-{mjd}-{plugging:0>2d}.par
plateHoles = $PLATELIST_DIR/plates/@platedir|/plateHoles-@plateid6|.par
$PRODUCT_ROOT is defined in no tree config. tree.py synthesises it at
runtime from the first of $PRODUCT_ROOT, $SDSS_GIT_ROOT, $SDSS_SVN_ROOT,
$SDSS_INSTALL_PRODUCT_ROOT, $SDSS_PRODUCT_ROOT, $SDSS4_PRODUCT_ROOT,
falling back to one directory above $SAS_BASE_DIR. The products underneath it
(platelist, speclog, mangacore, mangapreim, bosstilelist) are
separately versioned repositories you check out yourself, pinned to tags like
v1_2_3 written into the template.
These are therefore irreducibly environment-dependent. There is no
build-time value to bake in, because the answer is a property of the machine,
not of the archive. Roughly 150 SDSS-4 paths are in this class; they are marked
external in the registry and refuse to resolve rather than emitting a path
with a literal $PRODUCT_ROOT in it.
This is the design flaw the split is meant to address. sdss_access treats
"a file on the SAS" and "a file in a source checkout" as the same kind of
object, resolved by the same $ENVVAR mechanism, differing only in which
variables happen to be set. That is why the package needs os.environ at all,
why it carries check_modules() and force_modules, and why $SAS_BASE_DIR
feels mandatory. Data paths and code paths have genuinely different semantics:
data is immutable, archived, addressable by URL, and identical for every user;
a product checkout is mutable, local, version-pinned, and different per machine.
Collapsing them forces the data case — the overwhelmingly common one — to pay
the environmental cost of the code case.
Splitting them is what buys the zero-env-var property for everything else. Doing it properly means an explicit second resolver, e.g.
SDSS4(release="dr17", product_root="~/software") # not implemented
so software-product paths are opt-in, obviously environment-dependent at the
call site, and cannot silently contaminate data-path resolution. Until that
exists, external products raise.
The BOSS/EBOSS section deletion
tree.py resolves a variable conflict by deleting an entire config section:
if 'EBOSS' in cfg.sections() and 'BOSS' in cfg.sections():
cfg.remove_section('BOSS')
The intent is right — [EBOSS] redefines BOSS_SPECTRO_REDUX from
$BOSS_ROOT to $EBOSS_ROOT, and EBOSS should win for DR13+. Verified against
the archive: dr17/eboss/spectro/redux/ exists, dr17/boss/spectro/redux/ does
not.
But deleting the whole section also discards BOSS_GALAXY_REDUX, which only
[BOSS] defines. So for DR13–DR17, sdss_access cannot resolve it and returns
a path with the variable still in it:
Path(release="dr17").full("portsmouth_emlinekin", galaxy_vers="v1", run2d="v5_13_2")
# '$BOSS_GALAXY_REDUX/v1/portsmouth_emlinekin-v5_13_2.fits'
11 products are affected in each of DR13–DR17: the portsmouth_*,
granada_fsps*, and wisconsin_pca* VACs. This build overrides per variable
rather than per section, so the definition survives and points at the EBOSS root.
Practical impact is low. Those VACs exist only in DR12
(dr12/boss/spectro/redux/galaxy/ is real; no DR13+ equivalent exists under
either root), so DR13–DR17 are carrying stale templates forward. This build's
answer is well-formed but points at nothing real either — it is better than a
literal $VAR, not correct.
Migrating from sdss_access
from sloppy_sdss_access import Path keeps working. full(), location() and url()
were verified byte-identical against the real package (tests/test_compat.py
pins the reference values).
| legacy | here |
|---|---|
Path(release="dr19") |
same, or SDSS("dr19") for the native API |
.full(sp, **k) |
same — absolute, under root=/$SAS_BASE_DIR/~/sas |
.location(sp, **k) |
same — or native SDSS.path() |
.url(sp, **k) |
same, including the data.sdss.org vs data.sdss5.org split |
RsyncAccess().add(); .set_stream(); .commit() |
Access(SDSS(r)).fetch_many([(sp, keys), ...]) |
HttpAccess().get(sp, **k) |
Access(SDSS(r)).fetch(sp, **k) |
Auth/set_auth(inquire=True) |
sdss_access.auth — never prompts unless asked |
SDSSvsPath.SDSSis the native API (.path(),.local(),.url());Pathis the legacy-shaped shim (.location(),.full(),.url()). They resolve identically —Pathjust delegates. New code should useSDSS.
Things that deliberately behave differently:
- Missing keys raise. Legacy could emit a malformed path with an empty
segment (
spec--59797-….fits); here it is aMissingKeyserror naming the key. - Undefined variables raise. Legacy returned paths containing a literal
$APOGEE_DATA_S; here it isUnresolvableProduct. - Release defaults.
Path("dr19").location("specLite", …)no longer needsrun2d— DR19 impliesv6_1_3. Legacy raisedKeyError. - No
os.environwrites. Constructing a legacyPathreplanted the tree and overwrote ~97 variables (andmakedirs-ed yourSAS_BASE_DIR). Nothing here writes to the environment. - No rsync or curl.
RsyncAccess/CurlAccessraise with a pointer tofetch_many(). - DR7–DR12 unavailable.
Path(release="dr12")raises at construction.
Verification
Parity with sdss_access. tools/parity_check.py resolves every product in
every SDSS-5 release with both implementations, under five key scenarios each:
dr18 match=435 skipped=9
dr19 match=1580 skipped=18
dr20 match=1960 skipped=20
ipl1 match=515
ipl2 match=535
ipl3 match=940 skipped=27
ipl4 match=1005 skipped=27
sdsswork match=2225 skipped=18
TOTAL: match=9195 skipped=119
Derivation coverage: 17/20 exercised
every derivation ran and produced >=2 distinct outputs
RESULT: PASS
9,195 comparisons, zero divergences, zero errors. The 119 skipped are external
products (under $PLATELIST_DIR etc., outside the SAS) and the 37 broken ones
below; neither implementation can resolve them, so they are excluded rather than
counted as passes.
Why five scenarios, and a coverage gate. An earlier version of this harness
pinned run2d="v6_1_3", which makes every reorganised-BOSS-layout branch dead.
Ten of the derivations then returned a single constant all run and five were never
called — half of derive.py could have been replaced with hardcoded strings and
the check would still have passed. The scenarios now vary run2d, telescope,
obs, ftype and numeric magnitude, and the run fails unless every
derivation is exercised with ≥2 distinct outputs. Mutation-tested: replacing
pad_fieldid, spcoaddobs, sptypefolder or healpixgrp with a constant is
now caught (all four FAIL).
Three derivations (configgrp, configsubmodule, platedir) are reachable only
from external products, so parity cannot cover them; they are unit-tested and
explicitly exempted from the gate rather than silently counted.
Known blind spot. The comparison normalises away .gz/.bz2/.zip/.fz
suffixes, because legacy probes the filesystem and nothing exists locally. So
parity says nothing about #66
(compressed-file resolution), which is not implemented here at all.
Against the live SAS (2026-07-20), public DR19: resolved paths for
astraAllStarASPCAP and specLite confirmed to exist; 4 real spectra downloaded
concurrently in 3.2 s; a range read from a 1.17 GB file returned valid gzip magic.
Against collaboration-only sdsswork, with real ~/.netrc credentials:
- anonymous
HEAD->401 WWW-Authenticate: Basic realm="SDSS-V Science Archive Server (SAS)"; same request with netrc credentials ->200 - credential resolution picked up
~/.netrcand listed collaboration-only directories through fsspec - compression probing corrected
astraAllStarASPCAP-0.6.0.fits->.fits.gzand streamed 64 bytes from the 1.17 GB file fetch_manydownloaded 5 realmwmStarspectra concurrently in 6.7 s; re-fetch from cache took 1 ms- a downloaded file opens as valid FITS (5 HDUs) whose
SDSS_IDheader equals thesdss_idkey it was resolved from -- a full key -> path -> auth -> download -> correct-file round trip
pytest # 96 tests
python tools/parity_check.py # needs sdss-access installed too
sdss-access must be installed alongside for the differential check; the two have
distinct module names and coexist without interference.
pip install sdss-access
python tools/parity_check.py
Two bugs this surfaced in the current stack
1. 37 product definitions reference environment variables their own release
never defines. sdss_access uses os.path.expandvars, which leaves unknown
variables untouched, so these silently return a path with a literal $VAR in it:
Path(release="dr19").full("asR", mjd=59797, chip="a", num=1)
# '$APOGEE_DATA_S/59797/asR-a-00000001.apz'
Affected: asR, cannonStar, cannonStar-1m, apogee-rc (dr19/dr20/sdsswork),
gcam_lco (dr20), and the aspcap*/apogee* family in ipl3/ipl4 — full list
printed by tools/build_registry.py. Here they raise UnresolvableProduct.
2. Dead branch in spcoaddobs. Upstream reads:
if (('v5' in run2d) or ... or (...) and obs.lower()) == 'apo':
== 'apo' binds to the whole parenthesised expression, so this compares a
bool to a string and is always False. The APO-suppression it intends never
happens. Reproduced bug-for-bug in derive.py (marked BUG-COMPAT) so parity
holds; it should be fixed in both places together.
Both are worth filing against tree/sdss_access regardless of this prototype's fate.
Regenerating the registry
The registry is a compiled artifact of sdss/tree. Rebuild it with:
sloppy-sdss-access-build-registry # rebuild from the vendored tools/*.cfg
sloppy-sdss-access-build-registry --fetch # pull latest cfgs from sdss/tree first
sloppy-sdss-access-build-registry --fetch --ref 6.1.0 # ...pinned to a tag, branch, or SHA
sloppy-sdss-access-build-registry --check # exit 1 if the registry is stale (CI)
--fetch downloads data/*.cfg over the raw GitHub endpoint (no auth, no gh
required) and follows base = chains, so asking for DR17 also pulls
DR16…DR8 — 18 configs in total for the current release list. The ref and each
file's SHA256 are recorded under "source" in the registry, so any given
registry.json says which tree revision produced it.
.github/workflows/update-registry.yml automates this: it rebuilds weekly (and
on demand, with a ref input), runs the tests and the differential check
against sdss_access, and opens a pull request only if the compiled output
changed. A PR rather than a push is deliberate — one tree edit can move
thousands of paths, which deserves a human diff. A second job fails CI on any PR
whose committed registry does not match its vendored cfgs, so an edited .cfg
cannot be merged without a rebuild.
Layout
tools/build_registry.py offline: tree .cfg -> registry.json (resolves all $ENVVARs)
tools/parity_check.py differential test vs legacy, with a derivation coverage gate
src/sloppy_sdss_access/registry.py Product/Release models, release defaults
src/sloppy_sdss_access/paths.py SDSS: path(), url(), local(), search(), describe()
src/sloppy_sdss_access/derive.py the 20 "special functions", as pure declared functions
src/sloppy_sdss_access/auth.py credential resolution: explicit / env / netrc / prompt
src/sloppy_sdss_access/access.py fsspec: streaming, async fetch, caching, s3
src/sloppy_sdss_access/compat.py legacy Path/SDSSPath/AccessError shims
src/sloppy_sdss_access/_build.py the builder (also the console-script entry point)
.github/workflows/ weekly registry rebuild -> PR; staleness check on PRs
derive.py differs from upstream in that each function declares the keys it
consumes (in DERIVATION_KEYS) instead of the runtime AST-parsing its own source
to find out — which is both the speed win and what makes optional keys tractable.
Two upstream special functions (apginst, mos_target_num_underscore) are used
by no SDSS-5 template and were dropped; a test asserts none become dead again.
Not done
This is a prototype, not a package.
RELEASE_DEFAULTSis seeded for DR19 only. The other seven releases return{}. Their pipeline versions need to come from the release coordinators; they are not intree.- S3 is untested and has no credential path.
- No per-key default values — only release-wide ones (half of #100).
- The registry is a build artifact of
tree. Long term the templates should live somewhere versioned in their own right rather than being scraped. - The
DERIVATION_KEYStable lives in the builder, not next to the functions, so the two can drift. A test catches unused derivations but not wrong key sets. - Compression probing (#66) covers only the suffixes in
COMPRESSION_SUFFIXES, andNEVER_COMPRESSEDis a hand-maintained extension list. The underlying wrong templates should still be fixed intree— probing is a workaround. - No cache eviction.
~/.cache/sdss_access(or$XDG_CACHE_HOME/sdss_access) grows without bound -- nothing limits size or age, and nothing verifies an existing file still matches the server. A working session over many products will fill a disk.fetchtreats "the target path exists" as "the cache is valid", which is also wrong if a download was interrupted. Needs a size/age policy and either checksums (aspoochdoes) or a length/ETag check. - No CLI (#94), no progress bars (#104), no mirror failover (#102), no docs beyond this README, no CI.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file sloppy_sdss_access-0.1.0.tar.gz.
File metadata
- Download URL: sloppy_sdss_access-0.1.0.tar.gz
- Upload date:
- Size: 2.0 MB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
12d9c2d901a3637997d574b70b992d8b9ef8fcd6d952f968d2c5263e98bba83b
|
|
| MD5 |
ab0ae28972d59ab9a10743b613963c18
|
|
| BLAKE2b-256 |
539b6305c09b1d166ed4586d45114e1b2ecb7ef1dff2d4ce1aa3f75a7850ec0b
|
File details
Details for the file sloppy_sdss_access-0.1.0-py3-none-any.whl.
File metadata
- Download URL: sloppy_sdss_access-0.1.0-py3-none-any.whl
- Upload date:
- Size: 130.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via: twine/6.2.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5425009e1126c2344f180557004200e11e5ee8f52249cc7f88dd8fdecac58631
|
|
| MD5 |
0a5e27c01689a787de84b0fc1a86f39c
|
|
| BLAKE2b-256 |
45141095242d63245499fd4c7bc377ea47ea9fee17ee46464f5de36461593d85
|