feat: path budget - a file's path stays at 160 characters or fewer; new, rename, move and raw accept refuse more, lint reports Long Paths (#163)
CI / verify (push) Successful in 2m9s
Release / release (push) Successful in 38s

Files changed:
- CHANGES.md
- README.md
- VERSION
- instructions/page-lifecycle.md
- kb/CONTRACT.md
- tools/CONTRACT.md
- tools/chemenu/commands/_util.py
- tools/chemenu/commands/lint.py
- tools/chemenu/commands/new_page.py
- tools/chemenu/commands/page_ops.py
- tools/chemenu/commands/raw_cmd.py
- tools/chemenu/lint_core.py
- tools/chemenu/tests/test_lint.py
- tools/chemenu/tests/test_new_page.py
- tools/chemenu/tests/test_page_ops.py
- tools/chemenu/tests/test_raw_cmd.py
- tools/chemenu/tests/test_titles.py
- tools/chemenu/titles.py
This commit is contained in:
torben committed 2026-09-30 23:08:36 +02:00
1 parent 03743ebbc0
commit 04aebdeccf
18 files changed
+394 -29

No files matched your search

+21
View File
@@ -1,6 +1,7 @@
"""Shared helpers for wikitool subcommands."""
from __future__ import annotations
import os
import re
import sys
from datetime import date
@@ -231,6 +232,26 @@ def check_title(name: str) -> None:
fail(escape(f"'{name}' cannot be a page title: " + "; ".join(problems) + "."))
def path_budget_problem_for(path: Path) -> str | None:
"""`chemenu.titles.path_budget_problem` for a path on disk, measured from the
instance root."""
from chemenu.titles import path_budget_problem
return path_budget_problem(rel_path(path).replace(os.sep, "/"))
def check_path_budget(path: Path, remedy: str) -> None:
"""Fail if `path` is over the path budget (see `chemenu.titles.PATH_BUDGET`).
Runs before any write, for every root. `remedy` is the sentence that tells
the caller what to shorten, since the name comes from a title in one command
and from a file in `incoming/` in another.
"""
problem = path_budget_problem_for(path)
if problem:
fail(escape(f"Cannot write {problem}. {remedy}"))
def check_collision(name: str, *, ignore: Path | None = None) -> None:
"""Fail if a page under kb/ already has a title that collides with `name`.
+5 -1
View File
@@ -79,6 +79,10 @@ __all__ = [
"areas to shard, reported with the split its subtype field would produce, and only "
"when that split puts every resulting area at or under the threshold.",
"Advisory only: source pages sitting in the `unclassified` catalog slot.",
"Advisory only: Long Paths - a file under `kb/` or `raw/` whose path below the "
"instance root is over 160 UTF-16 code units, the budget that keeps a Windows checkout "
"without long paths working. Reported as `{path, length}`; a corpus over the budget breaks "
"no lint run. `wikitool rename` is the fix for a page.",
"Advisory only: quote-limit overages (>2 blockquotes/page).",
"Prints only the sections that found something and always writes the full report to "
"`reports/Lint Report <date>.md` (or `--markdown`), naming the path. `--full` prints "
@@ -101,7 +105,7 @@ __all__ = [
see_also=(
"`wiki-lint` skill - the procedure that runs this",
"`wikitool move --reconcile` - fixes Misplaced and Nested Pages",
"`wikitool rename` - fixes Unportable Titles",
"`wikitool rename` - fixes Unportable Titles and, for a page, Long Paths",
"`wikitool log status` - whether a full lint is due",
),
))
+10 -3
View File
@@ -32,6 +32,7 @@ from chemenu import cli_contract, config, tasks
from chemenu.commands._util import (
check_collision,
check_raw_files_exist,
check_path_budget,
check_target_free,
check_title,
fail,
@@ -381,6 +382,10 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
"well as Linux, whichever platform runs the command and whichever root the type writes "
"to. The rule is `kb/CONTRACT.md` § Titles are identifiers; it is checked on the full "
"title, after `title_prefix`.",
"The target's path below the instance root may be at most 160 characters, counted in "
"UTF-16 code units the way Windows counts MAX_PATH, so a Windows checkout without "
"long paths keeps working. A longer one is refused, for every root, naming the length "
"and how much shorter it has to get.",
"`new` never overwrites: a file already at the target - or one a case-insensitive file "
"system would treat as the same file - is refused for every root, `instructions/` "
"included.",
@@ -429,9 +434,10 @@ def _ensure_tracker_project(page_title: str, *, resume: bool) -> Optional[str]:
cli_contract.Failure(
cause="The title is not a valid file name (forbidden character, control "
"character, reserved name such as `CON` or `Index`, trailing dot or space, empty), "
"collides with another page by case or Unicode normalization, or the target file "
"already exists",
reaction="Not transient - choose another title and retry once. Nothing was created, "
"collides with another page by case or Unicode normalization, the target file "
"already exists, or the target path is over the 160-character path budget",
reaction="Not transient - choose another (for the budget: a shorter) title and retry "
"once. Nothing was created, "
"and for `new project` no tracker project either",
),
cli_contract.Failure(
@@ -609,6 +615,7 @@ def new_page_command(
check_raw_files_exist(frontmatter["raw_files"])
path = target_dir / f"{page_title}.md"
check_path_budget(path, "Choose a shorter title.")
check_target_free(path)
body = _apply_template_variables(
template,
+24 -8
View File
@@ -28,9 +28,11 @@ import typer
from chemenu import cli_contract, config, links
from chemenu.commands._util import (
check_collision,
check_path_budget,
check_target_free,
check_title,
fail,
path_budget_problem_for,
rel_path,
success,
target_conflict,
@@ -240,9 +242,10 @@ def inbound_pages(pages: dict[str, Page], title: str) -> list[str]:
"Each page's rewrite is idempotent, so a re-run as-is is safe. If a write fails "
"midway, nothing is renamed on disk and the error lists what was updated.",
"`--dry-run` lists every page it would change; run it first to see the blast radius.",
"Only `--to` is checked against the title rule. A page whose current title breaks it "
"(`lint`'s Unportable Titles) can always be renamed away from it, and a title that "
"differs from the page's own only by case (`Foo` to `FOO`) is allowed.",
"Only `--to` is checked against the title rule and the path budget (160 UTF-16 code "
"units for the whole path below the instance root). A page whose current title breaks "
"either (`lint`'s Unportable Titles and Long Paths) can always be renamed away from it, "
"and a title that differs from the page's own only by case (`Foo` to `FOO`) is allowed.",
),
failures=(
cli_contract.Failure(
@@ -257,8 +260,10 @@ def inbound_pages(pages: dict[str, Page], title: str) -> list[str]:
cli_contract.Failure(
cause="The `--to` title is already taken - also by a page that differs only in case "
"or Unicode normalization, or by a file in the page's directory - or is not a valid "
"file name (see `kb/CONTRACT.md` § Titles are identifiers)",
reaction="Choose another title and retry once. Checked under `--dry-run` too",
"file name, or would put the page's path over the path budget (see `kb/CONTRACT.md` "
"§ Titles are identifiers)",
reaction="Choose another, or a shorter, title and retry once. Checked under "
"`--dry-run` too",
),
cli_contract.Failure(
cause="A page write failed partway; nothing was renamed on disk",
@@ -317,6 +322,7 @@ def rename_command(
# rename the real run refuses is worse than none.
check_title(new)
check_collision(new, ignore=target.path)
check_path_budget(target.path.parent / f"{new}.md", "Choose a shorter --to title.")
check_target_free(target.path.parent / f"{new}.md", ignore=target.path)
touched: list[str] = []
@@ -538,6 +544,9 @@ def _rmdir_if_emptied(directory: Path) -> bool:
"Moves a page to the directory its type-spec computes for its current frontmatter "
"(`base_dir` + `layout`, the same rule `new` places a page by) - never to a hand-chosen "
"destination; there is no `--to <dir>`.",
"A destination whose path would be over the path budget (160 UTF-16 code units below "
"the instance root) is refused, and `--reconcile` skips such a page and names it, as it "
"does for an occupied destination - `wikitool rename` the page to a shorter title.",
"`--reconcile` applies it corpus-wide: every misplaced page moves in one call, and a "
"second run reports nothing left to do. It fixes `lint`'s `Misplaced Pages` (advisory) "
"and `Nested Pages` (hard) findings.",
@@ -563,8 +572,10 @@ def _rmdir_if_emptied(directory: Path) -> bool:
cli_contract.Failure(
cause="The destination already holds an entry with the same name, or one that "
"differs only in case or Unicode normalization (a pre-existing duplicate-stem "
"collision) - refused rather than silently skipped",
reaction="Resolve the collision, then retry",
"collision), or its path would be over the path budget - refused rather than "
"silently skipped",
reaction="Resolve the collision, or `wikitool rename` the page to a shorter title, "
"then retry",
),
cli_contract.Failure(
cause="`--reconcile` failed partway",
@@ -617,6 +628,10 @@ def move_command(
collisions: list[str] = []
for title, page, target_dir in candidates:
new_path = target_dir / f"{title}.md"
over_budget = path_budget_problem_for(new_path)
if over_budget is not None:
collisions.append(f"{title} ({over_budget})")
continue
clash = target_conflict(new_path)
if clash is not None:
collisions.append(f"{title} (target {rel_path(clash)} already exists)")
@@ -628,7 +643,7 @@ def move_command(
typer.echo(f"[dry-run] would move {rel_path(page.path)} -> {rel_path(new_path)}")
if collisions:
typer.echo("")
typer.echo("Skipped (target already exists) - resolve with `wikitool rename` first:")
typer.echo("Skipped (target already exists, or its path is over the budget) - resolve with `wikitool rename` first:")
for collision in collisions:
typer.echo(f" - {collision}")
typer.echo(f"[dry-run] would move {len(planned)} page(s). No files written.")
@@ -677,6 +692,7 @@ def move_command(
return
new_path = target_dir / f"{page_title}.md"
check_path_budget(new_path, "Rename the page to a shorter title first (`wikitool rename`).")
check_target_free(new_path)
if dry_run:
+12 -4
View File
@@ -72,7 +72,7 @@ from typing import Optional
import typer
from chemenu import cli_contract, config
from chemenu.commands._util import fail, rel_path, success
from chemenu.commands._util import check_path_budget, fail, rel_path, success
from chemenu.frontmatter_io import write_page
from chemenu.kb_scan import load_kb_pages
from chemenu.provenance import citing_pages, source_pages_by_raw_file, source_raw_files
@@ -371,9 +371,12 @@ def _replace(
cli_contract.Failure(
label="raw accept",
cause="A file does not exist, is not under `incoming/`, or is nested more than one "
"level below it; two files in one call share a filename; or a target path already "
"exists",
reaction="Fix the named argument and retry once",
"level below it; two files in one call share a filename; a target path already "
"exists; or a target path would be over the path budget (160 UTF-16 code units "
"below the instance root)",
reaction="Fix the named argument and retry once. For a path over the budget, "
"rename the file in `incoming/` to something shorter - the refusal comes before "
"anything moves, so `incoming/` and `raw/` are unchanged",
),
cli_contract.Failure(
label="raw accept",
@@ -562,6 +565,11 @@ def raw_accept_command(
moves.append((new_path, dst))
for _src, dst in moves:
check_path_budget(
dst,
"The name comes from the file in incoming/: rename it there to something shorter "
"and accept it again.",
)
if dst.exists():
fail(f"Cannot promote: {rel_path(dst)} already exists.")
+38 -1
View File
@@ -13,6 +13,7 @@ of the line because the markdown report is a data product (it is what
"""
from __future__ import annotations
import os
from datetime import date
from pathlib import Path
@@ -42,7 +43,7 @@ from chemenu.kb_scan import (
iter_kb_pages,
load_kb_pages,
)
from chemenu.titles import collision_key, title_problems
from chemenu.titles import PATH_BUDGET, collision_key, path_length, title_problems
from chemenu.type_resolver import resolver
# Style guide's one mechanically-checkable rule (hard oracle: a plain count).
@@ -270,6 +271,29 @@ def unportable_titles(kb_dir: Path) -> list[dict]:
return sorted(entries, key=lambda e: (e["path"], e["problem"]))
def long_paths(kb_dir: Path, raw_dir: Path) -> list[dict]:
"""Files under `kb/` and `raw/` whose path below the instance root is over
`PATH_BUDGET`, as `{path, length}` (length in UTF-16 code units).
Advisory, absent from `HARD_ERROR_KEYS`: a corpus over the budget breaks no
lint run and needs no migration, only a checkout on a system with long paths
switched off - and `wikitool rename` is the remedy for a page. Hidden files
are skipped, as `git` would have them only by accident.
"""
entries: list[dict] = []
for root in (kb_dir, raw_dir):
if not root.is_dir():
continue
for path in root.rglob("*"):
if not path.is_file() or path.name.startswith("."):
continue
rel = _repo_relative(path, root).replace(os.sep, "/")
length = path_length(rel)
if length > PATH_BUDGET:
entries.append({"path": rel, "length": length})
return sorted(entries, key=lambda e: e["path"])
def _repo_relative(path: Path, kb_dir: Path) -> str:
try:
return str(path.relative_to(config.ROOT))
@@ -523,6 +547,7 @@ def run_lint(kb_dir: Path) -> dict:
"title_mismatches": title_mismatches,
"duplicate_titles": duplicate_titles,
"unportable_titles": unportable_titles(kb_dir),
"long_paths": long_paths(kb_dir, config.RAW_DIR),
"misplaced_pages": misplaced,
"nested_pages": nested,
"unsharded_collections": unsharded_collections(kb_dir, pages),
@@ -592,6 +617,13 @@ def render_markdown(report: dict) -> str:
lambda i: f"[[{i['title']}]] at `{i['path']}` - {i['problem']}; "
f"`wikitool rename --from \"{i['title']}\" --to \"<new title>\"` fixes it",
)
_section(
lines, f"Long Paths (over {PATH_BUDGET} characters below the instance root) "
"- advisory, not an error",
report.get("long_paths", []),
lambda i: f"`{i['path']}` is {i['length']} long - a Windows checkout without long paths "
f"fails on it; `wikitool rename --from \"<title>\" --to \"<shorter title>\"` fixes a page",
)
_section(
lines, "Filename / H1 Title Mismatches", report["title_mismatches"],
lambda i: f"[[{i['page']}]] H1 is '{i['h1']}'",
@@ -812,6 +844,11 @@ def default_report_path(report: dict) -> Path:
# `MIGRATION_GATED_KEYS`: the rule needs no migration, so `kb_version` never
# advances for it and a gate would keep the finding advisory forever.
#
# `long_paths` is advisory, like `misplaced_pages`: a corpus over the path budget
# is not broken, only unfit for a checkout where Windows long paths are off, and
# it needs no migration - so failing a lint run on it would penalise an instance
# that never asked for that platform.
#
# One definition, used by `lint --fail-on-error` and by the eval scorecard: if
# the two disagreed, a run could pass its score while lint refused it.
HARD_ERROR_KEYS = (
+44
View File
@@ -951,3 +951,47 @@ def test_rendered_report_names_the_remedy(kb_dir):
text = render_markdown(run_lint(kb_dir))
assert "## Unportable Titles" in text
assert 'wikitool rename --from "CON"' in text
def _long_page(kb_dir, units):
title = "t" * (units - len("kb/entities/tools/") - len(".md"))
_plain_page(kb_dir / "entities/tools" / f"{title}.md")
return f"kb/entities/tools/{title}.md"
def test_lint_reports_a_path_over_the_budget_as_advisory(kb_dir, raw_dir):
over = _long_page(kb_dir, 161)
_long_page(kb_dir, 160)
report = run_lint(kb_dir)
assert report["long_paths"] == [{"path": over, "length": 161}]
assert "long_paths" not in HARD_ERROR_KEYS
def test_lint_reports_a_raw_path_over_the_budget(kb_dir, raw_dir):
name = "r" * (161 - len("raw/notes/"))
(raw_dir / "notes").mkdir(exist_ok=True)
(raw_dir / "notes" / name).write_text("x", encoding="utf-8")
report = run_lint(kb_dir)
assert {"path": f"raw/notes/{name}", "length": 161} in report["long_paths"]
def test_lint_fail_on_error_ignores_long_paths(kb_dir, raw_dir, tmp_path, monkeypatch):
import typer
monkeypatch.setattr(config, "ROOT", tmp_path)
monkeypatch.setattr(config, "KB_DIR", kb_dir)
monkeypatch.setattr(config, "RAW_DIR", raw_dir)
baseline = has_hard_errors(run_lint(kb_dir))
_long_page(kb_dir, 170)
report = run_lint(kb_dir)
assert report["long_paths"]
assert has_hard_errors(report) == baseline
if not baseline:
lint_command(json_out=True, markdown_out=None, full=False, fail_on_error=True)
def test_rendered_report_names_rename_for_a_long_path(kb_dir, raw_dir):
_long_page(kb_dir, 161)
text = render_markdown(run_lint(kb_dir))
assert "## Long Paths" in text
assert "wikitool rename" in text
+57
View File
@@ -1038,3 +1038,60 @@ def test_new_instruction_applies_the_title_rule_too(monkeypatch, tmp_path):
result = _invoke_new_instruction(monkeypatch, tmp_path, ["new", "instruction", "--name", "a:b"])
assert result.exit_code == 1
assert list((tmp_path / "instructions").iterdir()) == []
def _entity_title(units: int) -> str:
"""A title whose `kb/entities/tools/<title>.md` path is `units` long."""
return "t" * (units - len("kb/entities/tools/") - len(".md"))
def test_new_accepts_a_path_at_the_budget_and_refuses_one_past_it(monkeypatch, kb_dir):
before = _tree_snapshot(kb_dir)
refused = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", _entity_title(161), "--set", "entity_type=tool",
])
assert refused.exit_code == 1, refused.output
assert "161" in refused.output and "160" in refused.output
assert _tree_snapshot(kb_dir) == before
accepted = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", _entity_title(160), "--set", "entity_type=tool",
])
assert accepted.exit_code == 0, accepted.output
assert (kb_dir / "entities/tools" / f"{_entity_title(160)}.md").exists()
def test_new_counts_an_emoji_as_two_units_at_the_budget(monkeypatch, kb_dir):
fits = "😀" + _entity_title(160)[:-2]
assert len(f"kb/entities/tools/{fits}.md") == 159
over = fits + "t"
refused = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", over, "--set", "entity_type=tool",
])
assert refused.exit_code == 1, refused.output
assert not list(kb_dir.rglob(f"{over}.md"))
accepted = _invoke_new(monkeypatch, kb_dir, [
"new", "entity", "--name", fits, "--set", "entity_type=tool",
])
assert accepted.exit_code == 0, accepted.output
def test_new_refuses_a_path_over_the_budget_for_a_repo_root_type(monkeypatch, tmp_path):
name = "n" * (161 - len("instructions/") - len(".md"))
result = _invoke_new_instruction(monkeypatch, tmp_path, ["new", "instruction", "--name", name])
assert result.exit_code == 1, result.output
assert "160" in result.output
assert not list((tmp_path / "instructions").iterdir())
def test_new_project_over_the_path_budget_never_reaches_the_tracker(monkeypatch, kb_dir):
root = kb_dir.parent
name = "p" * 200
with _api_server([]) as (server, handler_cls):
_write_tasks_config(root, _base_url(server))
result = _invoke_new(monkeypatch, kb_dir, [
"new", "project", "--name", name, "--set", "responsibility=haus",
])
assert result.exit_code == 1, result.output
assert "160" in result.output
assert handler_cls.posted is False
+53
View File
@@ -470,3 +470,56 @@ def test_move_reconcile_skips_a_destination_that_differs_only_by_case(patched_wi
page_ops.move_command(page_title=None, reconcile=True, dry_run=False)
assert (patched_wiki / "entities/zzz-wrong/dup.md").exists()
assert (patched_wiki / "entities/tools/Dup.md").exists()
def _tools_title(units: int) -> str:
"""A title whose `kb/entities/tools/<title>.md` path is `units` long."""
return "t" * (units - len("kb/entities/tools/") - len(".md"))
@pytest.mark.parametrize("dry_run", [False, True])
def test_rename_refuses_a_target_over_the_path_budget(patched_wiki, dry_run):
_page_at(patched_wiki, "entities/tools", "Short")
before = {p: p.read_bytes() for p in patched_wiki.rglob("*.md")}
with pytest.raises(typer.Exit):
page_ops.rename_command(old="Short", new=_tools_title(161), dry_run=dry_run)
assert {p: p.read_bytes() for p in patched_wiki.rglob("*.md")} == before
def test_rename_accepts_a_target_at_the_path_budget(patched_wiki):
_page_at(patched_wiki, "entities/tools", "Short")
page_ops.rename_command(old="Short", new=_tools_title(160), dry_run=False)
assert (patched_wiki / "entities/tools" / f"{_tools_title(160)}.md").exists()
def test_rename_away_from_a_page_over_the_budget_is_allowed(patched_wiki):
"""`--from` is never checked: the rename is the remedy for a long page."""
_page_at(patched_wiki, "entities/tools", _tools_title(170))
page_ops.rename_command(old=_tools_title(170), new="Short", dry_run=False)
assert (patched_wiki / "entities/tools/Short.md").exists()
def test_move_refuses_a_destination_over_the_path_budget(patched_wiki):
# kb/entities/systems/<t>.md is 2 longer than kb/entities/tools/<t>.md, so a
# title that fits in tools/ is over the budget once the page moves to systems/.
title = "t" * (160 - len("kb/entities/tools/") - len(".md"))
_write_misplaced(patched_wiki, "entities/tools", title, "system")
with pytest.raises(typer.Exit):
page_ops.move_command(page_title=title, reconcile=False, dry_run=False)
assert (patched_wiki / "entities/tools" / f"{title}.md").exists()
assert not (patched_wiki / "entities/systems" / f"{title}.md").exists()
def test_move_reconcile_skips_a_destination_over_the_budget_and_names_it(patched_wiki, capsys):
title = "t" * (160 - len("kb/entities/tools/") - len(".md"))
_write_misplaced(patched_wiki, "entities/tools", title, "system")
_write_misplaced(patched_wiki, "entities/tools", "fine-system", "system")
page_ops.move_command(page_title=None, reconcile=True, dry_run=True)
out = capsys.readouterr().out
assert "fine-system" in out and title in out and "characters long" in out
assert "would move 1 page(s)" in out
with pytest.raises(typer.Exit):
page_ops.move_command(page_title=None, reconcile=True, dry_run=False)
assert (patched_wiki / "entities/systems/fine-system.md").exists()
assert (patched_wiki / "entities/tools" / f"{title}.md").exists()
+27
View File
@@ -627,3 +627,30 @@ def test_replaces_rejects_combination_with_page(tree):
assert target.read_text(encoding="utf-8") == "old"
assert new.exists()
def _incoming_name_for(units: int) -> str:
"""A file name whose accepted path `raw/YYYY/MM/<name>` is `units` long."""
return "f" * (units - len(_shard()) - 1 - len(".md")) + ".md"
def _tree_files(root):
return {str(p.relative_to(root)): p.read_bytes() for p in sorted(root.rglob("*")) if p.is_file()}
@pytest.mark.parametrize("dry_run", [False, True])
def test_accept_refuses_a_target_over_the_path_budget(tree, dry_run):
src = tree / "incoming" / _incoming_name_for(161)
src.write_text("x", encoding="utf-8")
before = _tree_files(tree)
with pytest.raises(typer.Exit):
_accept(src, dry_run=dry_run)
assert _tree_files(tree) == before
def test_accept_takes_a_target_at_the_path_budget(tree):
src = tree / "incoming" / _incoming_name_for(160)
src.write_text("x", encoding="utf-8")
_accept(src)
assert (tree / _shard() / src.name).exists()
assert not src.exists()
+20
View File
@@ -69,3 +69,23 @@ def test_collision_key_folds_case_and_normalization():
assert collision_key("Foo") == collision_key("foo") == collision_key("FOO")
assert collision_key("Straße") == collision_key("STRASSE")
assert collision_key("Foo") != collision_key("Foo ")
def test_path_budget_counts_utf16_code_units():
from chemenu.titles import PATH_BUDGET, path_budget_problem, path_length
assert PATH_BUDGET == 160
assert path_length("abc") == 3
assert path_length("😀") == 2
assert path_length("é") == 1
assert path_budget_problem("a" * 160) is None
over = path_budget_problem("a" * 161)
assert over is not None and "161" in over and "160" in over and "1 shorter" in over
def test_path_budget_counts_an_emoji_twice_at_the_boundary():
from chemenu.titles import path_budget_problem
# 159 characters, 160 code units: fits. One more ASCII character tips it.
assert path_budget_problem("a" * 158 + "😀") is None
assert path_budget_problem("a" * 159 + "😀") is not None
+25
View File
@@ -12,6 +12,12 @@ import unicodedata
FORBIDDEN_CHARS = frozenset('<>:"/\\|?*')
# Windows MAX_PATH is 259 usable characters for the whole path, install folder
# included, and long paths are off on the target system. A file's path below the
# instance root may use 160 of them; the folder limit that `doctor` and preflight
# enforce is the other half of the same sum, so change one only together with it.
PATH_BUDGET = 160
# Windows device names, matched on the part before the first dot. The superscript
# forms are reserved by some Windows versions and not others; refusing all of
# them costs nothing.
@@ -61,3 +67,22 @@ def title_problems(title: str) -> list[str]:
problems.append("ends with a dot or a space, which Windows strips from file names")
return problems
def path_length(rel_path: str) -> int:
"""`rel_path`'s length in UTF-16 code units, which is how Windows counts
MAX_PATH - a character outside the BMP (an emoji) counts twice."""
return len(rel_path.encode("utf-16-le")) // 2
def path_budget_problem(rel_path: str) -> str | None:
"""Why the path of a file, relative to the instance root and written with
`/`, is over `PATH_BUDGET`; None when it fits."""
length = path_length(rel_path)
if length <= PATH_BUDGET:
return None
excess = length - PATH_BUDGET
return (
f"{rel_path} is {length} characters long (counted in UTF-16 code units, as Windows "
f"counts MAX_PATH) and the budget is {PATH_BUDGET}: the path has to get {excess} shorter"
)