From ca8f6d1ff2447d1fce99491924c230c3331aa917 Mon Sep 17 00:00:00 2001 From: Cahid Arda Oz Date: Thu, 22 Oct 2026 11:10:43 +0000 Subject: [PATCH 1/5] chore: support Python packages in the monorepo (DX-3182) Add a uv workspace under python/* that releases through the existing Changesets flow. Each Python package carries a private package.json that holds its version. sync-version.mjs copies that version into pyproject.toml and _version.py, and publish-python.mjs uploads any version PyPI doesn't have yet. Scaffold upstash-hermes-memory at 0.0.0, which means unreleased. The CI and release workflow changes come in a separate commit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JTGdTkxgRFnNpNZow3diPo --- .changeset/config.json | 6 +- .gitignore | 5 + CLAUDE.md | 28 +++ README.md | 5 + eslint.config.js | 2 +- package.json | 7 +- pnpm-lock.yaml | 2 + pnpm-workspace.yaml | 1 + pyproject.toml | 15 ++ python/hermes-memory/README.md | 7 + python/hermes-memory/package.json | 6 + python/hermes-memory/pyproject.toml | 16 ++ .../src/upstash_hermes_memory/__init__.py | 12 ++ .../src/upstash_hermes_memory/_version.py | 2 + python/hermes-memory/tests/test_version.py | 10 + scripts/publish-python.mjs | 80 +++++++ scripts/sync-version.mjs | 60 +++++- uv.lock | 200 ++++++++++++++++++ 18 files changed, 452 insertions(+), 12 deletions(-) create mode 100644 pyproject.toml create mode 100644 python/hermes-memory/README.md create mode 100644 python/hermes-memory/package.json create mode 100644 python/hermes-memory/pyproject.toml create mode 100644 python/hermes-memory/src/upstash_hermes_memory/__init__.py create mode 100644 python/hermes-memory/src/upstash_hermes_memory/_version.py create mode 100644 python/hermes-memory/tests/test_version.py create mode 100644 scripts/publish-python.mjs create mode 100644 uv.lock diff --git a/.changeset/config.json b/.changeset/config.json index a25cc0e..bc94422 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -14,5 +14,9 @@ "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": [] + "ignore": [], + "privatePackages": { + "version": true, + "tag": false + } } diff --git a/.gitignore b/.gitignore index b4430a2..ab4320d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,8 @@ coverage *.tsbuildinfo .vscode .idea +.venv +__pycache__ +*.egg-info +.pytest_cache +.ruff_cache diff --git a/CLAUDE.md b/CLAUDE.md index 20349f6..a5ece39 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,7 @@ embeddings — keep that in mind when naming/among scoring. | `@upstash/agentkit-tanstack-ai` (`packages/tanstack-ai`) | TanStack AI backends: persistence stores, `StreamDurability`, `LockStore`, `MemoryAdapter`, middlewares, search tools. | | `@upstash/agentkit-eve-extension` (`packages/eve-extension`) | AgentKit as a mountable **eve extension** (eve ≥0.24): one `agent/extensions/.ts` file composes memory tools, search tools, a chat-history hook, and an instructions fragment under `__*`. | | `@upstash/mcp-toolkit` (`packages/mcp-toolkit`) | Durable building blocks for the official `@modelcontextprotocol/server` v2: `/tasks` (**long-running MCP tools** — start + `task_status` + `task_cancel`) and `/events` (**MCP Events**, webhook delivery). `/tasks` and `/events` are generic (layers, interfaces, memory backends); every Upstash backend for both is in `/upstash` (no `/tasks/upstash`, no `/events/upstash`). `@upstash/redis`, `@upstash/qstash` and `@upstash/workflow` are regular dependencies (the package "just works"); only `@modelcontextprotocol/server` is a peer. Renamed from `@upstash/mcp-tasks` before its first release. **Not an `agentkit-*` package** — separate name, versioned independently (the changesets `linked` glob only covers `@upstash/agentkit-*`), and it depends on none of the others. | +| `upstash-hermes-memory` (`python/hermes-memory`, **PyPI**) | Hermes Agent memory provider on Redis Search. **Scaffold at `0.0.0`** (unreleased) until the port lands (DX-3182). See **Python packages** below. | Examples (`examples/`): `ai-sdk-demo` (hand-written Next.js), `eve-demo` (a real `eve` CLI scaffold), `eve-extension-demo` (a minimal eve scaffold that mounts the extension), and `mcp-toolkit-demo` @@ -1284,6 +1285,33 @@ implements TanStack AI's own backend contracts (see its section below) — keep - The extension demo's `userId` is the static `"demo-user"` so memory persists across sessions in an unauthenticated local agent (the default derivation would fall back to the per-session id). +## Python packages (`python/*`) + +Python lives **beside** the pnpm workspace, not instead of any of it: + +- **uv workspace** at the root (`pyproject.toml`, members `python/*`, one `uv.lock`). `pnpm py:lint` + (ruff) and `pnpm py:test` (pytest) run across every member; `uv sync --all-packages` sets it up. +- **Changesets is still the only versioning tool.** Each Python package carries a **private + `package.json`** whose only job is to hold the version, and `python/*` is in `pnpm-workspace.yaml` + so changesets sees it. `pnpm changeset` lists it like any npm package; `changeset version` bumps it + and writes its `CHANGELOG.md`. `privatePackages: { version: true, tag: false }`: changesets never + tags or publishes it. +- `scripts/sync-version.mjs` copies that version into `pyproject.toml` and + `src//_version.py` (`VERSION`, for telemetry), discovering packages by folder; `ci:version` + then runs `uv lock`, so the Version Packages PR carries a consistent `uv.lock`. **Never edit a + Python version by hand** — `sync-version.mjs --check` fails CI on drift. +- `scripts/publish-python.mjs` (`pnpm ci:publish:python`) is PyPI's `changeset publish`: for every + `python/*` whose version is **not on PyPI yet**, `uv build` + `uv publish` (trusted publishing, + no token), then tag `@` + GitHub release from the CHANGELOG section. Idempotent and + keyed on the registry. **`0.0.0` = unreleased** and is always skipped, so a scaffold can merge + without reaching PyPI. `--dry-run` prints the plan. +- **Adding a package:** `python//` with `package.json` (private, `0.0.0`), `pyproject.toml` + (`[project] name` = the package.json name), `src//_version.py`, tests. Then register a + PyPI **trusted publisher** for it (repo `upstash/agentkit`, workflow `release.yml`) before its first + non-zero version merges. Nothing else to touch. +- Python tests share the test Redis (10-index cap) with vitest: run them **after** the Node tests, + never in parallel, and reuse the TS key prefixes/index names when the data is meant to be shared. + ## Commands - **Setup gotcha — use pnpm 11.5.3, matching the `packageManager` pin.** Root `package.json` pins `"packageManager": "pnpm@11.5.3"`. On a box whose global pnpm is a different version (and no diff --git a/README.md b/README.md index 7453293..ac8dd11 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ are powered by [Upstash Redis Search](https://upstash.com/docs/redis/search/intr | [`@upstash/agentkit-tanstack-ai`](./packages/tanstack-ai) | Production backends for [TanStack AI](https://tanstack.com/ai) — chat persistence, resumable streams, distributed locks, memory, tool caching, rate limiting and search tools. | | [`@upstash/agentkit-eve-extension`](./packages/eve-extension) | The same capabilities as a mountable [Eve extension](https://eve.dev/docs/extensions) — one file in `agent/extensions/` adds memory tools, search tools, and durable chat history the agent can search. | | [`@upstash/mcp-toolkit`](./packages/mcp-toolkit) | Durable building blocks for MCP servers on the official TypeScript SDK. `/tasks`: long-running tools that answer with a task id the model polls, with the record in Redis and the work on QStash or Workflow. `/events`: MCP Events, with subscriptions in Redis and signed webhook deliveries retried by QStash. | +| [`upstash-hermes-memory`](./python/hermes-memory) (PyPI) | Memory provider for [Hermes Agent](https://hermes-agent.nousresearch.com) on Upstash Redis Search. Work in progress. | ## Core features @@ -58,6 +59,10 @@ pnpm build # build all packages pnpm test # run all tests (against a real Upstash Redis; LLM calls mocked unless OPENAI_API_KEY is set) pnpm lint # eslint + prettier pnpm typecheck # tsc across packages + +uv sync --all-packages # Python packages under python/ (uv workspace) +pnpm py:lint # ruff +pnpm py:test # pytest ``` Tests need `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` (in a repo-root `.env`); suites that diff --git a/eslint.config.js b/eslint.config.js index 181927d..95d987a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -59,7 +59,7 @@ export default [ languageOptions: { ecmaVersion: 2022, sourceType: "module", - globals: { process: "readonly", console: "readonly" }, + globals: { process: "readonly", console: "readonly", fetch: "readonly" }, }, }, { diff --git a/package.json b/package.json index 076cd55..ae057e0 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,12 @@ "lint:fix": "eslint . --fix && prettier --write .", "typecheck": "pnpm --filter @upstash/agentkit-sdk build && pnpm -r --filter \"./packages/*\" typecheck", "clean": "pnpm -r --filter \"./packages/*\" clean", + "py:lint": "uv run ruff check python && uv run ruff format --check python", + "py:test": "uv run pytest", "changeset": "changeset", - "ci:version": "changeset version && node scripts/sync-version.mjs", - "ci:publish": "changeset publish" + "ci:version": "changeset version && node scripts/sync-version.mjs && uv lock", + "ci:publish": "changeset publish", + "ci:publish:python": "node scripts/publish-python.mjs" }, "devDependencies": { "@changesets/cli": "^2.27.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 49dbdcb..d75f109 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -479,6 +479,8 @@ importers: specifier: ^16.4.5 version: 16.6.1 + python/hermes-memory: {} + packages: '@ag-ui/core@1.0.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4e0eed7..740b4f3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - "packages/*" - "examples/*" + - "python/*" allowBuilds: esbuild: true sharp: true diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a93cc97 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +# uv workspace for the Python packages under python/*. It lives beside the pnpm workspace rather than +# replacing anything: each Python package also carries a private package.json so Changesets versions +# it like every other package (see the "Python packages" section of CLAUDE.md). +[tool.uv.workspace] +members = ["python/*"] + +[dependency-groups] +dev = ["pytest>=8", "ruff>=0.6"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.pytest.ini_options] +testpaths = ["python"] diff --git a/python/hermes-memory/README.md b/python/hermes-memory/README.md new file mode 100644 index 0000000..a66444e --- /dev/null +++ b/python/hermes-memory/README.md @@ -0,0 +1,7 @@ +# upstash-hermes-memory + +An Upstash Redis memory provider for [Hermes Agent](https://hermes-agent.nousresearch.com), built on +the same Redis Search layout as `@upstash/agentkit-sdk`'s `AgentMemory` and `ChatHistory`. + +**Status: scaffold.** The package is at `0.0.0`, which `scripts/publish-python.mjs` treats as +unreleased, so nothing is published to PyPI yet. The provider itself is tracked in DX-3182. diff --git a/python/hermes-memory/package.json b/python/hermes-memory/package.json new file mode 100644 index 0000000..3d5ab47 --- /dev/null +++ b/python/hermes-memory/package.json @@ -0,0 +1,6 @@ +{ + "name": "upstash-hermes-memory", + "version": "0.0.0", + "private": true, + "description": "Version carrier for the upstash-hermes-memory PyPI package. Changesets bumps it; scripts/sync-version.mjs copies it into pyproject.toml." +} diff --git a/python/hermes-memory/pyproject.toml b/python/hermes-memory/pyproject.toml new file mode 100644 index 0000000..d3f41ab --- /dev/null +++ b/python/hermes-memory/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "upstash-hermes-memory" +version = "0.0.0" +description = "Upstash Redis memory provider for Hermes Agent: long-term memory, searchable chat history and recall on Redis Search, with no separate vector database." +readme = "README.md" +license = "MIT" +requires-python = ">=3.11" +dependencies = ["upstash-redis>=1.8.0"] + +[project.urls] +Homepage = "https://github.com/upstash/agentkit" +Repository = "https://github.com/upstash/agentkit" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/python/hermes-memory/src/upstash_hermes_memory/__init__.py b/python/hermes-memory/src/upstash_hermes_memory/__init__.py new file mode 100644 index 0000000..b4a85bf --- /dev/null +++ b/python/hermes-memory/src/upstash_hermes_memory/__init__.py @@ -0,0 +1,12 @@ +"""Upstash Redis memory provider for Hermes Agent. + +Hermes stopped taking new memory providers in its own repository: they ship as standalone plugins +and are listed in its plugin catalog. This package is that plugin, and it lives in the AgentKit +monorepo rather than in a repository of its own so the Python port reads and writes exactly the +keys and Redis Search indexes the TypeScript SDK does. A memory saved from an AI SDK app is then +recalled by a Hermes agent on the same database. +""" + +from ._version import VERSION + +__all__ = ["VERSION"] diff --git a/python/hermes-memory/src/upstash_hermes_memory/_version.py b/python/hermes-memory/src/upstash_hermes_memory/_version.py new file mode 100644 index 0000000..802eed8 --- /dev/null +++ b/python/hermes-memory/src/upstash_hermes_memory/_version.py @@ -0,0 +1,2 @@ +# Generated by scripts/sync-version.mjs (run by `pnpm ci:version`) — do not edit by hand. +VERSION = "0.0.0" diff --git a/python/hermes-memory/tests/test_version.py b/python/hermes-memory/tests/test_version.py new file mode 100644 index 0000000..5d090a2 --- /dev/null +++ b/python/hermes-memory/tests/test_version.py @@ -0,0 +1,10 @@ +import tomllib +from pathlib import Path + +from upstash_hermes_memory import VERSION + + +def test_version_matches_pyproject(): + # The telemetry constant and the published version must agree; sync-version.mjs keeps them so. + pyproject = tomllib.loads((Path(__file__).parents[1] / "pyproject.toml").read_text()) + assert VERSION == pyproject["project"]["version"] diff --git a/scripts/publish-python.mjs b/scripts/publish-python.mjs new file mode 100644 index 0000000..7a842a3 --- /dev/null +++ b/scripts/publish-python.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// Publishes the Python packages under `python/*` to PyPI. The `changeset publish` step of the release +// workflow only knows npm, so this runs right after it and plays the same role for PyPI: whatever +// version Changesets left in a package's private `package.json` is published if PyPI does not have it +// yet. Like `changeset publish`, it is idempotent and keyed on the registry, not on what changed in +// the last commit, so a failed or re-run release simply picks up where it stopped. +// +// `0.0.0` means "not released yet" (a scaffold), and is skipped, so a package can live in the repo +// before its first changeset without anything reaching PyPI. +// +// After a successful upload the script tags `@` and opens a GitHub release with that +// version's CHANGELOG section, matching what changesets does for the npm packages. +// +// node scripts/publish-python.mjs # publish (CI: needs `id-token: write` + GH_TOKEN) +// node scripts/publish-python.mjs --dry-run # print what would be published, change nothing +import { execFileSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const dryRun = process.argv.includes("--dry-run"); +const run = (cmd, args) => execFileSync(cmd, args, { cwd: root, stdio: "inherit" }); + +/** Whether PyPI already serves `name==version`. Anything but 200/404 is an error, not a "no". */ +const onPyPI = async (name, version) => { + const res = await fetch(`https://pypi.org/pypi/${name}/${version}/json`); + if (res.status === 200) return true; + if (res.status === 404) return false; + throw new Error(`PyPI lookup for ${name}==${version} failed: HTTP ${res.status}`); +}; + +/** The `## ` section of a changesets-written CHANGELOG.md, without its heading. */ +const releaseNotes = (pkgDir, version) => { + const path = join(root, pkgDir, "CHANGELOG.md"); + if (!existsSync(path)) return ""; + const sections = readFileSync(path, "utf8").split(/^## /m); + const section = sections.find((s) => s.startsWith(`${version}\n`)); + return section ? section.slice(version.length).trim() : ""; +}; + +const pythonRoot = join(root, "python"); +const dirs = existsSync(pythonRoot) + ? readdirSync(pythonRoot).filter((d) => existsSync(join(pythonRoot, d, "pyproject.toml"))) + : []; + +for (const dir of dirs) { + const pkgDir = `python/${dir}`; + const { name, version } = JSON.parse(readFileSync(join(root, pkgDir, "package.json"), "utf8")); + if (version === "0.0.0") { + console.log(`${name}: 0.0.0 (unreleased), skipping`); + continue; + } + if (await onPyPI(name, version)) { + console.log(`${name}@${version}: already on PyPI, skipping`); + continue; + } + if (dryRun) { + console.log(`${name}@${version}: would build, publish and tag`); + continue; + } + + const outDir = join("dist", "python", name); + run("uv", ["build", "--package", name, "--out-dir", outDir]); + run("uv", ["publish", "--trusted-publishing", "always", join(outDir, "*")]); + + const tag = `${name}@${version}`; + run("git", ["tag", tag]); + run("git", ["push", "origin", tag]); + run("gh", [ + "release", + "create", + tag, + "--title", + tag, + "--notes", + releaseNotes(pkgDir, version) || tag, + ]); + console.log(`${tag}: published`); +} diff --git a/scripts/sync-version.mjs b/scripts/sync-version.mjs index 7b21b5f..41ccddc 100644 --- a/scripts/sync-version.mjs +++ b/scripts/sync-version.mjs @@ -2,13 +2,17 @@ // Stamps each package's own version into its `version.ts` constant, so the telemetry header reports // the real published version. // +// Python packages under `python/*` are versioned by Changesets through a private `package.json` +// (PyPI has no Changesets support of its own), so their version is copied from there into +// `pyproject.toml` and the package's `_version.py`. `ci:version` re-locks `uv.lock` afterwards. +// // This runs at **release time only** (`pnpm ci:version`, right after `changeset version` bumps the // package.json files), so the constant lands in the release PR alongside the version bump. Builds and // dev never rewrite tracked source. // // node scripts/sync-version.mjs # write // node scripts/sync-version.mjs --check # verify only, non-zero exit on drift (used by CI) -import { readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -26,16 +30,56 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const check = process.argv.includes("--check"); const stale = []; -for (const [pkgDir, target] of Object.entries(TARGETS)) { - const { version } = JSON.parse(readFileSync(join(root, pkgDir, "package.json"), "utf8")); - const path = join(root, pkgDir, target); - const contents = `// Generated by scripts/sync-version.mjs (run by \`pnpm ci:version\`) — do not edit by hand. -export const VERSION = "${version}"; -`; +const versionOf = (pkgDir) => + JSON.parse(readFileSync(join(root, pkgDir, "package.json"), "utf8")).version; - if (readFileSync(path, "utf8") === contents) continue; +/** Write `contents` to `pkgDir/target`, or record it as stale under `--check`. */ +const stamp = (pkgDir, target, version, contents) => { + const path = join(root, pkgDir, target); + if (readFileSync(path, "utf8") === contents) return; if (check) stale.push(`${pkgDir}/${target} (expected ${version})`); else writeFileSync(path, contents); +}; + +for (const [pkgDir, target] of Object.entries(TARGETS)) { + const version = versionOf(pkgDir); + stamp( + pkgDir, + target, + version, + `// Generated by scripts/sync-version.mjs (run by \`pnpm ci:version\`) — do not edit by hand. +export const VERSION = "${version}"; +`, + ); +} + +// Python packages are discovered rather than listed: a new one is a new folder under `python/`. +// The module is the distribution name with `-` → `_` (`upstash-hermes-memory` → `upstash_hermes_memory`). +const pythonRoot = join(root, "python"); +const pythonDirs = existsSync(pythonRoot) + ? readdirSync(pythonRoot).filter((d) => existsSync(join(pythonRoot, d, "pyproject.toml"))) + : []; + +for (const dir of pythonDirs) { + const pkgDir = `python/${dir}`; + const version = versionOf(pkgDir); + const pyproject = readFileSync(join(root, pkgDir, "pyproject.toml"), "utf8"); + // Only the `version` key of `[project]` — the first `version = ` line at the start of a line. + stamp( + pkgDir, + "pyproject.toml", + version, + pyproject.replace(/^version = ".*"$/m, `version = "${version}"`), + ); + const { name } = JSON.parse(readFileSync(join(root, pkgDir, "package.json"), "utf8")); + stamp( + pkgDir, + `src/${name.replace(/-/g, "_")}/_version.py`, + version, + `# Generated by scripts/sync-version.mjs (run by \`pnpm ci:version\`) — do not edit by hand. +VERSION = "${version}" +`, + ); } if (check && stale.length > 0) { diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b04b958 --- /dev/null +++ b/uv.lock @@ -0,0 +1,200 @@ +version = 1 +revision = 5 +requires-python = ">=3.11" + +[manifest] +members = [ + "upstash-hermes-memory", +] + +[manifest.dependency-groups] +dev = [ + { name = "pytest", specifier = ">=8" }, + { name = "ruff", specifier = ">=0.6" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.10.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/bd/1436c50cc46699de0230fbaacc1d1de7bfec413176099949e9d5112e88f2/certifi-2026.10.19.tar.gz", hash = "sha256:035c1e85572b141bf1c3a3eff0e33a8eb74917522f2559075ff8d2fea780a1ae", size = 137598, upload-time = "2026-10-20T02:24:16.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c4/51/29046e48f693c54e5b7412216d447a1d8362c9653148d98c02cdf65fa733/certifi-2026.10.19-py3-none-any.whl", hash = "sha256:42d35340cc5b9ba78e818ddbff0c5b1c2b779de448a340fd1b989f5fa8670e1b", size = 136464, upload-time = "2026-10-20T02:24:15.072Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e7/e6d5749983b020e6cb1055abec0a6f361a8e94f0184cdcf56d4760995f02/ruff-0.17.1.tar.gz", hash = "sha256:5bb796c5112e9fb9527f2ef2faa0a130d6312d19de92bcfaddd70dbb2d41020f", size = 5323349, upload-time = "2026-10-15T15:23:18.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/78/90c46ca6250709f7c37e397b60d06061adee8d0f7d13959ed6a8e49b14fd/ruff-0.17.1-py3-none-linux_armv6l.whl", hash = "sha256:1796c41eafec66f4635125dd1e074a7aa27b8d20c187d484360acf11d247fc4a", size = 10147528, upload-time = "2026-10-15T15:22:33.667Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d2/a2e28bc35b083ae51fa07dfba5280a667c8b01ecaee7802fc8849dcd5c46/ruff-0.17.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8eadb09a788010be29c895418bdfa513e4e1c65e64b94e5b19e4cd381f6cc7fb", size = 10225974, upload-time = "2026-10-15T15:22:36.328Z" }, + { url = "https://files.pythonhosted.org/packages/8e/71/7a9029a81c8206ee01a98f6cfeeb56e6c095af86857581a37045c46d7e94/ruff-0.17.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:29b454f18ca3eeaf37fbf140841dcf27d36109e29dee9aae8acecb68c26c6f74", size = 9270361, upload-time = "2026-10-15T15:22:38.651Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/e075e9c917cb0575297880c96768304e881240171c870cbd8d9b7b54bec2/ruff-0.17.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872e03dd231744d6c5ec5c8af6be52b71323fc6ee096e4c8b34d9f844ee3b978", size = 9938300, upload-time = "2026-10-15T15:22:41.284Z" }, + { url = "https://files.pythonhosted.org/packages/87/27/0b4bb926a49de2383f161272b4d863571a4c7ecdbf595a6bc58813a13367/ruff-0.17.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4c0d6cb5f8fbac77eec9af49f4fc0eaf856b05545e372b0694d24b4e76b1c640", size = 9948464, upload-time = "2026-10-15T15:22:43.681Z" }, + { url = "https://files.pythonhosted.org/packages/14/27/518d57e0e02aecb8ea4acac6b57b51a53173d25fa734952bfdd7d531e88e/ruff-0.17.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78beced4a3c94dfedbee4b999b95b6a27b35820fdc0afbacc6d37a8da26c1cd1", size = 10638639, upload-time = "2026-10-15T15:22:46.081Z" }, + { url = "https://files.pythonhosted.org/packages/01/82/d89a4e498ade59e6cee52d67f8da569019372212b87468c1fdeae884601c/ruff-0.17.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7feefc63921202894f0e158b0e32f45a5950d70a7fb693ccb9264816c25b1c05", size = 11337238, upload-time = "2026-10-15T15:22:48.894Z" }, + { url = "https://files.pythonhosted.org/packages/a2/7c/3b0fdef9352656317c977086fe1d802902406e4f2a0d8578da90c0e6d1c5/ruff-0.17.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684154861a819bf1f433669d2a0ce9c3e54677c65b8ebb866cc2a7ec86345785", size = 10896327, upload-time = "2026-10-15T15:22:51.45Z" }, + { url = "https://files.pythonhosted.org/packages/62/86/5566290219708ce2928f3a29c4f843a1185b341ec6111c4631a33c5ceced/ruff-0.17.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6ef98e51de8ef38082f90653ceeb84944bd7673783eea13e1647c5b9b064189", size = 10350811, upload-time = "2026-10-15T15:22:54.401Z" }, + { url = "https://files.pythonhosted.org/packages/ad/d2/ca8462dabb3c413990d49703ba306c30c862195a75448d080fa8fcce9775/ruff-0.17.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8f9aa5b5bd5fa1937803ea609c8f153b8de39bf3e0e149897ae34b0c5b52f467", size = 10569994, upload-time = "2026-10-15T15:22:57.142Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ad/dd1864c1f1db17c8cfb297ce70b352388e6977620f66f7182c52855407df/ruff-0.17.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:80e53b0e5faa3c5667f035daaa032893ae42b7cfe708c886d39ed131e54688ab", size = 10059672, upload-time = "2026-10-15T15:23:00.084Z" }, + { url = "https://files.pythonhosted.org/packages/65/45/3f56cf1d2168c2165cdd5ac6f9668aee66a4cfdd4caf2b2cc5ccb2b1d568/ruff-0.17.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:056c5d5fa0ac790051ae165d7035c7bf956d02de2db557f2e9b31d877a505fca", size = 9947803, upload-time = "2026-10-15T15:23:03.132Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4b/5457cb2be4c1d1ea5e919558131a0f197a4a454a0530d9e30820e8206e4c/ruff-0.17.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd8f5a55b10f371642eacd0e8665a42a8b97da15c9a7187c79e697b77b5e0ce4", size = 10351807, upload-time = "2026-10-15T15:23:05.628Z" }, + { url = "https://files.pythonhosted.org/packages/49/fb/db15b4f8fa24a1e36b99e1401f42ef6df16ee299a48670d5d262b771e7d7/ruff-0.17.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ce05e6f3973611d9550a600e04bb531061e368baba31610eb57c1509a8f21a0a", size = 10765716, upload-time = "2026-10-15T15:23:08.092Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e8/1e186578076aff7a80735f625005fde678412ab609cd323ed182eb7d6b87/ruff-0.17.1-py3-none-win32.whl", hash = "sha256:1de0030a8aa78c65c5fecb84b2e5c390ac684c0452875a02afc7c083d49a496a", size = 9895093, upload-time = "2026-10-15T15:23:10.582Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2e/da3d9589bae8e8b58f3a48bd5bc0b980232fb14a83bf02c3be7660f87c1b/ruff-0.17.1-py3-none-win_amd64.whl", hash = "sha256:5df76f16580fab58d0f6c98a3ac254a4abceb7b2489b8b678335869cff77b3d7", size = 10097955, upload-time = "2026-10-15T15:23:13.309Z" }, + { url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "upstash-hermes-memory" +version = "0.0.0" +source = { editable = "python/hermes-memory" } +dependencies = [ + { name = "upstash-redis" }, +] + +[package.metadata] +requires-dist = [{ name = "upstash-redis", specifier = ">=1.8.0" }] + +[[package]] +name = "upstash-redis" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/63/98fffe42262376f4b39d371d9b583b33b13c08f662bda05c627827d2c290/upstash_redis-1.8.0.tar.gz", hash = "sha256:5fcd172802f517473b711de2fd1d2d2587c13c816cd2c570ad66fceb4f77e695", size = 47555, upload-time = "2026-08-26T09:50:41.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/04/a350e6b9b910c6006826dd63deb45be30fed85b826351588df27671bd92b/upstash_redis-1.8.0-py3-none-any.whl", hash = "sha256:e08f18d9a955b2b224ab8d1611a9782bc21054a83afb3b88df36809ff87096fa", size = 49959, upload-time = "2026-08-26T09:50:40.809Z" }, +] From c7da5fa4455897017adad5c708a9d748bc653a1f Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 22 Oct 2026 14:43:06 +0300 Subject: [PATCH 2/5] ci: run Python checks and publish Python packages to PyPI (DX-3182) --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++++++++ .github/workflows/release.yml | 18 ++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10145a3..c70df19 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,3 +129,32 @@ jobs: exit 0 fi npx eve eval + + python: + name: python · lint · test + # After the Node job, not beside it: both suites share the test Redis, whose search-index cap + # (10) is why vitest already runs its files sequentially. + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v6 + with: + # The lowest version the packages support (requires-python >= 3.11). + python-version: "3.11" + enable-cache: true + + - name: Install (locked) + # --locked fails if uv.lock is stale, e.g. a version bump without `pnpm ci:version`. + run: uv sync --locked --all-packages + + - name: Lint (ruff) + run: uv run ruff check python && uv run ruff format --check python + + - name: Test + # Redis-backed suites must skip themselves when these are empty (fork PRs get no secrets). + run: uv run pytest + env: + UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }} + UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bd87be7..8d9eebd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,13 @@ jobs: registry-url: "https://registry.npmjs.org" package-manager-cache: false # never use caching in release builds + # `pnpm ci:version` re-locks uv.lock after bumping Python package versions, and + # `pnpm ci:publish:python` builds and uploads them, so both need uv. + - uses: astral-sh/setup-uv@v6 + with: + python-version: "3.11" + enable-cache: false # never use caching in release builds (setup-uv defaults to on) + - name: Install dependencies run: pnpm install --frozen-lockfile @@ -35,6 +42,7 @@ jobs: # Opens/updates a "Version Packages" PR, or publishes to npm when one is merged. - name: Create Release Pull Request or Publish + id: changesets uses: changesets/action@v1 with: # Dedicated scripts — `version`/`release` would collide with pnpm's built-in commands. @@ -43,3 +51,13 @@ jobs: createGithubReleases: true env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # PyPI's counterpart to `changeset publish`. It runs in publish mode only, i.e. when no + # changesets are pending (the Version Packages PR has merged). It is keyed on what PyPI + # already has, so a re-run is safe and a package at 0.0.0 is skipped. It must run in this + # workflow, because tags pushed with GITHUB_TOKEN do not trigger other workflows. + - name: Publish Python packages to PyPI + if: steps.changesets.outputs.hasChangesets == 'false' + run: pnpm ci:publish:python + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 435b1f09c5962940b03a94d714f2a1c64b52ecb7 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 22 Oct 2026 15:16:50 +0300 Subject: [PATCH 3/5] test(hermes-memory): check version carriers, wheel contents and the test Redis (DX-3182) --- python/hermes-memory/tests/conftest.py | 15 ++++++++ python/hermes-memory/tests/test_package.py | 44 ++++++++++++++++++++++ python/hermes-memory/tests/test_redis.py | 13 +++++++ python/hermes-memory/tests/test_version.py | 32 +++++++++++++++- 4 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 python/hermes-memory/tests/conftest.py create mode 100644 python/hermes-memory/tests/test_package.py create mode 100644 python/hermes-memory/tests/test_redis.py diff --git a/python/hermes-memory/tests/conftest.py b/python/hermes-memory/tests/conftest.py new file mode 100644 index 0000000..f5021ce --- /dev/null +++ b/python/hermes-memory/tests/conftest.py @@ -0,0 +1,15 @@ +import os + +import pytest + + +@pytest.fixture +def redis(): + """A client for the shared test Redis, or a skip when CI has no secrets (fork PRs).""" + if not ( + os.environ.get("UPSTASH_REDIS_REST_URL") and os.environ.get("UPSTASH_REDIS_REST_TOKEN") + ): + pytest.skip("UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN not set") + from upstash_redis import Redis + + return Redis.from_env() diff --git a/python/hermes-memory/tests/test_package.py b/python/hermes-memory/tests/test_package.py new file mode 100644 index 0000000..518b13b --- /dev/null +++ b/python/hermes-memory/tests/test_package.py @@ -0,0 +1,44 @@ +import shutil +import subprocess +import zipfile +from email.parser import Parser +from pathlib import Path + +import pytest +import upstash_hermes_memory +from upstash_hermes_memory import VERSION + +REPO_ROOT = Path(__file__).parents[3] + + +def test_public_api(): + assert upstash_hermes_memory.__all__ == ["VERSION"] + assert isinstance(VERSION, str) + + +@pytest.mark.skipif(shutil.which("uv") is None, reason="uv not on PATH") +def test_wheel_contents(tmp_path): + # What publish-python.mjs uploads: build the wheel the same way and check what is inside it. + subprocess.run( + ["uv", "build", "--wheel", "--package", "upstash-hermes-memory", "--out-dir", tmp_path], + cwd=REPO_ROOT, + check=True, + capture_output=True, + ) + (wheel,) = tmp_path.glob("*.whl") + assert wheel.name.startswith(f"upstash_hermes_memory-{VERSION}-") + + with zipfile.ZipFile(wheel) as zf: + names = zf.namelist() + assert "upstash_hermes_memory/__init__.py" in names + assert "upstash_hermes_memory/_version.py" in names + # Tests and the private package.json are repo plumbing, not part of the distribution. + assert not any(n.startswith("tests/") or n.endswith("package.json") for n in names) + metadata = Parser().parsestr( + zf.read(f"upstash_hermes_memory-{VERSION}.dist-info/METADATA").decode() + ) + + assert metadata["Name"] == "upstash-hermes-memory" + assert metadata["Version"] == VERSION + assert metadata["Requires-Python"] == ">=3.11" + assert any(r.startswith("upstash-redis") for r in metadata.get_all("Requires-Dist")) diff --git a/python/hermes-memory/tests/test_redis.py b/python/hermes-memory/tests/test_redis.py new file mode 100644 index 0000000..880b9ee --- /dev/null +++ b/python/hermes-memory/tests/test_redis.py @@ -0,0 +1,13 @@ +import uuid + + +def test_round_trip(redis): + # Smoke test for the CI wiring: the pinned client reaches the shared test database. It uses a + # plain key with a TTL, never a search index, so it does not count against the 10-index cap. + key = f"agentkit-test:python:{uuid.uuid4().hex}" + try: + assert redis.set(key, "hermes", ex=60) + assert redis.get(key) == "hermes" + finally: + redis.delete(key) + assert redis.get(key) is None diff --git a/python/hermes-memory/tests/test_version.py b/python/hermes-memory/tests/test_version.py index 5d090a2..7443bc6 100644 --- a/python/hermes-memory/tests/test_version.py +++ b/python/hermes-memory/tests/test_version.py @@ -1,10 +1,40 @@ +import json +import re import tomllib +from importlib.metadata import version from pathlib import Path from upstash_hermes_memory import VERSION +PACKAGE_DIR = Path(__file__).parents[1] + def test_version_matches_pyproject(): # The telemetry constant and the published version must agree; sync-version.mjs keeps them so. - pyproject = tomllib.loads((Path(__file__).parents[1] / "pyproject.toml").read_text()) + pyproject = tomllib.loads((PACKAGE_DIR / "pyproject.toml").read_text()) assert VERSION == pyproject["project"]["version"] + + +def test_version_matches_package_json(): + # Changesets bumps the private package.json; that is the source the other two are copied from. + package_json = json.loads((PACKAGE_DIR / "package.json").read_text()) + assert VERSION == package_json["version"] + + +def test_package_json_names_the_distribution(): + # sync-version.mjs derives the module path from this name, and publish-python.mjs queries PyPI + # with it, so it has to be the PyPI project name exactly. + package_json = json.loads((PACKAGE_DIR / "package.json").read_text()) + pyproject = tomllib.loads((PACKAGE_DIR / "pyproject.toml").read_text()) + assert package_json["name"] == pyproject["project"]["name"] + assert package_json["private"] is True + + +def test_version_is_plain_semver(): + # Changesets writes semver; anything else would not round-trip through PEP 440 unchanged. + assert re.fullmatch(r"\d+\.\d+\.\d+(-[0-9A-Za-z.]+)?", VERSION) + + +def test_installed_metadata_matches(): + # Catches a stale `uv sync`: the environment must carry the version the source declares. + assert version("upstash-hermes-memory") == VERSION From a12c48d9adb22d2e31e29c6cd67f97f780191be2 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 22 Oct 2026 16:43:57 +0300 Subject: [PATCH 4/5] fix: finish a half-done Python release on rerun; load .env in Python tests (DX-3182) publish-python.mjs checked PyPI only, so a run that uploaded but failed to push the tag or create the GitHub release was skipped forever after. Upload, tag and release are now reconciled one by one; an existing tag is never moved. conftest.py loads the repo-root .env like the TS test-support does, without overriding variables already set (CI's secrets). --- python/hermes-memory/tests/conftest.py | 26 +++++++++ scripts/publish-python.mjs | 73 ++++++++++++++++++-------- 2 files changed, 77 insertions(+), 22 deletions(-) diff --git a/python/hermes-memory/tests/conftest.py b/python/hermes-memory/tests/conftest.py index f5021ce..a8edd97 100644 --- a/python/hermes-memory/tests/conftest.py +++ b/python/hermes-memory/tests/conftest.py @@ -1,7 +1,33 @@ import os +from pathlib import Path import pytest +REPO_ROOT = Path(__file__).parents[3] + + +def _load_root_env() -> None: + """Load the repo-root `.env` like the TS tests do (dotenv in test-support.ts). + + Variables already in the environment win, so CI's secrets are never overridden. Without the + file and without the variables, the Redis tests skip themselves as before. + """ + path = REPO_ROOT / ".env" + if not path.is_file(): + return + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + key, value = key.strip().removeprefix("export ").strip(), value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + os.environ.setdefault(key, value) + + +_load_root_env() + @pytest.fixture def redis(): diff --git a/scripts/publish-python.mjs b/scripts/publish-python.mjs index 7a842a3..ac67517 100644 --- a/scripts/publish-python.mjs +++ b/scripts/publish-python.mjs @@ -8,8 +8,9 @@ // `0.0.0` means "not released yet" (a scaffold), and is skipped, so a package can live in the repo // before its first changeset without anything reaching PyPI. // -// After a successful upload the script tags `@` and opens a GitHub release with that -// version's CHANGELOG section, matching what changesets does for the npm packages. +// It then tags `@` and opens a GitHub release with that version's CHANGELOG section, +// matching what changesets does for the npm packages. Upload, tag and release are each checked on +// their own, so a run that stopped after the upload is completed by the next one. // // node scripts/publish-python.mjs # publish (CI: needs `id-token: write` + GH_TOKEN) // node scripts/publish-python.mjs --dry-run # print what would be published, change nothing @@ -21,6 +22,15 @@ import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const dryRun = process.argv.includes("--dry-run"); const run = (cmd, args) => execFileSync(cmd, args, { cwd: root, stdio: "inherit" }); +/** Whether a read-only probe command exits 0 (its output is discarded). */ +const succeeds = (cmd, args) => { + try { + execFileSync(cmd, args, { cwd: root, stdio: "ignore" }); + return true; + } catch { + return false; + } +}; /** Whether PyPI already serves `name==version`. Anything but 200/404 is an error, not a "no". */ const onPyPI = async (name, version) => { @@ -51,30 +61,49 @@ for (const dir of dirs) { console.log(`${name}: 0.0.0 (unreleased), skipping`); continue; } - if (await onPyPI(name, version)) { - console.log(`${name}@${version}: already on PyPI, skipping`); - continue; - } + // Upload, tag and release are reconciled separately, so a run that died after the upload (a + // failed tag push or `gh release`) is finished by the next run instead of being skipped. + const tag = `${name}@${version}`; + const uploaded = await onPyPI(name, version); if (dryRun) { - console.log(`${name}@${version}: would build, publish and tag`); + console.log( + uploaded + ? `${tag}: already on PyPI; would create the tag and the GitHub release if missing` + : `${tag}: would build, publish and tag`, + ); continue; } - const outDir = join("dist", "python", name); - run("uv", ["build", "--package", name, "--out-dir", outDir]); - run("uv", ["publish", "--trusted-publishing", "always", join(outDir, "*")]); + if (uploaded) { + console.log(`${tag}: already on PyPI, skipping the upload`); + } else { + const outDir = join("dist", "python", name); + run("uv", ["build", "--package", name, "--out-dir", outDir]); + run("uv", ["publish", "--trusted-publishing", "always", join(outDir, "*")]); + } - const tag = `${name}@${version}`; - run("git", ["tag", tag]); - run("git", ["push", "origin", tag]); - run("gh", [ - "release", - "create", - tag, - "--title", - tag, - "--notes", - releaseNotes(pkgDir, version) || tag, - ]); + // An existing tag is never moved: it already points at the commit that was published. + if (succeeds("git", ["ls-remote", "--exit-code", "--tags", "origin", `refs/tags/${tag}`])) { + console.log(`${tag}: tag exists`); + } else { + if (!succeeds("git", ["rev-parse", "--quiet", "--verify", `refs/tags/${tag}`])) { + run("git", ["tag", tag]); + } + run("git", ["push", "origin", `refs/tags/${tag}`]); + } + + if (succeeds("gh", ["release", "view", tag])) { + console.log(`${tag}: GitHub release exists`); + } else { + run("gh", [ + "release", + "create", + tag, + "--title", + tag, + "--notes", + releaseNotes(pkgDir, version) || tag, + ]); + } console.log(`${tag}: published`); } From 1127ccca9ca304817d11d87e3a9ba3b3aa7296f0 Mon Sep 17 00:00:00 2001 From: Arda Oz Date: Thu, 22 Oct 2026 17:31:46 +0300 Subject: [PATCH 5/5] fix: upload the files a partial PyPI release is missing (DX-3182) A version counts as published only when PyPI has both its wheel and its sdist. Otherwise the release is built and only the files PyPI does not list are uploaded, with --check-url as a second guard against a lagging JSON API. Before, a run that uploaded one file and then failed made every retry skip the upload. --- scripts/publish-python.mjs | 52 ++++++++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/scripts/publish-python.mjs b/scripts/publish-python.mjs index ac67517..39618ab 100644 --- a/scripts/publish-python.mjs +++ b/scripts/publish-python.mjs @@ -9,8 +9,8 @@ // before its first changeset without anything reaching PyPI. // // It then tags `@` and opens a GitHub release with that version's CHANGELOG section, -// matching what changesets does for the npm packages. Upload, tag and release are each checked on -// their own, so a run that stopped after the upload is completed by the next one. +// matching what changesets does for the npm packages. The uploaded files, the tag and the release +// are each checked on their own, so a run that stopped part way is completed by the next one. // // node scripts/publish-python.mjs # publish (CI: needs `id-token: write` + GH_TOKEN) // node scripts/publish-python.mjs --dry-run # print what would be published, change nothing @@ -32,14 +32,23 @@ const succeeds = (cmd, args) => { } }; -/** Whether PyPI already serves `name==version`. Anything but 200/404 is an error, not a "no". */ -const onPyPI = async (name, version) => { +/** + * The filenames PyPI serves for `name==version`, `[]` if the version does not exist. Anything but + * 200/404 is an error, not a "no". + */ +const filesOnPyPI = async (name, version) => { const res = await fetch(`https://pypi.org/pypi/${name}/${version}/json`); - if (res.status === 200) return true; - if (res.status === 404) return false; - throw new Error(`PyPI lookup for ${name}==${version} failed: HTTP ${res.status}`); + if (res.status === 404) return []; + if (res.status !== 200) { + throw new Error(`PyPI lookup for ${name}==${version} failed: HTTP ${res.status}`); + } + return (await res.json()).urls.map((file) => file.filename); }; +/** A pure-Python release is a wheel and a source distribution; with both on PyPI it is complete. */ +const isComplete = (files) => + files.some((f) => f.endsWith(".whl")) && files.some((f) => f.endsWith(".tar.gz")); + /** The `## ` section of a changesets-written CHANGELOG.md, without its heading. */ const releaseNotes = (pkgDir, version) => { const path = join(root, pkgDir, "CHANGELOG.md"); @@ -61,25 +70,40 @@ for (const dir of dirs) { console.log(`${name}: 0.0.0 (unreleased), skipping`); continue; } - // Upload, tag and release are reconciled separately, so a run that died after the upload (a - // failed tag push or `gh release`) is finished by the next run instead of being skipped. + // Files, tag and release are reconciled separately, so a run that died part way (one of the two + // files uploaded, a failed tag push or `gh release`) is finished by the next run. const tag = `${name}@${version}`; - const uploaded = await onPyPI(name, version); + const present = await filesOnPyPI(name, version); if (dryRun) { console.log( - uploaded + isComplete(present) ? `${tag}: already on PyPI; would create the tag and the GitHub release if missing` - : `${tag}: would build, publish and tag`, + : present.length + ? `${tag}: on PyPI without all its files (${present.join(", ")}); would upload the rest and tag` + : `${tag}: would build, publish and tag`, ); continue; } - if (uploaded) { + if (isComplete(present)) { console.log(`${tag}: already on PyPI, skipping the upload`); } else { const outDir = join("dist", "python", name); run("uv", ["build", "--package", name, "--out-dir", outDir]); - run("uv", ["publish", "--trusted-publishing", "always", join(outDir, "*")]); + const missing = readdirSync(join(root, outDir)).filter( + (f) => (f.endsWith(".whl") || f.endsWith(".tar.gz")) && !present.includes(f), + ); + if (missing.length) { + // --check-url also skips a file PyPI has but its JSON API did not list yet. + run("uv", [ + "publish", + "--trusted-publishing", + "always", + "--check-url", + "https://pypi.org/simple/", + ...missing.map((f) => join(outDir, f)), + ]); + } } // An existing tag is never moved: it already points at the commit that was published.