Repository: BoboTiG/ebook-reader-dict

Issue:
Make use of a proper logger
It will ease our work, more especially on #1535.

<!-- POLAR PLEDGE BADGE START -->
## Upvote & Fund

- We're using [Polar.sh](https://polar.sh/tiger-222) so you can upvote and help fund this issue.
- We receive the funding once the issue is completed & confirmed by you.
- Thank you in advance for helping prioritize & fund our backlog.

<a href="https://polar.sh/BoboTiG/ebook-reader-dict/issues/1616">
<picture>
  <source media="(prefers-color-scheme: dark)" srcset="https://polar.sh/api/github/BoboTiG/ebook-reader-dict/issues/1616/pledge.svg?darkmode=1">
  <img alt="Fund with Polar" src="https://polar.sh/api/github/BoboTiG/ebook-reader-dict/issues/1616/pledge.svg">
</picture>
</a>
<!-- POLAR PLEDGE BADGE END -->


Discussion:

Patch:
diff --git a/wikidict/__main__.py b/wikidict/__main__.py
index de533bec..16d992c0 100644
--- a/wikidict/__main__.py
+++ b/wikidict/__main__.py
@@ -42,6 +42,8 @@
 If no argument given, --download, --parse, --render, and --convert will be done automatically.
 """
 
+import logging
+import os
 import sys
 
 from docopt import docopt
@@ -49,6 +51,7 @@
 
 def main() -> int:  # pragma: nocover
     """Main entry point."""
+    logging.basicConfig(level=logging.DEBUG if "DEBUG" in os.environ else logging.INFO)
 
     args = docopt(__doc__)
 
diff --git a/wikidict/check_word.py b/wikidict/check_word.py
index 0c3edb5f..b4ca44bc 100644
--- a/wikidict/check_word.py
+++ b/wikidict/check_word.py
@@ -3,11 +3,11 @@
 from __future__ import annotations
 
 import copy
+import logging
 import os
 import re
 import urllib.parse
 from functools import partial
-from threading import Lock
 from time import sleep
 
 import requests
@@ -27,6 +27,8 @@
 MAX_RETRIES = 5  # count
 SLEEP_TIME = 5  # time, seconds
 
+log = logging.getLogger(__name__)
+
 
 def check_mute(wiktionary_text: str, parsed_html: str, category: str) -> list[str]:
     results: list[str] = []
@@ -58,8 +60,7 @@ def check_mute(wiktionary_text: str, parsed_html: str, category: str) -> list[st
 def check(wiktionary_text: str, parsed_html: str, category: str) -> int:
     """Run checks and return the error count to increment."""
     results = check_mute(wiktionary_text, parsed_html, category)
-    for r in results:
-        print(r, flush=True)
+    log.error("Diff:\n%s", "\n".join(results))
     return len(results) // 2
 
 
@@ -321,7 +322,7 @@ def get_url_content(url: str) -> str:
                 if resp.status_code == 429:
                     wait_time = int(resp.headers.get("retry-after") or "1")
                 elif resp.status_code == 404:
-                    print(err)
+                    log.error(err)
                     return "404"
             sleep(wait_time * SLEEP_TIME)
             retry += 1
@@ -342,7 +343,7 @@ def get_wiktionary_page(word: str, locale: str) -> str:
     return filter_html(html, locale)
 
 
-def check_word(word: str, locale: str, lock: Lock | None = None) -> int:
+def check_word(word: str, locale: str) -> int:
     errors = 0
     results: list[str] = []
     details = get_word(word, locale)
@@ -376,16 +377,14 @@ def check_word(word: str, locale: str, lock: Lock | None = None) -> int:
             r = check_mute(text, definition, message)
             results.extend(r)
             index += 1
-    # print with lock if any
+
     if results:
         errors = len(results) // 2
-        if lock:
-            lock.acquire()
         for result in results:
-            print(result, flush=True)
-        print(f"\n >>> [{word}] - Errors:", errors, flush=True)
-        if lock:
-            lock.release()
+            log.error(result)
+        log.warning(">>> [%s] - Errors: %s", word, errors)
+    else:
+        log.debug(">>> [%s] - OK", word)
 
     return errors
 
diff --git a/wikidict/check_words.py b/wikidict/check_words.py
index d9f3d8c8..d57a3164 100644
--- a/wikidict/check_words.py
+++ b/wikidict/check_words.py
@@ -1,17 +1,19 @@
 """Get and render N words; then compare with the rendering done on the Wiktionary to catch errors."""
 
+import logging
 import os
 import random
 from concurrent.futures import ThreadPoolExecutor
 from functools import partial
 from pathlib import Path
-from threading import Lock
 
 from . import check_word, render
 
+log = logging.getLogger(__name__)
 
-def local_check(word: str, locale: str, lock: Lock) -> int:
-    return check_word.check_word(word, locale, lock=lock)
+
+def local_check(word: str, locale: str) -> int:
+    return check_word.check_word(word, locale)
 
 
 def get_words_to_tackle(
@@ -29,10 +31,10 @@ def get_words_to_tackle(
     else:
         output_dir = Path(os.getenv("CWD", "")) / "data" / locale
         if not (file := render.get_latest_json_file(output_dir)):
-            print(">>> No dump found. Run with --parse first ... ", flush=True)
+            log.error(">>> No dump found. Run with --parse first ... ")
             return []
 
-        print(f">>> Loading {file} ...", flush=True)
+        log.info(">>> Loading %s ...", file)
         words = list(render.load(file).keys())
 
     if count == -1:
@@ -60,12 +62,10 @@ def main(locale: str, count: int, is_random: bool, offset: str, input_file: str)
 
     words = get_words_to_tackle(locale, count=count, is_random=is_random, offset=offset, input_file=input_file)
 
-    lock = Lock()
     with ThreadPoolExecutor(10) as pool:
-        err = pool.map(partial(local_check, locale=locale, lock=lock), words)
+        err = pool.map(partial(local_check, locale=locale), words)
 
-    errors = sum(err)
-    if errors:
-        print("\n >>> TOTAL Errors:", errors, flush=True)
+    if errors := sum(err):
+        log.warning(">>> TOTAL Errors: %d", errors)
 
     return errors
diff --git a/wikidict/convert.py b/wikidict/convert.py
index 1fbb099c..73d92d54 100644
--- a/wikidict/convert.py
+++ b/wikidict/convert.py
@@ -5,6 +5,7 @@
 import gzip
 import hashlib
 import json
+import logging
 import os
 import shutil
 from collections import defaultdict
@@ -134,6 +135,8 @@
 # Dictionnary file suffix for etymology-free files
 NO_ETYMOLOGY_SUFFIX = "-noetym"
 
+log = logging.getLogger(__name__)
+
 
 class BaseFormat:
     """Base class for all dictionaries."""
@@ -184,10 +187,10 @@ def compute_checksum(file: Path) -> None:
         checksum = hashlib.new(ASSET_CHECKSUM_ALGO, file.read_bytes()).hexdigest()
         checksum_file = file.with_suffix(f"{file.suffix}.{ASSET_CHECKSUM_ALGO}")
         checksum_file.write_text(f"{checksum} {file.name}")
-        print(f">>> Crafted {checksum_file.name} ({checksum})", flush=True)
+        log.info(">>> Crafted %s (%s)", checksum_file.name, checksum)
 
     def summary(self, file: Path) -> None:
-        print(f">>> Generated {file.name} ({file.stat().st_size:,} bytes)", flush=True)
+        log.info(">>> Generated %s (%d bytes)", file.name, file.stat().st_size)
         self.compute_checksum(file)
 
 
@@ -543,10 +546,10 @@ def run_formatter(
 
 def load(file: Path) -> Words:
     """Load the big JSON file containing all words and their details."""
-    print(f">>> Loading {file} ...", flush=True)
+    log.info(">>> Loading %s ...", file)
     with file.open(encoding="utf-8") as fh:
         words: Words = {key: Word(*values) for key, values in json.load(fh).items()}
-    print(f">>> Loaded {len(words):,} words from {file}", flush=True)
+    log.info(">>> Loaded %d words from %s", len(words), file)
     return words
 
 
@@ -594,11 +597,10 @@ def distribute_workload(
 
 def main(locale: str) -> int:
     """Entry point."""
-
     output_dir = Path(os.getenv("CWD", "")) / "data" / locale
     file = get_latest_json_file(output_dir)
     if not file:
-        print(">>> No dump found. Run with --render first ... ", flush=True)
+        log.error(">>> No dump found. Run with --render first ... ")
         return 1
 
     # Get all words from the database
diff --git a/wikidict/download.py b/wikidict/download.py
index 49337e3d..0f47a75d 100644
--- a/wikidict/download.py
+++ b/wikidict/download.py
@@ -1,6 +1,7 @@
 """Retrieve Wiktionary data."""
 
 import bz2
+import logging
 import os
 import re
 from collections.abc import Callable
@@ -11,20 +12,13 @@
 
 from .constants import BASE_URL, DUMP_URL
 
+log = logging.getLogger(__name__)
+
 
 def callback_progress(text: str, done: int, last: bool) -> None:
     """Progression callback. Used when fetching the Wiktionary dump and when extracting it."""
-    msg = f"{text}OK [{done:,} bytes]\n" if last else f"{text}{done:,} bytes"
-    print(f"\r{msg}", end="", flush=True)
-
-
-def callback_progress_ci(text: str, done: int, last: bool) -> None:
-    """
-    Progression callback. Used when fetching the Wiktionary dump and when extracting it.
-    This version is targeting the CI, it prints less lines and it is easier to follow.
-    """
-    msg = f". OK [{done:,} bytes]\n" if last else "."
-    print(msg, end="", flush=True)
+    size = f"OK [{done:,} bytes]" if last else f"{done:,} bytes"
+    log.debug("%s: %s", text, size)
 
 
 def decompress(file: Path, callback: Callable[[str, int, bool], None]) -> Path:
@@ -33,8 +27,8 @@ def decompress(file: Path, callback: Callable[[str, int, bool], None]) -> Path:
     if output.is_file():
         return output
 
-    msg = f">>> Uncompressing into {output.name}: "
-    print(msg, end="", flush=True)
+    msg = f">>> Uncompressing into {output.name}"
+    log.info(msg)
 
     comp = bz2.BZ2Decompressor()
     with file.open("rb") as fi, output.open(mode="wb") as fo:
@@ -70,8 +64,8 @@ def fetch_pages(date: str, locale: str, output_dir: Path, callback: Callable[[st
         return output
 
     url = DUMP_URL.format(locale, date)
-    msg = f">>> Fetching {url}: "
-    print(msg, end="", flush=True)
+    msg = f">>> Fetching {url}"
+    log.info(msg)
 
     with output.open(mode="wb") as fh, requests.get(url, stream=True) as req:
         req.raise_for_status()
@@ -95,23 +89,17 @@ def main(locale: str) -> int:
     snapshots = fetch_snapshots(locale)
     snapshot = snapshots[-1]
 
-    # The output style is different if run from a workflow
-    # Note: "CI" is automatically set in every GitHub workflow
-    # https://help.github.com/en/actions/configuring-and-managing-workflows/using-environment-variables#default-environment-variables
-    cb = callback_progress_ci if "CI" in os.environ else callback_progress
-
     # Fetch and uncompress the snapshot file
     try:
-        file = fetch_pages(snapshot, locale, output_dir, cb)
+        file = fetch_pages(snapshot, locale, output_dir, callback_progress)
     except HTTPError:
         (output_dir / f"pages-{snapshot}.xml.bz2").unlink(missing_ok=True)
-        print("FAIL", flush=True)
-        print(">>> Wiktionary dump is ongoing ... ", flush=True)
-        print(">>> Will use the previous one.", flush=True)
+        log.exception(">>> Wiktionary dump is ongoing ... ")
+        log.info(">>> Will use the previous one.")
         snapshot = snapshots[-2]
-        file = fetch_pages(snapshot, locale, output_dir, cb)
+        file = fetch_pages(snapshot, locale, output_dir, callback_progress)
 
-    decompress(file, cb)
+    decompress(file, callback_progress)
 
-    print(">>> Retrieval done!", flush=True)
+    log.info(">>> Retrieval done!")
     return 0
diff --git a/wikidict/find_templates.py b/wikidict/find_templates.py
index a6ea1a72..56a9b30f 100644
--- a/wikidict/find_templates.py
+++ b/wikidict/find_templates.py
@@ -1,5 +1,6 @@
 """DEBUG: find all templates in use."""
 
+import logging
 import os
 import re
 from collections import defaultdict
@@ -8,6 +9,8 @@
 from .lang import sections
 from .render import adjust_wikicode, find_all_sections, find_sections, get_latest_json_file, load
 
+log = logging.getLogger(__name__)
+
 
 def find_titles(code: str, locale: str) -> list[str]:
     """Find the correct section(s) holding the current locale definition(s)."""
@@ -36,7 +39,7 @@ def find_templates(in_words: dict[str, str], locale: str) -> None:
                 templates[template] = in_word
 
     if not found_sections:
-        print("No sections found.", flush=True)
+        log.warning("No sections found.")
         return
 
     with open("sections.txt", "w", encoding="utf-8") as f:
@@ -48,15 +51,15 @@ def find_templates(in_words: dict[str, str], locale: str) -> None:
                     f.write(f"    - {entry!r}\n")
             else:
                 f.write(f"    - {entries[0]!r}\n")
-    print(">>> File sections.txt created.", flush=True)
+    log.info(">>> File sections.txt created.")
 
     if templates:
         with open("templates.txt", "w", encoding="utf-8") as f:
             for template, entry in sorted(templates.items()):
                 f.write(f"{entry!r} => {template!r}\n")
-        print(">>> File templates.txt created.")
+        log.info(">>> File templates.txt created.")
     else:
-        print("No templates found.", flush=True)
+        log.warning("No templates found.")
 
 
 def main(locale: str) -> int:
@@ -65,12 +68,12 @@ def main(locale: str) -> int:
     output_dir = Path(os.getenv("CWD", "")) / "data" / locale
     file = get_latest_json_file(output_dir)
     if not file:
-        print(">>> No dump found. Run with --parse first ... ", flush=True)
+        log.error(">>> No dump found. Run with --parse first ... ")
         return 1
 
-    print(f">>> Loading {file} ...", flush=True)
+    log.info(">>> Loading %s ...", file)
     in_words: dict[str, str] = load(file)
 
-    print(">>> Working, please be patient ...", flush=True)
+    log.info(">>> Working, please be patient ...")
     find_templates(in_words, locale)
     return 0
diff --git a/wikidict/lang/ca/grc_trans.py b/wikidict/lang/ca/grc_trans.py
index 5359998e..8c7fbfb2 100644
--- a/wikidict/lang/ca/grc_trans.py
+++ b/wikidict/lang/ca/grc_trans.py
@@ -1,6 +1,9 @@
+import logging
 import re
 import unicodedata
 
+log = logging.getLogger(__name__)
+
 data = {}
 
 macron = chr(0x304)
@@ -80,9 +83,6 @@ def make_tokens(text: str) -> list[str]:
     prev = None
     for character in decompose(text):  # TODO filter non UTF8 ?
         curr_info = info.get(character, {})
-        # print(character)
-        # print(curr_info)
-        # print(prev_info)
         if curr_info.get("vowel"):
             vowel_count += 1
             if prev and (
@@ -104,7 +104,6 @@ def make_tokens(text: str) -> list[str]:
                 if character == diaeresis:
                     previous_vowel = ""
                     vowel_with_diaeresis = ""
-                    # print("jere = " + tokens[token_i])
                     if matches := re.match("^(" + basic_Greek + ")(" + basic_Greek + ".+)", tokens[token_i]):
                         previous_vowel, vowel_with_diaeresis = matches.groups()
                     if previous_vowel:
@@ -113,9 +112,9 @@ def make_tokens(text: str) -> list[str]:
                         token_i += 1
             elif prev_info == rho_t:
                 if curr_info != breathing_t:
-                    print(f"The character {prev} in {text} should not have the accent {character} on it.")
+                    log.error("The character %s in %s should not have the accent {character} on it.", prev, text)
             else:
-                print(f"The character {prev} cannot have a diacritic on it.")
+                log.error("The character %s cannot have a diacritic on it.", prev)
         else:
             vowel_count = 0
             if prev:
@@ -123,7 +122,6 @@ def make_tokens(text: str) -> list[str]:
             set_list(tokens, token_i, (tokens[token_i] if token_i < len(tokens) else "") + character)
         prev = character
         prev_info = curr_info
-    # print(tokens)
     return tokens
 
 
@@ -267,7 +265,6 @@ def transliterate(text: str) -> str:
     output = []
     for i in range(len(tokens)):
         token = tokens[i]
-        # print("token = " + token)
         translit = gsub(UTF8_char, tt, token.lower())
         for char, repl in tt.items():
             translit = translit.replace(char, repl)
diff --git a/wikidict/lang/defaults.py b/wikidict/lang/defaults.py
index 9b115b7f..4c618877 100644
--- a/wikidict/lang/defaults.py
+++ b/wikidict/lang/defaults.py
@@ -1,8 +1,11 @@
 """Defaults values for locales without specific needs."""
 
+import logging
 import re
 from collections import defaultdict
 
+log = logging.getLogger(__name__)
+
 # Float number separator
 float_separator = ""
 
@@ -86,15 +89,11 @@ def last_template_handler(template: tuple[str, ...], locale: str, word: str = ""
 
     # {{tpl|item1|item2|...}} -> ''
     if len(template) > 2:
-        from ..render import LOCK, MISSING_TPL_SEEN
-
-        with LOCK:
-            if tpl not in MISSING_TPL_SEEN:
-                MISSING_TPL_SEEN.append(tpl)
-                print(
-                    f" !! Missing {tpl!r} template support for word {word!r}",
-                    flush=True,
-                )
+        from ..render import MISSING_TPL_SEEN
+
+        if tpl not in MISSING_TPL_SEEN:
+            MISSING_TPL_SEEN.append(tpl)
+            log.warning(" !! Missing %r template support for word %r", tpl, word)
         return ""
 
     # {{template}}
diff --git a/wikidict/parse.py b/wikidict/parse.py
index e0408c20..304b7b80 100644
--- a/wikidict/parse.py
+++ b/wikidict/parse.py
@@ -1,6 +1,7 @@
 """Parse and store raw Wiktionary data."""
 
 import json
+import logging
 import os
 from collections import defaultdict
 from collections.abc import Generator
@@ -10,6 +11,8 @@
 
 from .lang import head_sections
 
+log = logging.getLogger(__name__)
+
 
 def xml_iter_parse(file: Path) -> Generator[Element, None, None]:
     """Efficient XML parsing for big files.
@@ -63,7 +66,7 @@ def process(file: Path, locale: str) -> dict[str, str]:
     """Process the big XML file and retain only information we are interested in."""
     words: dict[str, str] = defaultdict(str)
 
-    print(f">>> Processing {file} ...", flush=True)
+    log.info(">>> Processing %s ...", file)
     for element in xml_iter_parse(file):
         word, code = xml_parse_element(element, locale)
         if word and code and ":" not in word:
@@ -78,7 +81,7 @@ def save(snapshot: str, words: dict[str, str], output_dir: Path) -> None:
     with raw_data.open(mode="w", encoding="utf-8") as fh:
         json.dump(words, fh, indent=4, sort_keys=True)
 
-    print(f">>> Saved {len(words):,} words into {raw_data}", flush=True)
+    log.info(">>> Saved %s words into %s", len(words), raw_data)
 
 
 def get_latest_xml_file(output_dir: Path) -> Path | None:
@@ -93,12 +96,12 @@ def main(locale: str) -> int:
     output_dir = Path(os.getenv("CWD", "")) / "data" / locale
     file = get_latest_xml_file(output_dir)
     if not file:
-        print(">>> No dump found. Run with --download first ... ", flush=True)
+        log.error(">>> No dump found. Run with --download first ... ")
         return 1
 
     date = file.stem.split("-")[1]
     if not (output_dir / f"data_wikicode-{date}.json").is_file():
         words = process(file, locale)
         save(date, words, output_dir)
-    print(">>> Parse done!", flush=True)
+    log.info(">>> Parse done!")
     return 0
diff --git a/wikidict/render.py b/wikidict/render.py
index 88335e1f..879f35fd 100644
--- a/wikidict/render.py
+++ b/wikidict/render.py
@@ -1,6 +1,7 @@
 """Render templates from raw data."""
 
 import json
+import logging
 import multiprocessing
 import os
 import re
@@ -50,6 +51,8 @@
 LOCK = multiprocessing.Lock()
 MISSING_TPL_SEEN: list[str] = []
 
+log = logging.getLogger(__name__)
+
 
 def find_definitions(word: str, parsed_sections: Sections, locale: str) -> list[Definitions]:
     """Find all definitions, without eventual subtext."""
@@ -469,7 +472,7 @@ def load(file: Path) -> dict[str, str]:
     """Load the JSON file containing all words and their details."""
     with file.open(encoding="utf-8") as fh:
         words: dict[str, str] = json.load(fh)
-    print(f">>> Loaded {len(words):,} words from {file}", flush=True)
+    log.info(">>> Loaded %d words from %s", len(words), file)
     return words
 
 
@@ -478,7 +481,7 @@ def render_word(w: list[str], words: Words, locale: str) -> None:
     try:
         details = parse_word(word, code, locale)
     except Exception:  # pragma: nocover
-        print(f"ERROR with {word!r}", flush=True)
+        log.exception("ERROR with %r", word)
     else:
         if details.definitions or details.variants:
             words[word] = details
@@ -504,7 +507,7 @@ def save(snapshot: str, words: Words, output_dir: Path) -> None:
     raw_data = output_dir / f"data-{snapshot}.json"
     with raw_data.open(mode="w", encoding="utf-8") as fh:
         json.dump(words, fh, indent=4, sort_keys=True)
-    print(f">>> Saved {len(words):,} words into {raw_data}", flush=True)
+    log.info(">>> Saved %d words into %s", len(words), raw_data)
 
 
 def get_latest_json_file(output_dir: Path) -> Path | None:
@@ -519,10 +522,10 @@ def main(locale: str, workers: int = multiprocessing.cpu_count()) -> int:
     output_dir = Path(os.getenv("CWD", "")) / "data" / locale
     file = get_latest_json_file(output_dir)
     if not file:
-        print(">>> No dump found. Run with --parse first ... ", flush=True)
+        log.error(">>> No dump found. Run with --parse first ... ")
         return 1
 
-    print(f">>> Loading {file} ...", flush=True)
+    log.info(">>> Loading %s ...", file)
     in_words: dict[str, str] = load(file)
 
     workers = workers or multiprocessing.cpu_count()
@@ -533,5 +536,5 @@ def main(locale: str, workers: int = multiprocessing.cpu_count()) -> int:
     date = file.stem.split("-")[1]
     save(date, words, output_dir)
 
-    print(">>> Render done!", flush=True)
+    log.info(">>> Render done!")
     return 0
diff --git a/wikidict/utils.py b/wikidict/utils.py
index 87d549b8..321b1a74 100644
--- a/wikidict/utils.py
+++ b/wikidict/utils.py
@@ -1,5 +1,6 @@
 """Utilities for internal use."""
 
+import logging
 import os
 import re
 from collections import namedtuple
@@ -62,6 +63,8 @@
 OPEN_DOUBLE_CURLY = "##opendoublecurly##"
 CLOSE_DOUBLE_CURLY = "##closedoublecurly##"
 
+log = logging.getLogger(__name__)
+
 
 def process_special_pipe_template(text: str) -> str:
     splitter = SPECIAL_TEMPLATES["{{!}}"].placeholder
@@ -89,8 +92,7 @@ def get_random_word(locale: str) -> str:
     with requests.get(url) as req:
         word = str(req.json()["query"]["random"][0]["title"])
 
-    if "CI" in os.environ:  # pragma: nocover
-        print(f"🎯 {word = }")
+    log.debug("🎯 word = %r", word)
     return word
 
 
@@ -397,7 +399,6 @@ def process_templates(word: str, wikicode: str, locale: str, callback: Callable[
         >>> process_templates("foo", "{{unknown}}", "fr")
         '{{unknown}}'
         >>> process_templates("foo", "{{foo|{{bar}}|123}}", "fr")
-         !! Missing 'foo' template support for word 'foo'
         ''
         >>> process_templates("foo", "{{fchim|OH|2|{{!}}OH|2}}", "fr")
         'OH<sub>2</sub>|OH<sub>2</sub>'
@@ -407,12 +408,10 @@ def process_templates(word: str, wikicode: str, locale: str, callback: Callable[
         >>> process_templates("octonion", " <math>V^n</math>", "fr")  # doctest: +ELLIPSIS
         '<svg ...'
         >>> process_templates("test", r"<math>\frac</math>", "fr")
-        <math> ERROR with '\\frac' in [test]: KeyError('success')
         '\\frac'
         >>> process_templates("", r"<chem>C10H14N2O4</chem>", "fr") # doctest: +ELLIPSIS
         '<svg ...'
         >>> process_templates("test", r"<chem>C10HX\xz14N2O4</chem>", "fr")
-        <chem> ERROR with 'C10HX\\xz14N2O4' in [test]: KeyError('success')
         'C10HX\\xz14N2O4'
         >>> process_templates("test", r"<hiero>R11</hiero>", "fr")
         '<table class="mw-hiero-table mw-hiero-outer" dir="ltr" style=" border: 0; border-spacing: 0; font-size:1em;"><tr><td style="padding: 0; text-align: center; vertical-align: middle; font-size:1em;">\n<table class="mw-hiero-table" style="border: 0; border-spacing: 0; font-size:1em;"><tr>\n<td style="padding: 0; text-align: center; vertical-align: middle; font-size:1em;"><img src="data:image/gif;base64...'
@@ -511,8 +510,8 @@ def convert_math(match: str | re.Match[str], word: str) -> str:
     formula: str = (match.group(1) if isinstance(match, re.Match) else match).strip()
     try:
         return formula_to_svg(formula)
-    except Exception as exc:
-        print(f"<math> ERROR with {formula!r} in [{word}]: {exc!r}", flush=True)
+    except Exception:
+        log.exception("<math> ERROR with %r in [%s]", formula, word)
         return formula
 
 
@@ -521,8 +520,8 @@ def convert_chem(match: str | re.Match[str], word: str) -> str:
     formula: str = (match.group(1) if isinstance(match, re.Match) else match).strip()
     try:
         return formula_to_svg(formula, cat="chem")
-    except Exception as exc:
-        print(f"<chem> ERROR with {formula!r} in [{word}]: {exc!r}", flush=True)
+    except Exception:
+        log.exception("<chem> ERROR with %r in [%s]", formula, word)
         return formula
 
 
@@ -558,7 +557,6 @@ def transform(word: str, template: str, locale: str) -> str:
         >>> transform("foo", "grammaire |fr", "fr")
         '<i>(Grammaire)</i>'
         >>> transform("foo", "conj|grp=1|fr", "fr")
-         !! Missing 'conj' template support for word 'foo'
         ''
 
         >>> # Magic words


Test patch:
diff --git a/tests/test_0_download.py b/tests/test_0_download.py
index b18d7af2..72e46250 100644
--- a/tests/test_0_download.py
+++ b/tests/test_0_download.py
@@ -1,7 +1,7 @@
+import logging
 import os
 from collections.abc import Callable
 from pathlib import Path
-from typing import Any
 
 import pytest
 import responses
@@ -136,21 +136,10 @@ def test_ongoing_dump(craft_data: Callable[[str], bytes]) -> None:
     assert not (output_dir / "pages-20200514.xml.bz2").is_file()
 
 
-def test_progress_callback_normal(capsys: pytest.CaptureFixture[Any]) -> None:
-    download.callback_progress("Some text: ", 42 * 1024, False)
-    captured = capsys.readouterr()
-    assert captured.out == "\rSome text: 43,008 bytes"
+def test_progress_callback(caplog: pytest.LogCaptureFixture) -> None:
+    with caplog.at_level(logging.DEBUG):
+        download.callback_progress("Some text", 42 * 1024, False)
+        download.callback_progress("Some text", 42 * 1024, True)
 
-    download.callback_progress("Some text: ", 42 * 1024, True)
-    captured = capsys.readouterr()
-    assert captured.out == "\rSome text: OK [43,008 bytes]\n"
-
-
-def test_progress_callback_ci(capsys: pytest.CaptureFixture[Any]) -> None:
-    download.callback_progress_ci("Some text: ", 42 * 1024, False)
-    captured = capsys.readouterr()
-    assert captured.out == "."
-
-    download.callback_progress_ci("Some text: ", 42 * 1024, True)
-    captured = capsys.readouterr()
-    assert captured.out == ". OK [43,008 bytes]\n"
+    assert caplog.records[0].getMessage() == "Some text: 43,008 bytes"
+    assert caplog.records[1].getMessage() == "Some text: OK [43,008 bytes]"
diff --git a/tests/test_4_check_word.py b/tests/test_4_check_word.py
index b7e7a771..b937c2bb 100644
--- a/tests/test_4_check_word.py
+++ b/tests/test_4_check_word.py
@@ -1,5 +1,4 @@
 from collections.abc import Callable
-from threading import Lock
 from typing import Any
 from unittest.mock import patch
 
@@ -89,12 +88,11 @@ def test_subsublist(craft_urls: Callable[[str, str], str]) -> None:
 
 
 @responses.activate
-def test_error_and_lock(craft_urls: Callable[[str, str], str]) -> None:
+def test_error(craft_urls: Callable[[str, str], str]) -> None:
     craft_urls("fr", "42")
     with patch.object(check_word, "contains", return_value=False):
         assert check_word.main("fr", "42") > 0
-        lock = Lock()
-        assert check_word.check_word("42", "fr", lock=lock) > 0
+        assert check_word.check_word("42", "fr") > 0
 
 
 @responses.activate
@@ -443,13 +441,13 @@ def test_check_highlighting(
     parsed_html: str,
     ret_code: int,
     is_highlighted: bool,
-    capsys: pytest.CaptureFixture[Any],
+    caplog: pytest.LogCaptureFixture,
 ) -> None:
     error = check_word.check(wiktionary_text, parsed_html, "Test")
     assert error == ret_code
     if is_highlighted:
-        stdout = capsys.readouterr()[0]
-        assert "\033[31m" in stdout
+        errors = "\n".join(m.getMessage() for m in caplog.records)
+        assert "\033[31m" in errors
 
 
 def test_get_url_content_timeout_error(monkeypatch: pytest.MonkeyPatch) -> None: