Years ago I greeted a friend returning from vacation and asked how it had
been. She answered, “It was good, I got a lot done!” I understand that feeling.
I just had a long vacation myself, and used the time to clean up some old issues
and add some new features in coverage.py v7.10.
The major new feature is a configuration option,
[run] patch
. With it, you specify named
patches that coverage can use to monkey-patch some behavior that gets in the way
of coverage measurement.
The first is subprocess
. Coverage works great when you start your
program with coverage measurement, but has long had the problem of how to also
measure the coverage of sub-processes that your program created. The existing
solution had been a complicated two-step process of creating obscure .pth files
and setting environment variables. Whole projects appeared on PyPI to handle
this for you.
Now, patch = subprocess
will do this for you automatically, and clean
itself up when the program ends. It handles sub-processes created by the
subprocess module, the
os.system() function, and any of the
execv or spawnv families of
functions.
This alone has spurred one user to exclaim,
The latest release of Coverage feels like a Christmas present!
The native support for Python subprocesses is so good!
Another patch is _exit
. This patches
os._exit() so that coverage saves its data before
exiting. The os._exit() function is an immediate and abrupt termination of the
program, skipping all kinds of registered clean up code. This patch makes it
possible to collect coverage data from programs that end this way.
The third patch is execv
. The execv functions
end the current program and replace it with a new program in the same process.
The execv
patch arranges for coverage to save its data before the
current program is ended.
Now that these patches are available, it seems silly that it’s taken so long.
They (mostly) weren’t difficult. I guess it took looking at the old issues,
realizing the friction they caused, and thinking up a new way to let users
control the patching. Monkey-patching is a bit invasive, so I’ve never wanted
to do it implicitly. The patch option gives the user an explicit way to request
what they need without having to get into the dirty details themselves.
Another process-oriented feature was contributed by Arkady Gilinsky: with
--save-signal=USR1
you can specify a user signal that coverage will
attend to. When you send the signal to your running coverage process, it will
save the collected data to disk. This gives a way to measure coverage in a
long-running process without having to end the process.
There were some other fixes and features along the way, like better HTML
coloring of multi-line statements, and more default exclusions
(if TYPE_CHECKING:
and ...
).
It feels good to finally address some of these pain points. I also closed
some stale issues and pull requests. There is more to do, always more to do,
but this feels like a real step forward. Give coverage
7.10.0 a try and let me know how it works for you.
I wrote a low-tech terminal-based version of the
classic 2048 game and had some interesting difficulties
with iterators along the way.
2048 has a 4×4 grid with sliding tiles. Because the tiles can slide
left or right and up or down, sometimes we want to loop over the rows and
columns from 0 to 3, and sometimes from 3 to 0. My first attempt looked like
this:
N = 4
if sliding_right:
cols = range(N-1, -1, -1) # 3 2 1 0
else:
cols = range(N) # 0 1 2 3
if sliding_down:
rows = range(N-1, -1, -1) # 3 2 1 0
else:
rows = range(N) # 0 1 2 3
for row in rows:
for col in cols:
...
This worked, but those counting-down ranges are ugly. Let’s make it
nicer:
cols = range(N) # 0 1 2 3
if sliding_right:
cols = reversed(cols) # 3 2 1 0
rows = range(N) # 0 1 2 3
if sliding_down:
rows = reversed(rows) # 3 2 1 0
for row in rows:
for col in cols:
...
Looks cleaner, but it doesn’t work! Can you see why? It took me a bit of
debugging to see the light.
range()
produces an iterable: something that can be iterated over.
Similar but different is that reversed()
produces an iterator: something
that is already iterating. Some iterables (like ranges) can be used more than
once, creating a new iterator each time. But once an iterator like
reversed()
has been consumed, it is done. Iterating it again will
produce no values.
If “iterable” vs “iterator” is already confusing here’s a quick definition:
an iterable is something that can be iterated, that can produce values in a
particular order. An iterator tracks the state of an iteration in progress. An
analogy: the pages of a book are iterable; a bookmark is an iterator. The
English hints at it: an iter-able is able to be iterated at some point, an
iterator is actively iterating.
The outer loop of my double loop was iterating only once over the rows, so
the row iteration was fine whether it was going forward or backward. But the
columns were being iterated again for each row. If the columns were going
forward, they were a range, a reusable iterable, and everything worked fine.
But if the columns were meant to go backward, they were a one-use-only
iterator made by reversed()
. The first row would get all the columns,
but the other rows would try to iterate using a fully consumed iterator and get
nothing.
The simple fix was to use list()
to turn my iterator into a reusable
iterable:
cols = list(reversed(cols))
The code was slightly less nice, but it worked. An even better fix
was to change my doubly nested loop into a single loop:
for row, col in itertools.product(rows, cols):
That also takes care of the original iterator/iterable problem, so I can get
rid of that first fix:
cols = range(N)
if sliding_right:
cols = reversed(cols)
rows = range(N)
if sliding_down:
rows = reversed(rows)
for row, col in itertools.product(rows, cols):
...
Once I had this working, I wondered why product()
solved the
iterator/iterable problem. The docs have a sample Python
implementation that shows why: internally, product()
is doing just
what my list()
call did: it makes an explicit iterable from each of the
iterables it was passed, then picks values from them to make the pairs. This
lets product()
accept iterators (like my reversed range) rather than
forcing the caller to always pass iterables.
If your head is spinning from all this iterable / iterator / iteration talk,
I don’t blame you. Just now I said, “it makes an explicit iterable from each of
the iterables it was passed.” How does that make sense? Well, an iterator is an
iterable. So product()
can take either a reusable iterable (like a range
or a list) or it can take a use-once iterator (like a reversed range). Either
way, it populates its own reusable iterables internally.
Python’s iteration features are powerful but sometimes require careful
thinking to get right. Don’t overlook the tools in itertools, and mind your
iterators and iterables!
• • •
Some more notes:
1: Another way to reverse a range: you can slice them!
>>> range(4)
range(0, 4)
>>> range(4)[::-1]
range(3, -1, -1)
>>> reversed(range(4))
<range_iterator object at 0x10307cba0>
It didn’t occur to me to reverse-slice the range, since reversed
is
right there, but the slice gives you a new reusable range object while reversing
the range gives you a use-once iterator.
2: Why did product()
explicitly store the values it would need but
reversed
did not? Two reasons: first, reversed()
depends on the
__reversed__
dunder method, so it’s up to the original object to decide
how to implement it. Ranges know how to produce their values in backward order,
so they don’t need to store them all. Second, product()
is going to need
to use the values from each iterable many times and can’t depend on the
iterables being reusable.
63 is a centered octahedral number. That means if you
build an approximation of an octahedron with cubes, one size of octahedron will
have 63 cubes.
In the late 1700’s René Just Haüy developed a theory
about how crystals formed: successive layers of fundamental primitives in
orderly arrangements. One of those arrangements was stacking cubes together to
make an octahedron.
Start with one cube:
Add six more cubes around it, one on each face. Now we have seven:
Add another layer, adding a cube to touch each visible cube, making 25:
One more layer and we have a total of 63:
The remaining numbers in the sequence
less than 10,000 are 129, 231, 377, 575, 833, 1159, 1561, 2047, 2625, 3303,
4089, 4991, 6017, 7175, 8473, 9919.
63 also shows up in the Delannoy numbers: the
number of ways to traverse a grid from the lower left corner to upper right
using only steps north, east, or northeast. Here are the 63 ways of moving on a
3×3 grid:
(Diagram from Wikipedia)
In fact, the number of cubes in a Haüy octahedron with N layers is the same
as the number of Delannoy steps on a 3×N grid!
Since the two ideas are both geometric and fairly simple, I would love to
find a geometric explanation for the correspondence. The octahedron is
three-dimensional, and the Delannoy grids have that tantalizing 3 in them. It
seems like there should be a way to convert Haüy coordinates to Delannoy
coordinates to show how they relate. But I haven’t found one...
• • •
Colophon: I made the octahedron diagrams by asking Claude to write a
Python program to do it.
It wasn’t a fast process because it took pushing and prodding to get the
diagrams to come out the way I liked. But Claude was very competent, and I
could think about the results rather than about projections or color spaces. I
could dip into it for 10 minutes at a time over a number of days without having
to somehow reconstruct a mental context.
This kind of casual hobby programming is perfect for AI assistance. I don’t
need the code to be perfect or even good, I just want the diagrams to be nice.
I don’t have the focus time to learn how to write the program, so I can leave it
to an imperfect assistant.
Today is the 39-year anniversary of my first day working for
Digital Equipment Corporation. It was my first real job in
the tech world, two years out of college. I wrote
about it 19 years ago, but it’s on my mind again.
More and more, I find that people have never heard of Digital (as we called
it) or DEC (as they preferred we didn’t call it but everyone did). It’s
something I’ve had to get used to. I try to relate a story from that time, and
I find that even experienced engineers with deep knowledge of technologies don’t
know of the company.
I mention this not in a crabby “kids these days” kind of way. It does
surprise me, but I’m taking it as a learning opportunity. If there’s a lesson
to learn, it is this:
I am now working for Netflix, and one of the great things about it is that
everyone has heard of Netflix. I can mention my job to anyone and they are
impressed in some way. Techies know it as one of the FAANG companies, and
“civilians” know it for the entertainment it produces and delivers.
When I joined Digital in 1986, at least among tech people, it was similar.
Everyone knew about Digital and what they had done: the creation of the
minicomputer, the genesis of Unix and C, the ubiquitous VT100. Many foundations
of the software world flowed directly and famously from Digital.
These days Digital isn’t quite yet a footnote to history, but it is more and
more unknown even among the most tech-involved. And the tech world carries
on!
My small team at Netflix has a number of young engineers, less than two years
out of college, and even an intern still in college. I’m sure they felt
incredibly excited to join a company as well-known and influential as Netflix.
In 39 years when they tell a story from the early days of their career will they
start with, “Have you heard of Netflix?” and have to adjust to the blank stares
they get in return?
This too shall pass.
I’m headed to PyCon today, and I’m reminded about how it feels like summer
camp, in mostly good ways, but also in a tricky way.
You take some time off from your “real” life, you go somewhere else, you hang
out with old friends and meet some new friends. You do different things than in
your real life, some are playful, some take real work. These are all good ways
it’s like summer camp.
Here’s the tricky thing to watch out for: like summer camp, you can make
connections to people or projects that are intense and feel like they could last
forever. You make friends at summer camp, or even have semi-romantic crushes on
people. You promise to stay in touch, you think it’s the “real thing.” When you
get home, you write an email or two, maybe a phone call, but it fades away. The
excitement of the summer is overtaken by your autumnal real life again.
PyCon can be the same way, either with people or projects. Not a romance,
but the exciting feeling that you want to keep doing the project you started at
PyCon, or be a member of some community you hung out with for those days. You
want to keep talking about that exciting thing with that person. These are
great feelings, but it’s easy to emotionally over-commit to those efforts and
then have it fade away once PyCon is over.
How do you know what projects are just crushes, and which are permanent
relationships? Maybe it doesn’t matter, and we should just get excited about
things.
I know I started at least one effort last year that I thought would be done
in a few months, but has since stalled. Now I am headed back to PyCon. Will I
become attached to yet more things this time? Is that bad? Should I temper my
enthusiasm, or is it fine to light a few fires and accept that some will peter
out?
Coverage.py has a large test suite that runs in many environments, which can
take a while. But some changes don’t require running the test suite at all.
I’ve changed the actions to detect when they need to run based on what files
have changed, but there were some twists and turns along the way.
The dorny/paths-filter action can check which files
have changed for pull requests or branches. I added it to
my tests action like this:
jobs:
changed:
name: "Check what files changed"
outputs:
python: ${{ steps.filter.outputs.python }}
steps:
- name: "Check out the repo"
uses: actions/checkout
- name: "Examine changed files"
uses: dorny/paths-filter
id: filter
with:
filters: |
python:
- "**.py"
tests:
# Don't run tests if the branch name includes "-notests".
# Only run tests if Python files changed.
needs: changed
if: ${{ !contains(github.ref, '-notests') && needs.changed.outputs.python == 'true' }}
The “changed” jobs checks what files have changed, then the “tests” job
examines its output to decide whether to run at all.
It’s a little awkward having an output for the “changed” job as an
intermediary, but this did what I wanted: if any .py file changed, run the
tests, otherwise don’t run them. I left in an old condition: if the branch name
includes “-notests”, then don’t run the tests.
This worked, but I realized I needed to run the tests on other conditions
also. What if no Python file changed, but the GitHub action file itself had
changed? So I added that as a condition. The
if-expression was getting long, so I made it a multi-line string:
jobs:
changed:
name: "Check what files changed"
outputs:
python: ${{ steps.filter.outputs.python }}
workflow: ${{ steps.filter.outputs.workflow }}
steps:
- name: "Check out the repo"
uses: actions/checkout
- name: "Examine changed files"
uses: dorny/paths-filter
id: filter
with:
filters: |
python:
- "**.py"
workflow:
- ".github/workflows/testsuite.yml"
tests:
# Don't run tests if the branch name includes "-notests".
# Only run tests if Python files or this workflow changed.
needs: changed
if: |
${{
!contains(github.ref, '-notests')
&& (
needs.changed.outputs.python == 'true'
|| needs.changed.outputs.workflow == 'true'
)
}}
This seemed to work, but it has a bug that I will get to in a bit.
Thinking about it more, I realized there are other files that could affect
the test results: requirements files, test output files, and the tox.ini.
Rather than add them as three more conditions, I combined
them all into one:
jobs:
changed:
name: "Check what files changed"
outputs:
run_tests: ${{ steps.filter.outputs.run_tests }}
steps:
- name: "Check out the repo"
uses: actions/checkout
- name: "Examine changed files"
uses: dorny/paths-filter
id: filter
with:
filters: |
run_tests:
- "**.py"
- ".github/workflows/testsuite.yml"
- "tox.ini"
- "requirements/*.pip"
- "tests/gold/**"
tests:
# Don't run tests if the branch name includes "-notests".
# Only run tests if files that affect tests have changed.
needs: changed
if: |
${{
needs.changed.outputs.run_tests == 'true'
&& !contains(github.ref, '-notests')
}}
BTW: these commits also update the quality checks workflow which has other
kinds of mix-and-match conditions to deal with that you might be interested
in.
All seemed good! Then I made a commit that only changed my Makefile, and the
tests ran! Why!? The Makefile isn’t one of the checked files. The paths-filter
action helpfully includes debug output that showed that only the Makefile was
considered changed, and that the “run_test” output was false.
I took a guess that GitHub actions don’t like expressions with newlines in
them. Using the trusty YAML multi-line
string cheat sheet, I tried changing from the literal block style (with a
pipe) to the folded style (with a greater-than):
if: >
${{
needs.changed.outputs.run_tests == 'true'
&& !contains(github.ref, '-notests')
}}
The literal form includes all newlines, the folded style turns newlines into
spaces. To check that I had it right, I tried parsing the YAML files: to my
surprise, both forms included all the newlines, there was no difference at all.
It turns out that YAML “helpfully” notices changes in indentation, and includes
newlines for indented lines. My expression is nicely indented, so it has
newlines no matter what syntax I use.
The GitHub actions docs don’t mention it, but it
seems that newlines do break expression evaluation. Sigh. My expressions are
not as long now as they had gotten during this exploration, so I
changed them all back to one line, and now it all works
as I wanted.
There are some other things I’d like to tweak: when the tests are skipped,
the final status is “success”, but I’m wondering if there’s a way to make it
“skipped”. I’m also torn about whether every change to master should run all
the workflows or if they should also filter based on the changed files.
Currently they are filtered.
Continuous integration and GitHub workflows are great, but they always seem
to involve this kind of fiddling in environments that are difficult to debug.
Maybe I’ve saved you some grief.
Older: