pydepinject 0.0.3.dev0__tar.gz → 0.0.5.dev0__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.
Files changed (20) hide show
  1. {pydepinject-0.0.3.dev0/src/pydepinject.egg-info → pydepinject-0.0.5.dev0}/PKG-INFO +106 -5
  2. {pydepinject-0.0.3.dev0 → pydepinject-0.0.5.dev0}/README.md +103 -2
  3. {pydepinject-0.0.3.dev0 → pydepinject-0.0.5.dev0}/pyproject.toml +4 -6
  4. {pydepinject-0.0.3.dev0 → pydepinject-0.0.5.dev0}/src/pydepinject/__init__.py +29 -18
  5. {pydepinject-0.0.3.dev0 → pydepinject-0.0.5.dev0}/src/pydepinject/backends.py +50 -10
  6. {pydepinject-0.0.3.dev0 → pydepinject-0.0.5.dev0/src/pydepinject.egg-info}/PKG-INFO +106 -5
  7. {pydepinject-0.0.3.dev0 → pydepinject-0.0.5.dev0}/src/pydepinject.egg-info/SOURCES.txt +0 -5
  8. {pydepinject-0.0.3.dev0 → pydepinject-0.0.5.dev0}/src/pydepinject.egg-info/requires.txt +1 -1
  9. {pydepinject-0.0.3.dev0 → pydepinject-0.0.5.dev0}/tests/conftest.py +1 -1
  10. {pydepinject-0.0.3.dev0 → pydepinject-0.0.5.dev0}/tests/test_pydepinject.py +117 -0
  11. pydepinject-0.0.3.dev0/src/requirementmanager.egg-info/PKG-INFO +0 -32
  12. pydepinject-0.0.3.dev0/src/requirementmanager.egg-info/SOURCES.txt +0 -7
  13. pydepinject-0.0.3.dev0/src/requirementmanager.egg-info/dependency_links.txt +0 -1
  14. pydepinject-0.0.3.dev0/src/requirementmanager.egg-info/requires.txt +0 -11
  15. pydepinject-0.0.3.dev0/src/requirementmanager.egg-info/top_level.txt +0 -1
  16. {pydepinject-0.0.3.dev0 → pydepinject-0.0.5.dev0}/LICENSE +0 -0
  17. {pydepinject-0.0.3.dev0 → pydepinject-0.0.5.dev0}/MANIFEST.in +0 -0
  18. {pydepinject-0.0.3.dev0 → pydepinject-0.0.5.dev0}/setup.cfg +0 -0
  19. {pydepinject-0.0.3.dev0 → pydepinject-0.0.5.dev0}/src/pydepinject.egg-info/dependency_links.txt +0 -0
  20. {pydepinject-0.0.3.dev0 → pydepinject-0.0.5.dev0}/src/pydepinject.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydepinject
3
- Version: 0.0.3.dev0
3
+ Version: 0.0.5.dev0
4
4
  Summary: A package to dynamically inject requirements into a virtual environment.
5
5
  Author: pydepinject
6
6
  License-Expression: MIT
@@ -17,10 +17,10 @@ Classifier: Programming Language :: Python :: Implementation :: CPython
17
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Classifier: Topic :: Software Development :: Libraries
19
19
  Classifier: Typing :: Typed
20
- Requires-Python: >=3.10
20
+ Requires-Python: >=3.11
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: packaging>=23.0
23
+ Requires-Dist: packaging>=24.0
24
24
  Requires-Dist: typing-extensions>=4.4.0
25
25
  Provides-Extra: lint
26
26
  Requires-Dist: isort; extra == "lint"
@@ -64,9 +64,11 @@ from pydepinject import requires
64
64
  def my_function():
65
65
  import requests
66
66
  import numpy as np
67
+
67
68
  print(requests.__version__)
68
69
  print(np.__version__)
69
70
 
71
+
70
72
  my_function()
71
73
  ```
72
74
 
@@ -81,6 +83,7 @@ from pydepinject import requires
81
83
  with requires("requests", "numpy"):
82
84
  import requests
83
85
  import numpy as np
86
+
84
87
  print(requests.__version__)
85
88
  print(np.__version__)
86
89
  ```
@@ -93,30 +96,38 @@ The `requires` can create a virtual environment with a specific name:
93
96
  @requires("requests", venv_name="myenv")
94
97
  def my_function():
95
98
  import requests
99
+
96
100
  print(requests.__version__)
97
101
 
98
102
 
99
103
  with requires("pylint", "requests", venv_name="myenv"):
100
104
  import pylint
105
+
101
106
  print(pylint.__version__)
102
107
  import requests # This is also available here because it was installed in the same virtual environment
108
+
103
109
  print(requests.__version__)
104
110
 
105
111
 
106
112
  # The virtual environment name can also be set as PYDEPINJECT_VENV_NAME environment variable
107
113
  import os
114
+
108
115
  os.environ["PYDEPINJECT_VENV_NAME"] = "myenv"
109
116
 
117
+
110
118
  @requires("requests")
111
119
  def my_function():
112
120
  import requests
121
+
113
122
  print(requests.__version__)
114
123
 
115
124
 
116
125
  with requires("pylint", "requests"):
117
126
  import pylint
127
+
118
128
  print(pylint.__version__)
119
129
  import requests # This is also available here because it was installed in the same virtual environment
130
+
120
131
  print(requests.__version__)
121
132
  ```
122
133
 
@@ -130,13 +141,16 @@ The `requires` can create named virtual environments and reuse them across multi
130
141
  @requires("requests", venv_name="myenv", ephemeral=False)
131
142
  def my_function():
132
143
  import requests
144
+
133
145
  print(requests.__version__)
134
146
 
135
147
 
136
148
  with requires("pylint", "requests", venv_name="myenv", ephemeral=False):
137
149
  import pylint
150
+
138
151
  print(pylint.__version__)
139
152
  import requests # This is also available here because it was installed in the same virtual environment
153
+
140
154
  print(requests.__version__)
141
155
  ```
142
156
 
@@ -148,8 +162,10 @@ The `requires` can automatically delete ephemeral virtual environments after use
148
162
  @requires("requests", venv_name="myenv", ephemeral=True)
149
163
  def my_function():
150
164
  import requests
165
+
151
166
  print(requests.__version__)
152
167
 
168
+
153
169
  my_function()
154
170
  ```
155
171
 
@@ -160,12 +176,15 @@ If you need to ensure a completely clean environment, you can force its recreati
160
176
  ```python
161
177
  from pydepinject import requires
162
178
 
179
+
163
180
  # This will delete the "my-clean-env" venv if it exists and create it from scratch
164
181
  @requires("requests", venv_name="my-clean-env", recreate=True)
165
182
  def my_function():
166
183
  import requests
184
+
167
185
  print(requests.__version__)
168
186
 
187
+
169
188
  my_function()
170
189
  ```
171
190
 
@@ -199,16 +218,20 @@ You can forward extra arguments to the underlying installer (pip or uv pip) via
199
218
  ```python
200
219
  from pydepinject import requires
201
220
 
221
+
202
222
  @requires(
203
223
  "requests",
204
224
  install_args=(
205
- "--index-url", "https://pypi.myorg/simple",
206
- "--upgrade-strategy", "eager",
225
+ "--index-url",
226
+ "https://pypi.myorg/simple",
227
+ "--upgrade-strategy",
228
+ "eager",
207
229
  ),
208
230
  venv_backend="venv",
209
231
  )
210
232
  def my_function():
211
233
  import requests
234
+
212
235
  print(requests.__version__)
213
236
  ```
214
237
 
@@ -243,6 +266,84 @@ This helps with debugging and reproducibility.
243
266
 
244
267
  These variables provide a convenient way to standardize behavior in CI/CD pipelines or development environments.
245
268
 
269
+ ## Where venvs are stored and cleaning them up
270
+
271
+ Each managed environment is a plain virtual environment directory under a single
272
+ root. By default that root is `<system-temp-dir>/pydepinject/venvs` (overridable
273
+ with `PYDEPINJECT_VENV_ROOT`), and each venv is a subdirectory named either after
274
+ your `venv_name` or a content hash derived from the requirement set, Python
275
+ version, and backend.
276
+
277
+ Because the default root lives under the OS temporary directory, the operating
278
+ system usually reclaims it for you — temp-file cleaners and reboots clear stale
279
+ entries — so in the common case there is **nothing to clean up manually**.
280
+
281
+ For finer control:
282
+
283
+ - **Avoid persistence per call**: pass `ephemeral=True` so the venv is deleted as
284
+ soon as the decorated function or `with` block finishes.
285
+ - **Prune a persistent root you manage**: the venvs are just directories, so
286
+ `rm -rf "$PYDEPINJECT_VENV_ROOT"` removes everything, or delete individual
287
+ subdirectories to reclaim space selectively.
288
+ - **Script age-based cleanup**: each venv contains `.pydepinject-*.json` metadata
289
+ files recording the install timestamp (and the packages installed), which you
290
+ can use to find and remove environments older than a chosen age.
291
+
292
+ ## Limitations and thread-safety
293
+
294
+ `pydepinject` works by mutating **process-global interpreter state** for the
295
+ duration of the managed scope (the decorated call or `with` block). When a
296
+ requirement is not already importable, it:
297
+
298
+ - inserts the venv's `site-packages` at the front of `sys.path`,
299
+ - prepends to the `PYTHONPATH` and `PATH` environment variables,
300
+ - evicts conflicting entries from `sys.modules` so fresh imports resolve to the venv.
301
+
302
+ The original `sys.path`, environment variables, and `sys.modules` state are
303
+ restored when the scope exits. If every requested package is already satisfied in
304
+ the current environment, **no global state is changed at all** — activation is a
305
+ no-op fast path.
306
+
307
+ Because this state is global to the interpreter, please keep the following in mind:
308
+
309
+ - **Not thread-safe and not safe under `asyncio`/concurrent use.** Two managed
310
+ scopes active at the same time in different threads (or interleaved coroutines)
311
+ will race on `sys.path`, the environment variables, and `sys.modules`, and can
312
+ corrupt each other's save/restore. Use `pydepinject` from a single thread of
313
+ control. If you need dependencies isolated across concurrent workers, give each
314
+ worker its own **process**.
315
+ - **Already-imported modules are handled best-effort.** If a package was already
316
+ imported earlier in the process from a different location, swapping it for the
317
+ venv's copy within the scope cannot be guaranteed — code holding a reference to
318
+ the previously imported module keeps using it.
319
+ - **It mutates the running interpreter, by design.** This makes it ideal for
320
+ scripts, notebooks, plugins, and ad-hoc tooling, but it is **not** a substitute
321
+ for a properly provisioned environment or a lockfile in a production
322
+ application. For those, prefer real environment/dependency management.
323
+
324
+ ## When NOT to use this
325
+
326
+ `pydepinject` is built for **scripts, notebooks, plugins, and ad-hoc tooling or
327
+ CI glue** — cases where "make sure X is importable, install it if not" is the
328
+ whole job. It is the wrong tool, and you should reach for a real
329
+ environment plus a lockfile (`uv`, `pip-tools`, Poetry, etc.) instead, when:
330
+
331
+ - You are building a **deployable application or service** where dependency
332
+ reproducibility matters.
333
+ - Your code runs under **threads, `asyncio`, or parallel workers** — activation
334
+ mutates global interpreter state and is not safe to interleave (see
335
+ [Limitations and thread-safety](#limitations-and-thread-safety); give each
336
+ worker its own process).
337
+ - You need **pinned, audited, reproducible** dependency sets across machines and
338
+ CI runs.
339
+ - Packages are **already imported** earlier in the process and must be swapped
340
+ mid-run — `pydepinject` can only do this best-effort.
341
+ - You run a **long-lived production process** where persistent venvs accumulating
342
+ under the temp root would be a problem.
343
+
344
+ For the mechanics behind these caveats, see
345
+ [Limitations and thread-safety](#limitations-and-thread-safety) above.
346
+
246
347
  ## Unit Tests
247
348
 
248
349
  Unit tests are provided to verify the functionality of the `requires`. The tests use `pytest` and cover various scenarios including decorator usage, context manager usage, ephemeral environments, and more.
@@ -31,9 +31,11 @@ from pydepinject import requires
31
31
  def my_function():
32
32
  import requests
33
33
  import numpy as np
34
+
34
35
  print(requests.__version__)
35
36
  print(np.__version__)
36
37
 
38
+
37
39
  my_function()
38
40
  ```
39
41
 
@@ -48,6 +50,7 @@ from pydepinject import requires
48
50
  with requires("requests", "numpy"):
49
51
  import requests
50
52
  import numpy as np
53
+
51
54
  print(requests.__version__)
52
55
  print(np.__version__)
53
56
  ```
@@ -60,30 +63,38 @@ The `requires` can create a virtual environment with a specific name:
60
63
  @requires("requests", venv_name="myenv")
61
64
  def my_function():
62
65
  import requests
66
+
63
67
  print(requests.__version__)
64
68
 
65
69
 
66
70
  with requires("pylint", "requests", venv_name="myenv"):
67
71
  import pylint
72
+
68
73
  print(pylint.__version__)
69
74
  import requests # This is also available here because it was installed in the same virtual environment
75
+
70
76
  print(requests.__version__)
71
77
 
72
78
 
73
79
  # The virtual environment name can also be set as PYDEPINJECT_VENV_NAME environment variable
74
80
  import os
81
+
75
82
  os.environ["PYDEPINJECT_VENV_NAME"] = "myenv"
76
83
 
84
+
77
85
  @requires("requests")
78
86
  def my_function():
79
87
  import requests
88
+
80
89
  print(requests.__version__)
81
90
 
82
91
 
83
92
  with requires("pylint", "requests"):
84
93
  import pylint
94
+
85
95
  print(pylint.__version__)
86
96
  import requests # This is also available here because it was installed in the same virtual environment
97
+
87
98
  print(requests.__version__)
88
99
  ```
89
100
 
@@ -97,13 +108,16 @@ The `requires` can create named virtual environments and reuse them across multi
97
108
  @requires("requests", venv_name="myenv", ephemeral=False)
98
109
  def my_function():
99
110
  import requests
111
+
100
112
  print(requests.__version__)
101
113
 
102
114
 
103
115
  with requires("pylint", "requests", venv_name="myenv", ephemeral=False):
104
116
  import pylint
117
+
105
118
  print(pylint.__version__)
106
119
  import requests # This is also available here because it was installed in the same virtual environment
120
+
107
121
  print(requests.__version__)
108
122
  ```
109
123
 
@@ -115,8 +129,10 @@ The `requires` can automatically delete ephemeral virtual environments after use
115
129
  @requires("requests", venv_name="myenv", ephemeral=True)
116
130
  def my_function():
117
131
  import requests
132
+
118
133
  print(requests.__version__)
119
134
 
135
+
120
136
  my_function()
121
137
  ```
122
138
 
@@ -127,12 +143,15 @@ If you need to ensure a completely clean environment, you can force its recreati
127
143
  ```python
128
144
  from pydepinject import requires
129
145
 
146
+
130
147
  # This will delete the "my-clean-env" venv if it exists and create it from scratch
131
148
  @requires("requests", venv_name="my-clean-env", recreate=True)
132
149
  def my_function():
133
150
  import requests
151
+
134
152
  print(requests.__version__)
135
153
 
154
+
136
155
  my_function()
137
156
  ```
138
157
 
@@ -166,16 +185,20 @@ You can forward extra arguments to the underlying installer (pip or uv pip) via
166
185
  ```python
167
186
  from pydepinject import requires
168
187
 
188
+
169
189
  @requires(
170
190
  "requests",
171
191
  install_args=(
172
- "--index-url", "https://pypi.myorg/simple",
173
- "--upgrade-strategy", "eager",
192
+ "--index-url",
193
+ "https://pypi.myorg/simple",
194
+ "--upgrade-strategy",
195
+ "eager",
174
196
  ),
175
197
  venv_backend="venv",
176
198
  )
177
199
  def my_function():
178
200
  import requests
201
+
179
202
  print(requests.__version__)
180
203
  ```
181
204
 
@@ -210,6 +233,84 @@ This helps with debugging and reproducibility.
210
233
 
211
234
  These variables provide a convenient way to standardize behavior in CI/CD pipelines or development environments.
212
235
 
236
+ ## Where venvs are stored and cleaning them up
237
+
238
+ Each managed environment is a plain virtual environment directory under a single
239
+ root. By default that root is `<system-temp-dir>/pydepinject/venvs` (overridable
240
+ with `PYDEPINJECT_VENV_ROOT`), and each venv is a subdirectory named either after
241
+ your `venv_name` or a content hash derived from the requirement set, Python
242
+ version, and backend.
243
+
244
+ Because the default root lives under the OS temporary directory, the operating
245
+ system usually reclaims it for you — temp-file cleaners and reboots clear stale
246
+ entries — so in the common case there is **nothing to clean up manually**.
247
+
248
+ For finer control:
249
+
250
+ - **Avoid persistence per call**: pass `ephemeral=True` so the venv is deleted as
251
+ soon as the decorated function or `with` block finishes.
252
+ - **Prune a persistent root you manage**: the venvs are just directories, so
253
+ `rm -rf "$PYDEPINJECT_VENV_ROOT"` removes everything, or delete individual
254
+ subdirectories to reclaim space selectively.
255
+ - **Script age-based cleanup**: each venv contains `.pydepinject-*.json` metadata
256
+ files recording the install timestamp (and the packages installed), which you
257
+ can use to find and remove environments older than a chosen age.
258
+
259
+ ## Limitations and thread-safety
260
+
261
+ `pydepinject` works by mutating **process-global interpreter state** for the
262
+ duration of the managed scope (the decorated call or `with` block). When a
263
+ requirement is not already importable, it:
264
+
265
+ - inserts the venv's `site-packages` at the front of `sys.path`,
266
+ - prepends to the `PYTHONPATH` and `PATH` environment variables,
267
+ - evicts conflicting entries from `sys.modules` so fresh imports resolve to the venv.
268
+
269
+ The original `sys.path`, environment variables, and `sys.modules` state are
270
+ restored when the scope exits. If every requested package is already satisfied in
271
+ the current environment, **no global state is changed at all** — activation is a
272
+ no-op fast path.
273
+
274
+ Because this state is global to the interpreter, please keep the following in mind:
275
+
276
+ - **Not thread-safe and not safe under `asyncio`/concurrent use.** Two managed
277
+ scopes active at the same time in different threads (or interleaved coroutines)
278
+ will race on `sys.path`, the environment variables, and `sys.modules`, and can
279
+ corrupt each other's save/restore. Use `pydepinject` from a single thread of
280
+ control. If you need dependencies isolated across concurrent workers, give each
281
+ worker its own **process**.
282
+ - **Already-imported modules are handled best-effort.** If a package was already
283
+ imported earlier in the process from a different location, swapping it for the
284
+ venv's copy within the scope cannot be guaranteed — code holding a reference to
285
+ the previously imported module keeps using it.
286
+ - **It mutates the running interpreter, by design.** This makes it ideal for
287
+ scripts, notebooks, plugins, and ad-hoc tooling, but it is **not** a substitute
288
+ for a properly provisioned environment or a lockfile in a production
289
+ application. For those, prefer real environment/dependency management.
290
+
291
+ ## When NOT to use this
292
+
293
+ `pydepinject` is built for **scripts, notebooks, plugins, and ad-hoc tooling or
294
+ CI glue** — cases where "make sure X is importable, install it if not" is the
295
+ whole job. It is the wrong tool, and you should reach for a real
296
+ environment plus a lockfile (`uv`, `pip-tools`, Poetry, etc.) instead, when:
297
+
298
+ - You are building a **deployable application or service** where dependency
299
+ reproducibility matters.
300
+ - Your code runs under **threads, `asyncio`, or parallel workers** — activation
301
+ mutates global interpreter state and is not safe to interleave (see
302
+ [Limitations and thread-safety](#limitations-and-thread-safety); give each
303
+ worker its own process).
304
+ - You need **pinned, audited, reproducible** dependency sets across machines and
305
+ CI runs.
306
+ - Packages are **already imported** earlier in the process and must be swapped
307
+ mid-run — `pydepinject` can only do this best-effort.
308
+ - You run a **long-lived production process** where persistent venvs accumulating
309
+ under the temp root would be a problem.
310
+
311
+ For the mechanics behind these caveats, see
312
+ [Limitations and thread-safety](#limitations-and-thread-safety) above.
313
+
213
314
  ## Unit Tests
214
315
 
215
316
  Unit tests are provided to verify the functionality of the `requires`. The tests use `pytest` and cover various scenarios including decorator usage, context manager usage, ephemeral environments, and more.
@@ -14,9 +14,9 @@ authors = [
14
14
  ]
15
15
  license = "MIT"
16
16
  keywords = ["virtualenv", "requirements", "dependency management"]
17
- requires-python = ">=3.10"
17
+ requires-python = ">=3.11"
18
18
  dependencies = [
19
- "packaging>=23.0",
19
+ "packaging>=24.0",
20
20
  "typing-extensions>=4.4.0",
21
21
  ]
22
22
  classifiers = [
@@ -54,11 +54,9 @@ packages = ["pydepinject"]
54
54
  [tool.setuptools.dynamic]
55
55
  version = {attr = "pydepinject.VERSION"}
56
56
 
57
- [tool.pytest]
57
+ [tool.pytest.ini_options]
58
58
  log_cli = true
59
59
  log_level = "DEBUG"
60
-
61
- [tool.pytest.ini_options]
62
60
  pythonpath = "src"
63
61
  testpaths = ["tests"]
64
62
  addopts = "--durations=10 -n auto"
@@ -98,6 +96,7 @@ ignore = [
98
96
  "PLW2901",
99
97
  "RET503",
100
98
  "RUF005",
99
+ "RUF067",
101
100
  "S404",
102
101
  "S603",
103
102
  "SLF001",
@@ -137,7 +136,6 @@ reportImplicitStringConcatenation = true
137
136
  reportImportCycles = true
138
137
  reportMissingSuperCall = false
139
138
  reportPropertyTypeMismatch = true
140
- reportShadowedImports = true
141
139
  reportUninitializedInstanceVariable = true
142
140
  reportUnnecessaryTypeIgnoreComment = false # reportMissingImports for tomllib is valid python<3.11.
143
141
  reportUnusedCallResult = false
@@ -23,7 +23,7 @@ from .backends import VenvBackendRegistry
23
23
  P = typing.ParamSpec("P")
24
24
  R = typing.TypeVar("R")
25
25
 
26
- VERSION = "0.0.3dev0"
26
+ VERSION = "0.0.5dev0"
27
27
  __version__ = VERSION
28
28
 
29
29
  logger = logging.getLogger(__name__)
@@ -221,7 +221,7 @@ class RequirementManager:
221
221
  extra_args=list(self._install_args) if self._install_args else None,
222
222
  )
223
223
  # Write metadata file for traceability
224
- timestamp = int(datetime.datetime.now(tz=datetime.timezone.utc).timestamp())
224
+ timestamp = int(datetime.datetime.now(tz=datetime.UTC).timestamp())
225
225
  meta_filepath = self.venv_path / f".pydepinject-{timestamp}.json"
226
226
  try:
227
227
  meta = {
@@ -234,7 +234,7 @@ class RequirementManager:
234
234
  "platform": sys.platform,
235
235
  "packages": list(self.packages),
236
236
  "install_args": list(self._install_args),
237
- "timestamp": datetime.datetime.now(datetime.timezone.utc).isoformat(),
237
+ "timestamp": datetime.datetime.now(datetime.UTC).isoformat(),
238
238
  }
239
239
  meta_filepath.write_text(json.dumps(meta, indent=2))
240
240
  logger.debug("Wrote venv metadata to: %s", meta_filepath)
@@ -278,8 +278,29 @@ class RequirementManager:
278
278
  self.original_path = os.environ.get("PATH", "")
279
279
  self.original_syspath = sys.path.copy()
280
280
 
281
+ # Once we start mutating global interpreter state, any failure (of any
282
+ # exception type) must restore it before propagating; otherwise the host
283
+ # process is left with a polluted sys.path/PATH/PYTHONPATH.
284
+ try:
285
+ return self._mutate_and_install()
286
+ except BaseException:
287
+ logger.exception("Failed to activate venv %s", self.venv_path)
288
+ self._deactivate_venv()
289
+ raise
290
+
291
+ def _mutate_and_install(self):
292
+ """Mutate interpreter state to point at the venv and install packages.
293
+
294
+ Assumes ``original_*`` state has already been captured by the caller so
295
+ that a failure here can be rolled back.
296
+ """
281
297
  self._create_virtualenv()
282
298
 
299
+ # Mark activated before mutating any global state, so an interrupt in the
300
+ # middle of the mutations below still triggers a full restore via
301
+ # _deactivate_venv (which is gated on this flag).
302
+ self._activated = True
303
+
283
304
  bin_dir = _bin_dir(self.venv_path)
284
305
  venv_site_packages = _site_packages_dir(self.venv_path)
285
306
  os.environ["PYTHONPATH"] = str(venv_site_packages) + (
@@ -287,7 +308,6 @@ class RequirementManager:
287
308
  )
288
309
  os.environ["PATH"] = str(bin_dir) + os.pathsep + self.original_path
289
310
  sys.path.insert(0, str(venv_site_packages))
290
- self._activated = True
291
311
  if is_requirements_satisfied(*self.packages):
292
312
  logger.debug(
293
313
  "Requirements %s already satisfied within %s",
@@ -301,6 +321,7 @@ class RequirementManager:
301
321
  logger.debug(
302
322
  "Purged modules from venv %s: %s", self.venv_path, purged_modules
303
323
  )
324
+ return self
304
325
 
305
326
  def _deactivate_venv(self):
306
327
  if not self._activated:
@@ -316,13 +337,9 @@ class RequirementManager:
316
337
  shutil.rmtree(self.venv_path)
317
338
 
318
339
  def __enter__(self):
319
- try:
320
- self._activate_venv()
321
- except RuntimeError:
322
- logger.exception("Failed to activate venv: %s")
323
- if self.ephemeral:
324
- self._deactivate_venv()
325
- raise
340
+ # _activate_venv restores global state and cleans up on any failure, so
341
+ # we simply let the original exception propagate.
342
+ self._activate_venv()
326
343
  return self
327
344
 
328
345
  def __exit__(
@@ -336,13 +353,7 @@ class RequirementManager:
336
353
 
337
354
  def __call__(self, func: Callable[P, R] | None = None) -> Callable[P, R] | None:
338
355
  if func is None:
339
- try:
340
- self._activate_venv()
341
- except RuntimeError:
342
- logger.exception("Failed to activate venv: %s")
343
- if self.ephemeral:
344
- self._deactivate_venv()
345
- raise
356
+ self._activate_venv()
346
357
  return None
347
358
 
348
359
  @functools.wraps(func)
@@ -38,6 +38,27 @@ def _venv_python(venv_path: pathlib.Path) -> pathlib.Path:
38
38
  return _bin_dir(venv_path) / name
39
39
 
40
40
 
41
+ def _tail(output: str, *, max_lines: int = 20, max_chars: int = 4000) -> str:
42
+ """Return the trailing portion of command output for an error message.
43
+
44
+ Installers print the actual failure at the end of their output, so the tail
45
+ is the diagnostic part. The full output is logged separately; this keeps the
46
+ exception message readable.
47
+ """
48
+ output = output.strip()
49
+ if not output:
50
+ return "(no output captured)"
51
+ lines = output.splitlines()
52
+ truncated = len(lines) > max_lines
53
+ text = "\n".join(lines[-max_lines:])
54
+ if len(text) > max_chars:
55
+ text = text[-max_chars:]
56
+ truncated = True
57
+ if truncated:
58
+ text = f"(truncated; see logs for full output)\n{text}"
59
+ return text
60
+
61
+
41
62
  class VenvBackend:
42
63
  """Abstract base class for virtual environment backends."""
43
64
 
@@ -70,17 +91,36 @@ class VenvBackend:
70
91
  """Check if the backend is supported on the current system."""
71
92
 
72
93
  def _run_command(self, cmd: list[str]) -> None: # noqa: PLR6301
73
- """Run a command in the virtual environment."""
74
- logger.debug("Running command: %s", " ".join(cmd))
75
- try:
76
- subprocess.check_call(cmd, stdout=subprocess.DEVNULL)
77
- except subprocess.CalledProcessError as e:
78
- logger.exception(
79
- "Command failed with exit code %d: %s", e.returncode, " ".join(cmd)
94
+ """Run a command in the virtual environment.
95
+
96
+ Output is captured (stdout and stderr merged) and stays quiet on success.
97
+ On failure the full output is logged and a trailing excerpt is included in
98
+ the raised ``RuntimeError`` for debuggability.
99
+ """
100
+ cmd_str = " ".join(cmd)
101
+ logger.debug("Running command: %s", cmd_str)
102
+ result = subprocess.run(
103
+ cmd,
104
+ stdout=subprocess.PIPE,
105
+ stderr=subprocess.STDOUT,
106
+ text=True,
107
+ check=False,
108
+ )
109
+ output = result.stdout or ""
110
+ if result.returncode != 0:
111
+ logger.error(
112
+ "Command failed with exit code %d: %s\nOutput:\n%s",
113
+ result.returncode,
114
+ cmd_str,
115
+ output,
80
116
  )
81
- raise RuntimeError(
82
- f"Command failed with exit code {e.returncode}: {' '.join(cmd)}"
83
- ) from e
117
+ message = "\n".join([
118
+ f"Command failed with exit code {result.returncode}: {cmd_str}",
119
+ "Output:",
120
+ _tail(output),
121
+ ])
122
+ raise RuntimeError(message)
123
+ logger.debug("Command output:\n%s", output)
84
124
 
85
125
 
86
126
  class VenvBackendRegistry:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pydepinject
3
- Version: 0.0.3.dev0
3
+ Version: 0.0.5.dev0
4
4
  Summary: A package to dynamically inject requirements into a virtual environment.
5
5
  Author: pydepinject
6
6
  License-Expression: MIT
@@ -17,10 +17,10 @@ Classifier: Programming Language :: Python :: Implementation :: CPython
17
17
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
18
  Classifier: Topic :: Software Development :: Libraries
19
19
  Classifier: Typing :: Typed
20
- Requires-Python: >=3.10
20
+ Requires-Python: >=3.11
21
21
  Description-Content-Type: text/markdown
22
22
  License-File: LICENSE
23
- Requires-Dist: packaging>=23.0
23
+ Requires-Dist: packaging>=24.0
24
24
  Requires-Dist: typing-extensions>=4.4.0
25
25
  Provides-Extra: lint
26
26
  Requires-Dist: isort; extra == "lint"
@@ -64,9 +64,11 @@ from pydepinject import requires
64
64
  def my_function():
65
65
  import requests
66
66
  import numpy as np
67
+
67
68
  print(requests.__version__)
68
69
  print(np.__version__)
69
70
 
71
+
70
72
  my_function()
71
73
  ```
72
74
 
@@ -81,6 +83,7 @@ from pydepinject import requires
81
83
  with requires("requests", "numpy"):
82
84
  import requests
83
85
  import numpy as np
86
+
84
87
  print(requests.__version__)
85
88
  print(np.__version__)
86
89
  ```
@@ -93,30 +96,38 @@ The `requires` can create a virtual environment with a specific name:
93
96
  @requires("requests", venv_name="myenv")
94
97
  def my_function():
95
98
  import requests
99
+
96
100
  print(requests.__version__)
97
101
 
98
102
 
99
103
  with requires("pylint", "requests", venv_name="myenv"):
100
104
  import pylint
105
+
101
106
  print(pylint.__version__)
102
107
  import requests # This is also available here because it was installed in the same virtual environment
108
+
103
109
  print(requests.__version__)
104
110
 
105
111
 
106
112
  # The virtual environment name can also be set as PYDEPINJECT_VENV_NAME environment variable
107
113
  import os
114
+
108
115
  os.environ["PYDEPINJECT_VENV_NAME"] = "myenv"
109
116
 
117
+
110
118
  @requires("requests")
111
119
  def my_function():
112
120
  import requests
121
+
113
122
  print(requests.__version__)
114
123
 
115
124
 
116
125
  with requires("pylint", "requests"):
117
126
  import pylint
127
+
118
128
  print(pylint.__version__)
119
129
  import requests # This is also available here because it was installed in the same virtual environment
130
+
120
131
  print(requests.__version__)
121
132
  ```
122
133
 
@@ -130,13 +141,16 @@ The `requires` can create named virtual environments and reuse them across multi
130
141
  @requires("requests", venv_name="myenv", ephemeral=False)
131
142
  def my_function():
132
143
  import requests
144
+
133
145
  print(requests.__version__)
134
146
 
135
147
 
136
148
  with requires("pylint", "requests", venv_name="myenv", ephemeral=False):
137
149
  import pylint
150
+
138
151
  print(pylint.__version__)
139
152
  import requests # This is also available here because it was installed in the same virtual environment
153
+
140
154
  print(requests.__version__)
141
155
  ```
142
156
 
@@ -148,8 +162,10 @@ The `requires` can automatically delete ephemeral virtual environments after use
148
162
  @requires("requests", venv_name="myenv", ephemeral=True)
149
163
  def my_function():
150
164
  import requests
165
+
151
166
  print(requests.__version__)
152
167
 
168
+
153
169
  my_function()
154
170
  ```
155
171
 
@@ -160,12 +176,15 @@ If you need to ensure a completely clean environment, you can force its recreati
160
176
  ```python
161
177
  from pydepinject import requires
162
178
 
179
+
163
180
  # This will delete the "my-clean-env" venv if it exists and create it from scratch
164
181
  @requires("requests", venv_name="my-clean-env", recreate=True)
165
182
  def my_function():
166
183
  import requests
184
+
167
185
  print(requests.__version__)
168
186
 
187
+
169
188
  my_function()
170
189
  ```
171
190
 
@@ -199,16 +218,20 @@ You can forward extra arguments to the underlying installer (pip or uv pip) via
199
218
  ```python
200
219
  from pydepinject import requires
201
220
 
221
+
202
222
  @requires(
203
223
  "requests",
204
224
  install_args=(
205
- "--index-url", "https://pypi.myorg/simple",
206
- "--upgrade-strategy", "eager",
225
+ "--index-url",
226
+ "https://pypi.myorg/simple",
227
+ "--upgrade-strategy",
228
+ "eager",
207
229
  ),
208
230
  venv_backend="venv",
209
231
  )
210
232
  def my_function():
211
233
  import requests
234
+
212
235
  print(requests.__version__)
213
236
  ```
214
237
 
@@ -243,6 +266,84 @@ This helps with debugging and reproducibility.
243
266
 
244
267
  These variables provide a convenient way to standardize behavior in CI/CD pipelines or development environments.
245
268
 
269
+ ## Where venvs are stored and cleaning them up
270
+
271
+ Each managed environment is a plain virtual environment directory under a single
272
+ root. By default that root is `<system-temp-dir>/pydepinject/venvs` (overridable
273
+ with `PYDEPINJECT_VENV_ROOT`), and each venv is a subdirectory named either after
274
+ your `venv_name` or a content hash derived from the requirement set, Python
275
+ version, and backend.
276
+
277
+ Because the default root lives under the OS temporary directory, the operating
278
+ system usually reclaims it for you — temp-file cleaners and reboots clear stale
279
+ entries — so in the common case there is **nothing to clean up manually**.
280
+
281
+ For finer control:
282
+
283
+ - **Avoid persistence per call**: pass `ephemeral=True` so the venv is deleted as
284
+ soon as the decorated function or `with` block finishes.
285
+ - **Prune a persistent root you manage**: the venvs are just directories, so
286
+ `rm -rf "$PYDEPINJECT_VENV_ROOT"` removes everything, or delete individual
287
+ subdirectories to reclaim space selectively.
288
+ - **Script age-based cleanup**: each venv contains `.pydepinject-*.json` metadata
289
+ files recording the install timestamp (and the packages installed), which you
290
+ can use to find and remove environments older than a chosen age.
291
+
292
+ ## Limitations and thread-safety
293
+
294
+ `pydepinject` works by mutating **process-global interpreter state** for the
295
+ duration of the managed scope (the decorated call or `with` block). When a
296
+ requirement is not already importable, it:
297
+
298
+ - inserts the venv's `site-packages` at the front of `sys.path`,
299
+ - prepends to the `PYTHONPATH` and `PATH` environment variables,
300
+ - evicts conflicting entries from `sys.modules` so fresh imports resolve to the venv.
301
+
302
+ The original `sys.path`, environment variables, and `sys.modules` state are
303
+ restored when the scope exits. If every requested package is already satisfied in
304
+ the current environment, **no global state is changed at all** — activation is a
305
+ no-op fast path.
306
+
307
+ Because this state is global to the interpreter, please keep the following in mind:
308
+
309
+ - **Not thread-safe and not safe under `asyncio`/concurrent use.** Two managed
310
+ scopes active at the same time in different threads (or interleaved coroutines)
311
+ will race on `sys.path`, the environment variables, and `sys.modules`, and can
312
+ corrupt each other's save/restore. Use `pydepinject` from a single thread of
313
+ control. If you need dependencies isolated across concurrent workers, give each
314
+ worker its own **process**.
315
+ - **Already-imported modules are handled best-effort.** If a package was already
316
+ imported earlier in the process from a different location, swapping it for the
317
+ venv's copy within the scope cannot be guaranteed — code holding a reference to
318
+ the previously imported module keeps using it.
319
+ - **It mutates the running interpreter, by design.** This makes it ideal for
320
+ scripts, notebooks, plugins, and ad-hoc tooling, but it is **not** a substitute
321
+ for a properly provisioned environment or a lockfile in a production
322
+ application. For those, prefer real environment/dependency management.
323
+
324
+ ## When NOT to use this
325
+
326
+ `pydepinject` is built for **scripts, notebooks, plugins, and ad-hoc tooling or
327
+ CI glue** — cases where "make sure X is importable, install it if not" is the
328
+ whole job. It is the wrong tool, and you should reach for a real
329
+ environment plus a lockfile (`uv`, `pip-tools`, Poetry, etc.) instead, when:
330
+
331
+ - You are building a **deployable application or service** where dependency
332
+ reproducibility matters.
333
+ - Your code runs under **threads, `asyncio`, or parallel workers** — activation
334
+ mutates global interpreter state and is not safe to interleave (see
335
+ [Limitations and thread-safety](#limitations-and-thread-safety); give each
336
+ worker its own process).
337
+ - You need **pinned, audited, reproducible** dependency sets across machines and
338
+ CI runs.
339
+ - Packages are **already imported** earlier in the process and must be swapped
340
+ mid-run — `pydepinject` can only do this best-effort.
341
+ - You run a **long-lived production process** where persistent venvs accumulating
342
+ under the temp root would be a problem.
343
+
344
+ For the mechanics behind these caveats, see
345
+ [Limitations and thread-safety](#limitations-and-thread-safety) above.
346
+
246
347
  ## Unit Tests
247
348
 
248
349
  Unit tests are provided to verify the functionality of the `requires`. The tests use `pytest` and cover various scenarios including decorator usage, context manager usage, ephemeral environments, and more.
@@ -9,10 +9,5 @@ src/pydepinject.egg-info/SOURCES.txt
9
9
  src/pydepinject.egg-info/dependency_links.txt
10
10
  src/pydepinject.egg-info/requires.txt
11
11
  src/pydepinject.egg-info/top_level.txt
12
- src/requirementmanager.egg-info/PKG-INFO
13
- src/requirementmanager.egg-info/SOURCES.txt
14
- src/requirementmanager.egg-info/dependency_links.txt
15
- src/requirementmanager.egg-info/requires.txt
16
- src/requirementmanager.egg-info/top_level.txt
17
12
  tests/conftest.py
18
13
  tests/test_pydepinject.py
@@ -1,4 +1,4 @@
1
- packaging>=23.0
1
+ packaging>=24.0
2
2
  typing-extensions>=4.4.0
3
3
 
4
4
  [lint]
@@ -7,7 +7,7 @@ import pytest
7
7
  PROJECT_DIR = pathlib.Path(__file__).parent.parent
8
8
 
9
9
 
10
- @pytest.fixture(autouse=True)
10
+ @pytest.fixture(autouse=True) # noqa: RUF076
11
11
  def _check_test_leftovers():
12
12
  """Checks if the test left any files in the project directory."""
13
13
  items_before = list(PROJECT_DIR.iterdir())
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import functools
4
+ import os
5
+ import sys
4
6
 
5
7
  import pytest
6
8
 
@@ -376,6 +378,121 @@ def test_invalid_requirements_install_raises_error_decorator(
376
378
  assert len(list(venv_root.iterdir())) == 1
377
379
 
378
380
 
381
+ @pytest.mark.parametrize(
382
+ "exc",
383
+ [RuntimeError("install boom"), FileNotFoundError("missing binary")],
384
+ ids=["runtimeerror", "filenotfounderror"],
385
+ )
386
+ @pytest.mark.parametrize("ephemeral", [True, False], ids=["ephemeral", "non-ephemeral"])
387
+ def test_failed_activation_restores_global_state(
388
+ venv_root, monkeypatch, ephemeral, exc
389
+ ):
390
+ """A failure during installation must restore sys.path and env vars and
391
+ propagate the original exception type, regardless of ephemeral.
392
+
393
+ The venv is created (so global state has been mutated) before install runs,
394
+ so a raising install exercises the failure-cleanup path.
395
+ """
396
+ with pytest.raises(ImportError):
397
+ import attrs
398
+
399
+ def _boom(*args, **kwargs): # noqa: ARG001 - stub replacing backend.install
400
+ raise exc
401
+
402
+ from pydepinject import backends
403
+
404
+ monkeypatch.setattr(backends.VenvBackendVenv, "install", _boom, raising=True)
405
+
406
+ # Concrete sentinel for PYTHONPATH so restoration is unambiguous; PATH is left
407
+ # at its real value because venv creation shells out and needs it.
408
+ monkeypatch.setenv("PYTHONPATH", "/pdi-sentinel")
409
+ pythonpath_before = os.environ["PYTHONPATH"]
410
+ path_before = os.environ["PATH"]
411
+ syspath_before = sys.path.copy()
412
+
413
+ mgr = requires(
414
+ "attrs", venv_root=venv_root, ephemeral=ephemeral, venv_backend="venv"
415
+ )
416
+
417
+ with pytest.raises(type(exc)):
418
+ mgr()
419
+
420
+ assert sys.path == syspath_before
421
+ assert os.environ["PATH"] == path_before
422
+ assert os.environ["PYTHONPATH"] == pythonpath_before
423
+
424
+ # The import should still fail in the current interpreter.
425
+ with pytest.raises(ImportError):
426
+ import attrs
427
+
428
+
429
+ def test_interrupt_mid_activation_restores_env(venv_root, monkeypatch):
430
+ """An interrupt landing after env mutation but before activation completes
431
+ must still restore PATH/PYTHONPATH.
432
+
433
+ The env vars are set before ``sys.path.insert``; making ``insert`` raise
434
+ reproduces an interrupt in that narrow window. Cleanup must not depend on the
435
+ activation having reached its final ``_activated = True``.
436
+ """
437
+ with pytest.raises(ImportError):
438
+ import attrs
439
+
440
+ monkeypatch.setenv("PYTHONPATH", "/pdi-sentinel")
441
+ pythonpath_before = os.environ["PYTHONPATH"]
442
+ path_before = os.environ["PATH"]
443
+
444
+ # Must be a real list subclass: sys.path has to stay a plain list for
445
+ # CPython's import machinery, so UserList is not an option here.
446
+ class _RaisingInsertList(list): # noqa: FURB189
447
+ def insert(self, *args, **kwargs): # noqa: ARG002, PLR6301 - stub
448
+ raise KeyboardInterrupt("simulated interrupt mid-activation")
449
+
450
+ monkeypatch.setattr(sys, "path", _RaisingInsertList(sys.path))
451
+
452
+ mgr = requires("attrs", venv_root=venv_root, venv_backend="venv")
453
+
454
+ with pytest.raises(KeyboardInterrupt):
455
+ mgr()
456
+
457
+ assert os.environ["PYTHONPATH"] == pythonpath_before
458
+ assert os.environ["PATH"] == path_before
459
+
460
+
461
+ def test_run_command_surfaces_output_on_failure(tmp_path):
462
+ """A failing command must surface its captured output in the raised error.
463
+
464
+ The script is written to a file (rather than ``-c``) so the marker strings
465
+ appear only in the subprocess output, never in the command line itself.
466
+ """
467
+ from pydepinject import backends
468
+
469
+ script_path = tmp_path / "boom.py"
470
+ script_path.write_text(
471
+ "import sys\n"
472
+ "sys.stdout.write('hello-from-stdout\\n')\n"
473
+ "sys.stderr.write('boom-from-stderr\\n')\n"
474
+ "sys.exit(3)\n"
475
+ )
476
+
477
+ backend = backends.VenvBackendVenv(tmp_path)
478
+
479
+ with pytest.raises(RuntimeError) as excinfo:
480
+ backend._run_command([sys.executable, str(script_path)])
481
+
482
+ message = str(excinfo.value)
483
+ assert "boom-from-stderr" in message
484
+ assert "hello-from-stdout" in message
485
+ assert "exit code 3" in message
486
+
487
+
488
+ def test_run_command_success_returns_without_error(tmp_path):
489
+ """A successful command must not raise even though output is now captured."""
490
+ from pydepinject import backends
491
+
492
+ backend = backends.VenvBackendVenv(tmp_path)
493
+ backend._run_command([sys.executable, "-c", "print('ok')"])
494
+
495
+
379
496
  def test_invalid_backend_value_raises_valueerror(venv_root):
380
497
  # Supplying an unknown backend should raise ValueError during initialization
381
498
  with pytest.raises(ValueError, match="Invalid venv_backend: invalid"):
@@ -1,32 +0,0 @@
1
- Metadata-Version: 2.1
2
- Name: requirementmanager
3
- Version: 0.0.1.dev0
4
- Summary: A package to dynamically inject requirements into a virtual environment.
5
- Author-email: Your Name <your.email@example.com>
6
- Maintainer-email: Your Name <your.email@example.com>
7
- License: MIT
8
- Project-URL: homepage, https://github.com/yourusername/requirementmanager
9
- Project-URL: documentation, https://github.com/yourusername/requirementmanager
10
- Project-URL: repository, https://github.com/yourusername/requirementmanager
11
- Keywords: virtualenv,requirements,dependency management
12
- Classifier: Development Status :: 4 - Beta
13
- Classifier: Intended Audience :: Developers
14
- Classifier: Topic :: Software Development :: Libraries
15
- Classifier: License :: OSI Approved :: MIT License
16
- Classifier: Programming Language :: Python :: 3
17
- Classifier: Programming Language :: Python :: 3.9
18
- Classifier: Programming Language :: Python :: 3.10
19
- Classifier: Programming Language :: Python :: 3.11
20
- Classifier: Programming Language :: Python :: 3.12
21
- Classifier: Typing :: Typed
22
- Description-Content-Type: text/x-rst
23
- Provides-Extra: dev
24
- Requires-Dist: ruff; extra == "dev"
25
- Requires-Dist: pytest; extra == "dev"
26
- Requires-Dist: flake8; extra == "dev"
27
- Requires-Dist: black; extra == "dev"
28
- Requires-Dist: pyright; extra == "dev"
29
- Requires-Dist: twine; extra == "dev"
30
- Requires-Dist: wheel; extra == "dev"
31
- Requires-Dist: setuptools; extra == "dev"
32
- Requires-Dist: pre-commit; extra == "dev"
@@ -1,7 +0,0 @@
1
- pyproject.toml
2
- src/pydepinject/__init__.py
3
- src/requirementmanager.egg-info/PKG-INFO
4
- src/requirementmanager.egg-info/SOURCES.txt
5
- src/requirementmanager.egg-info/dependency_links.txt
6
- src/requirementmanager.egg-info/requires.txt
7
- src/requirementmanager.egg-info/top_level.txt
@@ -1,11 +0,0 @@
1
-
2
- [dev]
3
- ruff
4
- pytest
5
- flake8
6
- black
7
- pyright
8
- twine
9
- wheel
10
- setuptools
11
- pre-commit