diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 7c0edbe84a..b9410fba45 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/WRITING.md b/.github/WRITING.md index d1a252ee40..f7747cb06f 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 diff --git a/.github/example-width.toml b/.github/example-width.toml new file mode 100644 index 0000000000..f28e70a0fa --- /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 0c40e0a25e..096e486434 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/AGENTS.md b/AGENTS.md index 79c289585f..267cdf073b 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) diff --git a/MIGRATION b/MIGRATION index cbcf307f24..6e9196666d 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 ) diff --git a/README.md b/README.md index 920af194ff..024c537fda 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', '$'] ``` diff --git a/docs/quickstart.md b/docs/quickstart.md index b8f9188493..1e051fe8ea 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 diff --git a/docs/topics/automation_patterns.md b/docs/topics/automation_patterns.md index ba96411085..5af79a0887 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(@... ...) diff --git a/docs/topics/context_managers.md b/docs/topics/context_managers.md index d42b16b8b0..e94db49d1b 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: diff --git a/docs/topics/filtering.md b/docs/topics/filtering.md index 02cbbf4b22..db1d98e60f 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"]) diff --git a/docs/topics/options_and_hooks.md b/docs/topics/options_and_hooks.md index b74f5cc8c4..0e16c2a7d2 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') diff --git a/docs/topics/pane_interaction.md b/docs/topics/pane_interaction.md index 6bde824ec6..0a340cdbcb 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($... ...))) ``` diff --git a/docs/topics/self_location.md b/docs/topics/self_location.md index d06eaeac50..6092a9ef8c 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 diff --git a/docs/topics/traversal.md b/docs/topics/traversal.md index 600fa55f3b..54700df5c9 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() diff --git a/docs/topics/workspace_setup.md b/docs/topics/workspace_setup.md index cb4c7fe523..d6abe5e2ee 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 diff --git a/justfile b/justfile index 05557181a6..85c57156cb 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 0002133c92..cc4f975da8 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 0000000000..b29949890c --- /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()) diff --git a/src/libtmux/_internal/query_list.py b/src/libtmux/_internal/query_list.py index 20aeb407f6..0e8d127ceb 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'} diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 2871547700..f492f957a5 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) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 98ece86fa5..c633b4da08 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 diff --git a/src/libtmux/options.py b/src/libtmux/options.py index ff8bb2239d..e43e8cb516 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: diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index e0c2f59619..2ff65e45d3 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) diff --git a/src/libtmux/pytest_plugin.py b/src/libtmux/pytest_plugin.py index fcc3ce052d..c304f2b42f 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}) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index e650557c34..4d64155978 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'] """ diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 4277052a37..3cbb94a552 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 diff --git a/src/libtmux/window.py b/src/libtmux/window.py index b57db99692..55a32a745a 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