diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yaml similarity index 100% rename from .github/workflows/ci.yml rename to .github/workflows/ci.yaml diff --git a/.github/workflows/dependabot-automerge.yml b/.github/workflows/dependabot-automerge.yaml similarity index 100% rename from .github/workflows/dependabot-automerge.yml rename to .github/workflows/dependabot-automerge.yaml diff --git a/.github/workflows/python.yaml b/.github/workflows/python.yaml new file mode 100644 index 0000000..9e311e1 --- /dev/null +++ b/.github/workflows/python.yaml @@ -0,0 +1,121 @@ +name: Python + +on: + push: + tags: ["v*"] + pull_request: + paths: + - "src/python.rs" + - "python/**" + - "pyproject.toml" + - "murk.pyi" + - "Cargo.toml" + +env: + PYO3_USE_ABI3_FORWARD_COMPATIBILITY: "1" + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3 + with: + src: python/ + + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + - uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2 + + - name: Build release binary (for test fixture) + run: cargo build --release + + - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6 + + - name: Install and test + run: | + uv venv + source .venv/bin/activate + uv pip install maturin pytest + maturin develop --features python + pytest python/tests -v + + wheels: + name: Build wheels (${{ matrix.os }}) + if: startsWith(github.ref, 'refs/tags/v') + needs: [lint, test] + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64 + - os: ubuntu-latest + target: aarch64 + - os: macos-14 + target: x86_64 + - os: macos-latest + target: aarch64 + - os: windows-latest + target: x64 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + + - name: Build wheels + uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1 + with: + target: ${{ matrix.target }} + args: --release --out dist --features python + manylinux: auto + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: wheels-${{ matrix.os }}-${{ matrix.target }} + path: dist/ + + sdist: + name: Build sdist + if: startsWith(github.ref, 'refs/tags/v') + needs: [lint, test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Build sdist + uses: PyO3/maturin-action@04ac600d27cdf7a9a280dadf7147097c42b757ad # v1 + with: + command: sdist + args: --out dist + + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: wheels-sdist + path: dist/ + + publish: + name: Publish to PyPI + if: startsWith(github.ref, 'refs/tags/v') + needs: [wheels, sdist] + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: wheels-* + merge-multiple: true + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # release/v1 + with: + skip-existing: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yaml similarity index 100% rename from .github/workflows/release.yml rename to .github/workflows/release.yaml diff --git a/.gitignore b/.gitignore index 1ff3361..60fdc3c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ AGENTS.md CLAUDE.md .DS_Store +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/Cargo.lock b/Cargo.lock index 30c66fd..5a53e9f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1156,6 +1156,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "inout" version = "0.1.4" @@ -1267,6 +1276,15 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1301,6 +1319,7 @@ dependencies = [ "constant_time_eq", "fs2", "predicates", + "pyo3", "rand 0.10.0", "rpassword", "serde", @@ -1499,6 +1518,12 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1579,6 +1604,69 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pyo3" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5203598f366b11a02b13aa20cab591229ff0a89fd121a308a5df751d5fc9219" +dependencies = [ + "cfg-if", + "indoc", + "libc", + "memoffset", + "once_cell", + "portable-atomic", + "pyo3-build-config", + "pyo3-ffi", + "pyo3-macros", + "unindent", +] + +[[package]] +name = "pyo3-build-config" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99636d423fa2ca130fa5acde3059308006d46f98caac629418e53f7ebb1e9999" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f9cf92ba9c409279bc3305b5409d90db2d2c22392d443a87df3a1adad59e33" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b999cb1a6ce21f9a6b147dcf1be9ffedf02e0043aec74dc390f3007047cecd9" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "822ece1c7e1012745607d5cf0bcb2874769f0f7cb34c4cde03b9358eb9ef911a" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn", +] + [[package]] name = "quote" version = "1.0.45" @@ -2035,6 +2123,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -2164,6 +2258,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unindent" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" + [[package]] name = "universal-hash" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 06e37fd..6578618 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,11 @@ constant_time_eq = "0.4" tempfile = "3.27.0" clap_complete = "4.6.0" fs2 = "0.4.3" +pyo3 = { version = "0.24", features = ["extension-module"], optional = true } + +[features] +default = [] +python = ["pyo3"] [dev-dependencies] assert_cmd = "2" diff --git a/README.md b/README.md index 4ec3671..1788fb5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # murk -[![CI](https://github.com/iicky/murk/actions/workflows/ci.yml/badge.svg)](https://github.com/iicky/murk/actions/workflows/ci.yml) +[![CI](https://github.com/iicky/murk/actions/workflows/ci.yaml/badge.svg)](https://github.com/iicky/murk/actions/workflows/ci.yaml) [![codecov](https://codecov.io/gh/iicky/murk/graph/badge.svg)](https://codecov.io/gh/iicky/murk) [![Crates.io](https://img.shields.io/crates/v/murk-cli)](https://crates.io/crates/murk-cli) [![License](https://img.shields.io/crates/l/murk-cli)](LICENSE-MIT) diff --git a/murk.pyi b/murk.pyi new file mode 100644 index 0000000..9d0a49b --- /dev/null +++ b/murk.pyi @@ -0,0 +1,15 @@ +"""Type stubs for the murk Python module.""" + +class Vault: + def get(self, key: str) -> str | None: ... + def export(self) -> dict[str, str]: ... + def keys(self) -> list[str]: ... + def __len__(self) -> int: ... + def __getitem__(self, key: str) -> str: ... + def __contains__(self, key: str) -> bool: ... + def __repr__(self) -> str: ... + +def load(vault_path: str = ".murk") -> Vault: ... +def get(key: str, vault_path: str = ".murk") -> str | None: ... +def export_all(vault_path: str = ".murk") -> dict[str, str]: ... +def has_key() -> bool: ... diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a1b5396 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,45 @@ +[build-system] +requires = ["maturin>=1.0,<2.0"] +build-backend = "maturin" + +[project] +name = "murk-secrets" +dynamic = ["version"] +description = "Python bindings for murk — encrypted secrets manager" +readme = "python/README.md" +license = { text = "MIT OR Apache-2.0" } +requires-python = ">=3.9" +classifiers = [ + "Programming Language :: Rust", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Security :: Cryptography", + "License :: OSI Approved :: MIT License", + "License :: OSI Approved :: Apache Software License", +] +keywords = ["secrets", "encryption", "age", "dotenv", "security"] + +[project.urls] +Homepage = "https://github.com/iicky/murk" +Repository = "https://github.com/iicky/murk" +Issues = "https://github.com/iicky/murk/issues" + +[tool.maturin] +features = ["python"] +module-name = "murk" +bindings = "pyo3" + +[tool.pytest.ini_options] +testpaths = ["python/tests"] + +[tool.ruff] +target-version = "py39" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000..382302d --- /dev/null +++ b/python/README.md @@ -0,0 +1,78 @@ +# murk-secrets + +Python bindings for [murk](https://github.com/iicky/murk) — an encrypted secrets manager for developers. + +murk stores encrypted secrets in a single `.murk` file safe to commit to git. This package lets you read those secrets from Python. + +## Install + +```bash +pip install murk-secrets +``` + +## Quick start + +```python +import murk + +# Load the vault (reads MURK_KEY from environment) +vault = murk.load() + +# Get a single secret +db_url = vault.get("DATABASE_URL") + +# Get all secrets as a dict +secrets = vault.export() + +# Dict-style access +api_key = vault["API_KEY"] +``` + +## API + +### `murk.load(vault_path=".murk") -> Vault` + +Load and decrypt a murk vault. Reads `MURK_KEY` or `MURK_KEY_FILE` from the environment. + +### `murk.get(key, vault_path=".murk") -> str | None` + +One-liner: load the vault and get a single value. + +### `murk.export_all(vault_path=".murk") -> dict[str, str]` + +One-liner: load the vault and export all secrets as a dict. + +### `murk.has_key() -> bool` + +Check if a `MURK_KEY` is available in the environment. + +### `Vault` + +| Method | Returns | Description | +|--------|---------|-------------| +| `vault.get(key)` | `str \| None` | Get a single decrypted value | +| `vault.export()` | `dict[str, str]` | All secrets as a dict | +| `vault.keys()` | `list[str]` | List of key names | +| `vault[key]` | `str` | Dict-style access (raises on missing key) | +| `key in vault` | `bool` | Check if a key exists | +| `len(vault)` | `int` | Number of secrets | + +Scoped (per-user) overrides are applied automatically — if you have a scoped value for a key, it takes priority over the shared value. + +## Environment + +Set one of: +- `MURK_KEY` — your age secret key directly +- `MURK_KEY_FILE` — path to your key file (created by `murk init`) + +The easiest setup is `source .env` in your project directory after running `murk init`. + +## Requirements + +- Python >= 3.9 +- A `.murk` vault file (create one with the [murk CLI](https://github.com/iicky/murk)) +- `MURK_KEY` or `MURK_KEY_FILE` in the environment + +## License + +MIT OR Apache-2.0 diff --git a/python/tests/conftest.py b/python/tests/conftest.py new file mode 100644 index 0000000..707f7e4 --- /dev/null +++ b/python/tests/conftest.py @@ -0,0 +1,67 @@ +"""Shared fixtures for murk Python SDK tests.""" + +import os +import subprocess +import tempfile +from pathlib import Path + +import pytest + + +@pytest.fixture() +def vault_dir(): + """Create a temp directory with an initialized murk vault and secrets.""" + murk_bin = Path(__file__).resolve().parents[2] / "target" / "release" / "murk" + if not murk_bin.exists(): + pytest.skip("murk binary not found — run cargo build --release first") + + with tempfile.TemporaryDirectory() as tmpdir: + env = {**os.environ, "PATH": f"{murk_bin.parent}:{os.environ['PATH']}"} + + # Remove any existing MURK_KEY/MURK_KEY_FILE to avoid interference. + env.pop("MURK_KEY", None) + env.pop("MURK_KEY_FILE", None) + + # Init vault. + subprocess.run( + [str(murk_bin), "init", "--vault", ".murk"], + input="testuser\n", + capture_output=True, + text=True, + cwd=tmpdir, + env=env, + check=True, + ) + + # Read the key from .env. + dot_env = Path(tmpdir) / ".env" + for line in dot_env.read_text().splitlines(): + if line.startswith("export MURK_KEY_FILE="): + key_file = line.split("=", 1)[1].strip() + murk_key = Path(key_file).read_text().strip() + break + elif line.startswith("export MURK_KEY="): + murk_key = line.split("=", 1)[1].strip() + break + else: + pytest.fail("Could not find MURK_KEY in .env") + + env["MURK_KEY"] = murk_key + + # Add secrets. + for key, value in [ + ("DATABASE_URL", "postgres://localhost/mydb"), + ("API_KEY", "sk-test-123"), + ("STRIPE_SECRET", "sk_live_abc"), + ]: + subprocess.run( + [str(murk_bin), "add", key, "--vault", ".murk"], + input=f"{value}\n", + capture_output=True, + text=True, + cwd=tmpdir, + env=env, + check=True, + ) + + yield {"path": tmpdir, "key": murk_key} diff --git a/python/tests/test_murk.py b/python/tests/test_murk.py new file mode 100644 index 0000000..b8f14dd --- /dev/null +++ b/python/tests/test_murk.py @@ -0,0 +1,159 @@ +"""Tests for the murk Python SDK.""" + +import os + +import murk + + +class TestLoad: + def test_load_vault(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + assert vault is not None + + def test_load_with_explicit_path(self, vault_dir): + os.environ["MURK_KEY"] = vault_dir["key"] + vault_path = os.path.join(vault_dir["path"], ".murk") + + vault = murk.load(vault_path) + assert vault is not None + + def test_load_missing_vault_raises(self, vault_dir): + os.environ["MURK_KEY"] = vault_dir["key"] + + try: + murk.load("/nonexistent/.murk") + assert False, "Expected RuntimeError" + except RuntimeError: + pass + + def test_load_missing_key_raises(self, tmp_path): + # Use a clean dir with no .murk or .env to avoid key auto-discovery. + os.chdir(tmp_path) + os.environ.pop("MURK_KEY", None) + os.environ.pop("MURK_KEY_FILE", None) + + try: + murk.load() + assert False, "Expected RuntimeError" + except RuntimeError: + pass + + +class TestGet: + def test_get_existing_key(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + assert vault.get("DATABASE_URL") == "postgres://localhost/mydb" + assert vault.get("API_KEY") == "sk-test-123" + + def test_get_missing_key_returns_none(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + assert vault.get("NONEXISTENT") is None + + def test_get_oneliner(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + assert murk.get("DATABASE_URL") == "postgres://localhost/mydb" + + def test_get_oneliner_missing_returns_none(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + assert murk.get("NONEXISTENT") is None + + +class TestExport: + def test_export_returns_all_secrets(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + secrets = vault.export() + + assert isinstance(secrets, dict) + assert secrets["DATABASE_URL"] == "postgres://localhost/mydb" + assert secrets["API_KEY"] == "sk-test-123" + assert secrets["STRIPE_SECRET"] == "sk_live_abc" + assert len(secrets) == 3 + + def test_export_all_oneliner(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + secrets = murk.export_all() + assert len(secrets) == 3 + assert secrets["API_KEY"] == "sk-test-123" + + +class TestVaultMethods: + def test_keys(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + keys = vault.keys() + assert sorted(keys) == ["API_KEY", "DATABASE_URL", "STRIPE_SECRET"] + + def test_len(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + assert len(vault) == 3 + + def test_contains(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + assert "DATABASE_URL" in vault + assert "NONEXISTENT" not in vault + + def test_getitem(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + assert vault["API_KEY"] == "sk-test-123" + + def test_getitem_missing_raises(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + try: + _ = vault["NONEXISTENT"] + assert False, "Expected RuntimeError" + except RuntimeError: + pass + + def test_repr(self, vault_dir): + os.chdir(vault_dir["path"]) + os.environ["MURK_KEY"] = vault_dir["key"] + + vault = murk.load() + r = repr(vault) + assert "3 secrets" in r + assert "1 recipients" in r + + +class TestHasKey: + def test_has_key_true(self, vault_dir): + os.environ["MURK_KEY"] = vault_dir["key"] + assert murk.has_key() is True + + def test_has_key_false(self, tmp_path): + os.environ.pop("MURK_KEY", None) + os.environ.pop("MURK_KEY_FILE", None) + # Use a clean dir with no .murk or .env to avoid auto-discovery. + os.chdir(tmp_path) + assert murk.has_key() is False diff --git a/src/lib.rs b/src/lib.rs index 591bb66..16b95ea 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,6 +33,9 @@ pub(crate) mod secrets; pub mod types; pub mod vault; +#[cfg(feature = "python")] +mod python; + // Shared test utilities #[cfg(test)] pub mod testutil; diff --git a/src/python.rs b/src/python.rs new file mode 100644 index 0000000..0f50eff --- /dev/null +++ b/src/python.rs @@ -0,0 +1,126 @@ +//! Python bindings for murk via PyO3. +//! +//! ```python +//! import murk +//! +//! vault = murk.load() # reads MURK_KEY from env, .murk from cwd +//! vault.get("DATABASE_URL") # decrypt a single value +//! vault.export() # dict of all key/values +//! murk.get("DATABASE_URL") # one-liner convenience +//! ``` + +use std::collections::HashMap; + +use pyo3::exceptions::PyRuntimeError; +use pyo3::prelude::*; + +use crate::{env, export, types}; + +/// A loaded and decrypted murk vault. +#[pyclass] +struct Vault { + vault: types::Vault, + murk: types::Murk, + pubkey: String, +} + +#[pymethods] +impl Vault { + /// Get a single decrypted secret value. + /// Returns the scoped override if one exists, otherwise the shared value. + fn get(&self, key: &str) -> PyResult> { + // Check scoped first. + if let Some(scoped_map) = self.murk.scoped.get(key) { + if let Some(value) = scoped_map.get(&self.pubkey) { + return Ok(Some(value.clone())); + } + } + Ok(self.murk.values.get(key).cloned()) + } + + /// Export all secrets as a dict. Scoped values override shared values. + fn export(&self) -> PyResult> { + Ok( + export::resolve_secrets(&self.vault, &self.murk, &self.pubkey, &[]) + .into_iter() + .collect(), + ) + } + + /// List all key names. + fn keys(&self) -> PyResult> { + Ok(self.vault.schema.keys().cloned().collect()) + } + + /// Number of secrets in the vault. + fn __len__(&self) -> usize { + self.vault.schema.len() + } + + /// Get a value by key (dict-style access). + fn __getitem__(&self, key: &str) -> PyResult { + self.get(key)? + .ok_or_else(|| PyRuntimeError::new_err(format!("key not found: {key}"))) + } + + /// Check if a key exists. + fn __contains__(&self, key: &str) -> bool { + self.vault.schema.contains_key(key) + } + + fn __repr__(&self) -> String { + format!( + "Vault({} secrets, {} recipients)", + self.vault.schema.len(), + self.vault.recipients.len() + ) + } +} + +/// Load a murk vault. Reads MURK_KEY from the environment. +#[pyfunction] +#[pyo3(signature = (vault_path=".murk"))] +fn load(vault_path: &str) -> PyResult { + let (vault, murk, identity) = + crate::load_vault(vault_path).map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + let pubkey = identity + .pubkey_string() + .map_err(|e| PyRuntimeError::new_err(e.to_string()))?; + Ok(Vault { + vault, + murk, + pubkey, + }) +} + +/// One-liner: load the vault and get a single key. +#[pyfunction] +#[pyo3(signature = (key, vault_path=".murk"))] +fn get(key: &str, vault_path: &str) -> PyResult> { + load(vault_path)?.get(key) +} + +/// One-liner: load the vault and export all secrets as a dict. +#[pyfunction] +#[pyo3(signature = (vault_path=".murk"))] +fn export_all(vault_path: &str) -> PyResult> { + load(vault_path)?.export() +} + +/// Resolve the MURK_KEY from the environment without loading a vault. +/// Returns true if a key is available. +#[pyfunction] +fn has_key() -> bool { + env::resolve_key().is_ok() +} + +/// Python module definition. +#[pymodule] +fn murk(m: &Bound<'_, PyModule>) -> PyResult<()> { + m.add_class::()?; + m.add_function(wrap_pyfunction!(load, m)?)?; + m.add_function(wrap_pyfunction!(get, m)?)?; + m.add_function(wrap_pyfunction!(export_all, m)?)?; + m.add_function(wrap_pyfunction!(has_key, m)?)?; + Ok(()) +}