From f250b8a09a4487abdbae348ac93666a9dd19ea3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20M=C3=BCller?= Date: Sat, 21 Feb 2026 12:39:32 +0100 Subject: [PATCH] refactor: centralize shared helper utilities --- src/scribae/brief.py | 20 +++------------- src/scribae/common.py | 24 ++++++++++++++++++++ src/scribae/feedback.py | 6 +---- src/scribae/idea.py | 21 +++-------------- src/scribae/io_utils.py | 4 +--- src/scribae/language.py | 7 ++---- src/scribae/meta.py | 13 ++--------- src/scribae/refine.py | 19 ++++------------ src/scribae/write.py | 12 ++-------- tests/unit/common_test.py | 48 +++++++++++++++++++++++++++++++++++++++ 10 files changed, 90 insertions(+), 84 deletions(-) create mode 100644 src/scribae/common.py create mode 100644 tests/unit/common_test.py diff --git a/src/scribae/brief.py b/src/scribae/brief.py index 9e07aaa..12c1f6a 100644 --- a/src/scribae/brief.py +++ b/src/scribae/brief.py @@ -2,10 +2,8 @@ import asyncio import json -import re from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime from pathlib import Path from typing import Any, cast @@ -13,6 +11,9 @@ from pydantic_ai import Agent, NativeOutput, UnexpectedModelBehavior from pydantic_ai.settings import ModelSettings +from .common import current_timestamp as _current_timestamp +from .common import report as _report +from .common import slugify as _slugify from .idea import Idea, IdeaList from .io_utils import NoteDetails, Reporter, load_note from .language import LanguageMismatchError, LanguageResolutionError, ensure_language_output, resolve_output_language @@ -362,15 +363,6 @@ async def _call() -> SeoBrief: return asyncio.run(asyncio.wait_for(_call(), timeout_seconds)) -def _current_timestamp() -> str: - return datetime.now().strftime("%Y%m%d-%H%M%S") - - -def _slugify(value: str) -> str: - lowered = value.lower() - return re.sub(r"[^a-z0-9]+", "-", lowered).strip("-") - - def _brief_language_text(brief: SeoBrief) -> str: faq_text = "\n".join(f"{item.question} {item.answer}" for item in brief.faq) outline_text = "\n".join(brief.outline) @@ -419,9 +411,3 @@ def _metadata_idea_id(metadata: dict[str, Any]) -> str | None: return None value = raw.strip() if isinstance(raw, str) else str(raw).strip() return value or None - - -def _report(reporter: Reporter, message: str) -> None: - """Send verbose output when enabled.""" - if reporter: - reporter(message) diff --git a/src/scribae/common.py b/src/scribae/common.py new file mode 100644 index 0000000..dcb3c2c --- /dev/null +++ b/src/scribae/common.py @@ -0,0 +1,24 @@ +from __future__ import annotations + +import re +from collections.abc import Callable +from datetime import datetime + +Reporter = Callable[[str], None] | None + + +def slugify(value: str) -> str: + lowered = value.lower() + return re.sub(r"[^a-z0-9]+", "-", lowered).strip("-") + + +def report(reporter: Reporter, message: str) -> None: + if reporter: + reporter(message) + + +def current_timestamp() -> str: + return datetime.now().strftime("%Y%m%d-%H%M%S") + + +__all__ = ["Reporter", "current_timestamp", "report", "slugify"] diff --git a/src/scribae/feedback.py b/src/scribae/feedback.py index cba81a4..d0abbd1 100644 --- a/src/scribae/feedback.py +++ b/src/scribae/feedback.py @@ -14,6 +14,7 @@ from pydantic_ai.settings import ModelSettings from .brief import SeoBrief +from .common import report as _report from .io_utils import NoteDetails, Reporter, load_note, truncate from .language import LanguageMismatchError, LanguageResolutionError, ensure_language_output, resolve_output_language from .llm import LLM_OUTPUT_RETRIES, LLM_TIMEOUT_SECONDS, OpenAISettings, apply_optional_settings, make_model @@ -726,11 +727,6 @@ def _format_location(location: FeedbackLocation | None) -> str: return f" ({'; '.join(details)})" if details else "" -def _report(reporter: Reporter, message: str) -> None: - if reporter: - reporter(message) - - __all__ = [ "BriefAlignment", "FeedbackFinding", diff --git a/src/scribae/idea.py b/src/scribae/idea.py index c3e59d2..4fbd5e8 100644 --- a/src/scribae/idea.py +++ b/src/scribae/idea.py @@ -2,10 +2,8 @@ import asyncio import json -import re from collections.abc import Callable from dataclasses import dataclass -from datetime import datetime from pathlib import Path from typing import cast @@ -13,6 +11,9 @@ from pydantic_ai import Agent, NativeOutput, UnexpectedModelBehavior from pydantic_ai.settings import ModelSettings +from .common import current_timestamp as _current_timestamp +from .common import report as _report +from .common import slugify as _slugify from .io_utils import NoteDetails, Reporter, load_note from .language import LanguageMismatchError, LanguageResolutionError, ensure_language_output, resolve_output_language from .llm import ( @@ -266,22 +267,6 @@ async def _call() -> IdeaList: return asyncio.run(asyncio.wait_for(_call(), timeout_seconds)) -def _current_timestamp() -> str: - return datetime.now().strftime("%Y%m%d-%H%M%S") - - -def _slugify(value: str) -> str: - lowered = value.lower() - return re.sub(r"[^a-z0-9]+", "-", lowered).strip("-") - - -def _report(reporter: Reporter, message: str) -> None: - """Send verbose output when enabled.""" - - if reporter: - reporter(message) - - __all__ = [ "Idea", "IdeaContext", diff --git a/src/scribae/io_utils.py b/src/scribae/io_utils.py index 9b9517b..94792b6 100644 --- a/src/scribae/io_utils.py +++ b/src/scribae/io_utils.py @@ -1,14 +1,12 @@ from __future__ import annotations -from collections.abc import Callable from dataclasses import dataclass from pathlib import Path from typing import Any import frontmatter -# Type alias for verbose output callbacks used across modules. -Reporter = Callable[[str], None] | None +from .common import Reporter @dataclass(frozen=True) diff --git a/src/scribae/language.py b/src/scribae/language.py index 6615ac1..730cd65 100644 --- a/src/scribae/language.py +++ b/src/scribae/language.py @@ -4,6 +4,8 @@ from dataclasses import dataclass from typing import Any +from .common import report as _report + class LanguageResolutionError(Exception): """Raised when the output language cannot be determined.""" @@ -174,11 +176,6 @@ def _clean_language(value: Any) -> str | None: return cleaned or None -def _report(reporter: Callable[[str], None] | None, message: str) -> None: - if reporter: - reporter(message) - - __all__ = [ "LanguageResolution", "LanguageResolutionError", diff --git a/src/scribae/meta.py b/src/scribae/meta.py index 27cc116..ca6f1d4 100644 --- a/src/scribae/meta.py +++ b/src/scribae/meta.py @@ -2,7 +2,6 @@ import asyncio import json -import re from collections.abc import Callable from dataclasses import dataclass from pathlib import Path @@ -15,6 +14,8 @@ from pydantic_ai.settings import ModelSettings from .brief import SeoBrief +from .common import report as _report +from .common import slugify as _slugify from .io_utils import Reporter, truncate from .language import LanguageMismatchError, LanguageResolutionError, ensure_language_output, resolve_output_language from .llm import LLM_OUTPUT_RETRIES, LLM_TIMEOUT_SECONDS, OpenAISettings, apply_optional_settings, make_model @@ -622,11 +623,6 @@ def _clean_text(value: Any) -> str | None: return text or None -def _slugify(value: str) -> str: - lowered = value.lower() - return re.sub(r"[^a-z0-9]+", "-", lowered).strip("-") - - def _is_missing(value: Any) -> bool: return ( value is None @@ -635,11 +631,6 @@ def _is_missing(value: Any) -> bool: ) -def _report(reporter: Reporter, message: str) -> None: - if reporter: - reporter(message) - - __all__ = [ "ArticleMeta", "BodyDocument", diff --git a/src/scribae/refine.py b/src/scribae/refine.py index b5413a2..0e827da 100644 --- a/src/scribae/refine.py +++ b/src/scribae/refine.py @@ -13,6 +13,9 @@ from pydantic_ai.settings import ModelSettings from .brief import SeoBrief +from .common import Reporter +from .common import report as _report +from .common import slugify as _slugify from .io_utils import NoteDetails, load_note from .language import ( LanguageMismatchError, @@ -25,8 +28,6 @@ from .prompts.refine import SYSTEM_PROMPT, build_changelog_prompt, build_user_prompt from .snippets import SnippetSelection, build_snippet_block -Reporter = Callable[[str], None] | None - class RefiningError(Exception): """Base class for refine command failures.""" @@ -514,9 +515,7 @@ def _prepare_sections( draft_section = draft.sections[index - 1] anchor = draft_section.anchor if preserve_anchors else None heading = _compose_heading(title, anchor=anchor) - refined_sections.append( - RefinedSection(index=index, title=title, heading=heading, body=draft_section.body) - ) + refined_sections.append(RefinedSection(index=index, title=title, heading=heading, body=draft_section.body)) if not refined_sections: raise RefiningValidationError("No outline sections selected.") return refined_sections @@ -635,16 +634,6 @@ def _save_section_artifacts(directory: Path, section: RefinedSection, prompt: st raise RefiningFileError(f"Unable to save prompt artifacts: {exc}") from exc -def _slugify(value: str) -> str: - lowered = value.lower() - return re.sub(r"[^a-z0-9]+", "-", lowered).strip("-") - - -def _report(reporter: Reporter, message: str) -> None: - if reporter: - reporter(message) - - __all__ = [ "EvidenceMode", "RefinementIntensity", diff --git a/src/scribae/write.py b/src/scribae/write.py index 9af55b4..e3e7a95 100644 --- a/src/scribae/write.py +++ b/src/scribae/write.py @@ -14,6 +14,8 @@ from pydantic_ai.settings import ModelSettings from .brief import SeoBrief +from .common import report as _report +from .common import slugify as _slugify from .io_utils import NoteDetails, Reporter, load_note from .language import ( LanguageMismatchError, @@ -500,16 +502,6 @@ def _save_section_artifacts(directory: Path, section: SectionSpec, prompt: str, raise WritingFileError(f"Unable to save prompt artifacts: {exc}") from exc -def _slugify(value: str) -> str: - lowered = value.lower() - return re.sub(r"[^a-z0-9]+", "-", lowered).strip("-") - - -def _report(reporter: Reporter, message: str) -> None: - if reporter: - reporter(message) - - __all__ = [ "EvidenceMode", "WritingContext", diff --git a/tests/unit/common_test.py b/tests/unit/common_test.py new file mode 100644 index 0000000..1cd4969 --- /dev/null +++ b/tests/unit/common_test.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import re + +from scribae.common import Reporter, current_timestamp, report, slugify + + +def test_slugify_normalizes_text() -> None: + assert slugify("Hello, World!") == "hello-world" + + +def test_slugify_strips_edge_separators() -> None: + assert slugify("---Title---") == "title" + + +def test_report_calls_reporter_when_present() -> None: + messages: list[str] = [] + + def reporter(message: str) -> None: + messages.append(message) + + report(reporter, "done") + + assert messages == ["done"] + + +def test_report_is_noop_without_reporter() -> None: + report(None, "ignored") + + +def test_current_timestamp_matches_expected_format() -> None: + stamp = current_timestamp() + assert re.fullmatch(r"\d{8}-\d{6}", stamp) + + +def test_reporter_type_alias_accepts_callable_and_none() -> None: + captured: list[str] = [] + + def sink(message: str) -> None: + captured.append(message) + + reporter_callable: Reporter = sink + reporter_none: Reporter = None + + assert reporter_callable is not None + reporter_callable("ok") + assert reporter_none is None + assert captured == ["ok"]