[build-system] requires = ["hatchling"] build-backend = "hatchling.build" [project] name = "umbra-py" dynamic = ["version"] description = "Search, preview, load, and convert Umbra open SAR. Umbra ships no search API; umbra-py is that layer, plus a community STAC API and MCP server." readme = "README.md" requires-python = ">=3.10" license = "Apache-2.0" license-files = ["LICENSE"] authors = [{ name = "umbra-py contributors" }] keywords = [ "sar", "synthetic-aperture-radar", "umbra", "remote-sensing", "earth-observation", "stac", "geospatial", "gis", ] classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Science/Research", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Topic :: Scientific/Engineering :: GIS", "Topic :: Scientific/Engineering :: Image Processing", ] dependencies = [ "requests>=2.28", "click>=8.1", # Pure-Python, zero transitive deps -- hardens the remote S3 bucket-listing # XML parse against entity-expansion / XXE (see ``UmbraCatalog._parse_listing``). "defusedxml>=0.7", ] [project.optional-dependencies] convert = ["sarpy>=1.3", "rasterio>=1.3", "numpy>=1.23"] load = ["xarray>=2023.1", "rasterio>=1.3", "numpy>=1.23"] # Lazy, chunked datacubes: `to_stack(lazy=True)` / `umbra stack --lazy` defer # each pass's read into a dask task (one chunk per acquisition) so a long series # stacks at a resolution that would not fit in memory all at once. Deliberately # separate from [load] rather than folded into it: dask brings a task scheduler, # an eager cube is the right default at scene scale, and nothing else in the # package needs it. Not part of [all] for the same reason -- ask for it when you # hit the ceiling. dask = ["umbra-py[load]", "dask[array]>=2023.5"] viz = ["folium>=0.14", "matplotlib>=3.6", "rasterio>=1.3", "numpy>=1.23"] export = ["stac-geoparquet>=0.6"] serve = ["fastapi>=0.110", "uvicorn>=0.27"] # Floor at 2: `mcp_server.py` builds an `MCPServer` from `mcp.server.mcpserver`, # which is the 2.0 home of what 1.x called `FastMCP` in `mcp.server.fastmcp`. # The old module does not exist in 2.x and the new one does not exist in 1.x, so # the server cannot straddle the rename -- hence a floor rather than a range. mcp = ["mcp>=2", "umbra-py[viz]"] # The AI features call a model at the edge while the library stays # deterministic: `umbra ask` has a model *plan* a search (re-validated before it # runs), `umbra describe` has a vision model *read* a rendered quicklook # (returned as a provenance-stamped description, never a filter), and `umbra # embed` turns a quicklook (or a text query) into a vector for visual similarity # search (only the embedding step calls a model; ranking is deterministic). All # talk to Anthropic or any OpenAI-compatible endpoint over plain HTTPS with the # already-core `requests`, plus a user-supplied API key at runtime — no heavy # SDK. The extra exists as the documented install target and the marker of the # determinism boundary (AI is opt-in, never implicit); it pulls in nothing # beyond the core dependency it relies on. `umbra describe` and `umbra embed` # additionally need the `viz` extra to render the quicklook they embed/send. ai = ["requests>=2.28"] # The catalog tools as native LangChain / LangGraph tools (`umbra_py.langchain`). # Only `langchain-core` — the lightweight tool/abstraction package, not the full # framework — plus `viz` for the render tools' PNGs. It mirrors the `mcp` extra: # a second front door for agent builders over the identical deterministic # callables, so the two surfaces cannot drift. langchain = ["langchain-core>=0.3", "umbra-py[viz]"] # The catalog tools as native LlamaIndex tools (`umbra_py.llamaindex`). Only # `llama-index-core` — the lightweight tool/abstraction package, not the full # framework — plus `viz` for the render tools' PNGs. It mirrors the `mcp` / # `langchain` extras: a third front door for agent builders over the identical # deterministic callables, so the three surfaces cannot drift. llamaindex = ["llama-index-core>=0.11", "umbra-py[viz]"] dev = [ "pytest>=7.4", "pytest-cov>=4.1", # `test-all-extras` in CI runs ~2400 tests on a 4-vCPU runner; xdist is # what actually uses the other three cores. Not in pytest addopts so a # local `pytest -q` stays serial and the core matrix stays comparable. "pytest-xdist>=3.6", "responses>=0.23", "httpx>=0.27", # `tests/test_workflows.py` reads .github/workflows/*.yml to check every # `umbra ...` invocation against the real CLI. PyYAML already arrives via # responses; declared here because the suite imports it directly, and a # transitive dependency is not a promise. "pyyaml>=6", # `tests/test_schemas.py` validates a real payload from each `--json` surface # against the committed contract in `docs/schemas/`. Floor at 4.18 for the # `referencing` registry, which is how the render manifest's `stats` key # resolves its cross-file `$ref` to the stats schema without a network fetch. "jsonschema>=4.18", # Floor at 0.16: that release began formatting fenced code blocks inside # Markdown, and the `examples/*.md` snippets were reformatted against it. # Cap below 0.17 so the next formatter change cannot turn `ruff format # --check .` red on an unrelated file set; lift it in a dedicated PR that # also reformats. "ruff>=0.16,<0.17", "mypy>=1.8", "types-requests>=2.28", "types-defusedxml>=0.7", ] # The rendered documentation site (mkdocs-material + mkdocstrings), built from # the same docstrings the package already ships. `mkdocs-click` renders the CLI # reference straight from the Click group so the docs cannot drift from `umbra # --help`. Building the API reference imports the package, so this extra is # installed alongside `[all]` (and the agent extras) in the docs workflow. docs = [ "mkdocs-material>=9.5", "mkdocstrings[python]>=0.25", "mkdocs-click>=0.8", ] all = ["umbra-py[convert,load,viz,export]"] [project.urls] Homepage = "https://umbra-py.space/" Documentation = "https://umbra-py.space/" Repository = "https://github.com/reesehammer/umbra-py" Issues = "https://github.com/reesehammer/umbra-py/issues" "Umbra Open Data" = "https://umbra.space/open-data/" [project.scripts] umbra = "umbra_py.cli:main" umbra-py = "umbra_py.cli:main" umbra-mcp = "umbra_py.mcp_server:main" # Single-source the version from the package so it can never drift from # `__version__` (the value `umbra.__version__` and `umbra --version` report). [tool.hatch.version] path = "src/umbra_py/__init__.py" [tool.hatch.build.targets.wheel] packages = ["src/umbra_py"] # Ship the PEP 561 marker so downstream type checkers consume the inline types. artifacts = ["src/umbra_py/py.typed"] # The published JSON contracts (`docs/schemas/`) keep one home -- the path every # schema's own `$id` names, and where they are read on GitHub -- and the wheel # carries a copy of that directory as package data, the same way `py.typed` is a # build artifact of a source-tree fact. That is what lets a consumer validate # against the version it installed and lets `umbra serve` put the committed # shape into its generated OpenAPI document. `umbra_py.schemas` reads this copy # first and falls back to the checkout (an editable install builds no wheel, so # nothing runs this); `PACKAGE_DATA_DIR` there is the target below, and # `tests/test_schemas.py` checks that the two still agree. [tool.hatch.build.targets.wheel.force-include] "docs/schemas" = "umbra_py/_schemas" [tool.ruff] line-length = 100 target-version = "py310" [tool.ruff.lint] select = ["E", "F", "I", "UP", "B", "W"] [tool.ruff.lint.per-file-ignores] # `viz/__init__.py` is a pure re-export façade: splitting the old 2 000-line # viz.py into submodules must not move a single name, so it re-exports the # private helpers other package modules and tests import from `umbra_py.viz` # too. Those cannot go in `__all__` (that is the public `import *` surface), # so F401 is expected for every one of them. "src/umbra_py/viz/__init__.py" = ["F401"] # `cli/__init__.py` is the same kind of façade for the CLI split: the package # re-exports every name the old 5 500-line `cli.py` defined -- the commands, and # the private helpers tests address by name -- so no caller changed. Only `cli` # and `main` belong in `__all__` (the entry points), so F401 is expected for the # rest. The command-module imports are what register the commands on the group. "src/umbra_py/cli/__init__.py" = ["F401"] [tool.ruff.lint.flake8-bugbear] # FastAPI's dependency/parameter markers are called in argument defaults by # design (Query(...), Body(...)); that is the framework idiom, not a B008 bug. extend-immutable-calls = ["fastapi.Query", "fastapi.Body", "fastapi.Path", "fastapi.Depends"] [tool.mypy] # The package ships a py.typed marker (PEP 561), so downstream type checkers # consume these inline annotations -- this config makes CI verify they are # actually consistent, rather than shipping an unchecked promise. CI runs it in # two environments (the `type-check` and `type-check-all-extras` jobs), the same # core-vs-everything split the `test` / `test-all-extras` jobs use: on a core # [dev] install the extras' libraries are absent and import-ignored, while an # all-extras install brings the ones that *do* ship stubs (PIL, click, numpy) # into the check -- which is the surface a dev/agent environment actually sees. # Because the same config runs both ways, an ignore that is needed in one is # redundant in the other (`warn_unused_ignores` below would flag it), so prefer # referencing a real typed attribute (e.g. `Image.Palette.ADAPTIVE`) over a # `# type: ignore` for a stub-bearing extra. The rest -- rasterio, fastapi, # sarpy, folium, mcp, ... -- ship no stubs and stay import-ignored either way. python_version = "3.10" files = ["src/umbra_py"] ignore_missing_imports = true warn_unused_ignores = true warn_redundant_casts = true [tool.pytest.ini_options] testpaths = ["tests"] markers = [ "network: tests that require live network access to Umbra's public catalog", ] addopts = "-m 'not network'" # Coverage is measured only in the `test-all-extras` CI job, which installs # every optional extra so the visual / serve / convert / agent modules actually # run (the core [dev] matrix import-skips them, so its numbers would understate # the real figure). The floor guards against regressions without chasing 100% on # the interactive-only branches (the TTY spinner, `if TYPE_CHECKING:` blocks). [tool.coverage.run] source = ["umbra_py"] branch = true [tool.coverage.report] show_missing = true exclude_also = [ "if TYPE_CHECKING:", "raise NotImplementedError", "if __name__ == .__main__.:", "@(abc\\.)?abstractmethod", "\\.\\.\\.", ]