winrmxfer 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nagy Szabolcs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,187 @@
1
+ Metadata-Version: 2.4
2
+ Name: winrmxfer
3
+ Version: 0.1.0
4
+ Summary: File transfer over WinRM using base64-encoded PowerShell commands
5
+ Author: Nagy Szabolcs
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Dungscer/WinRMXfer
8
+ Project-URL: Issues, https://github.com/Dungscer/WinRMXfer/issues
9
+ Keywords: winrm,powershell,file-transfer,windows,pywinrm
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: System :: Systems Administration
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: pywinrm>=0.4
18
+ Provides-Extra: kerberos
19
+ Requires-Dist: pywinrm[kerberos]; extra == "kerberos"
20
+ Provides-Extra: credssp
21
+ Requires-Dist: pywinrm[credssp]; extra == "credssp"
22
+ Dynamic: license-file
23
+
24
+ # winrmxfer
25
+
26
+ A Python package for transferring files over WinRM (Windows Remote Management).
27
+
28
+ This package originated from a university thesis project where file transfer over WinRM was required as part of automating tasks on remote Windows machines. The solution — encoding file data as base64 and sending it through PowerShell commands — proved useful enough to be packaged as a standalone library.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install winrmxfer
34
+ ```
35
+
36
+ ## Requirements
37
+
38
+ - Python 3.10+
39
+ - [pywinrm](https://github.com/diyan/pywinrm)
40
+ - WinRM enabled and configured on the target Windows machine
41
+
42
+ ## Quick start
43
+
44
+ ```python
45
+ from winrmxfer import WinRMFileClient
46
+
47
+ # Option 1 — pass credentials, the client creates the WinRM session internally
48
+ client = WinRMFileClient(
49
+ host="192.168.1.100",
50
+ username="Administrator",
51
+ password="s3cr3t",
52
+ transport="ntlm", # ntlm (default), basic, kerberos, credssp
53
+ )
54
+
55
+ # Option 2 — pass an existing winrm.Session
56
+ import winrm
57
+ session = winrm.Session("http://192.168.1.100:5985/wsman", auth=("Administrator", "s3cr3t"))
58
+ client = WinRMFileClient(session=session)
59
+
60
+ # Upload a file
61
+ client.upload("local_file.zip", r"C:\Users\Administrator\Desktop\file.zip")
62
+
63
+ # Download a file
64
+ client.download(r"C:\logs\app.log", "app.log")
65
+ ```
66
+
67
+ ## API
68
+
69
+ ### `WinRMFileClient(host="", username="", password="", transport="ntlm", port=5985, chunk_size=2048, session=None)`
70
+
71
+ Creates a new client. Pass either `host`/`username`/`password` to create a session internally, or an existing `winrm.Session` via the `session` parameter. `chunk_size` controls how many raw bytes are sent per WinRM round-trip (default 2 KB, tuned to stay within PowerShell's `-EncodedCommand` limit).
72
+
73
+ ---
74
+
75
+ ### File transfer
76
+
77
+ #### `upload(local_path, remote_path, progress=None)`
78
+
79
+ Upload a local file to the remote machine. Large files are automatically split into chunks.
80
+
81
+ ```python
82
+ def progress(current_chunk, total_chunks):
83
+ print(f"{current_chunk}/{total_chunks}")
84
+
85
+ client.upload("archive.tar.gz", r"C:\Temp\archive.tar.gz", progress=progress)
86
+ ```
87
+
88
+ #### `download(remote_path, local_path, progress=None)`
89
+
90
+ Download a file from the remote machine. Parent directories on the local side are created automatically.
91
+
92
+ ```python
93
+ client.download(r"C:\Temp\report.pdf", "downloads/report.pdf")
94
+ ```
95
+
96
+ ---
97
+
98
+ ### Remote filesystem operations
99
+
100
+ #### `exists(remote_path) → bool`
101
+
102
+ Return `True` if the remote path exists.
103
+
104
+ #### `stat(remote_path) → dict`
105
+
106
+ Return basic metadata: `is_dir`, `size` (bytes), `last_write_utc`.
107
+
108
+ #### `mkdir(remote_path, parents=True)`
109
+
110
+ Create a directory. With `parents=True` (default), intermediate directories are created as needed.
111
+
112
+ #### `listdir(remote_path) → list[str]`
113
+
114
+ List the names of files and directories inside a remote directory.
115
+
116
+ #### `move(remote_src, remote_dst)`
117
+
118
+ Move a file or directory on the remote machine.
119
+
120
+ #### `copy(remote_src, remote_dst, recursive=False)`
121
+
122
+ Copy a file or directory on the remote machine.
123
+
124
+ #### `remove(remote_path, recursive=False)`
125
+
126
+ Delete a file or directory. Use `recursive=True` to remove a directory tree.
127
+
128
+ ---
129
+
130
+ ## How it works
131
+
132
+ WinRM does not have a built-in file transfer mechanism. `winrmxfer` works around this by:
133
+
134
+ 1. Reading the local file and splitting it into chunks (default 2 KB).
135
+ 2. Base64-encoding each chunk.
136
+ 3. Sending a PowerShell command per chunk that decodes the base64 and writes the bytes to the remote file using `[System.IO.File]` methods.
137
+ 4. Downloads work in reverse: PowerShell reads chunks from the remote file, base64-encodes them, and the client decodes and reassembles the result locally.
138
+
139
+ The 2 KB chunk size is chosen because `pywinrm` wraps every PowerShell script in a UTF-16LE + base64 encoded `-EncodedCommand`, producing roughly 8× the script size as a command-line argument. Larger chunks exceed the WinRM shell's command-line length limit.
140
+
141
+ ## WinRM setup on Windows
142
+
143
+ ```powershell
144
+ # Run as Administrator
145
+ Enable-PSRemoting -Force
146
+ Set-Item WSMan:\localhost\Client\TrustedHosts -Value "*" -Force
147
+ ```
148
+
149
+ For basic authentication (lab use only):
150
+ ```powershell
151
+ Set-Item WSMan:\localhost\Service\Auth\Basic -Value $true
152
+ Set-Item WSMan:\localhost\Service\AllowUnencrypted -Value $true
153
+ ```
154
+
155
+ ## Running the tests
156
+
157
+ The test suite runs against a real WinRM target:
158
+
159
+ ```bash
160
+ pip install pytest
161
+ python -m pytest tests/ -v \
162
+ --host 127.0.0.1 \
163
+ --username Administrator \
164
+ --password s3cr3t \
165
+ --port 5985
166
+ ```
167
+
168
+ Credentials can also be provided via environment variables: `WINRM_HOST`, `WINRM_PORT`, `WINRM_USER`, `WINRM_PASS`, `WINRM_TRANSPORT`.
169
+
170
+ ## Publishing
171
+
172
+ Releases are published to PyPI by the GitHub Actions workflow in `.github/workflows/publish.yml`.
173
+
174
+ - It runs when a tag matching `v*` is pushed (e.g. `git tag v0.1.1 && git push origin v0.1.1`).
175
+ - The build job runs `python -m build` and produces a wheel and an sdist artifact.
176
+ - The publish job uploads them to PyPI using OIDC Trusted Publishing, so no `PYPI_TOKEN` secret is needed.
177
+
178
+ One-time setup on PyPI:
179
+ 1. PyPI → your project → Publishing → "Add a new publisher".
180
+ 2. Enter the GitHub repository, the workflow name `publish.yml` and the environment `pypi`.
181
+
182
+ One-time setup on GitHub:
183
+ - Repository Settings → Environments → create an environment named `pypi` (optionally restrict it to `v*` tags).
184
+
185
+ ## License
186
+
187
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,164 @@
1
+ # winrmxfer
2
+
3
+ A Python package for transferring files over WinRM (Windows Remote Management).
4
+
5
+ This package originated from a university thesis project where file transfer over WinRM was required as part of automating tasks on remote Windows machines. The solution — encoding file data as base64 and sending it through PowerShell commands — proved useful enough to be packaged as a standalone library.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ pip install winrmxfer
11
+ ```
12
+
13
+ ## Requirements
14
+
15
+ - Python 3.10+
16
+ - [pywinrm](https://github.com/diyan/pywinrm)
17
+ - WinRM enabled and configured on the target Windows machine
18
+
19
+ ## Quick start
20
+
21
+ ```python
22
+ from winrmxfer import WinRMFileClient
23
+
24
+ # Option 1 — pass credentials, the client creates the WinRM session internally
25
+ client = WinRMFileClient(
26
+ host="192.168.1.100",
27
+ username="Administrator",
28
+ password="s3cr3t",
29
+ transport="ntlm", # ntlm (default), basic, kerberos, credssp
30
+ )
31
+
32
+ # Option 2 — pass an existing winrm.Session
33
+ import winrm
34
+ session = winrm.Session("http://192.168.1.100:5985/wsman", auth=("Administrator", "s3cr3t"))
35
+ client = WinRMFileClient(session=session)
36
+
37
+ # Upload a file
38
+ client.upload("local_file.zip", r"C:\Users\Administrator\Desktop\file.zip")
39
+
40
+ # Download a file
41
+ client.download(r"C:\logs\app.log", "app.log")
42
+ ```
43
+
44
+ ## API
45
+
46
+ ### `WinRMFileClient(host="", username="", password="", transport="ntlm", port=5985, chunk_size=2048, session=None)`
47
+
48
+ Creates a new client. Pass either `host`/`username`/`password` to create a session internally, or an existing `winrm.Session` via the `session` parameter. `chunk_size` controls how many raw bytes are sent per WinRM round-trip (default 2 KB, tuned to stay within PowerShell's `-EncodedCommand` limit).
49
+
50
+ ---
51
+
52
+ ### File transfer
53
+
54
+ #### `upload(local_path, remote_path, progress=None)`
55
+
56
+ Upload a local file to the remote machine. Large files are automatically split into chunks.
57
+
58
+ ```python
59
+ def progress(current_chunk, total_chunks):
60
+ print(f"{current_chunk}/{total_chunks}")
61
+
62
+ client.upload("archive.tar.gz", r"C:\Temp\archive.tar.gz", progress=progress)
63
+ ```
64
+
65
+ #### `download(remote_path, local_path, progress=None)`
66
+
67
+ Download a file from the remote machine. Parent directories on the local side are created automatically.
68
+
69
+ ```python
70
+ client.download(r"C:\Temp\report.pdf", "downloads/report.pdf")
71
+ ```
72
+
73
+ ---
74
+
75
+ ### Remote filesystem operations
76
+
77
+ #### `exists(remote_path) → bool`
78
+
79
+ Return `True` if the remote path exists.
80
+
81
+ #### `stat(remote_path) → dict`
82
+
83
+ Return basic metadata: `is_dir`, `size` (bytes), `last_write_utc`.
84
+
85
+ #### `mkdir(remote_path, parents=True)`
86
+
87
+ Create a directory. With `parents=True` (default), intermediate directories are created as needed.
88
+
89
+ #### `listdir(remote_path) → list[str]`
90
+
91
+ List the names of files and directories inside a remote directory.
92
+
93
+ #### `move(remote_src, remote_dst)`
94
+
95
+ Move a file or directory on the remote machine.
96
+
97
+ #### `copy(remote_src, remote_dst, recursive=False)`
98
+
99
+ Copy a file or directory on the remote machine.
100
+
101
+ #### `remove(remote_path, recursive=False)`
102
+
103
+ Delete a file or directory. Use `recursive=True` to remove a directory tree.
104
+
105
+ ---
106
+
107
+ ## How it works
108
+
109
+ WinRM does not have a built-in file transfer mechanism. `winrmxfer` works around this by:
110
+
111
+ 1. Reading the local file and splitting it into chunks (default 2 KB).
112
+ 2. Base64-encoding each chunk.
113
+ 3. Sending a PowerShell command per chunk that decodes the base64 and writes the bytes to the remote file using `[System.IO.File]` methods.
114
+ 4. Downloads work in reverse: PowerShell reads chunks from the remote file, base64-encodes them, and the client decodes and reassembles the result locally.
115
+
116
+ The 2 KB chunk size is chosen because `pywinrm` wraps every PowerShell script in a UTF-16LE + base64 encoded `-EncodedCommand`, producing roughly 8× the script size as a command-line argument. Larger chunks exceed the WinRM shell's command-line length limit.
117
+
118
+ ## WinRM setup on Windows
119
+
120
+ ```powershell
121
+ # Run as Administrator
122
+ Enable-PSRemoting -Force
123
+ Set-Item WSMan:\localhost\Client\TrustedHosts -Value "*" -Force
124
+ ```
125
+
126
+ For basic authentication (lab use only):
127
+ ```powershell
128
+ Set-Item WSMan:\localhost\Service\Auth\Basic -Value $true
129
+ Set-Item WSMan:\localhost\Service\AllowUnencrypted -Value $true
130
+ ```
131
+
132
+ ## Running the tests
133
+
134
+ The test suite runs against a real WinRM target:
135
+
136
+ ```bash
137
+ pip install pytest
138
+ python -m pytest tests/ -v \
139
+ --host 127.0.0.1 \
140
+ --username Administrator \
141
+ --password s3cr3t \
142
+ --port 5985
143
+ ```
144
+
145
+ Credentials can also be provided via environment variables: `WINRM_HOST`, `WINRM_PORT`, `WINRM_USER`, `WINRM_PASS`, `WINRM_TRANSPORT`.
146
+
147
+ ## Publishing
148
+
149
+ Releases are published to PyPI by the GitHub Actions workflow in `.github/workflows/publish.yml`.
150
+
151
+ - It runs when a tag matching `v*` is pushed (e.g. `git tag v0.1.1 && git push origin v0.1.1`).
152
+ - The build job runs `python -m build` and produces a wheel and an sdist artifact.
153
+ - The publish job uploads them to PyPI using OIDC Trusted Publishing, so no `PYPI_TOKEN` secret is needed.
154
+
155
+ One-time setup on PyPI:
156
+ 1. PyPI → your project → Publishing → "Add a new publisher".
157
+ 2. Enter the GitHub repository, the workflow name `publish.yml` and the environment `pypi`.
158
+
159
+ One-time setup on GitHub:
160
+ - Repository Settings → Environments → create an environment named `pypi` (optionally restrict it to `v*` tags).
161
+
162
+ ## License
163
+
164
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,38 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "winrmxfer"
7
+ dynamic = ["version"]
8
+ description = "File transfer over WinRM using base64-encoded PowerShell commands"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Nagy Szabolcs" }]
13
+ keywords = ["winrm", "powershell", "file-transfer", "windows", "pywinrm"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: OS Independent",
17
+ "Intended Audience :: Developers",
18
+ "Topic :: System :: Systems Administration",
19
+ ]
20
+ requires-python = ">=3.10"
21
+ dependencies = [
22
+ "pywinrm>=0.4",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ kerberos = ["pywinrm[kerberos]"]
27
+ credssp = ["pywinrm[credssp]"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/Dungscer/WinRMXfer"
31
+ Issues = "https://github.com/Dungscer/WinRMXfer/issues"
32
+
33
+ [tool.setuptools.dynamic]
34
+ version = { attr = "winrmxfer.__version__" }
35
+
36
+ [tool.setuptools.packages.find]
37
+ where = ["."]
38
+ include = ["winrmxfer*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,157 @@
1
+ """Integration tests against a real WinRM target at 10.0.2.15.
2
+
3
+ Run with:
4
+ python -m pytest tests/test_transfer.py -v \
5
+ --host 192.168.0.1 --username Administrator --password <pass>
6
+
7
+ Or set env vars WINRM_HOST, WINRM_USER, WINRM_PASS and run without flags.
8
+ """
9
+
10
+ import os
11
+
12
+ import pytest
13
+
14
+ REMOTE_TMP = r"C:\Temp\winrm_transfer_test"
15
+
16
+
17
+ @pytest.fixture(autouse=True)
18
+ def remote_tmp(client):
19
+ client.mkdir(REMOTE_TMP, parents=True)
20
+ yield
21
+ client.remove(REMOTE_TMP, recursive=True)
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Tests
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ def _remote(name):
30
+ return rf"{REMOTE_TMP}\{name}"
31
+
32
+
33
+ def test_upload_and_download_small_text(client, tmp_path):
34
+ content = b"Hello, WinRM!\n"
35
+ local_src = tmp_path / "src.txt"
36
+ local_dst = tmp_path / "dst.txt"
37
+ local_src.write_bytes(content)
38
+
39
+ client.upload(local_src, _remote("hello.txt"))
40
+ client.download(_remote("hello.txt"), local_dst)
41
+
42
+ assert local_dst.read_bytes() == content
43
+
44
+
45
+ def test_upload_and_download_binary(client, tmp_path):
46
+ content = bytes(range(256)) * 100 # 25 600 bytes with all byte values
47
+ local_src = tmp_path / "binary.bin"
48
+ local_dst = tmp_path / "binary_out.bin"
49
+ local_src.write_bytes(content)
50
+
51
+ client.upload(local_src, _remote("binary.bin"))
52
+ client.download(_remote("binary.bin"), local_dst)
53
+
54
+ assert local_dst.read_bytes() == content
55
+
56
+
57
+ def test_upload_empty_file(client, tmp_path):
58
+ local_src = tmp_path / "empty.txt"
59
+ local_dst = tmp_path / "empty_out.txt"
60
+ local_src.write_bytes(b"")
61
+
62
+ client.upload(local_src, _remote("empty.txt"))
63
+ client.download(_remote("empty.txt"), local_dst)
64
+
65
+ assert local_dst.read_bytes() == b""
66
+
67
+
68
+ def test_upload_large_file_chunked(client, tmp_path):
69
+ # 512 KB — forces multiple 8 KB chunks
70
+ content = os.urandom(512 * 1024)
71
+ local_src = tmp_path / "large.bin"
72
+ local_dst = tmp_path / "large_out.bin"
73
+ local_src.write_bytes(content)
74
+
75
+ chunks_seen = []
76
+ client.upload(
77
+ local_src, _remote("large.bin"), progress=lambda c, t: chunks_seen.append(c)
78
+ )
79
+ client.download(_remote("large.bin"), local_dst)
80
+
81
+ assert local_dst.read_bytes() == content
82
+ assert len(chunks_seen) > 1 # verified chunking happened
83
+
84
+
85
+ def test_exists(client, tmp_path):
86
+ local_src = tmp_path / "x.txt"
87
+ local_src.write_bytes(b"x")
88
+
89
+ assert not client.exists(_remote("x.txt"))
90
+ client.upload(local_src, _remote("x.txt"))
91
+ assert client.exists(_remote("x.txt"))
92
+
93
+
94
+ def test_listdir(client, tmp_path):
95
+ for name in ("a.txt", "b.txt", "c.txt"):
96
+ f = tmp_path / name
97
+ f.write_bytes(b"data")
98
+ client.upload(f, _remote(name))
99
+
100
+ names = client.listdir(REMOTE_TMP)
101
+ assert set(names) == {"a.txt", "b.txt", "c.txt"}
102
+
103
+
104
+ def test_move(client, tmp_path):
105
+ local_src = tmp_path / "move_src.txt"
106
+ local_src.write_bytes(b"move me")
107
+
108
+ client.upload(local_src, _remote("move_src.txt"))
109
+ client.move(_remote("move_src.txt"), _remote("move_dst.txt"))
110
+
111
+ assert not client.exists(_remote("move_src.txt"))
112
+ assert client.exists(_remote("move_dst.txt"))
113
+
114
+
115
+ def test_copy(client, tmp_path):
116
+ local_src = tmp_path / "copy_src.txt"
117
+ local_src.write_bytes(b"copy me")
118
+
119
+ client.upload(local_src, _remote("copy_src.txt"))
120
+ client.copy(_remote("copy_src.txt"), _remote("copy_dst.txt"))
121
+
122
+ assert client.exists(_remote("copy_src.txt"))
123
+ assert client.exists(_remote("copy_dst.txt"))
124
+
125
+
126
+ def test_remove(client, tmp_path):
127
+ local_src = tmp_path / "del.txt"
128
+ local_src.write_bytes(b"delete me")
129
+
130
+ client.upload(local_src, _remote("del.txt"))
131
+ assert client.exists(_remote("del.txt"))
132
+ client.remove(_remote("del.txt"))
133
+ assert not client.exists(_remote("del.txt"))
134
+
135
+
136
+ def test_mkdir_nested(client):
137
+ nested = rf"{REMOTE_TMP}\a\b\c"
138
+ client.mkdir(nested, parents=True)
139
+ assert client.exists(nested)
140
+
141
+
142
+ def test_stat_file(client, tmp_path):
143
+ content = b"stat test"
144
+ local_src = tmp_path / "stat.txt"
145
+ local_src.write_bytes(content)
146
+
147
+ client.upload(local_src, _remote("stat.txt"))
148
+ info = client.stat(_remote("stat.txt"))
149
+
150
+ assert info["is_dir"] is False
151
+ assert info["size"] == len(content)
152
+ assert info["last_write_utc"]
153
+
154
+
155
+ def test_stat_directory(client):
156
+ info = client.stat(REMOTE_TMP)
157
+ assert info["is_dir"] is True
@@ -0,0 +1,4 @@
1
+ from .client import WinRMFileClient
2
+
3
+ __all__ = ["WinRMFileClient"]
4
+ __version__ = "0.1.0"
@@ -0,0 +1,221 @@
1
+ import base64
2
+ from pathlib import Path
3
+ from typing import Callable, Optional
4
+
5
+ import winrm
6
+
7
+ # pywinrm encodes the PS script as UTF-16LE then base64 for -EncodedCommand,
8
+ # giving ~8/3x expansion. With ~250 bytes of PS overhead per chunk, 2 KB of raw
9
+ # data → ~8000 chars of encoded command, just under the WinRM shell limit.
10
+ DEFAULT_CHUNK_SIZE = 2 * 1024
11
+
12
+
13
+ class WinRMFileClient:
14
+ """File transfer client over WinRM using base64-encoded PowerShell commands.
15
+
16
+ Can be constructed in two ways:
17
+
18
+ # From credentials — the client creates the session internally.
19
+ client = WinRMFileClient("host", "user", "pass", transport="ntlm")
20
+
21
+ # From an existing winrm.Session.
22
+ session = winrm.Session("http://host:5985/wsman", auth=("user", "pass"))
23
+ client = WinRMFileClient(session=session)
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ host: str = "",
29
+ username: str = "",
30
+ password: str = "",
31
+ transport: str = "ntlm",
32
+ port: int = 5985,
33
+ chunk_size: int = DEFAULT_CHUNK_SIZE,
34
+ session: Optional[winrm.Session] = None,
35
+ **session_kwargs,
36
+ ):
37
+ self.chunk_size = chunk_size
38
+ if session is not None:
39
+ self.session = session
40
+ else:
41
+ if not host:
42
+ raise ValueError(
43
+ "Provide either 'host'/'username'/'password' or an existing 'session'."
44
+ )
45
+ self.session = winrm.Session(
46
+ target=f"http://{host}:{port}/wsman",
47
+ auth=(username, password),
48
+ transport=transport,
49
+ **session_kwargs,
50
+ )
51
+
52
+ def _run_ps(self, script: str) -> str:
53
+ result = self.session.run_ps(script)
54
+ if result.status_code != 0:
55
+ err = result.std_err.decode("utf-8", errors="replace").strip()
56
+ raise RuntimeError(f"PowerShell error (exit {result.status_code}): {err}")
57
+ return result.std_out.decode("utf-8", errors="replace").strip()
58
+
59
+ @staticmethod
60
+ def _esc(path: str) -> str:
61
+ """Escape single quotes for PowerShell single-quoted strings."""
62
+ return path.replace("'", "''")
63
+
64
+ def upload(
65
+ self,
66
+ local_path: str | Path,
67
+ remote_path: str,
68
+ progress: Optional[Callable[[int, int], None]] = None,
69
+ ) -> None:
70
+ """Upload a local file to the remote machine.
71
+
72
+ Args:
73
+ local_path: Path to the local source file.
74
+ remote_path: Destination path on the remote Windows machine.
75
+ progress: Optional callback(current_chunk, total_chunks) for progress reporting.
76
+ """
77
+ with open(local_path, "rb") as f:
78
+ data = f.read()
79
+
80
+ # Encode each raw chunk separately so every piece is valid base64.
81
+ raw_chunks = [
82
+ data[i : i + self.chunk_size] for i in range(0, len(data), self.chunk_size)
83
+ ]
84
+ chunks = [base64.b64encode(c).decode("ascii") for c in raw_chunks]
85
+
86
+ remote = self._esc(remote_path)
87
+ total = len(chunks)
88
+
89
+ # Create parent directory, then write first chunk (or empty file).
90
+ mkdir_ps = f"""$dir = Split-Path '{remote}' -Parent
91
+ if ($dir -and !(Test-Path $dir)) {{ New-Item -ItemType Directory -Path $dir -Force | Out-Null }}"""
92
+
93
+ if not data:
94
+ self._run_ps(f"{mkdir_ps}\n[System.IO.File]::Create('{remote}').Close()")
95
+ else:
96
+ self._run_ps(
97
+ f"{mkdir_ps}\n[System.IO.File]::WriteAllBytes('{remote}', [System.Convert]::FromBase64String('{chunks[0]}'))"
98
+ )
99
+ if progress:
100
+ progress(1, total)
101
+
102
+ for i, chunk in enumerate(chunks[1:], start=2):
103
+ self._run_ps(f"""
104
+ $path = '{remote}'
105
+ $bytes = [System.Convert]::FromBase64String('{chunk}')
106
+ $stream = [System.IO.File]::Open($path, [System.IO.FileMode]::Append)
107
+ try {{
108
+ $stream.Write($bytes, 0, $bytes.Length)
109
+ }} finally {{
110
+ $stream.Close()
111
+ }}
112
+ """)
113
+ if progress:
114
+ progress(i, total)
115
+
116
+ def download(
117
+ self,
118
+ remote_path: str,
119
+ local_path: str | Path,
120
+ progress: Optional[Callable[[int, int], None]] = None,
121
+ ) -> None:
122
+ """Download a file from the remote machine.
123
+
124
+ Args:
125
+ remote_path: Source path on the remote Windows machine.
126
+ local_path: Destination path on the local machine.
127
+ progress: Optional callback(current_chunk, total_chunks) for progress reporting.
128
+ """
129
+ remote = self._esc(remote_path)
130
+ local_path = Path(local_path)
131
+
132
+ size_str = self._run_ps(f"(Get-Item '{remote}').Length")
133
+ file_size = int(size_str)
134
+
135
+ if local_path.parent:
136
+ local_path.parent.mkdir(parents=True, exist_ok=True)
137
+
138
+ total = max((file_size + self.chunk_size - 1) // self.chunk_size, 1)
139
+
140
+ with open(local_path, "wb") as f:
141
+ offset = 0
142
+ chunk_num = 0
143
+ while offset < file_size:
144
+ b64 = self._run_ps(f"""
145
+ $path = '{remote}'
146
+ $offset = {offset}
147
+ $count = {self.chunk_size}
148
+ $stream = [System.IO.File]::OpenRead($path)
149
+ try {{
150
+ $buf = New-Object byte[] $count
151
+ [void]$stream.Seek($offset, [System.IO.SeekOrigin]::Begin)
152
+ $read = $stream.Read($buf, 0, $count)
153
+ [System.Convert]::ToBase64String($buf, 0, $read)
154
+ }} finally {{
155
+ $stream.Close()
156
+ }}
157
+ """)
158
+ chunk_data = base64.b64decode(b64)
159
+ f.write(chunk_data)
160
+ offset += len(chunk_data)
161
+ chunk_num += 1
162
+ if progress:
163
+ progress(chunk_num, total)
164
+
165
+ # Handle empty file
166
+ if file_size == 0:
167
+ pass
168
+
169
+ def mkdir(self, remote_path: str, parents: bool = True) -> None:
170
+ """Create a directory on the remote machine."""
171
+ remote = self._esc(remote_path)
172
+ force = "-Force" if parents else ""
173
+ self._run_ps(
174
+ f"New-Item -ItemType Directory -Path '{remote}' {force} | Out-Null"
175
+ )
176
+
177
+ def remove(self, remote_path: str, recursive: bool = False) -> None:
178
+ """Remove a file or directory on the remote machine."""
179
+ remote = self._esc(remote_path)
180
+ recurse = "-Recurse" if recursive else ""
181
+ self._run_ps(f"Remove-Item -Path '{remote}' -Force {recurse}")
182
+
183
+ def exists(self, remote_path: str) -> bool:
184
+ """Return True if the remote path exists."""
185
+ remote = self._esc(remote_path)
186
+ return self._run_ps(f"Test-Path '{remote}'").lower() == "true"
187
+
188
+ def listdir(self, remote_path: str) -> list[str]:
189
+ """List names of files and directories in a remote directory."""
190
+ remote = self._esc(remote_path)
191
+ output = self._run_ps(
192
+ f"Get-ChildItem -Path '{remote}' | Select-Object -ExpandProperty Name"
193
+ )
194
+ return output.splitlines() if output else []
195
+
196
+ def move(self, remote_src: str, remote_dst: str) -> None:
197
+ """Move a file or directory on the remote machine."""
198
+ src = self._esc(remote_src)
199
+ dst = self._esc(remote_dst)
200
+ self._run_ps(f"Move-Item -Path '{src}' -Destination '{dst}' -Force")
201
+
202
+ def copy(self, remote_src: str, remote_dst: str, recursive: bool = False) -> None:
203
+ """Copy a file or directory on the remote machine."""
204
+ src = self._esc(remote_src)
205
+ dst = self._esc(remote_dst)
206
+ recurse = "-Recurse" if recursive else ""
207
+ self._run_ps(f"Copy-Item -Path '{src}' -Destination '{dst}' -Force {recurse}")
208
+
209
+ def stat(self, remote_path: str) -> dict:
210
+ """Return basic metadata for a remote path (size, is_dir, last_write)."""
211
+ remote = self._esc(remote_path)
212
+ output = self._run_ps(f"""
213
+ $item = Get-Item '{remote}'
214
+ "$($item.PSIsContainer)|$($item.Length)|$($item.LastWriteTimeUtc.ToString('o'))"
215
+ """)
216
+ is_dir_str, size_str, mtime_str = output.split("|", 2)
217
+ return {
218
+ "is_dir": is_dir_str.lower() == "true",
219
+ "size": int(size_str) if size_str else 0,
220
+ "last_write_utc": mtime_str.strip(),
221
+ }
@@ -0,0 +1,187 @@
1
+ Metadata-Version: 2.4
2
+ Name: winrmxfer
3
+ Version: 0.1.0
4
+ Summary: File transfer over WinRM using base64-encoded PowerShell commands
5
+ Author: Nagy Szabolcs
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Dungscer/WinRMXfer
8
+ Project-URL: Issues, https://github.com/Dungscer/WinRMXfer/issues
9
+ Keywords: winrm,powershell,file-transfer,windows,pywinrm
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Topic :: System :: Systems Administration
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+ Requires-Dist: pywinrm>=0.4
18
+ Provides-Extra: kerberos
19
+ Requires-Dist: pywinrm[kerberos]; extra == "kerberos"
20
+ Provides-Extra: credssp
21
+ Requires-Dist: pywinrm[credssp]; extra == "credssp"
22
+ Dynamic: license-file
23
+
24
+ # winrmxfer
25
+
26
+ A Python package for transferring files over WinRM (Windows Remote Management).
27
+
28
+ This package originated from a university thesis project where file transfer over WinRM was required as part of automating tasks on remote Windows machines. The solution — encoding file data as base64 and sending it through PowerShell commands — proved useful enough to be packaged as a standalone library.
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install winrmxfer
34
+ ```
35
+
36
+ ## Requirements
37
+
38
+ - Python 3.10+
39
+ - [pywinrm](https://github.com/diyan/pywinrm)
40
+ - WinRM enabled and configured on the target Windows machine
41
+
42
+ ## Quick start
43
+
44
+ ```python
45
+ from winrmxfer import WinRMFileClient
46
+
47
+ # Option 1 — pass credentials, the client creates the WinRM session internally
48
+ client = WinRMFileClient(
49
+ host="192.168.1.100",
50
+ username="Administrator",
51
+ password="s3cr3t",
52
+ transport="ntlm", # ntlm (default), basic, kerberos, credssp
53
+ )
54
+
55
+ # Option 2 — pass an existing winrm.Session
56
+ import winrm
57
+ session = winrm.Session("http://192.168.1.100:5985/wsman", auth=("Administrator", "s3cr3t"))
58
+ client = WinRMFileClient(session=session)
59
+
60
+ # Upload a file
61
+ client.upload("local_file.zip", r"C:\Users\Administrator\Desktop\file.zip")
62
+
63
+ # Download a file
64
+ client.download(r"C:\logs\app.log", "app.log")
65
+ ```
66
+
67
+ ## API
68
+
69
+ ### `WinRMFileClient(host="", username="", password="", transport="ntlm", port=5985, chunk_size=2048, session=None)`
70
+
71
+ Creates a new client. Pass either `host`/`username`/`password` to create a session internally, or an existing `winrm.Session` via the `session` parameter. `chunk_size` controls how many raw bytes are sent per WinRM round-trip (default 2 KB, tuned to stay within PowerShell's `-EncodedCommand` limit).
72
+
73
+ ---
74
+
75
+ ### File transfer
76
+
77
+ #### `upload(local_path, remote_path, progress=None)`
78
+
79
+ Upload a local file to the remote machine. Large files are automatically split into chunks.
80
+
81
+ ```python
82
+ def progress(current_chunk, total_chunks):
83
+ print(f"{current_chunk}/{total_chunks}")
84
+
85
+ client.upload("archive.tar.gz", r"C:\Temp\archive.tar.gz", progress=progress)
86
+ ```
87
+
88
+ #### `download(remote_path, local_path, progress=None)`
89
+
90
+ Download a file from the remote machine. Parent directories on the local side are created automatically.
91
+
92
+ ```python
93
+ client.download(r"C:\Temp\report.pdf", "downloads/report.pdf")
94
+ ```
95
+
96
+ ---
97
+
98
+ ### Remote filesystem operations
99
+
100
+ #### `exists(remote_path) → bool`
101
+
102
+ Return `True` if the remote path exists.
103
+
104
+ #### `stat(remote_path) → dict`
105
+
106
+ Return basic metadata: `is_dir`, `size` (bytes), `last_write_utc`.
107
+
108
+ #### `mkdir(remote_path, parents=True)`
109
+
110
+ Create a directory. With `parents=True` (default), intermediate directories are created as needed.
111
+
112
+ #### `listdir(remote_path) → list[str]`
113
+
114
+ List the names of files and directories inside a remote directory.
115
+
116
+ #### `move(remote_src, remote_dst)`
117
+
118
+ Move a file or directory on the remote machine.
119
+
120
+ #### `copy(remote_src, remote_dst, recursive=False)`
121
+
122
+ Copy a file or directory on the remote machine.
123
+
124
+ #### `remove(remote_path, recursive=False)`
125
+
126
+ Delete a file or directory. Use `recursive=True` to remove a directory tree.
127
+
128
+ ---
129
+
130
+ ## How it works
131
+
132
+ WinRM does not have a built-in file transfer mechanism. `winrmxfer` works around this by:
133
+
134
+ 1. Reading the local file and splitting it into chunks (default 2 KB).
135
+ 2. Base64-encoding each chunk.
136
+ 3. Sending a PowerShell command per chunk that decodes the base64 and writes the bytes to the remote file using `[System.IO.File]` methods.
137
+ 4. Downloads work in reverse: PowerShell reads chunks from the remote file, base64-encodes them, and the client decodes and reassembles the result locally.
138
+
139
+ The 2 KB chunk size is chosen because `pywinrm` wraps every PowerShell script in a UTF-16LE + base64 encoded `-EncodedCommand`, producing roughly 8× the script size as a command-line argument. Larger chunks exceed the WinRM shell's command-line length limit.
140
+
141
+ ## WinRM setup on Windows
142
+
143
+ ```powershell
144
+ # Run as Administrator
145
+ Enable-PSRemoting -Force
146
+ Set-Item WSMan:\localhost\Client\TrustedHosts -Value "*" -Force
147
+ ```
148
+
149
+ For basic authentication (lab use only):
150
+ ```powershell
151
+ Set-Item WSMan:\localhost\Service\Auth\Basic -Value $true
152
+ Set-Item WSMan:\localhost\Service\AllowUnencrypted -Value $true
153
+ ```
154
+
155
+ ## Running the tests
156
+
157
+ The test suite runs against a real WinRM target:
158
+
159
+ ```bash
160
+ pip install pytest
161
+ python -m pytest tests/ -v \
162
+ --host 127.0.0.1 \
163
+ --username Administrator \
164
+ --password s3cr3t \
165
+ --port 5985
166
+ ```
167
+
168
+ Credentials can also be provided via environment variables: `WINRM_HOST`, `WINRM_PORT`, `WINRM_USER`, `WINRM_PASS`, `WINRM_TRANSPORT`.
169
+
170
+ ## Publishing
171
+
172
+ Releases are published to PyPI by the GitHub Actions workflow in `.github/workflows/publish.yml`.
173
+
174
+ - It runs when a tag matching `v*` is pushed (e.g. `git tag v0.1.1 && git push origin v0.1.1`).
175
+ - The build job runs `python -m build` and produces a wheel and an sdist artifact.
176
+ - The publish job uploads them to PyPI using OIDC Trusted Publishing, so no `PYPI_TOKEN` secret is needed.
177
+
178
+ One-time setup on PyPI:
179
+ 1. PyPI → your project → Publishing → "Add a new publisher".
180
+ 2. Enter the GitHub repository, the workflow name `publish.yml` and the environment `pypi`.
181
+
182
+ One-time setup on GitHub:
183
+ - Repository Settings → Environments → create an environment named `pypi` (optionally restrict it to `v*` tags).
184
+
185
+ ## License
186
+
187
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ tests/test_transfer.py
5
+ winrmxfer/__init__.py
6
+ winrmxfer/client.py
7
+ winrmxfer.egg-info/PKG-INFO
8
+ winrmxfer.egg-info/SOURCES.txt
9
+ winrmxfer.egg-info/dependency_links.txt
10
+ winrmxfer.egg-info/requires.txt
11
+ winrmxfer.egg-info/top_level.txt
@@ -0,0 +1,7 @@
1
+ pywinrm>=0.4
2
+
3
+ [credssp]
4
+ pywinrm[credssp]
5
+
6
+ [kerberos]
7
+ pywinrm[kerberos]
@@ -0,0 +1 @@
1
+ winrmxfer