hostctl 0.1.2__tar.gz → 0.2.0__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 (108) hide show
  1. {hostctl-0.1.2 → hostctl-0.2.0}/CHANGELOG.md +46 -1
  2. {hostctl-0.1.2 → hostctl-0.2.0}/PKG-INFO +17 -1
  3. {hostctl-0.1.2 → hostctl-0.2.0}/README.md +16 -0
  4. {hostctl-0.1.2 → hostctl-0.2.0}/docs/api/reference.md +1 -0
  5. {hostctl-0.1.2 → hostctl-0.2.0}/docs/guide/run.md +105 -2
  6. {hostctl-0.1.2 → hostctl-0.2.0}/pyproject.toml +1 -1
  7. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/AGENTS.md +14 -3
  8. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/__init__.py +2 -0
  9. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/_cli.py +5 -8
  10. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/__init__.py +1 -0
  11. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/_common.py +68 -17
  12. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/shell/_common.py +36 -0
  13. {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/test_live.py +3 -1
  14. {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/test_run_contract.py +15 -17
  15. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_cli.py +6 -4
  16. hostctl-0.2.0/tests/test_exec_command.py +122 -0
  17. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_host_container.py +2 -2
  18. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_system_hosts.py +3 -2
  19. {hostctl-0.1.2 → hostctl-0.2.0}/.gitignore +0 -0
  20. {hostctl-0.1.2 → hostctl-0.2.0}/LICENSE +0 -0
  21. {hostctl-0.1.2 → hostctl-0.2.0}/docs/changelog.md +0 -0
  22. {hostctl-0.1.2 → hostctl-0.2.0}/docs/guide/cli.md +0 -0
  23. {hostctl-0.1.2 → hostctl-0.2.0}/docs/guide/contracts.md +0 -0
  24. {hostctl-0.1.2 → hostctl-0.2.0}/docs/guide/extending.md +0 -0
  25. {hostctl-0.1.2 → hostctl-0.2.0}/docs/guide/path.md +0 -0
  26. {hostctl-0.1.2 → hostctl-0.2.0}/docs/guide/providers.md +0 -0
  27. {hostctl-0.1.2 → hostctl-0.2.0}/docs/guide/transfer.md +0 -0
  28. {hostctl-0.1.2 → hostctl-0.2.0}/docs/index.md +0 -0
  29. {hostctl-0.1.2 → hostctl-0.2.0}/examples/application_provider.py +0 -0
  30. {hostctl-0.1.2 → hostctl-0.2.0}/examples/copy_between_hosts.py +0 -0
  31. {hostctl-0.1.2 → hostctl-0.2.0}/examples/local_run.py +0 -0
  32. {hostctl-0.1.2 → hostctl-0.2.0}/examples/remote_run.py +0 -0
  33. {hostctl-0.1.2 → hostctl-0.2.0}/mkdocs.yml +0 -0
  34. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/__main__.py +0 -0
  35. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/_async.py +0 -0
  36. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/__init__.py +0 -0
  37. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/_common.py +0 -0
  38. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/_qga.py +0 -0
  39. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/container.py +0 -0
  40. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/local.py +0 -0
  41. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/psrp.py +0 -0
  42. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/qemu.py +0 -0
  43. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/serial.py +0 -0
  44. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/ssh.py +0 -0
  45. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/executor/winrm.py +0 -0
  46. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/_local.py +0 -0
  47. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/_ssh.py +0 -0
  48. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/_winrm.py +0 -0
  49. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/composite_path.py +0 -0
  50. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/container.py +0 -0
  51. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/container_path.py +0 -0
  52. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/qemu.py +0 -0
  53. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/serial.py +0 -0
  54. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/host/system.py +0 -0
  55. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/process/__init__.py +0 -0
  56. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/process/_common.py +0 -0
  57. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/process/container.py +0 -0
  58. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/process/psrp.py +0 -0
  59. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/process/qemu_serial.py +0 -0
  60. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/process/serial.py +0 -0
  61. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/process/ssh.py +0 -0
  62. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/provider/__init__.py +0 -0
  63. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/provider/_common.py +0 -0
  64. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/provider/transports.py +0 -0
  65. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/py.typed +0 -0
  66. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/serial/__init__.py +0 -0
  67. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/shell/__init__.py +0 -0
  68. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/shell/cmd.py +0 -0
  69. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/shell/fish.py +0 -0
  70. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/shell/posix.py +0 -0
  71. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/shell/powershell.py +0 -0
  72. {hostctl-0.1.2 → hostctl-0.2.0}/src/hostctl/sync.py +0 -0
  73. {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/__init__.py +0 -0
  74. {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/path_fakes.py +0 -0
  75. {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/providers.py +0 -0
  76. {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/test_composite_path_routing.py +0 -0
  77. {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/test_path_contract.py +0 -0
  78. {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/test_process_contract.py +0 -0
  79. {hostctl-0.1.2 → hostctl-0.2.0}/tests/conformance/test_sync_contract.py +0 -0
  80. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_application_provider.py +0 -0
  81. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_asyncssh_errors.py +0 -0
  82. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_composite_path_propagation.py +0 -0
  83. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_container_path.py +0 -0
  84. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_container_process.py +0 -0
  85. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_host_api.py +0 -0
  86. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_host_local.py +0 -0
  87. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_host_module_layout.py +0 -0
  88. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_host_qemu.py +0 -0
  89. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_host_remote.py +0 -0
  90. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_host_winrm.py +0 -0
  91. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_observability.py +0 -0
  92. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_process.py +0 -0
  93. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_provider_fault_injection.py +0 -0
  94. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_psrp.py +0 -0
  95. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_qemu_executor.py +0 -0
  96. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_qemu_path.py +0 -0
  97. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_qemu_serial.py +0 -0
  98. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_qemu_serial_process.py +0 -0
  99. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_qga_ssh_transport.py +0 -0
  100. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_qga_transport.py +0 -0
  101. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_serial_executor.py +0 -0
  102. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_serial_host.py +0 -0
  103. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_serial_live.py +0 -0
  104. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_shell.py +0 -0
  105. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_ssh_process.py +0 -0
  106. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_sync_helpers.py +0 -0
  107. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_system_host_fidelity.py +0 -0
  108. {hostctl-0.1.2 → hostctl-0.2.0}/tests/test_winrm_path.py +0 -0
@@ -7,6 +7,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-07-28
11
+
12
+ ### Added
13
+
14
+ - `Exec(program, *args)` marks one command for direct execution: the program
15
+ runs with that argv and no shell layer renders. The program and every
16
+ argument may be a `str`, `bytes`, or a path object — all reach the transport
17
+ as text, so the spelling records how the caller held the value rather than
18
+ changing what runs. A **bare program name is now possible**
19
+ (`Exec("ls", "-l")`), resolved through the target's `PATH`; it previously had
20
+ no spelling at all, because a plain string command is always shell text.
21
+ - The full `run()` command syntax is documented in the "Running commands"
22
+ guide: the varargs rule, the three command forms, operators, validation, and
23
+ the PowerShell rendering.
24
+
25
+ ### Changed
26
+
27
+ - **Breaking.** Direct execution is marked by `Exec`, not by position. A path
28
+ in the first argument used to mean "this is the executable, the rest is
29
+ argv"; a path is now an ordinary value that stringifies wherever it appears.
30
+
31
+ ```python
32
+ host.run(Path("/bin/ls"), "-l") # before: direct execution
33
+ host.run(Exec("/bin/ls", "-l")) # now
34
+ ```
35
+
36
+ This is a **silent** change for the old spelling: `run(Path(...), *args)`
37
+ still succeeds, but the values become several `;`-joined shell commands
38
+ instead of one program and its argv, so arguments are subject to shell
39
+ quoting and word-splitting. Anything passing a leading path must be updated;
40
+ there is no deprecation shim.
41
+
42
+ What it buys, both unspellable before: several path commands in one call
43
+ (`run(p1, p2)` is two commands), and direct execution of a `PATH`-resolved
44
+ name.
45
+
46
+ An `Exec` cannot be combined with other commands in one call — there is no
47
+ shell to join them with, and running only one would silently drop the rest.
48
+ It raises `TypeError` rather than choosing.
49
+ - `hostctl run` passes its operand as a single `Exec`, so the program is used
50
+ verbatim. It no longer converts the operand through the target shell's path
51
+ flavour, which means a bare name now resolves through the target's `PATH`
52
+ instead of being treated as a relative path.
53
+
10
54
  ## [0.1.2] - 2026-07-28
11
55
 
12
56
  Supersedes 0.1.1, which was tagged but never published: a release-gate test
@@ -186,6 +230,7 @@ test suite on Python 3.9 through 3.14.
186
230
  assigned to it; a config-less host now builds its own family configuration
187
231
  instead.
188
232
 
189
- [Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.1.2...HEAD
233
+ [Unreleased]: https://github.com/jose-pr/hostctl/compare/v0.2.0...HEAD
234
+ [0.2.0]: https://github.com/jose-pr/hostctl/compare/v0.1.2...v0.2.0
190
235
  [0.1.2]: https://github.com/jose-pr/hostctl/compare/v0.1.0...v0.1.2
191
236
  [0.1.0]: https://github.com/jose-pr/hostctl/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hostctl
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Run commands and access files on a host, local or remote, protocol-agnostic
5
5
  Project-URL: Homepage, https://github.com/jose-pr/hostctl/
6
6
  Project-URL: Documentation, https://jose-pr.github.io/hostctl/
@@ -172,6 +172,22 @@ with Host("ssh://admin:secret@nas.example.com") as same_host:
172
172
  same_host.run("uptime")
173
173
  ```
174
174
 
175
+ `run()` takes one positional argument per command, so a `list` is a single
176
+ command whose elements are each quoted, not a list of commands:
177
+
178
+ ```python
179
+ host.run(["chmod", "755", target]) # one command
180
+ host.run(["chmod", "755", target], ["chown", "u", target]) # two commands
181
+ host.run("chmod", "755", target) # WRONG: three commands
182
+ ```
183
+
184
+ A raw string is verbatim shell text, and `Exec(program, *args)` is direct
185
+ execution — one program plus argv, with no shell layer, where the program may
186
+ be an absolute path or a bare name resolved through the target's `PATH`. A
187
+ `ShellOperator` between two commands joins them conditionally. See the
188
+ [running commands guide](https://jose-pr.github.io/hostctl/guide/run/) for the
189
+ full rules.
190
+
175
191
  A URI may carry `user:password@host` on the way *in* — it is a valid URI, so
176
192
  hostctl accepts it, extracts the password into the credentials, and keeps it
177
193
  out of every rendered form. Passing the same password both in the URI and as an
@@ -118,6 +118,22 @@ with Host("ssh://admin:secret@nas.example.com") as same_host:
118
118
  same_host.run("uptime")
119
119
  ```
120
120
 
121
+ `run()` takes one positional argument per command, so a `list` is a single
122
+ command whose elements are each quoted, not a list of commands:
123
+
124
+ ```python
125
+ host.run(["chmod", "755", target]) # one command
126
+ host.run(["chmod", "755", target], ["chown", "u", target]) # two commands
127
+ host.run("chmod", "755", target) # WRONG: three commands
128
+ ```
129
+
130
+ A raw string is verbatim shell text, and `Exec(program, *args)` is direct
131
+ execution — one program plus argv, with no shell layer, where the program may
132
+ be an absolute path or a bare name resolved through the target's `PATH`. A
133
+ `ShellOperator` between two commands joins them conditionally. See the
134
+ [running commands guide](https://jose-pr.github.io/hostctl/guide/run/) for the
135
+ full rules.
136
+
121
137
  A URI may carry `user:password@host` on the way *in* — it is a valid URI, so
122
138
  hostctl accepts it, extracts the password into the credentials, and keeps it
123
139
  out of every rendered form. Passing the same password both in the URI and as an
@@ -39,6 +39,7 @@ without a deprecation cycle.
39
39
 
40
40
  ## Executors and processes
41
41
 
42
+ ::: hostctl.Exec
42
43
  ::: hostctl.Executor
43
44
  ::: hostctl.ExecutionOptions
44
45
  ::: hostctl.LocalExecutor
@@ -12,10 +12,113 @@ result = host.run("echo hello")
12
12
  print(result.stdout)
13
13
  ```
14
14
 
15
+ ## Command syntax
16
+
17
+ `run(*cmds, **options)` takes **one positional argument per command** — it is
18
+ varargs, not a list of commands. This is the distinction to get right:
19
+
20
+ ```python
21
+ host.run(["chmod", "755", target]) # ONE command
22
+ host.run(["chmod", "755", target], ["chown", "u", target]) # TWO commands
23
+ ```
24
+
25
+ A list is *one* command whose elements are quoted individually. Wrapping
26
+ commands in an outer list does not produce several commands — the inner lists
27
+ are stringified into a single nonsensical argument:
28
+
29
+ ```python
30
+ host.run([["chmod", "755", target]]) # WRONG: one command, mangled
31
+ ```
32
+
33
+ The mirror mistake is passing an argv as separate positionals, which makes each
34
+ element its own command:
35
+
36
+ ```python
37
+ host.run("chmod", "755", target) # WRONG: runs chmod;755;/path
38
+ ```
39
+
40
+ ### The three forms a command may take
41
+
42
+ | Form | Example | Result |
43
+ | --- | --- | --- |
44
+ | Structured `list`/`tuple` | `["chmod", "755", p]` | `chmod 755 '/tmp/a b'` — every element quoted as data |
45
+ | Raw `str` | `"echo $HOME"` | verbatim shell text; globs, pipes, and `$VAR` are interpreted |
46
+ | `Exec(...)` | `Exec("/bin/ls", "-l", p)` | direct execution: one program plus argv, no shell |
47
+
48
+ Prefer the structured form. It quotes each element, so a value containing a
49
+ space, quote, `$`, or `;` is passed as data instead of changing the command.
50
+ Use a raw string only when you want the shell to interpret the text.
51
+
52
+ Elements may be `str`, `bytes`, `int`, or path objects; all are normalized and
53
+ quoted. `["echo", 42, b"by", PurePosixPath("/p q")]` renders as
54
+ `echo 42 by '/p q'`.
55
+
56
+ ### Joining and operators
57
+
58
+ Commands are joined with the flavour's separator (`;` on POSIX). Pass a
59
+ `ShellOperator` *between* two commands to join them conditionally instead:
60
+
61
+ ```python
62
+ from hostctl import ShellOperator
63
+
64
+ host.run(["test", "-f", target], ShellOperator.AND, ["rm", target])
65
+ ```
66
+
67
+ `ShellOperator` provides `AND`, `OR`, `PIPE`, `REDIRECT`, `APPEND`, and
68
+ `SEQUENCE`. An operator that is leading, trailing, or adjacent to another
69
+ raises `ValueError`, and a flavour may reject one it cannot represent
70
+ portably — PowerShell 5 rejects `AND`/`OR`, while PowerShell 7 accepts them.
71
+
72
+ ### `Exec` is direct execution
73
+
74
+ `Exec(program, *args)` runs one program with an argv, with no shell layer
75
+ rendered — nothing quotes, splits, or interprets the values:
76
+
77
+ ```python
78
+ from hostctl import Exec
79
+
80
+ host.run(Exec("/bin/ls", "-l", target)) # absolute path
81
+ host.run(Exec("ls", "-l")) # resolved through the target's PATH
82
+ ```
83
+
84
+ The program and each argument may be a `str`, `bytes`, or a path object. All
85
+ of them reach the transport as text, so the spelling records how you happened
86
+ to hold the value rather than changing what runs. A bare name is only possible
87
+ here: a plain string command is always shell text.
88
+
89
+ Arguments are argv values, never nested commands — a list or tuple raises
90
+ `TypeError` rather than blurring the two.
91
+
92
+ Direct execution replaces the whole call, so an `Exec` cannot be combined with
93
+ other commands: there is no shell to join them with, and running just one
94
+ would silently drop the rest. Give each its own call, or use structured
95
+ commands if you want them joined.
96
+
97
+ Everything else is an ordinary value. A path is just a value that
98
+ stringifies, wherever it appears:
99
+
100
+ ```python
101
+ host.run(["chmod", "755", Path("/srv/app")]) # a quoted argument
102
+ host.run(Path("/bin/a"), Path("/bin/b")) # two shell commands
103
+ ```
104
+
105
+ ### Validation
106
+
107
+ An empty structured command and any control character (including a newline
108
+ inside a value, which could otherwise smuggle in a second command) raise
109
+ `ValueError`. An empty raw string is skipped when joining.
110
+
111
+ ### PowerShell targets
112
+
113
+ PowerShell flavours render a structured command through the `&` call operator
114
+ and append `; exit $LASTEXITCODE` so the remote status propagates:
115
+
116
+ ```
117
+ & 'Get-Item' 'C:/a b'; exit $LASTEXITCODE
118
+ ```
119
+
15
120
  `run()` returns a `subprocess.CompletedProcess`. Highlights:
16
121
 
17
- - Multiple positional commands are joined with `;`. A command given as a
18
- `list`/`tuple` is shell-quoted piece by piece.
19
122
  - `capture_output` may be `True` (both streams), `"stdout"`, `"stderr"`, or
20
123
  `False`.
21
124
  - SSH `input=`, `cwd=`, `env=`, `check=`, `timeout=`, and
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hostctl"
7
- version = "0.1.2"
7
+ version = "0.2.0"
8
8
  authors = [{ name = "Jose A." }]
9
9
  description = "Run commands and access files on a host, local or remote, protocol-agnostic"
10
10
  readme = "README.md"
@@ -57,9 +57,20 @@ shared protocol implementations can be added once; `Shell.__call__` delegates
57
57
  to `Shell.execute()`.
58
58
  `Host` itself supplies the multi-command `run()` provider contract; there is no
59
59
  duplicate host-executor protocol or unused host-options wrapper.
60
- `Shell.execute(path_like)` preserves the path, while string input remains a
61
- string. Executors and hosts distinguish direct commands from shell scripts by
62
- that value type; no duplicate command-kind flag is carried.
60
+ `Exec(program, *args)` is the direct-execution marker for `Host.run()`: one
61
+ program plus argv, no shell layer. The program may be a `str`, `bytes`, or a
62
+ path all reach the transport as text, so a bare name resolves through the
63
+ target's `PATH`. Direct execution replaces the whole call, so an `Exec`
64
+ combined with any other command raises. Everything else is an ordinary value:
65
+ a path is a value that stringifies wherever it appears, so `run(p1, p2)` is two
66
+ shell commands. `Exec` is a deliberately non-iterable frozen dataclass --
67
+ `ShellFlavour.command_text` dispatches structured commands on `Iterable`, and
68
+ an iterable marker would be quoted into an argv instead of taking the direct
69
+ branch; `command_text` raises if an `Exec` reaches it.
70
+ `Shell.execute(command, *args)` keeps its own program-plus-argv signature and
71
+ wraps into an `Exec` only when dispatching to a host `run(*cmds)` and only when
72
+ argv values are present -- `Shell.run` renders every command into one script
73
+ and dispatches it with no args, and that script is shell text, not a program.
63
74
 
64
75
  ## Configuration and lifecycle
65
76
 
@@ -34,6 +34,7 @@ from .executor import (
34
34
  from .host import (
35
35
  ContainerConfig as ContainerConfig,
36
36
  ContainerHost as ContainerHost,
37
+ Exec as Exec,
37
38
  Host as Host,
38
39
  HostConfig as HostConfig,
39
40
  HostInfo as HostInfo,
@@ -146,6 +147,7 @@ __all__ = [
146
147
  "IosConfig",
147
148
  "IosHost",
148
149
  # Executors and processes
150
+ "Exec",
149
151
  "Executor",
150
152
  "ExecutionOptions",
151
153
  "ExecutorCapability",
@@ -8,7 +8,6 @@ import dataclasses
8
8
  import getpass
9
9
  import json
10
10
  import os
11
- from pathlib import PurePath
12
11
  import re
13
12
  import subprocess
14
13
  import sys
@@ -17,7 +16,7 @@ import typing
17
16
 
18
17
  from pathlib_next import Path as NextPath
19
18
 
20
- from .host import Host, HostPath
19
+ from .host import Exec, Host, HostPath
21
20
 
22
21
  _URI_OPERAND = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
23
22
 
@@ -81,13 +80,11 @@ def _command_run(args: argparse.Namespace, stdout, stderr) -> int:
81
80
  if not command:
82
81
  raise ValueError("run requires a command after --")
83
82
  with Host(args.uri, **_credentials(args)) as host:
84
- try:
85
- command_path = host.shell_flavour.command_path(command[0])
86
- except NotImplementedError:
87
- command_path = PurePath(command[0])
83
+ # `Exec` marks direct execution and takes the program verbatim, so the
84
+ # operand needs no path flavour: a bare name resolves through the
85
+ # target's PATH and an absolute path is used as given.
88
86
  result = host.run(
89
- command_path,
90
- *command[1:],
87
+ Exec(command[0], *command[1:]),
91
88
  check=False,
92
89
  capture_output=True,
93
90
  )
@@ -1,6 +1,7 @@
1
1
  """Host contracts and built-in host implementations."""
2
2
 
3
3
  from ._common import (
4
+ Exec as Exec,
4
5
  Host as Host,
5
6
  HostConfig as HostConfig,
6
7
  HostInfo as HostInfo,
@@ -35,30 +35,81 @@ if _ty.TYPE_CHECKING:
35
35
  from ..process import Process, TerminalRequest
36
36
  from ..shell import Shell, ShellFlavour
37
37
 
38
- Command = _ty.Union[str, PathLike, _ty.Sequence[object]]
38
+ #: One argv value. Everything reaching a transport is text, so a program or
39
+ #: argument may be spelled as a string, bytes, or a path object
40
+ #: interchangeably -- the spelling records how the caller held the value, not
41
+ #: what crosses the wire.
42
+ ExecValue = _ty.Union[str, bytes, _PurePath, _Pathname]
43
+
44
+
45
+ @_dc.dataclass(frozen=True)
46
+ class Exec:
47
+ """One command executed directly, with no shell layer.
48
+
49
+ ``Exec(program, *args)`` names a program and its argv. The program may be
50
+ an absolute path or a bare name the target resolves through ``PATH``; a
51
+ bare name has no other spelling, since a plain string is always shell text.
52
+
53
+ This is the explicit marker for direct execution. A path used anywhere
54
+ else is an ordinary value that stringifies, so several commands can be
55
+ written without one of them silently becoming an executable::
56
+
57
+ host.run(Exec("/bin/ls", "-l")) # one direct command
58
+ host.run(Exec("ls", "-l")) # PATH-resolved, no shell
59
+ host.run(Exec("/bin/a"), Exec("/bin/b")) # two direct commands
60
+ host.run(PurePosixPath("/bin/a"), PurePosixPath("/bin/b"))
61
+ # two ordinary shell commands
62
+
63
+ Arguments are argv values, never nested commands: a list or tuple raises
64
+ ``TypeError`` rather than blurring argv and shell semantics.
65
+
66
+ A deliberately non-iterable container. `ShellFlavour.command_text`
67
+ dispatches structured commands on ``Iterable``, so an iterable marker
68
+ would be quoted into an argv string instead of taking the direct branch.
69
+ """
70
+
71
+ program: ExecValue
72
+ args: _ty.Tuple[ExecValue, ...]
73
+
74
+ def __init__(self, program: ExecValue, *args: ExecValue) -> None:
75
+ if not isinstance(program, (str, bytes, _PurePath, _Pathname)):
76
+ raise TypeError("Exec program must be a str, bytes, or path value")
77
+ for value in args:
78
+ if isinstance(value, (tuple, list)):
79
+ raise TypeError("direct command arguments must be scalar values")
80
+ if not isinstance(value, (str, bytes, _PurePath, _Pathname)):
81
+ raise TypeError(
82
+ "direct command arguments must be str, bytes, or path values"
83
+ )
84
+ object.__setattr__(self, "program", program)
85
+ object.__setattr__(self, "args", tuple(args))
86
+
87
+
88
+ Command = _ty.Union[str, PathLike, Exec, _ty.Sequence[object]]
39
89
 
40
90
 
41
91
  def starts_direct_command(
42
92
  cmds: _ty.Sequence[Command],
43
- ) -> _ty.Optional[_ty.Tuple[_ty.Union[_PurePath, _Pathname], _ty.Tuple[object, ...]]]:
44
- """Split a path-led call into one executable and its argv arguments.
93
+ ) -> _ty.Optional[_ty.Tuple[ExecValue, _ty.Tuple[object, ...]]]:
94
+ """Split an :class:`Exec` call into one executable and its argv arguments.
45
95
 
46
- A leading path object is the explicit direct-execution marker. Every
47
- trailing value is an argv scalar; nested command sequences are rejected so
48
- callers cannot accidentally mix shell-command and argv semantics.
96
+ Returns ``None`` unless the call is exactly one `Exec`. Direct execution
97
+ replaces the whole command list rather than joining with anything, so an
98
+ `Exec` alongside other commands is rejected: there is no shell to join
99
+ them with, and silently running only one would lose the rest.
49
100
  """
50
- if not cmds or not isinstance(cmds[0], (_PurePath, _Pathname)):
101
+ if not cmds:
51
102
  return None
52
- command = cmds[0]
53
- argv = tuple(cmds[1:])
54
- for value in argv:
55
- if isinstance(value, (tuple, list)):
56
- raise TypeError("direct command arguments must be scalar values")
57
- if not isinstance(value, (str, bytes, _PurePath, _Pathname)):
58
- raise TypeError(
59
- "direct command arguments must be str, bytes, or path values"
60
- )
61
- return command, argv
103
+ marked = [value for value in cmds if isinstance(value, Exec)]
104
+ if not marked:
105
+ return None
106
+ if len(cmds) > 1:
107
+ raise TypeError(
108
+ "a direct Exec command cannot be combined with other commands; "
109
+ "run it in its own call"
110
+ )
111
+ command = marked[0]
112
+ return command.program, command.args
62
113
 
63
114
 
64
115
  @_dc.dataclass(frozen=True)
@@ -24,6 +24,7 @@ from ..host._common import (
24
24
  CaptureOutput,
25
25
  Command,
26
26
  Environment,
27
+ Exec,
27
28
  FileHandle,
28
29
  Input,
29
30
  PathLike,
@@ -197,6 +198,15 @@ class ShellFlavour(abc.ABC):
197
198
  return self.command_separator.join(assignments)
198
199
 
199
200
  def command_text(self, value: Command) -> str:
201
+ if isinstance(value, Exec):
202
+ # Direct execution bypasses the shell entirely, so an `Exec` that
203
+ # reaches here means a transport did not take its direct branch.
204
+ # Rendering it as a quoted argv would silently reintroduce the
205
+ # shell layer the caller asked to skip.
206
+ raise TypeError(
207
+ "Exec is executed directly and cannot be rendered into a shell "
208
+ "script; this host does not support direct execution"
209
+ )
200
210
  if isinstance(value, (bytes, PurePath, Path, os.PathLike)):
201
211
  return self.quote(value)
202
212
  if isinstance(value, str):
@@ -340,6 +350,24 @@ class Shell(Executor[_Result], typing.Generic[_Result]):
340
350
  item.kind is inspect.Parameter.VAR_KEYWORD
341
351
  for item in self._executor_parameters.values()
342
352
  )
353
+ # A host `run(*cmds)` takes several *commands*; an executor
354
+ # `__call__(command, *args)` takes one command and its argv. Only the
355
+ # former needs the program and argv bundled into one `Exec`, since
356
+ # otherwise each argument would become a separate command. The shapes
357
+ # differ in their first parameter: VAR_POSITIONAL vs a named one.
358
+ positional = [
359
+ item
360
+ for item in self._executor_parameters.values()
361
+ if item.kind
362
+ in (
363
+ inspect.Parameter.POSITIONAL_ONLY,
364
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
365
+ inspect.Parameter.VAR_POSITIONAL,
366
+ )
367
+ ]
368
+ self._execute_takes_commands = bool(positional) and (
369
+ positional[0].kind is inspect.Parameter.VAR_POSITIONAL
370
+ )
343
371
  published = getattr(executor, "executor_capabilities", None)
344
372
  if published is None:
345
373
  inferred = set()
@@ -467,6 +495,14 @@ class Shell(Executor[_Result], typing.Generic[_Result]):
467
495
  ]
468
496
  if unsupported:
469
497
  raise TypeError(f"executor does not accept {sorted(unsupported)[0]}")
498
+ if executor_args and self._execute_takes_commands:
499
+ # A host `run(*cmds)` treats several positionals as several
500
+ # commands, so a program and its argv must arrive as one `Exec`
501
+ # rather than relying on position to mark direct execution.
502
+ # Only when there *are* argv values: `Shell.run` renders every
503
+ # command into one script and dispatches it here with no args, and
504
+ # that script is shell text to interpret, not a program to exec.
505
+ return self._execute(Exec(command, *executor_args), **options)
470
506
  return self._execute(command, *executor_args, **options)
471
507
 
472
508
  __call__ = execute
@@ -13,6 +13,8 @@ from pathlib import Path
13
13
 
14
14
  import pytest
15
15
 
16
+ from hostctl import Exec
17
+
16
18
  from .providers import live_providers, provider_context
17
19
 
18
20
 
@@ -22,7 +24,7 @@ def test_live_provider_direct_command(provider):
22
24
  pytest.skip(f"{provider.name} does not advertise run")
23
25
  with provider_context(provider) as host:
24
26
  if provider.name == "local":
25
- result = host.run(Path(sys.executable), "-c", "print('live')")
27
+ result = host.run(Exec(sys.executable, "-c", "print('live')"))
26
28
  else:
27
29
  command = (
28
30
  "Write-Output live"
@@ -10,6 +10,8 @@ from pathlib import Path
10
10
 
11
11
  import pytest
12
12
 
13
+ from hostctl import Exec
14
+
13
15
  from .providers import fake_providers, live_providers, provider_context
14
16
 
15
17
 
@@ -24,10 +26,12 @@ def test_direct_argv_and_capture(provider):
24
26
  pytest.skip(f"{provider.name} has no run capability")
25
27
  with provider_context(provider) as host:
26
28
  result = host.run(
27
- Path(sys.executable),
28
- "-c",
29
- "import sys; print(sys.argv[1]); print('err', file=sys.stderr)",
30
- "a & b",
29
+ Exec(
30
+ sys.executable,
31
+ "-c",
32
+ "import sys; print(sys.argv[1]); print('err', file=sys.stderr)",
33
+ "a & b",
34
+ )
31
35
  )
32
36
  assert result.returncode == 0
33
37
  assert result.stdout == b"a & b\r\n" if os.name == "nt" else b"a & b\n"
@@ -48,19 +52,17 @@ def test_text_env_and_nonzero_check(provider):
48
52
  environment["SystemRoot"] = os.environ["SystemRoot"]
49
53
  with provider_context(provider) as host:
50
54
  result = host.run(
51
- Path(sys.executable),
52
- "-c",
53
- code,
55
+ Exec(sys.executable, "-c", code),
54
56
  env=environment,
55
57
  text=True,
56
58
  )
57
59
  assert result.stdout.splitlines()[0] == "42"
58
60
  failed = host.run(
59
- Path(sys.executable), "-c", "raise SystemExit(3)", check=False
61
+ Exec(sys.executable, "-c", "raise SystemExit(3)"), check=False
60
62
  )
61
63
  assert failed.returncode == 3
62
64
  with pytest.raises(subprocess.CalledProcessError):
63
- host.run(Path(sys.executable), "-c", "raise SystemExit(4)")
65
+ host.run(Exec(sys.executable, "-c", "raise SystemExit(4)"))
64
66
 
65
67
 
66
68
  @pytest.mark.parametrize("provider", fake_providers(), ids=lambda p: p.name)
@@ -71,9 +73,7 @@ def test_cwd_is_applied_only_when_supported(provider, tmp_path):
71
73
  pytest.skip(f"{provider.name} does not support cwd")
72
74
  with provider_context(provider) as host:
73
75
  result = host.run(
74
- Path(sys.executable),
75
- "-c",
76
- "import pathlib; print(pathlib.Path.cwd())",
76
+ Exec(sys.executable, "-c", "import pathlib; print(pathlib.Path.cwd())"),
77
77
  cwd=tmp_path,
78
78
  text=True,
79
79
  )
@@ -85,7 +85,7 @@ def test_silent_capture_is_empty_bytes(provider):
85
85
  if "run" not in provider.capabilities:
86
86
  pytest.skip(f"{provider.name} has no run capability")
87
87
  with provider_context(provider) as host:
88
- result = host.run(Path(sys.executable), "-c", "pass")
88
+ result = host.run(Exec(sys.executable, "-c", "pass"))
89
89
  assert result.stdout == b""
90
90
  assert result.stderr == b""
91
91
 
@@ -118,14 +118,12 @@ def test_timeout_and_input_are_subprocess_compatible(provider):
118
118
  pytest.skip(f"{provider.name} does not support {', '.join(sorted(missing))}")
119
119
  with provider_context(provider) as host:
120
120
  result = host.run(
121
- Path(sys.executable),
122
- "-c",
123
- "import sys; print(sys.stdin.read())",
121
+ Exec(sys.executable, "-c", "import sys; print(sys.stdin.read())"),
124
122
  input="payload",
125
123
  text=True,
126
124
  )
127
125
  assert result.stdout.strip() == "payload"
128
126
  with pytest.raises(subprocess.TimeoutExpired):
129
127
  host.run(
130
- Path(sys.executable), "-c", "import time; time.sleep(2)", timeout=0.01
128
+ Exec(sys.executable, "-c", "import time; time.sleep(2)"), timeout=0.01
131
129
  )
@@ -2,10 +2,9 @@ import io
2
2
  import json
3
3
  import subprocess
4
4
  import types
5
- from pathlib import PurePosixPath
6
-
7
5
  import pytest
8
6
 
7
+ from hostctl import Exec
9
8
  from hostctl._cli import _parser, main
10
9
 
11
10
 
@@ -36,7 +35,7 @@ def test_run_propagates_status_and_streams():
36
35
  assert stderr.getvalue().strip() == b"err"
37
36
 
38
37
 
39
- def test_run_marks_direct_command_with_target_shell_path_flavour(monkeypatch):
38
+ def test_run_marks_the_operand_as_one_direct_command(monkeypatch):
40
39
  seen = []
41
40
 
42
41
  class _Host:
@@ -56,8 +55,11 @@ def test_run_marks_direct_command_with_target_shell_path_flavour(monkeypatch):
56
55
 
57
56
  monkeypatch.setattr("hostctl._cli.Host", lambda *args, **kwargs: _Host())
58
57
 
58
+ # One `Exec`, not a leading path plus positionals: the operand is a single
59
+ # direct command, and the program is passed verbatim so a bare name
60
+ # resolves through the target's PATH.
59
61
  assert main(["run", "ssh://host", "--", "/bin/sh", "-c", "true"]) == 0
60
- assert seen == [(PurePosixPath("/bin/sh"), "-c", "true")]
62
+ assert seen == [(Exec("/bin/sh", "-c", "true"),)]
61
63
 
62
64
 
63
65
  def test_cat_ls_cp_and_info(tmp_path):
@@ -0,0 +1,122 @@
1
+ """`Exec` marks direct execution explicitly, rather than by position."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from pathlib import PurePosixPath
8
+
9
+ import pytest
10
+ from pathlib_next import PosixPathname
11
+
12
+ from hostctl import Exec, LocalHost, POSIX_SHELL, POWERSHELL
13
+ from hostctl.host._common import starts_direct_command
14
+
15
+
16
+ def test_exec_accepts_str_bytes_and_path_programs():
17
+ # Everything reaching a transport is text, so how the caller held the
18
+ # value is not meant to change what runs.
19
+ for program in (
20
+ "/bin/ls",
21
+ b"/bin/ls",
22
+ PurePosixPath("/bin/ls"),
23
+ PosixPathname("/bin/ls"),
24
+ ):
25
+ command = Exec(program, "-l")
26
+ assert command.program == program
27
+ assert command.args == ("-l",)
28
+
29
+
30
+ def test_exec_is_not_iterable_so_a_shell_cannot_quote_it_as_argv():
31
+ # `ShellFlavour.command_text` dispatches structured commands on Iterable.
32
+ # An iterable marker would be silently rendered into a quoted argv,
33
+ # reintroducing the shell layer the caller asked to skip.
34
+ import collections.abc
35
+
36
+ assert not isinstance(Exec("ls"), collections.abc.Iterable)
37
+
38
+
39
+ def test_exec_reaching_a_shell_script_raises_rather_than_being_rendered():
40
+ with pytest.raises(TypeError, match="executed directly"):
41
+ POSIX_SHELL.script([Exec("ls", "-l")])
42
+
43
+
44
+ def test_exec_rejects_nested_commands_and_non_scalar_values():
45
+ with pytest.raises(TypeError, match="scalar values"):
46
+ Exec("ls", ["nested"])
47
+ with pytest.raises(TypeError, match="program must be"):
48
+ Exec(["ls"])
49
+ with pytest.raises(TypeError, match="program must be"):
50
+ Exec(42)
51
+
52
+
53
+ def test_exec_dispatches_as_one_direct_command():
54
+ assert starts_direct_command([Exec("/bin/ls", "-l")]) == ("/bin/ls", ("-l",))
55
+
56
+
57
+ def test_exec_cannot_be_combined_with_other_commands():
58
+ # There is no shell to join them with, and running only one would
59
+ # silently drop the rest.
60
+ with pytest.raises(TypeError, match="cannot be combined"):
61
+ starts_direct_command([Exec("/bin/a"), Exec("/bin/b")])
62
+ with pytest.raises(TypeError, match="cannot be combined"):
63
+ starts_direct_command([Exec("/bin/a"), "echo hi"])
64
+
65
+
66
+ def test_a_bare_path_is_an_ordinary_value_not_an_executable():
67
+ # The change this makes possible: several paths are several commands,
68
+ # because no position is special any more.
69
+ assert (
70
+ starts_direct_command([PurePosixPath("/bin/a"), PurePosixPath("/bin/b")])
71
+ is None
72
+ )
73
+ assert (
74
+ POSIX_SHELL.script([PurePosixPath("/bin/a"), PurePosixPath("/bin/b")])
75
+ == "/bin/a;/bin/b"
76
+ )
77
+
78
+
79
+ def test_a_path_inside_a_structured_command_stays_a_quoted_argument():
80
+ assert (
81
+ POSIX_SHELL.script([["chmod", "755", PurePosixPath("/srv/a b")]])
82
+ == "chmod 755 '/srv/a b'"
83
+ )
84
+
85
+
86
+ def test_local_exec_runs_the_program_without_a_shell():
87
+ result = LocalHost().run(
88
+ Exec(sys.executable, "-c", "import sys; print(sys.argv[1])", "a & b"),
89
+ text=True,
90
+ )
91
+
92
+ # `a & b` survives verbatim: no shell interpreted the `&`.
93
+ assert result.stdout.strip() == "a & b"
94
+
95
+
96
+ def test_local_exec_resolves_a_bare_program_name_through_path():
97
+ # Previously unspellable: a bare string is always shell text, so direct
98
+ # execution required knowing an absolute path.
99
+ name = "cmd.exe" if sys.platform == "win32" else "echo"
100
+ args = ("/c", "echo", "hi") if sys.platform == "win32" else ("hi",)
101
+
102
+ result = LocalHost().run(Exec(name, *args), text=True)
103
+
104
+ assert result.stdout.strip() == "hi"
105
+
106
+
107
+ def test_local_exec_reports_status_like_subprocess():
108
+ failed = LocalHost().run(
109
+ Exec(sys.executable, "-c", "raise SystemExit(3)"), check=False
110
+ )
111
+ assert failed.returncode == 3
112
+
113
+ with pytest.raises(subprocess.CalledProcessError):
114
+ LocalHost().run(Exec(sys.executable, "-c", "raise SystemExit(4)"))
115
+
116
+
117
+ def test_powershell_structured_command_still_renders_through_the_call_operator():
118
+ # `Exec` changes direct execution only; structured commands are untouched.
119
+ assert (
120
+ POWERSHELL.script([["Get-Item", "C:/a b"]])
121
+ == "& 'Get-Item' 'C:/a b'; exit $LASTEXITCODE"
122
+ )
@@ -8,6 +8,7 @@ from pathlib import PurePosixPath
8
8
 
9
9
  import pytest
10
10
 
11
+ from hostctl import Exec
11
12
  from hostctl.host._common import HostConfig
12
13
  from hostctl.executor.container import normalize_container_error
13
14
  from hostctl.host.container import ContainerConfig, ContainerHost
@@ -124,8 +125,7 @@ def test_container_auto_shell_uses_inspected_os(os_name, flavour):
124
125
  def test_container_direct_argv_preserves_arguments_and_context():
125
126
  host, _, container = _host(user="1000", workdir="/default")
126
127
  result = host.run(
127
- PurePosixPath("/opt/my tool"),
128
- "value with spaces",
128
+ Exec(PurePosixPath("/opt/my tool"), "value with spaces"),
129
129
  cwd="/override",
130
130
  env={"COUNT": 3},
131
131
  text=True,
@@ -5,6 +5,7 @@ import pytest
5
5
  from pathlib_next.mempath import MemPath, MemPathBackend
6
6
 
7
7
  from hostctl import (
8
+ Exec,
8
9
  HostConfig,
9
10
  OperationNotStarted,
10
11
  PathProvider,
@@ -168,7 +169,7 @@ def test_direct_path_command_without_argv_capability_renders_one_quoted_command(
168
169
  return subprocess.CompletedProcess((command, *args), 0, b"ok", b"")
169
170
 
170
171
  host = PosixHost(executor_providers=(ExecutorProvider("shell", execute),))
171
- host.run(HostPath("printf"), "a & b", check=False)
172
+ host.run(Exec("printf", "a & b"), check=False)
172
173
 
173
174
  assert len(calls) == 1
174
175
  rendered, args = calls[0]
@@ -191,7 +192,7 @@ def test_executor_fallback_replans_capabilities_before_retrying():
191
192
  first = ExecutorProvider("first", declined, capabilities=("args", "cwd"))
192
193
  second = ExecutorProvider("second", selected)
193
194
  host = PosixHost(executor_providers=(first, second))
194
- host.run(HostPath("printf"), "a & b", cwd="/tmp", check=False)
195
+ host.run(Exec("printf", "a & b"), cwd="/tmp", check=False)
195
196
 
196
197
  assert calls[0][0] == "first"
197
198
  assert calls[1][0] == "second"
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
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