envread 0.2.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.
envread/_specfile.py ADDED
@@ -0,0 +1,78 @@
1
+ """Read the plain-text spec format used by the ``envread check`` command.
2
+
3
+ One variable per line: ``NAME TYPE [optional] [secret]``. Blank lines and lines
4
+ starting with ``#`` are ignored. TYPE is one of ``str int float bool decimal path
5
+ duration json secret``, ``list`` or ``list:SEP`` (items split on SEP, comma by
6
+ default), or ``choice:A,B,C`` (exactly one of the listed words).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from ._check import VarSpec
14
+ from ._parsers import parse_list
15
+ from ._types import NAMED, Parser, choice_parser, parse_secret
16
+
17
+ TYPE_HELP = "str, int, float, bool, decimal, path, duration, json, list[:SEP], choice:A,B"
18
+ _FLAGS = frozenset({"optional", "secret"})
19
+
20
+
21
+ class SpecError(ValueError):
22
+ """A spec line or a ``--type`` value is malformed."""
23
+
24
+
25
+ def parse_type(token: str) -> tuple[Parser, str]:
26
+ """Return the parser and display name for a type token such as ``list:;``."""
27
+ kind, _, argument = token.partition(":")
28
+ if kind == "list":
29
+ separator = argument or ","
30
+ return (lambda name, raw: parse_list(name, raw, separator)), "list"
31
+ if kind == "choice":
32
+ choices = tuple(word for word in argument.split(",") if word)
33
+ if not choices:
34
+ raise SpecError("choice needs at least one word, for example choice:dev,prod")
35
+ return choice_parser(choices), "choice"
36
+ if not argument and kind in NAMED:
37
+ return NAMED[kind], kind
38
+ raise SpecError(f"unknown type {token!r}; expected one of {TYPE_HELP}")
39
+
40
+
41
+ def parse_spec(text: str, source: str = "spec") -> list[VarSpec]:
42
+ """Turn spec text into variable specs, raising SpecError naming the bad line."""
43
+ specs: list[VarSpec] = []
44
+ for number, line in enumerate(text.splitlines(), start=1):
45
+ words = line.split()
46
+ if not words or words[0].startswith("#"):
47
+ continue
48
+ try:
49
+ specs.append(_line(words))
50
+ except SpecError as exc:
51
+ raise SpecError(f"{source}:{number}: {exc}") from None
52
+ return specs
53
+
54
+
55
+ def _line(words: list[str]) -> VarSpec:
56
+ if len(words) < 2:
57
+ raise SpecError("expected NAME TYPE [optional] [secret]")
58
+ name, token, *flags = words
59
+ unknown = [flag for flag in flags if flag not in _FLAGS]
60
+ if unknown:
61
+ raise SpecError(f"unknown flag {unknown[0]!r}; expected optional or secret")
62
+ parser: Parser
63
+ if token == "secret":
64
+ parser, type_name = parse_secret, "secret"
65
+ else:
66
+ parser, type_name = parse_type(token)
67
+ return VarSpec(
68
+ name=name,
69
+ type_name=type_name,
70
+ parser=parser,
71
+ required="optional" not in flags,
72
+ secret="secret" in flags or token == "secret",
73
+ attr=name,
74
+ )
75
+
76
+
77
+ def read_spec(path: str) -> list[VarSpec]:
78
+ return parse_spec(Path(path).read_text(encoding="utf-8-sig"), path)
envread/_types.py ADDED
@@ -0,0 +1,110 @@
1
+ """Map a type annotation, or a type name from a spec file, to a parser."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import types
6
+ from collections.abc import Callable
7
+ from datetime import timedelta
8
+ from decimal import Decimal
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from typing import Any, Literal, Union, get_args, get_origin
12
+
13
+ from ._duration import parse_duration
14
+ from ._errors import InvalidEnvError
15
+ from ._parsers import (
16
+ parse_bool,
17
+ parse_choice,
18
+ parse_decimal,
19
+ parse_enum,
20
+ parse_float,
21
+ parse_int,
22
+ parse_json,
23
+ parse_path,
24
+ parse_str,
25
+ split_list,
26
+ )
27
+ from ._secret import Secret
28
+
29
+ Parser = Callable[[str, str], Any]
30
+
31
+ SCALARS: dict[Any, tuple[Parser, str]] = {
32
+ str: (parse_str, "str"),
33
+ int: (parse_int, "int"),
34
+ float: (parse_float, "float"),
35
+ bool: (parse_bool, "bool"),
36
+ Decimal: (parse_decimal, "decimal"),
37
+ Path: (parse_path, "path"),
38
+ timedelta: (parse_duration, "duration"),
39
+ }
40
+
41
+ NAMED: dict[str, Parser] = {name: parser for parser, name in SCALARS.values()}
42
+ NAMED["json"] = parse_json
43
+
44
+
45
+ def parse_secret(name: str, raw: str) -> Secret:
46
+ return Secret(raw)
47
+
48
+
49
+ def split_parser(item: Parser, separator: str, kind: Callable[[list[Any]], Any]) -> Parser:
50
+ """Build a parser that splits on ``separator`` and parses every item."""
51
+
52
+ def parse(name: str, raw: str) -> Any:
53
+ values = []
54
+ for index, part in enumerate(split_list(raw, separator), start=1):
55
+ try:
56
+ values.append(item(name, part))
57
+ except InvalidEnvError as exc:
58
+ # The reason must never quote the item: masking keeps the reason.
59
+ why = f"item {index} is not a valid {exc.type_name}"
60
+ why += f" ({exc.reason})" if exc.reason else ""
61
+ raise InvalidEnvError(name, raw, "list", why) from None
62
+ return kind(values)
63
+
64
+ return parse
65
+
66
+
67
+ def choice_parser(choices: tuple[str, ...]) -> Parser:
68
+ return lambda name, raw: parse_choice(name, raw, choices)
69
+
70
+
71
+ def _json_object(name: str, raw: str) -> dict[str, Any]:
72
+ value = parse_json(name, raw)
73
+ if not isinstance(value, dict):
74
+ raise InvalidEnvError(name, raw, "JSON object", "expected a JSON object")
75
+ return value
76
+
77
+
78
+ def unwrap_optional(annotation: Any) -> tuple[Any, bool]:
79
+ """Return ``(inner, True)`` for ``X | None`` / ``Optional[X]``, else ``(annotation, False)``."""
80
+ if get_origin(annotation) in (Union, types.UnionType):
81
+ args = [arg for arg in get_args(annotation) if arg is not type(None)]
82
+ if len(args) == 1 and len(args) < len(get_args(annotation)):
83
+ return args[0], True
84
+ return annotation, False
85
+
86
+
87
+ def resolve(annotation: Any, separator: str) -> tuple[Parser, str]:
88
+ """Return the parser and a readable type name for a field annotation.
89
+
90
+ Raises TypeError for an annotation this package cannot read from a string.
91
+ """
92
+ if annotation in SCALARS:
93
+ return SCALARS[annotation]
94
+ if annotation is Secret:
95
+ return parse_secret, "secret"
96
+ if isinstance(annotation, type) and issubclass(annotation, Enum):
97
+ enum_type = annotation
98
+ return (lambda name, raw: parse_enum(name, raw, enum_type)), enum_type.__name__
99
+ origin, args = get_origin(annotation), get_args(annotation)
100
+ if origin is Literal and args and all(isinstance(arg, str) for arg in args):
101
+ return choice_parser(tuple(args)), "choice"
102
+ if origin is dict or annotation is dict:
103
+ return _json_object, "JSON object"
104
+ if origin in (list, tuple) and args:
105
+ homogeneous = origin is list or (len(args) == 2 and args[1] is Ellipsis)
106
+ if homogeneous and args[0] in SCALARS:
107
+ item, item_name = SCALARS[args[0]]
108
+ kind: Callable[[list[Any]], Any] = list if origin is list else tuple
109
+ return split_parser(item, separator, kind), f"{origin.__name__}[{item_name}]"
110
+ raise TypeError(f"envread cannot read a field annotated {annotation!r} from a string")
envread/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the package version."""
2
+
3
+ __version__ = "0.2.0"
envread/py.typed ADDED
File without changes
@@ -0,0 +1,269 @@
1
+ Metadata-Version: 2.5
2
+ Name: envread
3
+ Version: 0.2.0
4
+ Summary: Typed reader for environment variables: inferred return types, settings dataclasses, .env files and a checker CLI, zero dependencies
5
+ Project-URL: Homepage, https://lianes8studio.com
6
+ Project-URL: Repository, https://github.com/JaviLianes8/typedenv
7
+ Project-URL: Issues, https://github.com/JaviLianes8/typedenv/issues
8
+ Project-URL: Changelog, https://github.com/JaviLianes8/typedenv/blob/main/CHANGELOG.md
9
+ Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
10
+ Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
11
+ Author-email: Javier Lianes <javier@lianes8studio.com>
12
+ License-Expression: MIT
13
+ License-File: LICENSE
14
+ Keywords: 12factor,config,dotenv,env,environment,environment-variables,settings,typed
15
+ Classifier: Development Status :: 4 - Beta
16
+ Classifier: Environment :: Console
17
+ Classifier: Intended Audience :: Developers
18
+ Classifier: Operating System :: OS Independent
19
+ Classifier: Programming Language :: Python :: 3
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Programming Language :: Python :: 3.10
22
+ Classifier: Programming Language :: Python :: 3.11
23
+ Classifier: Programming Language :: Python :: 3.12
24
+ Classifier: Programming Language :: Python :: 3.13
25
+ Classifier: Topic :: Software Development :: Libraries
26
+ Classifier: Topic :: System :: Systems Administration
27
+ Classifier: Typing :: Typed
28
+ Requires-Python: >=3.10
29
+ Provides-Extra: dev
30
+ Requires-Dist: build; extra == 'dev'
31
+ Requires-Dist: mypy; extra == 'dev'
32
+ Requires-Dist: pytest; extra == 'dev'
33
+ Requires-Dist: python-dotenv; extra == 'dev'
34
+ Requires-Dist: ruff; extra == 'dev'
35
+ Requires-Dist: twine; extra == 'dev'
36
+ Requires-Dist: typing-extensions; extra == 'dev'
37
+ Description-Content-Type: text/markdown
38
+
39
+ # envread
40
+
41
+ Typed reader for environment variables with mypy-inferred return types, settings
42
+ dataclasses, `.env` files and a checker CLI. Zero dependencies.
43
+
44
+ ## Install
45
+
46
+ ```
47
+ pip install envread
48
+ ```
49
+
50
+ ## Read one variable
51
+
52
+ ```python
53
+ from envread import env
54
+
55
+ database_url = env.str("DATABASE_URL") # str, raises MissingEnvError if unset
56
+ port = env.int("PORT", 8080) # int, the default when unset
57
+ workers = env.int("WORKERS", None) # int | None
58
+ debug = env.bool("DEBUG", False) # true/false, yes/no, on/off, 1/0, ...
59
+ hosts = env.list("ALLOWED_HOSTS") # split on "," and trimmed
60
+ timeout = env.duration("TIMEOUT", None) # "90", "250ms", "1h30m" -> timedelta
61
+ api_key = env.secret("API_KEY") # Secret: prints as ***
62
+ ```
63
+
64
+ Every getter takes the same arguments and the static type follows the call:
65
+
66
+ | call | type | when unset |
67
+ | --- | --- | --- |
68
+ | `env.int("X")` | `int` | raises `MissingEnvError` |
69
+ | `env.int("X", required=True)` | `int` | raises `MissingEnvError` |
70
+ | `env.int("X", 8080)` | `int` | `8080` |
71
+ | `env.int("X", None)` | `int \| None` | `None` |
72
+ | `env.int("X", required=False)` | `int \| None` | `None` |
73
+
74
+ | getter | returns | accepts |
75
+ | --- | --- | --- |
76
+ | `str` | `str` | anything, returned as is (`""` stays `""`) |
77
+ | `int` | `int` | base-10 integer, surrounding whitespace ignored |
78
+ | `float` | `float` | anything `float()` accepts |
79
+ | `bool` | `bool` | `true/false`, `yes/no`, `on/off`, `1/0`, `y/n`, `t/f`, `enable(d)/disable(d)`, any case |
80
+ | `decimal` | `Decimal` | exact decimal; `NaN` and infinities are rejected |
81
+ | `path` | `Path` | non-empty path, taken literally (no `~` expansion) |
82
+ | `duration` | `timedelta` | seconds (`90`, `1.5`) or units `w d h m s ms` from largest to smallest (`1h30m`) |
83
+ | `enum` | your `Enum` | member name in any case, or the member value as text |
84
+ | `list` | `list[str]` | split on `separator=` (default `,`), items trimmed, `""` is `[]` |
85
+ | `json` | `Any` | JSON, parsed by the standard library |
86
+ | `secret` | `Secret` | anything, wrapped so it never prints |
87
+
88
+ The boolean words match the default vocabulary of
89
+ [boolish](https://pypi.org/project/boolish/), without depending on it.
90
+
91
+ ## Load a settings dataclass
92
+
93
+ ```python
94
+ from dataclasses import dataclass, field
95
+ from datetime import timedelta
96
+ from typing import Literal
97
+
98
+ from envread import Env, Secret, load, var
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class Settings:
103
+ database_url: Secret # DATABASE_URL, required
104
+ port: int = 8080 # PORT
105
+ workers: int | None = None # WORKERS, optional
106
+ hosts: list[str] = field(default_factory=list)
107
+ timeout: timedelta = timedelta(seconds=5)
108
+ mode: Literal["dev", "prod"] = "dev"
109
+ token: str = var("SERVICE_TOKEN", default="", secret=True)
110
+ ports: tuple[int, ...] = var(default=(), separator=";")
111
+
112
+
113
+ settings = load(Settings) # from os.environ
114
+ settings = load(Settings, Env(prefix="APP_")) # APP_DATABASE_URL, APP_PORT, ...
115
+ ```
116
+
117
+ There is no base class and no metaclass: `load` reads `dataclasses.fields()` and the
118
+ annotations. The variable name is the field name upper-cased, or `var(name=...)`. A
119
+ field with a default, a `default_factory` or an `X | None` annotation is optional.
120
+ Supported annotations: `str`, `int`, `float`, `bool`, `Decimal`, `Path`, `timedelta`,
121
+ `Secret`, any `Enum`, `Literal["a", "b"]`, `dict[...]` (a JSON object), and `list[X]` or
122
+ `tuple[X, ...]` of a scalar type. Anything else raises `TypeError` when `load` runs.
123
+
124
+ `load` reads every field before raising, so one run reports every problem:
125
+
126
+ ```
127
+ EnvValidationError: 3 environment variables failed validation:
128
+ - required environment variable 'DATABASE_URL' is not set
129
+ - environment variable 'PORT' is not a valid integer: 'eighty'
130
+ - environment variable 'MODE' is not a valid choice: 'staging': expected one of dev, prod
131
+ ```
132
+
133
+ The individual errors are in `error.errors`. If you lint with ruff, add
134
+ `extend-immutable-calls = ["envread.var"]` under `[tool.ruff.lint.flake8-bugbear]`
135
+ so `var()` is treated like `field()`.
136
+
137
+ ## Secrets
138
+
139
+ `secret=True` on any getter, `var(secret=True)`, or a `Secret` annotation keeps the
140
+ value out of error messages, the error's `value` attribute (`"***"`) and the exception
141
+ chain, so even an uncaught traceback does not show it. A `Secret` prints as `***` in
142
+ `str`, `repr`, f-strings and dataclass reprs; call `.reveal()` where the real value is
143
+ needed. It compares in constant time and refuses to be pickled. `secret=True` alone only
144
+ protects errors; annotate the field as `Secret` to keep it out of the dataclass repr too.
145
+
146
+ ## Prefixes, empty values and presence
147
+
148
+ ```python
149
+ app = Env(prefix="APP_") # app.int("PORT") reads APP_PORT
150
+ db = app.prefixed("DB_") # db.str("HOST") reads APP_DB_HOST
151
+ strict = Env(empty_is_unset=True) # KEY= counts as unset
152
+ env.raw("PORT") # the unparsed string, or None
153
+ env.is_set("PORT"), "PORT" in env # presence
154
+ cfg = Env({"PORT": "9090"}) # any mapping instead of os.environ
155
+ ```
156
+
157
+ ## Platform facts
158
+
159
+ - **Names**: `os.environ` is case-insensitive on Windows (keys are stored upper-case)
160
+ and case-sensitive on POSIX, so `env.str("path")` finds `PATH` on Windows only. A
161
+ plain `dict` source is always exact.
162
+ - **Values** are always strings; there is no `None` in the environment. A variable can
163
+ be set to `""`, which is different from unset unless you pass `empty_is_unset=True`.
164
+ - **Length**: on Windows `os.environ` refuses an entry whose `NAME=value` is longer
165
+ than 32,767 characters, although a child process can receive longer values through
166
+ `subprocess` `env=`. `load_dotenv` checks this up front and raises `DotenvError`.
167
+
168
+ ## `.env` files
169
+
170
+ ```python
171
+ from envread import load_dotenv, parse_dotenv, read_dotenv
172
+
173
+ load_dotenv(".env") # set what is not already set; returns what it set
174
+ load_dotenv(".env", override=True) # the file wins
175
+ values = read_dotenv(".env") # dict, environment untouched
176
+ ```
177
+
178
+ The supported syntax is a subset of python-dotenv 1.x, tested line by line against
179
+ python-dotenv itself:
180
+
181
+ | syntax | result |
182
+ | --- | --- |
183
+ | blank lines, `# comment` lines | ignored |
184
+ | `export KEY=value`, `KEY = value` | `export` and spaces around `=` are allowed |
185
+ | `KEY=plain value # note` | `plain value` (an inline `#` needs a space before it) |
186
+ | `KEY='literal \n $X'` | taken literally; only `\'` and `\\` are escapes |
187
+ | `KEY="a\tb ${X}"` | escapes `\\ \' \" \a \b \f \n \r \t \v`, then expansion |
188
+ | `${NAME}`, `${NAME:-default}` | earlier keys, then the environment; unknown is `""` |
189
+ | `KEY` (no `=`) | skipped |
190
+ | `KEY=` | `""` |
191
+
192
+ Expansion order follows python-dotenv: `read_dotenv` and `parse_dotenv` prefer earlier
193
+ keys in the file, `load_dotenv` prefers the existing environment unless `override=True`.
194
+ The default in `${NAME:-default}` applies only when `NAME` is unset. Unquoted `$NAME`
195
+ without braces is literal.
196
+
197
+ Anything else raises `DotenvError` with the file and line, never the value:
198
+ multi-line quoted values, other `${...}` forms (`${X-d}`, `${X:?err}`, nesting), text
199
+ after a closing quote, and `${` inside single quotes (python-dotenv expands it, docker
200
+ compose keeps it literal). Docker compose `env_file` also expands unbraced `$NAME`
201
+ and treats `${NAME:-default}` as "unset or empty", so files that rely on those
202
+ compose-only behaviours read differently there.
203
+
204
+ ## Command line
205
+
206
+ ```
207
+ envread check env.spec validate every variable in a spec file
208
+ envread check myapp.settings:Settings validate the fields of a dataclass
209
+ envread check env.spec --env-file .env layer a .env file under the environment
210
+ envread check env.spec --format json machine-readable report
211
+ envread get PORT --type int --default 8080 print one parsed value
212
+ envread get DEBUG --type bool prints true or false
213
+ ```
214
+
215
+ A spec file lists one variable per line as `NAME TYPE [optional] [secret]`, with types
216
+ `str int float bool decimal path duration json secret`, `list[:SEP]` and
217
+ `choice:A,B,C`:
218
+
219
+ ```
220
+ # deployment contract
221
+ DATABASE_URL secret
222
+ PORT int optional
223
+ LOG_LEVEL choice:debug,info,warning
224
+ HOSTS list:;
225
+ ```
226
+
227
+ ```
228
+ $ envread check env.spec
229
+ ok DATABASE_URL secret
230
+ invalid PORT int, optional: not a valid integer: 'eighty'
231
+ missing LOG_LEVEL choice: not set
232
+ ok HOSTS list
233
+ envread: 2 of 4 variables failed
234
+ ```
235
+
236
+ `check` exits 0 when every variable is valid and 1 otherwise, so it gates a deploy
237
+ script directly. Values are printed only with `--show-values`, and secrets never are.
238
+ `--prefix`, `--env-file` and `--empty-is-unset` work on both commands; `--quiet` gives
239
+ an exit code only. Errors go to stderr with exit code 1. `python -m envread` works the same
240
+ as the `envread` script.
241
+
242
+ ## Errors
243
+
244
+ `MissingEnvError` is also a `KeyError`; `InvalidEnvError`, `EnvValidationError` and
245
+ `DotenvError` are also `ValueError`s; all of them derive from `EnvError`.
246
+
247
+ ## Why
248
+
249
+ Reading config from the environment is a one-liner until you want the value typed, a
250
+ missing variable to fail loudly, every failure reported in one go, secrets kept out of
251
+ logs, and your type checker to agree with all of it. The existing options each give up
252
+ one of those or pull in dependencies: `environs` needs `marshmallow` and
253
+ `python-dotenv`, `environ-config` is built on `attrs`, `pydantic-settings` pulls in
254
+ `pydantic-core`, and `typenv` depends on `python-dotenv` and has not shipped since 2021.
255
+
256
+ `envread` has no runtime dependencies, ships `py.typed`, and uses `@overload` so
257
+ `env.int("X")` is `int` and `env.int("X", None)` is `int | None` with no casts. Reach
258
+ for it when you want typed environment access and a deploy-time check without adding a
259
+ settings framework to the project.
260
+
261
+ ## License
262
+
263
+ MIT. See [LICENSE](LICENSE).
264
+
265
+ ## Links
266
+
267
+ - Website: https://lianes8studio.com
268
+ - GitHub: https://github.com/JaviLianes8
269
+ - LinkedIn: https://www.linkedin.com/in/jlianes/
@@ -0,0 +1,24 @@
1
+ envread/__init__.py,sha256=d3_0iA2PakvMLNMgP9dHTaAbYGdrpX0S7TiYtCPHfKk,683
2
+ envread/__main__.py,sha256=pnI90LA6TY7WQCY_EKn_kWt1tAbMSmCN253VTkiqT0A,129
3
+ envread/_check.py,sha256=Bd5gjZF4OZkrg3W5tvqtZVwC6Z7v5u8C3PpfNXRjviI,2129
4
+ envread/_cli.py,sha256=5pnqJ-0s7a_29earM3Lfk-LBG_d6CS3XMtKnWTHmloA,6036
5
+ envread/_dotenv.py,sha256=aU8K5lbaXrW-fNVj7JIewKPZElc04k7qSxvC7vKnBKc,4534
6
+ envread/_dotenv_value.py,sha256=emPcCBbDbt_uNPLcujFk7hTFbj9yjsJFuYayPMDJo8Q,3158
7
+ envread/_duration.py,sha256=l33Dxkf8vxLabzY8zW-HpWhCr8XR0JAoF6-feWXPlLE,1750
8
+ envread/_env.py,sha256=0RReXtrg4nUgW8tF6fWBPoP3ZGg88iIpK0vv8m-wyvY,5740
9
+ envread/_errors.py,sha256=224yi3Jd6bKsnuAWLlACNenBMqp8zioTwMN8Xn5ocq8,3431
10
+ envread/_parsers.py,sha256=6pWdk1ogPRGLkidtCsnJ3aL9d5e6Y4R_FdUaO7IOZ9E,3633
11
+ envread/_reader.py,sha256=WJptZApgKTDVmZV6rvMHE3gdvPqBTW6K7vkoCVr_NQI,3800
12
+ envread/_report.py,sha256=zgXQPRKRIBFQHEoCRLayjWhCATjwNPDn7REpTuEShxs,3090
13
+ envread/_rich.py,sha256=cma2krJ7XIEgl_qpZnuZIkIZg9TFLbdL4UPRK8caDrA,4886
14
+ envread/_secret.py,sha256=TU6DoAxmQHC6adAj_WRwzjvBJtQzfDEa58X5cBc6yoI,1849
15
+ envread/_settings.py,sha256=Y8QHXRzkJT5UMaS_IlxLGixWNaUlWp9gnfF216tNeXM,3818
16
+ envread/_specfile.py,sha256=UFeXC45A64GjKcEW7aABLVtqjEO9NmTlElGVvdAN_8w,2838
17
+ envread/_types.py,sha256=9hWs44aK1bTE06wocc2C3Y6-HiJFOgg9mcvsfqEP5OE,3972
18
+ envread/_version.py,sha256=qma9hy2PH3uT1-7KbTvB6thSsXLqsU_hMKESUJVJ_H0,77
19
+ envread/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ envread-0.2.0.dist-info/METADATA,sha256=0u93WcxEAPONL7ka48bdtCh-thJX-zzbRwxtKsoHgkA,11513
21
+ envread-0.2.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
22
+ envread-0.2.0.dist-info/entry_points.txt,sha256=sPiHHk_I5nSu7WCjzVHD5COWk22LUYHeuvHZ2p2q21E,46
23
+ envread-0.2.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
24
+ envread-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ envread = envread._cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Javier Lianes
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.