envlockup 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ node_modules/
2
+ dist/
3
+ *.tgz
4
+ .venv/
5
+ __pycache__/
6
+ *.egg-info/
7
+ build/
8
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 denisbilli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.5
2
+ Name: envlockup
3
+ Version: 0.2.0
4
+ Summary: Read envlockup files from Python: encrypted .env files you can commit, decrypted with your SSH or age key.
5
+ Project-URL: Homepage, https://github.com/denisbilli/envlockup
6
+ Author: denisbilli
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: age,dotenv,encryption,env,secrets,ssh
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Security :: Cryptography
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: bcrypt>=4
14
+ Requires-Dist: cryptography>=41
15
+ Provides-Extra: test
16
+ Requires-Dist: pytest>=8; extra == 'test'
17
+ Description-Content-Type: text/markdown
18
+
19
+ # envlockup for Python
20
+
21
+ Read [envlockup](https://github.com/denisbilli/envlockup) files from Python, without
22
+ Node.js. envlockup files are `.env` files whose values are encrypted, so they can be
23
+ committed; they are decrypted with your SSH key or a dedicated age key.
24
+
25
+ ```sh
26
+ pip install envlockup
27
+ ```
28
+
29
+ The package is imported as `envlockup`. It only reads files: create and edit them
30
+ with the envlockup CLI from npm (`npx envlockup init`, `npx envlockup set NAME`, ...).
31
+
32
+ ## Usage
33
+
34
+ At the top of your entry point (`manage.py`, `wsgi.py`, `asgi.py`, `celery.py`,
35
+ ...), before anything reads settings:
36
+
37
+ ```python
38
+ import envlockup
39
+ envlockup.config() # loads .env into os.environ, decrypting it
40
+ ```
41
+
42
+ Variables already in the environment win, as with python-dotenv; pass
43
+ `override=True` to change that. A missing file is ignored; a file that cannot be
44
+ decrypted raises, because starting without secrets is worse.
45
+
46
+ Other options:
47
+
48
+ ```python
49
+ envlockup.config(BASE_DIR / ".env") # explicit path (default: $ENVLOCKUP_FILE or ./.env)
50
+ values = envlockup.load(".env") # a dict, os.environ untouched
51
+ envlockup.load(".env", identity="/etc/app/key", passphrase="...")
52
+ ```
53
+
54
+ **python-decouple** reads `os.environ` before the `.env` file, so calling
55
+ `envlockup.config()` first is all it takes: `config("SECRET_KEY")` returns the
56
+ decrypted value.
57
+
58
+ Or, without touching the code, run the app through envlockup:
59
+
60
+ ```sh
61
+ python -m envlockup run -- gunicorn app.wsgi
62
+ ```
63
+
64
+ On Linux and macOS the command replaces the envlockup process (same PID, signals go
65
+ straight to it), which suits systemd, supervisord and Docker.
66
+
67
+ ## Keys
68
+
69
+ The private key is looked for in this order:
70
+
71
+ 1. `ENVLOCKUP_PRIVATE_KEY`: the key itself. For servers and CI, create one with
72
+ `npx envlockup keygen` and store the printed `AGE-SECRET-KEY-1...`.
73
+ 2. `ENVLOCKUP_IDENTITY`: path to a key file.
74
+ 3. `~/.ssh/id_ed25519`.
75
+
76
+ When 1 or 2 is set, nothing else is tried. A passphrase-protected SSH key is
77
+ unlocked with `ENVLOCKUP_SSH_PASSPHRASE` (or a prompt, in `python -m envlockup`).
78
+ `config()` and `run` remove `ENVLOCKUP_PRIVATE_KEY` and `ENVLOCKUP_SSH_PASSPHRASE`
79
+ from the environment once the file is decrypted.
80
+
81
+ ssh-agent is not supported by the Python package yet.
82
+
83
+ ## Command line
84
+
85
+ ```
86
+ python -m envlockup [-f FILE] [-i KEY] get NAME
87
+ python -m envlockup [-f FILE] [-i KEY] decrypt [--format json]
88
+ python -m envlockup [-f FILE] [-i KEY] run [--override] -- COMMAND [ARGS]...
89
+ ```
90
+
91
+ ## Compatibility
92
+
93
+ Files are parsed exactly like Node's `dotenv` package (and the envlockup CLI), not
94
+ like python-dotenv. The test suite runs the same vectors as the Node
95
+ implementation (`test/vectors.json` in the repository), generated with the real
96
+ `dotenv` package.
97
+
98
+ The file format is described in
99
+ [docs/DESIGN.md](https://github.com/denisbilli/envlockup/blob/main/docs/DESIGN.md), and
100
+ what it protects against in
101
+ [docs/THREAT_MODEL.md](https://github.com/denisbilli/envlockup/blob/main/docs/THREAT_MODEL.md).
@@ -0,0 +1,83 @@
1
+ # envlockup for Python
2
+
3
+ Read [envlockup](https://github.com/denisbilli/envlockup) files from Python, without
4
+ Node.js. envlockup files are `.env` files whose values are encrypted, so they can be
5
+ committed; they are decrypted with your SSH key or a dedicated age key.
6
+
7
+ ```sh
8
+ pip install envlockup
9
+ ```
10
+
11
+ The package is imported as `envlockup`. It only reads files: create and edit them
12
+ with the envlockup CLI from npm (`npx envlockup init`, `npx envlockup set NAME`, ...).
13
+
14
+ ## Usage
15
+
16
+ At the top of your entry point (`manage.py`, `wsgi.py`, `asgi.py`, `celery.py`,
17
+ ...), before anything reads settings:
18
+
19
+ ```python
20
+ import envlockup
21
+ envlockup.config() # loads .env into os.environ, decrypting it
22
+ ```
23
+
24
+ Variables already in the environment win, as with python-dotenv; pass
25
+ `override=True` to change that. A missing file is ignored; a file that cannot be
26
+ decrypted raises, because starting without secrets is worse.
27
+
28
+ Other options:
29
+
30
+ ```python
31
+ envlockup.config(BASE_DIR / ".env") # explicit path (default: $ENVLOCKUP_FILE or ./.env)
32
+ values = envlockup.load(".env") # a dict, os.environ untouched
33
+ envlockup.load(".env", identity="/etc/app/key", passphrase="...")
34
+ ```
35
+
36
+ **python-decouple** reads `os.environ` before the `.env` file, so calling
37
+ `envlockup.config()` first is all it takes: `config("SECRET_KEY")` returns the
38
+ decrypted value.
39
+
40
+ Or, without touching the code, run the app through envlockup:
41
+
42
+ ```sh
43
+ python -m envlockup run -- gunicorn app.wsgi
44
+ ```
45
+
46
+ On Linux and macOS the command replaces the envlockup process (same PID, signals go
47
+ straight to it), which suits systemd, supervisord and Docker.
48
+
49
+ ## Keys
50
+
51
+ The private key is looked for in this order:
52
+
53
+ 1. `ENVLOCKUP_PRIVATE_KEY`: the key itself. For servers and CI, create one with
54
+ `npx envlockup keygen` and store the printed `AGE-SECRET-KEY-1...`.
55
+ 2. `ENVLOCKUP_IDENTITY`: path to a key file.
56
+ 3. `~/.ssh/id_ed25519`.
57
+
58
+ When 1 or 2 is set, nothing else is tried. A passphrase-protected SSH key is
59
+ unlocked with `ENVLOCKUP_SSH_PASSPHRASE` (or a prompt, in `python -m envlockup`).
60
+ `config()` and `run` remove `ENVLOCKUP_PRIVATE_KEY` and `ENVLOCKUP_SSH_PASSPHRASE`
61
+ from the environment once the file is decrypted.
62
+
63
+ ssh-agent is not supported by the Python package yet.
64
+
65
+ ## Command line
66
+
67
+ ```
68
+ python -m envlockup [-f FILE] [-i KEY] get NAME
69
+ python -m envlockup [-f FILE] [-i KEY] decrypt [--format json]
70
+ python -m envlockup [-f FILE] [-i KEY] run [--override] -- COMMAND [ARGS]...
71
+ ```
72
+
73
+ ## Compatibility
74
+
75
+ Files are parsed exactly like Node's `dotenv` package (and the envlockup CLI), not
76
+ like python-dotenv. The test suite runs the same vectors as the Node
77
+ implementation (`test/vectors.json` in the repository), generated with the real
78
+ `dotenv` package.
79
+
80
+ The file format is described in
81
+ [docs/DESIGN.md](https://github.com/denisbilli/envlockup/blob/main/docs/DESIGN.md), and
82
+ what it protects against in
83
+ [docs/THREAT_MODEL.md](https://github.com/denisbilli/envlockup/blob/main/docs/THREAT_MODEL.md).
@@ -0,0 +1,34 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "envlockup"
7
+ version = "0.2.0"
8
+ description = "Read envlockup files from Python: encrypted .env files you can commit, decrypted with your SSH or age key."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "denisbilli" }]
13
+ requires-python = ">=3.10"
14
+ dependencies = [
15
+ "cryptography>=41",
16
+ "bcrypt>=4", # passphrase-protected OpenSSH keys
17
+ ]
18
+ keywords = ["dotenv", "env", "secrets", "encryption", "ssh", "age"]
19
+ classifiers = [
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Security :: Cryptography",
22
+ ]
23
+
24
+ [project.optional-dependencies]
25
+ test = ["pytest>=8"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/denisbilli/envlockup"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/envlockup"]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
@@ -0,0 +1,133 @@
1
+ """Read envlockup files: .env files whose values are encrypted for SSH or age keys.
2
+
3
+ import envlockup
4
+ envlockup.config() # like python-dotenv's load_dotenv(), decrypting as it goes
5
+
6
+ The private key is looked for in this order:
7
+
8
+ 1. ENVLOCKUP_PRIVATE_KEY the key itself (CI and servers: store it as a single secret)
9
+ 2. ENVLOCKUP_IDENTITY path to a key file
10
+ 3. ~/.ssh/id_ed25519 your SSH key
11
+
12
+ 1 and 2 are "explicit": when set, nothing else is tried. A passphrase-protected
13
+ SSH key is unlocked with ENVLOCKUP_SSH_PASSPHRASE. ssh-agent is not supported yet
14
+ by the Python package.
15
+
16
+ Files are created and edited with the envlockup CLI from npm.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ from pathlib import Path
23
+ from typing import MutableMapping
24
+
25
+ from ._keys import Identity, parse_identity
26
+ from ._vault import SealedFile
27
+ from .errors import (
28
+ DecryptError,
29
+ EnvlockupError,
30
+ FormatError,
31
+ NoKeyError,
32
+ NotARecipientError,
33
+ RecipientListError,
34
+ PassphraseRequiredError,
35
+ )
36
+
37
+ __version__ = "0.2.0"
38
+ __all__ = [
39
+ "config",
40
+ "load",
41
+ "DecryptError",
42
+ "EnvlockupError",
43
+ "FormatError",
44
+ "NoKeyError",
45
+ "NotARecipientError",
46
+ "RecipientListError",
47
+ "PassphraseRequiredError",
48
+ ]
49
+
50
+ # Removed from the environment once the file is decrypted, so the rest of the
51
+ # application (and anything it starts) never sees the key.
52
+ SECRET_ENV_VARS = ("ENVLOCKUP_PRIVATE_KEY", "ENVLOCKUP_SSH_PASSPHRASE")
53
+
54
+
55
+ def _default_path() -> Path:
56
+ return Path(os.environ.get("ENVLOCKUP_FILE") or ".env")
57
+
58
+
59
+ def _find_identity(private_key: str | None, identity_path: str | os.PathLike[str] | None) -> tuple[str, str] | None:
60
+ """(key text, where it came from), or None."""
61
+ if private_key:
62
+ return private_key, "private_key argument"
63
+ if identity_path:
64
+ return Path(identity_path).read_text(encoding="utf-8"), str(identity_path)
65
+ inline = os.environ.get("ENVLOCKUP_PRIVATE_KEY", "").strip()
66
+ if inline:
67
+ # CI systems often flatten multi-line secrets; accept literal "\n".
68
+ return (inline if "\n" in inline else inline.replace("\\n", "\n")), "ENVLOCKUP_PRIVATE_KEY"
69
+ file = os.environ.get("ENVLOCKUP_IDENTITY")
70
+ if file:
71
+ return Path(file).read_text(encoding="utf-8"), file
72
+ default = Path.home() / ".ssh" / "id_ed25519"
73
+ if default.is_file():
74
+ return default.read_text(encoding="utf-8"), str(default)
75
+ return None
76
+
77
+
78
+ def load(
79
+ path: str | os.PathLike[str] | None = None,
80
+ *,
81
+ private_key: str | None = None,
82
+ identity: str | os.PathLike[str] | None = None,
83
+ passphrase: str | None = None,
84
+ ) -> dict[str, str]:
85
+ """Decrypt a file and return its variables, without touching os.environ.
86
+
87
+ `path` defaults to $ENVLOCKUP_FILE or .env in the current directory. Pass
88
+ `private_key` (key text) or `identity` (key file path) to skip the default
89
+ lookup. The private key is only needed when the file has encrypted values.
90
+ """
91
+ file = Path(path) if path is not None else _default_path()
92
+ sealed = SealedFile(file.read_text(encoding="utf-8", errors="replace"))
93
+ if sealed.has_encrypted_values:
94
+ found = _find_identity(private_key, identity)
95
+ if found is None:
96
+ hint = " ssh-agent is not supported by the Python package yet." if sealed.agent_recipients else ""
97
+ raise NoKeyError(
98
+ f"{file} contains encrypted values but no private key was found. "
99
+ f"Use ~/.ssh/id_ed25519, or set ENVLOCKUP_PRIVATE_KEY or ENVLOCKUP_IDENTITY.{hint}"
100
+ )
101
+ text, source = found
102
+ key: Identity = parse_identity(text, source, passphrase or os.environ.get("ENVLOCKUP_SSH_PASSPHRASE"))
103
+ sealed.unlock(key)
104
+ return sealed.decrypt_all()
105
+
106
+
107
+ def config(
108
+ path: str | os.PathLike[str] | None = None,
109
+ *,
110
+ override: bool = False,
111
+ private_key: str | None = None,
112
+ identity: str | os.PathLike[str] | None = None,
113
+ passphrase: str | None = None,
114
+ environ: MutableMapping[str, str] | None = None,
115
+ ) -> dict[str, str]:
116
+ """Load the file into os.environ (or `environ`) and return its variables.
117
+
118
+ Variables already set are kept unless `override` is true, as in dotenv. A
119
+ missing file returns {}; a file that cannot be decrypted raises, because
120
+ silently starting without secrets is worse.
121
+ """
122
+ file = Path(path) if path is not None else _default_path()
123
+ if not file.is_file():
124
+ return {}
125
+ parsed = load(file, private_key=private_key, identity=identity, passphrase=passphrase)
126
+ target = os.environ if environ is None else environ
127
+ for name, value in parsed.items():
128
+ if override or name not in target:
129
+ target[name] = value
130
+ # The key has done its job; don't leave it around for the rest of the app.
131
+ for name in SECRET_ENV_VARS:
132
+ os.environ.pop(name, None)
133
+ return parsed
@@ -0,0 +1,132 @@
1
+ """python -m envlockup: read envlockup files from the command line.
2
+
3
+ Edit them with the envlockup CLI from npm (`npx envlockup set NAME`).
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import getpass
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ from . import SECRET_ENV_VARS, _find_identity, load
18
+ from ._dotenv import format_value
19
+ from ._keys import is_encrypted_ssh_key
20
+ from .errors import EnvlockupError, PassphraseRequiredError
21
+
22
+ USAGE = """python -m envlockup [-f FILE] [-i KEY] <command>
23
+
24
+ Commands:
25
+ get NAME Print one decrypted value.
26
+ decrypt [--format json]
27
+ Print the whole file decrypted, in dotenv format
28
+ (or as a JSON object).
29
+ run [--override] -- COMMAND [ARGS]...
30
+ Run COMMAND with the variables in its environment.
31
+ Nothing decrypted is written to disk.
32
+
33
+ Options:
34
+ -f, --file FILE File to use (default: $ENVLOCKUP_FILE or .env)
35
+ -i, --identity KEY Private key file (default: $ENVLOCKUP_PRIVATE_KEY,
36
+ $ENVLOCKUP_IDENTITY, or ~/.ssh/id_ed25519)
37
+ """
38
+
39
+
40
+ def _parser() -> argparse.ArgumentParser:
41
+ p = argparse.ArgumentParser(prog="python -m envlockup", usage=USAGE, add_help=True)
42
+ p.add_argument("-f", "--file")
43
+ p.add_argument("-i", "--identity")
44
+ sub = p.add_subparsers(dest="command", required=True)
45
+ get = sub.add_parser("get")
46
+ get.add_argument("name")
47
+ decrypt = sub.add_parser("decrypt")
48
+ decrypt.add_argument("--format", choices=["dotenv", "json"], default="dotenv")
49
+ run = sub.add_parser("run")
50
+ run.add_argument("--override", action="store_true")
51
+ run.add_argument("argv", nargs=argparse.REMAINDER)
52
+ return p
53
+
54
+
55
+ def _values(args: argparse.Namespace) -> dict[str, str]:
56
+ try:
57
+ return load(args.file, identity=args.identity)
58
+ except PassphraseRequiredError:
59
+ # Ask interactively, like the Node CLI, when there is someone to ask.
60
+ if not sys.stdin.isatty():
61
+ raise
62
+ found = _find_identity(None, args.identity)
63
+ if not found or not is_encrypted_ssh_key(found[0]):
64
+ raise
65
+ passphrase = getpass.getpass(f"Passphrase for {found[1]}: ")
66
+ return load(args.file, identity=args.identity, passphrase=passphrase)
67
+
68
+
69
+ def _run(command: list[str], env: dict[str, str]) -> int:
70
+ if os.name == "posix":
71
+ # Replace this process, so the command gets signals and keeps our PID
72
+ # (what systemd, supervisord and Docker expect).
73
+ try:
74
+ os.execvpe(command[0], command, env)
75
+ except OSError as err:
76
+ print(f"envlockup: cannot run {command[0]}: {err.strerror}", file=sys.stderr)
77
+ return 127
78
+ # Windows: resolve .exe/.cmd through PATHEXT, then wait for the child.
79
+ exe = shutil.which(command[0], path=env.get("PATH")) or command[0]
80
+ try:
81
+ return subprocess.call([exe, *command[1:]], env=env)
82
+ except OSError as err:
83
+ print(f"envlockup: cannot run {command[0]}: {err.strerror}", file=sys.stderr)
84
+ return 127
85
+ except KeyboardInterrupt:
86
+ return 130
87
+
88
+
89
+ def main(argv: list[str] | None = None) -> int:
90
+ args = _parser().parse_args(argv)
91
+ try:
92
+ if args.command == "get":
93
+ values = _values(args)
94
+ if args.name not in values:
95
+ print(f"envlockup: {args.name} is not set", file=sys.stderr)
96
+ return 1
97
+ print(values[args.name])
98
+ elif args.command == "decrypt":
99
+ values = _values(args)
100
+ if args.format == "json":
101
+ print(json.dumps(values, ensure_ascii=False, indent=2))
102
+ else:
103
+ lines = []
104
+ for name, value in values.items():
105
+ try:
106
+ lines.append(f"{name}={format_value(value)}")
107
+ except ValueError:
108
+ print(f"envlockup: {name} cannot be written in dotenv syntax (it mixes ', \" and `); use --format json", file=sys.stderr)
109
+ return 1
110
+ if lines:
111
+ print("\n".join(lines))
112
+ elif args.command == "run":
113
+ command = args.argv[1:] if args.argv[:1] == ["--"] else args.argv
114
+ if not command:
115
+ print("usage: python -m envlockup run -- COMMAND [ARGS]...", file=sys.stderr)
116
+ return 2
117
+ values = _values(args)
118
+ env = dict(os.environ)
119
+ for name in SECRET_ENV_VARS:
120
+ env.pop(name, None)
121
+ for name, value in values.items():
122
+ if args.override or name not in env:
123
+ env[name] = value
124
+ return _run(command, env)
125
+ except (EnvlockupError, OSError) as err:
126
+ print(f"envlockup: {err}", file=sys.stderr)
127
+ return 1
128
+ return 0
129
+
130
+
131
+ if __name__ == "__main__":
132
+ sys.exit(main())
@@ -0,0 +1,56 @@
1
+ # Bech32 (BIP 173) as used by age for `age1...` recipients and
2
+ # `AGE-SECRET-KEY-1...` identities. age does not enforce the 90 character limit.
3
+
4
+ from __future__ import annotations
5
+
6
+ _CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
7
+ _GENERATOR = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
8
+
9
+
10
+ def _polymod(values: list[int]) -> int:
11
+ chk = 1
12
+ for v in values:
13
+ top = chk >> 25
14
+ chk = ((chk & 0x1FFFFFF) << 5) ^ v
15
+ for i in range(5):
16
+ if (top >> i) & 1:
17
+ chk ^= _GENERATOR[i]
18
+ return chk
19
+
20
+
21
+ def _hrp_expand(hrp: str) -> list[int]:
22
+ return [ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp]
23
+
24
+
25
+ def _convert_bits(data: list[int], frm: int, to: int) -> bytes:
26
+ acc = bits = 0
27
+ out = []
28
+ maxv = (1 << to) - 1
29
+ for value in data:
30
+ acc = (acc << frm) | value
31
+ bits += frm
32
+ while bits >= to:
33
+ bits -= to
34
+ out.append((acc >> bits) & maxv)
35
+ if bits >= frm or ((acc << (to - bits)) & maxv) != 0:
36
+ raise ValueError("invalid bech32 padding")
37
+ return bytes(out)
38
+
39
+
40
+ def decode(s: str) -> tuple[str, bytes]:
41
+ if s != s.lower() and s != s.upper():
42
+ raise ValueError("mixed-case bech32 string")
43
+ lower = s.lower()
44
+ pos = lower.rfind("1")
45
+ if pos < 1 or pos + 7 > len(lower):
46
+ raise ValueError("invalid bech32 string")
47
+ hrp = lower[:pos]
48
+ words = []
49
+ for c in lower[pos + 1 :]:
50
+ v = _CHARSET.find(c)
51
+ if v == -1:
52
+ raise ValueError("invalid bech32 character")
53
+ words.append(v)
54
+ if _polymod(_hrp_expand(hrp) + words) != 1:
55
+ raise ValueError("invalid bech32 checksum")
56
+ return hrp, _convert_bits(words[:-6], 5, 8)
@@ -0,0 +1,95 @@
1
+ # A port of the dotenv 16/17 parser (lib/main.js), so that a file means the same
2
+ # thing to Python as to Node's dotenv and to envlockup. python-dotenv parses
3
+ # differently and is not used.
4
+ #
5
+ # The JavaScript regex is translated by hand, because JavaScript and Python
6
+ # disagree on what \s, \w, ^, $ and . match:
7
+ #
8
+ # \s JavaScript's whitespace set (includes U+FEFF, excludes U+001C-U+001F and U+0085)
9
+ # \w ASCII letters, digits and _
10
+ # ^ $ with the m flag, line starts/ends at \n, \r, U+2028 and U+2029
11
+ # . anything but those four line terminators
12
+ #
13
+ # The shared test vectors (test/vectors.json) check this against dotenv itself.
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+
19
+ _WS_CHARS = "\t\n\x0b\x0c\r \xa0 " + "".join(chr(c) for c in range(0x2000, 0x200B)) + "

   "
20
+ _S = "[\t\n\x0b\x0c\r \xa0  - 

   ]"
21
+ _BOL = "(?<![^\n\r

])" # start of input, or just after a line terminator
22
+ _EOL = "(?![^\n\r

])" # end of input, or just before a line terminator
23
+ _DOT = "[^\n\r\u2028\u2029]"
24
+
25
+ # dotenv: /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/mg
26
+ _LINE = re.compile(
27
+ _BOL
28
+ + _S + "*"
29
+ + "(?:export" + _S + "+)?"
30
+ + "([A-Za-z0-9_.-]+)"
31
+ + "(?:" + _S + "*=" + _S + "*?|:" + _S + "+?)"
32
+ + "("
33
+ + _S + r"*'(?:\\'|[^'])*'|"
34
+ + _S + r'*"(?:\\"|[^"])*"|'
35
+ + _S + r"*`(?:\\`|[^`])*`|"
36
+ + r"[^#\r\n]+"
37
+ + ")?"
38
+ + _S + "*"
39
+ + "(?:#" + _DOT + "*)?"
40
+ + _EOL
41
+ )
42
+
43
+ # dotenv: value.replace(/^(['"`])([\s\S]*)\1$/mg, '$2')
44
+ _QUOTED = re.compile(_BOL + r"(['\"`])([\s\S]*)\1" + _EOL)
45
+
46
+
47
+ def split_lines(text: str) -> list[str]:
48
+ lines = re.split(r"\r\n?|\n", text)
49
+ if lines and lines[-1] == "":
50
+ lines.pop()
51
+ return lines
52
+
53
+
54
+ def _unquote(raw: str) -> str:
55
+ value = raw.strip(_WS_CHARS)
56
+ quote = value[:1]
57
+ value = _QUOTED.sub(r"\2", value)
58
+ if quote == '"':
59
+ value = value.replace("\\n", "\n").replace("\\r", "\r")
60
+ return value
61
+
62
+
63
+ def parse_entries(lines: list[str]) -> list[tuple[str, str]]:
64
+ """(name, value) pairs in file order. Later definitions win, as in dotenv."""
65
+ text = "\n".join(lines)
66
+ return [(m.group(1), _unquote(m.group(2) or "")) for m in _LINE.finditer(text)]
67
+
68
+
69
+ def parse(text: str) -> dict[str, str]:
70
+ return dict(parse_entries(split_lines(text)))
71
+
72
+
73
+ _BARE = re.compile(r"[A-Za-z0-9_./:@%+,=~-]*")
74
+
75
+
76
+ def format_value(value: str) -> str:
77
+ """Serialize a value so that dotenv (and this parser) read it back unchanged.
78
+
79
+ Same rules as formatValue in the Node implementation. Raises ValueError for
80
+ the rare values dotenv's syntax cannot represent.
81
+ """
82
+ if _BARE.fullmatch(value):
83
+ return value
84
+ # dotenv turns every \r\n or \r into \n before parsing; only the \r escape inside double quotes survives.
85
+ if "\r" in value:
86
+ if '"' not in value and not re.search(r"\\[nr]", value):
87
+ return '"' + value.replace("\r", "\\r") + '"'
88
+ raise ValueError("this value cannot be written as a dotenv value (it has a carriage return and a \" or \\n)")
89
+ if "'" not in value:
90
+ return f"'{value}'"
91
+ if '"' not in value and not re.search(r"\\[nr]", value):
92
+ return f'"{value}"'
93
+ if "`" not in value:
94
+ return f"`{value}`"
95
+ raise ValueError("this value cannot be written as a dotenv value (it mixes ', \" and `)")