From dcf36f209a0e834aa746e036c49a5920192b244c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 07:58:08 -0500 Subject: [PATCH 01/23] docs(fix[README]): Test the capture example why: The capture-pane example carried `# doctest: +SKIP`, which WRITING.md bans: it rendered a prompt and output that nothing ever checked. Waiting for the echoed line and asserting it makes the quickstart's capture step a real test. A planted absent string fails with WaitTimeout. what: - Replace the skipped capture with retry_until on capture_pane() - Keep the sample capture beside it as a text fence, so a reader still sees what capture_pane() returns --- README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 920af194f..024c537fd 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,15 @@ Pane(%... ...) >>> pane.clear() Pane(%... ...) >>> pane.send_keys("echo 'hello world'", enter=True) ->>> pane.cmd('capture-pane', '-p').stdout # doctest: +SKIP +>>> from libtmux.test.retry import retry_until +>>> retry_until(lambda: 'hello world' in pane.capture_pane()) +True +``` + +`pane.capture_pane()` returns the pane's lines, prompt included, so the +capture above reads like this (the prompt depends on your shell): + +```text ["$ echo 'hello world'", 'hello world', '$'] ``` From ff99a47d30f5df6f83f59637787fcb68f402da1c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:14 -0500 Subject: [PATCH 02/23] docs(style[quickstart]): Fit examples to 80 why: The install commands and the two from_*_id examples ran past 80 columns. what: - Split the pip and uv trunk-install commands with `\`, one option per continuation line, and quote the pip URL - Name the new window and pane ids before passing them to Window.from_window_id and Pane.from_pane_id --- docs/quickstart.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/quickstart.md b/docs/quickstart.md index b8f918849..1e051fe8e 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -77,7 +77,8 @@ via trunk (can break easily): - [pip]\: ```console - $ pip install --user -e git+https://github.com/tmux-python/libtmux.git#egg=libtmux + $ pip install --user -e \ + 'git+https://github.com/tmux-python/libtmux.git#egg=libtmux' ``` - [pipx]\: @@ -92,7 +93,8 @@ via trunk (can break easily): - [uv]\: ```console - $ uv tool install libtmux --from git+https://github.com/tmux-python/libtmux.git + $ uv tool install libtmux \ + --from git+https://github.com/tmux-python/libtmux.git ``` [pip]: https://pip.pypa.io/en/stable/ @@ -190,7 +192,8 @@ and as shown later, you'd use {meth}`Session.new_window() `): ```python ->>> Window.from_window_id(window_id=session.cmd('new-window', '-P', '-F#{window_id}').stdout[0], server=session.server) +>>> new_window_id = session.cmd('new-window', '-P', '-F#{window_id}').stdout[0] +>>> Window.from_window_id(window_id=new_window_id, server=session.server) Window(@2 2:..., Session($1 libtmux_...)) ``` @@ -205,7 +208,8 @@ Raw output directly to a {class}`~libtmux.Pane` (in practice, you'd use {meth}`Window.split() `): ```python ->>> Pane.from_pane_id(pane_id=window.cmd('split-window', '-P', '-F#{pane_id}').stdout[0], server=window.server) +>>> new_pane_id = window.cmd('split-window', '-P', '-F#{pane_id}').stdout[0] +>>> Pane.from_pane_id(pane_id=new_pane_id, server=window.server) Pane(%... Window(@1 1:..., Session($1 libtmux_...))) ``` @@ -429,7 +433,8 @@ Pane(%1 Window(@1 ...:..., Session($1 ...))) ``` ```{eval-rst} -.. todo:: have a ``.kill()`` and ``.select()`` proxy for Server, Session, Window and Pane objects. +.. todo:: have a ``.kill()`` and ``.select()`` proxy for Server, Session, + Window and Pane objects. ``` ## Sending commands to tmux panes remotely From 37f2e4c96ec3599c88fd3c4f144ca26a6857b775 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:14 -0500 Subject: [PATCH 03/23] docs(style[automation_patterns]): Fit examples why: Two new_window calls and the retry_until_success signature ran past 80 columns in the rendered block. what: - Pass the window name to new_window by position in the timeout and cleanup demos - Shorten the retry_until_success parameter `success_marker` to `marker` so its signature fits on a line --- docs/topics/automation_patterns.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md index ba9641108..5af79a088 100644 --- a/docs/topics/automation_patterns.md +++ b/docs/topics/automation_patterns.md @@ -361,7 +361,7 @@ catch instead of a script that quietly stalls. ```python >>> import time ->>> timeout_window = session.new_window(window_name='timeout-demo', attach=False) +>>> timeout_window = session.new_window('timeout-demo', attach=False) >>> timeout_pane = timeout_window.active_pane >>> class CommandTimeout(Exception): @@ -401,13 +401,13 @@ up for. Tune both for how expensive the command is and how patient you can be. >>> retry_window = session.new_window(window_name='retry-demo', attach=False) >>> retry_pane = retry_window.active_pane ->>> def retry_until_success(pane, command, success_marker, max_retries=3, delay=0.5): +>>> def retry_until_success(pane, command, marker, max_retries=3, delay=0.5): ... """Retry command until success marker appears.""" ... for attempt in range(max_retries): ... pane.send_keys(command) ... time.sleep(delay) ... output = '\\n'.join(pane.capture_pane()) -... if success_marker in output: +... if marker in output: ... return True, attempt + 1 ... return False, max_retries @@ -550,7 +550,7 @@ it. Tear down what you opened when you're done, so a long-running automation pro doesn't accumulate orphaned objects. ```python ->>> cleanup_window = session.new_window(window_name='cleanup-demo', attach=False) +>>> cleanup_window = session.new_window('cleanup-demo', attach=False) >>> cleanup_window # doctest: +ELLIPSIS Window(@... ...) From 9f478820b1906d35fe1a5a432fb973329ea1cb6d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:14 -0500 Subject: [PATCH 04/23] docs(style[context_managers]): Shorten comment why: The comment under the innermost `with` ran past 80 columns. It said "Everything is cleaned up automatically when exiting contexts", which the prose below the block already states. what: - Reword to "Exiting the contexts cleans everything up" --- docs/topics/context_managers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/topics/context_managers.md b/docs/topics/context_managers.md index d42b16b8b..e94db49d1 100644 --- a/docs/topics/context_managers.md +++ b/docs/topics/context_managers.md @@ -105,7 +105,7 @@ once and have every layer torn down for you: ... with window.split() as pane: ... pane.send_keys('echo "Hello"') ... # Do work with the pane -... # Everything is cleaned up automatically when exiting contexts +... # Exiting the contexts cleans everything up ``` This ensures that: From 2621673ea473cce7714378f1dd3bd04fb658a006 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:14 -0500 Subject: [PATCH 05/23] docs(style[filtering]): Bind the link target once why: Four link-window setups repeated a 78-column call split across three lines, and several expected reprs listed the full Session(...) tail. The repeated target and the long reprs hid the lookup being taught. what: - Bind `target = f"{guest.session_id}:"` once per example and call link-window on one line - Bind `name` for the exact-lookup equivalence example and drop the doctest directive the global flags already cover - Bind `same` before comparing window ids in the from_window_id example - Bind the istartswith and `in` results and list their window names instead of printing full reprs, which asserts the exact windows and fits on a line --- docs/topics/filtering.md | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/docs/topics/filtering.md b/docs/topics/filtering.md index 02cbbf4b2..db1d98e60 100644 --- a/docs/topics/filtering.md +++ b/docs/topics/filtering.md @@ -38,9 +38,10 @@ When you pass a bare keyword like `session_name=...`, the default lookup is ```python >>> # These are equivalent ->>> server.sessions.filter(session_name=session.session_name) # doctest: +ELLIPSIS +>>> name = session.session_name +>>> server.sessions.filter(session_name=name) [Session($... ...)] ->>> server.sessions.filter(session_name__exact=session.session_name) # doctest: +ELLIPSIS +>>> server.sessions.filter(session_name__exact=name) [Session($... ...)] ``` @@ -153,9 +154,8 @@ edge: >>> home = server.new_session(session_name="home") >>> shared = home.new_window(window_name="shared", attach=False) >>> guest = server.new_session(session_name="guest") ->>> _ = server.cmd( -... "link-window", "-d", "-s", shared.window_id, "-t", f"{guest.session_id}:" -... ) +>>> target = f"{guest.session_id}:" +>>> _ = server.cmd("link-window", "-d", "-s", shared.window_id, "-t", target) >>> len(server.windows.filter(window_id=shared.window_id)) 2 @@ -173,9 +173,8 @@ ambiguous, and says so rather than guessing: >>> home = server.new_session(session_name="home") >>> shared = home.new_window(window_name="shared", attach=False) >>> guest = server.new_session(session_name="guest") ->>> _ = server.cmd( -... "link-window", "-d", "-s", shared.window_id, "-t", f"{guest.session_id}:" -... ) +>>> target = f"{guest.session_id}:" +>>> _ = server.cmd("link-window", "-d", "-s", shared.window_id, "-t", target) >>> try: ... server.windows.get(window_id=shared.window_id) @@ -193,9 +192,8 @@ listing each holding session once however many indexes it links the window at: >>> home = server.new_session(session_name="home") >>> shared = home.new_window(window_name="shared", attach=False) >>> guest = server.new_session(session_name="guest") ->>> _ = server.cmd( -... "link-window", "-d", "-s", shared.window_id, "-t", f"{guest.session_id}:" -... ) +>>> target = f"{guest.session_id}:" +>>> _ = server.cmd("link-window", "-d", "-s", shared.window_id, "-t", target) >>> sorted(s.session_name for s in shared.linked_sessions) ['guest', 'home'] @@ -215,11 +213,11 @@ so they are the right tool for a lookup by id: >>> home = server.new_session(session_name="home") >>> shared = home.new_window(window_name="shared", attach=False) >>> guest = server.new_session(session_name="guest") ->>> _ = server.cmd( -... "link-window", "-d", "-s", shared.window_id, "-t", f"{guest.session_id}:" -... ) +>>> target = f"{guest.session_id}:" +>>> _ = server.cmd("link-window", "-d", "-s", shared.window_id, "-t", target) ->>> Window.from_window_id(server, shared.window_id).window_id == shared.window_id +>>> same = Window.from_window_id(server, shared.window_id) +>>> same.window_id == shared.window_id True ``` @@ -275,8 +273,9 @@ Reach for the `i`-prefixed variants when the casing of a name shouldn't matter: True >>> # Case-insensitive startswith ->>> session.windows.filter(window_name__istartswith='myapp') # doctest: +ELLIPSIS -[Window(@... ...:MyApp-Server, Session($... ...)), Window(@... ...:myapp-worker, Session($... ...))] +>>> found = session.windows.filter(window_name__istartswith='myapp') +>>> [window.window_name for window in found] +['MyApp-Server', 'myapp-worker'] >>> # Clean up >>> w1.kill() @@ -322,8 +321,9 @@ When you already have a set of names in hand, `in` keeps the matches and `nin` >>> # Filter windows in a list of names >>> target_envs = ["dev", "prod"] ->>> session.windows.filter(window_name__in=target_envs) # doctest: +ELLIPSIS -[Window(@... ...:dev, Session($... ...)), Window(@... ...:prod, Session($... ...))] +>>> found = session.windows.filter(window_name__in=target_envs) +>>> [window.window_name for window in found] +['dev', 'prod'] >>> # Filter windows NOT in a list >>> non_prod = session.windows.filter(window_name__nin=["prod"]) From c7ac282b832c005f444120ab0d55e30a6f3252f2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:14 -0500 Subject: [PATCH 06/23] docs(style[options_and_hooks]): Fit hook examples why: The set_hook calls carried a trailing `# doctest: +ELLIPSIS` that pushed them past 80 columns, though Session(...) already matches under the global ELLIPSIS flag. what: - Name hook_command before the session-renamed set_hook call - Drop the per-line ELLIPSIS directives, which lets the two after-split-window set_hook calls fit on one line --- docs/topics/options_and_hooks.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/topics/options_and_hooks.md b/docs/topics/options_and_hooks.md index b74f5cc8c..0e16c2a7d 100644 --- a/docs/topics/options_and_hooks.md +++ b/docs/topics/options_and_hooks.md @@ -104,7 +104,8 @@ registered with tmux the instant `set_hook` returns — there's no refresh step before it starts firing: ```python ->>> session.set_hook('session-renamed', 'display-message "Session renamed"') # doctest: +ELLIPSIS +>>> hook_command = 'display-message "Session renamed"' +>>> session.set_hook('session-renamed', hook_command) Session(...) >>> session.show_hook('session-renamed') # doctest: +ELLIPSIS @@ -136,10 +137,10 @@ each hook (`session-renamed[0]`, `session-renamed[1]`, …), so you register several commands against the same event and they all run: ```python ->>> session.set_hook('after-split-window[0]', 'display-message "Split 0"') # doctest: +ELLIPSIS +>>> session.set_hook('after-split-window[0]', 'display-message "Split 0"') Session(...) ->>> session.set_hook('after-split-window[1]', 'display-message "Split 1"') # doctest: +ELLIPSIS +>>> session.set_hook('after-split-window[1]', 'display-message "Split 1"') Session(...) >>> hooks = session.show_hook('after-split-window') From fce42564786860b561b46bc8e7b435f37a9ab312 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:14 -0500 Subject: [PATCH 07/23] docs(style[pane_interaction]): Fit resize examples why: The two Pane.resize calls were 102 and 105 columns wide with a trailing directive the global ELLIPSIS flag makes redundant. what: - Bind the Up and Left directions before the Pane.resize calls - Drop the per-line ELLIPSIS directives --- docs/topics/pane_interaction.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/topics/pane_interaction.md b/docs/topics/pane_interaction.md index 6bde824ec..0a340cdbc 100644 --- a/docs/topics/pane_interaction.md +++ b/docs/topics/pane_interaction.md @@ -395,11 +395,13 @@ To grow or shrink a pane relative to its current size, name a direction from >>> from libtmux.constants import ResizeAdjustmentDirection >>> # Increase height by 5 rows ->>> pane.resize(adjustment_direction=ResizeAdjustmentDirection.Up, adjustment=5) # doctest: +ELLIPSIS +>>> up = ResizeAdjustmentDirection.Up +>>> pane.resize(adjustment_direction=up, adjustment=5) Pane(%... Window(@... ..., Session($... ...))) >>> # Decrease width by 10 columns ->>> pane.resize(adjustment_direction=ResizeAdjustmentDirection.Left, adjustment=10) # doctest: +ELLIPSIS +>>> left = ResizeAdjustmentDirection.Left +>>> pane.resize(adjustment_direction=left, adjustment=10) Pane(%... Window(@... ..., Session($... ...))) ``` From 1ca8c52253e358e6d658e875f98c5343fc40c0ce Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:14 -0500 Subject: [PATCH 08/23] docs(style[self_location]): Fit holders example why: The holders set comprehension ran past 80 columns. what: - Bind worker_panes, then build holders from it --- docs/topics/self_location.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/topics/self_location.md b/docs/topics/self_location.md index d06eaeac5..6092a9ef8 100644 --- a/docs/topics/self_location.md +++ b/docs/topics/self_location.md @@ -190,7 +190,8 @@ the session tmux's own `display-message -t` names: ... "link-window", "-s", shared.window_id, "-t", f"{guest.session_id}:" ... ) ->>> holders = {p.session_name for p in server.panes.filter(pane_id=worker.pane_id)} +>>> worker_panes = server.panes.filter(pane_id=worker.pane_id) +>>> holders = {p.session_name for p in worker_panes} >>> holders == {"aaa-home", "zzz-guest"} True From e3c064d3bae2228c0732f3d1aef1a6fe155e50b3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:14 -0500 Subject: [PATCH 09/23] docs(style[traversal]): List matching window names why: The window-name results printed every Session($... ...) tail, which ran the lines to 100 columns and buried the window names the example shows. One comment trailed the new_session call. what: - Bind the startswith, contains, istartswith and regex results and list their window names instead of printing full reprs, which asserts the exact windows and fits on a line - Drop the trailing comment on new_session; the lead-in sentence "First, create a test session" already says it - Drop per-line ELLIPSIS directives where the global flag applies --- docs/topics/traversal.md | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/docs/topics/traversal.md b/docs/topics/traversal.md index 600fa55f3..54700df5c 100644 --- a/docs/topics/traversal.md +++ b/docs/topics/traversal.md @@ -41,7 +41,7 @@ $ python First, create a test session: ```python ->>> session = server.new_session() # Create a test session using existing server +>>> session = server.new_session() ``` ## Server level @@ -191,7 +191,7 @@ return only matching rows on large servers, see {ref}`native-filtering`. Find windows by exact attribute match: ```python ->>> session.windows.filter(window_index=window.window_index) # doctest: +ELLIPSIS +>>> session.windows.filter(window_index=window.window_index) [Window(@... ..., Session($... ...))] ``` @@ -213,12 +213,14 @@ Use lookup suffixes like `__contains`, `__startswith`, `__endswith`: >>> w3 = session.new_window(window_name="logs") >>> # Find windows starting with 'app-' ->>> session.windows.filter(window_name__startswith='app-') # doctest: +ELLIPSIS -[Window(@... ...:app-frontend, Session($... ...)), Window(@... ...:app-backend, Session($... ...))] +>>> found = session.windows.filter(window_name__startswith='app-') +>>> [window.window_name for window in found] +['app-frontend', 'app-backend'] >>> # Find windows containing 'end' ->>> session.windows.filter(window_name__contains='end') # doctest: +ELLIPSIS -[Window(@... ...:app-frontend, Session($... ...)), Window(@... ...:app-backend, Session($... ...))] +>>> found = session.windows.filter(window_name__contains='end') +>>> [window.window_name for window in found] +['app-frontend', 'app-backend'] >>> # Clean up >>> w1.kill() @@ -236,8 +238,9 @@ Prefix any lookup with `i` for case-insensitive matching: >>> w2 = session.new_window(window_name="myapp-worker") >>> # Case-insensitive search ->>> session.windows.filter(window_name__istartswith='myapp') # doctest: +ELLIPSIS -[Window(@... ...:MyApp, Session($... ...)), Window(@... ...:myapp-worker, Session($... ...))] +>>> found = session.windows.filter(window_name__istartswith='myapp') +>>> [window.window_name for window in found] +['MyApp', 'myapp-worker'] >>> # Clean up >>> w1.kill() @@ -255,8 +258,9 @@ For complex patterns, use `__regex` or `__iregex`: >>> w3 = session.new_window(window_name="dev") >>> # Match version pattern ->>> session.windows.filter(window_name__regex=r'v\d+-\d+') # doctest: +ELLIPSIS -[Window(@... ...:release-v1-0, Session($... ...)), Window(@... ...:release-v2-0, Session($... ...))] +>>> found = session.windows.filter(window_name__regex=r'v\d+-\d+') +>>> [window.window_name for window in found] +['release-v1-0', 'release-v2-0'] >>> # Clean up >>> w1.kill() From 8f906ce6abab124cb370e719a98e063b835f2df3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:14 -0500 Subject: [PATCH 10/23] docs(style[workspace_setup]): Fit examples to 80 why: The v-split new_window call, the pane-grid split and the create_pane_grid call each passed 80 columns. what: - Pass the window name to new_window and the arguments to create_pane_grid by position - Bind `source` for the grid split so the split call fits on a line --- docs/topics/workspace_setup.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/topics/workspace_setup.md b/docs/topics/workspace_setup.md index cb4c7fe52..d6abe5e2e 100644 --- a/docs/topics/workspace_setup.md +++ b/docs/topics/workspace_setup.md @@ -111,7 +111,7 @@ you split: >>> from libtmux.constants import PaneDirection >>> # Create a window with enough space ->>> v_split_window = session.new_window(window_name='v-split-demo', attach=False) +>>> v_split_window = session.new_window('v-split-demo', attach=False) >>> v_split_window.resize(height=40, width=120) # doctest: +ELLIPSIS Window(@... ...) @@ -348,14 +348,15 @@ Split a row across, repeat down the rows, then let the `tiled` ... row_start = panes[-cols] ... current = row_start ... for col in range(cols): -... new_pane = panes[-cols + col].split(direction=PaneDirection.Below) +... source = panes[-cols + col] +... new_pane = source.split(direction=PaneDirection.Below) ... panes.append(new_pane) ... ... # Apply tiled layout for even distribution ... window.select_layout('tiled') ... return window, panes ->>> grid_window, grid_panes = create_pane_grid(session, rows=2, cols=2, name='test-grid') +>>> grid_window, grid_panes = create_pane_grid(session, 2, 2, 'test-grid') >>> len(grid_panes) >= 4 True From 561338f2843755573563e4a35a15a7e60c471c6a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:14 -0500 Subject: [PATCH 11/23] docs(style[query_list]): Name lookup and rows why: parse_lookup, the QueryList example data and QueryList.get had doctest lines over 80 columns. what: - Bind `obj` for the parse_lookup call and break the dict argument of the QueryList example - Expand the two nested `foods` dicts in the QueryList data, one key per line - Bind `rows` before building the QueryList in the get example --- src/libtmux/_internal/query_list.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/libtmux/_internal/query_list.py b/src/libtmux/_internal/query_list.py index 20aeb407f..0e8d127ce 100644 --- a/src/libtmux/_internal/query_list.py +++ b/src/libtmux/_internal/query_list.py @@ -121,7 +121,8 @@ def parse_lookup( If comparator not used or value not found, return None. - >>> parse_lookup({ "food": "red apple" }, "food__istartswith", "__istartswith") + >>> obj = {"food": "red apple"} + >>> parse_lookup(obj, "food__istartswith", "__istartswith") 'red apple' It can also look up objects: @@ -337,13 +338,19 @@ class QueryList(list[T], t.Generic[T]): ... "place": "Largo", ... "city": "Tampa", ... "state": "Florida", - ... "foods": {"fruit": ["banana", "orange"], "breakfast": "cereal"}, + ... "foods": { + ... "fruit": ["banana", "orange"], + ... "breakfast": "cereal", + ... }, ... }, ... { ... "place": "Chicago suburbs", ... "city": "Elmhurst", ... "state": "Illinois", - ... "foods": {"fruit": ["apple", "cantelope"], "breakfast": "waffles"}, + ... "foods": { + ... "fruit": ["apple", "cantelope"], + ... "breakfast": "waffles", + ... }, ... }, ... ] ... ) @@ -585,7 +592,8 @@ def get( -------- >>> from libtmux._internal.query_list import QueryList >>> from libtmux import exc - >>> qs = QueryList([{"pane_id": "%0"}, {"pane_id": "%0"}, {"pane_id": "%1"}]) + >>> rows = [{"pane_id": "%0"}, {"pane_id": "%0"}, {"pane_id": "%1"}] + >>> qs = QueryList(rows) >>> qs.get(pane_id="%1") {'pane_id': '%1'} From ff6ec9b7e42a9ef101e607127a0b61292c59dfe1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:14 -0500 Subject: [PATCH 12/23] docs(style[common]): Fit tmux_cmd doctest why: The tmux_cmd new-session example was 85 columns wide. what: - Bind the socket flag as `socket_flag` before the tmux_cmd call --- src/libtmux/common.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 287154770..f492f957a 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -287,7 +287,8 @@ class tmux_cmd: -------- Create a new session, check for error: - >>> proc = tmux_cmd(f'-L{server.socket_name}', 'new-session', '-d', '-P', '-F#S') + >>> socket_flag = f'-L{server.socket_name}' + >>> proc = tmux_cmd(socket_flag, 'new-session', '-d', '-P', '-F#S') >>> if proc.stderr: ... raise exc.LibTmuxException( ... 'Command: %s returned error: %s' % (proc.cmd, proc.stderr) From f72ba10fd2cb746d6b5b91ef41051787573dcf89 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:14 -0500 Subject: [PATCH 13/23] docs(style[neo]): Bind stderr before each check why: Two doctests in _is_target_not_found_error and _best_winlink ran past 80 columns. what: - Bind `stderr` (and `missing` for the connection error) before each _is_target_not_found_error call - Bind `rows` before calling _best_winlink --- src/libtmux/neo.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 98ece86fa..c633b4da0 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -1191,11 +1191,12 @@ def _is_target_not_found_error(stderr_text: str) -> bool: A server that isn't there is a different answer: - >>> _is_target_not_found_error("no server running on /tmp/tmux-1000/default") + >>> stderr = "no server running on /tmp/tmux-1000/default" + >>> _is_target_not_found_error(stderr) False - >>> _is_target_not_found_error( - ... "error connecting to /tmp/tmux-1000/nope (No such file or directory)" - ... ) + >>> missing = "No such file or directory" + >>> stderr = f"error connecting to /tmp/tmux-1000/nope ({missing})" + >>> _is_target_not_found_error(stderr) False """ return "can't find " in stderr_text @@ -1232,7 +1233,8 @@ def _best_winlink(rows: OutputsRaw) -> OutputRaw: One row is the whole answer: >>> from libtmux.neo import _best_winlink - >>> _best_winlink([{"window_id": "@0", "window_index": "1"}])["window_index"] + >>> rows = [{"window_id": "@0", "window_index": "1"}] + >>> _best_winlink(rows)["window_index"] '1' A window linked into one session twice gives two rows. When the session is From 606456ffd74a95f05a05ea55c98432731365a185 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:14 -0500 Subject: [PATCH 14/23] docs(style[pane]): Bind the above direction why: The Pane.split full-window example was 88 columns wide, and the 83-column capture_pane example carried a `+NORMALIZE_WHITESPACE` directive the global doctest flags already provide. what: - Bind `above` for PaneDirection.Above so the top_pane split fits on a line - Drop the redundant directive from the print(capture_pane()) example --- src/libtmux/pane.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index e0c2f5961..2ff65e45d 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -782,7 +782,7 @@ def send_keys( >>> pane.capture_pane() ['$ echo "Hello world"', 'Hello world', '$'] - >>> print('\n'.join(pane.capture_pane())) # doctest: +NORMALIZE_WHITESPACE + >>> print('\n'.join(pane.capture_pane())) $ echo "Hello world" Hello world $ @@ -1310,7 +1310,8 @@ def split( >>> pane = session.new_window().active_pane - >>> top_pane = pane.split(direction=PaneDirection.Above, full_window_split=True) + >>> above = PaneDirection.Above + >>> top_pane = pane.split(direction=above, full_window_split=True) >>> (top_pane.at_left, top_pane.at_right, ... top_pane.at_top, top_pane.at_bottom) From 2c6180a8bdcd10b4d5df4f39f7f2aaae06c25f6f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:14 -0500 Subject: [PATCH 15/23] docs(style[pytest_plugin]): Fit fixture doctests why: The fixture docstrings re-read the doctest examples with a 88-column line in three places, and the server example shadowed `session` inside a comprehension. what: - Bind `examples` from the pytest item before slicing it into source in the server, session_params and session fixtures - Rename the comprehension variable to `s` in the server fixture's sample test so it no longer shadows `session` --- src/libtmux/pytest_plugin.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/libtmux/pytest_plugin.py b/src/libtmux/pytest_plugin.py index fcc3ce052..c304f2b42 100644 --- a/src/libtmux/pytest_plugin.py +++ b/src/libtmux/pytest_plugin.py @@ -155,13 +155,14 @@ def server( ... assert isinstance(server, Server) ... session = server.new_session('my session') ... assert len(server.sessions) == 1 - ... assert [session.name.startswith('my') for session in server.sessions] + ... assert [s.name.startswith('my') for s in server.sessions] .. :: >>> locals().keys() dict_keys(...) - >>> source = ''.join([e.source for e in request._pyfuncitem.dtest.examples][:3]) + >>> examples = request._pyfuncitem.dtest.examples + >>> source = ''.join([e.source for e in examples][:3]) >>> pytester = request.getfixturevalue('pytester') >>> pytester.makepyfile(**{'whatever.py': source}) @@ -206,7 +207,8 @@ def session_params() -> dict[str, t.Any]: >>> locals().keys() dict_keys(...) - >>> source = ''.join([e.source for e in request._pyfuncitem.dtest.examples][:4]) + >>> examples = request._pyfuncitem.dtest.examples + >>> source = ''.join([e.source for e in examples][:4]) >>> pytester = request.getfixturevalue('pytester') >>> pytester.makepyfile(**{'whatever.py': source}) @@ -240,7 +242,8 @@ def session( >>> locals().keys() dict_keys(...) - >>> source = ''.join([e.source for e in request._pyfuncitem.dtest.examples][:3]) + >>> examples = request._pyfuncitem.dtest.examples + >>> source = ''.join([e.source for e in examples][:3]) >>> pytester = request.getfixturevalue('pytester') >>> pytester.makepyfile(**{'whatever.py': source}) From 905b67d759d0300a6f452c9fccca961aa3f4c47c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:14 -0500 Subject: [PATCH 16/23] docs(style[server]): Bind the new id first why: The from_window_id and from_pane_id examples, the control-mode send-keys examples and the search_sessions example ran past 80 columns, two of them by splitting a call mid-argument. what: - Bind `new_id` and pass it to Window.from_window_id and Pane.from_pane_id - In the control-mode examples bind `client`, then `show`, so the send-keys and show-options calls fit - Bind `pattern` for the search_sessions filter --- src/libtmux/server.py | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index e650557c3..4d6415597 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -360,8 +360,8 @@ def cmd( Output of `tmux -L ... new-window -P -F#{window_id}` to a `Window` object: - >>> Window.from_window_id(window_id=session.cmd( - ... 'new-window', '-P', '-F#{window_id}').stdout[0], server=session.server) + >>> new_id = session.cmd('new-window', '-P', '-F#{window_id}').stdout[0] + >>> Window.from_window_id(window_id=new_id, server=session.server) Window(@4 3:..., Session($1 libtmux_...)) Create a pane from a window: @@ -371,8 +371,8 @@ def cmd( Output of `tmux -L ... split-window -P -F#{pane_id}` to a `Pane` object: - >>> Pane.from_pane_id(pane_id=window.cmd( - ... 'split-window', '-P', '-F#{pane_id}').stdout[0], server=window.server) + >>> new_id = window.cmd('split-window', '-P', '-F#{pane_id}').stdout[0] + >>> Pane.from_pane_id(pane_id=new_id, server=window.server) Pane(%... Window(@... ...:..., Session($1 libtmux_...))) Parameters @@ -1145,8 +1145,10 @@ def confirm_before( ... 'set -g @cf_test yes', ... target_client=ctl.client_name, ... ) - ... _ = server.cmd('send-keys', '-K', '-c', ctl.client_name, 'y') - ... result = server.cmd('show-options', '-gv', '@cf_test').stdout[0] + ... client = ctl.client_name + ... _ = server.cmd('send-keys', '-K', '-c', client, 'y') + ... show = server.cmd('show-options', '-gv', '@cf_test') + ... result = show.stdout[0] ... else: ... result = 'yes' >>> result @@ -1256,13 +1258,15 @@ def command_prompt( >>> from libtmux.common import has_gte_version >>> if has_gte_version("3.4"): ... with control_mode() as ctl: + ... client = ctl.client_name ... server.command_prompt( ... "set -g @cp_test '%1'", - ... target_client=ctl.client_name, + ... target_client=client, ... ) ... for key in ['h', 'i', 'Enter']: - ... _ = server.cmd('send-keys', '-K', '-c', ctl.client_name, key) - ... result = server.cmd('show-options', '-gv', '@cp_test').stdout[0] + ... _ = server.cmd('send-keys', '-K', '-c', client, key) + ... show = server.cmd('show-options', '-gv', '@cp_test') + ... result = show.stdout[0] ... else: ... result = 'hi' >>> result @@ -2538,7 +2542,8 @@ def search_sessions( Session($... gap7_alpha) >>> server.new_session(session_name='other_beta') Session($... other_beta) - >>> matches = server.search_sessions(filter='#{m:gap7_*,#{session_name}}') + >>> pattern = '#{m:gap7_*,#{session_name}}' + >>> matches = server.search_sessions(filter=pattern) >>> [s.session_name for s in matches] ['gap7_alpha'] """ From d4da485ffa2e7091498648e845c72e2eade073a7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:15 -0500 Subject: [PATCH 17/23] docs(style[session]): Bind the search pattern why: The search_windows filter and the from_window_id example ran past 80 columns. what: - Bind `pattern` for the search_windows filter - Bind `new_id` and pass it to Window.from_window_id --- src/libtmux/session.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 4277052a3..3cbb94a55 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -337,7 +337,8 @@ def search_windows( -------- >>> _ = session.new_window(window_name='gap7s_target') >>> _ = session.new_window(window_name='other_window') - >>> matches = session.search_windows(filter='#{m:gap7s_*,#{window_name}}') + >>> pattern = '#{m:gap7s_*,#{window_name}}' + >>> matches = session.search_windows(filter=pattern) >>> [w.window_name for w in matches] ['gap7s_target'] """ @@ -431,8 +432,8 @@ def cmd( From raw output to an enriched `Window` object: - >>> Window.from_window_id(window_id=session.cmd( - ... 'new-window', '-P', '-F#{window_id}').stdout[0], server=session.server) + >>> new_id = session.cmd('new-window', '-P', '-F#{window_id}').stdout[0] + >>> Window.from_window_id(window_id=new_id, server=session.server) Window(@... ...:..., Session($1 libtmux_...)) Parameters From 3e2dcfaaa3dbfb0fb7f20ff3066ab9039fa6a33e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:15 -0500 Subject: [PATCH 18/23] docs(style[window]): Pass the id by position why: Five doctests in Window ran past 80 columns. what: - Pass the server and window id to Window.from_window_id by position - Bind `source` for the link-window call - Bind `names` for the linked_sessions comparison - Bind `new_id` and pass it to Pane.from_pane_id - Bind `windows` before filtering for the active window --- src/libtmux/window.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index b57db9969..55a32a745 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -226,7 +226,7 @@ def from_window_id(cls, server: Server, window_id: str) -> Window: Examples -------- - >>> Window.from_window_id(server=window.server, window_id=window.window_id) + >>> Window.from_window_id(window.server, window.window_id) Window(@1 1:..., Session($1 ...)) """ window = fetch_obj( @@ -331,14 +331,16 @@ def linked_sessions(self) -> QueryList[Session]: A window you just made belongs to the session you made it in: >>> window = session.new_window(window_name="solo", attach=False) - >>> [s.session_name for s in window.linked_sessions] == [session.session_name] + >>> names = [s.session_name for s in window.linked_sessions] + >>> names == [session.session_name] True Link it into a second session and it belongs to both: >>> guest = server.new_session(session_name="guest") >>> target = f"{guest.session_id}:" - >>> _ = server.cmd("link-window", "-d", "-s", window.window_id, "-t", target) + >>> source = window.window_id + >>> _ = server.cmd("link-window", "-d", "-s", source, "-t", target) >>> sorted(s.session_name for s in window.linked_sessions) == sorted( ... [session.session_name, "guest"] ... ) @@ -478,8 +480,8 @@ def cmd( Magic, directly to a `Pane`: - >>> Pane.from_pane_id(pane_id=session.cmd( - ... 'split-window', '-P', '-F#{pane_id}').stdout[0], server=session.server) + >>> new_id = session.cmd('split-window', '-P', '-F#{pane_id}').stdout[0] + >>> Pane.from_pane_id(pane_id=new_id, server=session.server) Pane(%... Window(@... ...:..., Session($1 libtmux_...))) Parameters @@ -1613,7 +1615,8 @@ def select(self) -> Window: >>> window = session.active_window >>> new_window = session.new_window() >>> session.refresh() - >>> active_windows = [w for w in session.windows if w.window_active == '1'] + >>> windows = session.windows + >>> active_windows = [w for w in windows if w.window_active == '1'] >>> new_window.window_active == '1' False From 9f6fd7ff2f8128b3ed22735fb711bcd34527a43f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:15 -0500 Subject: [PATCH 19/23] docs(style[options]): Rebuild status doctest why: The explode_complex doctests carried a status-format literal of over 1,500 characters, written out twice (input and expected value), plus terminal-features expectations broken mid-dict. A reader could not see what the example demonstrates: an array option parsed into indexed entries. what: - Rebuild the status-format example from two short named fragments, `left` and `centre`, joined into option text with an f-string and compared against the same fragments, so the expected value cannot drift from the input. It keeps indexes 0 and 1 and the #[...] and #{...} syntax, but no longer round-trips tmux's full default status-format string - Break the terminal-features expected dict one key per line, and bind `features` and `sparse` for the two array inputs; the sparse result is still compared with a SparseArray literal - Bind `raw` once for the two _show_option_raw assertions --- src/libtmux/options.py | 50 ++++++++++++++++++++++++++++-------------- 1 file changed, 34 insertions(+), 16 deletions(-) diff --git a/src/libtmux/options.py b/src/libtmux/options.py index ff8bb2239..e43e8cb51 100644 --- a/src/libtmux/options.py +++ b/src/libtmux/options.py @@ -455,8 +455,9 @@ def explode_complex( ... terminal-features[0] xterm*:clipboard:ccolour:cstyle:focus ... terminal-features[5] screen*:title ... ''')))) == { - ... "terminal-features": {"xterm*": ["clipboard", "ccolour", "cstyle", "focus"], - ... "screen*": ["title"]}} + ... "terminal-features": { + ... "xterm*": ["clipboard", "ccolour", "cstyle", "focus"], + ... "screen*": ["title"]}} True >>> explode_complex(explode_arrays(parse_options_to_dict(io.StringIO(r''' @@ -469,13 +470,23 @@ def explode_complex( ... "server-info": "show-messages -JT"}} True - >>> explode_complex(explode_arrays({"terminal-features": {0: "xterm*:clipboard:ccolour:cstyle:focus", - ... 1: "screen*:title"}})) + >>> features = { + ... 0: "xterm*:clipboard:ccolour:cstyle:focus", + ... 1: "screen*:title", + ... } + >>> explode_complex(explode_arrays({"terminal-features": features})) {'terminal-features': {0: 'xterm*:clipboard:ccolour:cstyle:focus', 1: 'screen*:title'}} - >>> explode_complex(explode_arrays({"terminal-features": {0: "xterm*:clipboard:ccolour:cstyle:focus", - ... 8: "screen*:title"}})) == SparseArray({'terminal-features': {0: - ... 'xterm*:clipboard:ccolour:cstyle:focus', 8: 'screen*:title'}}) + >>> sparse = { + ... 0: "xterm*:clipboard:ccolour:cstyle:focus", + ... 8: "screen*:title", + ... } + >>> explode_complex(explode_arrays({"terminal-features": sparse})) == ( + ... SparseArray({"terminal-features": { + ... 0: "xterm*:clipboard:ccolour:cstyle:focus", + ... 8: "screen*:title", + ... }}) + ... ) True >>> explode_complex(explode_arrays(parse_options_to_dict(io.StringIO(r''' @@ -496,13 +507,19 @@ def explode_complex( ... 100: "\\e[test"}} True - >>> explode_complex(explode_arrays(parse_options_to_dict(io.StringIO(r''' - ... status-format[0] "#[align=left range=left #{E:status-left-style}]#[push-default]#{T;=/#{status-left-length}:status-left}#[pop-default]#[norange default]#[list=on align=#{status-justify}]#[list=left-marker]<#[list=right-marker]>#[list=on]#{W:#[range=window|#{window_index} #{E:window-status-style}#{?#{&&:#{window_last_flag},#{!=:#{E:window-status-last-style},default}}, #{E:window-status-last-style},}#{?#{&&:#{window_bell_flag},#{!=:#{E:window-status-bell-style},default}}, #{E:window-status-bell-style},#{?#{&&:#{||:#{window_activity_flag},#{window_silence_flag}},#{!=:#{E:window-status-activity-style},default}}, #{E:window-status-activity-style},}}]#[push-default]#{T:window-status-format}#[pop-default]#[norange default]#{?window_end_flag,,#{window-status-separator}},#[range=window|#{window_index} list=focus #{?#{!=:#{E:window-status-current-style},default},#{E:window-status-current-style},#{E:window-status-style}}#{?#{&&:#{window_last_flag},#{!=:#{E:window-status-last-style},default}}, #{E:window-status-last-style},}#{?#{&&:#{window_bell_flag},#{!=:#{E:window-status-bell-style},default}}, #{E:window-status-bell-style},#{?#{&&:#{||:#{window_activity_flag},#{window_silence_flag}},#{!=:#{E:window-status-activity-style},default}}, #{E:window-status-activity-style},}}]#[push-default]#{T:window-status-current-format}#[pop-default]#[norange list=on default]#{?window_end_flag,,#{window-status-separator}}}#[nolist align=right range=right #{E:status-right-style}]#[push-default]#{T;=/#{status-right-length}:status-right}#[pop-default]#[norange default]" - ... status-format[1] "#[align=centre]#{P:#{?pane_active,#[reverse],}#{pane_index}[#{pane_width}x#{pane_height}]#[default] }" - ... ''')))) == { - ... "status-format": {0: "#[align=left range=left #{E:status-left-style}]#[push-default]#{T;=/#{status-left-length}:status-left}#[pop-default]#[norange default]#[list=on align=#{status-justify}]#[list=left-marker]<#[list=right-marker]>#[list=on]#{W:#[range=window|#{window_index} #{E:window-status-style}#{?#{&&:#{window_last_flag},#{!=:#{E:window-status-last-style},default}}, #{E:window-status-last-style},}#{?#{&&:#{window_bell_flag},#{!=:#{E:window-status-bell-style},default}}, #{E:window-status-bell-style},#{?#{&&:#{||:#{window_activity_flag},#{window_silence_flag}},#{!=:#{E:window-status-activity-style},default}}, #{E:window-status-activity-style},}}]#[push-default]#{T:window-status-format}#[pop-default]#[norange default]#{?window_end_flag,,#{window-status-separator}},#[range=window|#{window_index} list=focus #{?#{!=:#{E:window-status-current-style},default},#{E:window-status-current-style},#{E:window-status-style}}#{?#{&&:#{window_last_flag},#{!=:#{E:window-status-last-style},default}}, #{E:window-status-last-style},}#{?#{&&:#{window_bell_flag},#{!=:#{E:window-status-bell-style},default}}, #{E:window-status-bell-style},#{?#{&&:#{||:#{window_activity_flag},#{window_silence_flag}},#{!=:#{E:window-status-activity-style},default}}, #{E:window-status-activity-style},}}]#[push-default]#{T:window-status-current-format}#[pop-default]#[norange list=on default]#{?window_end_flag,,#{window-status-separator}}}#[nolist align=right range=right #{E:status-right-style}]#[push-default]#{T;=/#{status-right-length}:status-right}#[pop-default]#[norange default]", - ... 1: "#[align=centre]#{P:#{?pane_active,#[reverse],}#{pane_index}[#{pane_width}x#{pane_height}]#[default] }", - ... }} + >>> left = ( + ... "#[align=left range=left #{E:status-left-style}]" + ... "#[push-default]#{T;=/#{status-left-length}:status-left}" + ... "#[pop-default]#[norange default]" + ... ) + >>> centre = ( + ... "#[align=centre]#{P:#{?pane_active,#[reverse],}" + ... "#{pane_index}[#{pane_width}x#{pane_height}]#[default] }" + ... ) + >>> text = f'status-format[0] "{left}"\nstatus-format[1] "{centre}"\n' + >>> explode_complex(explode_arrays(parse_options_to_dict( + ... io.StringIO(text) + ... ))) == {"status-format": {0: left, 1: centre}} True """ options: dict[str, t.Any] = {} @@ -1092,10 +1109,11 @@ def _show_option_raw( >>> MyServer()._show_option_raw('exit-unattached', global_=True).stdout ['exit-unattached off'] - >>> isinstance(MyServer()._show_option_raw('exit-unattached', global_=True).stdout, list) + >>> raw = MyServer()._show_option_raw('exit-unattached', global_=True) + >>> isinstance(raw.stdout, list) True - >>> isinstance(MyServer()._show_option_raw('exit-unattached', global_=True).stdout[0], str) + >>> isinstance(raw.stdout[0], str) True """ if scope is DEFAULT_OPTION_SCOPE: From 2984b441115d41a619fd771df7a038073227729e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 09:41:26 -0500 Subject: [PATCH 20/23] docs(style[MIGRATION]): Shorten the Live comment why: Three fenced Python examples ran 81 to 90 columns. A trailing comment, a three-name import, and a call with a long keyword argument caused it. what: - Shorten the "Live" comment in the client snapshot example - Parenthesize the libtmux.test.random import, one name per line - Break the resize_pane call after its opening parenthesis --- MIGRATION | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/MIGRATION b/MIGRATION index cbcf307f2..6e9196666 100644 --- a/MIGRATION +++ b/MIGRATION @@ -248,7 +248,7 @@ For typed access that reflects the client's *live* attachment, use client = server.clients.get(client_name=ctl.client_name) session_id = client.session_id # str captured when the object was read -# Live (re-reads list-clients; returns None if tmux no longer reports the client) +# Live (re-reads list-clients; None if tmux no longer reports the client) attached = client.attached_session # libtmux.Session | None window = client.attached_window # libtmux.Window | None pane = client.attached_pane # libtmux.Pane | None @@ -449,7 +449,11 @@ from libtmux.test import ( # New (0.45.0+) from libtmux.test.constants import TEST_SESSION_PREFIX from libtmux.test.environment import EnvironmentVarGuard -from libtmux.test.random import get_test_session_name, get_test_window_name, namer +from libtmux.test.random import ( + get_test_session_name, + get_test_window_name, + namer, +) from libtmux.test.temporary import temp_session, temp_window ``` @@ -526,7 +530,9 @@ from libtmux.test.temporary import temp_session, temp_window ```python from libtmux.constants import ResizeAdjustmentDirection - pane.resize_pane(adjustment_direction=ResizeAdjustmentDirection.Down, adjustment=25) + pane.resize_pane( + adjustment_direction=ResizeAdjustmentDirection.Down, adjustment=25 + ) pane.resize_pane( adjustment_direction=ResizeAdjustmentDirection.Right, adjustment=25 ) From 63733fb9f15bea2464693ca82eb76588d80a9a39 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:15 -0500 Subject: [PATCH 21/23] docs(feat[WRITING]): Add example style rules why: Example width, placement of comments and what may not be wrapped were unwritten, so each page drifted its own way. what: - Add the shared example rules to "Documented examples that run" - Add "In this repository" in the shared four-bullet shape: the hard limit and the width check command, the surfaces no formatter touches, the markers that decide what runs or is exempt, and the output and copied blocks that never break - Add a Bad and Good pair from a doctest line the example commits fitted --- .github/WRITING.md | 86 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/.github/WRITING.md b/.github/WRITING.md index d1a252ee4..f7747cb06 100644 --- a/.github/WRITING.md +++ b/.github/WRITING.md @@ -252,6 +252,92 @@ available for a case that ever needs an explicitly marked block, rather than a plain prompted fence. This section is where any additional executed-block format is documented when one is adopted. + + +An example is code written for a reader: a program under `examples/`, code in +a doc comment or docstring, and every fenced block in a README or docs page. +Shell blocks also follow [Code blocks](#code-blocks). + +The text between the shared markers is the same in every libtmux port. +Change it in all of them together. + +### Width + +- **Examples stay within 80 columns.** They render in fixed-width boxes that + scroll sideways, and 80 columns fits a libtmux.org code block in a + laptop-width window. Comments inside examples wrap at 80 too. +- **The width check enforces it.** It reads the tracked files that + `.github/example-width.toml` names and fails on a wider line. It measures + the whole source line, so code in a doc comment counts its indent and + comment marker. It skips output (a fence tagged `text`, and what a + `console` block prints), hidden setup lines, and a line that is only a URL; + an untagged fence counts as code. +- **A line that must stay wider is listed there with its reason.** An entry + that no longer matches a line fails the check, so no stale entry stays. +- **The formatter's width is the hard limit for all other source.** Example + directories set their formatter to 80 where the formatter takes a width. + +### Reaching 80 + +- **Change the code, not the line breaks.** A formatter rejoins any line that + fits its width. Name a sub-expression, use a short example name, hide setup + the reader does not need, or print less. +- **Break at the outermost level when a break is still needed:** after an + opening parenthesis with one argument per line, one call per line in a + chain, one field per line in a literal. +- **Put a comment on its own line above the code it explains.** Never trail + one after code in an example, unless the repository's example runner reads + it there, as with an assertion marker. +- **Break a long string at a word boundary,** never inside a tmux format + (`#{...}`) or an escape sequence; the joined text stays the same. +- **Continue a long command in a `console` block the way its shell does:** + `\` after a `$ ` prompt, a backtick after `PS> `, one flag per continuation + line. + +### What never breaks + +- **Output a test compares.** Wrapping it changes what the test expects. +- **A block copied from a source file.** Fix the width in the source, then + refresh the copy with the sync command the port block names, or by hand + where it names none; never fix the copy alone. +- **Marker lines and URLs,** which tools and readers take whole. + + + +### In this repository + +- **Hard limit:** `ruff format` and ruff's E501 (on through + `extend-select`) at ruff's default 88, because `[tool.ruff]` in + `pyproject.toml` sets no `line-length`; both read `.py` files only. The + width check, `python3 scripts/check_example_width.py` + (`just check-example-width`), holds examples to 80. +- **Not formatted:** doctests in docstrings, fenced code in Markdown pages and + `docs/*.md` doctests. `ruff format` leaves docstring code alone and E501 stops + at 88, so fit these lines to 80 by hand, as Reaching 80 says. + `just format-markdown` runs Prettier, which leaves Python fences alone and, + with `proseWrap` unset, does not wrap prose. +- **Runs, compiles, exempt:** the doctest rules above decide what runs. + An `[[allow]]` entry in `.github/example-width.toml` exempts a line from + the width check, which also skips a line `# doctest: +HIDE` hides from + the rendered page. +- **Compared output and copied blocks:** expected output is never + wrapped; `ELLIPSIS` is global, so trim a long repr with `...`. No + example is copied from another file here, so there is no sync command; + edit the docstring or page itself. + +Bad, over 80: + +```python +>>> Window.from_window_id(window_id=session.cmd('new-window', '-P', '-F#{window_id}').stdout[0], server=session.server) +``` + +Good, naming the sub-expression: + +```python +>>> new_window_id = session.cmd('new-window', '-P', '-F#{window_id}').stdout[0] +>>> Window.from_window_id(window_id=new_window_id, server=session.server) +``` + ## Documentation pages Warm the framing, never the facts. Resolution-order lists, value From 8378a2e4b40b85fe4bf3599ab6e1e0e9a62e13b6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:15 -0500 Subject: [PATCH 22/23] ai(rules[AGENTS]): Route code examples to WRITING why: An agent editing a doctest or Markdown code block was not sent to the example rules. what: - Add code examples to the WRITING.md routing line - Link the "Documented examples that run" section --- AGENTS.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 79c289585..267cdf073 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,9 +28,12 @@ what was asked for. ## Which policy applies -- Documentation, user-facing text, `CHANGES`, release notes, commit - messages, docstrings, and source comments: - [.github/WRITING.md](.github/WRITING.md) +- Documentation, user-facing text, `CHANGES`, release notes, commit messages, + docstrings, source comments, and any code example (example programs, doctests, + Markdown code blocks): [.github/WRITING.md](.github/WRITING.md), and its + [Documented examples that + run](.github/WRITING.md#documented-examples-that-run) section for code a + reader sees - Environment, the gates, tests, documentation builds, releases, and pull requests: [.github/CONTRIBUTING.md](.github/CONTRIBUTING.md) From 887481f85d5344fe28acf094adb0769afd047c6e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 4 Oct 2026 08:44:15 -0500 Subject: [PATCH 23/23] ci(feat[example-width]): Check example width why: Examples render in fixed-width boxes that scroll sideways, and nothing enforced a limit. Doctest lines and fenced blocks accumulated past 80 columns. A libtmux.org code block holds 78 columns at 1024 px (Firefox, WebKit, CI's Chrome) and 99 at 1440 px (Firefox, WebKit); libtmux/docs#101 sizes the code font so 80 columns fit from 1024 px up. ruff cannot enforce this. ruff 0.16.9 formats docstring code only when `docstring-code-format` is on, and the option was rejected after a probe: it parsed a `::` literal block in src/libtmux/options.py as Python and rewrote the tmux option text `visual-activity* on` to `visual - activity * on`. The formatter cannot tell a literal block from code, so it corrupts documented output. Markdown fences are outside ruff's Python formatting entirely. https://github.com/astral-sh/ruff/blob/0.16.9/docs/formatter.md#docstring-formatting Alternatives considered: - ruff E501: already on (`extend-select` includes `"E"`), but it measures 88 columns and reads only .py files, never Markdown fences - hand review: the limit drifted before this commit, so it needs a gate what: - Add scripts/check_example_width.py and .github/example-width.toml, which reads every `**/*.md` page plus MIGRATION and CHANGES, and the doc comments under src/ - No `examples/` directory and no exclusions; the one [[allow]] entry is the deliberately wide Bad line in the WRITING.md port block - Record checker_digest from `--digest`, and shared_digest, the --digest of WRITING.md's shared section, so a local edit to it fails the check - Run its self-test and the check in tests.yml beside the ruff steps, add a check-example-width just recipe, and list it in CONTRIBUTING - Exclude scripts/ from mypy, which targets 3.10 while the script needs tomllib (3.11+) --- .github/CONTRIBUTING.md | 6 + .github/example-width.toml | 11 + .github/workflows/tests.yml | 5 + justfile | 6 + pyproject.toml | 2 + scripts/check_example_width.py | 3066 ++++++++++++++++++++++++++++++++ 6 files changed, 3096 insertions(+) create mode 100644 .github/example-width.toml create mode 100644 scripts/check_example_width.py diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7c0edbe84..b9410fba4 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -50,6 +50,12 @@ Lint: $ uv run ruff check . --fix --show-fixes ``` +Example width: + +```console +$ python3 scripts/check_example_width.py +``` + Type-check: ```console diff --git a/.github/example-width.toml b/.github/example-width.toml new file mode 100644 index 000000000..f28e70a0f --- /dev/null +++ b/.github/example-width.toml @@ -0,0 +1,11 @@ +width = 80 +markdown = ["**/*.md", "MIGRATION", "CHANGES"] +doc_comments = ["src/**/*.py"] +allow_ceiling = 1 +shared_digest = "28b74bae1f7a06731fea5a150cbde4430179b7788b7564ea1644c3951df8531b" +checker_digest = "eb87afceb3fc8f11ef5a478cd38f901ef3950e89ff94fbe0ca281c0cfc1a0e2e" + +[[allow]] +path = ".github/WRITING.md" +line = """>>> Window.from_window_id(window_id=session.cmd('new-window', '-P', '-F#{window_id}').stdout[0], server=session.server)""" +reason = "the Bad example in the port block; it shows the width problem the Good example fixes" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0c40e0a25..096e48643 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -65,6 +65,11 @@ jobs: - name: Format with ruff format run: uv run ruff format . --check + - name: Check example width + run: | + python3 scripts/check_example_width.py --self-test + python3 scripts/check_example_width.py + - name: Lint with mypy run: uv run mypy . diff --git a/justfile b/justfile index 05557181a..85c57156c 100644 --- a/justfile +++ b/justfile @@ -97,6 +97,12 @@ watch-ruff: just _entr-warn fi +# Check that documented examples stay within 80 columns +[group: 'lint'] +check-example-width: + python3 scripts/check_example_width.py --self-test + python3 scripts/check_example_width.py + # Run mypy type checker [group: 'lint'] mypy: diff --git a/pyproject.toml b/pyproject.toml index 0002133c9..cc4f975da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -147,6 +147,8 @@ files = [ "src", "tests", ] +# scripts/ run on the CI interpreter, not the 3.10 floor (tomllib is 3.11+). +exclude = ["^scripts/"] [tool.coverage.run] diff --git a/scripts/check_example_width.py b/scripts/check_example_width.py new file mode 100644 index 000000000..b29949890 --- /dev/null +++ b/scripts/check_example_width.py @@ -0,0 +1,3066 @@ +r"""Fail when code a reader sees in rendered documentation is too wide. + +Shared by every libtmux port; change it in all of them together. It +enforces the example rules in WRITING.md. Configuration lives in +``.github/example-width.toml``:: + + width = 80 + tab_width = 4 + markdown = ["**/*.md"] + sources = ["examples/**/*.rs"] + doc_comments = ["src/**/*.rs"] + shared_digest = "" + checker_digest = "" + allow_ceiling = 1 + + [[exclude]] + glob = "docs/decisions/**" + reason = "dated decision records, not reader-facing examples" + + [[formatter_copies]] + copy = "examples/rustfmt.toml" + root = "rustfmt.toml" + key = "max_width" + + [[allow]] + path = "README.md" + line = "the exact line, as it appears in the file" + reason = "why breaking it would make the example worse" + +It checks code in ``markdown`` files (fences, indented blocks, including +one four columns past a list item's text, and ``
`` blocks; literal
+and ``code-block`` blocks in a ``.rst`` one), every line of ``sources``
+files, and the code inside doc comments of ``doc_comments`` files:
+fences, read as a Markdown page reads them, ``>>>`` doctests, and reST
+literal blocks in Python docstrings. Output is not checked: fences
+tagged ``text``, and the lines a session prints (a ``console``,
+``shell-session`` or ``pycon`` block, or a ``python`` fence that opens
+with ``>>> ``). A command runs on through an open quote, heredoc, loop,
+conditional or brace block and past a trailing ``\``, backtick, pipe,
+``&&`` or ``||``; a ``$ `` command must continue with ``\`` and a
+``PS> `` command with a backtick. An untagged Markdown fence is code,
+and a ``text`` fence that opens with a ``$ `` or ``PS> `` prompt is a
+console session. A line that is only a URL (after an optional comment, list or
+quote marker) and a hidden line (a rustdoc ``# `` line, a doctest marked
+``# doctest: +HIDE``) are skipped. Wide characters count as two columns;
+a tab advances to the next multiple of 4.
+
+``width`` must be 80, the shared rule, and ``tab_width``, if set, 4.
+Every key must be known, at the top level and inside each ``[[...]]``
+entry, so a key written below a table header cannot be swallowed by that
+entry; every entry must hold
+the keys the check reads, and every value must have the type it reads.
+Every glob must match a tracked file, and every exclusion a checked file
+or a file under ``examples``;
+every exclusion and allow entry must give a reason, and every allow
+entry must match a line. Every tracked file under a directory named
+``examples`` must be checked or excluded, so a new example file cannot
+go unread. ``allow_ceiling`` must equal the number of allow entries, so
+the list grows only through a reviewed change to that number and a fixed
+line must lower it. Each formatter copy must equal its root file once
+its ``key`` value is set aside, and the digests of this checker and of
+WRITING.md's shared section must equal ``checker_digest`` and
+``shared_digest``, both required, so a local edit to either fails until
+every port changes together. A WRITING.md without both shared markers is
+reported by name. AGENTS.md must hold a Markdown link that resolves to
+this WRITING.md and the heading the shared section sits under, so an
+agent is routed to the rules, and WRITING.md's
+``### In this repository`` block must hold the four labelled bullets in
+order, then one Bad and one Good example, with the Bad example still
+past 80 columns.
+
+Run ``--self-test`` to prove the checker can fail. ``--digest`` prints the
+SHA-256 of this file and of WRITING.md's shared example section (between
+```` and ````), so a job
+that reads every port can confirm they still agree.
+"""
+
+from __future__ import annotations
+
+import argparse
+import dataclasses
+import hashlib
+import json
+import os
+import pathlib
+import re
+import subprocess
+import sys
+import typing as t
+import unicodedata
+
+try:
+    import tomllib
+except ModuleNotFoundError:  # tomllib is in the standard library from 3.11.
+    sys.exit("check_example_width.py needs Python 3.11 or newer")
+
+CONFIG = pathlib.PurePosixPath(".github/example-width.toml")
+KEYS = frozenset(
+    {
+        "width",
+        "tab_width",
+        "markdown",
+        "sources",
+        "doc_comments",
+        "exclude",
+        "allow",
+        "allow_ceiling",
+        "formatter_copies",
+        "shared_digest",
+        "checker_digest",
+    }
+)
+# Keys each [[table]] entry holds.
+TABLE_KEYS = {
+    "exclude": frozenset({"glob", "reason"}),
+    "allow": frozenset({"path", "line", "reason"}),
+    "formatter_copies": frozenset({"copy", "root", "key"}),
+}
+GLOB_KEYS = ("markdown", "sources", "doc_comments")
+# The reason the printed allow entry carries until someone writes a real one.
+PLACEHOLDER = "why breaking this line would make the example worse"
+# The type each top-level value must have; a TOML string where a list belongs
+# would otherwise be read one character at a time.
+VALUE_TYPES: dict[str, tuple[type, str]] = {
+    "width": (int, "an integer"),
+    "tab_width": (int, "an integer"),
+    "allow_ceiling": (int, "an integer"),
+    "shared_digest": (str, "a string"),
+    "checker_digest": (str, "a string"),
+}
+CODE_DIRECTIVES = frozenset({"code-block", "code", "code-cell", "sourcecode"})
+# gp-libs hides a doctest line carrying this directive from the rendered page.
+HIDDEN = re.compile(r"^\s*(?:>>>|\.\.\.) .*# doctest: \+HIDE\b")
+OUTPUT_TAGS = frozenset({"text"})
+# Fences that mix prompted commands with the output they print.
+SESSION_TAGS = frozenset({"console", "shell-session", "pycon"})
+# Fences of shell script, with no prompts and no output.
+SCRIPT_TAGS = frozenset({"sh", "bash", "zsh", "shell", "powershell", "pwsh", "ps1"})
+# A Python fence that opens with a doctest prompt is a session too.
+PYTHON_TAGS = frozenset({"python", "py", "python3"})
+PROMPTS = ("$ ", "PS> ", ">>> ", "... ")
+# Shell words that open and close a block a command continues through.
+SHELL_OPEN = frozenset({"if", "for", "while", "until", "case", "select"})
+SHELL_CLOSE = frozenset({"fi", "done", "esac"})
+HEREDOC = re.compile(r"(?[A-Za-z_]\w*)\1")
+# A reST line that opens an indented code block: a paragraph ending in
+# ``::`` or a code directive. Other directives, such as toctree, hold no code.
+REST_BLOCK = re.compile(
+    r"^\s*(?!\.\. )\S.*::\s*$|^\s*\.\. (code-block|code|sourcecode)::"
+)
+FENCE = re.compile(r"^(?P\s*)(?P`{3,}|~{3,})\s*(?P[^`]*)$")
+RUSTDOC_TAGS = frozenset({"rust", "no_run", "ignore", "should_panic", "compile_fail"})
+SHARED = re.compile(
+    r"\n(?P.*?)", re.DOTALL
+)
+DOC_LINE = re.compile(r"^\s*(?:///|//!|\*(?!/)|#'|--- ?)\s?")
+# Files under examples/ with no text lines to measure.
+BINARY = (
+    ".png",
+    ".jpg",
+    ".jpeg",
+    ".gif",
+    ".ico",
+    ".webp",
+    ".pdf",
+    ".woff",
+    ".woff2",
+    ".ttf",
+    ".zip",
+    ".gz",
+    ".tar",
+    ".wasm",
+    ".so",
+    ".dll",
+    ".exe",
+    ".jar",
+    ".class",
+    ".bin",
+    ".dylib",
+    ".a",
+    ".o",
+    ".db",
+    ".sqlite",
+)
+# Comments a test compares or a copy step reads: Go's `// Output:` and
+# `// docs:` lines, java's `// →` results, and region markers.
+MARKER = re.compile(
+    r"(?:Output:|Unordered output:|docs:|→|#?(?:end)?region\b)", re.IGNORECASE
+)
+# Shell and PowerShell scripts, whose lines continue the way a console command does.
+SCRIPTS = (".sh", ".bash", ".zsh", ".ps1", ".psm1")
+# Go marks doc-comment code with a tab after a bare //.
+GO_DOC = re.compile(r"^\s*//\t")
+DOCTEST = re.compile(r"(?:>>>|\.\.\.) ")
+BLOCK_OPEN = re.compile(r"
\s*\{@code|\s*$")
+BLOCK_CLOSE = re.compile(r"\}\s*
|^\s*") +URL = re.compile(r"^(?:(?:#+|//+!?|-+|\*|>|\n\n [x](y)\n- [z](w)\n") + 'a b \n\n- [z](w)\n' + >>> prose("```text\n[x](y)\n```\n") + '' + """ + text = unfenced(text) + code = {number for number, _ in markdown_lines(text)} + lines = text.splitlines(keepends=True) + text = "".join(line for n, line in enumerate(lines, 1) if n not in code) + text = re.sub(r"", "", text, flags=re.DOTALL) + span = r"(? list[str]: + r"""Return the targets of a Markdown text's inline and reference links. + + >>> links('[A](a.md#x "A"), [B]() and [C](c.md \'C\')\n[d]: d.md') + ['a.md#x', 'b.md', 'c.md', 'd.md'] + >>> links("![E](e.md) \\[F](f.md) [G\nH](g.md)") + ['g.md'] + """ + return [inline or reference for inline, reference in LINK.findall(text)] + + +def routing_problems(root: pathlib.Path) -> list[str]: + """Return a problem unless AGENTS.md links the shared section's heading. + + The heading is the last one of level one or two above the shared + markers: the section an agent routed from AGENTS.md should land in. + """ + writing = writing_path(root) + if writing is None: + return [] + text = writing.read_text(encoding="utf-8") + above = unfenced(text[: text.index("")]) + headings = [m[2] for m in HEADING.finditer(above) if len(m[1]) <= 2] + if not headings: + where = writing.relative_to(root).as_posix() + return [f"{where}: no heading above the shared section"] + anchor = slug(headings[-1]) + agents = root / "AGENTS.md" + where = f"{writing.relative_to(root).as_posix()}#{anchor}" + link = f"[{headings[-1]}]({where})" + if not agents.is_file(): + return [f"AGENTS.md is missing; add it with a link such as {link}"] + route = prose(agents.read_text(encoding="utf-8")) + for target in links(route): + path, _, fragment = target.partition("#") + # GitHub reads a leading slash as the repository root. + here = root / path.removeprefix("/") + if fragment == anchor and here.resolve() == writing.resolve(): + return [] + return [ + f"AGENTS.md: no link to {where}, the section with the example rules; " + + f"add one such as {link}" + ] + + +def port_block_problems(text: str) -> list[str]: + r"""Return how a WRITING.md's ``### In this repository`` block strays. + + >>> labels = "\n".join(label + " x" for label in PORT_LABELS) + >>> bad = "```text\n" + "x" * 81 + "\n```" + >>> head = f"### In this repository\n\n{labels}\n\n" + >>> block = head + f"Bad, over 80:\n\n{bad}\n\nGood, named:\n" + >>> port_block_problems(block) + [] + >>> [p.split(";")[0] for p in port_block_problems(block.replace("x" * 81, "x"))] + ['In this repository: the Bad example fits in 80 columns'] + >>> port_block_problems(block.replace("- **Not formatted:**", "- **Other:**")) + ['In this repository: no "- **Not formatted:**" bullet in its place'] + >>> port_block_problems("## Examples\n") + ['no "### In this repository" block'] + >>> port_block_problems(block + "\n" + block) + ['2 "### In this repository" blocks; keep one'] + """ + blocks = PORT_BLOCK.findall(unfenced(text)) + if len(blocks) > 1: + return [f'{len(blocks)} "### In this repository" blocks; keep one'] + match = PORT_BLOCK.search(unfenced(text)) + if match is None: + return ['no "### In this repository" block'] + lines = match["body"].splitlines() + problems = [] + at = 0 + for label in PORT_LABELS: + found = next((i for i, line in enumerate(lines) if line.startswith(label)), -1) + if found < at: + problems.append(f'In this repository: no "{label}" bullet in its place') + else: + at = found + for example in ("Bad, over 80", "Good"): + count = sum(line.startswith(example) for line in lines[at:]) + if count != 1: + problems.append(f'In this repository: {count} "{example}" examples, want 1') + # The fence after "Bad, over 80" is stripped above, so read it raw: a + # Bad example that a later edit brought within 80 no longer shows a fault. + raw = text[text.find("### In this repository") :].splitlines() + start = next( + (i for i, line in enumerate(raw) if line.startswith("Bad, over 80")), None + ) + if start is not None: + opens = [i for i in range(start + 1, len(raw)) if FENCE.match(raw[i])] + code = raw[opens[0] + 1 : opens[1]] if len(opens) > 1 else [] + if not any(columns(line.expandtabs(4)) > 80 for line in code): + problems.append( + "In this repository: the Bad example fits in 80 columns; " + "show a line that does not" + ) + return problems + + +# A comment after code, set off by two spaces as most style guides ask. +TRAILING = re.compile(r"\S\s{2,}(?://|#|--)\s+(?P.*)$") +# How a comment line starts in the languages the examples use. +COMMENTS = ("# ", "// ", "-- ", "/// ", "//! ", "/* ", "* ", "") + >>> fix_advice(html, "W.md")[:22] + 'Wrap the comment at 80' + >>> doc = Finding("a.rs", 2, 90, 80, "/// // a comment line in doc code", "doc") + >>> fix_advice(doc, "W.md")[:22] + 'Wrap the comment at 80' + >>> go = Finding("a.go", 2, 90, 80, "//\t// Wait for the marker line", "doc") + >>> pydoc = Finding("a.py", 2, 90, 80, " >>> # Wait for the marker", "doc") + >>> [fix_advice(f, "W.md")[:22] for f in (go, pydoc)] + ['Wrap the comment at 80', 'Wrap the comment at 80'] + >>> said = Finding("a.md", 2, 90, 80, ">>> # Wait for the marker", "session") + >>> data = Finding("a.md", 2, 90, 80, "# a heredoc line, not a comment", "session") + >>> fix_advice(said, "W.md")[:22] + 'Wrap the comment at 80' + >>> fix_advice(data, "W.md").split(":")[0] + 'Fix a wide line by changing the code, not the line breaks' + >>> doc = Finding("a.rs", 2, 90, 80, "/// let total = first + second;", "doc") + >>> fix_advice(doc, "W.md").split(":")[0], "copied" in fix_advice(doc, "W.md") + ('Fix a wide line by changing the code, not the line breaks', True) + >>> note = Finding("README.md", 3, 84, 80, "// wait until the pane prints it") + >>> "copied" in fix_advice(note, "W.md") + True + >>> line = "/// $ tmux new-session -d -s demo \\" + >>> doced = Finding("a.rs", 4, 90, 80, line, "doc:command") + >>> fix_advice(doced, "W.md").split(":")[0] + 'Split a wide command the way its shell continues' + >>> noted = Finding("a.rs", 5, 90, 80, "/// # Create a session first", "doc:note") + >>> fix_advice(noted, "W.md")[:24] + 'Move the comment out of ' + >>> bare_note = Finding("a.rs", 2, 90, 80, "/// # Create it", "doc:promptless") + >>> fix_advice(bare_note, "W.md")[:24] + 'Move the comment out of ' + >>> labelled = Finding("A.md", 3, 90, 80, "session.name(); // → demo", "") + >>> fix_advice(labelled, "W.md").split(": ")[0] + 'Keep the code and its marker on one line (W.md#what-never-breaks)' + >>> "shorten the code before the marker (W.md#reaching-80)" in fix_advice( + ... labelled, "W.md" + ... ) + True + >>> remark_copy = fix_advice(remark, "W.md").split(". ")[-1] + >>> remark_copy + 'If a Markdown block copies this file, re-copy it as W.md#in-this-repository says.' + >>> command = Finding("a.md", 1, 90, 80, " PS> Get-Item -Path x") + >>> fix_advice(command, "W.md") + 'Split a wide command the way its shell continues: W.md#reaching-80.' + """ + kind, text = finding.kind, finding.text.lstrip() + # A line in a doc-comment fence carries its Markdown kind after "doc:"; + # read it past its comment marker, as the Markdown line would be read. + if kind.startswith("doc:"): + marker = GO_DOC if GO_DOC.match(finding.text) else DOC_LINE + kind, text = kind[4:], marker.sub("", finding.text, count=1).lstrip() + reach = f"{rules}#reaching-80" + advice = f"Fix a wide line by changing the code, not the line breaks: {reach}." + # Code blocks keeps explanations out of a console block, in the prose. + prose = ( + f"Move the comment out of the block into the prose around it: " + f"{rules}#code-blocks." + ) + if kind == "note": + return prose + if kind == "promptless": + if text.startswith("# "): + return prose + return ( + "If it is a command, add the prompt its shell takes, `$ ` or `PS> `, " + f"and continue it the way that shell does ({reach}); if it is " + "output, tag the fence `text`." + ) + if kind == "command" or text.startswith(("$ ", "PS> ")): + dialect = "PS> " if text.startswith("PS> ") else "$ " + if scan(text, Shell(dialect))[3] < len(text): + return prose + if len(text.rstrip(" \\`").split()) < 2: + # One token has nowhere to split. + return advice + return f"Split a wide command the way its shell continues: {reach}." + if kind == "doc": + marker = GO_DOC if GO_DOC.match(finding.text) else DOC_LINE + text = marker.sub("", finding.text, count=1).lstrip() + # A doctest comment sits behind its prompt. A session line without one is + # heredoc or other data, where # starts no comment. + doctest = DOCTEST.match(text) if kind in ("doc", "session") else None + text = text[doctest.end() :] if doctest else text + commented = kind in ("", "source", "doc", "script") or bool(doctest) + # Markdown blocks and doc-comment fences can be copies of a source file, + # and a source file can be the original a block copies. + copied = ( + " In a block copied from a source file, fix the source, then follow the " + f"copy step that {rules}#in-this-repository gives." + if kind in ("", "doc", "script") + else " If a Markdown block copies this file, re-copy it as " + f"{rules}#in-this-repository says." + if kind == "source" + else "" + ) + if commented and text.startswith(COMMENTS): + if MARKER.match(text.split(None, 1)[1] if " " in text else ""): + return kept_whole(rules) + ": add this one to [[allow]] with its reason." + # Width says comments wrap at 80; What never breaks keeps compared + # output and marker lines whole. + return ( + f"Wrap the comment at 80 ({rules}#width); compared output and marker " + f"lines stay whole ({rules}#what-never-breaks).{copied}" + ) + # A marker after the code it labels, such as `name(); // → demo`, keeps + # its line whole too. + trailing = TRAILING.search(text) if commented else None + if trailing and MARKER.match(trailing["body"]): + return ( + f"Keep the code and its marker on one line ({rules}#what-never-breaks): " + f"shorten the code before the marker ({reach}); add it to [[allow]] only " + "if it still runs past 80." + ) + # A script line is a shell command, which breaks at its continuation. + script = kind == "script" or (kind == "source" and finding.path.endswith(SCRIPTS)) + if script and len(text.rstrip(" \\`").split()) > 1: + paste = ( + " A command a reader pastes goes in a `console` fence with its prompt " + f"({rules}#code-blocks)." + if kind == "script" + else "" + ) + return ( + f"{advice} If it is one command, it may instead continue the way its " + f"shell does.{paste}{copied}" + ) + if kind == "source": + return ( + f"If the port block names a formatter for this file, run it. {advice}" + + copied + ) + return advice + copied + + +# A line edited in place keeps most of its text; a stale entry and a wide line +# that share less are a deleted allowed line and a new wide one. +SIMILAR = 0.6 + + +def resemblance(finding: Finding, entry: dict[str, t.Any]) -> float: + """Return how alike a wide line and a stale entry's line are, 0 to 1. + + >>> line = Finding("a.md", 1, 90, 80, "print(totals, values)") + >>> round(resemblance(line, {"line": "print(total, values)"}), 2) + 0.98 + """ + return alike(finding.text, entry["line"]) + + +def alike(one: str, other: str) -> float: + """Return how alike two lines are, 0 to 1. + + >>> alike("abcd", "abce") + 0.75 + """ + import difflib + + return difflib.SequenceMatcher(None, one, other).ratio() + + +def edit_pairs( + findings: list[Finding], + stale: list[dict[str, t.Any]], + bad_path: str | None, + shortened: list[dict[str, t.Any]] | None = None, +) -> list[tuple[Finding, dict[str, t.Any]]]: + """Pair stale entries with the wide lines that kept most of their text. + + Each stale entry and each wide line joins at most one pair, closest + first, so two allowed lines edited in one file make two pairs; the rest + of the wide lines are new. + + >>> stale = [{"path": "a.md", "line": "alpha(" + "x" * 80 + ")"}] + >>> stale.append({"path": "a.md", "line": "beta(" + "y" * 80 + ")"}) + >>> one = Finding("a.md", 3, 90, 80, "alpha(" + "x" * 82 + ")") + >>> two = Finding("a.md", 7, 90, 80, "beta(" + "y" * 83 + ")") + >>> new = Finding("a.md", 9, 90, 80, "z" * 90) + >>> pairs = edit_pairs([two, new, one], stale, None) + >>> [(f.number, e["line"][:4]) for f, e in pairs] + [(7, 'beta'), (3, 'alph')] + >>> edit_pairs([one], stale, "a.md") + [] + >>> edit_pairs([one], stale, None, shortened=stale) + [] + """ + scored = sorted( + ( + (resemblance(f, e), i, j) + for i, f in enumerate(findings) + for j, e in enumerate(stale) + if f.path == e["path"] and f.path != bad_path and e not in (shortened or []) + ), + reverse=True, + ) + pairs, taken_lines, taken_entries = [], set(), set() + for score, i, j in scored: + if score < SIMILAR: + break + if i in taken_lines or j in taken_entries: + continue + taken_lines.add(i) + taken_entries.add(j) + pairs.append((i, findings[i], stale[j])) + return [(finding, entry) for _, finding, entry in sorted(pairs, key=lambda p: p[0])] + + +def edited_finding( + findings: list[Finding], stale: list[dict[str, t.Any]], bad_path: str | None +) -> Finding | None: + """Return the first wide line that is an edited allowed line, if any. + + >>> stale = [{"path": "a.md", "line": "print(total, " + "x" * 80 + ")"}] + >>> new = Finding("a.md", 2, 90, 80, "y" * 90) + >>> edit = Finding("a.md", 9, 95, 80, "print(totals, " + "x" * 80 + ")") + >>> edited_finding([new, edit], stale, None).number + 9 + >>> edited_finding([new, edit], stale, "a.md") is None + True + >>> far = Finding("a.md", 3, 92, 80, "print(total, " + "y" * 20 + "x" * 60 + ")") + >>> edited_finding([far, edit], stale, None).number + 9 + """ + pairs = edit_pairs(findings, stale, bad_path) + return pairs[0][0] if pairs else None + + +def shortened_entries( + root: pathlib.Path, + stale: list[dict[str, t.Any]], + width: int, + tab_width: int, +) -> list[dict[str, t.Any]]: + """Return stale entries whose line was shortened to fit since the last commit. + + Such an entry's file holds a line that fits, resembles the entry and is + not in the committed file. A wide line that also resembles the entry is + then new, not the allowed line edited, so it gets an entry of its own. + """ + found = [] + for entry in stale: + path = str(entry["path"]) + shown = subprocess.run( + ["git", "-C", str(root), "show", f"HEAD:{path}"], + capture_output=True, + text=True, + check=False, + ) + if shown.returncode != 0: + continue + before = {line.rstrip() for line in shown.stdout.splitlines()} + if any( + line not in before + and columns(line.expandtabs(tab_width)) <= width + and alike(line, str(entry["line"])) >= SIMILAR + for line in source_lines(root, entry) + ): + found.append(entry) + return found + + +def source_lines(root: pathlib.Path, entry: dict[str, t.Any]) -> set[str]: + """Return the stripped lines of an allow entry's file, or none if unreadable.""" + try: + text = (root / str(entry["path"])).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return set() + return {line.rstrip() for line in text.splitlines()} + + +def entry_name(entry: dict[str, t.Any], entries: list[dict[str, t.Any]]) -> str: + r"""Name an allow entry by its path, and by its line when the path has two. + + >>> one = {"path": "a.md", "line": "first line"} + >>> entry_name(one, [one]), entry_name(one, [one, {"path": "a.md", "line": "x"}]) + ('a.md', "a.md whose line begins 'first line'") + >>> indented = {"path": "a.md", "line": " first line"} + >>> entry_name(indented, [indented, one]) + "a.md whose line begins 'first line'" + >>> long = {"path": "a.md", "line": "print(total, first + second + third + fourth)"} + >>> entry_name(long, [long, one]) + "a.md whose line begins 'print(total, first +'" + >>> path = {"path": "a.ps1", "line": r"C:\Users\me\AppData\Local\Temp\run.ps1"} + >>> entry_name(path, [path, {"path": "a.ps1", "line": "x"}]) + "a.ps1 whose line begins 'C:\\Users\\me\\AppData\\Local\\Temp'" + """ + if sum(e.get("path") == entry["path"] for e in entries) < 2: + return str(entry["path"]) + text = entry["line"].strip() + if len(text) > 32: + # Cut at a space, or else before a backslash, so the quoted start + # never ends inside an escape. + space, slash = text.find(" ", 20), text.rfind("\\", 20, 33) + if 0 < space <= 40: + text = text[:space] + else: + text = text[:slash].rstrip("\\") if slash > 0 else text[:32] + return f"{entry['path']} whose line begins {quoted(text)}" + + +def quoted(text: str) -> str: + r"""Return a line in single quotes, as a TOML literal string writes it. + + A line that holds a single quote falls back to an escaped basic string. + + >>> print(quoted(r'"C:\Windows"'), quoted("it's")) + '"C:\Windows"' "it's" + """ + return f"'{text}'" if "'" not in text else literal(text) + + +def ceiling_hints( + findings: list[Finding], + stale: list[dict[str, t.Any]], + count: int, + bad_path: str | None = None, + hidden: list[dict[str, t.Any]] | None = None, + entries: list[dict[str, t.Any]] | None = None, + shortened: list[dict[str, t.Any]] | None = None, +) -> list[str]: + r"""Return what to change in the allow list after a failed run. + + A stale entry in a file that also has a wide line is an edited allowed + line, so its entry takes the new text. A stale entry for ``bad_path``, + the WRITING.md whose Bad example now fits, asks for a wide Bad line + again. Every other stale entry goes, and the last line gives the + ceiling all of that leaves. + + >>> wide = Finding("a.md", 3, 90, 80, "x" * 90) + >>> ceiling_hints([wide], [], 1) + ['Set allow_ceiling to 1 plus one for each line you add to [[allow]].'] + >>> edited = {"path": "a.md", "line": "x" * 88} + >>> for hint in ceiling_hints([wide], [edited], 1): + ... print(hint) + a.md: an allowed line changed; put its new text in the stale entry. + allow_ceiling stays 1. + >>> gone = {"path": "b.md", "line": "old"} + >>> for hint in ceiling_hints([wide], [edited, gone], 2): + ... print(hint) + a.md: an allowed line changed; put its new text in the stale entry. + Delete the stale allow entry for b.md. + Lower allow_ceiling to 1. + >>> kept = {"path": "b.md", "line": "kept"} + >>> ceiling_hints([], [gone], 2, entries=[gone, kept])[0] + "Delete the stale allow entry for b.md whose line begins 'old'." + >>> second = Finding("a.md", 5, 88, 80, "z" * 88) + >>> ceiling_hints([wide, second], [edited], 1)[-1] + 'Set allow_ceiling to 1 plus one for each line you add to [[allow]].' + >>> other = Finding("c.md", 7, 85, 80, "y" * 85) + >>> for hint in ceiling_hints([wide, other], [edited], 1): + ... print(hint) + a.md: an allowed line changed; put its new text in the stale entry. + Set allow_ceiling to 1 plus one for each line you add to [[allow]]. + >>> both = [edited, {"path": "a.md", "line": "y" * 88}] + >>> lines = [wide, Finding("a.md", 5, 90, 80, "y" * 90)] + >>> for hint in ceiling_hints(lines, both, 2): + ... print(hint) + a.md: an allowed line changed; put its new text in the stale entry. + a.md: an allowed line changed; put its new text in the stale entry. + allow_ceiling stays 2. + >>> kept = {"path": "a.md", "line": "x" * 90} + >>> for hint in ceiling_hints([], [kept], 1, hidden=[kept]): + ... print(hint.split(";")[0]) + a.md: the allowed line is still there but the check no longer reads it as wide + allow_ceiling stays 1. + >>> unrelated = {"path": "a.md", "line": "old"} + >>> for hint in ceiling_hints([wide], [unrelated], 1): + ... print(hint) + Delete the stale allow entry for a.md. + Set allow_ceiling to 0 plus one for each line you add to [[allow]]. + >>> bad = {"path": "WRITING.md", "line": "x" * 90} + >>> for hint in ceiling_hints([], [bad], 1, "WRITING.md"): + ... print(hint) + WRITING.md: restore a Bad example past 80; its [[allow]] entry quotes it. + allow_ceiling stays 1. + >>> other = Finding("WRITING.md", 9, 85, 80, "x" * 85) + >>> for hint in ceiling_hints([other], [bad], 1, "WRITING.md"): + ... print(hint) + WRITING.md: restore a Bad example past 80; its [[allow]] entry quotes it. + Set allow_ceiling to 1 plus one for each line you add to [[allow]]. + """ + pairs = edit_pairs(findings, stale, bad_path, shortened) + edited = [entry for _, entry in pairs] + hints = [ + f"{entry['path']}: an allowed line changed; put its new text in the " + "stale entry." + for entry in edited + ] + restored = [entry for entry in stale if entry["path"] == bad_path] + hints += [ + f"{bad_path}: restore a Bad example past 80; its [[allow]] entry quotes it." + ] * bool(restored) + # An allowed line still in its file was hidden by a config change, not + # fixed: deleting its entry would only weaken the gate. + hidden = [entry for entry in hidden or [] if entry in stale] + hints += [ + f"{entry_name(e, entries or stale)}: the allowed line is still there but " + "the check no longer reads it as wide; undo the change that hid it: a " + "glob, [[exclude]] or tab_width edit, or a fence tag or comment marker " + "around the line." + for e in hidden + ] + gone = [e for e in stale if e not in edited + restored + hidden] + hints += [ + f"Delete the stale allow entry for {entry_name(e, entries or stale)}." + for e in gone + ] + target = count - len(gone) + # Every wide line not paired with a stale entry is new. + if len(findings) > len(pairs): + hints.append( + f"Set allow_ceiling to {target} plus one for each line you add " + "to [[allow]]." + ) + elif target == count: + hints.append(f"allow_ceiling stays {count}.") + else: + hints.append(f"Lower allow_ceiling to {target}.") + return hints + + +def digests(root: pathlib.Path) -> list[str]: + """Return ``checker `` and ``shared `` lines. + + The shared digest covers the text between the markers in WRITING.md; + ``shared missing`` means no WRITING.md carries them. + """ + checker = hashlib.sha256(pathlib.Path(__file__).read_bytes()).hexdigest() + shared = "missing" + writing = writing_path(root) + if writing is not None: + match = SHARED.search(writing.read_text(encoding="utf-8")) + if match: + shared = hashlib.sha256(match["body"].encode()).hexdigest() + return [f"checker {checker}", f"shared {shared}"] + + +def digest_problems(root: pathlib.Path, config: dict[str, t.Any]) -> list[str]: + """Return digests that differ from the ones the configuration records. + + Each message names the file that changed. Both files are the same in + every port, so an intended change goes to all of them; an edit meant for + one repository is undone instead. + """ + actual = dict(line.split(" ", 1) for line in digests(root)) + here, top = pathlib.Path(__file__).resolve(), root.resolve() + script = here.relative_to(top) if here.is_relative_to(top) else here.name + writing = writing_file(root) + shared = writing.relative_to(root).as_posix() if writing else "WRITING.md" + where = {"checker": script, "shared": shared} + names = {"checker": "this checker", "shared": "the shared section"} + undo = { + "checker": f"it (`git checkout -- {script}`, or a copy from another port)", + "shared": ( + "the text between the shared markers, copied from " + f"`git show HEAD:{shared}` so port-block edits stay, or from another " + "port once the edit is committed" + ), + } + # A digest written below a [[...]] header or misspelled is reported as + # an unknown key already. + nested = { + key + for table in TABLE_KEYS + for entry in config.get(table, []) + if isinstance(entry, dict) + for key in entry + } | {meant(key) for key in set(config) - KEYS} + missing = [ + f"{CONFIG}: {name}_digest is missing; run `python3 {script} --digest` " + f"and set {name}_digest to the {name} value it prints" + for name in ("checker", "shared") + if name + "_digest" not in config and name + "_digest" not in nested + ] + return missing + [ + f"{where[name]}: {names[name]} has digest {actual[name][:12]}, but " + f"{name}_digest in {CONFIG} records {config[name + '_digest'][:12]}.\n" + f" To undo an edit meant for this repository only, restore {undo[name]}.\n" + " To change it everywhere, change libtmux/docs and each repository " + f"listed in {PORTS}, which also names where each keeps this checker; " + "run each copy with `--digest` and set checker_digest and " + "shared_digest to the values it prints." + for name in ("checker", "shared") + if name + "_digest" in config + and actual[name] not in (config[name + "_digest"], "missing") + ] + + +def self_test() -> int: + """Run the doctests above, plus planted failures and clean controls.""" + import contextlib + import doctest + import io + import tempfile + + failures, _ = doctest.testmod(sys.modules[__name__]) + + def expect(condition: bool, message: str) -> None: + nonlocal failures + if not condition: + print(f"self-test: {message}", file=sys.stderr) + failures += 1 + + with tempfile.TemporaryDirectory() as scratch: + root = pathlib.Path(scratch) + wide = "let total = " + " + ".join(["value"] * 12) + ";" + (root / "README.md").write_text(f"```rust\n{wide}\n```\n") + config: dict[str, t.Any] = {"width": 60, "markdown": ["README.md"]} + planted, _ = check(root, config, ["README.md"]) + expect(len(planted) == 1, "a planted wide line was not reported") + if planted: + pasted = tomllib.loads(allow_hint(planted[0]))["allow"][0] + expect(pasted["line"] == wide, "the allow hint does not round-trip") + excluded = dict(config, exclude=[{"glob": "README.md", "reason": "r"}]) + expect(not check(root, excluded, ["README.md"])[0], "an exclusion was read") + config["allow"] = [{"path": "README.md", "line": wide, "reason": "test"}] + clean, stale = check(root, config, ["README.md"]) + expect(not clean and not stale, "an allowed line was reported") + url = "https://example.com/" + "x" * 80 + hidden = ">>> setup = " + "x" * 70 + " # doctest: +HIDE" + (root / "skip.md").write_text(f"```\n{url}\n```\n```python\n{hidden}\n```\n") + skipped, _ = check(root, {"width": 80, "markdown": ["skip.md"]}, ["skip.md"]) + expect(not skipped, "a URL line or a hidden doctest line was reported") + body = "x" * 90 + (root / "here.md").write_text( + f"```console\n$ cat < one failed", + ) + (root / "page.rst").write_text(".. code-block:: sh\n\n " + "x" * 81 + "\n") + rst, _ = check(root, {"width": 80, "markdown": ["page.rst"]}, ["page.rst"]) + expect(len(rst) == 1, "a wide line in a reST code block passed") + config["allow"].append({"path": "README.md", "line": "gone", "reason": "x"}) + _, stale = check(root, config, ["README.md"]) + expect(len(stale) == 1, "a stale allow entry was not reported") + tabbed = "\t" + wide + (root / "main.go").write_text(tabbed + "\n") + config = {"width": 60, "sources": ["*.go"]} + config["allow"] = [{"path": "main.go", "line": tabbed, "reason": "test"}] + clean, stale = check(root, config, ["main.go"]) + expect(not clean and not stale, "a raw tabbed allow entry did not match") + (root / "tab.go").write_text("\t" + "x" * 78 + "\n") + tabbed_config = {"width": 80, "tab_width": 4, "sources": ["tab.go"]} + expanded, _ = check(root, tabbed_config, ["tab.go"]) + expect(len(expanded) == 1, "a tab counted as one column") + (root / "tab.go").write_text("\t" + "x" * 76 + "\n") + expanded, _ = check(root, tabbed_config, ["tab.go"]) + expect(not expanded, "a tabbed line within 80 was reported") + (root / "fmt.toml").write_text("edition = 1\nmax_width = 100\n") + (root / "copy.toml").write_text("edition = 1\nmax_width = 80\n") + pair = {"copy": "copy.toml", "root": "fmt.toml", "key": "max_width"} + copies = {"formatter_copies": [pair]} + expect(not copy_problems(root, copies), "a width-only copy was reported") + wider_copies = ( + "max_width = 120", + "max_width = 100 # was 80", + "# max_width = 80", + "# keep max_width = 80\nmax_width = 100", + "max_width = 80\nmax_width = 120", + "max_width = 80, max_width = 120", + ) + for wider in wider_copies: + (root / "copy.toml").write_text(f"edition = 1\n{wider}\n") + expect(bool(copy_problems(root, copies)), f"a copy with {wider!r} passed") + (root / "copy.toml").write_text( + "edition = 1\nmax_width = 80, max_width = 120\n" + ) + doubled = copy_problems(root, copies) + expect(bool(doubled) and "2 times" in doubled[0], "a doubled width passed") + (root / "copy.toml").write_text("edition = 1\nmax_width = 80 # was 120\n") + expect(not copy_problems(root, copies), "a commented width was read twice") + (root / "copy.toml").write_text( + "edition = 1\nmax_width = 80 # the root sets max_width = 100\n" + ) + expect( + not copy_problems(root, copies), "a width named in a comment was counted" + ) + (root / "copy.toml").write_text("edition = 1\nmax_width = 80\n") + (root / "copy.toml").write_text("edition = 2\nmax_width = 80\n") + expect(bool(copy_problems(root, copies)), "a drifted copy was not reported") + (root / "fmt.json").write_text('{\n "lineLength": 100, "indent": 2\n}\n') + (root / "copy.json").write_text('{\n "lineLength": 80, "indent": 9\n}\n') + same_line = {"copy": "copy.json", "root": "fmt.json", "key": "lineLength"} + expect( + bool(copy_problems(root, {"formatter_copies": [same_line]})), + "a setting changed on the width line passed", + ) + (root / "copy.json").write_text('{\n "lineLength": 80, "indent": 2\n}\n') + expect( + not copy_problems(root, {"formatter_copies": [same_line]}), + "a copy differing only in its width value was reported", + ) + expect(digests(root)[1] == "shared missing", "a missing section was digested") + shared = "\nrule\n\n" + (root / "WRITING.md").write_text(shared) + expect(digests(root)[1] != "shared missing", "a shared section was missed") + headless = routing_problems(root) + expect( + bool(headless) and "no heading" in headless[0], + "a shared section with no heading above passed", + ) + checker_now = digests(root)[0].split(" ", 1)[1] + recorded: dict[str, t.Any] = { + "checker_digest": checker_now, + "shared_digest": "0" * 64, + } + changed = digest_problems(root, recorded) + expect( + bool(changed) and "or from another port" in changed[0], + "a shared-section change was missed or offered only HEAD to restore", + ) + shared_now = digests(root)[1].split(" ", 1)[1] + recorded = {"checker_digest": checker_now, "shared_digest": shared_now} + expect(not digest_problems(root, recorded), "a matching digest was reported") + below = {"path": "a", "line": "b", "reason": "r"} + below["checker_digest"] = recorded.pop("checker_digest") + recorded["allow"] = [below] + expect( + not digest_problems(root, recorded), + "a digest below [[allow]] was also reported missing", + ) + del recorded["allow"] + recorded["checker_digset"] = below["checker_digest"] + expect( + not digest_problems(root, recorded), + "a misspelled digest key was also reported missing", + ) + del recorded["checker_digset"] + expect(bool(digest_problems(root, recorded)), "a missing digest passed") + (root / "WRITING.md").write_text("## Examples\n\n" + shared) + alone = root / "alone" / ".github" + alone.mkdir(parents=True) + section = "## Examples\n\n\n" + (alone / "WRITING.md").write_text(section) + broken = marker_problems(alone.parent) + expect( + bool(broken) and ".github/WRITING.md" in broken[0], + "a missing closing marker passed or named the wrong file", + ) + (alone / "WRITING.md").write_text(section + "x\n\n") + expect(not marker_problems(alone.parent), "balanced markers were reported") + body = " echo " + "x" * 80 + loop = f"```console\n$ for x in a; do\n{body}\ndone\n{'y' * 81}\n```\n" + (root / "loop.md").write_text(loop) + looped, _ = check(root, {"width": 80, "markdown": ["loop.md"]}, ["loop.md"]) + expect( + [finding.number for finding in looped] == [3], + "a wide loop body passed, or printed output was measured", + ) + expect(bool(routing_problems(root)), "a missing AGENTS.md link passed") + (root / "AGENTS.md").write_text("Code examples: WRITING.md#examples\n") + expect(bool(routing_problems(root)), "a bare anchor counted as a link") + (root / "AGENTS.md").write_text("[Examples](docs/WRITING.md#examples)\n") + expect(bool(routing_problems(root)), "a link to a missing file passed") + (root / "AGENTS.md").write_text("[Examples](WRITING.md#other)\n") + expect(bool(routing_problems(root)), "a link to the wrong heading passed") + (root / "AGENTS.md").write_text("[Examples](WRITING.md#examples)\n") + expect(not routing_problems(root), "a linked section was reported") + (root / "AGENTS.md").write_text("[Examples]()\n") + expect(not routing_problems(root), "an angle-bracket link was reported") + (root / "AGENTS.md").write_text("[Examples][ex]\n\n[ex]: WRITING.md#examples\n") + expect(not routing_problems(root), "a reference-style link was reported") + (root / "AGENTS.md").write_text("[Examples](/WRITING.md#examples)\n") + expect(not routing_problems(root), "a root-relative link was reported") + for hidden_link in ( + "- Code examples:\n\n {}\n", + "```\n{}\n```\n", + "`{}`\n", + "\n", + "!{}\n", + "\\{}\n", + ): + link = "[Examples](WRITING.md#examples)" + (root / "AGENTS.md").write_text(hidden_link.format(link)) + expect(bool(routing_problems(root)), f"a link in {hidden_link!r} passed") + (root / "AGENTS.md").write_text("[Examples](WRITING.md#examples)\n") + (root / "AGENTS.md").unlink() + gone = routing_problems(root) + expect( + bool(gone) and "is missing" in gone[0], "a missing AGENTS.md was unnamed" + ) + (root / "AGENTS.md").write_text("[Examples](WRITING.md#examples)\n") + labels = "\n".join(label + " x" for label in PORT_LABELS) + wide = "```text\n" + "x" * 81 + "\n```" + head = f"### In this repository\n\n{labels}\n\n" + block = head + f"Bad, over 80:\n\n{wide}\n\nGood, x:\n" + expect(not port_block_problems(block), "a complete port block was reported") + twice = block + f"\nBad, over 80:\n\n{wide}\n" + expect(bool(port_block_problems(twice)), "a second Bad example passed") + missing = block.replace(PORT_LABELS[2], "- **Other:**") + expect( + bool(port_block_problems(missing)), "a port block missing a label passed" + ) + fenced = "## Examples\n\n```sh\n# a comment\n```\n\n" + shared + (root / "WRITING.md").write_text(fenced) + expect(not routing_problems(root), "a fenced comment read as a heading") + nested = tomllib.loads( + 'allow_ceiling = 0\n[[exclude]]\nglob = "a"\nreason = "r"\n' + 'doc_comments = ["a"]\n' + ) + nested["markdown"] = ["a"] + expect( + any("doc_comments" in p for p in config_problems(nested, ["a"])), + "a key nested under [[exclude]] passed", + ) + del nested["exclude"][0]["doc_comments"] + expect(not config_problems(nested, ["a"]), "a clean [[exclude]] was reported") + nested["exclude"][0]["reason"] = " " + expect( + any("no reason" in p for p in config_problems(nested, ["a"])), + "an exclusion without a reason passed", + ) + table = {"exclude": {"glob": "a", "reason": "r"}} + expect(bool(type_problems(table)), "[exclude] written as one table passed") + typed = {"width": "80", "markdown": "**/*.md"} + expect(len(type_problems(typed)) == 2, "a string width or glob list passed") + listed = {"allow": [{"path": ["a"], "line": "b", "reason": "c"}]} + expect(bool(type_problems(listed)), "a list where a string belongs passed") + absent = {"copy": "gone.toml", "root": "fmt.toml", "key": "max_width"} + expect( + bool(copy_problems(root, {"formatter_copies": [absent]})), + "a missing formatter copy passed", + ) + expect(not type_problems({"width": 80, "markdown": ["a"]}), "good types failed") + misnamed: dict[str, t.Any] = { + "markdown": ["README.md"], + "exclude": [{"path": "README.md", "reason": "r"}], + "allow": [{"file": "README.md", "line": wide, "reason": "r"}], + "formatter_copies": [{"copy": "copy.toml", "root": "fmt.toml"}], + "allow_ceiling": 1, + } + found = " ".join(config_problems(misnamed, ["README.md"])) + expect( + all(f"has no {key}" in found for key in ("glob", "path", "key")), + "an entry missing a key the check reads passed", + ) + try: + check(root, misnamed, ["README.md"]) + copy_problems(root, misnamed) + except KeyError as error: + expect(False, f"an entry missing {error} crashed the check") + entry = {"path": "a", "line": "b", "reason": "c"} + ceiling: dict[str, t.Any] = {"allow": [entry, entry], "allow_ceiling": 1} + grown = config_problems(ceiling, ["a"]) + expect( + bool(grown) and "delete the new entry" in grown[0], + "a grown allow list passed or was not told to delete the new entry", + ) + ceiling = {"allow": [entry], "allow_ceiling": 2} + expect(bool(config_problems(ceiling, ["a"])), "a shrunk list kept its ceiling") + ceiling = {"allow": [entry], "allow_ceiling": 1} + expect(not config_problems(ceiling, ["a"]), "an exact ceiling was reported") + with tempfile.TemporaryDirectory() as scratch: + top = pathlib.Path(scratch) + git = ["git", "-C", scratch, "-c", "init.defaultBranch=main"] + subprocess.run([*git, "init", "-q"], check=True) + (top / ".github").mkdir() + (top / "README.md").write_text("x\n") + subprocess.run([*git, "add", "README.md"], check=True) + (top / "LINKED.md").symlink_to("README.md") + subprocess.run([*git, "add", "LINKED.md"], check=True) + expect( + tracked_files(top) == ["README.md"], + "a tracked symbolic link was read beside its target", + ) + + def run(config_text: str) -> str: + (top / CONFIG).write_text(config_text) + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = main(["--root", scratch]) + expect(code == 1, "a broken config passed") + return out.getvalue() + + expect( + "fix the config first" in run('width = "80"\n'), + "a string width reached the width pass", + ) + expect( + "cannot read the config" in run("width = [\n"), + "a config that is not TOML went unreported", + ) + misspelled = ( + 'markdwn = ["*.md"]\nallow_ceiling = 1\n[[allow]]\n' + 'path = "README.md"\nline = "x"\nreason = "r"\n' + ) + expect( + "0 stale allow entries" in run(misspelled), + "a misspelled glob key made an allow entry look stale", + ) + allowed = "print(total, " + "x" * 80 + ")" + edited = allowed.replace("total", "totals") + (top / "README.md").write_text(f"```py\n{'y' * 90}\n{edited}\n```\n") + config_text = ( + 'markdown = ["README.md"]\nallow_ceiling = 1\n[[allow]]\n' + f'path = "README.md"\nline = "{allowed}"\nreason = "r"\n' + ) + advice = run(config_text) + expect( + advice.count(f'line = "{edited}"') == 1 + and f'[[allow]]\npath = "README.md"\nline = "{"y" * 90}"' in advice, + "an edited allowed line was paired with another wide line, or the " + "new wide line got no entry", + ) + expect( + "\nREADME.md: an allowed line changed; its entry says why it stays" + in "\n" + advice + and "give it an entry of its own" in advice + and "Fix a wide line by changing the code" in advice, + "an edited allowed line was told to change its code", + ) + (top / "README.md").write_text(f"```go\n// Output: {'o' * 80}\n```\n") + marked = run('markdown = ["README.md"]\nallow_ceiling = 0\n') + expect( + marked.split("\n[[allow]]")[0].count("[[allow]]") == 1 + and "#what-never-breaks); add the line to [[allow]]" in marked, + "a marker line's advice named [[allow]] twice", + ) + (top / "README.md").write_text(f"```py\nprint({'w' * 80})\n```\n") + plain = "\n" + run('markdown = ["README.md"]\nallow_ceiling = 0\n') + expect( + "\nFix a wide line by changing the code" in plain + and "\nIf no change keeps the example clear, add the line to" in plain, + "a wide line's advice and its allow fallback were not on lines of " + "their own", + ) + with tempfile.TemporaryDirectory() as other: + repo = pathlib.Path(other) + sub = ["git", "-C", other, "-c", "user.name=t", "-c", "user.email=t@t"] + subprocess.run([*sub, "init", "-q"], check=True) + allowed_line = "print(total, " + "x" * 80 + ")" + (repo / "README.md").write_text(f"```py\n{allowed_line}\n```\n") + subprocess.run([*sub, "add", "README.md"], check=True) + subprocess.run([*sub, "commit", "-qm", "x"], check=True) + # The allowed line is shortened to fit, and a new wide line that + # resembles it is added: the entry goes, the new line gets its own. + fits = "print(total, " + "x" * 40 + ")" + new = "print(totals, " + "x" * 80 + ")" + (repo / "README.md").write_text(f"```py\n{fits}\n{new}\n```\n") + (repo / ".github").mkdir() + (repo / CONFIG).write_text( + 'markdown = ["README.md"]\nallow_ceiling = 1\n[[allow]]\n' + f'path = "README.md"\nline = "{allowed_line}"\nreason = "r"\n' + ) + out = io.StringIO() + with contextlib.redirect_stdout(out): + main(["--root", other]) + told = out.getvalue() + expect( + "an allowed line changed" not in told + and f"names a line shortened to fit in 80: '{allowed_line}'" in told + and "Delete the stale allow entry for README.md." in told + and "Set allow_ceiling to 0 plus one" in told, + "a new wide line beside a shortened allowed line was taken for " + "its edit", + ) + # A short similar line already committed is not a shortening: an + # edit to the allowed line still takes its entry. + (repo / "README.md").write_text(f"```py\n{fits}\n{allowed_line}\n```\n") + subprocess.run([*sub, "commit", "-qam", "y"], check=True) + edited_line = allowed_line.replace("total", "totals") + (repo / "README.md").write_text(f"```py\n{fits}\n{edited_line}\n```\n") + out = io.StringIO() + with contextlib.redirect_stdout(out): + main(["--root", other]) + expect( + "an allowed line changed" in out.getvalue(), + "an edited allowed line beside a committed short line lost its entry", + ) + (top / "lib.rs").write_text("/// ```console\n/// $ ls `\n/// -l\n/// ```\n") + subprocess.run([*git, "add", "lib.rs"], check=True) + shells = run('doc_comments = ["lib.rs"]\nallow_ceiling = 0\n') + expect( + "lib.rs:2: this `$ ` command is continued with a backtick" in shells, + "a doc-comment console fence continued the other shell's way passed", + ) + subprocess.run([*git, "rm", "-q", "--cached", "lib.rs"], check=True) + (top / "lib.rs").unlink() + labelled = f"session.name(); // → {'d' * 80}" + (top / "README.md").write_text(f"```java\n{labelled}\n```\n") + told = run('markdown = ["README.md"]\nallow_ceiling = 0\n') + expect( + "Keep the code and its marker on one line" in told + and told.split("\n[[allow]]")[0].count("[[allow]]") == 1, + "a trailing marker line's advice named [[allow]] twice", + ) + output = f"// Output: {'o' * 80}" + (top / "README.md").write_text(f"```go\n{edited}\n{output}\n```\n") + marked = run( + 'markdown = ["README.md"]\nallow_ceiling = 1\n[[allow]]\n' + f'path = "README.md"\nline = "{allowed}"\nreason = "r"\n' + ) + expect( + "#what-never-breaks); add the first other wide line to [[allow]]" in marked, + "a marker line after an edited allowed line named [[allow]] twice", + ) + second = "print(count, " + "z" * 80 + ")" + moved = second.replace("count", "counts") + (top / "README.md").write_text(f"```py\n{edited}\n{moved}\n```\n") + both = run( + 'markdown = ["README.md"]\nallow_ceiling = 2\n[[allow]]\n' + f'path = "README.md"\nline = "{allowed}"\nreason = "r"\n' + f'[[allow]]\npath = "README.md"\nline = "{second}"\nreason = "r"\n' + ) + expect( + "[[allow]]" not in both + and f'line = "{moved}"' in both + and "allow_ceiling stays 2" in both, + "two allowed lines edited in one file were not both paired", + ) + (top / "notes.md").write_text("x\n") + subprocess.run([*git, "add", "notes.md"], check=True) + (top / "README.md").write_text(f"```py\n{allowed}\n```\n") + unread = run( + 'markdown = ["notes.md"]\nallow_ceiling = 1\n[[allow]]\n' + f'path = "README.md"\nline = "{allowed}"\nreason = "r"\n' + ) + expect( + "no longer reads it as wide" in unread and "Delete" not in unread, + "an allowed line a glob change hid was offered for deletion", + ) + (top / "README.md").write_text("```py\nprint(1)\n```\n") + fits = run( + 'markdown = ["README.md"]\nallow_ceiling = 1\n[[allow]]\n' + 'path = "README.md"\nline = "print(1)"\nreason = "r"\n' + ) + expect( + "Delete the stale allow entry for README.md" in fits + and "names a line that now fits in 80" in fits + and "no longer reads" not in fits, + "an allowed line that now fits was not offered for deletion", + ) + (top / "README.md").write_text(f"```py\n{allowed}\n```\n") + (top / "examples").mkdir() + (top / "examples" / "new.txt").write_text("x\n") + expect( + "examples/new.txt: untracked, under examples/ but selected by no glob; " + '`git add` it, then add "examples/new.txt" to sources' + in run('markdown = ["README.md"]\nallow_ceiling = 0\nwidth = 1\n'), + "an untracked, unselected example file went unnamed", + ) + (top / "README.md").write_text("```console\n$ ls `\n -l\n```\n") + expect( + "is continued with a backtick" + in run('markdown = ["README.md"]\nallow_ceiling = 0\n'), + "a run left a `$ ` command continued with a backtick unreported", + ) + print("self-test:", "failed" if failures else "ok") + return 1 if failures else 0 + + +def main(argv: list[str] | None = None) -> int: + """Check the repository, or run the self-test.""" + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--self-test", action="store_true", help="prove the checker can fail" + ) + parser.add_argument( + "--digest", + action="store_true", + help="print digests of this checker and the shared WRITING.md section", + ) + parser.add_argument( + "--root", type=pathlib.Path, help="repository root (default: git toplevel)" + ) + args = parser.parse_args(argv) + if args.self_test: + return self_test() + root = args.root or pathlib.Path( + subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + check=True, + capture_output=True, + text=True, + ).stdout.strip() + ) + if args.digest: + for line in digests(root): + print(line) + return 0 + try: + config = tomllib.loads((root / CONFIG).read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError) as error: + print(f"{CONFIG}: cannot read the config: {error}") + print("1 setup problem; fix the config first.") + return 1 + wrong = type_problems(config) + if wrong: + for problem in wrong: + print(f"{CONFIG}: {problem}") + print(f"{plural(len(wrong), 'setup problem')}; fix the config first.") + return 1 + files = tracked_files(root) + writing = writing_path(root) + # Each problem names the file to change; routing problems name their own. + setup = config_problems(config, files) + problems = ( + [f"{CONFIG}: {problem}" for problem in setup] + + copy_problems(root, config) + + digest_problems(root, config) + + marker_problems(root) + + routing_problems(root) + ) + new = untracked_matches(config, tracked_files(root, others=True)) + chosen = [path for path in new if selected(config, path)] + shown = ", ".join(chosen[:3]) + (", ..." if len(chosen) > 3 else "") + notice = ( + f"Not checked until `git add`: {plural(len(chosen), 'untracked file')} " + f"the config selects ({shown})." + if chosen + else "" + ) + # An untracked file does not fail the run, but its wide lines are shown + # so a new example can be fixed before it is added. + pending, _ = check(root, config, chosen) if chosen and not setup else ([], []) + if pending: + they = "it fails" if len(chosen) == 1 else "they fail" + notice += f" Once added, {they} on {plural(len(pending), 'wide line')}: " + ( + "; ".join(str(finding) for finding in pending[:3]) + ) + notice = "\n".join( + [notice] * bool(notice) + + [ + f"{path}: untracked, under examples/ but selected by no glob; `git add` " + "it, then " + select_advice(path) + for path in new + if path not in chosen + ] + ) + bad_path = None + if writing: + where = writing.relative_to(root).as_posix() + text = writing.read_text(encoding="utf-8") + block = port_block_problems(text) + problems += [f"{where}: {problem}" for problem in block] + bad_path = where if any("Bad example fits" in p for p in block) else None + # A config problem such as a misspelled glob key would make allow entries + # look stale, so the width pass waits until the config is right. + findings, stale = check(root, config, files) if not setup else ([], []) + problems += prompt_problems(root, config, files) if not setup else [] + annotate = os.environ.get("GITHUB_ACTIONS") == "true" + for problem in problems: + print(problem) + for finding in findings: + print(finding) + if annotate: + print( + f"::error file={finding.path},line={finding.number}::" + f"{finding.columns} columns > {finding.width}" + ) + # A line still in its file is hidden only while it is still wide; one that + # now fits needs its entry deleted. + width, tab_width = config.get("width", 80), config.get("tab_width", 4) + present = [entry for entry in stale if entry["line"] in source_lines(root, entry)] + hidden = [ + entry + for entry in present + if columns(entry["line"].expandtabs(tab_width)) > width + ] + absent = [entry for entry in stale if entry not in present] + shortened = shortened_entries(root, absent, width, tab_width) if absent else [] + for entry in stale: + how = ( + "names a line the check no longer reads" + if entry in hidden + else f"names a line that now fits in {width}" + if entry in present + else f"names a line shortened to fit in {width}" + if entry in shortened + else "matches no line" + ) + print( + f"{CONFIG}: allow entry for {entry['path']} {how}: {quoted(entry['line'])}" + ) + if problems or findings or stale: + page = writing or writing_file(root) + rules = page.relative_to(root).as_posix() if page else "WRITING.md" + print( + f"{plural(len(problems), 'problem')}, " + f"{plural(len(findings), 'wide line')}, " + f"{plural(len(stale), 'stale allow entry', 'stale allow entries')}." + ) + if setup: + print(f"The width check runs once the problems in {CONFIG} are fixed.") + edited = "" + # Name an entry by its line when its file has two allow entries. + allowed = [e for e in config.get("allow", []) if isinstance(e, dict)] + if findings: + first = findings[0] + many = len(findings) > 1 + pairs = edit_pairs(findings, stale, bad_path, shortened) + if pairs: + # An allowed line changed: its entry already says why it stays + # whole, so it takes the new text. + first, edited = pairs[0][0], pairs[0][0].path + named = entry_name(pairs[0][1], allowed) + print( + f"{edited}: an allowed line changed; its entry says why it stays " + f"whole. Put its new text in the stale entry for {named} (if the " + "line is new instead, give it an entry of its own):" + ) + else: + lead = ( + f"The first of {len(findings)}, as an entry:" + if many + else "As an entry:" + ) + advice = fix_advice(first, rules) + if "[[allow]]" in advice and not advice.startswith(kept_whole(rules)): + # The advice already says when to allow the line. + print(advice) + print(lead) + elif advice.startswith(kept_whole(rules)): + # The rule itself says to allow it; say [[allow]] once. + print( + f"{kept_whole(rules)}; add the line to [[allow]] in " + f"{CONFIG} with its reason. {lead}" + ) + else: + print(advice) + print( + "If no change keeps the example clear, add the line to " + f"[[allow]] in {CONFIG} with a reason. {lead}" + ) + hint = allow_hint(first) + print(hint.splitlines()[2] if edited else hint) + for finding, entry in pairs[1:]: + print(f"In the stale entry for {entry_name(entry, allowed)}, put:") + print(allow_hint(finding).splitlines()[2]) + paired = [finding for finding, _ in pairs] + others = [finding for finding in findings if finding not in paired] + if edited and others: + advice = fix_advice(others[0], rules) + if advice.startswith(kept_whole(rules)): + print( + f"{kept_whole(rules)}; add the first other wide line to " + f"[[allow]] in {CONFIG} with its reason:" + ) + else: + print(advice) + print( + "If no change keeps the example clear, add the first " + f"other wide line to [[allow]] in {CONFIG}:" + ) + print(allow_hint(others[0])) + if findings or stale: + count = len(config.get("allow", [])) + for hint in ceiling_hints( + findings, stale, count, bad_path, hidden, allowed, shortened + ): + if not (edited and "an allowed line changed" in hint): + print(hint) + if notice: + print(notice) + return 1 + read = [g for key in GLOB_KEYS for g in config.get(key, [])] + skip = [entry["glob"] for entry in entries(config, "exclude")] + count = sum(matches(path, read) and not matches(path, skip) for path in files) + print(f"{count} files checked; no example is over {config.get('width', 80)}.") + if notice: + print(notice) + return 0 + + +if __name__ == "__main__": + sys.exit(main())