A genuinely Pythonic PowerShell: typed records and a fluent pipeline over native Windows bindings, with an in-process PowerShell 7 SDK fallback — never a pwsh.exe subprocess.
Project description
pwshpy
I hate PowerShell.
Not the way you hate Mondays. The way you hate a houseguest who keeps "helpfully" rearranging your kitchen while you sleep. PowerShell looks like a shell, quacks like a shell, and then every convenience it hands you turns out to have a tripwire underneath. The infuriating part is not that it is hard to learn. It is that the obvious code is the wrong code, and it fails quietly, in production, at 3 a.m.
A short tasting menu of the betrayals:
- Ask for one service and you might get back one object, or a collection containing one object, and
len()misleads you either way. That is the famous single-item collapse. - Put
$nullon the wrong side of-eqand your comparison silently becomes a filter. Yourifnow depends on operand order. Nobody warned you; it just returns the wrong answer. - A cmdlet "fails" and your
try/catchsits there twiddling its thumbs, because the error was "non-terminating" and you forgot the incantation-ErrorAction Stop. $events = Get-WinEvent ...looks harmless and eats all your RAM, because assigning to a variable slurps the whole result set into memory at once. The memory-safe version is the one you would never write by accident.Out-File -Encoding utf8still writes a BOM on Windows PowerShell 5.1, so "the same script" produces different bytes on different machines.- A function returns everything it emits, not just what you
return. One stray$list.Add(x)(which quietly returns the new count) poisons your return value, and the caller gets back an array of junk they never asked for. - Read
"false"from a config file, treat it as a boolean, and it is$true. Casting a string to bool tests its length, not its content - every non-empty string is true,"0"and"false"included. Only the empty string is false. $results += $itemin a loop is quietly O(n^2): every append copies the entire array. Fine at a thousand rows, a coffee break at a hundred thousand. (7.5 finally softened it; 5.1 still punishes you for the obvious code.)- When a script does die, you get a one-line message and, half the time, the wrong line number
(a real, still-open bug). The actual call stack sits unprinted on
$Error[0].ScriptStackTrace, and step-debugging needs an IDE. Tracing a failure is archaeology. - And my personal favourite: someone at Microsoft decided the Administrators group should be called Administratoren on a German box and Administrateurs on a French one. So a script that checks group membership by name is one locale away from a security hole. Who, precisely, thought translating a security principal was a good idea?
You can learn to dodge every single one of these. That is the tragedy, not the consolation: PowerShell asks you to become an expert in its landmines instead of an expert in your actual problem. The prize for mastery is that you get to type defensive boilerplate slightly faster than the next person.
pwshpy is the other bet
Stop wrapping the shell. Talk to the OS directly, in Python, and get typed objects back.
pwshpy is a genuinely Pythonic PowerShell: typed records and a lazy, fluent pipeline over
native OS bindings. It never spawns pwsh.exe and never parses text. Two backends feed the same
typed records - native (win32 / wmi / winreg / psutil / socket, fast, no .NET) and .NET
(the real PowerShell 7.6 engine hosted in-process for the AD / Exchange / Azure long tail, behind
the optional [full] extra). It ships as a library (from pwshpy import ps) and a pwshpy CLI
over the same surface.
The point is not that it is prettier. It is that the obvious code is now the correct code. So, against that tasting menu of betrayals, here is what you get instead:
- Typed objects, not text. Records come back as Pydantic models with real fields. You never re-parse a formatted string and pray the columns did not shift.
- The naive version is the right version. No single-item collapse, no
$nulloperand dance, no forgotten-ErrorAction Stop. The code you would write without thinking is the code that works. - Memory-bounded by default. The natural way to write it is also the way that does not fall over on a twenty-million-row event log. Streaming is the default, not something you remember.
- You can actually debug it. When it breaks you get a Python traceback pointing at the exact
line,
breakpoint()works anywhere (even "mid-pipeline" - it is ordinary iteration), and every failure is an exception. Nothing gets appended to a global$Errorlist and quietly skipped. - A colleague can review it. It reads as plain Python. A reviewer does not need to know where the bodies are buried to approve the pull request.
- Identity survives a locale. Principals are keyed by SID, not by a group name Microsoft translates, so a script written on an English box still works on a German one.
- One script, every machine. A large part of pwshpy is the same bytes on Windows, macOS, and
Linux - not just processes and files, but services, the event log, scheduled tasks, local accounts,
ACLs, the credential vault, and the elevation relaunch, each on its native backend (win32 on
Windows; systemd, journald,
pwd/grp, the ACL xattr, the desktop keyring,sudoon Linux), never a shelled-out fake. The second script, the.shtwin that always drifts, does not exist. - Built for LLMs and agents, on purpose. pwshpy is deliberately shaped so a model can use it
optimally: typed records, the obvious-code-is-the-correct-code discipline, and real exceptions
give an agent an unambiguous surface with no text to re-parse and no silent failures. And it ships
a
using-pwshClaude Code skill (install anywhere with/plugin marketplace add bitranox/pwshpythen/plugin install pwshpy) that makes an agent reach for pwshpy - whether issuing a PowerShell command or translating an existing, probably-buggy.ps1- instead of writing or debugging PowerShell. Migrating is a review, not a rewrite.
uv tool install pwshpy # or: uvx pwshpy@latest --help (no install)
One script, every machine
Here is a cost that never makes it onto the spreadsheet: the second script. You write
bootstrap.ps1 for the Windows fleet, then bootstrap.sh for the Linux fleet, and promise yourself
they do the same thing. They do not. They drift, each quietly lying about the other, and six months
on a one-line change means editing two files in two languages and testing on two machines.
A large part of pwshpy is portable by construction. The portable core - processes, network, disks,
files, environment, exec, HTTP downloads, the admin check, the credential prompt - is the same
bytes on Windows, macOS, and Linux. So are the pieces a real setup script actually leans on: services
(win32service / systemd), the event log (win32evtlog / journald), scheduled tasks (Task Scheduler /
systemd timers), local users and groups (win32net / pwd/grp), the credential vault (Credential
Manager / the desktop keyring), and the elevation relaunch (UAC / sudo) - each on its native
backend, talked to over its own API, never a shelled-out impersonation. So a provisioning or setup
script written once, in Python, runs on every box unchanged. No if ($IsWindows) fork, no parallel
.sh sliding out of sync.
from pwshpy import ps
# the same lines on Windows, macOS, and Linux
ps.require_elevation() # honest #Requires, any OS
ps.exec(["git", "clone", "https://github.com/acme/app", "/opt/app"]).check()
ps.write_text("/opt/app/config.toml", "port = 8080\n") # UTF-8, no BOM, identical bytes
ps.download_file("https://host/tool.tgz", "/tmp/tool.tgz") # streamed, memory-bounded
sshd = ps.get_process().where(lambda p: p.name == "sshd").first()
ps.get_service().where(lambda s: s.name.startswith("nginx")).first() # win32service | systemd D-Bus
Services, the event log, scheduled tasks, local accounts, ACLs, the credential vault, and the elevation relaunch all run natively on both Windows and Linux, verified on a real box on each. The only subsystems that stay Windows-only are the ones with no honest Linux analog - the registry and CIM/WMI (the portability roadmap has the reasoning). The ambition is deliberately dull and rather large: one setup script, every machine.
The same task, with and without the landmines
The job: as an admin, make sure a service is running, then export the System error events (there could be millions) to a report file. Simple. In PowerShell it is a minefield of defensive rituals, and the memory-safe way is not the way you would reach for.
PowerShell (correct, but every line hides a trap)
# 1. Needs admin - but there is no clean self-relaunch, so we can only CHECK and abort.
# (Start-Process -Verb RunAs loses your cwd, args, and any return value.)
if (-not ([Security.Principal.WindowsPrincipal]`
[Security.Principal.WindowsIdentity]::GetCurrent()
).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Error "Run me as Administrator"; exit 1
}
$name = "Spooler"
# 2. Get-Service may return a collection OR a single object -> force an array, take [0].
$svc = @(Get-Service -Name $name -ErrorAction SilentlyContinue)[0]
# 3. $null MUST be on the left ($svc -eq $null would *filter a collection*, not test it).
if ($null -eq $svc) { Write-Error "No such service"; exit 1 }
# 4. Cmdlet errors are non-terminating by default: without -ErrorAction Stop the catch never fires.
try {
if ($svc.Status -ne 'Running') { Start-Service -Name $name -ErrorAction Stop }
} catch { Write-Error "Failed to start: $_"; exit 1 }
# 5. ALL error events - potentially millions. The MEMORY TRAP: assigning to a variable
# ($events = Get-WinEvent ...) collects the ENTIRE result into RAM and can exhaust memory;
# Sort-Object / Select-Object buffer everything too. To stay bounded you must NOT capture and
# must pipe straight through - and Get-WinEvent still throws on empty.
# 6. ...and Out-File defaults to UTF-16LE-with-BOM on Windows PowerShell 5.1, so the bytes differ
# by host version.
Get-WinEvent -FilterHashtable @{LogName='System'; Level=2} -ErrorAction SilentlyContinue |
ForEach-Object { '{0}`t{1}`t{2}' -f $_.TimeCreated, $_.Id, $_.Message } |
Out-File -FilePath "C:\reports\errors.txt" -Encoding utf8
pwshpy (the traps simply do not exist)
from pwshpy import ps, EventLevel, ServiceState
# 1. Self-elevate: relaunch THIS process as admin, preserving cwd + argv, then exit with the
# child's code. No-op when already elevated. (PowerShell has no clean equivalent.)
if not ps.is_elevated():
raise SystemExit(ps.elevate())
# 2. Find the service with a plain loop. ps.get_service() streams typed records; break stops at the
# first match. No collection-collapse trap and no $null puzzle - just Python.
svc = None
for service in ps.get_service():
if service.name == "Spooler":
svc = service
break
if svc is None:
raise SystemExit("no such service")
# 3. Every failure raises a PwshPyError - no -ErrorAction Stop to remember, no swallowed error.
if svc.status is not ServiceState.RUNNING:
ps.start_service("Spooler")
# 4. ALL error events, memory-bound. A plain generator function: read one event, test it, yield the
# matches. It COLLECTS nothing; calling it reads nothing (a generator is lazy) - the log is
# pulled one event at a time in step 5.
def error_events():
for event in ps.get_win_event("System"):
if event.level is EventLevel.ERROR:
yield event
# 5. write_records STREAMS record-by-record straight to disk: read one, write one, discard. O(1)
# memory whether the log holds twenty entries or twenty million. UTF-8, no BOM, LF.
ps.write_records("C:/reports/errors.jsonl", error_events())
Six defensive workarounds become six lines that say what they mean. Same task, no traps.
On a huge log the memory difference is the whole point. In PowerShell the natural
$events = Get-WinEvent ... capture (or a stray Sort-Object) pulls every matching record into RAM
at once, so staying bounded is a careful-coding burden that the obvious code gets wrong. pwshpy reads
and writes one record at a time end to end - a batched EvtNext read, a lazy .where, a
per-record write_records - so memory stays flat whether the log holds twenty entries or twenty
million. Streaming is the default, not something you have to remember. (The full memory model lives
in Library & Pipeline.)
The costly part: it stays true
Claims are cheap, so here is the receipt. Every native read command is pinned by an oracle test that
runs the real cmdlet (Get-Service, Get-WinEvent, Get-CimInstance, ...) on Windows and compares
pwshpy's typed records against it on locale-invariant fields. The gotchas above are not "gone because
we say so" - there are tests whose entire job is to keep them gone. Principals are identified by SID,
not by a name Microsoft translates. The whole thing runs strict-typed (pyright), memory-bounded, and
green across Linux / macOS / Windows and CPython 3.10 through 3.14.
Documentation
Read it like a short book:
- Quick Start - install and the first five commands.
- Library & Pipeline - typed records, the lazy pipeline, the memory model.
- Power Tools - self-elevation,
exec, predictable file I/O, credentials, .NET binding. - CLI Reference - the full command surface and configuration commands.
- Backends & Platforms - native vs .NET, OS support, Python baseline, status.
- Porting from PowerShell - the cmdlet-by-cmdlet translation table.
Reference: Installation | Configuration | Security | Development | Contributing | Changelog | License | AI stance | AI transparency
Still not convinced? Run the before/after above on your own event log and tell me which one you would rather be debugging at 3 a.m. That is the only benchmark that matters.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pwshpy-1.0.0.tar.gz.
File metadata
- Download URL: pwshpy-1.0.0.tar.gz
- Upload date:
- Size: 302.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
950eae59e3c344366ec4c7ab0bd692152f4b9eee07d454c32951387c8752e911
|
|
| MD5 |
c09eb615e9f4ccb98ce9a999a402824d
|
|
| BLAKE2b-256 |
372256d20e0d433b5dcccf593899c2868885b9863eb15068a1b689edf28447e9
|
Provenance
The following attestation bundles were made for pwshpy-1.0.0.tar.gz:
Publisher:
default_release_public.yml on bitranox/pwshpy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pwshpy-1.0.0.tar.gz -
Subject digest:
950eae59e3c344366ec4c7ab0bd692152f4b9eee07d454c32951387c8752e911 - Sigstore transparency entry: 2136375997
- Sigstore integration time:
-
Permalink:
bitranox/pwshpy@b194a5cb5eaaed0e41e4a2cb158b9d9fa981de72 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/bitranox
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
default_release_public.yml@b194a5cb5eaaed0e41e4a2cb158b9d9fa981de72 -
Trigger Event:
push
-
Statement type:
File details
Details for the file pwshpy-1.0.0-py3-none-any.whl.
File metadata
- Download URL: pwshpy-1.0.0-py3-none-any.whl
- Upload date:
- Size: 202.7 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
578de498ce10f9e604b28d2c574b81b9b8697dc1b2558655366ac39192425cf7
|
|
| MD5 |
c42e327ef3ec882c4959e660dd96ece5
|
|
| BLAKE2b-256 |
83ec1337adaf06a0932a5e54984965e88805b3d1b043d4287e9ba758d1794d68
|
Provenance
The following attestation bundles were made for pwshpy-1.0.0-py3-none-any.whl:
Publisher:
default_release_public.yml on bitranox/pwshpy
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pwshpy-1.0.0-py3-none-any.whl -
Subject digest:
578de498ce10f9e604b28d2c574b81b9b8697dc1b2558655366ac39192425cf7 - Sigstore transparency entry: 2136376016
- Sigstore integration time:
-
Permalink:
bitranox/pwshpy@b194a5cb5eaaed0e41e4a2cb158b9d9fa981de72 -
Branch / Tag:
refs/tags/v1.0.0 - Owner: https://github.com/bitranox
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
default_release_public.yml@b194a5cb5eaaed0e41e4a2cb158b9d9fa981de72 -
Trigger Event:
push
-
Statement type: