Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,24 @@ jobs:
distribution: 'temurin'
java-version: ${{matrix.jdk}}
cache: 'gradle'
- name: setup python
uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: build and test
id: thebuild
run: ./gradlew clean build --info --init-script init.gradle
- name: integration tests
run: ./gradlew integrationtest --info --init-script init.gradle -PCDA.oracle.database.image=${{matrix.schema.image}}
- name: Upload Python SDK and documentation
if: matrix.schema.env == 'latest'
uses: actions/upload-artifact@v4
with:
name: cda-python
path: |
clients/python/build/dist/
clients/python/build/docs/html/
if-no-files-found: error
- name: Create matrix job badge
if: success() || failure() # always run even if the previous step fails
uses: ./.github/actions/badge
Expand Down
11 changes: 10 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ jobs:
distribution: 'temurin'
java-version: '11'
cache: 'gradle'
- name: setup python
uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Set version
id: version
env:
Expand Down Expand Up @@ -93,14 +97,19 @@ jobs:
- name: show version
run: echo ${{ steps.version.outputs.version }}
- name: build war
env:
VERSION: ${{ steps.version.outputs.version }}
run: ./gradlew build --info --init-script init.gradle -PversionOverride=$VERSION
- name: Create GitHub Release
id: create_release
# Allow testing without creating a release
if: github.event_name != 'pull_request' && (github.event.ref == 'refs/heads/develop' || startsWith(github.event.ref, 'refs/tags'))
uses: softprops/action-gh-release@v2.6.1
with:
files: cwms-data-api/build/libs/cwms-data-api-${{steps.version.outputs.version}}.war
files: |
cwms-data-api/build/libs/cwms-data-api-${{steps.version.outputs.version}}.war
clients/python/build/dist/*.whl
clients/python/build/dist/*.tar.gz
tag_name: ${{steps.version.outputs.version}}
generate_release_notes: true
prerelease: ${{steps.version.outputs.prerelease}}
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ End user documentation available here: [📕 Read the Docs](https://cwms-data-ap

## Development notes

Generated SDKs live alongside the API source:

- [cda-python](clients/python/README.md): Python SDK, built locally; not yet published on PyPI.
- [cwmsjs](clients/typescript/README.md): TypeScript SDK.

Development and runtime currently requires java 11. JDKs and JREs greater than 11 should work, please report if they don't.


Expand Down
2 changes: 2 additions & 0 deletions clients/python/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
114 changes: 114 additions & 0 deletions clients/python/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# cda-python

Python SDK generated from the CWMS Data API (CDA) OpenAPI specification. Install
the `cda-python` distribution and import `cda`. The SDK provides generated
API methods and models; higher-level workflows can wrap it separately.

## Build and test

Use Python 3.10 or newer, the repository's Java/Node build prerequisites, and a
running Docker engine for CDA's existing OpenAPI export task. From the repository
root:

```sh
./gradlew :clients:python:build --init-script init.gradle
```

On Windows use `./gradlew.bat`. To select a Python interpreter, pass
`-PpythonExecutable=/path/to/python`. Gradle creates its own virtual environment
under `clients/python/build/venv`; it does not install into your system Python.

The build generates source and documentation, builds a wheel and source archive,
installs the wheel, and runs HTTP contract tests against a local test server.
The SDK tests require no external CDA instance or credentials.

To build against a previously exported specification without Docker, pass
`-PpythonOpenApiSpec=/absolute/path/to/openapi.json`. Relative paths are resolved
from `clients/python`. This explicitly replaces the local export for that build;
the generator never silently downloads a different API version.

| Output | Location |
| --- | --- |
| Generated project | `clients/python/build/cda-python/` |
| API and model documentation | `clients/python/build/cda-python/docs/` |
| HTML reference and tested examples | `clients/python/build/docs/html/` |
| Wheel and source archive | `clients/python/build/dist/` |

Use `:clients:python:generatePythonClient` to generate only the source and docs.
Use `:clients:python:clean` to remove all Python build outputs.

## Install locally

This package has **not been set up or published on PyPI**. Install the wheel from
`clients/python/build/dist/` by supplying its actual filename:

```sh
python -m pip install /path/to/cda_python-<version>-py3-none-any.whl
```

```python
from cda import ApiClient, Configuration
from cda.api.offices_api import OfficesApi

config = Configuration(host="https://cwms-data.usace.army.mil/cwms-data")
with ApiClient(config) as client:
offices = OfficesApi(client).get_offices(has_data=True)
print([office.name for office in offices])
```

For authenticated requests, configure `config.api_key["ApiKey"]` with your key
and `config.api_key_prefix["ApiKey"] = "apikey"`. The host includes the deployment
context, such as `/cwms-data`; generated operation paths omit that prefix.

See [read_data.py](examples/read_data.py) for a complete time-series, level, and
location example, including changing the CDA root. Run it with the interpreter
where you installed the wheel. The sample requests one page of time-series data;
use `series.next_page` as the next request's `page` argument for longer windows.

## Specification adjustments

Python attributes use snake case while serialization preserves CDA's JSON keys.
The preparation script separates rating inheritance from the rating union to
avoid circular imports, preserves discriminator values, and describes time-series
rows as numeric arrays that can contain null values. These adaptations affect
only the Python generator input. Office types accept both the descriptive labels
in the schema and the codes returned by `has-data=true` (for example, `DIS`).
Time-series intervals use CDA's ISO 8601 duration strings, and level variants
require their distinguishing value fields so responses deserialize unambiguously.

## Versions and documentation publishing

The package version follows CDA, with Python's PEP 440 spelling:

| CDA version | Python package version |
| --- | --- |
| `2026.09.03` | `2026.9.3` |
| `2026.09.03-deva` | `2026.9.3.dev1` |
| `2026.09.03-testa` | `2026.9.3rc1` |
| `2026.09.03-a` | `2026.9.3.post1` |
| `2026.09.03-feature/python-sdk` | `2026.9.3.dev0+feature.python.sdk` |
| Untagged CI commit `7098006` | `0.dev0+g7098006` |

Unlettered `-dev` and `-test` use suffix number 0. Nightly builds use the build
date and a development label. There is no separate generator-version prefix.
Detached CI checkouts without a release tag use a development version containing
the commit hash; these artifacts are not published as CDA releases.
PyPI publishing remains follow-up work; GitHub releases include the wheel and source archive.

`buildPythonDocs` runs the SDK tests before building Sphinx HTML from the generated
API/model Markdown and the same example functions executed by the tests. Warnings
fail the documentation build. CI uploads the package and HTML for review.

The companion SDK Pages workflow publishes the generated HTML at
[CDA Python SDK documentation](https://usace.github.io/cwms-data-api/sdk/python/),
alongside cwmsjs. That URL becomes available after both the Python SDK and shared
Pages workflow changes are merged and deployed. Release-specific documentation is
retained under `/cwms-data-api/releases/<CDA-version>/sdk/python/`; development docs
live under `/cwms-data-api/development/sdk/python/`.

## Naming

Generated libraries use the `cda-*` naming convention to distinguish them from
existing CWMS projects. See the [proposal to use a generated SDK underneath
cwms-python](https://github.com/HydrologicEngineeringCenter/cwms-python/issues/299).
The existing TypeScript package is named `cwmsjs`.
120 changes: 120 additions & 0 deletions clients/python/build.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import org.openapitools.generator.gradle.plugin.tasks.GenerateTask
import org.openapitools.generator.gradle.plugin.tasks.ValidateTask

plugins {
id 'base'
id 'org.openapi.generator' version '7.15.0'
}

def specOverride = providers.gradleProperty('pythonOpenApiSpec')
def specFile = specOverride.isPresent() ? file(specOverride.get()) : rootProject.file('cwms-data-api/build/openapi.json')
def preparedSpec = layout.buildDirectory.file('openapi.json')
def generatedClientDir = layout.buildDirectory.dir('cda-python')
def distDir = layout.buildDirectory.dir('dist')
def venvDir = layout.buildDirectory.dir('venv')
def windows = System.getProperty('os.name').toLowerCase().contains('windows')
def python = providers.gradleProperty('pythonExecutable').getOrElse(windows ? 'python' : 'python3')
def venvPython = venvDir.get().file(windows ? 'Scripts/python.exe' : 'bin/python').asFile.absolutePath
def clientVersion = providers.provider {
def output = new ByteArrayOutputStream()
exec {
commandLine python, 'scripts/package_version.py', project.version.toString()
standardOutput = output
}
output.toString('UTF-8').trim()
}

tasks.register('prepareOpenApiSpec', Exec) {
group 'openapi'
description 'Prepare the CDA specification for the Python generator.'
if (!specOverride.isPresent()) {
dependsOn ':cwms-data-api:executeOpenAPIConversion'
}
commandLine python, 'scripts/prepare_spec.py', specFile.absolutePath, preparedSpec.get().asFile.absolutePath
inputs.file specFile
inputs.file 'scripts/prepare_spec.py'
outputs.file preparedSpec
}

tasks.register('validateOpenApiSpec', ValidateTask) {
group 'openapi'
description 'Validate the adjusted Python client OpenAPI specification.'
dependsOn prepareOpenApiSpec
inputSpec.set(preparedSpec.get().asFile.absolutePath)
}

tasks.register('generatePythonClient', GenerateTask) {
group 'openapi'
description 'Generate the cda-python SDK and API documentation.'
dependsOn validateOpenApiSpec
generatorName = 'python'
inputSpec = preparedSpec.get().asFile.absolutePath
outputDir = generatedClientDir.get().asFile.absolutePath
configFile = layout.projectDirectory.file('openapi.config.json').asFile.absolutePath
templateDir = layout.projectDirectory.dir('templates').asFile.absolutePath
inputs.property 'cdaVersion', project.version.toString()
inputs.file 'scripts/package_version.py'
additionalProperties.put('packageVersion', clientVersion)
globalProperties.set([apiDocs: 'true', modelDocs: 'true', apiTests: 'false', modelTests: 'false'])
inputs.file rootProject.file('LICENSE.md')
// Remove obsolete generated modules when operations or schemas disappear.
doFirst {
delete generatedClientDir
}
doLast {
copy {
from rootProject.file('LICENSE.md')
into generatedClientDir
}
}
}

tasks.register('createPythonEnvironment', Exec) {
group 'build'
description 'Create an isolated Python environment for building and testing the SDK.'
commandLine python, '-m', 'venv', venvDir.get().asFile.absolutePath
inputs.property 'pythonExecutable', python
outputs.file venvPython
}

tasks.register('installBuildDependencies', Exec) {
dependsOn createPythonEnvironment
commandLine venvPython, '-m', 'pip', 'install', '-r', file('requirements-build.txt')
}

tasks.register('buildPythonClient', Exec) {
group 'build'
description 'Build the cda-python wheel and source distribution without publishing.'
dependsOn generatePythonClient, installBuildDependencies
commandLine venvPython, '-m', 'build', '--outdir', distDir.get().asFile.absolutePath,
generatedClientDir.get().asFile.absolutePath
doFirst { delete distDir }
}

tasks.register('installPythonClient', Exec) {
dependsOn buildPythonClient
doFirst {
def wheel = fileTree(distDir).matching { include '*.whl' }.singleFile
commandLine venvPython, '-m', 'pip', 'install', '--force-reinstall', wheel.absolutePath
}
}

tasks.register('testPythonClient', Exec) {
group 'verification'
description 'Test the installed wheel against a local HTTP server; no CDA database is needed.'
dependsOn installPythonClient
commandLine venvPython, '-m', 'unittest', 'discover', '-s', 'tests', '-v'
environment 'CDA_VERSION', project.version.toString()
}

tasks.register('buildPythonDocs', Exec) {
group 'documentation'
description 'Build HTML reference and examples from the tested Python SDK.'
dependsOn testPythonClient
commandLine venvPython, 'scripts/build_docs.py'
environment 'CDA_VERSION', project.version.toString()
}

tasks.named('assemble') { dependsOn buildPythonClient }
tasks.named('check') { dependsOn testPythonClient }
tasks.named('build') { dependsOn buildPythonDocs }
61 changes: 61 additions & 0 deletions clients/python/examples/read_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
"""Read a time series, location level, and location using the generated SDK."""

from cda import ApiClient, Configuration
from cda.api.time_series_api import TimeSeriesApi
from cda.api.levels_api import LevelsApi
from cda.api.locations_api import LocationsApi


# Change this to your CDA deployment, including its context path.
CDA_ROOT = "https://cwms-data.usace.army.mil/cwms-data"
# CDA_ROOT = "http://localhost:7000/cwms-data"

config = Configuration(host=CDA_ROOT)
json_v2 = {"Accept": "application/json;version=2"}

def get_time_series(client):
series = TimeSeriesApi(client).get_timeseries(
name="KEYS.Elev.Inst.1Hour.0.Ccp-Rev",
office="SWT",
begin="2026-09-01T00:00:00Z",
end="2026-09-02T00:00:00Z",
units="ft",
_headers=json_v2,
_request_timeout=30.0,
)
return series



def get_level(client):
level = LevelsApi(client).get_levels_with_level_id(
level_id="KEYS.Elev.Inst.0.Top of Conservation",
office="SWT",
effective_date="2026-09-01T00:00:00",
timezone="UTC",
use_exact_effective_date=False,
unit="ft",
_headers=json_v2,
_request_timeout=30.0,
)
return level



def get_location(client):
location = LocationsApi(client).get_locations_with_location_id(
location_id="KEYS",
office="SWT",
unit="EN",
_request_timeout=30.0,
)
return location


if __name__ == "__main__":
with ApiClient(config) as client:
series = get_time_series(client)
print(series.name, series.units, series.interval, series.values[:3])
print(get_level(client).to_dict())
location = get_location(client)
print(location.public_name, location.latitude, location.longitude)
9 changes: 9 additions & 0 deletions clients/python/openapi.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"packageName": "cda",
"projectName": "cda-python",
"packageUrl": "https://github.com/USACE/cwms-data-api",
"gitUserId": "USACE",
"gitRepoId": "cwms-data-api",
"library": "urllib3",
"hideGenerationTimestamp": true
}
3 changes: 3 additions & 0 deletions clients/python/requirements-build.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
build==1.3.0
sphinx==8.1.3
myst-parser==4.0.1
Loading
Loading