Skip to content

Commit 52420b2

Browse files
authored
Merge pull request #9 from mutating/develop
0.0.7
2 parents 046ea89 + 24a7cef commit 52420b2

11 files changed

Lines changed: 1604 additions & 58 deletions

File tree

.github/workflows/benchmarks.yml

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ on:
55
branches:
66
- "main"
77
pull_request:
8-
# `workflow_dispatch` allows CodSpeed to trigger backtest
9-
# performance analysis in order to generate initial data.
108
workflow_dispatch:
119

1210
permissions:
@@ -19,7 +17,6 @@ concurrency:
1917

2018
jobs:
2119
benchmarks:
22-
2320
runs-on: ubuntu-latest
2421
timeout-minutes: 30
2522

@@ -29,7 +26,7 @@ jobs:
2926
- name: Set up Python 3.13
3027
uses: actions/setup-python@v5
3128
with:
32-
python-version: '3.13'
29+
python-version: '3.13'
3330

3431
- name: Set up uv
3532
uses: astral-sh/setup-uv@v7
@@ -49,6 +46,7 @@ jobs:
4946
run: uv pip install --system pytest-codspeed
5047

5148
- name: Run benchmarks
49+
continue-on-error: true
5250
uses: CodSpeedHQ/action@v4
5351
with:
5452
mode: simulation

README.md

Lines changed: 98 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Suby is a small wrapper around the [subprocess](https://docs.python.org/3/librar
3535
- [**Quick start**](#quick-start)
3636
- [**Run subprocess and look at the result**](#run-subprocess-and-look-at-the-result)
3737
- [**Command parsing**](#command-parsing)
38-
- [**Backslashes on Windows**](#backslashes-on-windows)
38+
- [**Managing directory and environment variables**](#managing-directory-and-environment-variables)
3939
- [**Output**](#output)
4040
- [**Logging**](#logging)
4141
- [**Exceptions**](#exceptions)
@@ -57,7 +57,7 @@ And use it:
5757
from suby import run
5858

5959
run('python -c "print(\'hello, world!\')"')
60-
# > hello, world!
60+
#> hello, world!
6161
```
6262

6363
You can also quickly try out this and other packages without installing them, using [instld](https://github.com/pomponchik/instld).
@@ -76,7 +76,7 @@ Let's try to call it:
7676
```python
7777
result = run('python -c "print(\'hello, world!\')"')
7878
print(result)
79-
# > SubprocessResult(id='e9f2d29acb4011ee8957320319d7541c', stdout='hello, world!\n', stderr='', returncode=0, killed_by_token=False)
79+
#> SubprocessResult(id='e9f2d29acb4011ee8957320319d7541c', stdout='hello, world!\n', stderr='', returncode=0, killed_by_token=False)
8080
```
8181

8282
It returns an object of the `SubprocessResult` class, which contains the following fields:
@@ -120,6 +120,8 @@ If you pass `split=False`, you must provide arguments in their final form:
120120
run('python', '-c', 'print(777)', split=False)
121121
```
122122

123+
<details>
124+
<summary>Windows has its own quirks when it comes to backslashes.</summary>
123125

124126
### Backslashes on Windows
125127

@@ -140,6 +142,81 @@ run(r'path\to\executable -c pass', double_backslash=True)
140142

141143
Note that this only affects string arguments that go through `shlex` splitting. `Path` objects and arguments passed with `split=False` are not affected.
142144

145+
</details>
146+
147+
148+
## Managing directory and environment variables
149+
150+
By default, all commands are executed in the same directory and with the same environment variables as the parent process. However, this can be changed.
151+
152+
Pass a string or a `Path` object containing the directory path as the `directory` argument to change the directory itself:
153+
154+
```python
155+
from pathlib import Path
156+
157+
run(
158+
'python -c "import os; print(os.getcwd())"',
159+
directory=Path('project'),
160+
)
161+
```
162+
163+
Relative paths are resolved from the parent process's current working directory at the moment `run` is called, so values like `.`, `..`, and `./../.././project/` are valid when they point to an existing directory:
164+
165+
```python
166+
run('python -c "import os; print(os.getcwd())"', directory='./project/')
167+
```
168+
169+
`directory` is separate from [command parsing](#command-parsing). It is not split with `shlex`, and it is not affected by `split` or `double_backslash`. A directory path containing spaces is passed as one directory path:
170+
171+
```python
172+
run('python -c pass', directory='project with spaces')
173+
```
174+
175+
`directory` does not perform shell-style expansion. If you want a path under your home directory, expand it yourself:
176+
177+
```python
178+
from pathlib import Path
179+
180+
run('python -c pass', directory=Path.home())
181+
```
182+
183+
> ⚠️ If the directory is “broken” — for example, if it does not exist, or if there is a file at the specified path instead of a directory — a `suby.errors.WrongDirectoryError` exception will be raised. This exception will not be [caught](#exceptions) if `catch_exceptions=True` is used.
184+
185+
Use `env` when the subprocess should receive exactly the variables you provide:
186+
187+
```python
188+
run(
189+
'python -c "import os; print(os.environ.get(\'MY_VARIABLE\'))"',
190+
env={'MY_VARIABLE': 'hello'},
191+
)
192+
#> hello
193+
```
194+
195+
> ↑ When `env` is provided, variables from the current process are not added automatically. `env={}` means a truly empty environment, which may make some executables fail on platforms that require system variables.
196+
197+
Use `add_env` to start with the current process environment and add or override selected variables:
198+
199+
```python
200+
run(
201+
'python -c "import os; print(os.environ.get(\'MY_VARIABLE\'))"',
202+
add_env={'MY_VARIABLE': 'hello'},
203+
)
204+
#> hello
205+
```
206+
207+
And use `delete_env` to remove variables from the environment passed to the subprocess:
208+
209+
```python
210+
# If MY_VARIABLE exists in the current process environment, it will not be passed to this subprocess.
211+
run(
212+
'python -c "import os; print(os.environ.get(\'MY_VARIABLE\'))"',
213+
delete_env=['MY_VARIABLE'],
214+
)
215+
#> None
216+
```
217+
218+
> ⓘ On `Windows`, environment variable names are handled case-insensitively. On other platforms, names are case-sensitive.
219+
143220

144221
## Output
145222

@@ -154,7 +231,7 @@ def my_new_stdout(string: str) -> None:
154231
print(colored(string, 'red'), end='')
155232

156233
run('python -c "print(\'hello, world!\')"', stdout_callback=my_new_stdout)
157-
# > hello, world!
234+
#> hello, world!
158235
# You can't see it here, but if you run this code yourself, the output in the console will be red!
159236
```
160237

@@ -191,16 +268,16 @@ logging.basicConfig(
191268
)
192269

193270
run('python -c pass', logger=logging.getLogger('logger_name'))
194-
# > 2024-02-22 02:15:08,155 [INFO] The beginning of the execution of the command "python -c pass".
195-
# > 2024-02-22 02:15:08,190 [INFO] The command "python -c pass" has been successfully executed.
271+
#> 2024-02-22 02:15:08,155 [INFO] The beginning of the execution of the command "python -c pass".
272+
#> 2024-02-22 02:15:08,190 [INFO] The command "python -c pass" has been successfully executed.
196273
```
197274

198275
The message about the start of the command execution is always logged at the `INFO` [level](https://docs.python.org/3/library/logging.html#logging-levels). If the command is completed successfully, the completion message will also be at the `INFO` level. If the command fails, it will be at the `ERROR` level:
199276

200277
```python
201278
run('python -c "raise ValueError"', logger=logging.getLogger('logger_name'), catch_exceptions=True, catch_output=True)
202-
# > 2024-02-22 02:20:25,549 [INFO] The beginning of the execution of the command "python -c "raise ValueError"".
203-
# > 2024-02-22 02:20:25,590 [ERROR] Error when executing the command "python -c "raise ValueError"".
279+
#> 2024-02-22 02:20:25,549 [INFO] The beginning of the execution of the command "python -c "raise ValueError"".
280+
#> 2024-02-22 02:20:25,590 [ERROR] Error when executing the command "python -c "raise ValueError"".
204281
```
205282

206283
If you don't need these details, simply omit the logger argument.
@@ -223,7 +300,7 @@ try:
223300
run('python -c 1/0')
224301
except RunningCommandError as e:
225302
print(e)
226-
# > Error when executing the command "python -c 1/0".
303+
#> Error when executing the command "python -c 1/0".
227304
```
228305

229306
2. If the subprocess cannot be started, `suby` raises `RunningCommandError` with a startup-specific message and chains the original `OSError` as `__cause__`. In this case, the attached `result.stdout` and `result.stderr` stay empty because the process never started:
@@ -235,11 +312,11 @@ try:
235312
run('definitely_missing_command')
236313
except RunningCommandError as e:
237314
print(e)
238-
# > The executable for the command "definitely_missing_command" was not found.
315+
#> The executable for the command "definitely_missing_command" was not found.
239316
print(type(e.__cause__))
240-
# > <class 'FileNotFoundError'>
317+
#> <class 'FileNotFoundError'>
241318
print(e.result.stderr)
242-
# >
319+
#>
243320
```
244321

245322
3. If you pass a [cancellation token](https://cantok.readthedocs.io/en/latest/the_pattern/) when calling the command, and the token is canceled, an exception will be raised [corresponding to the type](https://cantok.readthedocs.io/en/latest/what_are_tokens/exceptions/) of the canceled token. [This feature](#working-with-cancellation-tokens) is integrated with the [cantok](https://cantok.readthedocs.io/en/latest/) library, so we recommend that you familiarize yourself with it first.
@@ -251,7 +328,7 @@ You can prevent `suby` from raising these exceptions. To do this, set the `catch
251328
```python
252329
result = run('python -c "import time; time.sleep(10_000)"', timeout=1, catch_exceptions=True)
253330
print(result)
254-
# > SubprocessResult(id='c9125b90d03111ee9660320319d7541c', stdout='', stderr='', returncode=-9, killed_by_token=True)
331+
#> SubprocessResult(id='c9125b90d03111ee9660320319d7541c', stdout='', stderr='', returncode=-9, killed_by_token=True)
255332
```
256333

257334
Keep in mind that the full result of the subprocess call can also be found through the `result` attribute of any exception raised by `suby`:
@@ -263,9 +340,11 @@ try:
263340
run('python -c "import time; time.sleep(10_000)"', timeout=1)
264341
except TimeoutCancellationError as e:
265342
print(e.result)
266-
# > SubprocessResult(id='a80dc26cd03211eea347320319d7541c', stdout='', stderr='', returncode=-9, killed_by_token=True)
343+
#> SubprocessResult(id='a80dc26cd03211eea347320319d7541c', stdout='', stderr='', returncode=-9, killed_by_token=True)
267344
```
268345

346+
`catch_exceptions=True` applies to the four subprocess-related cases above. Invalid [directory](#changing-directories) values still raise their validation exceptions before a subprocess is started.
347+
269348
<details>
270349
<summary>Notes about callback and token errors</summary>
271350

@@ -300,15 +379,15 @@ from cantok import ConditionToken
300379

301380
token = ConditionToken(lambda: randint(1, 1000) == 7) # This token will be canceled when a random unlikely event occurs.
302381
run('python -c "import time; time.sleep(10_000)"', token=token)
303-
# > cantok.errors.ConditionCancellationError: The cancellation condition was satisfied.
382+
#> cantok.errors.ConditionCancellationError: The cancellation condition was satisfied.
304383
```
305384

306385
However, if you pass the `catch_exceptions=True` argument, the exception will not be raised (see [Exceptions](#exceptions)). Instead, you will get the [usual result](#run-subprocess-and-look-at-the-result) of calling `run` with the `killed_by_token=True` flag:
307386

308387
```python
309388
token = ConditionToken(lambda: randint(1, 1000) == 7)
310389
print(run('python -c "import time; time.sleep(10_000)"', token=token, catch_exceptions=True))
311-
# > SubprocessResult(id='e92ccd54d24b11ee8376320319d7541c', stdout='', stderr='', returncode=-9, killed_by_token=True)
390+
#> SubprocessResult(id='e92ccd54d24b11ee8376320319d7541c', stdout='', stderr='', returncode=-9, killed_by_token=True)
312391
```
313392

314393
Under the hood, token state is checked while `stdout` and `stderr` are being read. When the token is canceled, the subprocess is killed.
@@ -319,7 +398,7 @@ You can set a timeout for `suby`. It must be a number greater than or equal to z
319398

320399
```python
321400
run('python -c "import time; time.sleep(10_000)"', timeout=1)
322-
# > cantok.errors.TimeoutCancellationError: The timeout of 1 seconds has expired.
401+
#> cantok.errors.TimeoutCancellationError: The timeout of 1 seconds has expired.
323402
```
324403

325404
A timeout of `0` is valid and means that the subprocess will be canceled immediately if it has not already exited.
@@ -335,12 +414,12 @@ try:
335414
run('python -c "import time; time.sleep(10_000)"', timeout=1)
336415
except TimeoutCancellationError as e: # As you can see, TimeoutCancellationError is available in the suby module.
337416
print(e)
338-
# > The timeout of 1 seconds has expired.
417+
#> The timeout of 1 seconds has expired.
339418
```
340419

341420
Just as with [regular cancellation tokens](#working-with-cancellation-tokens), you can prevent exceptions from being raised using the `catch_exceptions=True` argument:
342421

343422
```python
344423
print(run('python -c "import time; time.sleep(10_000)"', timeout=1, catch_exceptions=True))
345-
# > SubprocessResult(id='ea88c518d25011eeb25e320319d7541c', stdout='', stderr='', returncode=-9, killed_by_token=True)
424+
#> SubprocessResult(id='ea88c518d25011eeb25e320319d7541c', stdout='', stderr='', returncode=-9, killed_by_token=True)
346425
```

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "suby"
7-
version = "0.0.6"
7+
version = "0.0.7"
88
authors = [
99
{ name="Evgeniy Blinov", email="zheni-b@yandex.ru" },
1010
]

suby/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
from suby.errors import ConditionCancellationError as ConditionCancellationError
2+
from suby.errors import (
3+
EnvironmentVariablesConflict as EnvironmentVariablesConflict,
4+
)
25
from suby.errors import RunningCommandError as RunningCommandError
36
from suby.errors import TimeoutCancellationError as TimeoutCancellationError
47
from suby.errors import WrongCommandError as WrongCommandError
8+
from suby.errors import WrongDirectoryError as WrongDirectoryError
59
from suby.run import run as run

suby/errors.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ class ConditionCancellationError(CantokConditionCancellationError):
88
result: SubprocessResult # pragma: no cover (py314+)
99

1010

11+
class EnvironmentVariablesConflict(ValueError): # noqa: N818
12+
...
13+
14+
1115
class RunningCommandError(Exception):
1216
def __init__(self, message: str, subprocess_result: SubprocessResult) -> None:
1317
self.result = subprocess_result
@@ -20,3 +24,7 @@ class TimeoutCancellationError(CantokTimeoutCancellationError):
2024

2125
class WrongCommandError(Exception):
2226
...
27+
28+
29+
class WrongDirectoryError(ValueError):
30+
...

0 commit comments

Comments
 (0)