tidyenv 0.1.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,10 @@
1
+ # Opens one monthly PR that bumps the GitHub Actions used in .github/workflows.
2
+ version: 2
3
+ updates:
4
+ - package-ecosystem: github-actions
5
+ directory: /
6
+ schedule:
7
+ interval: monthly
8
+ groups:
9
+ actions:
10
+ patterns: ["*"]
@@ -0,0 +1,25 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_call:
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ python-version: ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
16
+ steps:
17
+ - uses: actions/checkout@v7
18
+ - uses: astral-sh/setup-uv@v7
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ - run: uv sync
22
+ - run: uv run ruff check .
23
+ - run: uv run ruff format --check .
24
+ - run: uv run mypy
25
+ - run: uv run pytest
@@ -0,0 +1,69 @@
1
+ # Publishing, with no tokens (PyPI "trusted publishing"):
2
+ # - Run this workflow by hand (Actions -> Release -> Run workflow) to publish to TestPyPI.
3
+ # - Publish a GitHub release with tag vX.Y.Z to publish to PyPI.
4
+ # One-time setup on pypi.org and test.pypi.org: add a trusted publisher with
5
+ # owner LenaBarretta, repository tidyenv, workflow release.yml and environment
6
+ # `pypi` (on pypi.org) or `testpypi` (on test.pypi.org).
7
+ name: Release
8
+
9
+ on:
10
+ release:
11
+ types: [published]
12
+ workflow_dispatch:
13
+
14
+ jobs:
15
+ test:
16
+ uses: ./.github/workflows/ci.yml
17
+
18
+ build:
19
+ needs: test
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v7
23
+ - uses: astral-sh/setup-uv@v7
24
+ - run: uv build
25
+ - name: Check that the release tag matches the package version
26
+ if: github.event_name == 'release'
27
+ run: |
28
+ version="${GITHUB_REF_NAME#v}"
29
+ test -f "dist/tidyenv-${version}-py3-none-any.whl" || {
30
+ echo "Tag $GITHUB_REF_NAME does not match the version in src/tidyenv/__init__.py"
31
+ ls dist
32
+ exit 1
33
+ }
34
+ - uses: actions/upload-artifact@v7
35
+ with:
36
+ name: dist
37
+ path: dist/
38
+
39
+ testpypi:
40
+ if: github.event_name == 'workflow_dispatch'
41
+ needs: build
42
+ runs-on: ubuntu-latest
43
+ environment: testpypi
44
+ permissions:
45
+ id-token: write
46
+ steps:
47
+ - uses: actions/download-artifact@v8
48
+ with:
49
+ name: dist
50
+ path: dist/
51
+ - uses: pypa/gh-action-pypi-publish@release/v1
52
+ with:
53
+ repository-url: https://test.pypi.org/legacy/
54
+ # A version can be uploaded only once; reruns just re-check the setup.
55
+ skip-existing: true
56
+
57
+ pypi:
58
+ if: github.event_name == 'release'
59
+ needs: build
60
+ runs-on: ubuntu-latest
61
+ environment: pypi
62
+ permissions:
63
+ id-token: write
64
+ steps:
65
+ - uses: actions/download-artifact@v8
66
+ with:
67
+ name: dist
68
+ path: dist/
69
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .coverage
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .DS_Store
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-02)
4
+
5
+ First release: `str`, `int`, `float`, `bool`, `list`, `choice`, `path` and `json`
6
+ readers, defaults, `secret=True`, prefixes, `env.collect()` for all-at-once errors,
7
+ and built-in `.env` loading with `env.read_dotenv()` and `parse_dotenv()`.
tidyenv-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Elena Raikova
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.
tidyenv-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,156 @@
1
+ Metadata-Version: 2.5
2
+ Name: tidyenv
3
+ Version: 0.1.0
4
+ Summary: Typed environment variables with friendly errors and built-in .env support.
5
+ Project-URL: Homepage, https://github.com/LenaBarretta/tidyenv
6
+ Project-URL: Issues, https://github.com/LenaBarretta/tidyenv/issues
7
+ Author-email: Elena Raikova <lenabarretta@gmail.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: 12-factor,config,env,environment,settings
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+
26
+ <p align="center">
27
+ <img src="https://raw.githubusercontent.com/LenaBarretta/tidyenv/main/docs/logo.png" alt="tidyenv logo" width="160">
28
+ </p>
29
+
30
+ # tidyenv
31
+
32
+ [![PyPI](https://img.shields.io/pypi/v/tidyenv)](https://pypi.org/project/tidyenv/)
33
+ [![Python](https://img.shields.io/pypi/pyversions/tidyenv)](https://pypi.org/project/tidyenv/)
34
+ [![CI](https://github.com/LenaBarretta/tidyenv/actions/workflows/ci.yml/badge.svg)](https://github.com/LenaBarretta/tidyenv/actions/workflows/ci.yml)
35
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
36
+
37
+ **Typed environment variables with friendly errors.** Built-in `.env` support. Zero dependencies.
38
+
39
+ ```python
40
+ from tidyenv import env
41
+
42
+ PORT = env.int("PORT", default=8000)
43
+ DEBUG = env.bool("DEBUG", default=False)
44
+ HOSTS = env.list("ALLOWED_HOSTS", default=["localhost"])
45
+ DATABASE_URL = env.str("DATABASE_URL")
46
+ ```
47
+
48
+ Each line converts the type, applies the default, and if something is wrong, says exactly what:
49
+
50
+ ```text
51
+ tidyenv.EnvError: 1 environment problem:
52
+ - PORT: expected an integer (got 'eighty')
53
+ ```
54
+
55
+ ## Why
56
+
57
+ | | Plain `os.environ` | tidyenv |
58
+ | --- | --- | --- |
59
+ | Number with a default | `int(os.environ.get("PORT", "8000"))` | `env.int("PORT", default=8000)` |
60
+ | Bad number | `ValueError: invalid literal for int() with base 10: 'eighty'` (which variable?) | `PORT: expected an integer (got 'eighty')` |
61
+ | Missing variable | `KeyError: 'DB_URL'` | `DB_URL: is not set` |
62
+ | Empty value `DB_URL=` | silently `""` | treated as not set |
63
+ | Boolean | `os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")`, and a typo like `ture` silently means `False` | `env.bool("DEBUG", default=False)`, and `ture` is an error |
64
+ | List | `[h.strip() for h in os.environ.get("HOSTS", "").split(",") if h.strip()]` | `env.list("HOSTS")` |
65
+ | List of numbers | the same, plus `int()` on every item | `env.list("PORTS", of=int)` |
66
+ | One of several values | `if mode not in ("dev", "prod"): raise ...` | `env.choice("MODE", ["dev", "prod"])` |
67
+ | Secrets in errors | the value ends up in your logs | `secret=True` shows `***` |
68
+ | `.env` file | `pip install python-dotenv` + `load_dotenv()` | `env.read_dotenv()`, nothing to install |
69
+ | Types for mypy / IDE | `str \| None`, cast it yourself | `env.int` returns `int` |
70
+ | Several broken variables | crash, fix, redeploy, crash on the next one | `env.collect()` lists them all at once |
71
+
72
+ One line per variable, and every error names the variable and says what is wrong with it.
73
+
74
+ ## Install
75
+
76
+ ```bash
77
+ pip install tidyenv
78
+ ```
79
+
80
+ Python 3.9+.
81
+
82
+ ## Usage
83
+
84
+ | Reader | Example value | Returns |
85
+ | --- | --- | --- |
86
+ | `env.str(name)` | `hello` | `str` (stripped) |
87
+ | `env.int(name)` | `8000`, `1_000` | `int` |
88
+ | `env.float(name)` | `0.25` | `float` (`nan` and `inf` are rejected) |
89
+ | `env.bool(name)` | `true/false`, `yes/no`, `on/off`, `1/0`, any case | `bool` |
90
+ | `env.list(name, sep=",", of=str)` | `a, b, c` | `list` (use `of=int` to convert items; `int`, `float` and `bool` follow the rules above) |
91
+ | `env.choice(name, choices)` | `prod` | `str` that must be in `choices` |
92
+ | `env.path(name, must_exist=False)` | `~/data` | `pathlib.Path` with `~` expanded |
93
+ | `env.json(name)` | `{"a": 1}` | parsed JSON |
94
+
95
+ Every reader takes an optional `default`. Without one, a missing or empty variable is
96
+ an error. With one, the default is returned instead, and type checkers know the result
97
+ is `int | <type of default>`.
98
+
99
+ ### `.env` files
100
+
101
+ No need for `python-dotenv`:
102
+
103
+ ```python
104
+ env.read_dotenv() # reads ./.env if it exists
105
+ env.read_dotenv("config/dev.env", required=True)
106
+ ```
107
+
108
+ Real environment variables always win over the file, and nothing is written to
109
+ `os.environ`. When you read several files, later ones override earlier ones.
110
+ Supported syntax: `KEY=value`, `export KEY=value`, `# comments`,
111
+ `'single quotes'` (literal) and `"double quotes"` (with `\n`, `\t`, `\"` escapes).
112
+ Each value must fit on one line; for a multi-line value such as a PEM key, write
113
+ `\n` inside double quotes.
114
+ Need just the parser? `tidyenv.parse_dotenv(text)` returns a `dict`.
115
+
116
+ ### All errors at once (optional)
117
+
118
+ By default the first bad variable raises `EnvError`. If you'd rather see every
119
+ problem in one go, wrap your reads in `env.collect()`:
120
+
121
+ ```python
122
+ with env.collect():
123
+ PORT = env.int("PORT", default=8000)
124
+ DATABASE_URL = env.str("DATABASE_URL")
125
+ API_KEY = env.str("API_KEY", secret=True)
126
+ ```
127
+
128
+ ```text
129
+ tidyenv.EnvError: 3 environment problems:
130
+ - PORT: expected an integer (got 'eighty')
131
+ - DATABASE_URL: is not set
132
+ - API_KEY: is not set
133
+ ```
134
+
135
+ Failed reads return `None` inside the block, so only use the values after it exits.
136
+
137
+ ### More
138
+
139
+ **Secrets.** Pass `secret=True` and the raw value is shown as `***` in error messages,
140
+ including errors about single `env.list` items.
141
+
142
+ **Prefixes and custom sources.** Build your own reader:
143
+
144
+ ```python
145
+ from tidyenv import Env
146
+
147
+ env = Env(prefix="MYAPP_") # reads MYAPP_PORT for env.int("PORT")
148
+ test_env = Env(environ={"PORT": "1"}) # any mapping, handy in tests
149
+ ```
150
+
151
+ **Handling errors.** `EnvError.problems` is a list of `Problem(name, message)`, so you
152
+ can print them your own way.
153
+
154
+ ## License
155
+
156
+ MIT
@@ -0,0 +1,131 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/LenaBarretta/tidyenv/main/docs/logo.png" alt="tidyenv logo" width="160">
3
+ </p>
4
+
5
+ # tidyenv
6
+
7
+ [![PyPI](https://img.shields.io/pypi/v/tidyenv)](https://pypi.org/project/tidyenv/)
8
+ [![Python](https://img.shields.io/pypi/pyversions/tidyenv)](https://pypi.org/project/tidyenv/)
9
+ [![CI](https://github.com/LenaBarretta/tidyenv/actions/workflows/ci.yml/badge.svg)](https://github.com/LenaBarretta/tidyenv/actions/workflows/ci.yml)
10
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
11
+
12
+ **Typed environment variables with friendly errors.** Built-in `.env` support. Zero dependencies.
13
+
14
+ ```python
15
+ from tidyenv import env
16
+
17
+ PORT = env.int("PORT", default=8000)
18
+ DEBUG = env.bool("DEBUG", default=False)
19
+ HOSTS = env.list("ALLOWED_HOSTS", default=["localhost"])
20
+ DATABASE_URL = env.str("DATABASE_URL")
21
+ ```
22
+
23
+ Each line converts the type, applies the default, and if something is wrong, says exactly what:
24
+
25
+ ```text
26
+ tidyenv.EnvError: 1 environment problem:
27
+ - PORT: expected an integer (got 'eighty')
28
+ ```
29
+
30
+ ## Why
31
+
32
+ | | Plain `os.environ` | tidyenv |
33
+ | --- | --- | --- |
34
+ | Number with a default | `int(os.environ.get("PORT", "8000"))` | `env.int("PORT", default=8000)` |
35
+ | Bad number | `ValueError: invalid literal for int() with base 10: 'eighty'` (which variable?) | `PORT: expected an integer (got 'eighty')` |
36
+ | Missing variable | `KeyError: 'DB_URL'` | `DB_URL: is not set` |
37
+ | Empty value `DB_URL=` | silently `""` | treated as not set |
38
+ | Boolean | `os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")`, and a typo like `ture` silently means `False` | `env.bool("DEBUG", default=False)`, and `ture` is an error |
39
+ | List | `[h.strip() for h in os.environ.get("HOSTS", "").split(",") if h.strip()]` | `env.list("HOSTS")` |
40
+ | List of numbers | the same, plus `int()` on every item | `env.list("PORTS", of=int)` |
41
+ | One of several values | `if mode not in ("dev", "prod"): raise ...` | `env.choice("MODE", ["dev", "prod"])` |
42
+ | Secrets in errors | the value ends up in your logs | `secret=True` shows `***` |
43
+ | `.env` file | `pip install python-dotenv` + `load_dotenv()` | `env.read_dotenv()`, nothing to install |
44
+ | Types for mypy / IDE | `str \| None`, cast it yourself | `env.int` returns `int` |
45
+ | Several broken variables | crash, fix, redeploy, crash on the next one | `env.collect()` lists them all at once |
46
+
47
+ One line per variable, and every error names the variable and says what is wrong with it.
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install tidyenv
53
+ ```
54
+
55
+ Python 3.9+.
56
+
57
+ ## Usage
58
+
59
+ | Reader | Example value | Returns |
60
+ | --- | --- | --- |
61
+ | `env.str(name)` | `hello` | `str` (stripped) |
62
+ | `env.int(name)` | `8000`, `1_000` | `int` |
63
+ | `env.float(name)` | `0.25` | `float` (`nan` and `inf` are rejected) |
64
+ | `env.bool(name)` | `true/false`, `yes/no`, `on/off`, `1/0`, any case | `bool` |
65
+ | `env.list(name, sep=",", of=str)` | `a, b, c` | `list` (use `of=int` to convert items; `int`, `float` and `bool` follow the rules above) |
66
+ | `env.choice(name, choices)` | `prod` | `str` that must be in `choices` |
67
+ | `env.path(name, must_exist=False)` | `~/data` | `pathlib.Path` with `~` expanded |
68
+ | `env.json(name)` | `{"a": 1}` | parsed JSON |
69
+
70
+ Every reader takes an optional `default`. Without one, a missing or empty variable is
71
+ an error. With one, the default is returned instead, and type checkers know the result
72
+ is `int | <type of default>`.
73
+
74
+ ### `.env` files
75
+
76
+ No need for `python-dotenv`:
77
+
78
+ ```python
79
+ env.read_dotenv() # reads ./.env if it exists
80
+ env.read_dotenv("config/dev.env", required=True)
81
+ ```
82
+
83
+ Real environment variables always win over the file, and nothing is written to
84
+ `os.environ`. When you read several files, later ones override earlier ones.
85
+ Supported syntax: `KEY=value`, `export KEY=value`, `# comments`,
86
+ `'single quotes'` (literal) and `"double quotes"` (with `\n`, `\t`, `\"` escapes).
87
+ Each value must fit on one line; for a multi-line value such as a PEM key, write
88
+ `\n` inside double quotes.
89
+ Need just the parser? `tidyenv.parse_dotenv(text)` returns a `dict`.
90
+
91
+ ### All errors at once (optional)
92
+
93
+ By default the first bad variable raises `EnvError`. If you'd rather see every
94
+ problem in one go, wrap your reads in `env.collect()`:
95
+
96
+ ```python
97
+ with env.collect():
98
+ PORT = env.int("PORT", default=8000)
99
+ DATABASE_URL = env.str("DATABASE_URL")
100
+ API_KEY = env.str("API_KEY", secret=True)
101
+ ```
102
+
103
+ ```text
104
+ tidyenv.EnvError: 3 environment problems:
105
+ - PORT: expected an integer (got 'eighty')
106
+ - DATABASE_URL: is not set
107
+ - API_KEY: is not set
108
+ ```
109
+
110
+ Failed reads return `None` inside the block, so only use the values after it exits.
111
+
112
+ ### More
113
+
114
+ **Secrets.** Pass `secret=True` and the raw value is shown as `***` in error messages,
115
+ including errors about single `env.list` items.
116
+
117
+ **Prefixes and custom sources.** Build your own reader:
118
+
119
+ ```python
120
+ from tidyenv import Env
121
+
122
+ env = Env(prefix="MYAPP_") # reads MYAPP_PORT for env.int("PORT")
123
+ test_env = Env(environ={"PORT": "1"}) # any mapping, handy in tests
124
+ ```
125
+
126
+ **Handling errors.** `EnvError.problems` is a list of `Problem(name, message)`, so you
127
+ can print them your own way.
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,57 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "tidyenv"
7
+ dynamic = ["version"]
8
+ description = "Typed environment variables with friendly errors and built-in .env support."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Elena Raikova", email = "lenabarretta@gmail.com" }]
13
+ requires-python = ">=3.9"
14
+ dependencies = []
15
+ keywords = ["environment", "env", "config", "settings", "12-factor"]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.9",
23
+ "Programming Language :: Python :: 3.10",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
28
+ "Typing :: Typed",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/LenaBarretta/tidyenv"
33
+ Issues = "https://github.com/LenaBarretta/tidyenv/issues"
34
+
35
+ [dependency-groups]
36
+ dev = ["pytest>=8", "pytest-cov>=5", "ruff>=0.6", "mypy>=1.11"]
37
+
38
+ [tool.hatch.build.targets.sdist]
39
+ exclude = ["docs"]
40
+
41
+ [tool.hatch.version]
42
+ path = "src/tidyenv/__init__.py"
43
+
44
+ [tool.pytest.ini_options]
45
+ addopts = "-q --cov=tidyenv --cov-report=term-missing --cov-fail-under=100"
46
+ testpaths = ["tests"]
47
+
48
+ [tool.ruff]
49
+ line-length = 100
50
+ target-version = "py39"
51
+
52
+ [tool.ruff.lint]
53
+ select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
54
+
55
+ [tool.mypy]
56
+ strict = true
57
+ files = ["src", "tests"]
@@ -0,0 +1,13 @@
1
+ """Typed environment variables with friendly errors and built-in .env support."""
2
+
3
+ from tidyenv._dotenv import parse_dotenv
4
+ from tidyenv._env import Env, EnvError, Problem
5
+
6
+ __all__ = ["Env", "EnvError", "Problem", "env", "parse_dotenv"]
7
+ __version__ = "0.1.0"
8
+
9
+ # Show errors as tidyenv.EnvError, not tidyenv._env.EnvError.
10
+ for _cls in (Env, EnvError, Problem):
11
+ _cls.__module__ = __name__
12
+
13
+ env = Env()
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+
5
+ # The value keeps its leading whitespace: `A= # note` is an empty value plus a comment.
6
+ _LINE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.]*)\s*=(.*?)\s*$")
7
+ _ESCAPES = {"n": "\n", "r": "\r", "t": "\t", '"': '"', "\\": "\\"}
8
+
9
+
10
+ def parse_dotenv(text: str) -> dict[str, str]:
11
+ """Parse the contents of a ``.env`` file into a dict.
12
+
13
+ Supports ``KEY=value``, ``export KEY=value``, ``#`` comments, blank lines,
14
+ 'single quotes' (taken literally) and "double quotes" (with ``\\n``, ``\\t``,
15
+ ``\\"`` and ``\\\\`` escapes). Values must fit on one line.
16
+ Raises ``ValueError`` naming the bad line.
17
+ """
18
+ values: dict[str, str] = {}
19
+ for lineno, line in enumerate(text.splitlines(), start=1):
20
+ if not line.strip() or line.lstrip().startswith("#"):
21
+ continue
22
+ match = _LINE.match(line)
23
+ if match is None:
24
+ raise ValueError(f"line {lineno}: expected KEY=VALUE")
25
+ key, raw = match.groups()
26
+ try:
27
+ values[key] = _value(raw)
28
+ except ValueError as exc:
29
+ raise ValueError(f"line {lineno}: {exc}") from None
30
+ return values
31
+
32
+
33
+ def _value(raw: str) -> str:
34
+ value = raw.lstrip()
35
+ if value[:1] in ("'", '"'):
36
+ quote = value[0]
37
+ end = _closing_quote(value, quote)
38
+ rest = value[end + 1 :].strip()
39
+ if rest and not rest.startswith("#"):
40
+ raise ValueError(f"unexpected text after closing quote: {rest!r}")
41
+ body = value[1:end]
42
+ if quote == "'":
43
+ return body
44
+ return re.sub(r"\\(.)", lambda m: _ESCAPES.get(m.group(1), m.group(0)), body)
45
+ # Unquoted: an inline comment needs whitespace before the '#'; the space after
46
+ # '=' counts, so `A= # note` is empty while `A=#x` is the literal '#x'.
47
+ return re.split(r"\s+#", raw, maxsplit=1)[0].strip()
48
+
49
+
50
+ def _closing_quote(raw: str, quote: str) -> int:
51
+ i = 1
52
+ while i < len(raw):
53
+ if raw[i] == "\\" and quote == '"':
54
+ i += 2
55
+ continue
56
+ if raw[i] == quote:
57
+ return i
58
+ i += 1
59
+ raise ValueError(f"missing closing {quote}")