Skip to content

adopt-project.py and five other tools escape a name their stdout cannot encode, so plan on 日本.md under a cp1252 stdout (strict, or Windows' default surrogateescape) no longer exits 1 after writing the plan (#214) - #246

Merged
brettheap merged 6 commits into
mainfrom
fix/plan-report-stdout-encoding
Oct 11, 2026

Conversation

@brettheap

@brettheap brettheap commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Lane: openreposhape-5 (openRepoShape-5)
Claim: #214 (comment)

Closes #214.

adopt-project.py plan wrote the plan, then raised UnicodeEncodeError in its terminal report for a name its stdout could not encode, and exited 1 before plan written to and the questions it exists to ask. On Windows that happens on any stdout that is not the console itself: a file, a pipe to tee, CI, an agent running the tool. Python gives such a stdout the ANSI code page (cp1252 on a Western-European install), which has no 日本. It also gives it the surrogateescape error handler, which CPython uses for EVERY Windows stdout by default. config_get_stdio_errors() in Python/initconfig.c ends /* On Windows, always use surrogateescape by default */ return L"surrogateescape"; in 3.9, 3.12 and 3.13, and Doc/c-api/init_config.rst says the same ("Default error handler: On Windows: use "surrogateescape""). That handler covers only U+DC80..U+DCFF, so it raises on 日本 just as strict does.

What was wrong

Reproduced on origin/main 20e13f0 with the issue's method, PYTHONIOENCODING=cp1252:strict. Under utf-8:strict every run exits 0, except the doctor's NOT A SHAPE ROOT, which exits 2 as designed. Under cp1252 the raise comes at the first plain print of a path or a person's text, in six tools and the root bootstrap shim:

tool what holds the name line on 20e13f0
adopt-project.py plan a source file 日本.md; the --source path; the --out path 1588, 1497, plan written to
adopt-project.py check the source 2141
shape-doctor.py the --root path 3690
update-shape.py check the --root path 1245
scaffold-project.py --name, --elected-by; a --work-dir or --local-remote-dir path 602, 620
scripts/family.py init --name, --created-by; an --into or --local-remote-dir path 563, 567
scripts/bump-leg.py (--dry-run) the project's display name 706
bootstrap (root shim) the directory it is run from templates/assembly-root/scripts/bootstrap.py:400

Under cp1252:surrogateescape, the Windows default, the same prints raise. This was measured on this branch's first commit 3006303, whose helper changed only strict and so left that case exactly as on main.

The fix

shape_materialize.escape_what_stdout_cannot_encode() is the first statement of each of the six tools' main(), and the bootstrap shim calls it before it hands over to the canonical bootstrap with runpy. It changes two error handlers and no others, and it never changes the encoding:

  • strict becomes backslashreplace, the handler Python already gives stderr. strict is what a POSIX locale gives stdout when it is not C or POSIX and not one Python coerces C to (en_US.UTF-8, but not C.UTF-8, the usual container locale, which gives surrogateescape), and what PYTHONIOENCODING=<codec> gives when it names no handler.
  • surrogateescape becomes openreposhape.surrogateescape-then-backslashreplace, a handler registered with codecs.register_error by the first call that needs it and by no other. It writes a surrogate-escaped byte back as that byte, which is surrogateescape's own rule, so a name that is not UTF-8 still comes out as its own bytes. Anything else the encoding cannot hold it writes in codecs.backslashreplace_errors' own spelling. surrogateescape is the Windows default, and UTF-8 mode and the C, POSIX and C.UTF-8 locales give it too.
  • Every other handler (replace, backslashreplace, ignore, xmlcharrefreplace, namereplace, surrogatepass, or one an operator set) is left as it was.

Each replacement writes exactly the bytes the handler it replaces wrote, and differs only where that handler raised. So nothing that printed before prints differently. On a cp1252 stdout 日本.md prints as \u65e5\u672c.md under either handler, and plan exits 0 with its whole report. stderr is untouched. The helper lives in shape_materialize.py, not repo_shape.py, because repo_shape.py is a digested shape copy, and a helper no project calls would put an upstream-changed row in front of every project. The bootstrap shim at the repository root is not a shape copy: no copy list and no shape-pin.yaml names it, and projects hold templates/*/scripts/bootstrap.py instead.

Why this and not flattening each name where it is printed (the issue's other candidate):

Evidence

  • tests/test_stdout_encoding.py, 48 tests.

    • Every run is made under cp1252:surrogateescape (the Windows default), cp1252:strict and utf-8:strict. Output is read as bytes and decoded strictly in the encoding the run was given.
    • plan runs on a source holding 日本.md (answered, in the table) and 東京.ipynb (asked, in the question list).
    • check, the doctor, update-shape.py check, bump-leg.py --dry-run, the scaffold's and family.py init's dry runs, and the bootstrap shim each exit alike under all three, with the name shown.
    • plan --out writes into a directory whose name holds byte 0xff, under utf-8:surrogateescape and cp1252:surrogateescape. The raw byte comes out raw, and in the cp1252 run 日本.md is escaped in the same report. This one runs on POSIX filesystems only, and skips where a name that is not UTF-8 cannot be made.
    • In process: a surrogateescape stream in utf-8 and in cp1252 keeps the raw byte, escapes the rest and keeps its encoding, and a second call changes nothing. The handler is registered once, under a name starting openreposhape.. Six other handlers are left alone. A reconfigure that raises a plain ValueError or a plain OSError, a closed stream, a stream with no reconfigure, and no stdout at all each fail nothing.
    • The handler's boundaries. 17 in-process cases, in cp1252 and in utf-8, each through a TextIOWrapper and through str.encode: a raw byte before and after 日 in one run, the lowest and highest raw bytes, U+DC7F, U+DC00 and U+DC41 under the range, U+DD00 over it, a high surrogate, and one run holding every kind.
    • No PYTHONIOENCODING at all. Two tests give the child none, and take what Python itself chooses for a redirected stdout. A first child reports that stdout's codec and handler. The test skips, with the reason, when the handler is not surrogateescape or the code page holds 日本, so it never fails on a platform it does not test. Then plan runs on 日本.md and 東京.ipynb, its stdout decoded strictly in the probed codec, and exits 0 with the name escaped.
      • POSIX: LC_ALL=C PYTHONUTF8=0, which gives ascii:surrogateescape (PYTHONUTF8=0 because the C locale turns UTF-8 mode on, and 3.15 does everywhere). It runs on Linux, and on macOS when its C locale gives the same.
      • Windows: PYTHONUTF8=0 (the job sets PYTHONUTF8=1), which gives the ANSI code page with surrogateescape. It has not been run on Windows by the author: only this PR's CI runs it. A Windows whose code page holds 日本 (cp932 does) skips it.
    • The tests run on Windows CI too: 日本.md is a legal NTFS name, and PYTHONIOENCODING overrides the job's PYTHONUTF8=1.
  • They fail where the bug is. On 3006303 the 29 tests of round 1 give 12 failed, 17 passed. The failures are every cp1252:surrogateescape run (7 tool runs, plan, and the raw-byte cp1252 case), each with UnicodeEncodeError, plus the three in-process tests that need the new handler. The file as it stands gives 30 failed, 17 passed, 1 skipped there: those 12, the POSIX default test (exit 1, UnicodeEncodeError: 'ascii' codec can't encode characters in position 0-1), and the 17 handler cases, which fail there for want of the handler and so say nothing about their boundaries. The mutants below do. The first test file, 11 tests, failed 10 of 11 on 20e13f0; the utf-8 plan control passed.

  • Mutations. 14 mutants were run against the test file, and 13 were killed:

    • catching only AttributeError, dropping OSError, and dropping ValueError;
    • mapping surrogateescape to plain backslashreplace;
    • leaving surrogateescape alone;
    • registering on every call;
    • not splitting a run of escaped bytes from the characters after it;
    • writing escaped bytes as \udcff;
    • no call in the shim;
    • forcing the encoding to UTF-8;
    • the re-review's three on the handler's boundaries: the backslash stretch running to the end of the codec's span, so a raw byte after an unencodable character in one run comes out as \udcff and not as the byte; U+DC80 left out of the range; and U+DC00..U+DC7F let into it. The first 29 tests let all three through, and the new cases kill them (4, 5 and 8 failures, the other 39 to 43 tests still passing).

    The survivor moves the doctor's call after parse_args, and it is not observable. Before the first report line, only argparse writes to stdout, and only for --help. All 17 --help outputs (7 tools, 10 subcommands) are pure ASCII: all exit 0 under ascii:strict, with no byte above 0x7f. The doctor's prog is fixed, and its help interpolates nothing from the environment. A usage error goes to stderr, which is already backslashreplace.

  • Same bytes where it worked. 17 commands, each run through a main checkout (20e13f0) and through 20e13f0 with this branch's diff applied. Both checkouts were equally dirty, and each checkout's own path was normalised. The commands: plan on four sources, check on three, plan with an --out path holding byte 0xff, adopt-project.py --help, the doctor on a non-shape root, on a project and with --json, update-shape.py check, the scaffold's and family.py init's dry runs, bump-leg.py --dry-run and the bootstrap shim. Each ran under five environments: utf-8:strict, the container's inherited locale (utf-8:surrogateescape), PYTHONUTF8=1, cp1252:strict and cp1252:surrogateescape, for 85 pairs.

    • Every pair in which main did not raise, 64 of them, is byte-identical in exit code, stdout, stderr and plan file.
    • In the 16 pairs where only main raised (UnicodeEncodeError every time), main's partial stdout is a byte prefix of the head's. Those 16 are 8 under cp1252:strict, 7 under cp1252:surrogateescape, and the 0xff --out path under utf-8:strict.
    • In the other 5, a source file named caf\xff.md makes both sides raise in write_lf writing the plan file (see Left out). Stdout and the plan are identical, and stderr is identical once traceback line numbers are masked.
  • Targeted runs. At round 1's head 4d4e844: tests/test_shape_doctor.py 181 passed. In the second stream, 392 passed and 1 skipped: test_stdout_encoding 29, test_adopt_plan 56 (+1 skip, which needs a MedxEHR clone), test_shape_advisory 40, test_update_shape 61, test_family 76, test_bump_leg 23, test_scaffold_e2e 44, test_repo_hygiene 50 and test_cli_reference 13 (no --help text changed). In all, 573 passed, 1 skipped, 0 failed. At round 2's head 2d60c3e, which changes only the test file and the helper's docstring, test_stdout_encoding (48 tests), test_repo_hygiene and test_cli_reference give 110 passed, 1 skipped (the Windows-only test); the other files were not re-run. git diff --check is clean, and no added line holds a host-absolute path or a non-stdlib import.
    At the merge head 164fa16 (main 4835fb4 merged in), git-filter-repo 2.47.0 on PATH, the quick check over test_stdout_encoding (48), test_adopt_plan_escapes (59), test_yaml_flow_quotes (17), test_adopt_plan (57), test_repo_hygiene (50) and test_cli_reference (13) gives 242 passed, 2 skipped (the Windows-only test in test_stdout_encoding, and the MedxEHR-clone test in test_adopt_plan). Then test_shape_doctor gives 181 passed, and test_bump_leg (23), test_update_shape (77), test_family (76) and test_scaffold_e2e (44) give 220 passed. In all 643 passed, 2 skipped, 0 failed. git diff --check HEAD^1 HEAD is clean.
    At the merge head 657abb1 (main 39ac59c merged in), git-filter-repo 2.47.0 on PATH, the quick check over test_stdout_encoding (48), test_update_shape (77), test_update_shape_add (17), test_scaffold_e2e (44), test_repo_hygiene (50), test_cli_reference (13) and test_adopt_plan_escapes (59) gives 307 passed, 1 skipped (the Windows-only test in test_stdout_encoding). Then test_shape_doctor gives 181 passed, and test_bump_leg (23), test_family (76), test_scaffold_leg_path_collisions (104) and test_scaffold_mount_ignored (42) give 245 passed. In all 733 passed, 1 skipped, 0 failed over those 12 files. test_update_shape_add_ignored (update-shape.py apply --add stages the new file with a plain git add, so a path the operator's excludes ignore fails after the copies, the re-pin and the branch are written, nothing is rolled back, and check then exits 0 over a re-pin nobody committed #202's new file, 26 tests) and test_scaffold_ignored_shape_files (15 tests) also pass on 657abb1, 41 passed. git diff --check HEAD^1 HEAD is clean.

Left out

Composition

🤖 Generated with Claude Code

brettheap and others added 2 commits October 11, 2026 17:39
…py, family.py and bump-leg.py escape a name their stdout cannot encode rather than raise on it, so `plan` on a source holding `日本.md` under a cp1252 stdout no longer exits 1 after writing the plan, before `plan written to` and its questions (#214)

WHAT WAS WRONG. `cmd_plan` writes the plan file and then prints its report
with plain `print`s. On a stdout whose encoding cannot hold a name -- on
Windows, any stdout that is not the console itself: a file, a pipe to `tee`,
CI, an agent running the tool, which Python gives the ANSI code page, cp1252
on a Western-European install -- the table raised `UnicodeEncodeError` two
rows in, and `plan` exited 1 with a traceback after the plan was on disk and
before `plan written to` and the questions it exists to ask.

Emulated with `PYTHONIOENCODING=cp1252:strict` on origin/main 20e13f0, the
same raise is at the first plain `print` of a path or a person's text in six
tools; each run exits 0 (the doctor's NOT A SHAPE ROOT, 2) under
`utf-8:strict`:

  adopt-project.py:1588    `plan`'s table (a source file `日本.md`); also
                           its `source` line (a `--source` path) and
                           `plan written to` (an `--out` path)
  adopt-project.py:2141    `check`'s `WHAT WILL HAPPEN, against` (the source)
  shape-doctor.py:3690     `root` (a `--root` path; its rows are ASCII)
  update-shape.py:1245     `check`'s `root` (a `--root` path)
  scaffold-project.py:602  the plan (`--name`, `--elected-by`; :620 a
                           `--work-dir` or `--local-remote-dir` path)
  scripts/family.py:563    `init`'s plan (`--name`, `--created-by`; :567 an
                           `--into` or `--local-remote-dir` path)
  scripts/bump-leg.py:706  `project` (the project's display name)

THE FIX. `shape_materialize.escape_what_stdout_cannot_encode()`, the first
statement of each tool's `main()`: a stdout whose error handler is `strict`
is reconfigured to `backslashreplace`, the handler Python already gives
stderr. `日本.md` prints as `日本.md` on a cp1252 stdout, and `plan`
exits 0 with its whole report.

WHY THIS, AND NOT FLATTENING EACH NAME WHERE IT IS PRINTED (the issue's
other candidate):
- Nothing that printed prints differently. `backslashreplace` writes what
  `strict` writes and differs only where `strict` raises. Checked by running
  main's tools and this head's on the same inputs under `utf-8:strict` and
  under this container's own locale: identical exit, stdout and stderr in
  every pair once each checkout's own path is normalised. The advisory's
  `printable` (ASCII, `?`) would print `??.md` on a UTF-8 terminal that
  shows `日本.md` today, and lose which file it was.
- Smallest: one helper and one call per tool, against nine raising sites in
  six files, and the next `print` somebody adds.
- Composes with #199, whose `_shown()` rewrites the two `print`s in
  `_print_entries` and `plan`'s question list -- the lines a per-site fix
  would rewrite too. This touches neither.

ONLY `strict` IS CHANGED, and the encoding never is. UTF-8 mode and the
POSIX locale give stdout `surrogateescape`, which writes a name that is not
UTF-8 back as its own bytes (this container's stdout is `utf-8
surrogateescape`); switching it would turn that byte into `\udcff` in a run
that works today. So any handler but `strict` is left as it was. stderr is
not touched. The helper is in `shape_materialize.py`, not `repo_shape.py`:
that one is a shape copy every project digests, and a helper no project
calls would put an `upstream-changed` row in front of each of them.

TESTS. tests/test_stdout_encoding.py: `plan` on a source holding `日本.md`
(answered, in the table) and `東京.ipynb` (asked, in the question list),
under cp1252:strict and utf-8:strict, read as bytes and decoded strictly in
the encoding the run was given; `check`, the doctor, `update-shape.py
check`, `bump-leg.py --dry-run` and the scaffold's and `family.py init`'s
dry runs, each under both, exiting alike with the name shown; and the
helper's three promises in process (escapes only what it cannot encode,
leaves `surrogateescape` alone, fails nothing without `reconfigure`). On
origin/main 20e13f0, 10 of the 11 fail -- the utf-8 `plan` control passes
-- each cp1252 run at the line listed above.

LEFT OUT. `scripts/validate-repository-naming.py`, the project validators
and `bootstrap.py` are shape copies and were not changed; `setup-project.py`
already sets its own streams. A stdout set by hand to
`<code page>:surrogateescape` still raises on a name outside the code page:
an operator's explicit handler is theirs. A `plan` follow-up naming a code
file with a non-ASCII name prints git's quoted octal
(`code/"src/\346\227\245\346\234\254.py"`, `git grep -l` under
core.quotepath), a separate defect.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…very Windows stdout by default, now escapes a name it cannot encode as well and still writes a byte that is not UTF-8 back as that byte, so a redirected cp1252 `plan` on `日本.md` exits 0 on real Windows too; the root `bootstrap` shim calls the same helper

WHAT WAS WRONG WITH 3006303. Its guard changed a stdout whose handler is
`strict` and returned for every other one. On Windows a redirected stdout
is never `strict`. CPython gives EVERY Windows stdout the
`surrogateescape` handler by default: `config_get_stdio_errors()` in
`Python/initconfig.c` ends `/* On Windows, always use surrogateescape by
default */ return L"surrogateescape";` (3.9 and 3.12/3.13 alike), and
`Doc/c-api/init_config.rst` says "Default error handler: On Windows: use
"surrogateescape"". A stdout that is not the console gets the locale
encoding, the ANSI code page. So the issue's own case, a file, a pipe to
`tee`, CI or an agent on a Western-European Windows, is
`cp1252:surrogateescape`. That handler covers only U+DC80..U+DCFF, so
`日本.md` still raised, and the helper did nothing. The tests stood in for
Windows with `cp1252:strict`, which Windows never gives by default
(`PYTHONIOENCODING=cp1252` with no handler does, and still works).
Reproduced by the review under `PYTHONIOENCODING=cp1252:surrogateescape`:
`plan` exited 1 in `_print_entries`, unchanged from main.

THE FIX. `escape_what_stdout_cannot_encode()` now changes two handlers and
no others, and never the encoding:
- `strict` becomes `backslashreplace`, as before.
- `surrogateescape` becomes `openreposhape.surrogateescape-then-
  backslashreplace` (`SURROGATEESCAPE_THEN_BACKSLASHREPLACE`), registered
  with `codecs.register_error` by the first call that needs it and by no
  other. It writes a surrogate-escaped byte back as that byte, which is
  surrogateescape's own rule, and anything else the encoding cannot hold
  in `codecs.backslashreplace_errors`' spelling. It is the review's
  prototype, with the run of unencodable characters split into
  escaped-byte and other stretches, so `caf\udcff` followed by `日本`
  writes `caf` 0xff then `\u65e5\u672c`.
- Every other handler (`replace`, `backslashreplace`, `ignore`,
  `xmlcharrefreplace`, `namereplace`, `surrogatepass`, one an operator set)
  is left as it was.
Each replacement writes what the handler it replaces would have written
and differs only where that one raised. In particular the inherited
`utf-8:surrogateescape` of UTF-8 mode and the C locale still writes a name
that is not UTF-8 as its own bytes. `日本.md` prints as `\u65e5\u672c.md` on a
cp1252 stdout under either handler. The same applies to the
`ascii:surrogateescape` of `LC_ALL=C PYTHONUTF8=0` (checked in process).

The root `bootstrap` shim (not a shape copy: no copy list or
`shape-pin.yaml` names it; projects hold `templates/*/scripts/bootstrap.py`)
now imports and calls the helper before it hands over to the canonical
bootstrap with `runpy`. Run in a directory named `日本` under cp1252 it
exited 1 at `print(f"bootstrap: {name} ({root})")`.

TESTS. tests/test_stdout_encoding.py, 29 tests:
- Every tool run is now made under `cp1252:surrogateescape` (the Windows
  default), `cp1252:strict` and `utf-8:strict`. The tool runs are `plan`
  and `check`, the doctor, `update-shape.py check`, `bump-leg.py
  --dry-run`, the scaffold's and `family.py init`'s dry runs, and now the
  `bootstrap` shim.
- `plan --out` into a directory whose name holds byte 0xff, under
  `utf-8:surrogateescape` and `cp1252:surrogateescape`: the raw byte comes
  out raw, and in the cp1252 run `日本.md` is escaped in the same report.
  POSIX filesystems only; skipped where a name that is not UTF-8 cannot be
  made.
- In process: a surrogateescape stream in utf-8 and in cp1252 writes the
  raw byte and escapes the rest, with the encoding kept; a second call
  changes nothing; the handler is registered once, under a name that
  starts `openreposhape.`; six other handlers are left alone.
- `reconfigure` raising a plain `ValueError`, a plain `OSError`, a closed
  stream, a stream with no `reconfigure`, and no stdout fail nothing.
On 3006303 the same file gives 12 failed, 17 passed. The failures are
every cp1252:surrogateescape run, each a `UnicodeEncodeError` (7 tool runs,
`plan`, the raw-byte cp1252 case), and the three in-process tests that
need the new handler.

THE CALL'S PLACE. Moving the doctor's call after `parse_args` changes
nothing anyone can observe, so no test tells the two apart. Before the
first report line, only argparse writes to stdout, and only for `--help`.
Every tool's and subcommand's `--help` is pure ASCII (17 outputs, 7
tools and 10 subcommands, under `ascii:strict`: all exit 0, no byte above
0x7f). The doctor's `prog` is
fixed and its help interpolates nothing from the environment. A usage
error goes to stderr, which is already `backslashreplace`.

CORRECTIONS TO 3006303's MESSAGE (pushed, so not amended):
- "`日本.md` prints as `日本.md`" lost its escapes; it is `\u65e5\u672c.md`.
- "identical exit, stdout and stderr in every pair" holds for every pair
  that completes on main. Where main raised, its partial stdout is a byte
  prefix of the head's.
- "ONLY `strict` IS CHANGED", and the Left-out line that called a
  `<code page>:surrogateescape` stdout an operator's explicit choice, are
  both superseded above. On Windows that handler is the default.

LEFT OUT. A real `bump-leg.py` run, and `update-shape.py apply`, still fail
under a code page that cannot hold the project's display name. Their child
`validate-manifest.py` prints `manifest ok: <name>` with the inherited
stdout and its own default handler. The fix belongs in those two tools'
`run_validator`. `bump-leg.py`'s is the call #230
(fix/decode-git-output-utf8) rewrites, with the opposite decoding choice
(the locale codec with `errors="replace"`), so it is left to a follow-up
that composes with #230.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 11, 2026

Copy link
Copy Markdown

Sorry @brettheap, you've used your own review budget of 250,000 diff characters for the last 7 days.

You can request another review in 1 day and 3 hours by commenting @sourcery-ai review. Upgrade to get a review now.

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-11T20:54Z) @codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@sourcery-ai

sourcery-ai Bot commented Oct 11, 2026

Copy link
Copy Markdown

Reviewer's Guide

This PR prevents affected commands from failing after producing output when stdout cannot encode paths or user-provided names by safely adapting only strict and surrogateescape error handling, while preserving the stream encoding and adding comprehensive cross-platform regression tests.

Sequence diagram for safe stdout encoding in CLI tools

sequenceDiagram
    participant User
    participant ToolMain
    participant StdoutHelper as shape_materialize.escape_what_stdout_cannot_encode
    participant Stdout
    participant Report

    User->>ToolMain: main(argv)
    ToolMain->>StdoutHelper: escape_what_stdout_cannot_encode()
    StdoutHelper->>Stdout: inspect errors
    alt errors is strict
        StdoutHelper->>Stdout: reconfigure(errors=backslashreplace)
    else errors is surrogateescape
        StdoutHelper->>Stdout: reconfigure(errors=openreposhape.surrogateescape-then-backslashreplace)
    else other handler or unavailable stream
        StdoutHelper-->>ToolMain: leave stdout unchanged
    end
    ToolMain->>Report: print path or user-provided name
    Report->>Stdout: encode using original stream encoding
    Stdout-->>User: encoded name or escaped name, command continues
Loading

File-Level Changes

Change Details Files
Added centralized stdout reconfiguration that preserves the selected encoding while preventing unrepresentable names from terminating commands.
  • Map strict output to backslashreplace.
  • Register a repository-scoped handler that preserves surrogateescaped bytes and backslash-escapes other unencodable characters.
  • Ignore unsupported, closed, or unavailable stdout streams and leave all other error handlers unchanged.
scripts/shape_materialize.py
Enabled the stdout-safety helper at the start of each affected command and the repository bootstrap shim.
  • Install the handling before argument parsing and reporting, including help paths.
  • Cover adopt-project, shape-doctor, update-shape, scaffold-project, family, bump-leg, and bootstrap execution.
adopt-project.py
shape-doctor.py
update-shape.py
scaffold-project.py
scripts/family.py
scripts/bump-leg.py
bootstrap
Added regression coverage for encoding failures, byte preservation, handler behavior, and command-wide compatibility.
  • Exercise cp1252 strict and surrogateescape alongside UTF-8 across all affected tools.
  • Verify plans and reports complete with escaped Unicode names and preserve raw surrogateescaped filesystem bytes.
  • Verify registration is idempotent, unrelated handlers are unchanged, reconfiguration failures are harmless, and previously successful output remains compatible.
tests/test_stdout_encoding.py

Assessment against linked issues

Issue Objective Addressed Explanation
#214 Ensure adopt-project.py plan completes successfully after writing the plan when report names cannot be encoded by stdout, displaying those names escaped and still printing the complete report, including plan written to and follow-up questions. ✅
#214 Apply a robust stdout encoding fallback across the relevant tools so unencodable names do not cause UnicodeEncodeError, while preserving the configured stdout encoding and existing behavior for handlers that already work. ✅
#214 Add regression coverage for strict and Windows-like surrogateescape stdout behavior, including the affected plan report and related tools. ✅

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

…ange boundaries are pinned by 17 in-process cases, and `plan` on `日本.md` is run on a real default stdout, with no `PYTHONIOENCODING` at all (POSIX `ascii:surrogateescape` here, Windows' own in CI); the helper's docstring now names `C.UTF-8` as a `surrogateescape` locale

R1, TESTS. The re-review found three mutants of the new error handler that
all 29 tests let through: the stretch it backslash-escapes running to the end
of the codec's span, so a raw byte AFTER an unencodable character in the
same run is written \udcff and not as the byte; U+DC80 left out of the
range of bytes surrogateescape makes; and U+DC00..U+DC7F let into it, which
surrogateescape never makes and stdlib raises on. HANDLER_CASES in
tests/test_stdout_encoding.py is 17 cases, in cp1252 and in utf-8, each run
through a TextIOWrapper after the helper and through `str.encode` by the
handler's name:
- a raw byte before and after `日`, the lowest and highest raw bytes, the
  characters just under (U+DC7F), at the bottom of (U+DC00, and U+DC41,
  which a wrong range writes as `A`) and just over (U+DD00) the range, a high
  surrogate, and one run holding every kind;
- in utf-8, where a run is made of surrogates alone, the same boundaries
  with a raw byte beside U+D800 and beside U+DC7F.
Each mutant was applied in a throwaway worktree and run against the file: the
stretch-to-span-end one fails 4 of the 47 tests that run, the U+DC80 one 5,
the U+DC00 one 8, all in the new cases, and the other 39 to 43 still pass.
On 3006303 the 17 fail too, for want of the handler; the mutants are what
shows they tell a wrong boundary from a right one.

R2, TESTS. No test ran a tool without a `PYTHONIOENCODING`, so none proved
the premise of the first round: what CPython itself gives a redirected
stdout. Now two do, through plan_on_the_default_stdout(), which gives the
child NO `PYTHONIOENCODING` under any spelling and asks Python:
- test_plan_on_the_posix_default_stdout: `LC_ALL=C PYTHONUTF8=0`, which
  gives `ascii:surrogateescape` (`PYTHONUTF8=0` because the C locale turns
  UTF-8 mode on, and 3.15 does everywhere). It runs on Linux, and on macOS
  when its C locale gives the same.
- test_plan_on_the_windows_default_stdout: Windows only, `PYTHONUTF8=0`
  (CI's job sets `PYTHONUTF8=1`, and 3.15 turns UTF-8 mode on by default).
  It has not been run on Windows: only CI runs it.
Before either runs `plan`, a child reports its own stdout's codec and
handler. The test is SKIPPED, with the reason, when the handler is not
surrogateescape or the code page holds `日本` (UTF-8 mode, cp932): never
failed on a platform it does not test. Then `plan` runs on a source holding
`日本.md` and `東京.ipynb`, and its stdout is decoded strictly in the probed
codec, with the name escaped in the table and in the question list. On
3006303 the POSIX one exits 1 with `UnicodeEncodeError: 'ascii' codec can't
encode characters in position 0-1`; on this head it passes. The first test's
body is now plan_arguments() and assert_plan_reported_every_name(), shared;
run_tool_bytes() takes `encoding=None` and `changes`, through
child_environ().

R4, WORDING. The helper's docstring said `strict` is what "a UTF-8 or other
non-C POSIX locale" gives stdout. `C.UTF-8`, the usual container locale, is
a locale Python coerces C to, and gives `surrogateescape`
(`config_get_stdio_errors`, `_Py_IsLocaleCoercionTarget`; observed here:
`LC_ALL=C.UTF-8` is `utf-8 surrogateescape`). `strict` is a POSIX locale that
is none of C, POSIX or such a target, `en_US.UTF-8` for one, with UTF-8 mode
off. The docstring now says so, and lists `C.UTF-8` among what gives
`surrogateescape`. No code changes.

CORRECTIONS TO 4d4e844's MESSAGE (pushed, so not amended):
- Its LEFT OUT paragraph says the child `validate-manifest.py` prints "with
  the inherited stdout". That holds for `update-shape.py`, whose child
  inherits stdout. `bump-leg.py` captures the child's output; its child
  takes the stdout CONFIGURATION from the environment, not the stdout.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-11T21:07Z) Review trail for this PR.

This PR conflicts with #247 (#199) in scripts/shape_materialize.py, where both add a function after write_lf; whichever lands second keeps both. It merges cleanly with main and with #230. Every report is in opensoft/brett-wip handoffs/openRepoShape/attachments/lane-openRepoShape-5/ (review214-report.md, fix214-report.md, rereview214-report.md; fix214b-report.md to follow).

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-11T21:07Z) @codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

brettheap and others added 2 commits October 11, 2026 21:24
…s skipped on Windows before its body runs, where `os.fsdecode` of that name raised `UnicodeDecodeError` and failed tests-windows twice

PR #246's tests-windows job (run 38174834166) failed 2 of 2040 tests,
both parametrizations of test_a_name_that_is_not_utf8_still_comes_out_as_its_own_bytes
([utf-8:surrogateescape] and [cp1252:surrogateescape]), with 1801
passed and 237 skipped:
`parent = os.path.join(os.fsencode(tmp_path), b"b\xffd")` raised
`UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 1`
at `<frozen ntpath>:112`. Windows' `os.fsencode` and `os.fsdecode` are
UTF-8 (PEP 529), which has no byte 0xff, so a bytes path holding it cannot
even be joined there. The docstring already said "POSIX only", but
the only guard was the runtime skip after `os.mkdir`, which is never reached
on Windows. macOS passed because its filesystem refuses the name and the
`os.mkdir` skip fires.

The test now wears `@pytest.mark.skipif(os.name == "nt", ...)`, the same
condition its neighbour test_plan_on_the_posix_default_stdout wears, with a
reason naming the cause. conftest.WINDOWS_SKIP is not used: its reason is
about a POSIX shell and shebang execution, which is not this cause. The
macOS runtime skip and every other line are unchanged.

The rest of the file was read for the same exposure: no other test builds a
bytes path, or calls os.fsencode or os.fsdecode, outside this one. The tests
that hold U+DC80..U+DCFF write them to an in-memory io.BytesIO wrapper or to
`str.encode`, and the file-system tests name directories `日本`, which is
valid UTF-16 and UTF-8.

Run: tests/test_stdout_encoding.py and tests/test_repo_hygiene.py on this
head under Linux, 97 passed, 1 skipped (the Windows-default test).
Not run on Windows or macOS: CI is the check.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fix/plan-report-stdout-encoding (#214)

One conflict, scripts/shape_materialize.py, the single add/add hunk right
after write_lf: #199's write_paths_listing (from main) and this branch's
escape_what_stdout_cannot_encode with SURROGATEESCAPE_THEN_BACKSLASHREPLACE,
_registered_surrogateescape_then_backslashreplace,
_surrogateescape_then_backslashreplace and _is_escaped_byte. Resolved by
keeping both sides whole, this branch's block first and write_paths_listing
after it, two blank lines between. Every other file merged without a conflict
and is taken as main has it. No code was written for the merge.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-5, 2026-10-11T21:34Z) @codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

Reviewed commit: 164fa16c89

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-11T21:39:22.684514Z 164fa16 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

…) into fix/plan-report-stdout-encoding (#214)

One conflict, resolved by keeping both sides: the `from shape_materialize
import (...)` list in update-shape.py. This branch added
`escape_what_stdout_cannot_encode` and main added `force_stage` (#202) in the
same place. Both names are now in the list, in its existing order
(commit_trailers, escape_what_stdout_cannot_encode, force_stage,
lane_trailer_argument, ...), wrapped to 79 columns like its neighbours, so the
list's tail lines re-flow with it. No other file conflicted, and nothing else
was changed.

Lane: openreposhape-5 (openRepoShape-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@brettheap

Copy link
Copy Markdown
Contributor Author

(lane openRepoShape-4, 2026-10-11T22:05:55Z) Landing note: our #233 (#202) landed as 150c41d, and main is now 39ac59c. This PR now conflicts with main in one hunk only: update-shape.py's from shape_materialize import (...) list. Main added force_stage and this PR adds escape_what_stdout_cannot_encode. The resolution is the union. Replace everything between the markers with:

    commit_trailers, escape_what_stdout_cannot_encode, force_stage,
    lane_trailer_argument, readme_shape_lines,

Leave the next line, readme_trailing_shape_line, run, trailer_line,, as it is. Everything else merges by itself. Each tool's main() still calls escape_what_stdout_cannot_encode() first, so the new prints from #233 and #234 are covered. --help is unchanged (render-cli-reference.py --check reports ok).

We applied this in scratch on 39ac59c + 164fa16. tests/test_stdout_encoding.py tests/test_update_shape.py tests/test_update_shape_add_ignored.py tests/test_cli_reference.py: 163 passed, 1 skipped. The patch against this branch is on Eagle (py-bench) at /tmp/claude-1000/-workspace-projects-openRepoShape/83338f75-6338-4d51-9749-89179ecaf55f/scratchpad/l4cf/resolved-246-vs-pr.patch.

@brettheap
brettheap merged commit 06b5204 into main Oct 11, 2026
5 checks passed
@brettheap
brettheap deleted the fix/plan-report-stdout-encoding branch October 11, 2026 22:23
This was referenced Oct 11, 2026
brettheap added a commit that referenced this pull request Oct 12, 2026
…, #252 via #257, #195 via #226, #210 via #219, #211 via #220, #248 via #265, #212 via #223) into fix/adopt-record-submodule-placement

Brings in 06b5204 (#214), 60d08a1 (#217), 2727b08 (#209), 4b2add8 (#252),
aaa40eb (#195), d89fffa (#210), eb6df75 (#211), c3f4987 (#248) and
5751460 (#212) on top of 39ac59c. No rebase. 5751460 landed while the
merge of c3f4987 was being checked, and it changes adopt-project.py,
scripts/shape_materialize.py and tests/test_adopt_submodules.py, as this
branch does, so this one merge takes it too.

No conflicts. Both sides change adopt-project.py, scripts/repo_shape.py,
scripts/shape_materialize.py, shape-doctor.py, tests/test_adopt_submodules.py
and tests/test_shape_doctor.py. Every top-level definition only one side
changed is that side's text in the merge, byte for byte. The one thing
both changed is shape_materialize.py's imports, and both are kept: main's
`codecs` and this branch's `path_spelling_fault` and `PATH_*` codes.
Checked:

- adopt-project.py: main calls `escape_what_stdout_cannot_encode()` first
  in `main` (#214). It makes a `.gitmodules` git's submodule reader cannot
  read a finding where the tip holds no submodule and the history does, or
  where `ignore`, `update` or `branch` has no value (#195). And every path
  a finding names is now `_spelled` (#212). This branch's
  `submodule_record`, `_resolution_text` and the record's preamble are
  unchanged by the merge, and so are `_answered_legs`, `_kept_in_a_leg`,
  `_commit_the_split`, `_refuse_what_check_finds`, `cmd_execute` and `Plan`.
  The record and #195's finding read the kept submodules the same way,
  `_kept_in_a_leg(_answered_legs(...))` over the tip's gitlinks. `execute`
  refuses #195's finding in `_refuse_what_check_finds`, before any leg
  exists and before `_commit_the_split` writes the record. So a source
  #195 refuses gets no record, and so does a source whose tip holds no
  submodule.
- scripts/repo_shape.py: main refuses an explicit key in a flow mapping
  and reads an empty flow-mapping value as null (#252). A block scalar's
  record is now a (key, body) pair, and `_BLOCK_SENTINEL` is gone (#248);
  nothing on this branch names it. The record is a block sequence of
  double-quoted scalars, and it reads back through the merged reader with
  0 mismatches (`submodule_record`, then `parse_yaml`, path and
  resolution):
  - 4,525 names, plus 35 more;
  - 240 aimed at #252 and #248 (`? a`, `{a: }`, `|`, `a: |`, a NUL, the
    old sentinel).
- scripts/shape_materialize.py: main adds `escape_what_stdout_cannot_encode`,
  `run()` reading UTF-8 (#217), and `first_and_more(paths, spell)` (#212).
  This branch's `LEG_SEGMENT_REASONS` and `_spelling_reason` are untouched.
- shape-doctor.py: main adds the `manifest kinds` finding for a
  `family.yaml` beside `project.yaml` (#211), and the stdout escaping.
  This branch's placement row and `recorded_submodules` are untouched.
- tests: #212 respells the finding text that the earlier tests in
  test_adopt_submodules.py assert. None of this branch's tests quotes a
  finding.
- No top-level name is bound twice in adopt-project.py,
  scaffold-project.py, scripts/repo_shape.py, scripts/shape_materialize.py,
  shape-doctor.py, validate-manifest.py or update-shape.py. Each merged
  module's names are the base's, plus what either side added, minus what
  either side removed. No side added a name the other side also added,
  in the tests either.
- AGENTS.md is untouched by main: 472 lines, under the cap of 472.
- The branch's diff against main is still #189 alone: 12 files, +1670
  -156, file by file the same as against 39ac59c.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…, #209, #252, #195, #210, #211, #248, #212, #251 and #206 merge with one hunk, the materializer import list, kept on both sides, and `_entry_findings` still hands `_leg_findings` the tree and ends with the root-symlink findings

origin/main moved 39ac59c -> 60588d5: 06b5204 (#214 via #246), 60d08a1
(#217 via #230), 2727b08 (#209 via #218), 4b2add8 (#252 via #257),
aaa40eb (#195 via #226), d89fffa (#210 via #219), eb6df75 (#211 via
#220), c3f4987 (#248 via #265), 5751460 (#212 via #223), 23e16ae (#251
via #271) and 60588d5 (#206; its subject names no pull request).

Conflict, resolved by keeping both sides:
- adopt-project.py, the `shape_materialize` import list. This branch
  adds BOOTSTRAP, SYMLINK_HOPS and template_files; #214 adds
  escape_what_stdout_cannot_encode. The resolution is the union.

Auto-merged: scripts/shape_materialize.py, where #214's escaping, #217's
UTF-8 `run()` and #212's `first_and_more(paths, spell=None)` sit apart
from this branch's #183 section, and adopt-project.py's other hunks.
#195 adds `import os` at the line where this branch added it, and the
merge keeps it once.

Checked on the merged tree:
- `_entry_findings` still calls `_leg_findings(plan, tree)` and ends
  with `_root_symlink_findings(plan, source, tree)`. `main` still calls
  `escape_what_stdout_cannot_encode()` first.
- No top-level name is bound twice in adopt-project.py,
  scaffold-project.py, scripts/repo_shape.py or
  scripts/shape_materialize.py, and none is loaded but bound nowhere
  (AST). `first_and_more` gives its new `spell` a default, so this
  branch's call is unchanged.
- Both sides are intact by +/- line sets: each side's change against the
  base reappears against the other side, apart from the import hunk and
  the shared `import os`.
- #212 says this branch's `plan-root-symlink` names its paths raw, and
  that whichever pull request lands second should spell them. That was
  04792d2. Since d11cf9f every path the finding names goes through
  `_spelled`.

One clash the textual merge does not show is fixed in the next commit:
#217's tests/test_git_output_decoding.py refuses a shipped text-mode
`subprocess` call that names no codec, and this branch's
`_checks_out_symlinks` probe names none.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
brettheap added a commit that referenced this pull request Oct 12, 2026
…e-notice

It merged with no conflict. Main brings nine commits since 39ac59c:

- 06b5204, six tools escape a name their stdout cannot encode (#214
  via #246), with tests/test_stdout_encoding.py;
- 60d08a1, `shape_materialize.run()` and 26 other git and gh calls
  read the child's output as UTF-8 (#217 via #230), with
  tests/test_git_output_decoding.py;
- 2727b08, update-shape.py's `gh pr create --base` (#209 via #218),
  with tests/test_update_shape_pr_base.py;
- 4b2add8, scripts/repo_shape.py's flow mappings (#252 via #257), with
  tests/test_yaml_flow_mappings.py;
- aaa40eb, adopt-project.py refuses a `.gitmodules` the history's
  gitlinks make unreadable (#195 via #226), with
  tests/test_adopt_history_gitlinks.py;
- d89fffa, family.py `bump` to the current pin (#210 via #219);
- eb6df75, shape-doctor.py's family.yaml beside a project.yaml (#211
  via #220);
- c3f4987, scripts/repo_shape.py's block-scalar records (#248 via
  #265), with tests/test_yaml_block_scalar_keys.py;
- 5751460, adopt-project.py spells every path a FINDING or a refusal
  names (#212 via #223), with tests/test_adopt_finding_spelling.py.

Three files changed on both sides: adopt-project.py,
tests/test_adopt_ignored_shape_files.py and tests/test_adopt_verify.py.
In each, `git diff -U0` of this merge against 5a8b649 equals main's
diff against 39ac59c, and this merge against 5751460 equals the
branch's diff against 39ac59c, apart from the hunk headers. Neither
side's lines are changed.

How main's changes meet this branch's failure handling:

- Every definition the late failures go through is byte-identical to
  5a8b649's: LATE_FAILURES, the notice (`_nothing_rolled_back`),
  `_failed_loudly`, `_report_late`, the three builders' handlers,
  `_verify` and `cmd_execute` among them. Three definitions differ, each
  by main's hunk alone.
- `main()` gains `escape_what_stdout_cannot_encode()` as its first
  statement (06b5204). The branch writes every REFUSED block and notice
  to stderr, which Python opens with `backslashreplace` whatever the
  locale.
- `run()` names its codec (60d08a1). Output that is not UTF-8 now
  reaches the branch's handlers as `CommandFailed`, not as
  `UnicodeDecodeError`. LATE_FAILURES keeps `UnicodeError`, because
  `git_out` in scripts/repo_shape.py still decodes strictly.
- `Source.registered_submodules` reads the history (aaa40eb). Its one
  caller in `execute` is `_refuse_what_check_finds`, before
  `_create_leg_remotes`, so the new refusal comes before any leg
  repository exists and needs no notice.
- 5751460 spells the paths of the verification's findings. The
  branch's footer and notice follow those findings unchanged.

No top-level name is bound twice in adopt-project.py,
scaffold-project.py, scripts/repo_shape.py or
scripts/shape_materialize.py, and a `symtable` check finds no name
they read that nothing binds. On the merged tree, ten test files give
439 passed and 1 skipped (Windows' own stdout default): the two shared
ones, main's four new ones that read adopt-project.py
(finding_spelling, history_gitlinks, stdout_encoding,
git_output_decoding), and test_adopt_late_failure,
test_adopt_filter_repo_failure, test_adopt_execute_refusals and
test_adopt_plan_escapes.

Lane: openreposhape-1 (openRepoShape-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant