tidyenv 0.1.0__py3-none-any.whl

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.
tidyenv/__init__.py ADDED
@@ -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()
tidyenv/_dotenv.py ADDED
@@ -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}")
tidyenv/_env.py ADDED
@@ -0,0 +1,322 @@
1
+ from __future__ import annotations
2
+
3
+ import json as _json
4
+ import math
5
+ import os
6
+ from collections.abc import Callable, Iterator, Mapping, Sequence
7
+ from contextlib import contextmanager
8
+ from contextvars import ContextVar
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, TypeVar, overload
12
+
13
+ from tidyenv._dotenv import parse_dotenv
14
+
15
+ T = TypeVar("T")
16
+ U = TypeVar("U")
17
+
18
+ # Env has methods named str/int/list/...; these aliases keep the builtins reachable.
19
+ _str = str
20
+ _int = int
21
+ _float = float
22
+ _bool = bool
23
+ _list = list
24
+
25
+ _MISSING: Any = object()
26
+ _TRUE = {"1", "true", "yes", "y", "on"}
27
+ _FALSE = {"0", "false", "no", "n", "off"}
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class Problem:
32
+ """One thing that is wrong with the environment."""
33
+
34
+ name: str
35
+ message: str
36
+
37
+ def __str__(self) -> str:
38
+ return f"{self.name}: {self.message}"
39
+
40
+
41
+ class EnvError(Exception):
42
+ """Raised when one or more environment variables are missing or invalid."""
43
+
44
+ def __init__(self, problems: Sequence[Problem]) -> None:
45
+ self.problems = list(problems)
46
+ lines = "\n".join(f" - {p}" for p in self.problems)
47
+ noun = "problem" if len(self.problems) == 1 else "problems"
48
+ super().__init__(f"{len(self.problems)} environment {noun}:\n{lines}")
49
+
50
+
51
+ class Env:
52
+ """Reads typed values from the environment.
53
+
54
+ Each reader raises :class:`EnvError` right away, unless it runs inside
55
+ :meth:`collect`, which gathers every problem and raises once at the end.
56
+ """
57
+
58
+ def __init__(self, environ: Mapping[_str, _str] | None = None, prefix: _str = "") -> None:
59
+ self._environ = environ
60
+ self.prefix = prefix
61
+ self._dotenv: dict[_str, _str] = {}
62
+ # Per thread / async task, so a collect() in one never swallows another's errors.
63
+ self._pending: ContextVar[_list[Problem] | None] = ContextVar(
64
+ "tidyenv_pending", default=None
65
+ )
66
+
67
+ @property
68
+ def environ(self) -> Mapping[_str, _str]:
69
+ return os.environ if self._environ is None else self._environ
70
+
71
+ def read_dotenv(
72
+ self, path: _str | os.PathLike[_str] = ".env", *, required: _bool = False
73
+ ) -> Env:
74
+ """Use values from a ``.env`` file for variables the environment doesn't set.
75
+
76
+ Real environment variables always win. A missing file is skipped unless
77
+ ``required=True``. When several files are read, later ones override
78
+ earlier ones. Nothing is written to ``os.environ``.
79
+ Returns ``self``, so ``env = Env().read_dotenv()`` works.
80
+ """
81
+ file = Path(path)
82
+ if not file.exists():
83
+ if required:
84
+ raise EnvError([Problem(_str(file), "file not found")])
85
+ return self
86
+ if not file.is_file():
87
+ raise EnvError([Problem(_str(file), "not a file")])
88
+ try:
89
+ # utf-8-sig drops the BOM that some Windows editors add.
90
+ text = file.read_text(encoding="utf-8-sig")
91
+ except UnicodeDecodeError:
92
+ raise EnvError([Problem(_str(file), "not valid UTF-8")]) from None
93
+ except OSError as exc:
94
+ raise EnvError([Problem(_str(file), exc.strerror or _str(exc))]) from None
95
+ try:
96
+ self._dotenv.update(parse_dotenv(text))
97
+ except ValueError as exc:
98
+ raise EnvError([Problem(_str(file), _str(exc))]) from None
99
+ return self
100
+
101
+ @contextmanager
102
+ def collect(self) -> Iterator[Env]:
103
+ """Gather problems from every read inside the block, then raise them together.
104
+
105
+ Failed reads return ``None`` inside the block, so don't use those
106
+ values until the block has exited cleanly.
107
+ """
108
+ token = self._pending.set([])
109
+ try:
110
+ yield self
111
+ problems = self._pending.get()
112
+ finally:
113
+ self._pending.reset(token)
114
+ if problems:
115
+ raise EnvError(problems)
116
+
117
+ # -- readers ---------------------------------------------------------
118
+ # Each reader returns its type, or the type of `default` when one is given.
119
+
120
+ @overload
121
+ def str(self, name: _str, *, secret: _bool = ...) -> _str: ...
122
+ @overload
123
+ def str(self, name: _str, default: T, *, secret: _bool = ...) -> _str | T: ...
124
+ def str(self, name: _str, default: Any = _MISSING, *, secret: _bool = False) -> Any:
125
+ """Read a string. Values are stripped; an empty value counts as missing."""
126
+ return self._read(name, default, lambda raw: raw, secret=secret)
127
+
128
+ @overload
129
+ def int(self, name: _str, *, secret: _bool = ...) -> _int: ...
130
+ @overload
131
+ def int(self, name: _str, default: T, *, secret: _bool = ...) -> _int | T: ...
132
+ def int(self, name: _str, default: Any = _MISSING, *, secret: _bool = False) -> Any:
133
+ """Read an integer, e.g. ``8000`` or ``1_000``."""
134
+ return self._read(name, default, _parse_int, secret=secret)
135
+
136
+ @overload
137
+ def float(self, name: _str, *, secret: _bool = ...) -> _float: ...
138
+ @overload
139
+ def float(self, name: _str, default: T, *, secret: _bool = ...) -> _float | T: ...
140
+ def float(self, name: _str, default: Any = _MISSING, *, secret: _bool = False) -> Any:
141
+ """Read a number, e.g. ``0.25``."""
142
+ return self._read(name, default, _parse_float, secret=secret)
143
+
144
+ @overload
145
+ def bool(self, name: _str) -> _bool: ...
146
+ @overload
147
+ def bool(self, name: _str, default: T) -> _bool | T: ...
148
+ def bool(self, name: _str, default: Any = _MISSING) -> Any:
149
+ """Read a boolean: 1/true/yes/y/on or 0/false/no/n/off, in any case."""
150
+ return self._read(name, default, _parse_bool)
151
+
152
+ @overload
153
+ def list(self, name: _str, *, sep: _str = ..., secret: _bool = ...) -> _list[_str]: ...
154
+ @overload
155
+ def list(
156
+ self, name: _str, default: T, *, sep: _str = ..., secret: _bool = ...
157
+ ) -> _list[_str] | T: ...
158
+ @overload
159
+ def list(
160
+ self, name: _str, *, sep: _str = ..., of: Callable[[_str], U], secret: _bool = ...
161
+ ) -> _list[U]: ...
162
+ @overload
163
+ def list(
164
+ self,
165
+ name: _str,
166
+ default: T,
167
+ *,
168
+ sep: _str = ...,
169
+ of: Callable[[_str], U],
170
+ secret: _bool = ...,
171
+ ) -> _list[U] | T: ...
172
+ def list(
173
+ self,
174
+ name: _str,
175
+ default: Any = _MISSING,
176
+ *,
177
+ sep: _str = ",",
178
+ of: Callable[[_str], Any] = _str,
179
+ secret: _bool = False,
180
+ ) -> Any:
181
+ """Read a separated list, e.g. ``a, b, c``. Items are stripped; blanks dropped.
182
+
183
+ ``of`` converts each item, e.g. ``of=int``. ``int``, ``float`` and ``bool``
184
+ use the same rules as the matching readers, so ``of=bool`` understands ``no``.
185
+ """
186
+ known = of in _ITEM_PARSERS
187
+ convert = _ITEM_PARSERS.get(of, of)
188
+
189
+ def parse(raw: _str) -> _list[Any]:
190
+ items = [item.strip() for item in raw.split(sep)]
191
+ values = []
192
+ for number, item in enumerate((item for item in items if item), start=1):
193
+ try:
194
+ values.append(convert(item))
195
+ except Exception as exc:
196
+ label = f"item {number}" if secret else f"item {number} ({item!r})"
197
+ raise ValueError(f"{label}: {_item_error(exc, known, secret)}") from None
198
+ return values
199
+
200
+ return self._read(name, default, parse, secret=secret)
201
+
202
+ @overload
203
+ def choice(self, name: _str, choices: Sequence[_str]) -> _str: ...
204
+ @overload
205
+ def choice(self, name: _str, choices: Sequence[_str], default: T) -> _str | T: ...
206
+ def choice(self, name: _str, choices: Sequence[_str], default: Any = _MISSING) -> Any:
207
+ """Read a string that must be one of ``choices``."""
208
+
209
+ def parse(raw: _str) -> _str:
210
+ if raw not in choices:
211
+ raise ValueError(f"expected one of {', '.join(choices)}")
212
+ return raw
213
+
214
+ return self._read(name, default, parse)
215
+
216
+ @overload
217
+ def path(self, name: _str, *, must_exist: _bool = ...) -> Path: ...
218
+ @overload
219
+ def path(self, name: _str, default: T, *, must_exist: _bool = ...) -> Path | T: ...
220
+ def path(self, name: _str, default: Any = _MISSING, *, must_exist: _bool = False) -> Any:
221
+ """Read a filesystem path, with ``~`` expanded."""
222
+
223
+ def parse(raw: _str) -> Path:
224
+ p = Path(raw).expanduser()
225
+ if must_exist and not p.exists():
226
+ raise ValueError("path does not exist")
227
+ return p
228
+
229
+ return self._read(name, default, parse)
230
+
231
+ def json(self, name: _str, default: Any = _MISSING, *, secret: _bool = False) -> Any:
232
+ """Read a JSON value, e.g. ``{"a": 1}``."""
233
+ return self._read(name, default, _parse_json, secret=secret)
234
+
235
+ # -- internals -------------------------------------------------------
236
+
237
+ def _read(
238
+ self,
239
+ name: _str,
240
+ default: Any,
241
+ parse: Callable[[_str], Any],
242
+ *,
243
+ secret: _bool = False,
244
+ ) -> Any:
245
+ key = self.prefix + name
246
+ raw = self.environ.get(key)
247
+ if raw is None:
248
+ raw = self._dotenv.get(key)
249
+ if raw is None or raw.strip() == "":
250
+ if default is not _MISSING:
251
+ return default
252
+ return self._fail(key, "is not set")
253
+ try:
254
+ return parse(raw.strip())
255
+ except (ValueError, TypeError) as exc:
256
+ shown = "***" if secret else repr(raw)
257
+ return self._fail(key, f"{exc} (got {shown})")
258
+
259
+ def _fail(self, key: _str, message: _str) -> None:
260
+ problem = Problem(key, message)
261
+ pending = self._pending.get()
262
+ if pending is None:
263
+ raise EnvError([problem])
264
+ pending.append(problem)
265
+ return None
266
+
267
+
268
+ def _parse_int(raw: str) -> int:
269
+ try:
270
+ return int(raw) # int() already accepts 1_000 but rejects 1__0 and _1
271
+ except ValueError:
272
+ raise ValueError("expected an integer") from None
273
+
274
+
275
+ def _parse_float(raw: str) -> float:
276
+ try:
277
+ value = float(raw)
278
+ except ValueError:
279
+ raise ValueError("expected a number") from None
280
+ if not math.isfinite(value):
281
+ raise ValueError("expected a finite number")
282
+ return value
283
+
284
+
285
+ def _parse_bool(raw: str) -> bool:
286
+ value = raw.lower()
287
+ if value in _TRUE:
288
+ return True
289
+ if value in _FALSE:
290
+ return False
291
+ raise ValueError("expected true/false, yes/no, on/off or 1/0")
292
+
293
+
294
+ def _parse_json(raw: str) -> Any:
295
+ try:
296
+ return _json.loads(raw)
297
+ except _json.JSONDecodeError as exc:
298
+ raise ValueError(f"invalid JSON: {exc.msg}") from None
299
+
300
+
301
+ def _parse_str(raw: str) -> str:
302
+ return raw
303
+
304
+
305
+ # Converters for env.list(of=...) that get the readers' rules and friendly messages.
306
+ _ITEM_PARSERS: dict[Callable[[str], Any], Callable[[str], Any]] = {
307
+ str: _parse_str,
308
+ int: _parse_int,
309
+ float: _parse_float,
310
+ bool: _parse_bool,
311
+ }
312
+
313
+
314
+ def _item_error(exc: Exception, known: bool, secret: bool) -> str:
315
+ """Describe why ``of`` rejected a list item without leaking a secret value."""
316
+ if known:
317
+ return str(exc) # our own messages never contain the value
318
+ if secret:
319
+ return "invalid value"
320
+ if isinstance(exc, (ValueError, TypeError)):
321
+ return str(exc)
322
+ return f"{type(exc).__name__}: {exc}"
tidyenv/py.typed ADDED
File without changes
@@ -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,8 @@
1
+ tidyenv/__init__.py,sha256=1EGuoTp0hMJdIZv9bllA7jzphFl0JiU9-kq_sfRn7Mc,404
2
+ tidyenv/_dotenv.py,sha256=qqOUfLt4BX8OQ5HMi3UiiioSBKaSkl-usmnMfCCZd9c,2158
3
+ tidyenv/_env.py,sha256=oeGQKjrADXefMhuxQTt3RgO4jXFoFwCdcOLTOoDxBUI,11256
4
+ tidyenv/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ tidyenv-0.1.0.dist-info/METADATA,sha256=G-fp19rTU9Ah-84idpOV-e3Uk7wIKwzi_n-J_3HkrLs,6192
6
+ tidyenv-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ tidyenv-0.1.0.dist-info/licenses/LICENSE,sha256=KHfqCKAIyFnUFdMWTerpafhQcS240iaDuPMq3w_J67M,1070
8
+ tidyenv-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.