python

Two hundred lines of PowerShell I could not test

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.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev