omlish 0.0.0.dev4__py3-none-any.whl → 0.0.0.dev6__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.

Potentially problematic release.


This version of omlish might be problematic. Click here for more details.

Files changed (143) hide show
  1. omlish/__about__.py +1 -1
  2. omlish/__init__.py +1 -1
  3. omlish/asyncs/__init__.py +10 -4
  4. omlish/asyncs/anyio.py +142 -12
  5. omlish/asyncs/asyncio.py +23 -0
  6. omlish/asyncs/asyncs.py +9 -6
  7. omlish/asyncs/bridge.py +316 -0
  8. omlish/asyncs/flavors.py +27 -1
  9. omlish/asyncs/trio_asyncio.py +28 -18
  10. omlish/c3.py +1 -1
  11. omlish/cached.py +1 -2
  12. omlish/collections/__init__.py +5 -1
  13. omlish/collections/cache/impl.py +1 -1
  14. omlish/collections/identity.py +7 -0
  15. omlish/collections/indexed.py +1 -1
  16. omlish/collections/utils.py +38 -6
  17. omlish/configs/__init__.py +5 -0
  18. omlish/configs/classes.py +53 -0
  19. omlish/configs/strings.py +94 -0
  20. omlish/dataclasses/__init__.py +9 -0
  21. omlish/dataclasses/impl/api.py +1 -1
  22. omlish/dataclasses/impl/as_.py +1 -1
  23. omlish/dataclasses/impl/copy.py +30 -0
  24. omlish/dataclasses/impl/exceptions.py +6 -0
  25. omlish/dataclasses/impl/fields.py +25 -25
  26. omlish/dataclasses/impl/init.py +5 -3
  27. omlish/dataclasses/impl/main.py +3 -0
  28. omlish/dataclasses/impl/metaclass.py +6 -1
  29. omlish/dataclasses/impl/order.py +1 -1
  30. omlish/dataclasses/impl/reflect.py +15 -2
  31. omlish/dataclasses/utils.py +44 -0
  32. omlish/defs.py +1 -1
  33. omlish/diag/__init__.py +4 -0
  34. omlish/diag/procfs.py +31 -3
  35. omlish/diag/procstats.py +32 -0
  36. omlish/{testing → diag}/pydevd.py +35 -0
  37. omlish/diag/replserver/console.py +3 -3
  38. omlish/diag/replserver/server.py +6 -5
  39. omlish/diag/threads.py +86 -0
  40. omlish/dispatch/_dispatch2.py +65 -0
  41. omlish/dispatch/_dispatch3.py +104 -0
  42. omlish/docker.py +20 -1
  43. omlish/fnpairs.py +37 -18
  44. omlish/graphs/dags.py +113 -0
  45. omlish/graphs/domination.py +268 -0
  46. omlish/graphs/trees.py +2 -2
  47. omlish/http/__init__.py +25 -0
  48. omlish/http/asgi.py +132 -0
  49. omlish/http/collections.py +15 -0
  50. omlish/http/consts.py +47 -5
  51. omlish/http/cookies.py +194 -0
  52. omlish/http/dates.py +70 -0
  53. omlish/http/encodings.py +6 -0
  54. omlish/http/json.py +273 -0
  55. omlish/http/sessions.py +204 -0
  56. omlish/inject/__init__.py +51 -17
  57. omlish/inject/binder.py +185 -5
  58. omlish/inject/bindings.py +3 -36
  59. omlish/inject/eagers.py +2 -8
  60. omlish/inject/elements.py +30 -9
  61. omlish/inject/exceptions.py +3 -3
  62. omlish/inject/impl/elements.py +65 -31
  63. omlish/inject/impl/injector.py +20 -2
  64. omlish/inject/impl/inspect.py +33 -5
  65. omlish/inject/impl/multis.py +74 -0
  66. omlish/inject/impl/origins.py +75 -0
  67. omlish/inject/impl/{private.py → privates.py} +2 -2
  68. omlish/inject/impl/providers.py +19 -39
  69. omlish/inject/{proxy.py → impl/proxy.py} +2 -2
  70. omlish/inject/impl/scopes.py +7 -2
  71. omlish/inject/injector.py +9 -4
  72. omlish/inject/inspect.py +18 -0
  73. omlish/inject/keys.py +11 -23
  74. omlish/inject/listeners.py +26 -0
  75. omlish/inject/managed.py +76 -10
  76. omlish/inject/multis.py +120 -0
  77. omlish/inject/origins.py +27 -0
  78. omlish/inject/overrides.py +5 -4
  79. omlish/inject/{private.py → privates.py} +6 -10
  80. omlish/inject/providers.py +12 -85
  81. omlish/inject/scopes.py +20 -9
  82. omlish/inject/types.py +2 -8
  83. omlish/iterators.py +13 -0
  84. omlish/lang/__init__.py +12 -2
  85. omlish/lang/cached.py +2 -2
  86. omlish/lang/classes/restrict.py +3 -2
  87. omlish/lang/classes/simple.py +18 -8
  88. omlish/lang/classes/virtual.py +2 -2
  89. omlish/lang/contextmanagers.py +75 -2
  90. omlish/lang/datetimes.py +6 -5
  91. omlish/lang/descriptors.py +131 -0
  92. omlish/lang/functions.py +18 -28
  93. omlish/lang/imports.py +11 -2
  94. omlish/lang/iterables.py +20 -1
  95. omlish/lang/typing.py +6 -0
  96. omlish/lifecycles/__init__.py +34 -0
  97. omlish/lifecycles/abstract.py +43 -0
  98. omlish/lifecycles/base.py +51 -0
  99. omlish/lifecycles/contextmanagers.py +74 -0
  100. omlish/lifecycles/controller.py +116 -0
  101. omlish/lifecycles/manager.py +161 -0
  102. omlish/lifecycles/states.py +43 -0
  103. omlish/lifecycles/transitions.py +64 -0
  104. omlish/logs/formatters.py +1 -1
  105. omlish/logs/utils.py +1 -1
  106. omlish/marshal/__init__.py +4 -0
  107. omlish/marshal/datetimes.py +1 -1
  108. omlish/marshal/naming.py +4 -0
  109. omlish/marshal/objects.py +1 -0
  110. omlish/marshal/polymorphism.py +4 -4
  111. omlish/reflect.py +139 -18
  112. omlish/secrets/__init__.py +7 -0
  113. omlish/secrets/marshal.py +41 -0
  114. omlish/secrets/passwords.py +120 -0
  115. omlish/secrets/secrets.py +47 -0
  116. omlish/serde/__init__.py +0 -0
  117. omlish/serde/dotenv.py +574 -0
  118. omlish/{json.py → serde/json.py} +4 -2
  119. omlish/serde/props.py +604 -0
  120. omlish/serde/yaml.py +223 -0
  121. omlish/sql/dbs.py +1 -1
  122. omlish/sql/duckdb.py +136 -0
  123. omlish/sql/sqlean.py +17 -0
  124. omlish/sync.py +70 -0
  125. omlish/term.py +7 -2
  126. omlish/testing/pytest/__init__.py +8 -2
  127. omlish/testing/pytest/helpers.py +0 -24
  128. omlish/testing/pytest/inject/harness.py +4 -4
  129. omlish/testing/pytest/marks.py +45 -0
  130. omlish/testing/pytest/plugins/__init__.py +3 -0
  131. omlish/testing/pytest/plugins/asyncs.py +136 -0
  132. omlish/testing/pytest/plugins/managermarks.py +60 -0
  133. omlish/testing/pytest/plugins/pydevd.py +1 -1
  134. omlish/testing/testing.py +10 -0
  135. omlish/text/delimit.py +4 -0
  136. omlish/text/glyphsplit.py +92 -0
  137. {omlish-0.0.0.dev4.dist-info → omlish-0.0.0.dev6.dist-info}/METADATA +1 -1
  138. omlish-0.0.0.dev6.dist-info/RECORD +240 -0
  139. {omlish-0.0.0.dev4.dist-info → omlish-0.0.0.dev6.dist-info}/WHEEL +1 -1
  140. omlish/configs/props.py +0 -64
  141. omlish-0.0.0.dev4.dist-info/RECORD +0 -195
  142. {omlish-0.0.0.dev4.dist-info → omlish-0.0.0.dev6.dist-info}/LICENSE +0 -0
  143. {omlish-0.0.0.dev4.dist-info → omlish-0.0.0.dev6.dist-info}/top_level.txt +0 -0
omlish/serde/dotenv.py ADDED
@@ -0,0 +1,574 @@
1
+ # Copyright (c) 2014, Saurabh Kumar (python-dotenv), 2013, Ted Tieken (django-dotenv-rw), 2013, Jacob Kaplan-Moss
2
+ # (django-dotenv)
3
+ #
4
+ # Redistribution and use in source and binary forms, with or without modification, are permitted provided that the
5
+ # following conditions are met:
6
+ #
7
+ # - Redistributions of source code must retain the above copyright notice, this list of conditions and the following
8
+ # disclaimer.
9
+ #
10
+ # - Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following
11
+ # disclaimer in the documentation and/or other materials provided with the distribution.
12
+ #
13
+ # - Neither the name of django-dotenv nor the names of its contributors may be used to endorse or promote products
14
+ # derived from this software without specific prior written permission.
15
+ #
16
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES,
17
+ # INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
18
+ # DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
19
+ # SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
20
+ # SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
21
+ # WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
22
+ # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
23
+ import abc
24
+ import codecs
25
+ import contextlib
26
+ import io
27
+ import logging
28
+ import os
29
+ import pathlib
30
+ import re
31
+ import shutil
32
+ import tempfile
33
+ import typing as ta
34
+
35
+
36
+ ##
37
+
38
+
39
+ log = logging.getLogger(__name__)
40
+
41
+
42
+ ##
43
+
44
+
45
+ _posix_variable: ta.Pattern[str] = re.compile(
46
+ r"""
47
+ \$\{
48
+ (?P<name>[^}:]*)
49
+ (?::-
50
+ (?P<default>[^}]*)
51
+ )?
52
+ }
53
+ """,
54
+ re.VERBOSE,
55
+ )
56
+
57
+
58
+ class Atom(metaclass=abc.ABCMeta):
59
+ def __ne__(self, other: object) -> bool:
60
+ result = self.__eq__(other)
61
+ if result is NotImplemented:
62
+ return NotImplemented
63
+ return not result
64
+
65
+ @abc.abstractmethod
66
+ def resolve(self, env: ta.Mapping[str, str | None]) -> str: ...
67
+
68
+
69
+ class Literal(Atom):
70
+ def __init__(self, value: str) -> None:
71
+ super().__init__()
72
+ self.value = value
73
+
74
+ def __repr__(self) -> str:
75
+ return f'Literal(value={self.value})'
76
+
77
+ def __eq__(self, other: object) -> bool:
78
+ if not isinstance(other, self.__class__):
79
+ return NotImplemented
80
+ return self.value == other.value
81
+
82
+ def __hash__(self) -> int:
83
+ return hash((self.__class__, self.value))
84
+
85
+ def resolve(self, env: ta.Mapping[str, str | None]) -> str:
86
+ return self.value
87
+
88
+
89
+ class Variable(Atom):
90
+ def __init__(self, name: str, default: str | None) -> None:
91
+ super().__init__()
92
+ self.name = name
93
+ self.default = default
94
+
95
+ def __repr__(self) -> str:
96
+ return f'Variable(name={self.name}, default={self.default})'
97
+
98
+ def __eq__(self, other: object) -> bool:
99
+ if not isinstance(other, self.__class__):
100
+ return NotImplemented
101
+ return (self.name, self.default) == (other.name, other.default)
102
+
103
+ def __hash__(self) -> int:
104
+ return hash((self.__class__, self.name, self.default))
105
+
106
+ def resolve(self, env: ta.Mapping[str, str | None]) -> str:
107
+ default = self.default if self.default is not None else ''
108
+ result = env.get(self.name, default)
109
+ return result if result is not None else ''
110
+
111
+
112
+ def parse_variables(value: str) -> ta.Iterator[Atom]:
113
+ cursor = 0
114
+
115
+ for match in _posix_variable.finditer(value):
116
+ (start, end) = match.span()
117
+ name = match['name']
118
+ default = match['default']
119
+
120
+ if start > cursor:
121
+ yield Literal(value=value[cursor:start])
122
+
123
+ yield Variable(name=name, default=default)
124
+ cursor = end
125
+
126
+ length = len(value)
127
+ if cursor < length:
128
+ yield Literal(value=value[cursor:length])
129
+
130
+
131
+ ##
132
+
133
+
134
+ def _make_regex(string: str, extra_flags: int = 0) -> ta.Pattern[str]:
135
+ return re.compile(string, re.UNICODE | extra_flags)
136
+
137
+
138
+ _newline = _make_regex(r'(\r\n|\n|\r)')
139
+ _multiline_whitespace = _make_regex(r'\s*', extra_flags=re.MULTILINE)
140
+ _whitespace = _make_regex(r'[^\S\r\n]*')
141
+ _export = _make_regex(r'(?:export[^\S\r\n]+)?')
142
+ _single_quoted_key = _make_regex(r"'([^']+)'")
143
+ _unquoted_key = _make_regex(r'([^=\#\s]+)')
144
+ _equal_sign = _make_regex(r'(=[^\S\r\n]*)')
145
+ _single_quoted_value = _make_regex(r"'((?:\\'|[^'])*)'")
146
+ _double_quoted_value = _make_regex(r'"((?:\\"|[^"])*)"')
147
+ _unquoted_value = _make_regex(r'([^\r\n]*)')
148
+ _comment = _make_regex(r'(?:[^\S\r\n]*#[^\r\n]*)?')
149
+ _end_of_line = _make_regex(r'[^\S\r\n]*(?:\r\n|\n|\r|$)')
150
+ _rest_of_line = _make_regex(r'[^\r\n]*(?:\r|\n|\r\n)?')
151
+ _double_quote_escapes = _make_regex(r"\\[\\'\"abfnrtv]")
152
+ _single_quote_escapes = _make_regex(r"\\[\\']")
153
+
154
+
155
+ class Original(ta.NamedTuple):
156
+ string: str
157
+ line: int
158
+
159
+
160
+ class Binding(ta.NamedTuple):
161
+ key: str | None
162
+ value: str | None
163
+ original: Original
164
+ error: bool
165
+
166
+
167
+ class _Position:
168
+ def __init__(self, chars: int, line: int) -> None:
169
+ super().__init__()
170
+ self.chars = chars
171
+ self.line = line
172
+
173
+ @classmethod
174
+ def start(cls) -> '_Position':
175
+ return cls(chars=0, line=1)
176
+
177
+ def set(self, other: '_Position') -> None:
178
+ self.chars = other.chars
179
+ self.line = other.line
180
+
181
+ def advance(self, string: str) -> None:
182
+ self.chars += len(string)
183
+ self.line += len(re.findall(_newline, string))
184
+
185
+
186
+ class Error(Exception):
187
+ pass
188
+
189
+
190
+ class _Reader:
191
+ def __init__(self, stream: ta.IO[str]) -> None:
192
+ super().__init__()
193
+ self.string = stream.read()
194
+ self.position = _Position.start()
195
+ self.mark = _Position.start()
196
+
197
+ def has_next(self) -> bool:
198
+ return self.position.chars < len(self.string)
199
+
200
+ def set_mark(self) -> None:
201
+ self.mark.set(self.position)
202
+
203
+ def get_marked(self) -> Original:
204
+ return Original(
205
+ string=self.string[self.mark.chars:self.position.chars],
206
+ line=self.mark.line,
207
+ )
208
+
209
+ def peek(self, count: int) -> str:
210
+ return self.string[self.position.chars:self.position.chars + count]
211
+
212
+ def read(self, count: int) -> str:
213
+ result = self.string[self.position.chars:self.position.chars + count]
214
+ if len(result) < count:
215
+ raise Error('read: End of string')
216
+ self.position.advance(result)
217
+ return result
218
+
219
+ def read_regex(self, regex: ta.Pattern[str]) -> ta.Sequence[str]:
220
+ match = regex.match(self.string, self.position.chars)
221
+ if match is None:
222
+ raise Error('read_regex: Pattern not found')
223
+ self.position.advance(self.string[match.start():match.end()])
224
+ return match.groups()
225
+
226
+
227
+ def _decode_escapes(regex: ta.Pattern[str], string: str) -> str:
228
+ def decode_match(match: ta.Match[str]) -> str:
229
+ return codecs.decode(match.group(0), 'unicode-escape')
230
+
231
+ return regex.sub(decode_match, string)
232
+
233
+
234
+ def _parse_key(reader: _Reader) -> str | None:
235
+ char = reader.peek(1)
236
+ if char == '#':
237
+ return None
238
+ elif char == "'":
239
+ (key,) = reader.read_regex(_single_quoted_key)
240
+ else:
241
+ (key,) = reader.read_regex(_unquoted_key)
242
+ return key
243
+
244
+
245
+ def _parse_unquoted_value(reader: _Reader) -> str:
246
+ (part,) = reader.read_regex(_unquoted_value)
247
+ return re.sub(r'\s+#.*', '', part).rstrip()
248
+
249
+
250
+ def _parse_value(reader: _Reader) -> str:
251
+ char = reader.peek(1)
252
+ if char == "'":
253
+ (value,) = reader.read_regex(_single_quoted_value)
254
+ return _decode_escapes(_single_quote_escapes, value)
255
+ elif char == '"':
256
+ (value,) = reader.read_regex(_double_quoted_value)
257
+ return _decode_escapes(_double_quote_escapes, value)
258
+ elif char in ('', '\n', '\r'):
259
+ return ''
260
+ else:
261
+ return _parse_unquoted_value(reader)
262
+
263
+
264
+ def _parse_binding(reader: _Reader) -> Binding:
265
+ reader.set_mark()
266
+ try:
267
+ reader.read_regex(_multiline_whitespace)
268
+ if not reader.has_next():
269
+ return Binding(
270
+ key=None,
271
+ value=None,
272
+ original=reader.get_marked(),
273
+ error=False,
274
+ )
275
+ reader.read_regex(_export)
276
+ key = _parse_key(reader)
277
+ reader.read_regex(_whitespace)
278
+ if reader.peek(1) == '=':
279
+ reader.read_regex(_equal_sign)
280
+ value: str | None = _parse_value(reader)
281
+ else:
282
+ value = None
283
+ reader.read_regex(_comment)
284
+ reader.read_regex(_end_of_line)
285
+ return Binding(
286
+ key=key,
287
+ value=value,
288
+ original=reader.get_marked(),
289
+ error=False,
290
+ )
291
+ except Error:
292
+ reader.read_regex(_rest_of_line)
293
+ return Binding(
294
+ key=None,
295
+ value=None,
296
+ original=reader.get_marked(),
297
+ error=True,
298
+ )
299
+
300
+
301
+ def parse_stream(stream: ta.IO[str]) -> ta.Iterator[Binding]:
302
+ reader = _Reader(stream)
303
+ while reader.has_next():
304
+ yield _parse_binding(reader)
305
+
306
+
307
+ ##
308
+
309
+
310
+ # A type alias for a string path to be used for the paths in this file. These paths may flow to `open()` and
311
+ # `shutil.move()`; `shutil.move()` only accepts string paths, not byte paths or file descriptors. See
312
+ # https://github.com/python/typeshed/pull/6832.
313
+ StrPath: ta.TypeAlias = ta.Union[str, 'os.PathLike[str]']
314
+
315
+
316
+ def _with_warn_for_invalid_lines(mappings: ta.Iterator[Binding]) -> ta.Iterator[Binding]:
317
+ for mapping in mappings:
318
+ if mapping.error:
319
+ log.warning(
320
+ 'dotenv could not parse statement starting at line %s',
321
+ mapping.original.line,
322
+ )
323
+ yield mapping
324
+
325
+
326
+ class DotEnv:
327
+ def __init__(
328
+ self,
329
+ path: StrPath | None,
330
+ stream: ta.IO[str] | None = None,
331
+ verbose: bool = False,
332
+ encoding: str | None = None,
333
+ interpolate: bool = True,
334
+ override: bool = True,
335
+ env: ta.Mapping[str, str] | None = None,
336
+ ) -> None:
337
+ super().__init__()
338
+ self.path: StrPath | None = path
339
+ self.stream: ta.IO[str] | None = stream
340
+ self._dict: dict[str, str | None] | None = None
341
+ self.verbose: bool = verbose
342
+ self.encoding: str | None = encoding
343
+ self.interpolate: bool = interpolate
344
+ self.override: bool = override
345
+ self.env = env or {}
346
+
347
+ @contextlib.contextmanager
348
+ def _get_stream(self) -> ta.Iterator[ta.IO[str]]:
349
+ if self.path and os.path.isfile(self.path):
350
+ with open(self.path, encoding=self.encoding) as stream:
351
+ yield stream
352
+ elif self.stream is not None:
353
+ yield self.stream
354
+ else:
355
+ if self.verbose:
356
+ log.info(
357
+ 'dotenv could not find configuration file %s.',
358
+ self.path or '.env',
359
+ )
360
+ yield io.StringIO('')
361
+
362
+ def dict(self) -> dict[str, str | None]:
363
+ if self._dict:
364
+ return self._dict
365
+
366
+ raw_values = self.parse()
367
+
368
+ if self.interpolate:
369
+ self._dict = resolve_variables(raw_values, override=self.override, env=self.env)
370
+ else:
371
+ self._dict = dict(raw_values)
372
+
373
+ return self._dict
374
+
375
+ def parse(self) -> ta.Iterator[tuple[str, str | None]]:
376
+ with self._get_stream() as stream:
377
+ for mapping in _with_warn_for_invalid_lines(parse_stream(stream)):
378
+ if mapping.key is not None:
379
+ yield mapping.key, mapping.value
380
+
381
+ def get(self, key: str) -> str | None:
382
+ data = self.dict()
383
+
384
+ if key in data:
385
+ return data[key]
386
+
387
+ if self.verbose:
388
+ log.warning('Key %s not found in %s.', key, self.path)
389
+
390
+ return None
391
+
392
+
393
+ ##
394
+
395
+
396
+ def get_key(
397
+ path: StrPath,
398
+ key_to_get: str,
399
+ *,
400
+ encoding: str | None = 'utf-8',
401
+ ) -> str | None:
402
+ """
403
+ Get the value of a given key from the given .env.
404
+
405
+ Returns `None` if the key isn't found or doesn't have a value.
406
+ """
407
+ return DotEnv(path, verbose=True, encoding=encoding).get(key_to_get)
408
+
409
+
410
+ @contextlib.contextmanager
411
+ def _rewrite(
412
+ path: StrPath,
413
+ encoding: str | None,
414
+ ) -> ta.Iterator[tuple[ta.IO[str], ta.IO[str]]]:
415
+ pathlib.Path(path).touch()
416
+
417
+ with tempfile.NamedTemporaryFile(mode='w', encoding=encoding, delete=False) as dest:
418
+ error = None
419
+ try:
420
+ with open(path, encoding=encoding) as source:
421
+ yield (source, dest)
422
+ except BaseException as err: # noqa
423
+ error = err
424
+
425
+ if error is None:
426
+ shutil.move(dest.name, path)
427
+ else:
428
+ os.unlink(dest.name)
429
+ raise error from None
430
+
431
+
432
+ def set_key(
433
+ path: StrPath,
434
+ key_to_set: str,
435
+ value_to_set: str,
436
+ *,
437
+ quote_mode: str = 'always',
438
+ export: bool = False,
439
+ encoding: str | None = 'utf-8',
440
+ ) -> tuple[bool | None, str, str]:
441
+ """
442
+ Adds or Updates a key/value to the given .env
443
+
444
+ If the .env path given doesn't exist, fails instead of risking creating
445
+ an orphan .env somewhere in the filesystem
446
+ """
447
+ if quote_mode not in ('always', 'auto', 'never'):
448
+ raise ValueError(f'Unknown quote_mode: {quote_mode}')
449
+
450
+ quote = (
451
+ quote_mode == 'always'
452
+ or (quote_mode == 'auto' and not value_to_set.isalnum())
453
+ )
454
+
455
+ if quote:
456
+ value_out = "'{}'".format(value_to_set.replace("'", "\\'"))
457
+ else:
458
+ value_out = value_to_set
459
+ if export:
460
+ line_out = f'export {key_to_set}={value_out}\n'
461
+ else:
462
+ line_out = f'{key_to_set}={value_out}\n'
463
+
464
+ with _rewrite(path, encoding=encoding) as (source, dest):
465
+ replaced = False
466
+ missing_newline = False
467
+ for mapping in _with_warn_for_invalid_lines(parse_stream(source)):
468
+ if mapping.key == key_to_set:
469
+ dest.write(line_out)
470
+ replaced = True
471
+ else:
472
+ dest.write(mapping.original.string)
473
+ missing_newline = not mapping.original.string.endswith('\n')
474
+ if not replaced:
475
+ if missing_newline:
476
+ dest.write('\n')
477
+ dest.write(line_out)
478
+
479
+ return True, key_to_set, value_to_set
480
+
481
+
482
+ def unset_key(
483
+ path: StrPath,
484
+ key_to_unset: str,
485
+ *,
486
+ quote_mode: str = 'always',
487
+ encoding: str | None = 'utf-8',
488
+ ) -> tuple[bool | None, str]:
489
+ """
490
+ Removes a given key from the given `.env` file.
491
+
492
+ If the .env path given doesn't exist, fails.
493
+ If the given key doesn't exist in the .env, fails.
494
+ """
495
+ if not os.path.exists(path):
496
+ log.warning("Can't delete from %s - it doesn't exist.", path)
497
+ return None, key_to_unset
498
+
499
+ removed = False
500
+ with _rewrite(path, encoding=encoding) as (source, dest):
501
+ for mapping in _with_warn_for_invalid_lines(parse_stream(source)):
502
+ if mapping.key == key_to_unset:
503
+ removed = True
504
+ else:
505
+ dest.write(mapping.original.string)
506
+
507
+ if not removed:
508
+ log.warning("Key %s not removed from %s - key doesn't exist.", key_to_unset, path)
509
+ return None, key_to_unset
510
+
511
+ return removed, key_to_unset
512
+
513
+
514
+ def resolve_variables(
515
+ values: ta.Iterable[tuple[str, str | None]],
516
+ override: bool,
517
+ env: ta.Mapping[str, str],
518
+ ) -> dict[str, str | None]:
519
+ new_values: dict[str, str | None] = {}
520
+
521
+ for (name, value) in values:
522
+ if value is None:
523
+ result = None
524
+ else:
525
+ atoms = parse_variables(value)
526
+ aenv: dict[str, str | None] = {}
527
+ if override:
528
+ aenv.update(env)
529
+ aenv.update(new_values)
530
+ else:
531
+ aenv.update(new_values)
532
+ aenv.update(env)
533
+ result = ''.join(atom.resolve(aenv) for atom in atoms)
534
+
535
+ new_values[name] = result
536
+
537
+ return new_values
538
+
539
+
540
+ def dotenv_values(
541
+ path: StrPath | None = None,
542
+ stream: ta.IO[str] | None = None,
543
+ *,
544
+ verbose: bool = False,
545
+ interpolate: bool = True,
546
+ encoding: str | None = 'utf-8',
547
+ ) -> dict[str, str | None]:
548
+ """
549
+ Parse a .env file and return its content as a dict.
550
+
551
+ The returned dict will have `None` values for keys without values in the .env file.
552
+ For example, `foo=bar` results in `{"foo": "bar"}` whereas `foo` alone results in
553
+ `{"foo": None}`
554
+
555
+ Parameters:
556
+ path: Absolute or relative path to the .env file.
557
+ stream: `StringIO` object with .env content, used if `path` is `None`.
558
+ verbose: Whether to output a warning if the .env file is missing.
559
+ encoding: Encoding to be used to read the file.
560
+
561
+ If both `path` and `stream` are `None`, `find_dotenv()` is used to find the
562
+ .env file.
563
+ """
564
+ if path is None and stream is None:
565
+ raise ValueError('must set path or stream')
566
+
567
+ return DotEnv(
568
+ path=path,
569
+ stream=stream,
570
+ verbose=verbose,
571
+ interpolate=interpolate,
572
+ override=True,
573
+ encoding=encoding,
574
+ ).dict()
@@ -125,7 +125,8 @@ import functools
125
125
  import json as _json
126
126
  import typing as ta
127
127
 
128
- from . import lang
128
+ from .. import lang
129
+
129
130
 
130
131
  if ta.TYPE_CHECKING:
131
132
  import orjson as _orjson
@@ -159,13 +160,14 @@ PRETTY_KWARGS: ta.Mapping[str, ta.Any] = dict(
159
160
  dump_pretty: ta.Callable[..., bytes] = functools.partial(dump, **PRETTY_KWARGS) # type: ignore
160
161
  dumps_pretty: ta.Callable[..., str] = functools.partial(dumps, **PRETTY_KWARGS)
161
162
 
163
+
162
164
  ##
163
165
 
164
166
 
165
167
  COMPACT_SEPARATORS = (',', ':')
166
168
 
167
169
  COMPACT_KWARGS: ta.Mapping[str, ta.Any] = dict(
168
- indent=0,
170
+ indent=None,
169
171
  separators=COMPACT_SEPARATORS,
170
172
  )
171
173