From 8597e4ab64abb4d342be29c8107cb8991a5250e4 Mon Sep 17 00:00:00 2001 From: alperien Date: Wed, 29 Jul 2026 17:56:45 +0500 Subject: [PATCH] Fix em-dash prose punctuation across messages, comments, and the changelog Replace prose em dashes with semicolons, colons, commas, or parentheses in error messages, docstrings, code comments, test comments, and CHANGELOG entries. The em dash stays where it is data or chrome: the artist-title separator the TUI prints, the review screen header, the year-range display, and quoted examples of those strings. 142 tests pass and ruff is clean. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 16 ++++++++-------- chartarr/__init__.py | 2 +- chartarr/cli.py | 34 +++++++++++++++++----------------- chartarr/demo.py | 4 ++-- chartarr/lidarr.py | 14 +++++++------- chartarr/matcher.py | 4 ++-- chartarr/review.py | 2 +- chartarr/screen.py | 10 +++++----- tests/test_cli.py | 2 +- tests/test_lidarr.py | 6 +++--- tests/test_matcher.py | 2 +- tests/test_review.py | 4 ++-- tests/test_screen.py | 4 ++-- 13 files changed, 52 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d14938..02f9e5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ A fix release. The first one is the reason to upgrade. - **The review screen pinned a CPU core while it waited for you.** The progress screens poll for a keypress so they can notice `q` mid-run, - and that non-blocking mode stayed on the window afterwards — + and that non-blocking mode stayed on the window afterwards: `curses.wrapper` does not reset it between screens. The review list, which expects to wait for input, read "no key pressed" instead and redrew immediately: about 17,000 times a second, for as long as the @@ -29,8 +29,8 @@ A fix release. The first one is the reason to upgrade. decision only otherwise, so a row you skipped that later matched on a `--rematch` went to Lidarr anyway, and a re-pick lost to the candidate the matcher had led with. A decision now outranks the match result. A - matched row carrying no release group id — possible in a state file - from an older version — is left out instead of failing at Lidarr's + matched row carrying no release group id (possible in a state file + from an older version) is left out instead of failing at Lidarr's lookup. - **One failed search request stranded the albums behind it.** Searches go out in batches of a hundred and a failing batch stopped the rest, so @@ -41,7 +41,7 @@ A fix release. The first one is the reason to upgrade. - **A Lidarr URL with credentials in it appeared in error messages.** `http://user:pw@host` is how you get through a reverse proxy that asks for basic auth, and the whole URL was quoted back in connection errors, - timeouts, non-JSON replies and proxy error pages — the kind of text + timeouts, non-JSON replies and proxy error pages, the kind of text that ends up pasted into a bug report. The password is stripped from what's printed; the request still sends it. @@ -51,8 +51,8 @@ A fix release. The first one is the reason to upgrade. be permanent for that run: the state file has always understood a cleared decision, but nothing could write one. - **A push without `--search` now says how many albums are monitored but - idle.** Monitoring is not downloading — Lidarr picks monitored albums - up on its own schedule — and a run ending "added 40" while nothing + idle.** Monitoring is not downloading (Lidarr picks monitored albums + up on its own schedule), and a run ending "added 40" while nothing downloads reads like a finished job. ### Changed @@ -79,7 +79,7 @@ Match a CSV of albums against MusicBrainz, resolve the uncertain ones in a review screen, and add the results to Lidarr as monitored albums. Runs in three resumable stages and can be stopped and restarted at any point. -Notes for anyone who ran this from git before the release — three fixes +Notes for anyone who ran this from git before the release. Three fixes changed behaviour you may have been affected by: - **Albums pushed to Lidarr stayed unmonitored.** The artist was added with @@ -87,7 +87,7 @@ changed behaviour you may have been affected by: then, once its background scan finishes, every album of theirs including the one just pushed. Verified against Lidarr 3.1.0.4875: a fresh push reported "added" and fifteen seconds later nothing was monitored. If you - pushed a chart with an earlier build, check Lidarr — those albums are + pushed a chart with an earlier build, check Lidarr: those albums are probably sitting there unmonitored, and re-running chartarr will fix them. - **Editing the CSV between runs mixed up the matches.** Rows were keyed by position, so inserting a line at the top shifted every key and each album diff --git a/chartarr/__init__.py b/chartarr/__init__.py index 4cb0f1f..8d83539 100644 --- a/chartarr/__init__.py +++ b/chartarr/__init__.py @@ -1,3 +1,3 @@ -"""chartarr — feed your album charts to Lidarr.""" +"""chartarr - feed your album charts to Lidarr.""" __version__ = "0.1.1" diff --git a/chartarr/cli.py b/chartarr/cli.py index 30029ac..5ca1540 100644 --- a/chartarr/cli.py +++ b/chartarr/cli.py @@ -67,7 +67,7 @@ def status(line: str) -> None: if not sys.stdout.isatty(): return width = shutil.get_terminal_size().columns - 1 - # truncate and pad by display cells, not code points — a cjk label is + # truncate and pad by display cells, not code points: a cjk label is # wider than len() says, and overflowing the row garbles the redraw line = screen._fit(line, width) sys.stdout.write("\r" + line + " " * max(0, width - screen.cells(line))) @@ -119,13 +119,13 @@ def _ask(prompt: str, secret: bool = False) -> str: return (getpass.getpass(prompt) if secret else input(prompt)).strip() except (EOFError, KeyboardInterrupt): print(file=sys.stderr) # step off the interrupted prompt line - fail("setup cancelled — nothing was saved") + fail("setup cancelled; nothing was saved") return "" # unreachable; fail() exits def setup_wizard(existing: dict) -> dict: if not sys.stdin.isatty(): - fail("lidarr isn't set up and there's no terminal to ask on — set " + fail("lidarr isn't set up and there's no terminal to ask on; set " "LIDARR_URL and LIDARR_API_KEY, or run chartarr --setup in a terminal") default = existing.get("lidarr_url", "http://localhost:8686") url = _ask(f"lidarr url [{default}]: ") or default @@ -233,7 +233,7 @@ def load_csv(path: Path): # python 3.12 stopped rejecting those in the csv reader, so the file # parses into gibberish column names instead of failing; say what it is. if b"\x00" in head: - fail(f'{path} looks like utf-16 (excel\'s "unicode text" export) — ' + fail(f'{path} looks like utf-16 (excel\'s "unicode text" export); ' 're-save it as "csv utf-8" and rerun') try: with path.open(newline="", encoding="utf-8-sig") as f: @@ -241,11 +241,11 @@ def load_csv(path: Path): rows = list(reader) cols = reader.fieldnames or [] except UnicodeDecodeError: - fail(f"{path} isn't utf-8 — re-save it as utf-8 " + fail(f"{path} isn't utf-8; re-save it as utf-8 " '(in excel: "csv utf-8") and rerun') except csv.Error as e: - hint = (' — excel\'s "unicode text" export is utf-16, which does ' - 'this; re-save as "csv utf-8" and rerun' + hint = (' (excel\'s "unicode text" export is utf-16, which does ' + 'this; re-save as "csv utf-8" and rerun)' if "NUL" in str(e) else "") fail(f"can't parse {path}: {e}{hint}") except OSError as e: @@ -321,12 +321,12 @@ def events(): # don't promise saved progress when the first lookup was the one # that failed; there is nothing to resume and saying so is a lie done = sum(counts.values()) - sum(base.values()) - fail("musicbrainz stopped answering — " + fail("musicbrainz stopped answering; " + (f"the {_n(done, 'row')} matched so far {'is' if done == 1 else 'are'} " "saved, rerun to pick up the rest" if done else "nothing was matched, so nothing was saved; try again later")) if stopped: - print(dim("stopped — progress is saved, rerun to resume")) + print(dim("stopped; progress is saved, rerun to resume")) sys.exit(0) @@ -338,7 +338,7 @@ def stage_review(rows, artist_col, title_col, state: State) -> None: if not pending: return if not (sys.stdin.isatty() and sys.stdout.isatty()): - print(dim(f"{len(pending)} rows need review — rerun in a terminal, " + print(dim(f"{len(pending)} rows need review; rerun in a terminal, " f"or use --yes to push without them")) return review.run(pending, artist_col, title_col, state.add_decision) @@ -356,7 +356,7 @@ def import_set(rows, state: State) -> list[dict]: a decision outranks the match result. review only offers uncertain rows, but a row can be decided and then match cleanly on a --rematch, - and the state file is editable — either way the answer the user gave + and the state file is editable; either way the answer the user gave is the one they meant, so an explicit skip is honoured on a matched row and a re-pick replaces the automatic choice. """ @@ -472,14 +472,14 @@ def events(): elif touched and not args.dry_run: # without --search the albums sit there monitored and idle, which # looks like a finished job that downloaded nothing - print(dim(f"{_n(len(touched), 'album')} monitored but not searched — " + print(dim(f"{_n(len(touched), 'album')} monitored but not searched; " f"rerun with --search, or hit Search in lidarr")) for f_ in failures[:8]: print(dim(f" {f_}")) if len(failures) > 8: print(dim(f" … and {len(failures) - 8} more")) if stopped: - print(dim("stopped — the push is safe to rerun")) + print(dim("stopped; the push is safe to rerun")) sys.exit(0) @@ -542,10 +542,10 @@ def main(argv=None) -> None: try: _main(build_parser().parse_args(argv)) except KeyboardInterrupt: - # covers ctrl-c anywhere — csv loading, state replay, a wizard - # network call — not just the matching/push loops + # covers ctrl-c anywhere (csv loading, state replay, a wizard + # network call), not just the matching/push loops status_end() - print(dim("stopped — progress is saved, rerun to resume")) + print(dim("stopped; progress is saved, rerun to resume")) sys.exit(130) finally: # flush inside the try so a closed pipe surfaces here as a @@ -568,7 +568,7 @@ def _main(args) -> None: if p.exists(): fail("sample.csv already exists here") p.write_text(EXAMPLE_CSV, encoding="utf-8") - print("wrote sample.csv — try: chartarr sample.csv --dry-run") + print("wrote sample.csv; try: chartarr sample.csv --dry-run") return if args.demo: diff --git a/chartarr/demo.py b/chartarr/demo.py index dd32c2c..23a471f 100644 --- a/chartarr/demo.py +++ b/chartarr/demo.py @@ -124,7 +124,7 @@ def match_events(): f"review {cli.accent(counts.get('review', 0))} · " f"not found {cli.accent(counts.get('not_found', 0))}") if stopped: - print(cli.dim("demo over — nothing was saved or sent")) + print(cli.dim("demo over; nothing was saved or sent")) return # review, for real @@ -163,4 +163,4 @@ def push_events(): except KeyboardInterrupt: print() - print(cli.dim("demo over — nothing was saved or sent")) + print(cli.dim("demo over; nothing was saved or sent")) diff --git a/chartarr/lidarr.py b/chartarr/lidarr.py index 84c460f..754527a 100644 --- a/chartarr/lidarr.py +++ b/chartarr/lidarr.py @@ -30,7 +30,7 @@ def __init__(self, message: str, status: int = 0, body: str = ""): def _safe_url(url: str) -> str: """the url with any user:password@ removed, for showing in messages. - a lidarr url can carry basic-auth credentials — behind a reverse proxy + a lidarr url can carry basic-auth credentials: behind a reverse proxy that asks for them, http://user:pw@host is how you get through. those end up in every connection error otherwise, and errors get pasted into bug reports. @@ -98,7 +98,7 @@ def _call(self, path: str, method: str = "GET", **kw): r = self.s.request(method, url, timeout=60, **kw) except requests.ConnectionError as e: raise LidarrError( - f"Can't reach Lidarr at {self.shown} — is it running, and is the " + f"Can't reach Lidarr at {self.shown}; is it running, and is the " f"URL right? (the address you use in your browser)") from e except requests.Timeout as e: raise LidarrError(f"Lidarr at {self.shown} timed out.") from e @@ -106,7 +106,7 @@ def _call(self, path: str, method: str = "GET", **kw): requests.exceptions.MissingSchema, requests.exceptions.InvalidURL) as e: raise LidarrError( - f"{self.shown} is not a URL Lidarr can be reached at — it needs " + f"{self.shown} is not a URL Lidarr can be reached at; it needs " f"to start with http:// or https://") from e except requests.RequestException as e: # requests quotes the url it was given, credentials and all @@ -128,7 +128,7 @@ def _call(self, path: str, method: str = "GET", **kw): return r.json() except ValueError as e: raise LidarrError( - f"Lidarr returned something that isn't JSON — is {self.shown} " + f"Lidarr returned something that isn't JSON; is {self.shown} " f"really Lidarr, and not a login page or another service?") from e def status(self) -> dict: @@ -179,8 +179,8 @@ def add_album(self, rgid: str, quality_profile_id: int, adding does not search. lidarr's addOptions.searchForNewAlbum is read by SearchForRecentlyAdded, which ArtistScannedHandler only - reaches for an artist with no AddOptions — never the artist this - add just created — and the handler clears AddOptions on its way + reaches for an artist with no AddOptions (never the artist this + add just created), and the handler clears AddOptions on its way out, so the flag is stored and then dropped (Lidarr#5012). the caller searches explicitly with search_albums instead. """ @@ -194,7 +194,7 @@ def add_album(self, rgid: str, quality_profile_id: int, album = self.lookup(rgid) if album is None: raise LidarrError( - "Lidarr's lookup didn't find this MusicBrainz ID — it needs a " + "Lidarr's lookup didn't find this MusicBrainz ID; it needs a " "release group ID, not a release ID") wanted = [rgid] + [r for r in (also_monitor or []) if r != rgid] artist = album["artist"] diff --git a/chartarr/matcher.py b/chartarr/matcher.py index efdd0b4..6b039fb 100644 --- a/chartarr/matcher.py +++ b/chartarr/matcher.py @@ -163,7 +163,7 @@ def _first_artist(rg: dict) -> tuple[str | None, str]: # same-titled "Fleetwood Mac" rgs). each secondary type costs clearly more # than the Album bump (0.03) so a clean studio album always outranks a # same-titled flavoured one; ties otherwise favour the fewest secondary -# types. the penalty lives in the sort key ONLY — confidence/title_sim are +# types. the penalty lives in the sort key ONLY: confidence/title_sim are # untouched, so a legitimately live/soundtrack chart entry that wins its row # anyway ("The Last Waltz", "Purple Rain") still clears the auto-match gate. _SECONDARY_PENALTY = 0.05 @@ -289,7 +289,7 @@ def absorb(data: dict, from_alias: bool = False) -> None: # alias pass: when the winner is either unproven (fails the auto-match # gates) or a flavoured release group the row did not ask for, spend a # few lookups confirming aliases for candidates an alias could still - # save — right artist, unproven title. this is what lets "Blackstar" + # save: right artist, unproven title. this is what lets "Blackstar" # auto-match ★ instead of stalling on "Blackstar Radio Edits". hinted = _hinted_types(t_vars) diff --git a/chartarr/review.py b/chartarr/review.py index 01649da..0be4e01 100644 --- a/chartarr/review.py +++ b/chartarr/review.py @@ -21,7 +21,7 @@ def run(items, artist_col, title_col, on_decision): return if not available(): print(f"this terminal can't draw the review screen " - f"(TERM={os.environ.get('TERM', '')!r}) — rerun in a working " + f"(TERM={os.environ.get('TERM', '')!r}); rerun in a working " f"terminal, or use --yes to push just the confident matches") return _run(_loop, items, artist_col, title_col, on_decision) diff --git a/chartarr/screen.py b/chartarr/screen.py index 22a75ed..67c494c 100644 --- a/chartarr/screen.py +++ b/chartarr/screen.py @@ -22,7 +22,7 @@ def available() -> bool: back to the plain-line output instead of crashing. note: cpython caches setupterm's first success process-wide, so this - answers for the TERM the process started with — which is the one that + answers for the TERM the process started with, which is the one that matters. tests that flip TERM must probe in a subprocess. """ if curses is None: @@ -43,7 +43,7 @@ def _run(func, *args): # a utf-8 locale makes ncurses draw wide characters correctly, but an # LC_ALL this box doesn't know (classic ssh-forwarded locale) must not - # kill the screen — degrade toward the C locale instead + # kill the screen; degrade toward the C locale instead try: locale.setlocale(locale.LC_ALL, "") except locale.Error: @@ -56,7 +56,7 @@ def entry(scr, *inner): # every session starts blocking. cpython keeps nodelay on the window # itself and curses.wrapper does not reset it between sessions, so # the nodelay(True) _progress needs would carry into the review loop - # that follows it — whose getch() would then return -1 forever and + # that follows it, whose getch() would then return -1 forever and # redraw at ~17k frames a second on a pinned core. a screen that # wants non-blocking input asks for it, as _progress does. try: @@ -75,7 +75,7 @@ def _cell(ch: str) -> int: def cells(s: str) -> int: - """display cells s occupies — the terminal lays out by cell, not code + """display cells s occupies: the terminal lays out by cell, not code point, and cjk take two, so len() undercounts exactly on the dual-script charts this tool is for.""" return sum(_cell(ch) for ch in s) @@ -128,7 +128,7 @@ def _dark_background() -> bool: COLORFGBG is what terminals that care about this set (rxvt, konsole, some terminfo-aware setups); "15;0" means light text on dark. absent - it, assume dark — the common case, and both shades are readable there. + it, assume dark: the common case, and both shades are readable there. """ fgbg = os.environ.get("COLORFGBG", "") if ";" in fgbg: diff --git a/tests/test_cli.py b/tests/test_cli.py index c5f84ce..5b23bfd 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -335,7 +335,7 @@ def half(rows, artist_col, title_col): def test_review_choice_beats_the_auto_match_and_skips_are_dropped(tmp_path): # the reviewed pick has to reach lidarr, not the candidate the matcher - # led with — that's the whole point of the review screen + # led with; that's the whole point of the review screen p = _csv(tmp_path, "title,artist\nZiggy Stardust,David Bowie\n" "Kid A,Radiohead\nDummy,Portishead\n") rows, _, _ = load_csv(p) diff --git a/tests/test_lidarr.py b/tests/test_lidarr.py index 13621b0..24f3d47 100644 --- a/tests/test_lidarr.py +++ b/tests/test_lidarr.py @@ -108,7 +108,7 @@ def test_also_monitor_names_every_sibling_once(): def test_adding_never_asks_lidarr_to_search(): # searchForNewAlbum is read by SearchForRecentlyAdded, which # ArtistScannedHandler only reaches when the artist has no AddOptions - # — never the artist this add just created — and the handler clears + # (never the artist this add just created), and the handler clears # AddOptions on the way out, so the flag is stored and dropped # (Lidarr#5012). searching is the caller's job, via AlbumSearch. stage_lookup() @@ -182,7 +182,7 @@ def test_duplicate_row_already_monitored_is_skipped(): @responses.activate def test_config_400_is_an_error_not_a_duplicate(): # the original client sniffed the body for "exist", so "Quality Profile - # does not exist" — a config mistake — took the duplicate path and died + # does not exist" (a config mistake) took the duplicate path and died # with a baffling "conflict but album not found afterwards". it must # surface as the validation error it is. stage_lookup() @@ -191,7 +191,7 @@ def test_config_400_is_an_error_not_a_duplicate(): "errorMessage": "Quality Profile does not exist"}]) with pytest.raises(LidarrError, match="Quality Profile"): add() - assert len(responses.calls) == 3 # find, lookup, post — no duplicate dance + assert len(responses.calls) == 3 # find, lookup, post; no duplicate dance @responses.activate diff --git a/tests/test_matcher.py b/tests/test_matcher.py index 607e1b3..ef70c41 100644 --- a/tests/test_matcher.py +++ b/tests/test_matcher.py @@ -151,7 +151,7 @@ def test_hint_waives_only_the_named_type(): def test_alias_counts_as_title(): # mb titles bowie's Blackstar "★"; the name every chart uses is only an # alias, and releasegroup:"Blackstar" returns just "Blackstar Radio - # Edits" — the aliased album must outscore it + # Edits"; the aliased album must outscore it cands = score_rgs( [rg("Blackstar Radio Edits", "David Bowie", "radio-id", ptype="Single"), rg("★", "David Bowie", "star-id", aliases=["Blackstar", "★ (Blackstar)"])], diff --git a/tests/test_review.py b/tests/test_review.py index 27f4b24..02c12dd 100644 --- a/tests/test_review.py +++ b/tests/test_review.py @@ -64,7 +64,7 @@ def item(key, candidates, artist="David Bowie", title="Ziggy Stardust"): @pytest.fixture(autouse=True) def offline_curses(monkeypatch): - """the loop's terminal calls, neutered — no initscr, no real screen.""" + """the loop's terminal calls, neutered: no initscr, no real screen.""" if review.curses is None: pytest.skip("no curses at all") monkeypatch.setattr(review.curses, "curs_set", lambda n: None) @@ -236,7 +236,7 @@ def fake_wrapper(func, *args): def test_an_empty_read_costs_a_full_redraw(): """why the flag matters: -1 does nothing but go round again. - the loop has no idle branch — a non-blocking getch returns -1, falls + the loop has no idle branch: a non-blocking getch returns -1, falls through every key test, and redraws. that is the spin: on a real terminal it ran ~17k times a second until a key arrived. """ diff --git a/tests/test_screen.py b/tests/test_screen.py index 9e986eb..b16f590 100644 --- a/tests/test_screen.py +++ b/tests/test_screen.py @@ -35,7 +35,7 @@ def _run_probe(code, term): def _terminfo_has(term): # a SUCCESSFUL curses.setupterm is cached process-wide by cpython # (initialised_setupterm), after which every later call succeeds no - # matter what TERM says — so positive probes must run in a fresh + # matter what TERM says, so positive probes must run in a fresh # process, or they poison the TERM=unknown tests below. failures are # not cached, so the negative tests are safe in-process. return _run_probe( @@ -195,7 +195,7 @@ def test_nodelay_does_not_survive_into_the_next_session_on_a_real_terminal(): os.close(master) os.close(slave) assert proc.returncode == 0, ( - "the second session's getch did not block — nodelay leaked " + "the second session's getch did not block; nodelay leaked " f"({proc.stderr.decode()[-300:]})")