This release is a pre-release and may not be stable for production use.
openglcontext-checks
Static checks for defect classes found repeatedly in reviews of the
OpenGLContext stack: document values used unchecked, files opened at paths a
document chose, files written in place, GL state left changed when a draw
raises, identity-keyed caches, GL calls from finalisers, configuration read
at import, suppressions with no reason, and tests that cannot fail. Each rule has a stable code, OGC and three digits, and decides
from a module's syntax tree, what its names are bound to, and its comment
tokens. The package needs nothing beyond the standard library (and tomli on
Python 3.10), so a project installs it without the engine.
$ pip install openglcontext-checks
$ oglc-check
src/game/cache.py:41:14: OGC131 id(mesh) as a key: the id is reused once the object is collected, so the entry can answer a different object; key on the object (a WeakKeyDictionary) or hold it in the entry
tests/test_render.py:12:1: OGC222 test_draws has no assertion: it passes whatever the code does, unless something raises; assert on the result, or use pytest.raises
Running it
$ oglc-check # the project's configured paths
$ oglc-check src/pkg/module.py # just these files or directories
$ oglc-check --force-exclude FILE # FILE, if a run with no arguments would check it
$ oglc-check --statistics # how many findings of each rule
$ oglc-check --select OGC131,OGC2 # these rules only (codes or prefixes)
$ oglc-check --ignore OGC222 # all but these
$ oglc-check --no-cache # neither read nor write the result cache
Findings print as path:line:column: CODE message, sorted by path and line,
with paths relative to the current directory. --statistics prints a count
per rule instead. The exit status is 0 when there are no findings, 1 when
there are, and 2 for a usage or configuration error or a file that does not
parse; a file that does not parse is named on standard error.
--select replaces the configured selection and --ignore adds to the
configured ignores. --jobs N sets the number of worker processes; a run
with 48 or more files to parse spreads them over up to eight processes by
default, and a smaller run uses one whatever --jobs says.
Over the 514 modules of OpenGLContext/ (138,000 lines) on a 32-core machine,
a run with nothing cached takes 0.24 s (0.87 s in one process), and a run
answered from the cache takes 0.06 s.
Configuration
The project is the nearest directory, at or above the current one, holding a
pyproject.toml, and settings are read from [tool.openglcontext-checks] in
that file. Its directory is the project root: configured paths and globs are
relative to it. Where that file has no table, every setting takes its default;
a table in an enclosing project (a workspace root around several projects, say)
is not used, since its paths are relative to another root.
[tool.openglcontext-checks]
paths = ["src", "tests"] # what a run with no arguments checks; default ["."]
select = ["OGC131", "OGC2"] # codes or prefixes; default every rule
ignore = ["OGC222"] # taken out of select
exclude = ["src/generated"] # not checked, beyond the defaults below
[tool.openglcontext-checks.per-file-ignores]
"scripts/**" = ["OGC161"] # not run on the files the glob matches
[tool.openglcontext-checks.scopes]
test = ["tests/**", "**/test_*.py"] # the modules the test rules run on
script = ["tools/*.py", "!tools/_*.py"] # programs run by path; not OGC161
loader = ["src/game/levels/**"] # modules reading documents: OGC101, OGC102, OGC111
pass = ["src/game/render/**"] # modules that draw: OGC151
[tool.openglcontext-checks.sanctioned]
OGC121 = ["game.files.staged_file", "game.files.staged_directory"]
Two more keys are read by the mypy plugin only (see Checked types, in mypy):
[tool.openglcontext-checks]
checked-types = ["game.levels.LevelKey"] # made only in the module defining them
contained-paths = ["game.levels.LevelPath"] # paths a loader-scope module may open
A path is in a scope when it matches one of the scope's globs and none of
the globs written with a leading !. The test scope, which OGC221 to
OGC223 run in, defaults to tests/**, **/test_*.py, **/*_test.py and
**/conftest.py. The script scope is empty unless a project names its
programs: files run by path, whose module level is their start-up, which
OGC161 does not run on. The loader scope (the modules that read values and
file names out of a document: model and level loaders, the handlers a
document's extensions name) and the pass scope (the modules that draw) are
empty until a project names them, so OGC101, OGC102, OGC111 and OGC151 report
nothing in a project that has not. An unknown key, an unknown rule code or
scope, or a value of the wrong type is a configuration error, and the run
exits 2 naming it.
OGC101, OGC102, OGC111, OGC121 and OGC151 report a raw form that has one
sanctioned replacement, and their messages name it. By default that is
OpenGLContext's API; a project with its own names it under sanctioned, by
qualified name, and the rule uses those instead. For OGC102 the names are the
size checks that count as a check, for OGC121 the staging calls whose
directory or file may be written, and for OGC151 the context managers that
restore state; for OGC101 and OGC111 they appear in the message only. The
module that implements the sanctioned API does what the rule reports, and a
per-file-ignores entry exempts it.
Globs are matched against the path relative to the project root, written
with /. A glob with no / matches any one component, so build matches a
directory of that name at any depth and test_*.py a file anywhere. A glob
with a / is anchored at the root and also matches everything beneath a
directory it names. * and ? stay within one component, [...] is a
character class, and a ** component matches any number of directories.
Files and directories whose names start with a dot are not checked
(version control, virtualenvs, tool caches, git worktrees), nor venv,
__pycache__, *.egg-info, build, _build, dist, node_modules and
site-packages. The package does not read .gitignore. A path named on the
command line or in paths is checked even when an exclusion matches it;
exclusions apply to what is found beneath it.
--force-exclude holds the paths named on the command line to the
configuration as well: a path outside the project, outside paths, or
matched by an exclusion is passed over. An editor or a hook that hands over
every file it touched uses it, so that what it checks is what the project's
own run checks.
Suppressing a finding
A finding is suppressed by a # noqa comment on the reported line that names
its code and gives a reason:
cache[id(node)] = value # noqa: OGC131 the cache is cleared with the scene that owns every node
The syntax is ruff's, so one comment can serve both tools:
# noqa: E501, OGC131 reason. A # noqa with no reason, or a bare # noqa,
does not suppress an OGC finding, and OGC201 reports it. A project that runs
ruff's RUF100 (unused noqa) tells ruff the codes are another tool's with
external = ["OGC"] in [tool.ruff.lint].
The result cache
Each project keeps the findings of its last run in .oglc-check-cache/ in the
project root. An entry is keyed on the file's bytes, this package's own source
(so an edited rule in an editable install counts, not only a new version),
the codes and scopes that apply to the file and the project's sanctioned
names, so editing the file, changing
the package or changing the configuration each make it miss. An
unchanged file is not parsed again, and its stored findings are reported as
before. The cache file is replaced whole, through a temporary file and one
rename. The directory holds a .gitignore of *, so it stays out of version
control without an entry in the project's own .gitignore.
In a pytest suite
[tool.pytest.ini_options]
addopts = "-p openglcontext_checks.pytest_plugin"
A run of the suite (pytest started with no paths) then includes one item per
selected rule, oglc-check[OGC131] and so on. The rules run once per session
over the project's configured paths with its settings and cache, and each
item fails listing its rule's findings. A file that does not parse fails
every item. -k and -m deselect the items like any others. A run naming its
own paths does not include them unless --oglc-check is given. The package
declares no pytest11 entry point, so installing it changes no suite that has
not asked for the items.
The rules
OGC101: bare conversion of a document value, in loaders
A call to the builtin float, int or bool whose first argument is a
subscript by a string literal (extras['depth']) or a get call with a
string literal first (params.get('count', 0)), in the loader scope,
including one given a default with or (extras.get('rate') or 1.0) or
chosen by a conditional expression. A misspelt value raises and aborts the load, 1e999 and nan pass float,
a count of four billion passes int, and bool('false') is true. An index
by a number or a variable (shape[0], values[key]), and a field of a table
the module itself defines or imports, are not reported. Read
the value through a reader that checks it, reports it once and answers a
default: OpenGLContext.loaders.documentvalues.DocumentValues.
rate = float(extras['rate']) # OGC101
rate = float(extras.get('rate') or 1.0) # OGC101
rate = values.number(extras.get('rate'), 1.0, 'emitter rate', minimum=0.0) # not reported
OGC102: decode before a size check, in loaders
In the loader scope: a decoder (base64's decoders,
binascii.a2b_base64, the decompress of zlib, gzip, bz2 and
lzma, DracoPy.decode) handed a parameter or local of the function, or a
numpy.frombuffer count or offset, or a numpy.empty, zeros, ones or
full shape, that reads a document's named field -- where nothing earlier in
the function compares one of the names the value is made of, or hands one to
a sanctioned size check (OpenGLContext.loaders.resolver.check_size,
check_pixels). A document naming gigabytes allocates them before anything
refuses it.
data = base64.b64decode(payload) # OGC102
check_size(len(payload) * 3 // 4, most, 'data: URI')
data = base64.b64decode(payload) # not reported
OGC111: raw opener on an unconfined path, in loaders
In the loader scope: open, io.open, codecs.open, the open of
gzip, bz2 and lzma, tarfile.open, zipfile.ZipFile,
PIL.Image.open, numpy.load or numpy.fromfile handed a path the same
function joins (os.path.join, a path class of several parts, +, /,
% or str.format on a literal template, an f-string) from a part after
the first that the source does not fix, or a path read from a document's
named field. A local name is followed through everything the function
assigns it. A part is fixed when it is a literal, a name of the module, or a
local assigned only fixed values; a parameter, an attribute or another
call's result opened as it is is not reported. Resolve the name against the
document's base, which refuses one that leads outside it:
OpenGLContext.loaders.resolver.Resolver.resolve,
OpenGLContext.loaders.tiles3d.fetch.beside.
card = Image.open(os.path.join(directory, species['card'])) # OGC111
card = Image.open(fetch.local_copy(fetch.beside(directory, species['card']))) # not reported
OGC121: file written in place
open, io.open, codecs.open, or the open of gzip, bz2 and lzma,
with a literal mode that writes over what the file holds (w, x, or +
other than to append); tarfile.open or zipfile.ZipFile with a mode that
writes (w, a, x); shutil.copy, copy2, copyfile or copytree to a
destination; and write_text, write_bytes, or open with a literal
mode that writes, on anything that is not an imported module (a
pathlib.Path). Not run in the test scope. A write cut short leaves part
of the file, which the next run takes for the whole. Not reported: a write
under a directory or to a file that a sanctioned staging call
(OpenGLContext.atomicfiles.staged_file, staged_directory) or
tempfile.mkdtemp, mkstemp or TemporaryDirectory made, and a write to a
name the same function moves into place with os.replace or os.rename.
A stream opened to append (a log, a journal, a lock file) keeps what it held
and is not reported.
with open(path, 'w') as handle: # OGC121
json.dump(record, handle)
with atomicfiles.staged_file(path, 'w') as handle: # not reported
json.dump(record, handle)
OGC131: id() as a key
id(x), alone or in a tuple, used as a subscript, a dict or set key, the
left side of in, the first argument of get, setdefault, pop, add,
discard or remove, or a value assigned to an attribute, where the same
statement does not also store x. An id is reused as soon as its object is
collected, and the table then answers a new object with the old one's entry.
Key on the object (a WeakKeyDictionary where the table should not keep it
alive), or hold the object in the entry and compare it on lookup.
Not reported: a table that exists for one call (a local name bound only to a
container made in the function, used only in place, never passed on,
returned, stored or read from a nested function), a set or dict display that
is compared or measured with len and dropped, and a lookup whose entry is
compared by identity with the object, directly (table.get(id(x)) is x) or
through the name it is assigned to (entry[0] is x).
_FITS[id(positions)] = fitted # OGC131
_FITS[id(positions)] = (positions, fitted) # holds the object: not reported
entry = _FITS.get(id(positions)) # compared on lookup: not reported
if entry is None or entry[0] is not positions:
...
seen = set() # the call's own table: not reported
for node in walk(root):
seen.add(id(node))
OGC141: GL call in __del__
A call inside a __del__ method to a function of OpenGL.GL, an
OpenGL.GLES* module or their OpenGL.raw forms, through any import form;
after a star import from one of those, an unbound name spelled gl and a
capital letter; and a parameter whose default is one of those functions
(def __del__(self, glDeleteLists=glDeleteLists)), unless the body rebinds
it. A finaliser runs on whichever thread collects the object,
with whatever context is current there or none. Queue the release for the
context that owns the name, and delete it there.
class Texture:
def __del__(self):
GL.glDeleteTextures([self.name]) # OGC141
class QueuedTexture:
def __del__(self):
PENDING_RELEASES.append(self.name) # not reported
OGC151: GL state change not restored, in passes
In the pass scope: glEnable, glDisable, glBindFramebuffer,
glScissor, glUseProgram or glCullFace, from OpenGL.GL or a GLES
module, unless a try in the same function restores it in its finally
(the same call again; glEnable and glDisable of the same capability;
disabling the scissor test for glScissor) and either holds the change in
its body or handlers or follows it in the same block or one around it. A
call in a finally is a restore itself, and one inside a with of a
sanctioned state manager is restored by it. A draw that raises part way
leaves the state it set for the next view, pass or frame.
glEnable(GL_SCISSOR_TEST) # OGC151
scene.render(view)
glDisable(GL_SCISSOR_TEST) # OGC151
glEnable(GL_SCISSOR_TEST) # not reported
try:
scene.render(view)
finally:
glDisable(GL_SCISSOR_TEST)
OGC161: configuration or I/O at import
At module level or in a class body (decorators and default values included;
not in a function or lambda body, and not in the body of an
if __name__ == '__main__': or if TYPE_CHECKING: block): any use of
os.environ, os.environb or sys.argv, and calls to os.getenv,
os.getenvb, os.putenv, os.unsetenv, locale.setlocale, open,
io.open, os.makedirs, os.mkdir, os.remove, os.unlink, os.rename,
os.replace, os.chdir and every function of shutil and subprocess.
Names resolve through imports. The set is closed: other calls at import are
not reported. A value read at import is fixed before an application or a
test can set it. Read it where it is used, in a function.
A program run by path reads its configuration at module level by design, and
nothing in its syntax says it is one: a project lists its programs in the
script scope, and OGC161 does not run on them. A module with a main()
that a console-script entry point imports is not a program in this sense;
its start-up is main().
BACKEND = os.environ.get('BACKEND', 'glfw') # OGC161
def backend():
return os.environ.get('BACKEND', 'glfw') # not reported
OGC201: suppression without a reason
A # noqa or # type: ignore that names no code, or names codes with no
reason after them. A # noqa's reason is at least one word after the codes,
optionally after -, --, : or a dash. A # type: ignore's reason is a
comment of its own after it: mypy reports any other text after the codes as
an invalid comment, so text written there is reported too. Pragmas are read
from comment tokens, so text in a string is never one.
import os # noqa: F401 # OGC201
x = f() # type: ignore # OGC201 (bare)
x = f() # type: ignore[attr-defined] the stubs lack it # OGC201 (mypy rejects it)
x = f() # type: ignore[attr-defined] # the stubs lack it # not reported
OGC221: skip inside an except, in tests
A call to pytest.skip, pytest.xfail or pytest.importorskip in the body
of an except handler, in the test scope. The failure the handler caught is
reported as a skip. Use pytest.importorskip on its own for an optional
module, check for a capability before the code under test runs, or let the
exception fail the test.
try:
image = render()
except Exception:
pytest.skip('render failed') # OGC221
OGC222: test with no assertion, in tests
A function pytest collects as a test (named test*, at module level or a
method of a module-level class) whose body, less nested definitions, has no
assert, no raise of an exception, no pytest.raises, pytest.warns,
pytest.fail or pytest.deprecated_call, and no call to a function or method
named fail or starting with assert, check, expect or verify (leading
underscores aside). A call to a helper defined in the same module counts when
the helper asserts, followed through the helpers it calls: a module-level
function called by the name its def binds, or a method called on the test's
self, from the test's class or a base class defined in the module. Such a
test fails only if something raises. Assert on the result, or use
pytest.raises; a helper from another module that asserts is named for it.
def test_draws():
draw() # OGC222
def test_draws():
assert draw().covered > 0 # not reported
OGC223: pass in a test's handler, in tests
An except handler anywhere inside a test function whose body is only pass
or .... The test passes whether or not the exception happened. Remove the
handler, use pytest.raises, or assert on what was caught.
def test_teardown():
try:
close()
except Exception: # OGC223
pass
Checked types, in mypy
A checked type is a value whose type says a check has been made: a path held to the directory of the document that named it, a URL put to a redirect policy, the handle of the GL context that is current. Its constructor is private to the module that makes the check, and the functions below that module take only the checked type, so a value that has not been through the check does not type-check where one is needed. mypy on its own accepts a construction written anywhere; the plugin in this package reports one:
[tool.mypy]
plugins = ["openglcontext_checks.mypy_plugin"]
It reports, with the error code checked-construction, a checked type made
(ContainedPath(name)) or subclassed outside its home module, the module
defining the class. OpenGLContext's checked types (ContainedPath and
CheckedURL in OpenGLContext.loaders.resolver, ContextKey in
OpenGLContext.contextresources) are reported in every project; a project
adds its own as checked-types.
In a module of the loader scope it also reports, with the error code
unchecked-open, a file opened at a path whose type is not a contained path:
open, io.open, codecs.open, gzip, bz2 and lzma's open,
tarfile.open, zipfile.ZipFile, PIL.Image.open, numpy.load and
numpy.fromfile handed a str, bytes, a pathlib.Path or a value typed
Any. A string literal, a Literal type, a file descriptor and an open file
are accepted. OpenGLContext's ContainedPath is a contained path; a project
adds its own as contained-paths. This is OGC111 by type rather than by
syntax: OGC111 reports a path the function builds from a name, and the plugin
reports every path whose type does not say where it came from, parameters
and attributes included.
The settings are read from the pyproject.toml beside mypy's configuration
file, or in the directory mypy runs in when it has none, and are part of each
module's entry in mypy's cache, so a changed scope checks the module again.
Where mypy has a hook of its own for one of these calls, that hook still
gives the call's type. typing.cast to a checked type is not reported.
Development
$ pip install -e ".[dev]"
$ python -m coverage run -m pytest && python -m coverage report
$ ruff check . && ruff format --check .
$ mypy src/openglcontext_checks
$ oglc-check
The suite requires 100% line and branch coverage. It runs the package's own pytest entry point, so it includes an item per rule over this project.
A rule is a module in src/openglcontext_checks/rules/: a Rule subclass
with a code, a name, a docstring saying what it flags, why and what to use
instead, and VALID and INVALID examples, which tests/test_examples.py
runs for every registered rule. It reports from visit (handed each node of
the types in nodes, from one walk shared by every rule) or check_module,
and resolves names through module.symbols. It is registered in
rules/__init__.py.
Licence
BSD 3-Clause; see LICENSE.
Release files for openglcontext-checks 0.1.0a2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| openglcontext_checks-0.1.0a2.tar.gz | 99.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| openglcontext_checks-0.1.0a2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 178.2 kB
Release files / openglcontext_checks-0.1.0a2.tar.gz
| Download URL | openglcontext_checks-0.1.0a2.tar.gz |
|---|---|
| Size | 99.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8ad3645d849dadba0dab8843389cc4571eb695715611e296277a828f5b7c08b5
|
|
BLAKE2b-256 checksum How to use checksums |
5eb87f91a4a3ae0cb3e0b5e7d51fcfe045ea85f2a18a43e4c0be85a8f650aacc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.
Transparency logRelease files / openglcontext_checks-0.1.0a2-py3-none-any.whl
| Download URL | openglcontext_checks-0.1.0a2-py3-none-any.whl |
|---|---|
| Size | 78.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
339a7b3a3cb7bc3001b016b473bf98af47a2e39a3eab0c1dc05ff1d6e7b59807
|
|
BLAKE2b-256 checksum How to use checksums |
0977563516a4acb6d9a2260cdde5dc37a5cf6c0d222e8a4277063542fe4fe839
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.
Transparency log