Skip to content

Match 4.6.0 speed with #330 and the default colours - #333

Open
wolph wants to merge 46 commits into
developfrom
perf/match-4.6.0
Open

wolph wants to merge 46 commits into
developfrom
perf/match-4.6.0

Conversation

@wolph

@wolph wolph commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Develop plus #330 rendered a forced redraw 1.5 times slower than 4.6.0
and a single widget call four times slower. This branch merges #330 and
brings the README benchmarks back to 4.6.0 speed or better, without
changing any public name, signature, default, return type or exception
type, and without undoing the #330 fixes.

Measured on one machine (Apple Silicon, CPython 3.13, real pty) with
benchmarks/bench.py sizes, 4.6.0 and this branch interleaved, minimum
of several runs:

Measurement 4.6.0 this branch
A. iterator overhead, plain install 27.5 ns 26.4 ns
A. iterator overhead, [fast] 3.88 ns 3.46 ns
B. forced redraw, default widgets 23.6 us 18.4 us
B. forced redraw, FastProgressBar 4.73 us 4.75 us
C. cold import, plain and [fast] 3.3 to 3.7 ms 3.5 to 3.9 ms, same spread
one FormatLabel call, coloured since develop 1.13 us 1.19 us
one Timer call, coloured since develop 0.86 us 0.98 us
one captured print() write 0.039 us 0.119 us

Where the time went and what changed:

  • develop's colour-by-default pipeline resolved each widget colour
    through remap, interpolate and a Color round trip on every
    redraw. The gradient arithmetic is inlined for the default blend, the
    rendered escapes are remembered per gradient and per fixed colour
    (bounded tables, invalidated when the colour support, colors or
    interpolate changes), and apply_colors concatenates so a str
    subclass such as Markup keeps its type. Output is byte identical:
    tests/test_color_fast_path.py compares against reference
    implementations for every ColorSupport level on a dense grid.
  • Fix the security audit and adversarial hunt findings against 4.6.0 #330 rendered new-style templates through a string.Formatter
    subclass on every call. A template's safety depends on its text only,
    so each distinct text is validated once and then rendered with
    str.format_map. Dunder and frame attributes are still refused, and a
    positional field still raises the IndexError 4.6.0 raised.
  • Fix the security audit and adversarial hunt findings against 4.6.0 #330 checked the capture buffer's type and instance dict with a
    generator on every captured print(). The check is one type test and
    one dict test, the buffer lock is a plain Lock, and streams.flush()
    returns before the lock when nothing is wrapped. A captured write still
    costs three times 4.6.0's 0.04 us: 4.6.0 took no lock and lost text
    written during a drain, the lock is what fixes that.
  • Include labels in fast progress bar width #331's label width fix measured prefix and suffix on every fast
    redraw, also for the default bar that has neither. Without labels the
    width is len(left) + len(right) as in 4.6.0.
  • update_capturing() calls flush() again once the last capture stops,
    so a StreamWrapper subclass overriding flush() sees it as in 4.6.0.
  • The three specialised-template tests and two label tests of Fix the security audit and adversarial hunt findings against 4.6.0 #330 strip
    the default colours before comparing, the fork hook is guarded by
    platform so pyright passes on Windows, the CLI docs state the exit
    statuses, the tqdm page says desc is literal text, and the changelog
    mentions the python-utils floor.

Checks on this branch: pytest with coverage on 3.10 and 3.14t (100%),
ruff 0.15.20 check and format, codespell, pyright on Linux and Windows
platform, the 4.6.0 test files (290 pass, the 3 test_widgets colour
tests fail on develop already), and the #330 compatibility probes
(no new difference against develop plus #330 apart from the restored
IndexError message and the restored flush() hook).

Known and unchanged: widgets called directly return ANSI codes by
default since develop's colour change, so a single Timer() call costs
about 0.1 us more than the uncoloured 4.6.0 call. Merging this branch
also lands #330.

wolph and others added 30 commits September 9, 2026 01:12
The maxval, poll, and currval deprecation warnings used stacklevel=1, so they named progressbar's own source instead of the line that used the old name. A fixed stacklevel=2 would only be right for currval: _apply_deprecated_aliases is reached through ProgressBar.__init__ and any subclass __init__ chain above it, so the depth varies per caller. _caller_stacklevel() walks the frames from the warning site outward until the code is no longer inside the progressbar package and returns that depth. Five tests record the warning and assert its filename and line are the caller's, for a plain bar, a DataTransferBar, a user subclass with its own __init__, and the currval property.
WrappingIO.write iterated the shared listener set while start_capturing
and stop_capturing on another thread added or removed bars, which on
3.14t and 3.15t raised "Set changed size during iteration" tens of
thousands of times per run and dropped bar updates. The listener set is
now guarded by a small dedicated StreamWrapper.listeners_lock that every
WrappingIO shares, held only around the add, remove and the snapshot in
write(), never while calling out, so it cannot take part in a lock-order
cycle with a logging handler or a locking target stream. The wider
StreamWrapper.lock (reentrant) guards wrap, unwrap and capture
transitions.

Both classes pickle and deepcopy again by dropping the locks in
__getstate__ and recreating them on load. StreamWrapper has class-level
default locks so a subclass that skips super().__init__() keeps working.
Unwrapping delivers the dropped wrapper's buffered text to the original
stream and never raises for a stream that cannot take it (logged at
DEBUG).

What working code can notice: WrappingIO gains an optional lock=
keyword (api_surface_snapshot regenerated), its default lock is a plain
threading.Lock, and text that was buffered in a wrapper at unwrap time
now reaches the original stream instead of being lost.
The new-style widget formatter resolved any attribute path a template
named, so a template taken from user input could walk {variables.x.__class__}
and further into the interpreter. Rendering goes through a string.Formatter
subclass that refuses attribute segments starting with two underscores
and raises ValueError; single-underscore names and everything else render
exactly as in 4.6.0. Non-str templates with a callable format() (lazy
translation proxies) are rendered through str(); None, lists and numbers
raise TypeError as before.

ProgressBar(desc=...) is rendered literally as f'{desc}: ' and is never
a format template; a prefix assigned afterwards is a template as before.
PROGRESSBAR_MINIMUM_UPDATE_INTERVAL that is not a non-negative finite
number is ignored with one WARNING per distinct value instead of raising
at construction.

What working code can notice: templates addressing __dunder__ paths now
raise ValueError, a desc containing braces renders them literally, and a
bad interval variable logs instead of raising.
Validate every size option through size_to_bytes with a minimum and a
parser error (status 2) instead of a traceback, reject a missing input,
a directory input, a directory output and an output path that is also an
input before the output is truncated, and close every input after its
copy. An early-closing reader (`... | head`) ends the copy quietly with
status 0: the BrokenPipeError is caught and, when the broken stream is
the process's real stdout (fd 1), the descriptor is pointed at os.devnull
so the interpreter's exit flush cannot raise again. A replacement
sys.stdout that owns another descriptor is left alone. Ctrl-C exits 130.

main() is split into small helpers (_parse_size_option, _collect_inputs,
_check_output_path, _open_input, _copy_stream) with no behaviour change
beyond the above.

What working code can notice: invalid -s/-B/-L values exit 2 with a
message instead of a traceback, `-o` onto an input file is refused, and
`... | head` exits 0 where 4.6.0 exited 120 with "Exception ignored".
…play

Batch item labels are data, not terminal markup: every ESC-introduced
sequence and every C0/C1 control is stripped before the label becomes a
MultiBar key, so an item cannot move the cursor, retitle the terminal or
hide output from the other rows. Labels that are not str are rendered
through str() first. A finished task's bar is finished (end='') before
it is dropped so its stream listeners, capture refcounts and resize
handler return to baseline instead of leaking per task.

In the shell runner the {} and {item} placeholders are substituted in a
single regex pass, so placeholder text inside an item is never expanded
again, and shell=True with a list or callable template is a TypeError:
subprocess would run only the first element through the shell and drop
the item.
The ANSI detection called the console-mode probe on Windows for every
stream, including pipes and files, where it reports on the console the
process happens to own rather than on the stream being asked about.
The probe now runs only when the stream is a tty, matching the other
platforms.
The publish job runs only for a v* tag whose name matches __version__
and whose commit is the checked-out commit, so a manual dispatch on a
branch or a mistyped tag cannot publish. The built sdist and wheel are
listed and refused when they contain sync-conflict, .orig, .rej or .bak
files. The workflow token is limited to contents: read. Local secret
and environment files are ignored by git.
Two adversarial reviews of the previous commit found that the
transition lock was still held while logging ("wrapping excepthook")
and while flushing captured text into the original streams. A logging
handler that writes into a wrapper, or a target stream with its own
lock, could then wait on a thread that was waiting for the transition
lock: a deadlock 4.6.0 could not have, because it held no lock at all.
Moving the call-outs outside the lock naively made two overlapping
stop_capturing() calls deliver the same text twice.

Now every transition takes the buffered text under the lock
(WrappingIO._take) and writes it to the original stream after
releasing it (WrappingIO._deliver), so nothing blocks while the lock is
held and no text is delivered twice. The excepthook installation logs
after the lock is released.

The locks live in a small shared box, _SharedLock, that a StreamWrapper
and the wrappers it installs hold in common. The box keeps its identity
through pickle and deepcopy whichever object is the root, so restored
wrappers share the restored lock, subclass slots survive (default
pickling again, no __getstate__), subclasses that define __eq__ without
__hash__ construct, and a forked child gets fresh unlocked locks for
every live box through os.register_at_fork, standalone wrappers
included. Lock annotations evaluate on 3.10 to 3.12, where
threading.Lock is a factory function.

What working code can notice: StreamWrapper.lock and listeners_lock
are read-only properties (both were introduced on this branch, 4.6.0
had neither), and get_type_hints(WrappingIO.__init__) works again on
3.10 to 3.12.
ProgressBar(desc=...) rendered the literal text by inserting the plain
str into the widget list, so bar.widgets[0] was a str where 4.6.0 (and
prefix=) had a FormatLabel; a subclass that rewrites that label after
start() silently missed it. The desc-derived prefix is now escaped and
wrapped in a FormatLabel when the widgets are built, while bar.prefix
keeps the raw f'{desc}: ' value that the fast path renders verbatim.

The safe formatter also refuses the non-dunder attributes that lead
from a generator, coroutine, traceback or frame stored as a bar
variable to a frame's globals (f_globals, f_locals, f_builtins,
f_back, gi_frame, cr_frame, ag_frame, tb_frame).

The once-per-value warning for a bad PROGRESSBAR_MINIMUM_UPDATE_INTERVAL
records the value under a lock before logging, so concurrent first
constructions produce one record, remembers every distinct value, and
its lock is reset in a forked child.
Refuse an -o path that is the file stdin is open on, an unreadable
input and a closed stdin before the output is opened, so the output is
never truncated for an invocation that then fails. Reject a size option
above sys.maxsize instead of raising OverflowError from read(). Flush
the output inside the guarded block, so a broken pipe that is only
noticed when a small copy's buffer reaches the pipe exits 0 like a
large one.
The render thread snapshots the bars and a worker may delete one
(task_finished) before the finished-bar path runs. That path now
returns early for a bar that is no longer registered, undoes its
timestamp if the bar vanished while it was stamped, and ignores a
concurrent removal when expiring it, instead of leaking the entry or
raising KeyError from the render thread.
…ames

The version check accepted only `__version__ = '...'`; the annotated
spelling `__version__: str = '...'` failed the guard although the
package built. The stray-file pattern matched any path containing
.orig, .rej or .bak; it now matches those as file extensions only.
The test relied on 30 ms sleep differences between five workers and
failed twice on a loaded machine. Only one item can now complete until
a gate opens, so the first yielded pair is deterministic.
test_wrapping_io_and_a_redirecting_bar_deepcopy_and_pickle deep-copied a
started, redirecting bar and a StreamWrapper holding one, and dropped the
copies. A copy of a started bar is itself a started bar, so its __del__
ran finish() whenever the gc got to it, usually inside a later test,
where it dropped the singleton's capture count and restored sys.stdout
under that test's feet. That is what made
test_redirected_stderr_lines_are_flushed_above_bar fail now and then
under coverage. The copies are marked finished before they go.
A flush takes the buffered text of both wrapped streams and then writes
it to the two original streams. When the stdout write raised, the
exception left the loop before the stderr text was written, and that
text was already gone from the buffer, so a broken stdout cost the
stderr lines of the same round.

Every taken text is now written before the first exception is raised.
Delivery stays as 4.6.0 did it: each caller writes what it took, at
once, with no lock held during the writes. A single ordered delivery
queue was tried first and taken back again, in both a non-blocking
shape (a caller returned with its own text still queued behind another
thread, so text written right after a stop landed above it, and a
flush during a render thread's delivery wrote nothing) and a blocking
shape (a lock-order cycle with a logging handler whose stream is the
wrapper, when the original stream itself logs).

Working code notices: an exception from the stdout target no longer
drops the stderr text flushed in the same call.
The shared resize registry saved the previous handler when its set of
bars was empty and restored it from the last uninstall(). Three ways out
of a bar skipped that restore: a bar collected without finish() left the
registry's handler installed for the rest of the process, a finish() off
the main thread hit the ValueError from signal.signal() with the bar
already discarded, after which the next bar saved the registry's own
handler as the "previous" one and the real one was lost for good, and a
handler installed from C (getsignal() returns None) made the restore a
TypeError. One bar whose _handle_resize() raised also skipped the other
bars and propagated out of the signal handler into unrelated code.

Whether the shared handler is installed is now read from getsignal()
instead of inferred from the set, so a handler that other code set over
ours is never clobbered. The saved handler is cleared only after a
restore succeeds, a restore that fails off the main thread is retried at
the next registration change or the next resize signal, None restores
SIG_DFL (SIGWINCH is ignored by default), every registered bar gets a
weakref finalizer that restores once no live bar is left, and a failing
bar is logged at WARNING on the progressbar.bar logger and skipped.

What working code can notice: a resize handler that raised now logs
instead of raising, and the previous handler comes back after bars that
were dropped without finish() or finished from a worker thread.
WrappingIO.write() appended to the StringIO buffer without a lock while
_take() read it, then truncated it. A write landing between the read and
the truncate was gone: deterministic with a buffer that pauses in
truncate(), and a handful of tokens per run in a six-thread stress on the
free-threaded builds. 4.6.0 has the same race.

Each WrappingIO now holds a reentrant lock in a shared-lock box that
write() and _take() take around the StringIO write, getvalue, seek and
truncate calls only. It is never held while a listener, the target
stream or the buffer's own flush() is called (a review found that a
replacement buffer whose flush() logs through a handler on the wrapper
deadlocked when the flush ran inside the lock), so it cannot take part
in a lock-order cycle with a logging handler. The box survives pickle,
deepcopy and fork like the listeners lock does, and the class-level
default keeps a subclass that skips super().__init__() working.
`progressbar FILE -o /dev/stdout | head -c1` exited 1. The file opened
for -o is not sys.stdout, so the quiet exit for a reader that went away
did not apply, and the interpreter reported the failing flush at exit.

_output_is_stdout() now also accepts a file whose fstat() reports the
same device and inode as sys.stdout, and _discard_broken_stdout() points
both descriptor 1 and the file's own descriptor at os.devnull so neither
the exit flush nor the exit stack's close raises again. Nothing changes
unless descriptor 1 is involved: a replacement stdout on another
descriptor, a regular output file and a FIFO given by path are left
alone.
python-utils before 4.1.4 scaled a value between 0 and 1/1024 to a
negative power, and DataSize and format_unit_value() used that power as
an index into their prefix tuple, so a fraction of a byte rendered with
the largest prefix ('0.1 YiB', '0.1 PiB'). 4.1.4 returns power 0 for
those values. The floor moves from 3.8.1 to 4.1.4, the development lock
follows, and two tests pin the rendering of a value below one byte.

What working code can notice: installing progressbar2 upgrades a
python-utils older than 4.1.4. Both packages already require Python
3.10 or newer.
…ests

The two tests compare a FormatLabel called directly with plain text.
On develop every widget colours its output by default (624ebf2), so
the direct call returns ANSI codes and the merge of #330 went red on
every tox job. Compare through utils.no_color(), as the FormatLabel
doctest does.

(cherry picked from commit 772a2ea914e6d1dbb0c9d810c93c0452a9456620)
…330

(cherry picked from commit b81f026c0db3541a8dbd64e29a401c47e6cdfa21)
… after #330

The Windows tox job of #330 failed in pyright: the stubs declare
os.register_at_fork only off Windows, so the hasattr() guard did not
narrow it. A sys.platform check does, and every platform that can fork
has the hook.

(cherry picked from commit 4dde4e55a2c29f8926df23ecfa9c76af5b52cf39)
A new-style template went through string.Formatter.vformat on every
render so that get_field() could refuse dunder and frame attributes.
The parse, the field split and the attribute check depend only on the
template text, so they ran again for every update with the same
result. _safe_format() now validates each distinct text once, keeps it
in a bounded dict, and renders a plain str with str.format_map(). A
specialised template keeps its own format() method and its text is
read again on every call, so a proxy that switches language is
checked for the text it is about to render.

_safe_format on '{value} of {max_value} {percentage:3.0f}%':
1.52 us before, 0.26 us after (str.format itself: 0.28 us).
FormatLabel with the default colours: 5.75 us before, 4.42 us after,
against 4.38 us on develop without this check.
wolph added 13 commits November 4, 2026 05:05
…ate tests

develop colours widgets by default, so the rendered text carries the
ANSI prefix. The Markup type assertion stays on the raw result.
Two per-call costs crept into the hot paths with #330 and the fast
label fix. Every captured write() ran _native_buffer(): a type test,
vars() and a five-name generator, under a re-entrant lock. Every bar
redraw called streams.flush(), which took the transition lock even
with nothing wrapped, and the fast line measured its width through
len_color() for text that never carries an escape code.

write() now inlines the native test as one type check and one dict
truth test, and the buffer lock is a plain Lock: it is only ever held
around StringIO calls, never while calling out, so re-entrancy was
never needed. flush() returns before the lock when neither stream is
wrapped. The fast formatter uses len() when the default measurer
applies and the text has no escape code, and still calls a custom
measurer.

Measured on CPython 3.13 against v4.6.0 with benchmarks/bench.py
sizes: captured write 0.373 to 0.133 us (4.6.0: 0.041, unlocked and
losing writes during a drain), forced fast render 5.89 to 4.96 us
(4.6.0: 4.92), function calls per fast update back to the 4.6.0 count.
Two tests pin the flush shortcut and the lock rule for a buffer with
an unrelated instance attribute.
str.format_map refuses a positional field with ValueError, where
4.6.0's str.format(**data) raised IndexError. The validation step now
raises that IndexError itself, with the 4.6.0 message, so callers that
caught it keep working.
Colouring widgets by default made a forced redraw 1.5 times slower than
4.6.0 and a bare Timer() call 4.5 times slower. Every coloured widget
resolved its gradient through python_utils remap twice (type promotion,
range checks and typing.cast on each call), built three namedtuples for
the interpolated Color, created an SGRColor and rendered its escapes
through two str.format calls.

The gradient now does the remap arithmetic inline for a float value,
which is bit for bit what remap computed for a 0..1 input range, and a
plain ColorGradient with the default blend renders its SGR parameter
straight from the channel arithmetic. Color.ansi, RGB.to_ansi_16 and
RGB.to_ansi_256 share module functions with that shortcut, so there is
one rule for the encoding. apply_colors writes the escape sequence
directly instead of going through Color.fg and Color.bg, a Color
subclass keeps its own fg and bg, and a gradient with a custom blend, a
subclass or a non float value takes the old path. The FormatLabel alias
loop uses try/except instead of a contextlib.suppress per call,
len_color skips the no_color frame, and _format_widgets tests for a
plain str before the abstract isinstance checks and only converts a
widget result that is not already a str.

tests/test_color_fast_path.py compares apply_colors and get_color with
the plain composition of the public pieces for every ColorSupport level
over a dense percentage grid, fixed and unregistered colours, two to
eight stop gradients, snap and custom blends and a HueGradient.

Quick benchmark on this machine, CPython 3.13, real pty:

    forced redraw, default widgets  37.6 us -> 21.0 us  (4.6.0: 24.7 us)
    Timer() call                     4.06 us -> 1.55 us (4.6.0: 0.90 us)
    FormatLabel() call               6.45 us -> 3.1 us  (4.6.0: 1.18 us)

The remaining FormatLabel gap is the string.Formatter template path,
and the remaining Timer gap is the colour itself.
4.6.0's update_capturing() flushed through the public method once the
refcount reached zero, so a StreamWrapper subclass that overrides
flush() saw every capture end. #330 moved the drain into the locked
_apply_capturing() path and the hook went silent. The drain stays
where it is, and update_capturing() calls flush() afterwards, which
restores the 4.6.0 call sequence and costs one early return when no
stream is wrapped.
The bump from 3.8.1 to 4.1.4 is the first thing an install with a
python-utils<4 pin notices, so it belongs in the Unreleased section.
A coloured widget call still cost 0.75 us more than its uncoloured
twin: the gradient arithmetic, the SGR parameter and two f-strings ran
on every call although the default widgets of one bar all resolve the
same gradient at the same percentage on every redraw, and the escapes
of a fixed Color never change for a given colour support.

apply_colors now remembers the rendered (prefix, suffix) pair per plain
ColorGradient by (start code, value) in a module table tagged with the
colour support it was rendered for, and per plain Color by (colour,
start code). Assigning a gradient's colors or interpolate drops its
entry, a support change invalidates on the next call, the per gradient
table stops at 1024 values and the gradient table at 64 gradients, and
a custom blend is never memoised. The hot path for a gradient fg is one
table lookup inside apply_colors with no helper call in between.

The wrap is a concatenation, prefix + text + suffix, as Color.fg and
Color.bg always produced it: a str subclass such as markupsafe.Markup
dispatches to its own __radd__ and comes back as that subclass, where
the f-string of the previous commit returned a plain str.

Quick benchmark on this machine, CPython 3.13, real pty, merged tree:

    forced redraw, default widgets  20.9 us -> 18.7 us  (4.6.0: 24.7 us)
    Timer() call                     1.47 us -> 1.04 us (4.6.0: 0.90 us)
    FormatLabel() call               1.75 us -> 1.25 us (4.6.0: 1.18 us)

An uncoloured Timer() costs 0.77 to 0.88 us on this tree, so the
remaining gap to 4.6.0 is the colour step itself: one dict lookup and
one concatenation.
…bels

The #331 label width fix measured prefix and suffix on every redraw,
also for the default bar that has neither, which cost 0.1 us of the
1.6 us formatter. Without labels the width is len(left) + len(right)
as in 4.6.0. With labels the str() conversion, the escape check and the
custom measurer run as before, and two tests pin a coloured label and a
str subclass label.

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

wolph added 2 commits November 4, 2026 06:08
CodeQL flagged five test asserts whose expression pops or writes, which
python -O would skip, and the module global that tracked the colour
support of the fixed-colour escape table. The asserts read a variable
now, and the support lives in a one-cell list next to the table.
The CI terminal has no colour support, so the bar stripped the escapes
from the label before the assertion looked for them.
Comment thread tests/test_stream_security.py Fixed
Comment thread tests/test_stream_security.py Fixed
Comment thread progressbar/multi.py
self._finished_at.clear()
self._labeled.clear()
# Release the last references after leaving the membership guard.
del removed
Comment thread tests/test_multibar.py
'operation', ['replace', 'pop', 'popitem', 'clear', 'finish']
)
def test_membership_tracking_never_calls_bar_hash(operation: str) -> None:
class HashBar(progressbar.ProgressBar):
import pytest
from test_stream import reset_wrapped_streams

import progressbar
Comment thread progressbar/utils.py
self.capturing = max(self.capturing - 1, 0)
try:
pending = self._apply_capturing()
except BaseException as exception: # noqa: BLE001
Comment thread progressbar/utils.py
# Never flushes here: the count just went above zero
try:
self._apply_capturing()
except BaseException as exception: # noqa: BLE001
Comment thread progressbar/utils.py
return None
try:
return self._take()
except BaseException as exception: # noqa: BLE001
Comment thread progressbar/multi.py
@typing.overload
def setdefault(
self, key: str, default: None = None
) -> bar.ProgressBar | None: ...
Comment thread progressbar/multi.py
@typing.overload
def setdefault(
self, key: str, default: bar.ProgressBar
) -> bar.ProgressBar: ...
The two tests check that an instance whose constructor never reached
the parent's still wraps and writes. Creating it with __new__ and
setting the attributes in the test keeps that scenario and no longer
reads as a subclass that forgot super().__init__().

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants