python
Two hundred lines of PowerShell I could not test

The drift check I wrote about a fortnight ago works. It has found three real things. It is also two hundred and fourteen lines of PowerShell that I change by editing a file, pushing, waiting for a nightly run, and reading a console log at nine the next morning.
That is not a language problem yet. The first fix was moving the script out of
the Jenkinsfile and into ci/collect.ps1, which gets you syntax highlighting,
a linter, and the ability to run it by hand. The second question is the one
worth an afternoon: should the logic be PowerShell at all?
I moved it to Python. Here is the argument, including the parts where PowerShell wins.
What I actually wanted
Tests I would write without being made to. Dependencies I could pin. The same
code runnable on the Linux controller, on the Windows agent and on my laptop.
A data model rather than a bag of PSCustomObjects. And errors that behave
like errors.
Four of those five are hard in PowerShell, and the fifth is the one that made the decision.
The error model
PowerShell has terminating errors, non-terminating errors,
$ErrorActionPreference, per-cmdlet -ErrorAction, $?, $LASTEXITCODE,
throw, Write-Error, trap, and a try/catch that only catches the
terminating kind. Nine mechanisms, and the default behaviour of most of them
is keep going.
Every serious PowerShell script therefore starts with a two-line ritual to turn the defaults off, and every native call gets wrapped so that a non-zero exit code becomes an exception. That is not a criticism of anyone's code — it is the correct thing to write. It is just a lot of scaffolding to arrive at the behaviour that Python has on line one.
notafter = datetime.fromisoformat(cert["notAfter"])
If that fails, it raises, nothing after it runs, the process exits non-zero,
and Jenkins goes red. I have never written the Python equivalent of
$ErrorActionPreference = 'Stop' because there is nothing to write.
Dependencies you can actually pin
Install-Module against the PowerShell Gallery has no lockfile. You can pin
one module with -RequiredVersion, by hand, per machine. A module manifest's
RequiredModules pins a minimum, not an exact version, and says nothing
about what those modules depend on in turn.
On a build agent that is the difference between reproducible and "worked in January":
# requirements.txt
requests==2.27.1 \
--hash=sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61
python-dateutil==2.8.2 \
--hash=sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9
The agent installs from that file and from nothing else:
pip install --require-hashes -r requirements.txt
With --require-hashes, pip refuses to install anything the file does not
name, at a version it does not name, with a hash it does not match. The agent
either gets exactly the tree the tests ran against or it gets an error.
The one that decided it
This is the test I wanted to be able to write:
def test_flags_a_certificate_expiring_inside_the_window():
report = evaluate_certificates(
certificates=[
Certificate(subject="CN=build-signing", not_after=date(2022, 3, 1))
],
today=date(2022, 2, 12),
warn_within=timedelta(days=30),
)
assert [f.severity for f in report] == [Severity.WARN]
It needs no Windows machine, no certificate store, no Jenkins agent and no night. It runs on the Linux controller in four milliseconds, which means it runs on every push instead of once a day.
Pester 5 is a real framework and I am not going to pretend otherwise. But the
unit under test in a PowerShell script is usually a script, not a function with
parameters and a return value, and mocking a cmdlet is mocking a global. You
end up testing the shape of your Mock calls. The reason the Python version
is easy to test is not Python — it is that the certificate list arrives as an
argument. Nothing stops you writing PowerShell that way. Almost nobody does,
including me, because the language does not push you towards it.
A type checker instead of a two a.m. surprise
Set-StrictMode -Version Latest catches a misspelled property. It catches it
at runtime, on the agent, during the nightly run.
@dataclass(frozen=True)
class Certificate:
subject: str
thumbprint: str
not_after: datetime
@dataclass(frozen=True)
class Finding:
check: str
severity: Severity
detail: str
def evaluate(facts: Facts, baseline: Baseline, *, today: date) -> list[Finding]:
...
list[Finding] without an import is 3.10, which came out in October and which
I am on because 3.10.2 landed on the fourteenth of January and I had no reason
to wait. mypy in a pre-commit hook caught two genuine bugs in the first hour:
a notAfter that was a string on one code path and a datetime on another,
and a threshold compared against a ratio in one place and a percentage in the
other. The second one meant the disk check had never fired.
JSON that survives the round trip
The check hands data between two languages, so this matters more than usual, and Windows PowerShell 5.1 is genuinely awkward here.
ConvertTo-Json defaults to -Depth 2 and replaces everything deeper with a
type name string. A single-element collection unrolls to the element, so one
machine emits an object where the others emit a list. ConvertFrom-Json in
5.1 gives you PSCustomObject and no -AsHashtable — that arrived in
PowerShell 6. Dates come out formatted according to whatever the process
culture is.
json.dumps(payload)
A list is a list. A dict is a dict. There is no depth limit, and json.dumps
of a datetime raises rather than guessing.
One language on both sides of the wall
The same module runs under pwsh on the Windows agent, under python3 on the
Linux controller, and in the pre-commit hook on my laptop. Last year's home
automation work was Python; this client's data tooling is Python. Keeping the
operations glue in a third language was a cost I was paying for no reason
beyond "the machine is Windows".
And exit codes are free. sys.exit(2) and the build goes red. No wrapper
function, no $LASTEXITCODE after every call.
Where PowerShell wins, and I am not pretending otherwise
It is already there. 5.1 is on every Windows machine you will ever be
handed. No installer, no virtual environment, no argument with anyone about
what a service account's %APPDATA% contains. For a script that runs once, on
a box you do not own, that is the whole game.
The Windows surface area. Get-CimInstance Win32_LogicalDisk, the
registry as a drive you can cd into, ACLs, the event log, scheduled tasks,
IIS, Active Directory, the certificate store as Cert:\. Every one of those
is one line. The Python equivalents are pywin32 or WMI bindings, an
afternoon, and a worse object model at the end of it.
The object pipeline. Get-Service | Where-Object Status -eq Running | Select-Object Name, StartType is better than anything I can write in Python
for interactive work, and it is not close.
Remoting. WinRM and Invoke-Command -ComputerName are built in,
authenticated and encrypted by default. Python's story here is a third-party
library and a longer conversation with security.
Discoverability. Get-Help, Get-Member, consistent verb-noun naming. It
is a better shell than it has any right to be, and a better language than most
shells will ever be.
None of that changed. What changed is that none of it is about the part of my script that decides whether 12% free disk is a problem.
So the seam goes here
PowerShell is a driver. Keep it thin enough to read in one screen: touch the Windows-only APIs, emit JSON, exit. No thresholds, no formatting, no decisions.
# ci/collect.ps1 — facts only. Nothing in here has an opinion.
[CmdletBinding()]
param(
[Parameter(Mandatory)][string] $OutFile,
[Parameter(Mandatory)][string[]] $ServiceNames
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
$facts = [ordered]@{
computerName = $env:COMPUTERNAME
collectedAt = (Get-Date).ToUniversalTime().ToString('o')
powerShell = $PSVersionTable.PSVersion.ToString()
executionPolicy = "$(Get-ExecutionPolicy -Scope LocalMachine)"
disks = @(
Get-CimInstance Win32_LogicalDisk -Filter 'DriveType = 3' | ForEach-Object {
@{ id = $_.DeviceID; freeBytes = [int64]$_.FreeSpace; sizeBytes = [int64]$_.Size }
}
)
services = @(
Get-Service -Name $ServiceNames | ForEach-Object {
@{ name = $_.Name; status = "$($_.Status)"; startType = "$($_.StartType)" }
}
)
certificates = @(
Get-ChildItem Cert:\LocalMachine\My | ForEach-Object {
@{
subject = $_.Subject
thumbprint = $_.Thumbprint
notAfter = $_.NotAfter.ToUniversalTime().ToString('o')
}
}
)
}
$facts | ConvertTo-Json -Depth 6 | Set-Content -Path $OutFile -Encoding utf8
Forty-one lines. Every @() and every "$( )" in there is deliberate: the
first stops a one-element collection from serialising as an object, the second
stops an enum from arriving as a number on one box and a string on another.
The ISO-8601 round-trip format with ToUniversalTime() means Python's
datetime.fromisoformat reads it without a timezone argument.
Then the part with opinions in it:
def evaluate(facts: Facts, baseline: Baseline, *, today: date) -> list[Finding]:
return [
*_disks(facts.disks, baseline.min_free_ratio),
*_services(facts.services, baseline.services),
*_certificates(facts.certificates, today, baseline.cert_warn_within),
*_policy(facts.execution_policy, baseline.execution_policy),
]
Sixty-one pytest cases against that, all of them running on Linux.
Getting Python onto the agent without regretting it
Four things, learned the hard way.
Not the Microsoft Store build. Under a service account it resolves to an
app execution alias that is not installed for that user, and the error tells
you the file is not found while where python cheerfully prints a path.
All-users MSI, and keep it off PATH. InstallAllUsers=1 PrependPath=0,
then refer to it by full path or through the py -3.10 launcher. A build
agent should never be guessing which Python it got.
A virtual environment per job, inside the workspace.
"C:\Python310\python.exe" -m venv .venv
.venv\Scripts\python.exe -m pip install --disable-pip-version-check "pip==22.0.3"
.venv\Scripts\python.exe -m pip install --require-hashes -r ci\requirements.txt
.venv\Scripts\python.exe -m driftcheck \
--facts facts.json --baseline %BASELINE% --out drift.json
Pin the environment, not just the packages. A service account has a profile, but it is not one anybody has looked at, and the defaults for pip's cache and Python's bytecode directory point into it. Put them somewhere you control:
withEnv([
'PYTHONUTF8=1',
'PYTHONDONTWRITEBYTECODE=1',
'PIP_DISABLE_PIP_VERSION_CHECK=1',
"PIP_CACHE_DIR=${env.WORKSPACE}\\.pipcache",
]) {
bat script: 'ci\\check.cmd', label: 'drift check'
}
PYTHONUTF8=1 is the one to remember. UTF-8 mode makes Python ignore the
Windows code page for file and stdio encoding, which removes the entire class
of "works on my machine, mojibake on the agent" problem in one environment
variable.
The result
Two hundred and fourteen lines of PowerShell became forty-one lines of PowerShell and about three hundred lines of Python, which is more code. It is also code with sixty-one tests that run in four seconds on every push, a type checker that found two bugs before it ran once, and a dependency set that is identical on three machines.
The job itself got slower — building a virtual environment costs twenty seconds — and it runs at ten past two in the morning, so I stopped caring within a day.
The honest summary is that this was never really an argument about languages. PowerShell is very good at the thing it is for, which is reaching into Windows, and it is not very good at being the place your business logic lives. The question is not "Python or PowerShell". It is where you want the tests to be, and then the seam follows from the answer.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


