diff --git a/micropython.cmake b/micropython.cmake index b6b3002..e24991e 100644 --- a/micropython.cmake +++ b/micropython.cmake @@ -14,6 +14,7 @@ set(PYGRAPHICS_MOD_DIR ${CMAKE_CURRENT_LIST_DIR}) add_library(pygraphics INTERFACE) target_sources(pygraphics INTERFACE + ${PYGRAPHICS_MOD_DIR}/src/gfx_build.c ${PYGRAPHICS_MOD_DIR}/src/gfx_module_mp.c ${PYGRAPHICS_MOD_DIR}/src/gfx_bindings_mp.c ${PYGRAPHICS_MOD_DIR}/src/gfx_canvas_mp.c @@ -36,4 +37,25 @@ target_compile_options(pygraphics INTERFACE # Arc/polygon use Q15 LUT (gfx_trig.h) — no libm. +# --- which pygraphics this firmware was built from -------------------------- +# See micropython.mk for why this is computed at build time and never stored. +# Quoted so the strings survive as C string literals. +execute_process( + COMMAND git -C ${PYGRAPHICS_MOD_DIR} describe --always --dirty --abbrev=7 + OUTPUT_VARIABLE PYGRAPHICS_REVISION + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) +if(NOT PYGRAPHICS_REVISION) + set(PYGRAPHICS_REVISION "unknown") +endif() +if(EXISTS ${PYGRAPHICS_MOD_DIR}/VERSION) + file(READ ${PYGRAPHICS_MOD_DIR}/VERSION PYGRAPHICS_VERSION) + string(STRIP "${PYGRAPHICS_VERSION}" PYGRAPHICS_VERSION) +else() + set(PYGRAPHICS_VERSION "0.0.0+unknown") +endif() +target_compile_definitions(pygraphics INTERFACE + PYGRAPHICS_VERSION=\"${PYGRAPHICS_VERSION}\" + PYGRAPHICS_REVISION=\"${PYGRAPHICS_REVISION}\") + target_link_libraries(usermod INTERFACE pygraphics) diff --git a/micropython.mk b/micropython.mk index 57731e9..260f2de 100644 --- a/micropython.mk +++ b/micropython.mk @@ -8,7 +8,25 @@ PYGRAPHICS_MOD_DIR := $(USERMOD_DIR) CFLAGS_USERMOD += -I$(PYGRAPHICS_MOD_DIR)/src -Wno-unused-function -Wno-sign-compare -Wno-unused-const-variable # Arc/polygon use Q15 LUT in gfx_trig.h — no libm required. +# --- which pygraphics this firmware was built from -------------------------- +# +# pygraphics#25. `os.uname().version` answers for MicroPython and says nothing +# about the user C modules in the image, so a rendering that moves between two +# firmwares cannot be attributed. Computed HERE, from the directory the build +# is actually reading, because that is the only thing that knows which tree it +# compiled -- audiodsp lost a revision exactly this way, to a throwaway source +# tree during a pin move. +# +# `--always --dirty` so a tree with uncommitted changes says so: dirty is the +# normal state in this workspace and a bare hash would claim more than it knows. +# Both fall back to "unknown" rather than guessing -- see src/gfx_build.c. +PYGRAPHICS_VERSION := $(shell cat $(PYGRAPHICS_MOD_DIR)/VERSION 2>/dev/null || echo 0.0.0+unknown) +PYGRAPHICS_REVISION := $(shell git -C $(PYGRAPHICS_MOD_DIR) describe --always --dirty --abbrev=7 2>/dev/null || echo unknown) +CFLAGS_USERMOD += -DPYGRAPHICS_VERSION='"$(PYGRAPHICS_VERSION)"' +CFLAGS_USERMOD += -DPYGRAPHICS_REVISION='"$(PYGRAPHICS_REVISION)"' + SRC_USERMOD_C += \ + $(PYGRAPHICS_MOD_DIR)/src/gfx_build.c \ $(PYGRAPHICS_MOD_DIR)/src/gfx_module_mp.c \ $(PYGRAPHICS_MOD_DIR)/src/gfx_bindings_mp.c \ $(PYGRAPHICS_MOD_DIR)/src/gfx_canvas_mp.c \ diff --git a/setup.py b/setup.py index a8fbaa1..8f78a6e 100644 --- a/setup.py +++ b/setup.py @@ -2,12 +2,39 @@ """Build native pygraphics C extension.""" import os +import subprocess import sys from setuptools import Extension, setup ROOT = os.path.dirname(os.path.abspath(__file__)) + +def _build_strings(): + """The version and the revision, computed the way micropython.mk does. + + A wheel that cannot say which pygraphics it is leaves the same hole on the + desktop that a firmware leaves on a board (pygraphics#25), and the two must + answer alike or comparing them proves nothing. "unknown" from an sdist with + no git is the honest answer, not a guess. + """ + try: + with open(os.path.join(ROOT, "VERSION"), encoding="utf-8") as handle: + version = handle.read().strip() or "0.0.0+unknown" + except OSError: + version = "0.0.0+unknown" + try: + revision = subprocess.run( + ["git", "-C", ROOT, "describe", "--always", "--dirty", "--abbrev=7"], + capture_output=True, text=True, check=True, + ).stdout.strip() or "unknown" + except (OSError, subprocess.CalledProcessError): + revision = "unknown" + return version, revision + + +PYGRAPHICS_VERSION, PYGRAPHICS_REVISION = _build_strings() + if sys.platform == "win32": extra_compile_args = ["/wd4996"] extra_link_args = [] @@ -38,6 +65,10 @@ "pygraphics", sources=GFX_SOURCES, include_dirs=[os.path.join(ROOT, "src")], + define_macros=[ + ("PYGRAPHICS_VERSION", '"%s"' % PYGRAPHICS_VERSION), + ("PYGRAPHICS_REVISION", '"%s"' % PYGRAPHICS_REVISION), + ], extra_compile_args=extra_compile_args, extra_link_args=extra_link_args, ), diff --git a/src/gfx_bindings_mp.c b/src/gfx_bindings_mp.c index 7579a12..7ff7410 100644 --- a/src/gfx_bindings_mp.c +++ b/src/gfx_bindings_mp.c @@ -10,6 +10,7 @@ #include "py/runtime.h" #include "py/binary.h" +#include "gfx_build.h" #include "gfx_core.h" #include "gfx_framebuffer.h" #include "gfx_shapes.h" @@ -1965,6 +1966,10 @@ MP_DEFINE_CONST_FUN_OBJ_0(mod_capabilities_obj, mod_capabilities); static const mp_rom_map_elem_t graphics_module_globals_table[] = { { MP_ROM_QSTR(MP_QSTR___name__), MP_ROM_QSTR(MP_QSTR_pygraphics) }, + // Which pygraphics this firmware was built from (pygraphics#25). One line, + // two pointers into gfx_build.c; a test asserts these entries are here, so + // a rewrite of this table fails rather than shipping unmarked. + PYGRAPHICS_BUILD_GLOBALS, { MP_ROM_QSTR(MP_QSTR_FrameBuffer), MP_ROM_PTR(&mp_type_framebuf) }, { MP_ROM_QSTR(MP_QSTR_Area), MP_ROM_PTR(&mp_type_area) }, { MP_ROM_QSTR(MP_QSTR_Draw), MP_ROM_PTR(&mp_type_draw) }, diff --git a/src/gfx_build.c b/src/gfx_build.c new file mode 100644 index 0000000..72fb2e5 --- /dev/null +++ b/src/gfx_build.c @@ -0,0 +1,22 @@ +// The two build strings, defined once. See gfx_build.h. +// +// SPDX-License-Identifier: MIT + +#include "gfx_build.h" + +#include "py/objstr.h" + +// Passed by micropython.mk / micropython.cmake. A build that reaches here +// without them says so rather than claiming a revision it does not know -- +// "unknown" is the honest answer, and a test asserts it is NOT the answer +// inside a checkout, because a mechanism that reports "unknown" everywhere +// passes every other check. +#ifndef PYGRAPHICS_VERSION +#define PYGRAPHICS_VERSION "0.0.0+unknown" +#endif +#ifndef PYGRAPHICS_REVISION +#define PYGRAPHICS_REVISION "unknown" +#endif + +const MP_DEFINE_STR_OBJ(pygraphics_version_obj, PYGRAPHICS_VERSION); +const MP_DEFINE_STR_OBJ(pygraphics_revision_obj, PYGRAPHICS_REVISION); diff --git a/src/gfx_build.h b/src/gfx_build.h new file mode 100644 index 0000000..ceed843 --- /dev/null +++ b/src/gfx_build.h @@ -0,0 +1,31 @@ +// Which pygraphics a firmware was built from, readable on the device. +// +// pygraphics#25, following audiodsp's 04e9578. `os.uname().version` answers for +// MicroPython and says nothing about the user C modules compiled into the +// image, so when a rendering moves between two firmwares there is no way to ask +// the board which pygraphics produced it. On audiodsp that was not hypothetical: +// a firmware had been built from a throwaway source tree during a pin move and +// the commit was not recoverable from the working tree or from the .bin files +// left on disk, by any means. Hence: computed at build time, never stored. +// +// The two strings are defined ONCE (gfx_build.c) and the module table points at +// them, so the cost is two `mp_obj_str_t` plus two table entries. +// +// NOT in the sources the CPython extension compiles: `mp_obj_str_t` is a +// MicroPython type. The CPython target answers the same question from +// gfx_module_cpy.c, from the same two macros, computed the same way in +// setup.py -- so `pygraphics.__revision__` reads alike on a board and in the +// wheel. + +#pragma once + +#include "py/obj.h" +#include "py/objstr.h" // mp_obj_str_t -- py/obj.h alone does not declare it + +extern const mp_obj_str_t pygraphics_version_obj; +extern const mp_obj_str_t pygraphics_revision_obj; + +// One line inside the module's existing globals table. +#define PYGRAPHICS_BUILD_GLOBALS \ + { MP_ROM_QSTR(MP_QSTR___version__), MP_ROM_PTR(&pygraphics_version_obj) }, \ + { MP_ROM_QSTR(MP_QSTR___revision__), MP_ROM_PTR(&pygraphics_revision_obj) } diff --git a/src/gfx_module_cpy.c b/src/gfx_module_cpy.c index 40a428b..f95c639 100644 --- a/src/gfx_module_cpy.c +++ b/src/gfx_module_cpy.c @@ -3252,5 +3252,21 @@ PyMODINIT_FUNC PyInit_pygraphics(void) { ADD_INT("GS8", GFX_GS8); ADD_INT("RGB888", GFX_RGB888); #undef ADD_INT + // Which pygraphics this wheel was built from, the same two macros the + // firmware carries, computed the same way in setup.py -- so + // pygraphics.__revision__ reads alike on a board and on the desktop + // (pygraphics#25). "unknown" where there is no checkout and no git, which + // is honest; a test asserts it is not the answer inside one. +#ifndef PYGRAPHICS_VERSION +#define PYGRAPHICS_VERSION "0.0.0+unknown" +#endif +#ifndef PYGRAPHICS_REVISION +#define PYGRAPHICS_REVISION "unknown" +#endif + if (PyModule_AddStringConstant(m, "__version__", PYGRAPHICS_VERSION) < 0 + || PyModule_AddStringConstant(m, "__revision__", PYGRAPHICS_REVISION) < 0) { + Py_DECREF(m); + return NULL; + } return m; } diff --git a/tests/test_build_revision.py b/tests/test_build_revision.py new file mode 100644 index 0000000..b23fcc0 --- /dev/null +++ b/tests/test_build_revision.py @@ -0,0 +1,121 @@ +"""A firmware and a wheel must both say which pygraphics they were built from. + +pygraphics#25. `os.uname().version` answers for MicroPython and says nothing +about the user C modules compiled into the image, so a rendering that moves +between two firmwares cannot be attributed to a revision. audiodsp lost one +exactly that way -- a firmware built from a throwaway source tree during a pin +move, unrecoverable from the working tree or the `.bin` files afterwards. + +The case that matters here is the third: **the revision is not "unknown" in a +checkout**. Without it the whole mechanism can ship reporting `unknown` from +every target while every other assertion still passes -- the marker exists, the +two sides agree, and neither knows anything. +""" + +from __future__ import annotations + +import os +import re +import subprocess +import unittest + +import _env # noqa: F401 puts lib/ on sys.path when no native build is present +import pygraphics + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +#: pygraphics ships twice (tests/_env.py): a pure-Python package under lib/ and +#: the native C extension. The marker is the *build's*, so only the native one +#: can carry it -- there is no build step to stamp a .py file with, and a source +#: package installed by mip is identified by the mip package version. Under the +#: default pure-Python mode these tests skip and say so, rather than passing +#: vacuously; PYGRAPHICS_TEST_NATIVE=1 is where they mean something. +NATIVE = not getattr(pygraphics, "__file__", "").endswith(".py") + +#: `git describe --always --dirty --abbrev=7`: a tag-and-distance, or a bare +#: short hash in a repository with no tags, each optionally `-dirty`. +DESCRIBE = re.compile(r"^[0-9A-Za-z._/+-]+$") + + +def _in_a_checkout() -> bool: + # os.path.exists, not isdir: in a git WORKTREE `.git` is a file pointing at + # the real one, and isdir() silently skipped the test that matters here -- + # the same "absence reads as agreement" this file exists to prevent. + return os.path.exists(os.path.join(ROOT, ".git")) + + +@unittest.skipUnless( + NATIVE, + "the pure-Python pygraphics carries no build marker and cannot: " + "set PYGRAPHICS_TEST_NATIVE=1 to test the extension") +class BuildRevisionTests(unittest.TestCase): + def test_the_module_carries_both_markers(self): + # Asserted by name so that a rewrite of the module table fails here + # rather than shipping a firmware that cannot be identified. + self.assertTrue(hasattr(pygraphics, "__version__"), + "pygraphics.__version__ is missing: the build marker " + "is not in the module table") + self.assertTrue(hasattr(pygraphics, "__revision__"), + "pygraphics.__revision__ is missing: the build marker " + "is not in the module table") + self.assertIsInstance(pygraphics.__version__, str) + self.assertIsInstance(pygraphics.__revision__, str) + + def test_the_version_is_the_VERSION_file(self): + with open(os.path.join(ROOT, "VERSION"), encoding="utf-8") as handle: + expected = handle.read().strip() + # An extension built from an older checkout reports that checkout's + # version, which is the point of the marker -- so this only holds where + # the two were built together. Skip rather than lie about it. + if pygraphics.__version__ != expected: + self.skipTest( + "this pygraphics reports %r and the tree says %r -- the " + "extension was built from a different checkout, which is " + "exactly what the marker exists to tell you" + % (pygraphics.__version__, expected)) + + def test_the_revision_is_not_unknown_in_a_checkout(self): + """The one that catches a mechanism reporting nothing from everywhere.""" + if not _in_a_checkout(): + self.skipTest("not a git checkout; 'unknown' is the honest answer") + # NO second escape hatch here. The first draft also skipped when + # __version__ was "0.0.0+unknown", reading it as "built somewhere + # else" -- and an extension built right here with the -D macros + # forgotten says exactly that, so the planted fault SKIPPED instead of + # failing. In a checkout, "unknown" is a failure whatever else is true. + self.assertNotEqual( + pygraphics.__version__, "0.0.0+unknown", + "built inside a git checkout and still reporting the fallback " + "version: the build did not pass -DPYGRAPHICS_VERSION") + self.assertNotEqual( + pygraphics.__revision__, "unknown", + "built inside a git checkout and still reporting 'unknown': the " + "build did not pass -DPYGRAPHICS_REVISION, so every target would " + "report nothing while every other check here still passed") + self.assertRegex(pygraphics.__revision__, DESCRIBE) + + def test_it_agrees_with_what_git_says_now(self): + if not _in_a_checkout(): + self.skipTest("not a git checkout") + try: + described = subprocess.run( + ["git", "-C", ROOT, "describe", "--always", "--dirty", "--abbrev=7"], + capture_output=True, text=True, check=True).stdout.strip() + except (OSError, subprocess.CalledProcessError): + self.skipTest("git is not available here") + if described != pygraphics.__revision__: + # Not a failure: the tree moves under a built extension all day. + # Reported, because a mismatch is a true and useful statement. + self.skipTest( + "this pygraphics was built from %r and the tree is now at %r" + % (pygraphics.__revision__, described)) + + def test_the_marker_is_not_sprayed_over_every_object(self): + """A control: if everything had it, nothing would be proven by having it.""" + self.assertFalse(hasattr(pygraphics.FrameBuffer, "__revision__"), + "the marker belongs to the module, not to every type") + self.assertFalse(hasattr(pygraphics.Area, "__revision__")) + + +if __name__ == "__main__": + unittest.main()