Files
chemenu/tools/chemenu/titles.py
T
torben 04aebdeccf
CI / verify (push) Successful in 2m9s
Release / release (push) Successful in 38s
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)
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
2026-09-30 23:08:36 +02:00

89 lines
3.7 KiB
Python

"""What a page title may be, stated as pure functions.
A title becomes a file name one to one, so the rule is the intersection of what
Windows, macOS and Linux accept - checked on every platform, because a corpus
written on Linux is checked out on the others. The normative statement is
`kb/CONTRACT.md` § "Titles are identifiers"; this module implements it and holds
no `fail()`, so `lint_core` can use it without going through `commands/`.
"""
from __future__ import annotations
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.
_WINDOWS_RESERVED = frozenset(
{"CON", "PRN", "AUX", "NUL"}
| {f"{device}{digit}" for device in ("COM", "LPT") for digit in "0123456789¹²³"}
)
# Names the stack itself owns next to a page: the generated catalog shard and
# the per-collection authoring contract (`kb_scan.GENERATED_INDEX`,
# `kb_scan._COLLECTION_CONTRACT`, both matched here by the part before the dot).
_STACK_RESERVED = frozenset({"INDEX", "COLLECTION"})
def collision_key(name: str) -> str:
"""The key under which two names count as the same file on a case-insensitive,
normalizing file system (NTFS, APFS): NFC, then `casefold`."""
return unicodedata.normalize("NFC", name).casefold()
def title_problems(title: str) -> list[str]:
"""Every reason `title` cannot be a page title; empty when it can."""
if not title:
return ["the title is empty"]
problems: list[str] = []
forbidden = sorted({c for c in title if c in FORBIDDEN_CHARS})
if forbidden:
shown = " ".join(f"'{c}'" for c in forbidden)
problems.append(f"contains {shown}, which Windows does not allow in a file name")
control = sorted({c for c in title if unicodedata.category(c) == "Cc"})
if control:
shown = " ".join(f"U+{ord(c):04X}" for c in control)
problems.append(f"contains the control character(s) {shown}")
# Windows ignores trailing spaces and dots on the name before the extension,
# so `CON .x` opens the console device just as `CON.x` does.
base = title.split(".", 1)[0].rstrip(" ").upper()
if base in _WINDOWS_RESERVED:
problems.append(f"'{base}' is a reserved device name on Windows")
elif base in _STACK_RESERVED:
problems.append(f"'{base}' is reserved for a file the stack generates or owns")
if title.endswith((".", " ")):
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"
)