Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 3 additions & 17 deletions src/scribae/brief.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@

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

from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
24 changes: 24 additions & 0 deletions src/scribae/common.py
Original file line number Diff line number Diff line change
@@ -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"]
6 changes: 1 addition & 5 deletions src/scribae/feedback.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
21 changes: 3 additions & 18 deletions src/scribae/idea.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@

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

from pydantic import BaseModel, ConfigDict, Field, field_validator
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 (
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 1 addition & 3 deletions src/scribae/io_utils.py
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
7 changes: 2 additions & 5 deletions src/scribae/language.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 2 additions & 11 deletions src/scribae/meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import asyncio
import json
import re
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -635,11 +631,6 @@ def _is_missing(value: Any) -> bool:
)


def _report(reporter: Reporter, message: str) -> None:
if reporter:
reporter(message)


__all__ = [
"ArticleMeta",
"BodyDocument",
Expand Down
19 changes: 4 additions & 15 deletions src/scribae/refine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
12 changes: 2 additions & 10 deletions src/scribae/write.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
48 changes: 48 additions & 0 deletions tests/unit/common_test.py
Original file line number Diff line number Diff line change
@@ -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"]