Skip to content

Modernize C++ code - #144

Draft
djhoese wants to merge 8 commits into
pytroll:mainfrom
djhoese:drop-py2
Draft

Modernize C++ code#144
djhoese wants to merge 8 commits into
pytroll:mainfrom
djhoese:drop-py2

Conversation

@djhoese

@djhoese djhoese commented Aug 30, 2026

Copy link
Copy Markdown
Member

This PR comes from a long time wish of mine to rewrite the C++ code (_aggdraw.cxx) to remove all Python 2 conditionals and API usage. I basically asked Claude to do the python 2 removal then I asked "this module uses factory functions to create objects, is this still done in modern times?" and Claude said no that's a 2003 common practice, not 2026.

The main things are marked in the Changelog. This PR should fix multiple memory leaks according to Claude. This PR should also be the first major step toward a Cython wrapper or maybe something more maintainable than this Python C API extension with a python wrapper. Not all class creation has been migrated to the newer APIs, but I'll have to do more reviewing to be sure what is the right path forward.

TOOD: I still need to review this completely which is why I'm marking it as a draft.

djhoese and others added 5 commits August 24, 2026 16:13
Six leaks, all in hand-written refcount glue and all independent of each
other. Verified by measuring sys.getallocatedblocks() and RSS before and
after; the pre-fix numbers are given below.

- Path.coords() leaked one float per coordinate. PyList_Append takes its
  own reference, so the one from PyFloat_FromDouble was never released.
  Measured: 160,000 blocks over 20k calls on an 8-coordinate path.
- draw_dealloc never deleted self->transform, so every Draw that called
  settransform() leaked an agg::trans_affine. Measured: 12.8 MB over 200k
  Draw+settransform pairs. settransform() itself already freed the old
  one on replacement; only teardown was missing.
- Three constructor error paths marked "FIXME: cleanup"/"FIXME: release
  resources" leaked the half-built object: a rejected Symbol() descriptor,
  a Font() whose file will not load, and a Draw() given short image data.
  Measured: 20,000 blocks over 20k failed Symbol() calls.
- Draw(image) assigned self->image and only increfed it after the tobytes
  round-trip, holding a borrowed reference in between.
- The dict backing the color-resolution helper was deliberately leaked to
  keep a borrowed reference alive. It now takes a strong reference and
  releases the dict, and PyRun_String's result is checked and released.

draw_new now nulls every pointer field immediately after PyObject_NEW,
which is what makes Py_DECREF safe on those error paths: PyObject_NEW does
not zero the allocation, and draw_dealloc deletes all six pointers. That
is why the bad-mode path previously used PyObject_DEL.

Also drops a stale comment in core.py claiming a Python-side pen/brush
swap segfaults. It does not reproduce; it was a symptom of the NULL
ob_type bug fixed in the previous commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pen, Brush, Font, Path and Draw were module-level factory functions
returning opaque static-typed objects -- the standard CPython idiom when
this was written in 2003, but it predates the type/class unification.
They could not be subclassed, and Pen.color and Pen.width were
unreachable from Python entirely.

They are now heap types built with PyType_FromSpec, exposed on the module
as real classes, and subclassable. Each PyType_Spec sits next to that
type's own methods, which removes the reason tp_methods had to be
assigned imperatively in aggdraw_init: the method arrays are defined far
below the old static initializers and C++ has no tentative definitions,
so they could not be named from there.

Symbol stays a factory function. It returns a Path -- Symbol and Path
have always been the same C type -- so it cannot become a type of its own
without changing what it returns.

Two invariants that heap types require:

- The deallocators go through tp_free and then Py_DECREF(Py_TYPE(self))
  rather than calling PyObject_DEL. path_new's direct call to
  path_dealloc becomes Py_DECREF for the same reason.
- The _Check macros use PyObject_TypeCheck instead of an identity test.
  Py_TPFLAGS_BASETYPE is set, and an identity test would make the
  dispatcher in draw_adaptor::draw silently ignore a subclass of Pen or
  Brush.

The draw_getattro and font_getattro strcmp-style chains are replaced by
tp_getset descriptors, and Pen/Brush gain color and width. Draw.mode
still reports the adaptor's label, so a BGRA surface still reads back as
"RGBA"; that behaviour is unchanged and now carries a comment.

core.py gains matching read-only properties, since doc/source/index.rst
renders the API from a single automodule and anything absent from core.py
is invisible in the docs. Verified the generated HTML, not just that
sphinx-build -W passes.

Checked with and without FreeType, since font_getset is guarded and
Windows wheels ship without it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
tp_new is handed a keyword dict whether or not it parses one, so the
conversion from METH_VARARGS quietly started accepting and ignoring
keywords: Draw("RGB", (4,4), color="red") built a white surface instead
of raising. Restore the TypeError.

Pen, Brush and Font are unaffected -- they parse keywords deliberately
and always have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@djhoese djhoese self-assigned this Aug 30, 2026
@djhoese

djhoese commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

This PR is probably stuck until I/we can decide what we want to do with Symbol (#145). This will remove the last of the factory function code.

@a-hurst

a-hurst commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Wow, this is a big undertaking!! I can review this more carefully when it's further along, my only 2 cents for now is that I'm slightly wary of the number of unintentional rendering changes and unexpected bugs a rewrite like this might introduce given the state of the test suite (having flashbacks to the aborted agg 2.4 update many moons ago).

For peace of mind, my current thought would be to do something like the old Acid 2 test for browsers way back in the day, where you draw a test image using a bunch of different rendering functions that can serve as a reference.

The test script would save the rendered image and then hash its contents. Once you have a hash for the current pre-rewrite version, you can add the test script to the test suite and have it compare the calculated hash against the known hash, so we'd always know when a change to the code actually changes the way things are drawn/rendered.

For deliberate changes in rendering we'd just update the reference hash in the tests, but the idea would be to catch accidental changes in rendering more than anything else!

@djhoese

djhoese commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

Yes, absolutely. That's a great idea. And definitely, this should really be merged when things have been verified. I'm less concerned with these changes here as I'm not touching the actual code doing the drawing, just the wrapper that is aggdraw.

With agg 2.4 changes, yeah that was...a mistake. I think I was so surprised by how many backwards incompatible changes the library had in a version bump where some of my coastline drawing use case just had completely different results. And with that, how we did simple tests with small images and they looked reasonable but then when I started doing larger images with lots of paths is when it was more obvious that things were way off.

Let me see if I can set Claude on getting some skeleton together and I'll put it in another PR, eventually merge that, then rebase this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants