diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b810f4f..ba96026 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,57 +3,74 @@ name: ci on: push: branches: [main] - tags: ['*'] + tags: ["v*"] pull_request: branches: [main] +permissions: + contents: read + jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: - fail-fast: true + fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10", "3.11"] + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.8", "3.13"] steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - run: | - python -m pip install --upgrade pip - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + - uses: dtolnay/rust-toolchain@stable + - run: python -m pip install maturin pytest + - run: maturin build --locked --out dist + - run: python -m pip install --no-index --find-links dist python-dotenvx + - run: python -m pytest - # run only on semantic version tag - release: + wheels: + if: startsWith(github.ref, 'refs/tags/v') needs: test - if: startsWith(github.ref, 'refs/tags/v') && contains(github.ref, '.') - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: - fail-fast: true + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64 + manylinux: "2014" + - os: ubuntu-latest + target: aarch64 + manylinux: "2014" + - os: macos-13 + target: x86_64 + - os: macos-14 + target: aarch64 + - os: windows-latest + target: x64 steps: - uses: actions/checkout@v4 - # GITHUB_ENV operates like a .env file - - name: extract version - run: | - VERSION=${GITHUB_REF#refs/tags/} - CLEAN_VERSION=${VERSION#v} - echo "VERSION=$VERSION" >> $GITHUB_ENV - echo "CLEAN_VERSION=$CLEAN_VERSION" >> $GITHUB_ENV + - uses: PyO3/maturin-action@v1 + with: + target: ${{ matrix.target }} + manylinux: ${{ matrix.manylinux }} + args: --release --out dist + - uses: actions/upload-artifact@v4 + with: + name: wheels-${{ matrix.os }}-${{ matrix.target }} + path: dist - pypi: - needs: release + publish: + if: startsWith(github.ref, 'refs/tags/v') + needs: wheels runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/download-artifact@v4 + with: + pattern: wheels-* + path: dist + merge-multiple: true + - uses: pypa/gh-action-pypi-publish@release/v1 with: - python-version: '3.11' - - run: | - python -m pip install --upgrade pip - if [ -f requirements.txt ]; then pip install -r requirements.txt; fi - pip install setuptools wheel twine build - - name: publish - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - run: make release + password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore index bcfd31e..eeb0a3e 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ __pycache__/ # C extensions *.so +*.pyd +*.dylib # Distribution / packaging .Python @@ -73,6 +75,7 @@ docs/_build/ # PyBuilder target/ +.tmp-publish/ # Jupyter Notebook .ipynb_checkpoints diff --git a/CHANGELOG.md b/CHANGELOG.md index b7c2db6..c71f079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,34 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. -## [Unreleased](https://github.com/dotenvx/python-dotenvx/compare/v0.3.0...main) +## [Unreleased](https://github.com/dotenvx/python-dotenvx/compare/v2.0.0...main) + +## [2.0.0](https://github.com/dotenvx/python-dotenvx/compare/v0.3.0...v2.0.0) + +### Added + +- Add `load_dotenv`, `dotenv_values`, and `find_dotenv` with the same public + arguments and defaults as `python-dotenv`. +- Add native dotenv parsing, expansion, command substitution, and decryption + through `dotenvx-primitives` 2.1.1. +- Add automatic decryption using `DOTENV_PRIVATE_KEY` environment variables + or a neighboring `.env.keys` file. +- Add conformance tests against `python-dotenv` 1.2.2, plus dotenvx encrypted + loading behavior. + +### Changed + +- Package the Rust implementation as a PyO3 native extension inside each + Python wheel. +- Build and publish Linux, macOS, and Windows wheels using Maturin. +- Use Python ABI3 wheels compatible with CPython 3.8 and newer. +- Keep `load_dotenvx` as a backward-compatible API returning parsed values. + +### Removed + +- Remove the `dotenvx-postinstall` command and runtime dotenvx CLI download. +- Remove the external dotenvx executable dependency. +- Remove the legacy setuptools build configuration. ## [0.3.0](https://github.com/dotenvx/dotenvx/compare/v0.2.6...v0.3.0) diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c739d79 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,985 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-mac" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b584a330336237c1eecd3e94266efb216c56ed91225d634cb2991c5f3fd1aeab" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common", + "subtle", +] + +[[package]] +name = "dotenvx-primitives" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28705cdc0e4172eb2acb38d45d2544b067e59b85e88fb9a9a5e4236fe070f069" +dependencies = [ + "base64", + "ecies", + "hex", + "libsecp256k1", + "rand", + "regex", + "zeroize", +] + +[[package]] +name = "ecies" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a2df85e2b13a5f7fa61e7f0ee776acfe74da80bf2fb1bcde2725af19c197b33" +dependencies = [ + "aes-gcm", + "getrandom", + "hkdf", + "k256", + "lock_api", + "once_cell", + "parking_lot", + "rand_core", + "sha2 0.10.9", + "typenum", + "wasm-bindgen", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core", + "subtle", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core", + "subtle", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "126888268dcc288495a26bf004b38c5fdbb31682f992c84ceb046a1f0fe38840" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac-drbg" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17ea0a1394df5b6574da6e0c1ade9e78868c9fb0a4e5ef4428e32da4676b85b1" +dependencies = [ + "digest 0.9.0", + "generic-array", + "hmac 0.8.1", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "elliptic-curve", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsecp256k1" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79019718125edc905a079a70cfa5f3820bc76139fc91d6f9abc27ea2a887139" +dependencies = [ + "arrayref", + "base64", + "digest 0.9.0", + "hmac-drbg", + "libsecp256k1-core", + "libsecp256k1-gen-ecmult", + "libsecp256k1-gen-genmult", + "rand", + "serde", + "sha2 0.9.9", + "typenum", +] + +[[package]] +name = "libsecp256k1-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be9b9bb642d8522a44d533eab56c16c738301965504753b03ad1de3425d5451" +dependencies = [ + "crunchy", + "digest 0.9.0", + "subtle", +] + +[[package]] +name = "libsecp256k1-gen-ecmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3038c808c55c87e8a172643a7d87187fc6c4174468159cb3090659d55bcb4809" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "libsecp256k1-gen-genmult" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db8d6ba2cec9eacc40e6e8ccc98931840301f1006e95647ceb2dd5c3aa06f7c" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pyo3" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f402062616ab18202ae8319da13fa4279883a2b8a9d9f83f20dbade813ce1884" +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.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b14b5775b5ff446dd1056212d778012cbe8a0fbffd368029fd9e25b514479c38" +dependencies = [ + "once_cell", + "target-lexicon", +] + +[[package]] +name = "pyo3-ffi" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab5bcf04a2cdcbb50c7d6105de943f543f9ed92af55818fd17b660390fc8636" +dependencies = [ + "libc", + "pyo3-build-config", +] + +[[package]] +name = "pyo3-macros" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fd24d897903a9e6d80b968368a34e1525aeb719d568dba8b3d4bfa5dc67d453" +dependencies = [ + "proc-macro2", + "pyo3-macros-backend", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pyo3-macros-backend" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c011a03ba1e50152b4b394b479826cad97e7a21eb52df179cd91ac411cbfbe" +dependencies = [ + "heck", + "proc-macro2", + "pyo3-build-config", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "python-dotenvx-native" +version = "2.0.0" +dependencies = [ + "dotenvx-primitives", + "pyo3", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.7", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..8f5da21 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "python-dotenvx-native" +version = "2.0.0" +edition = "2021" +publish = false + +[lib] +name = "_native" +crate-type = ["cdylib"] +path = "rust/lib.rs" + +[dependencies] +dotenvx-primitives = "=2.1.1" +pyo3 = { version = "0.22", features = ["abi3-py38"] } diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b4169e7..a2eb0a5 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,10 +1,57 @@ -# DEVELOPMENT +# Development + +`python-dotenvx` is a mixed Python/Rust project. Maturin compiles the PyO3 +extension in `rust/lib.rs` and places it inside the `dotenvx` Python package. +The extension uses the published `dotenvx-primitives` crate. + +## Local setup + +```sh +python3 -m venv .venv +.venv/bin/python -m pip install maturin pytest +.venv/bin/maturin develop +.venv/bin/pytest +``` + +Build a wheel for the current platform: + +```sh +.venv/bin/maturin build --release +``` + +The resulting wheel is written to `target/wheels`. + +## Version changes + +Keep these versions synchronized: + +- `pyproject.toml`: Python distribution version +- `Cargo.toml`: native extension version +- `src/dotenvx/__version__.py`: Python runtime version + +Unlike npm, Python packaging has no built-in `npm version patch` equivalent. +Update all three files to the chosen semantic version before tagging. + +The `dotenvx-primitives` dependency version in `Cargo.toml` is independent. It +should identify the crate release that this Python package embeds. ## Publishing -1. Tag master branch `git tag v0.0.0` and `git push --tags` -2. Visit [https://github.com/dotenvx/python-dotenvx/tags](https://github.com/dotenv/python-dotenvx/tags) -3. Click '...' > 'Create Release' next to the new tag -4. Put changelog details in body and name release by the same tag `v0.0.0` -5. Click 'Publish release' -6. `.github/workflows/python-publish.yml` will take care of publishing the latest version +Git tags matching `v*` trigger wheel builds for Linux, macOS, and Windows and +publish the collected wheels to PyPI. Before tagging: + +```sh +.venv/bin/maturin develop +.venv/bin/pytest +.venv/bin/maturin build --release +``` + +Then: + +```sh +git tag v0.0.0 +git push origin v0.0.0 +``` + +The tag version must match `pyproject.toml`, `Cargo.toml`, and +`src/dotenvx/__version__.py`. diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index e9343b9..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,2 +0,0 @@ -include CHANGELOG.md -include src/dotenvx/bin/dotenvx diff --git a/README.md b/README.md index b114289..6d29b59 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,3 @@ -> [!IMPORTANT] -> -> Warning: work in progress. until complete, please use [github.com/dotenvx/dotenvx](https://github.com/dotenvx/dotenvx) directly. -> -> see [python examples](https://dotenvx.com/docs/languages/python) -> - ---- - [![dotenvx](https://dotenvx.com/better-banner.png)](https://dotenvx.com) *a better dotenv*–from the creator of [`dotenv`](https://github.com/motdotla/dotenv). @@ -26,27 +17,28 @@ Install and use it in code just like `python-dotenv`. pip install python-dotenvx ``` -Then run `dotenvx-postinstall` to install the `dotenvx` binary (python-dotenvx is a wrapper). +The wheel includes native dotenvx Rust primitives. No dotenvx executable or +post-install download is required. -```sh -dotenvx-postinstall +```python +# main.py +import os +from dotenvx import load_dotenv -# or to specify the os-arch – useful for building binaries to a specific target such as linux-x86_64 on aws lambda -dotenvx-postinstall --os linux --arch x86_64 +load_dotenv() # take environment variables from .env -# you might also find you need to specify PYTHONPATH depend on how/where dotenvx installs to -PYTHONPATH=. bin/dotenvx-postinstall --os linux --arch x86_64 +print(os.getenv("S3_BUCKET")) ``` -Then use it in code. +You can also parse values without changing the environment: ```python -# main.py -import os -from dotenvx import load_dotenvx -load_dotenvx() # take environment variables from .env. +from dotenvx import dotenv_values -print(os.getenv("S3_BUCKET")) +values = dotenv_values(".env") ``` +Encrypted values are decrypted using `DOTENV_PRIVATE_KEY` from the environment +or a neighboring `.env.keys` file. +   diff --git a/pyproject.toml b/pyproject.toml index 4aff98b..2c14d42 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,38 @@ [build-system] -requires = ["setuptools>=61.0.0", "wheel"] -build-backend = "setuptools.build_meta" +requires = ["maturin>=1.7,<2.0"] +build-backend = "maturin" + +[project] +name = "python-dotenvx" +version = "2.0.0" +description = "A better dotenv, backed by dotenvx Rust primitives" +readme = "README.md" +requires-python = ">=3.8" +license = { text = "BSD-3-Clause" } +authors = [ + { name = "dotenv", email = "mot@dotenvx.com" } +] +keywords = [ + "environment", + "environment variables", + "env", + "dotenv", + "python", + "dotenvx" +] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Rust", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent" +] + +[project.urls] +Homepage = "https://dotenvx.com" +Repository = "https://github.com/dotenvx/python-dotenvx" + +[tool.maturin] +python-source = "src" +module-name = "dotenvx._native" +features = ["pyo3/extension-module"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index e69de29..0000000 diff --git a/rust/lib.rs b/rust/lib.rs new file mode 100644 index 0000000..f5a52e3 --- /dev/null +++ b/rust/lib.rs @@ -0,0 +1,95 @@ +use dotenvx_primitives::{keyring, parse, KeyringOptions, ParseOptions, ParseResult, Value}; +use pyo3::exceptions::PyRuntimeError; +use pyo3::prelude::*; +use std::collections::HashMap; +use std::path::PathBuf; + +type StringMap = HashMap; +const INTERPOLATION_SENTINEL: &str = "\u{e000}"; + +fn scalar_values(values: HashMap, interpolate: bool) -> StringMap { + values + .into_iter() + .filter_map(|(key, value)| match value { + Value::Scalar(value) => Some(( + key, + if interpolate { + value + } else { + value.replace(INTERPOLATION_SENTINEL, "$") + }, + )), + Value::Array(_) => None, + }) + .collect() +} + +fn parse_result(result: ParseResult, interpolate: bool) -> PyResult<(StringMap, StringMap)> { + if !result.errors.is_empty() { + let message = result + .errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + return Err(PyRuntimeError::new_err(message)); + } + + Ok(( + scalar_values(result.parsed, interpolate), + scalar_values(result.injected, interpolate), + )) +} + +#[pyfunction] +#[pyo3(signature = ( + source, + process_env=None, + override_=false, + key_files=None, + interpolate=true +))] +fn parse_dotenv( + source: &str, + process_env: Option, + override_: bool, + key_files: Option>, + interpolate: bool, +) -> PyResult<(StringMap, StringMap)> { + let process_env = process_env.unwrap_or_default(); + let ring = keyring(&KeyringOptions { + process_env: process_env.clone(), + key_files: key_files + .unwrap_or_default() + .into_iter() + .map(PathBuf::from) + .collect(), + ..Default::default() + }) + .map_err(|error| PyRuntimeError::new_err(error.to_string()))?; + + let source = if interpolate { + source.to_owned() + } else { + source.replace('$', INTERPOLATION_SENTINEL) + }; + + parse_result( + parse( + &source, + &ParseOptions { + process_env, + overload: override_, + ring, + ..Default::default() + }, + ), + interpolate, + ) +} + +#[pymodule] +fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_function(wrap_pyfunction!(parse_dotenv, module)?)?; + Ok(()) +} diff --git a/setup.py b/setup.py deleted file mode 100644 index 260d07a..0000000 --- a/setup.py +++ /dev/null @@ -1,49 +0,0 @@ -import os -from setuptools import setup, find_packages - -src = {} -dir = os.path.abspath(os.path.dirname(__file__)) -with open(os.path.join(dir, "src/dotenvx", "__version__.py"), "r") as f: - exec(f.read(), src) - -def read_files(files): - data = [] - for file in files: - with open(file, encoding='utf-8') as f: - data.append(f.read()) - return "\n".join(data) - -readme = read_files(['README.md', 'CHANGELOG.md']) - -setup( - name='python-dotenvx', - description=src['__description__'], - long_description=readme, - long_description_content_type='text/markdown', - version=src['__version__'], - license=src['__license__'], - author=src['__author__'], - author_email=src['__author_email__'], - include_package_data=True, - packages=find_packages('src'), - package_dir={'': 'src'}, - url=src['__url__'], - keywords=[ - 'environment', - 'environment variables', - 'deployments', - 'settings', - 'env', - 'dotenv', - 'configurations', - 'python', - 'dotenvx' - ], - install_requires=[ - ], - entry_points={ - 'console_scripts': [ - 'dotenvx-postinstall=dotenvx.main:postinstall', - ], - } -) diff --git a/src/dotenvx/__init__.py b/src/dotenvx/__init__.py index 476558e..519cd6d 100644 --- a/src/dotenvx/__init__.py +++ b/src/dotenvx/__init__.py @@ -1 +1,3 @@ -from .main import load_dotenvx +from .main import dotenv_values, find_dotenv, load_dotenv, load_dotenvx + +__all__ = ["dotenv_values", "find_dotenv", "load_dotenv", "load_dotenvx"] diff --git a/src/dotenvx/__version__.py b/src/dotenvx/__version__.py index 0656fc0..3dd8cbb 100644 --- a/src/dotenvx/__version__.py +++ b/src/dotenvx/__version__.py @@ -1,7 +1,7 @@ __title__ = "python-dotenvx" __description__ = "[dotenvx.com] a better dotenv–from the creator of `dotenv`" __url__ = "https://github.com/dotenvx/python-dotenvx" -__version__ = "0.3.0" +__version__ = "2.0.0" __author__ = "dotenv" __author_email__ = "mot@dotenvx.com" __license__ = "BSD-3-Clause" diff --git a/src/dotenvx/bin/dotenvx b/src/dotenvx/bin/dotenvx deleted file mode 100755 index e390f35..0000000 --- a/src/dotenvx/bin/dotenvx +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -echo "[MISSING_BINARY] missing dotenvx binary. run: dotenvx-postinstall" -exit 1 diff --git a/src/dotenvx/main.py b/src/dotenvx/main.py index efbed54..171ebbc 100644 --- a/src/dotenvx/main.py +++ b/src/dotenvx/main.py @@ -1,92 +1,181 @@ import os -import json -import shutil -import subprocess -import argparse +import re +from pathlib import Path +from typing import Dict, List, Optional, TextIO, Tuple, Union -from dotenvx import __file__ as package_path +from ._native import parse_dotenv -ERROR_MISSING_BINARY = "[MISSING_BINARY] missing dotenvx binary\n[MISSING_BINARY] https://github.com/dotenvx/dotenvx/issues/576" +StrPath = Union[str, os.PathLike] +Values = Dict[str, Optional[str]] -def load_dotenvx( - dotenv_path=None, - override=False -): - output = dotenvx_get( - dotenv_path=dotenv_path, - override=override + +def find_dotenv( + filename: str = ".env", + raise_error_if_not_found: bool = False, + usecwd: bool = False +) -> str: + """Find ``filename`` by walking from the current directory toward root.""" + del usecwd # The package intentionally uses cwd for predictable discovery. + directory = Path.cwd() + + for candidate_directory in (directory, *directory.parents): + candidate = candidate_directory / filename + if candidate.is_file(): + return str(candidate) + + if raise_error_if_not_found: + raise IOError("File not found") + return "" + + +def _source( + dotenv_path: Optional[StrPath], + stream: Optional[TextIO], + encoding: Optional[str] +) -> Tuple[str, Optional[Path]]: + if stream is not None: + return stream.read(), None + + path = Path(dotenv_path) if dotenv_path is not None else Path(find_dotenv()) + if not str(path) or not path.is_file(): + return "", None + return path.read_text(encoding=encoding or "utf-8"), path + + +def _key_files(path: Optional[Path]) -> List[str]: + if path is None: + return [] + + candidates = [path.with_name(f"{path.name}.keys")] + if path.name != ".env": + candidates.append(path.with_name(".env.keys")) + return [str(candidate) for candidate in candidates if candidate.is_file()] + + +def _parse( + dotenv_path: Optional[StrPath] = None, + stream: Optional[TextIO] = None, + override: bool = False, + interpolate: bool = True, + encoding: Optional[str] = "utf-8" +) -> Tuple[Values, Dict[str, str], bool]: + source, path = _source(dotenv_path, stream, encoding) + if path is None and not source: + return {}, {}, False + + parsed, injected = parse_dotenv( + source, + dict(os.environ), + override, + _key_files(path), + interpolate, ) + has_values = bool(parsed) + for key in _bare_keys(source): + parsed.setdefault(key, None) + return parsed, injected, has_values + + +def _bare_keys(source: str) -> List[str]: + pattern = re.compile( + r"^([A-Za-z_][A-Za-z0-9_.-]*)\s*(?:#.*)?$" + ) + keys = [] + multiline_quote = None + + for raw_line in source.splitlines(): + if multiline_quote is not None: + if _has_unescaped_quote(raw_line, multiline_quote): + multiline_quote = None + continue - try: - parsed = json.loads(output) - for key, value in parsed.items(): - os.environ[key] = value - return parsed - except Exception as e: - raise RuntimeError(f"Failed to parse dotenvx output: {e}") - -def dotenvx_get( - dotenv_path=None, - override=False -): - binpath = binary() - cmd = [binpath, "get", "-pp"] - - if dotenv_path: - cmd += ["-f", dotenv_path] - if override: - cmd.append("--overload") - - output = subprocess.run( - cmd, - capture_output=True, - text=True, - check=True + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[7:].lstrip() + + if "=" in line: + value = line.split("=", 1)[1].lstrip() + if value[:1] in {"'", '"', "`"}: + quote = value[0] + if not _has_unescaped_quote(value[1:], quote): + multiline_quote = quote + continue + + match = pattern.fullmatch(line) + if match: + keys.append(match.group(1)) + + return keys + + +def _has_unescaped_quote(value: str, quote: str) -> bool: + escaped = False + for character in value: + if character == quote and not escaped: + return True + if character == "\\": + escaped = not escaped + else: + escaped = False + return False + + +def dotenv_values( + dotenv_path: Optional[StrPath] = None, + stream: Optional[TextIO] = None, + verbose: bool = False, + interpolate: bool = True, + encoding: Optional[str] = "utf-8" +) -> Values: + """Parse a dotenv source without changing ``os.environ``.""" + del verbose + parsed, _, _ = _parse( + dotenv_path=dotenv_path, + stream=stream, + override=True, + interpolate=interpolate, + encoding=encoding, ) - return output.stdout.strip() - -def binary(): - candidates = [ - os.path.join(os.path.dirname(__file__), "bin", "dotenvx"), # package-local - os.path.join(os.getcwd(), "bin", "dotenvx"), # project-local - shutil.which("dotenvx") # global/system - ] - - for candidate in candidates: - if candidate and os.path.isfile(candidate) and os.access(candidate, os.X_OK): - if is_stub_file(candidate): - continue - return candidate - - print("[MISSING_BINARY] missing dotenvx binary") - print("[MISSING_BINARY] https://github.com/dotenvx/dotenvx/issues/576") - raise SystemExit(1) - -def is_stub_file(path, max_stub_size=1024): - try: - return os.path.getsize(path) < max_stub_size - except OSError: + return parsed + + +def load_dotenv( + dotenv_path: Optional[StrPath] = None, + stream: Optional[TextIO] = None, + verbose: bool = False, + override: bool = False, + interpolate: bool = True, + encoding: Optional[str] = "utf-8" +) -> bool: + """Load dotenv values into ``os.environ`` using native Rust primitives.""" + del verbose + + if os.environ.get("PYTHON_DOTENV_DISABLED", "").lower() in { + "1", "true", "t", "yes", "y" + }: return False -def postinstall(): - parser = argparse.ArgumentParser() - parser.add_argument("--os", help="Override OS", default="") - parser.add_argument("--arch", help="Override architecture", default="") - args = parser.parse_args() - - bin_dir = os.path.join(os.path.dirname(__file__), "bin") - os.makedirs(bin_dir, exist_ok=True) - - url = f"https://dotenvx.sh?directory={bin_dir}&force=true" - if args.os: - url += f"&os={args.os}" - if args.arch: - url += f"&arch={args.arch}" - - try: - subprocess.run( - ["sh", "-c", f'curl -sfS "{url}" | sh'], - check=True - ) - except subprocess.CalledProcessError as e: - raise SystemExit(e.returncode) + _, injected, found = _parse( + dotenv_path=dotenv_path, + stream=stream, + override=override, + interpolate=interpolate, + encoding=encoding, + ) + os.environ.update(injected) + return found + + +def load_dotenvx( + dotenv_path: Optional[StrPath] = None, + override: bool = False +) -> Values: + """Backward-compatible dotenvx loader returning the parsed mapping.""" + parsed, injected, _ = _parse( + dotenv_path=dotenv_path, + override=override, + ) + os.environ.update(injected) + return parsed diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..a79c648 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,79 @@ +import io +import os + +from dotenvx import dotenv_values, find_dotenv, load_dotenv, load_dotenvx + +PRIVATE_KEY = ( + "a4547dcd9d3429615a3649bb79e87edb62ee6a74b007075e9141ae44f5fb412c" +) +PUBLIC_KEY = ( + "03da6adce70a0e5b1b518357e8b5ecc9d85e79284908b4a87ed0c9974bcae60fa4" +) +ENCRYPTED_WORLD = ( + "encrypted:BE9Y7LKANx77X1pv1HnEoil93fPa5c9rpL/1ps48uaRT9zM8VR6mHx9y" + "M+HktKdsPGIZELuZ7rr2mn1gScsmWitppAgE/1lVprNYBCqiYeaTcKXjDUXU5" + "LfsEsflnAsDhT/kWG1l" +) + + +def test_dotenv_values_uses_native_parser(monkeypatch): + monkeypatch.setenv("HOST", "example.com") + + values = dotenv_values(stream=io.StringIO( + "GREETING=hello\nURL=https://${HOST}\n" + )) + + assert values == { + "GREETING": "hello", + "URL": "https://example.com", + } + assert "GREETING" not in os.environ + + +def test_load_dotenv_respects_override(tmp_path, monkeypatch): + path = tmp_path / ".env" + path.write_text("EXISTING=file\nADDED=value\n", encoding="utf-8") + monkeypatch.setenv("EXISTING", "environment") + monkeypatch.delenv("ADDED", raising=False) + + assert load_dotenv(path) is True + assert os.environ["EXISTING"] == "environment" + assert os.environ["ADDED"] == "value" + + assert load_dotenv(path, override=True) is True + assert os.environ["EXISTING"] == "file" + + +def test_load_dotenvx_returns_values(tmp_path, monkeypatch): + path = tmp_path / ".env" + path.write_text("HELLO=world\n", encoding="utf-8") + monkeypatch.delenv("HELLO", raising=False) + + assert load_dotenvx(path) == {"HELLO": "world"} + assert os.environ["HELLO"] == "world" + + +def test_find_dotenv_walks_to_parent(tmp_path, monkeypatch): + env_path = tmp_path / ".env" + env_path.write_text("HELLO=world\n", encoding="utf-8") + child = tmp_path / "one" / "two" + child.mkdir(parents=True) + monkeypatch.chdir(child) + + assert find_dotenv() == str(env_path) + + +def test_load_dotenv_decrypts_with_neighboring_key_file(tmp_path, monkeypatch): + env_path = tmp_path / ".env" + env_path.write_text( + f"DOTENV_PUBLIC_KEY={PUBLIC_KEY}\nSECRET={ENCRYPTED_WORLD}\n", + encoding="utf-8", + ) + (tmp_path / ".env.keys").write_text( + f"DOTENV_PRIVATE_KEY={PRIVATE_KEY}\n", + encoding="utf-8", + ) + monkeypatch.delenv("SECRET", raising=False) + + assert load_dotenv(env_path) is True + assert os.environ["SECRET"] == "World"