penrun 0.2.0__tar.gz → 0.2.2__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.
- penrun-0.2.2/PKG-INFO +159 -0
- penrun-0.2.2/README.md +148 -0
- {penrun-0.2.0 → penrun-0.2.2}/pyproject.toml +10 -18
- {penrun-0.2.0 → penrun-0.2.2}/src/penrun/artifacts.py +1 -1
- {penrun-0.2.0 → penrun-0.2.2}/src/penrun/locking.py +2 -1
- penrun-0.2.0/PKG-INFO +0 -7
- {penrun-0.2.0 → penrun-0.2.2}/src/penrun/__init__.py +0 -0
- {penrun-0.2.0 → penrun-0.2.2}/src/penrun/__main__.py +0 -0
- {penrun-0.2.0 → penrun-0.2.2}/src/penrun/api.py +0 -0
- {penrun-0.2.0 → penrun-0.2.2}/src/penrun/batch.py +0 -0
- {penrun-0.2.0 → penrun-0.2.2}/src/penrun/cli.py +0 -0
- {penrun-0.2.0 → penrun-0.2.2}/src/penrun/config.py +0 -0
- {penrun-0.2.0 → penrun-0.2.2}/src/penrun/constants.py +0 -0
- {penrun-0.2.0 → penrun-0.2.2}/src/penrun/exceptions.py +0 -0
- {penrun-0.2.0 → penrun-0.2.2}/src/penrun/output.py +0 -0
- {penrun-0.2.0 → penrun-0.2.2}/src/penrun/process.py +0 -0
- {penrun-0.2.0 → penrun-0.2.2}/src/penrun/py.typed +0 -0
- {penrun-0.2.0 → penrun-0.2.2}/src/penrun/runner.py +0 -0
penrun-0.2.2/PKG-INFO
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: penrun
|
|
3
|
+
Version: 0.2.2
|
|
4
|
+
Summary: Run any command inside a well-defined, timestamped artifacts directory.
|
|
5
|
+
Author: AISEC Pentesting Team
|
|
6
|
+
License: Apache-2.0
|
|
7
|
+
Maintainer: Stefan Tatschner, Tobias Specht
|
|
8
|
+
Maintainer-email: Stefan Tatschner <stefan.tatschner@aisec.fraunhofer.de>, Tobias Specht <tobias.specht@aisec.fraunhofer.de>
|
|
9
|
+
Requires-Python: >=3.14
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
<!--
|
|
13
|
+
SPDX-FileCopyrightText: AISEC Pentesting Team
|
|
14
|
+
|
|
15
|
+
SPDX-License-Identifier: CC0-1.0
|
|
16
|
+
-->
|
|
17
|
+
|
|
18
|
+
# penrun
|
|
19
|
+
|
|
20
|
+
This tool was initially rescued from the gallia [commit history](https://github.com/Fraunhofer-AISEC/gallia/commit/985664905b6ef352db6fdc895af2ac79492539da) and ported to Python.
|
|
21
|
+
|
|
22
|
+
`penrun` runs any command inside a well-defined, timestamped artifacts directory. Point it at a test,
|
|
23
|
+
scan, or anything else you invoke repeatedly, and every run gets its own directory with the command's
|
|
24
|
+
compressed output and exit code - so results are never overwritten and old runs stay reproducible.
|
|
25
|
+
|
|
26
|
+
## Requirements
|
|
27
|
+
|
|
28
|
+
Python 3.14+, standard library only. No runtime dependencies.
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
```console
|
|
33
|
+
$ uvx penrun -h
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Without installing, `uv run penrun` also works straight out of a checkout.
|
|
37
|
+
|
|
38
|
+
## Quick start: normal mode
|
|
39
|
+
|
|
40
|
+
Run any command; `penrun` creates `penrun-artifacts/<command>/run-<timestamp>/` for it:
|
|
41
|
+
|
|
42
|
+
```console
|
|
43
|
+
$ penrun ls -lah
|
|
44
|
+
$ tree penrun-artifacts/ls
|
|
45
|
+
penrun-artifacts/ls
|
|
46
|
+
├── LATEST -> run-20260723-101334.123456
|
|
47
|
+
└── run-20260723-101334.123456
|
|
48
|
+
├── META.json # command, start/end time, exit code, environment (with -e), error (on failure)
|
|
49
|
+
└── OUTPUT.zst # combined stdout+stderr, zstd-compressed
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`LATEST` always points at the most recent run of that command, regardless of tags or where
|
|
53
|
+
`artifacts_template` places the timestamped directory (see below).
|
|
54
|
+
|
|
55
|
+
`penrun` sets these environment variables for COMMAND:
|
|
56
|
+
|
|
57
|
+
- `PENRUN_COMMAND` - the command as run, shell-quoted.
|
|
58
|
+
- `PENRUN_ARTIFACTS` - the absolute path of this run's artifacts directory.
|
|
59
|
+
- `PENRUN_BATCH_MODE=1` - only in batch mode.
|
|
60
|
+
- `PENRUN_PWD_CONF`/`PENRUN_GIT_ROOT_CONF`/`PENRUN_USER_CONF` - path of each config file found while
|
|
61
|
+
searching (see [Configuration](#configuration)), regardless of which one was actually used.
|
|
62
|
+
|
|
63
|
+
Run `penrun -h` for the full list of flags.
|
|
64
|
+
|
|
65
|
+
## Quick start: batch mode
|
|
66
|
+
|
|
67
|
+
Batch mode (`-b`) reads a command - or command fragment - per line from stdin and runs each one,
|
|
68
|
+
sequentially by default (`-j N` for up to `N` in parallel):
|
|
69
|
+
|
|
70
|
+
```console
|
|
71
|
+
$ printf 'true\nfalse\ntrue\n' | penrun -b
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Pass `-T` with a template to turn plain input lines into full commands; `{1}` is replaced with the
|
|
75
|
+
line. Since every job below runs `curl`, add `-N` to name each job's directory after its input line
|
|
76
|
+
instead of the program name - otherwise they'd all land in the same `curl/` directory:
|
|
77
|
+
|
|
78
|
+
```console
|
|
79
|
+
$ printf 'example.com\nexample.org\n' | penrun -b -N -T 'curl -Lo {1}.html https://{1}'
|
|
80
|
+
$ tree -L 2 penrun-artifacts/curl
|
|
81
|
+
penrun-artifacts/curl
|
|
82
|
+
├── LATEST -> run-20260723-101334.123456
|
|
83
|
+
└── run-20260723-101334.123456
|
|
84
|
+
├── example.com
|
|
85
|
+
└── example.org
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
For programmatic callers that already know each job's command and artifacts directory name, `-J`/`--json`
|
|
89
|
+
reads newline-delimited JSON objects instead of plain lines - one `{"executable": "...", "args": [...],
|
|
90
|
+
"dirname": "..."}` per line (`dirname` is optional, falling back to `executable`'s name):
|
|
91
|
+
|
|
92
|
+
```console
|
|
93
|
+
$ printf '%s\n' \
|
|
94
|
+
'{"executable": "curl", "args": ["-Lo", "example.com.html", "https://example.com"], "dirname": "example.com"}' \
|
|
95
|
+
'{"executable": "curl", "args": ["-Lo", "example.org.html", "https://example.org"], "dirname": "example.org"}' \
|
|
96
|
+
| penrun -b -J
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Run `penrun -h` for the rest of the batch flags (`-S`, `-u`, ...).
|
|
100
|
+
|
|
101
|
+
## Python API
|
|
102
|
+
|
|
103
|
+
`penrun` exposes the same functionality as an importable library, so other Python tools can drive it
|
|
104
|
+
without going through the CLI.
|
|
105
|
+
|
|
106
|
+
Single command - equivalent to `penrun -t staging curl https://example.com`:
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
import penrun
|
|
110
|
+
|
|
111
|
+
options = penrun.RunOptions(config=penrun.Config(artifacts_base="penrun-artifacts"), tag="staging")
|
|
112
|
+
penrun.run(["curl", "https://example.com"], options)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Batch, every job running the same program - equivalent to the `-J` example above:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
batch = penrun.Batch(
|
|
119
|
+
executable="curl",
|
|
120
|
+
shared_args=["-Lo"],
|
|
121
|
+
jobs=[
|
|
122
|
+
penrun.BatchJob(args=["example.com.html", "https://example.com"], dirname="example.com"),
|
|
123
|
+
penrun.BatchJob(args=["example.org.html", "https://example.org"], dirname="example.org"),
|
|
124
|
+
],
|
|
125
|
+
)
|
|
126
|
+
penrun.run_batched(batch, options, group_by_executable=True, schedule=penrun.ScheduleOptions(max_workers=2))
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Batch mixing completely different programs, by overriding `executable` on the jobs that don't run
|
|
130
|
+
`Batch.executable`:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
batch = penrun.Batch(
|
|
134
|
+
executable="nmap",
|
|
135
|
+
jobs=[
|
|
136
|
+
penrun.BatchJob(args=["-oA", "scan", "example.com"], dirname="nmap"),
|
|
137
|
+
penrun.BatchJob(executable="curl", args=["-I", "https://example.com"], dirname="curl-head"),
|
|
138
|
+
],
|
|
139
|
+
)
|
|
140
|
+
penrun.run_batched(batch, options)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Errors raise `penrun.PenrunError` subclasses (e.g. `penrun.CommandNotFoundError`, `penrun.ConfigError`,
|
|
144
|
+
each carrying a matching `.exit_code`) instead of exiting the process. If `RunOptions.timeout`/`-w` fires,
|
|
145
|
+
COMMAND is killed and Python's built-in `TimeoutError` is raised instead.
|
|
146
|
+
|
|
147
|
+
## Configuration
|
|
148
|
+
|
|
149
|
+
`penrun` looks for a config file in this order and stops at the first match: `./.penrun.toml`,
|
|
150
|
+
`$(git rev-parse --show-toplevel)/.penrun.toml`, then `~/.config/penrun/config.toml`. See
|
|
151
|
+
[`config.toml`](config.toml) for all available keys with explanations.
|
|
152
|
+
|
|
153
|
+
## Development
|
|
154
|
+
|
|
155
|
+
```console
|
|
156
|
+
$ just lint
|
|
157
|
+
$ just fmt
|
|
158
|
+
$ just test
|
|
159
|
+
```
|
penrun-0.2.2/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
SPDX-FileCopyrightText: AISEC Pentesting Team
|
|
3
|
+
|
|
4
|
+
SPDX-License-Identifier: CC0-1.0
|
|
5
|
+
-->
|
|
6
|
+
|
|
7
|
+
# penrun
|
|
8
|
+
|
|
9
|
+
This tool was initially rescued from the gallia [commit history](https://github.com/Fraunhofer-AISEC/gallia/commit/985664905b6ef352db6fdc895af2ac79492539da) and ported to Python.
|
|
10
|
+
|
|
11
|
+
`penrun` runs any command inside a well-defined, timestamped artifacts directory. Point it at a test,
|
|
12
|
+
scan, or anything else you invoke repeatedly, and every run gets its own directory with the command's
|
|
13
|
+
compressed output and exit code - so results are never overwritten and old runs stay reproducible.
|
|
14
|
+
|
|
15
|
+
## Requirements
|
|
16
|
+
|
|
17
|
+
Python 3.14+, standard library only. No runtime dependencies.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```console
|
|
22
|
+
$ uvx penrun -h
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Without installing, `uv run penrun` also works straight out of a checkout.
|
|
26
|
+
|
|
27
|
+
## Quick start: normal mode
|
|
28
|
+
|
|
29
|
+
Run any command; `penrun` creates `penrun-artifacts/<command>/run-<timestamp>/` for it:
|
|
30
|
+
|
|
31
|
+
```console
|
|
32
|
+
$ penrun ls -lah
|
|
33
|
+
$ tree penrun-artifacts/ls
|
|
34
|
+
penrun-artifacts/ls
|
|
35
|
+
├── LATEST -> run-20260723-101334.123456
|
|
36
|
+
└── run-20260723-101334.123456
|
|
37
|
+
├── META.json # command, start/end time, exit code, environment (with -e), error (on failure)
|
|
38
|
+
└── OUTPUT.zst # combined stdout+stderr, zstd-compressed
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`LATEST` always points at the most recent run of that command, regardless of tags or where
|
|
42
|
+
`artifacts_template` places the timestamped directory (see below).
|
|
43
|
+
|
|
44
|
+
`penrun` sets these environment variables for COMMAND:
|
|
45
|
+
|
|
46
|
+
- `PENRUN_COMMAND` - the command as run, shell-quoted.
|
|
47
|
+
- `PENRUN_ARTIFACTS` - the absolute path of this run's artifacts directory.
|
|
48
|
+
- `PENRUN_BATCH_MODE=1` - only in batch mode.
|
|
49
|
+
- `PENRUN_PWD_CONF`/`PENRUN_GIT_ROOT_CONF`/`PENRUN_USER_CONF` - path of each config file found while
|
|
50
|
+
searching (see [Configuration](#configuration)), regardless of which one was actually used.
|
|
51
|
+
|
|
52
|
+
Run `penrun -h` for the full list of flags.
|
|
53
|
+
|
|
54
|
+
## Quick start: batch mode
|
|
55
|
+
|
|
56
|
+
Batch mode (`-b`) reads a command - or command fragment - per line from stdin and runs each one,
|
|
57
|
+
sequentially by default (`-j N` for up to `N` in parallel):
|
|
58
|
+
|
|
59
|
+
```console
|
|
60
|
+
$ printf 'true\nfalse\ntrue\n' | penrun -b
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Pass `-T` with a template to turn plain input lines into full commands; `{1}` is replaced with the
|
|
64
|
+
line. Since every job below runs `curl`, add `-N` to name each job's directory after its input line
|
|
65
|
+
instead of the program name - otherwise they'd all land in the same `curl/` directory:
|
|
66
|
+
|
|
67
|
+
```console
|
|
68
|
+
$ printf 'example.com\nexample.org\n' | penrun -b -N -T 'curl -Lo {1}.html https://{1}'
|
|
69
|
+
$ tree -L 2 penrun-artifacts/curl
|
|
70
|
+
penrun-artifacts/curl
|
|
71
|
+
├── LATEST -> run-20260723-101334.123456
|
|
72
|
+
└── run-20260723-101334.123456
|
|
73
|
+
├── example.com
|
|
74
|
+
└── example.org
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
For programmatic callers that already know each job's command and artifacts directory name, `-J`/`--json`
|
|
78
|
+
reads newline-delimited JSON objects instead of plain lines - one `{"executable": "...", "args": [...],
|
|
79
|
+
"dirname": "..."}` per line (`dirname` is optional, falling back to `executable`'s name):
|
|
80
|
+
|
|
81
|
+
```console
|
|
82
|
+
$ printf '%s\n' \
|
|
83
|
+
'{"executable": "curl", "args": ["-Lo", "example.com.html", "https://example.com"], "dirname": "example.com"}' \
|
|
84
|
+
'{"executable": "curl", "args": ["-Lo", "example.org.html", "https://example.org"], "dirname": "example.org"}' \
|
|
85
|
+
| penrun -b -J
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Run `penrun -h` for the rest of the batch flags (`-S`, `-u`, ...).
|
|
89
|
+
|
|
90
|
+
## Python API
|
|
91
|
+
|
|
92
|
+
`penrun` exposes the same functionality as an importable library, so other Python tools can drive it
|
|
93
|
+
without going through the CLI.
|
|
94
|
+
|
|
95
|
+
Single command - equivalent to `penrun -t staging curl https://example.com`:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
import penrun
|
|
99
|
+
|
|
100
|
+
options = penrun.RunOptions(config=penrun.Config(artifacts_base="penrun-artifacts"), tag="staging")
|
|
101
|
+
penrun.run(["curl", "https://example.com"], options)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Batch, every job running the same program - equivalent to the `-J` example above:
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
batch = penrun.Batch(
|
|
108
|
+
executable="curl",
|
|
109
|
+
shared_args=["-Lo"],
|
|
110
|
+
jobs=[
|
|
111
|
+
penrun.BatchJob(args=["example.com.html", "https://example.com"], dirname="example.com"),
|
|
112
|
+
penrun.BatchJob(args=["example.org.html", "https://example.org"], dirname="example.org"),
|
|
113
|
+
],
|
|
114
|
+
)
|
|
115
|
+
penrun.run_batched(batch, options, group_by_executable=True, schedule=penrun.ScheduleOptions(max_workers=2))
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Batch mixing completely different programs, by overriding `executable` on the jobs that don't run
|
|
119
|
+
`Batch.executable`:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
batch = penrun.Batch(
|
|
123
|
+
executable="nmap",
|
|
124
|
+
jobs=[
|
|
125
|
+
penrun.BatchJob(args=["-oA", "scan", "example.com"], dirname="nmap"),
|
|
126
|
+
penrun.BatchJob(executable="curl", args=["-I", "https://example.com"], dirname="curl-head"),
|
|
127
|
+
],
|
|
128
|
+
)
|
|
129
|
+
penrun.run_batched(batch, options)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Errors raise `penrun.PenrunError` subclasses (e.g. `penrun.CommandNotFoundError`, `penrun.ConfigError`,
|
|
133
|
+
each carrying a matching `.exit_code`) instead of exiting the process. If `RunOptions.timeout`/`-w` fires,
|
|
134
|
+
COMMAND is killed and Python's built-in `TimeoutError` is raised instead.
|
|
135
|
+
|
|
136
|
+
## Configuration
|
|
137
|
+
|
|
138
|
+
`penrun` looks for a config file in this order and stops at the first match: `./.penrun.toml`,
|
|
139
|
+
`$(git rev-parse --show-toplevel)/.penrun.toml`, then `~/.config/penrun/config.toml`. See
|
|
140
|
+
[`config.toml`](config.toml) for all available keys with explanations.
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
```console
|
|
145
|
+
$ just lint
|
|
146
|
+
$ just fmt
|
|
147
|
+
$ just test
|
|
148
|
+
```
|
|
@@ -8,11 +8,16 @@ build-backend = "uv_build"
|
|
|
8
8
|
|
|
9
9
|
[project]
|
|
10
10
|
name = "penrun"
|
|
11
|
-
version = "0.2.
|
|
11
|
+
version = "0.2.2"
|
|
12
12
|
description = "Run any command inside a well-defined, timestamped artifacts directory."
|
|
13
|
+
readme = "README.md"
|
|
13
14
|
requires-python = ">=3.14"
|
|
14
15
|
license = { text = "Apache-2.0" }
|
|
15
16
|
authors = [{ name = "AISEC Pentesting Team" }]
|
|
17
|
+
maintainers = [
|
|
18
|
+
{ name = "Stefan Tatschner", email = "stefan.tatschner@aisec.fraunhofer.de" },
|
|
19
|
+
{ name = "Tobias Specht", email = "tobias.specht@aisec.fraunhofer.de" },
|
|
20
|
+
]
|
|
16
21
|
|
|
17
22
|
[project.scripts]
|
|
18
23
|
penrun = "penrun.cli:main"
|
|
@@ -32,29 +37,16 @@ target-version = "py314"
|
|
|
32
37
|
line-length = 110
|
|
33
38
|
|
|
34
39
|
[tool.ruff.lint]
|
|
35
|
-
select
|
|
36
|
-
|
|
40
|
+
# extend-select adds to ruff's own (already fairly broad) default rule set,
|
|
41
|
+
# instead of replacing it like `select` would - so only list rules here that
|
|
42
|
+
# the default doesn't already cover.
|
|
43
|
+
extend-select = [
|
|
37
44
|
"A", # flake8-builtins
|
|
38
|
-
"ASYNC",# flake8-async
|
|
39
|
-
"C4", # flake8-comprehensions
|
|
40
|
-
"E", # pycodestlye
|
|
41
|
-
"F", # pyflakes
|
|
42
|
-
"FA", # flake8-future-annotations
|
|
43
|
-
"FLY", # flynt
|
|
44
|
-
"FURB", # refurb
|
|
45
|
-
"I", # isort
|
|
46
45
|
"IC", # flake8-import-conventions
|
|
47
|
-
"LOG", # flake8-logging
|
|
48
|
-
"PGH", # pygrep-hooks
|
|
49
|
-
"PIE", # flake8-pie
|
|
50
|
-
"PL", # pylint
|
|
51
|
-
"PTH", # flake8-use-pathlib
|
|
52
46
|
"Q", # flake8-quotes
|
|
53
47
|
"RSE", # flake8-raise
|
|
54
|
-
"RUF100", # unused-noqa
|
|
55
48
|
"TD", # flake8-todos
|
|
56
49
|
"TID", # flake8-tidy-imports
|
|
57
|
-
"UP", # pyupgrade
|
|
58
50
|
]
|
|
59
51
|
|
|
60
52
|
[tool.mypy]
|
|
@@ -45,7 +45,7 @@ def render_artifactsdir(template: str, placeholders: ArtifactsPlaceholders) -> P
|
|
|
45
45
|
|
|
46
46
|
|
|
47
47
|
def timestamp_now() -> str:
|
|
48
|
-
return datetime.now().strftime("%Y%m%d-%H%M%S.%f")
|
|
48
|
+
return datetime.now().astimezone().strftime("%Y%m%d-%H%M%S.%f")
|
|
49
49
|
|
|
50
50
|
|
|
51
51
|
def now_iso() -> str:
|
|
@@ -6,6 +6,7 @@ import fcntl
|
|
|
6
6
|
import os
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from types import TracebackType
|
|
9
|
+
from typing import Self
|
|
9
10
|
|
|
10
11
|
from penrun.output import log
|
|
11
12
|
|
|
@@ -15,7 +16,7 @@ class FileLock:
|
|
|
15
16
|
self._path = path
|
|
16
17
|
self._fd: int | None = None
|
|
17
18
|
|
|
18
|
-
def __enter__(self) ->
|
|
19
|
+
def __enter__(self) -> Self:
|
|
19
20
|
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
21
|
self._path.touch(exist_ok=True)
|
|
21
22
|
self._fd = os.open(self._path, os.O_RDONLY)
|
penrun-0.2.0/PKG-INFO
DELETED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|