diff --git a/.github/workflows/check-release.yml b/.github/workflows/check-release.yml new file mode 100644 index 0000000..00e1063 --- /dev/null +++ b/.github/workflows/check-release.yml @@ -0,0 +1,26 @@ +name: Check release metadata + +on: + pull_request: + paths: + - 'pyproject.toml' + - 'CHANGELOG.md' + +permissions: + contents: read + +jobs: + check: + name: Verify changelog matches version bump + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.12' + + - name: Check release metadata + run: python scripts/check-release.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9629449 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: ["main", "master"] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + test: + name: Test (Python 3.12) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v6 + with: + enable-cache: true + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync --locked + + - name: Test + run: uv run pytest -v diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5612914..6f38754 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -28,7 +28,6 @@ jobs: - name: Verify tag matches pyproject version run: | - # Release tags must start with `v` followed by a PEP 440 version (e.g. v1.2.3, v1.2.3a1). if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9] ]]; then echo "Release tag '$GITHUB_REF_NAME' must start with 'v' followed by a digit (e.g. v1.0.0)" >&2 exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..66c6c1e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,54 @@ +name: GitHub Release + +on: + push: + tags: + - 'v[0-9]*' + +permissions: + contents: write + +jobs: + release: + name: Create GitHub Release + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + with: + python-version: '3.12' + + - name: Read package metadata + id: meta + run: | + pkg_name=$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['name'])") + pkg_version="${GITHUB_REF_NAME#v}" + echo "name=${pkg_name}" >> "$GITHUB_OUTPUT" + echo "version=${pkg_version}" >> "$GITHUB_OUTPUT" + + - name: Extract changelog notes + id: notes + run: | + set -euo pipefail + version="${GITHUB_REF_NAME#v}" + if [[ -f CHANGELOG.md ]]; then + body="$(python scripts/extract-changelog.py "$version")" + else + body="Release ${version}." + fi + delimiter="EOF_${RANDOM}_${RANDOM}" + { + echo "body<<${delimiter}" + echo "$body" + echo "${delimiter}" + } >> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + uses: softprops/action-gh-release@1e812e8210a4a8a0b23075e5795f2a4e2b2a0b7 # v2.2.2 + with: + tag_name: ${{ github.ref_name }} + name: ${{ steps.meta.outputs.name }} ${{ steps.meta.outputs.version }} + body: ${{ steps.notes.outputs.body }} + generate_release_notes: false + make_latest: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..38793ad --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,39 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.2.0] - 2026-05-24 + +### Changed + +- Switch managed database operations from the connections API to the dedicated `/databases` API (`hotdata>=0.2.3` required). +- `create_managed_database` first parameter renamed from `name` to `description` (keyword-only). +- `ManagedDatabase` dataclass: replace `name`/`source_type` fields with `description`/`default_connection_id`. +- `resolve_managed_database` tries direct ID lookup first, then falls back to a description scan. +- `list_managed_databases` now fetches all databases regardless of source type. +- `list_managed_tables`, `load_managed_table`, and `delete_managed_table` use `default_connection_id` instead of database `id` for connection-scoped operations. + +### Added + +- `create_managed_database` accepts an optional `expires_at` parameter. + +### Removed + +- `MANAGED_SOURCE_TYPE`, `build_managed_config`, and `create_connection_request` removed from the public API. + +## [0.1.1] - 2026-05-19 + +### Added + +- Managed database helpers on `HotdataClient`. + +## [0.1.0] - 2026-05-06 + +### Added + +- Initial release. diff --git a/CONTRACT.md b/CONTRACT.md index cca5d1f..f5f23c5 100644 --- a/CONTRACT.md +++ b/CONTRACT.md @@ -33,10 +33,7 @@ The supported import surface is: - `ManagedDatabase` - `ManagedTable` - `LoadManagedTableResult` -- `MANAGED_SOURCE_TYPE` - `DEFAULT_SCHEMA` -- `build_managed_config` -- `create_connection_request` - `is_parquet_path` Adapters should import from `hotdata_runtime` and treat this surface as the stable API. @@ -58,10 +55,10 @@ Adapters should import from `hotdata_runtime` and treat this surface as the stab - `columns_for_qualified(qualified, connection_id=...)` resolves table columns, and adapters should pass `connection_id` when known. - `uploads()` returns the uploads API wrapper for parquet staging. -- `list_managed_databases()` returns managed-catalog connections (`source_type: managed`). -- `resolve_managed_database(name_or_id)` resolves a managed database by name or id. -- `create_managed_database(name, schema=..., tables=...)` creates a managed database and optionally declares tables up front. -- `delete_managed_database(name_or_id)` deletes a managed database connection. +- `list_managed_databases()` returns all databases via the `/databases` API. +- `resolve_managed_database(name_or_id)` resolves a database by id (direct lookup) or description (list scan). +- `create_managed_database(description=..., schema=..., tables=..., expires_at=...)` creates a database via the `/databases` API and optionally declares tables up front. +- `delete_managed_database(name_or_id)` deletes a database via the `/databases` API. - `list_managed_tables(database, schema=...)` lists tables in a managed database. - `upload_parquet(path)` uploads a local parquet file and returns an upload id. - `load_managed_table(database, table, schema=..., upload_id=..., file=...)` publishes parquet data into a declared managed table. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..0b50ff4 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,43 @@ +# Releasing + +Every release uses `./scripts/release.sh`. Do not bump versions, tag, or create GitHub Releases manually. + +## One-time setup + +- Install [GitHub CLI](https://cli.github.com/) (`gh`) and authenticate. +- Ensure PyPI [trusted publishing](https://docs.pypi.org/trusted-publishers/) is configured for this repo (`publish.yml` uses the `pypi` GitHub environment). + +## Release steps + +1. Add user-facing notes under `## [Unreleased]` in `CHANGELOG.md`. +2. Prepare the release PR: + + ```bash + ./scripts/release.sh prepare patch # or minor | major | 1.2.3 + ``` + +3. Merge the PR after CI passes (including the changelog check). +4. Publish from a clean default branch checkout: + + ```bash + git checkout main # or master for hotdata-marimo + git pull + ./scripts/release.sh publish + ``` + +## What happens automatically + +Pushing a `vX.Y.Z` tag triggers two workflows: + +| Workflow | Purpose | +|----------|---------| +| `publish.yml` | Build wheel/sdist and publish to PyPI | +| `release.yml` | Create the GitHub Release with notes from `CHANGELOG.md` | + +## Enforcement + +- **PR check** (`check-release.yml`): if `pyproject.toml` version changes, `CHANGELOG.md` must contain a matching `## [X.Y.Z]` section. +- **Tag check** (`publish.yml`): the tag (without `v`) must match `[project].version` in `pyproject.toml`. +- **Publish guard** (`release.sh publish`): refuses to tag if the changelog section is missing. + +Together, these make it hard to ship a version without changelog notes or a GitHub Release. diff --git a/hotdata_runtime/__init__.py b/hotdata_runtime/__init__.py index b9d3118..aadc0f8 100644 --- a/hotdata_runtime/__init__.py +++ b/hotdata_runtime/__init__.py @@ -13,9 +13,6 @@ LoadManagedTableResult, ManagedDatabase, ManagedTable, - MANAGED_SOURCE_TYPE, - build_managed_config, - create_connection_request, is_parquet_path, ) from hotdata_runtime.env import ( @@ -42,12 +39,9 @@ "DEFAULT_SCHEMA", "HotdataClient", "LoadManagedTableResult", - "MANAGED_SOURCE_TYPE", "ManagedDatabase", "ManagedTable", "QueryResult", - "build_managed_config", - "create_connection_request", "is_parquet_path", "workspace_health_lines", "default_api_key", diff --git a/hotdata_runtime/client.py b/hotdata_runtime/client.py index 9c5ced5..4665f78 100644 --- a/hotdata_runtime/client.py +++ b/hotdata_runtime/client.py @@ -9,6 +9,7 @@ from hotdata import ApiClient, Configuration from hotdata.api.connections_api import ConnectionsApi +from hotdata.api.databases_api import DatabasesApi from hotdata.api.information_schema_api import InformationSchemaApi from hotdata.api.query_api import QueryApi from hotdata.api.query_runs_api import QueryRunsApi @@ -16,9 +17,12 @@ from hotdata.api.uploads_api import UploadsApi from hotdata.exceptions import ApiException from hotdata.models.async_query_response import AsyncQueryResponse +from hotdata.models.create_database_request import CreateDatabaseRequest +from hotdata.models.database_default_schema_decl import DatabaseDefaultSchemaDecl +from hotdata.models.database_default_table_decl import DatabaseDefaultTableDecl +from hotdata.models.load_managed_table_request import LoadManagedTableRequest from hotdata.models.query_request import QueryRequest from hotdata.models.query_response import QueryResponse -from hotdata.models.load_managed_table_request import LoadManagedTableRequest from hotdata.models.table_info import TableInfo from hotdata_runtime.env import ( @@ -33,11 +37,9 @@ LoadManagedTableResult, ManagedDatabase, ManagedTable, - MANAGED_SOURCE_TYPE, api_error_message, - create_connection_request, is_parquet_path, - managed_database_from_connection, + managed_database_from_detail, ) from hotdata_runtime.http import default_http_retries from hotdata_runtime.result import QueryResult @@ -130,6 +132,9 @@ def __exit__(self, *args: object) -> None: def connections(self) -> ConnectionsApi: return ConnectionsApi(self._api) + def _databases_api(self) -> DatabasesApi: + return DatabasesApi(self._api) + def _information_schema(self) -> InformationSchemaApi: return InformationSchemaApi(self._api) @@ -152,47 +157,71 @@ def uploads(self) -> UploadsApi: return UploadsApi(self._api) def list_managed_databases(self) -> list[ManagedDatabase]: - listing = self.connections().list_connections() - return [ - managed_database_from_connection(c) - for c in listing.connections - if c.source_type == MANAGED_SOURCE_TYPE - ] + listing = self._databases_api().list_databases() + result: list[ManagedDatabase] = [] + for summary in listing.databases: + try: + detail = self._databases_api().get_database(summary.id) + result.append(managed_database_from_detail(detail)) + except ApiException: + pass + return result def resolve_managed_database(self, name_or_id: str) -> ManagedDatabase: - listing = self.connections().list_connections() - match = None - for c in listing.connections: - if c.id == name_or_id or c.name == name_or_id: - match = c + # Try direct ID lookup first + try: + detail = self._databases_api().get_database(name_or_id) + return managed_database_from_detail(detail) + except ApiException as e: + if e.status != 404: + raise RuntimeError(api_error_message(e)) from e + + # Fall back to description-based lookup + listing = self._databases_api().list_databases() + match_id: str | None = None + for db in listing.databases: + if db.description == name_or_id: + match_id = db.id break - if match is None: + if match_id is None: raise KeyError(f"No database named or with id {name_or_id!r}") - if match.source_type != MANAGED_SOURCE_TYPE: - raise ValueError( - f"{match.name!r} is not a managed database " - f"(source_type: {match.source_type})" - ) - return managed_database_from_connection(match) + try: + detail = self._databases_api().get_database(match_id) + except ApiException as e: + raise RuntimeError(api_error_message(e)) from e + return managed_database_from_detail(detail) def create_managed_database( self, - name: str, + description: str | None = None, *, schema: str = DEFAULT_SCHEMA, tables: list[str] | None = None, + expires_at: str | None = None, ) -> ManagedDatabase: - request = create_connection_request(name, schema=schema, tables=tables) + schemas = None + if tables: + schemas = [ + DatabaseDefaultSchemaDecl( + name=schema, + tables=[DatabaseDefaultTableDecl(name=t) for t in tables], + ) + ] + request = CreateDatabaseRequest( + description=description, + schemas=schemas, + expires_at=expires_at, + ) try: - created = self.connections().create_connection(request) + created = self._databases_api().create_database(request) except ApiException as e: raise RuntimeError(api_error_message(e)) from e - return managed_database_from_connection(created) + return managed_database_from_detail(created) def delete_managed_database(self, name_or_id: str) -> None: db = self.resolve_managed_database(name_or_id) try: - self.connections().delete_connection(db.id) + self._databases_api().delete_database(db.id) except ApiException as e: raise RuntimeError(api_error_message(e)) from e @@ -204,12 +233,12 @@ def list_managed_tables( ) -> list[ManagedTable]: db = self.resolve_managed_database(database) rows: list[ManagedTable] = [] - for t in self.iter_tables(connection_id=db.id): + for t in self.iter_tables(connection_id=db.default_connection_id): if schema is not None and t.var_schema != schema: continue rows.append( ManagedTable( - full_name=f"{db.name}.{t.var_schema}.{t.table}", + full_name=f"{db.id}.{t.var_schema}.{t.table}", schema=t.var_schema, table=t.table, synced=t.synced, @@ -258,7 +287,7 @@ def load_managed_table( ) try: loaded = self.connections().load_managed_table( - db.id, + db.default_connection_id, schema, table, request, @@ -270,7 +299,7 @@ def load_managed_table( schema_name=loaded.schema_name, table_name=loaded.table_name, row_count=loaded.row_count, - full_name=f"{db.name}.{loaded.schema_name}.{loaded.table_name}", + full_name=f"{db.id}.{loaded.schema_name}.{loaded.table_name}", ) def delete_managed_table( @@ -282,7 +311,7 @@ def delete_managed_table( ) -> None: db = self.resolve_managed_database(database) try: - self.connections().delete_managed_table(db.id, schema, table) + self.connections().delete_managed_table(db.default_connection_id, schema, table) except ApiException as e: raise RuntimeError(api_error_message(e)) from e diff --git a/hotdata_runtime/databases.py b/hotdata_runtime/databases.py index f9e4b69..4d0756b 100644 --- a/hotdata_runtime/databases.py +++ b/hotdata_runtime/databases.py @@ -7,17 +7,15 @@ from typing import Any from hotdata.exceptions import ApiException -from hotdata.models.create_connection_request import CreateConnectionRequest -MANAGED_SOURCE_TYPE = "managed" DEFAULT_SCHEMA = "public" @dataclass(frozen=True) class ManagedDatabase: id: str - name: str - source_type: str + description: str | None + default_connection_id: str def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -51,39 +49,11 @@ def is_parquet_path(path: str) -> bool: return Path(path).suffix.lower() == ".parquet" -def build_managed_config(schema: str, tables: list[str]) -> dict[str, Any]: - if not tables: - return {} - return { - "schemas": [ - { - "name": schema, - "tables": [{"name": table} for table in tables], - } - ] - } - - -def create_connection_request( - name: str, - *, - schema: str = DEFAULT_SCHEMA, - tables: list[str] | None = None, -) -> CreateConnectionRequest: - table_list = tables or [] - return CreateConnectionRequest( - name=name, - source_type=MANAGED_SOURCE_TYPE, - config=build_managed_config(schema, table_list), - skip_discovery=True, - ) - - -def managed_database_from_connection(conn: Any) -> ManagedDatabase: +def managed_database_from_detail(detail: Any) -> ManagedDatabase: return ManagedDatabase( - id=str(conn.id), - name=str(conn.name), - source_type=str(conn.source_type), + id=str(detail.id), + description=detail.description, + default_connection_id=str(detail.default_connection_id), ) diff --git a/pyproject.toml b/pyproject.toml index 27c9637..a8517e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.10" license = { text = "MIT" } dependencies = [ - "hotdata>=0.2.0", + "hotdata>=0.2.3", "pandas>=2.0", ] diff --git a/scripts/check-release.py b/scripts/check-release.py new file mode 100755 index 0000000..7d437f5 --- /dev/null +++ b/scripts/check-release.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Fail CI when pyproject.toml version changes without a matching CHANGELOG entry.""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + + +def git_show(path: str, ref: str) -> str: + try: + return subprocess.check_output(["git", "show", f"{ref}:{path}"], text=True) + except subprocess.CalledProcessError: + return "" + + +def read_version(text: str) -> str: + match = re.search(r'(?m)^version = "([^"]+)"', text) + if not match: + raise SystemExit("could not read version from pyproject.toml") + return match.group(1) + + +def has_changelog_section(version: str) -> bool: + changelog = Path("CHANGELOG.md") + if not changelog.exists(): + return False + return bool(re.search(rf"^## \[{re.escape(version)}\]", changelog.read_text(), re.M)) + + +def main() -> None: + base = "origin/main" + for candidate in ("origin/main", "origin/master"): + if subprocess.call(["git", "rev-parse", "--verify", candidate], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0: + base = candidate + break + + current = Path("pyproject.toml").read_text() + previous = git_show("pyproject.toml", base) + if not previous: + print("skip: no base pyproject.toml to compare") + return + + old_version = read_version(previous) + new_version = read_version(current) + if old_version == new_version: + print(f"version unchanged ({new_version})") + return + + if not has_changelog_section(new_version): + raise SystemExit( + f"pyproject.toml version bumped to {new_version} but CHANGELOG.md " + f"has no '## [{new_version}]' section" + ) + + print(f"release metadata ok for {new_version}") + + +if __name__ == "__main__": + main() diff --git a/scripts/extract-changelog.py b/scripts/extract-changelog.py new file mode 100755 index 0000000..c2caef1 --- /dev/null +++ b/scripts/extract-changelog.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Print the Keep a Changelog section for a release version.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +def extract(changelog: str, version: str) -> str: + pattern = rf"^## \[{re.escape(version)}\].*$" + match = re.search(pattern, changelog, re.M) + if not match: + raise SystemExit(f"no changelog section for {version}") + + start = match.start() + rest = changelog[match.end() :] + next_heading = re.search(r"^## \[", rest, re.M) + end = match.end() + (next_heading.start() if next_heading else len(rest)) + section = changelog[start:end].strip() + title, _, body = section.partition("\n") + return body.strip() or f"Release {version}." + + +def main() -> None: + if len(sys.argv) != 2: + raise SystemExit("usage: extract-changelog.py VERSION") + + version = sys.argv[1] + changelog = Path("CHANGELOG.md").read_text() + print(extract(changelog, version)) + + +if __name__ == "__main__": + main() diff --git a/scripts/publish-workflow.sh b/scripts/publish-workflow.sh new file mode 100755 index 0000000..88c9db9 --- /dev/null +++ b/scripts/publish-workflow.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Generate publish.yml for a package. Usage: publish-workflow.sh hotdata-runtime +set -euo pipefail +pkg="${1:?package name}" +cat <&2 + exit 1 + fi + tag="\${GITHUB_REF_NAME#v}" + pkg_version=\$(python -c "import tomllib,pathlib; print(tomllib.loads(pathlib.Path('pyproject.toml').read_text())['project']['version'])") + if [ "\$tag" != "\$pkg_version" ]; then + echo "Release tag (\$tag) does not match pyproject.toml version (\$pkg_version)" >&2 + exit 1 + fi + + - name: Build sdist and wheel + run: python -m build + + - name: Check distribution metadata + run: python -m twine check --strict dist/* + + - uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5 + with: + name: dist + path: dist/ + + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/${pkg} + permissions: + id-token: write + steps: + - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5 + with: + name: dist + path: dist/ + + - name: Publish via Trusted Publishing + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 +EOF diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100755 index 0000000..03aaeae --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +die() { echo "error: $*" >&2; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } + +usage() { + cat <<'EOF' +Usage: + ./scripts/release.sh prepare [patch|minor|major|X.Y.Z] + ./scripts/release.sh publish + +Workflow: + 1. Move notes from [Unreleased] in CHANGELOG.md (or add them there). + 2. ./scripts/release.sh prepare patch + 3. Merge the release PR. + 4. ./scripts/release.sh publish + +Tag push triggers PyPI publish and GitHub Release creation in CI. +EOF +} + +get_version() { + python3 - <<'PY' +import tomllib +from pathlib import Path +print(tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]) +PY +} + +get_pkg_name() { + python3 - <<'PY' +import tomllib +from pathlib import Path +print(tomllib.loads(Path("pyproject.toml").read_text())["project"]["name"]) +PY +} + +set_version() { + local ver="$1" + python3 - "$ver" <<'PY' +import re, sys +from pathlib import Path +ver = sys.argv[1] +path = Path("pyproject.toml") +text = path.read_text() +new, n = re.subn(r'(?m)^version = "[^"]+"', f'version = "{ver}"', text, count=1) +if n != 1: + raise SystemExit("could not update version in pyproject.toml") +path.write_text(new) +PY +} + +bump_version() { + local kind="$1" current="$2" + python3 - "$kind" "$current" <<'PY' +import re, sys +kind, current = sys.argv[1], sys.argv[2] +match = re.match(r"^(\d+)\.(\d+)\.(\d+)(.*)$", current) +if not match: + raise SystemExit(f"unsupported version: {current}") +major, minor, patch, suffix = int(match[1]), int(match[2]), int(match[3]), match[4] +if suffix: + raise SystemExit("pre-release versions must be set explicitly as X.Y.Z") +if kind == "patch": + patch += 1 +elif kind == "minor": + minor += 1 + patch = 0 +elif kind == "major": + major += 1 + minor = 0 + patch = 0 +else: + raise SystemExit(f"unknown bump kind: {kind}") +print(f"{major}.{minor}.{patch}") +PY +} + +default_branch() { + local remote="${1:-origin}" + git symbolic-ref --quiet "refs/remotes/${remote}/HEAD" 2>/dev/null | sed "s|refs/remotes/${remote}/||" \ + || { git branch -r | sed -n "s|^ ${remote}/\\(main\\|master\\)$|\\1|p" | head -1; } \ + || echo main +} + +ensure_clean() { + [[ -z "$(git status --porcelain)" ]] || die "working tree is not clean" +} + +update_changelog() { + local ver="$1" + local date + date="$(date +%Y-%m-%d)" + python3 scripts/update_changelog.py "$ver" "$date" +} + +cmd_prepare() { + local bump="${1:-}" + [[ -n "$bump" ]] || { usage; die "missing bump kind or explicit version"; } + need gh + need python3 + ensure_clean + + local current new base branch pkg + current="$(get_version)" + if [[ "$bump" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + new="$bump" + else + new="$(bump_version "$bump" "$current")" + fi + [[ "$new" != "$current" ]] || die "new version ($new) equals current ($current)" + + base="$(default_branch)" + git fetch origin "$base" + git checkout "$base" + git pull --ff-only origin "$base" + ensure_clean + + set_version "$new" + update_changelog "$new" + + branch="release/v${new}" + git checkout -b "$branch" + git add pyproject.toml CHANGELOG.md + git commit -m "chore: release v${new}" + + pkg="$(get_pkg_name)" + git push -u origin "$branch" + gh pr create --base "$base" --head "$branch" \ + --title "chore: release ${pkg} v${new}" \ + --body "## Summary +Release **${pkg} v${new}**. + +## Checklist +- [x] Version bumped in \`pyproject.toml\` +- [x] \`CHANGELOG.md\` updated +- [ ] CI green + +After merge, run \`./scripts/release.sh publish\` from a clean \`${base}\` checkout." + + echo "Prepared ${pkg} v${new}. Merge the PR, then run: ./scripts/release.sh publish" +} + +cmd_publish() { + need gh + need python3 + ensure_clean + + local base ver tag + base="$(default_branch)" + git fetch origin "$base" + git checkout "$base" + git pull --ff-only origin "$base" + ensure_clean + + ver="$(get_version)" + tag="v${ver}" + + git rev-parse "$tag" >/dev/null 2>&1 && die "tag $tag already exists" + [[ -f CHANGELOG.md ]] || die "CHANGELOG.md is required" + python3 - "$ver" <<'PY' +import re, sys +from pathlib import Path +ver = sys.argv[1] +text = Path("CHANGELOG.md").read_text() +if not re.search(rf"^## \[{re.escape(ver)}\]", text, re.M): + raise SystemExit(f"CHANGELOG.md missing section for {ver}") +PY + + git tag "$tag" + git push origin "$tag" + + pkg="$(get_pkg_name)" + echo "Pushed ${tag} for ${pkg}." + echo "CI will publish to PyPI and create the GitHub Release." +} + +case "${1:-}" in + prepare) shift; cmd_prepare "${1:-}" ;; + publish) cmd_publish ;; + -h|--help|help|"") usage ;; + *) usage; die "unknown command: $1" ;; +esac diff --git a/scripts/update_changelog.py b/scripts/update_changelog.py new file mode 100755 index 0000000..a81b5a6 --- /dev/null +++ b/scripts/update_changelog.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Update CHANGELOG.md for a new release version.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +def update_changelog_text(text: str, ver: str, date: str) -> str: + if re.search(rf"^## \[{re.escape(ver)}\]", text, re.M): + return text + + unreleased = re.search(r"^## \[Unreleased\]\s*\n(.*?)(?=^## \[|\Z)", text, re.M | re.S) + if unreleased: + body = unreleased.group(1).strip() + if body: + section = f"## [{ver}] - {date}\n\n{body}\n\n" + else: + section = ( + f"## [{ver}] - {date}\n\n" + "### Changed\n\n" + f"- Release {ver}\n\n" + ) + return re.sub( + r"^(## \[Unreleased\]\s*\n)(.*?)(?=^## \[|\Z)", + lambda match: match.group(1) + "\n" + section, + text, + count=1, + flags=re.M | re.S, + ) + + section = ( + f"## [Unreleased]\n\n" + f"## [{ver}] - {date}\n\n" + "### Changed\n\n" + f"- Release {ver}\n\n" + ) + first_heading = re.search(r"^## \[", text, re.M) + if first_heading: + pos = first_heading.start() + return text[:pos] + section + text[pos:] + return text.rstrip() + "\n\n" + section + + +def update_changelog_file(path: Path, ver: str, date: str) -> None: + if not path.exists(): + path.write_text( + "# Changelog\n\n" + "All notable changes to this project will be documented in this file.\n\n" + "The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\n" + "and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n" + f"## [Unreleased]\n\n" + f"## [{ver}] - {date}\n\n" + "### Changed\n\n" + f"- Release {ver}\n" + ) + return + + path.write_text(update_changelog_text(path.read_text(), ver, date)) + + +def main() -> None: + if len(sys.argv) != 3: + raise SystemExit("usage: update_changelog.py VERSION YYYY-MM-DD") + update_changelog_file(Path("CHANGELOG.md"), sys.argv[1], sys.argv[2]) + + +if __name__ == "__main__": + main() diff --git a/tests/test_contract.py b/tests/test_contract.py index f324864..2d6ee36 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -14,12 +14,9 @@ def test_public_exports_contract(): "DEFAULT_SCHEMA", "HotdataClient", "LoadManagedTableResult", - "MANAGED_SOURCE_TYPE", "ManagedDatabase", "ManagedTable", "QueryResult", - "build_managed_config", - "create_connection_request", "is_parquet_path", "workspace_health_lines", "default_api_key", diff --git a/tests/test_databases.py b/tests/test_databases.py index 8673c64..7b39490 100644 --- a/tests/test_databases.py +++ b/tests/test_databases.py @@ -8,9 +8,8 @@ from hotdata.exceptions import ApiException from hotdata_runtime.client import HotdataClient from hotdata_runtime.databases import ( - build_managed_config, - create_connection_request, is_parquet_path, + managed_database_from_detail, ) @@ -18,28 +17,12 @@ def _client() -> HotdataClient: return HotdataClient("k", "ws", host="https://api.hotdata.dev") -def test_build_managed_config_empty_without_tables(): - assert build_managed_config("public", []) == {} - - -def test_build_managed_config_declares_tables(): - cfg = build_managed_config("public", ["orders", "customers"]) - assert cfg == { - "schemas": [ - { - "name": "public", - "tables": [{"name": "orders"}, {"name": "customers"}], - } - ] - } - - -def test_create_connection_request_uses_managed_source_type(): - req = create_connection_request("sales", schema="public", tables=["orders"]) - assert req.name == "sales" - assert req.source_type == "managed" - assert req.skip_discovery is True - assert req.config["schemas"][0]["tables"][0]["name"] == "orders" +def _detail(id="db_1", description="sales", default_connection_id="conn_1"): + return SimpleNamespace( + id=id, + description=description, + default_connection_id=default_connection_id, + ) @pytest.mark.parametrize( @@ -54,64 +37,94 @@ def test_is_parquet_path(path: str, expected: bool): assert is_parquet_path(path) is expected -def test_list_managed_databases_filters_managed_only(): +def test_managed_database_from_detail(): + db = managed_database_from_detail(_detail()) + assert db.id == "db_1" + assert db.description == "sales" + assert db.default_connection_id == "conn_1" + + +def test_list_managed_databases_returns_all(): + client = _client() + summary = SimpleNamespace(id="db_1") + detail = _detail() + listing = SimpleNamespace(databases=[summary]) + with patch.object(client, "_databases_api") as dbs: + dbs.return_value.list_databases.return_value = listing + dbs.return_value.get_database.return_value = detail + result = client.list_managed_databases() + assert len(result) == 1 + assert result[0].id == "db_1" + assert result[0].description == "sales" + + +def test_list_managed_databases_skips_failed_gets(): client = _client() - listing = SimpleNamespace( - connections=[ - SimpleNamespace(id="c1", name="sales", source_type="managed"), - SimpleNamespace(id="c2", name="warehouse", source_type="postgres"), + summaries = [SimpleNamespace(id="db_1"), SimpleNamespace(id="db_2")] + detail = _detail(id="db_2", description="warehouse", default_connection_id="conn_2") + listing = SimpleNamespace(databases=summaries) + with patch.object(client, "_databases_api") as dbs: + dbs.return_value.list_databases.return_value = listing + dbs.return_value.get_database.side_effect = [ + ApiException(status=404, reason="not found"), + detail, ] - ) - with patch.object(client, "connections") as connections: - connections.return_value.list_connections.return_value = listing - dbs = client.list_managed_databases() - assert [db.name for db in dbs] == ["sales"] + result = client.list_managed_databases() + assert len(result) == 1 + assert result[0].id == "db_2" -def test_resolve_managed_database_by_name_and_id(): +def test_resolve_managed_database_by_id(): client = _client() - listing = SimpleNamespace( - connections=[ - SimpleNamespace(id="conn_abc", name="sales", source_type="managed"), - ] - ) - with patch.object(client, "connections") as connections: - connections.return_value.list_connections.return_value = listing - by_name = client.resolve_managed_database("sales") - by_id = client.resolve_managed_database("conn_abc") - assert by_name.id == "conn_abc" - assert by_id.name == "sales" + detail = _detail() + with patch.object(client, "_databases_api") as dbs: + dbs.return_value.get_database.return_value = detail + db = client.resolve_managed_database("db_1") + assert db.id == "db_1" + assert db.default_connection_id == "conn_1" -def test_resolve_managed_database_rejects_non_managed(): +def test_resolve_managed_database_by_description(): client = _client() - listing = SimpleNamespace( - connections=[ - SimpleNamespace(id="c1", name="warehouse", source_type="postgres"), + summary = SimpleNamespace(id="db_1", description="sales") + listing = SimpleNamespace(databases=[summary]) + detail = _detail() + with patch.object(client, "_databases_api") as dbs: + dbs.return_value.get_database.side_effect = [ + ApiException(status=404, reason="not found"), + detail, ] - ) - with patch.object(client, "connections") as connections: - connections.return_value.list_connections.return_value = listing - with pytest.raises(ValueError, match="not a managed database"): - client.resolve_managed_database("warehouse") + dbs.return_value.list_databases.return_value = listing + db = client.resolve_managed_database("sales") + assert db.id == "db_1" + + +def test_resolve_managed_database_not_found(): + client = _client() + listing = SimpleNamespace(databases=[]) + with patch.object(client, "_databases_api") as dbs: + dbs.return_value.get_database.side_effect = ApiException(status=404, reason="not found") + dbs.return_value.list_databases.return_value = listing + with pytest.raises(KeyError, match="no-such"): + client.resolve_managed_database("no-such") def test_create_managed_database_returns_summary(): client = _client() - created = SimpleNamespace(id="conn_new", name="mydb", source_type="managed") - with patch.object(client, "connections") as connections: - connections.return_value.create_connection.return_value = created + created = _detail(id="db_new", description="mydb", default_connection_id="conn_new") + with patch.object(client, "_databases_api") as dbs: + dbs.return_value.create_database.return_value = created db = client.create_managed_database("mydb", tables=["orders"]) - assert db.id == "conn_new" - assert db.name == "mydb" - req = connections.return_value.create_connection.call_args.args[0] - assert req.config["schemas"][0]["tables"][0]["name"] == "orders" + assert db.id == "db_new" + assert db.description == "mydb" + req = dbs.return_value.create_database.call_args.args[0] + assert req.schemas[0].tables[0].name == "orders" def test_create_managed_database_wraps_api_errors(): client = _client() - with patch.object(client, "connections") as connections: - connections.return_value.create_connection.side_effect = ApiException( + with patch.object(client, "_databases_api") as dbs: + dbs.return_value.create_database.side_effect = ApiException( status=400, reason="bad request", ) @@ -119,27 +132,40 @@ def test_create_managed_database_wraps_api_errors(): client.create_managed_database("mydb") +def test_create_managed_database_with_expires_at(): + client = _client() + created = _detail() + with patch.object(client, "_databases_api") as dbs: + dbs.return_value.create_database.return_value = created + client.create_managed_database("mydb", expires_at="7d") + req = dbs.return_value.create_database.call_args.args[0] + assert req.expires_at == "7d" + + +def test_delete_managed_database_calls_sdk(): + client = _client() + db = managed_database_from_detail(_detail()) + with patch.object(client, "resolve_managed_database", return_value=db), \ + patch.object(client, "_databases_api") as dbs: + client.delete_managed_database("db_1") + dbs.return_value.delete_database.assert_called_once_with("db_1") + + def test_list_managed_tables_builds_full_names(): client = _client() - listing = SimpleNamespace( - connections=[ - SimpleNamespace(id="conn1", name="sales", source_type="managed"), - ] - ) + db = managed_database_from_detail(_detail()) table = SimpleNamespace( - connection="sales", + connection="conn_1", var_schema="public", table="orders", synced=True, last_sync="2026-05-19T00:00:00Z", ) - with patch.object(client, "connections") as connections, patch.object( - client, "iter_tables", return_value=[table] - ): - connections.return_value.list_connections.return_value = listing - rows = client.list_managed_tables("sales") + with patch.object(client, "resolve_managed_database", return_value=db), \ + patch.object(client, "iter_tables", return_value=[table]): + rows = client.list_managed_tables("db_1") assert len(rows) == 1 - assert rows[0].full_name == "sales.public.orders" + assert rows[0].full_name == "db_1.public.orders" assert rows[0].synced is True @@ -162,48 +188,55 @@ def test_upload_parquet_returns_upload_id(): def test_load_managed_table_with_upload_id(): client = _client() - db = SimpleNamespace(id="conn1", name="sales", source_type="managed") + db = managed_database_from_detail(_detail()) loaded = SimpleNamespace( - connection_id="conn1", + connection_id="conn_1", schema_name="public", table_name="orders", row_count=42, ) - with patch.object(client, "resolve_managed_database", return_value=db), patch.object( - client, "connections" - ) as connections: + with patch.object(client, "resolve_managed_database", return_value=db), \ + patch.object(client, "connections") as connections: connections.return_value.load_managed_table.return_value = loaded result = client.load_managed_table( - "sales", + "db_1", "orders", upload_id="upl_123", ) assert result.row_count == 42 - assert result.full_name == "sales.public.orders" + assert result.full_name == "db_1.public.orders" + connections.return_value.load_managed_table.assert_called_once_with( + "conn_1", "public", "orders", _Any() + ) def test_load_managed_table_requires_exactly_one_source(): client = _client() with pytest.raises(ValueError, match="Exactly one"): - client.load_managed_table("sales", "orders") + client.load_managed_table("db_1", "orders") with pytest.raises(ValueError, match="Exactly one"): client.load_managed_table( - "sales", + "db_1", "orders", upload_id="upl_1", file="/tmp/x.parquet", ) -def test_delete_managed_table_calls_sdk(): +def test_delete_managed_table_uses_default_connection_id(): client = _client() - db = SimpleNamespace(id="conn1", name="sales", source_type="managed") - with patch.object(client, "resolve_managed_database", return_value=db), patch.object( - client, "connections" - ) as connections: - client.delete_managed_table("sales", "orders") + db = managed_database_from_detail(_detail()) + with patch.object(client, "resolve_managed_database", return_value=db), \ + patch.object(client, "connections") as connections: + client.delete_managed_table("db_1", "orders") connections.return_value.delete_managed_table.assert_called_once_with( - "conn1", + "conn_1", "public", "orders", ) + + +class _Any: + """Matches any value in assert_called_once_with.""" + def __eq__(self, other: object) -> bool: + return True diff --git a/tests/test_update_changelog.py b/tests/test_update_changelog.py new file mode 100644 index 0000000..c34c126 --- /dev/null +++ b/tests/test_update_changelog.py @@ -0,0 +1,48 @@ +import importlib.util +from pathlib import Path + + +def _update_changelog_text(text: str, ver: str, date: str) -> str: + path = Path(__file__).resolve().parents[1] / "scripts" / "update_changelog.py" + spec = importlib.util.spec_from_file_location("update_changelog", path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module.update_changelog_text(text, ver, date) + + +HEADER = """# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [0.1.1] - 2026-05-19 + +### Added + +- Initial feature. +""" + + +def test_empty_unreleased_inserts_version_without_duplicate_heading(): + result = _update_changelog_text(HEADER, "0.1.2", "2026-05-20") + assert result.count("## [Unreleased]") == 1 + assert "## [0.1.2] - 2026-05-20" in result + assert "The format is based on [Keep a Changelog]" in result.split("## [0.1.2]")[0] + assert result.index("## [0.1.2]") < result.index("## [0.1.1]") + + +def test_populated_unreleased_moves_notes_into_new_section(): + text = HEADER.replace( + "## [Unreleased]\n\n", + "## [Unreleased]\n\n### Added\n\n- New widget.\n\n", + ) + result = _update_changelog_text(text, "0.1.2", "2026-05-20") + assert result.count("## [Unreleased]") == 1 + assert "- New widget." in result + assert result.index("- New widget.") < result.index("## [0.1.1]") + assert "The format is based on [Keep a Changelog]" in result.split("## [0.1.2]")[0] diff --git a/uv.lock b/uv.lock index 285eb49..a6f56a5 100644 --- a/uv.lock +++ b/uv.lock @@ -43,7 +43,7 @@ wheels = [ [[package]] name = "hotdata" -version = "0.2.0" +version = "0.2.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, @@ -51,9 +51,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ce/0f/1e9e024aa13f8d4bf8f9fb1bce777da6ca19da05b8435f2ba5cd5f87ec80/hotdata-0.2.0.tar.gz", hash = "sha256:e1131c05ed34d2f39ddee84930eb6694ed46971d7a442df5932689b28a6c9b4f", size = 108780, upload-time = "2026-05-19T04:01:38.345Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/20/5d016d4aec39fe04eb77a6394651e3b18f6ecc701dc678563889debd79ed/hotdata-0.2.3.tar.gz", hash = "sha256:bc415af4ac475e5bd5fe3320d1c14aaac92942462a0ef9dac22b89bcc120ad55", size = 118187, upload-time = "2026-05-23T04:41:10.835Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/e7/63b4820963ec475fe16403d363e5ddec237cfe01a39c2d7aff6a6d48d720/hotdata-0.2.0-py3-none-any.whl", hash = "sha256:d3d644a3b607f4891a784b8d5afa30a00bd9e437db013fd0581bf8bca501ac0d", size = 256603, upload-time = "2026-05-19T04:01:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/09/87/d3cb845ba01e5b4e9bfb1e59d0032a246b94497e470d171f2ee2a56bd850/hotdata-0.2.3-py3-none-any.whl", hash = "sha256:aed2ae884d184cf143572c84d068a9ceedbe021a6d14005332647a46aa7be11c", size = 275718, upload-time = "2026-05-23T04:41:09.355Z" }, ] [[package]] @@ -74,7 +74,7 @@ dev = [ [package.metadata] requires-dist = [ - { name = "hotdata", specifier = ">=0.2.0" }, + { name = "hotdata", specifier = ">=0.2.3" }, { name = "pandas", specifier = ">=2.0" }, ]