Skip to content
Merged
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
21 changes: 11 additions & 10 deletions src/scribae/cli_output.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
from __future__ import annotations

from collections.abc import Mapping
from typing import Any, cast
from contextvars import ContextVar
from typing import Any

import click
import typer

# typer 0.27 invokes command functions outside the Click context stack, so
# `click.get_current_context()` is empty inside a command. The root callback
# therefore records the flag here instead of on `ctx.obj`.
_quiet: ContextVar[bool] = ContextVar("scribae_quiet", default=False)

def _context_obj() -> Mapping[str, Any]:
context = click.get_current_context(silent=True)
if context is None or context.obj is None:
return {}
return cast(Mapping[str, Any], context.obj)

def set_quiet(quiet: bool) -> None:
_quiet.set(quiet)


def is_quiet() -> bool:
return bool(_context_obj().get("quiet", False))
return _quiet.get()


def echo_info(message: str, *, err: bool = False) -> None:
Expand All @@ -30,4 +31,4 @@ def secho_info(message: str, **kwargs: Any) -> None:
typer.secho(message, **kwargs)


__all__ = ["echo_info", "is_quiet", "secho_info"]
__all__ = ["echo_info", "is_quiet", "secho_info", "set_quiet"]
3 changes: 2 additions & 1 deletion src/scribae/init_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ def _prompt_text(label: str, description: str, example: str, *, default: str, sh
typer.secho(label, fg=typer.colors.CYAN, bold=True)
typer.echo(description)
typer.secho(f"Example: {example}", fg=typer.colors.MAGENTA)
return typer.prompt("Value", default=default, show_default=show_default)
# typer 0.27 types `prompt` as returning Any, so narrow it back to the declared return type.
return cast(str, typer.prompt("Value", default=default, show_default=show_default))


def _split_list(value: str) -> list[str]:
Expand Down
8 changes: 3 additions & 5 deletions src/scribae/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

import os

import click
import typer

from .brief_cli import brief_command
from .cli_output import set_quiet
from .feedback_cli import feedback_command
from .idea_cli import idea_command
from .init_cli import init_command
Expand Down Expand Up @@ -44,11 +44,9 @@ def app_callback(
) -> None:
"""Root Scribae CLI callback."""
setup_logging()
ctx.obj = {"quiet": quiet}
set_quiet(quiet)
if no_color or "NO_COLOR" in os.environ:
context = click.get_current_context(silent=True)
if context is not None:
context.color = False
ctx.color = False


app.command("idea", help="Brainstorm article ideas from a note with project-aware guidance.")(idea_command)
Expand Down
42 changes: 42 additions & 0 deletions tests/unit/cli_output_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from __future__ import annotations

import pytest

from scribae.cli_output import echo_info, is_quiet, secho_info, set_quiet


@pytest.fixture(autouse=True)
def _reset_quiet() -> None:
set_quiet(False)


def test_is_quiet_defaults_to_false_outside_a_cli_run() -> None:
assert is_quiet() is False


def test_set_quiet_toggles_the_flag() -> None:
set_quiet(True)

assert is_quiet() is True


def test_echo_info_is_suppressed_when_quiet(capsys: pytest.CaptureFixture[str]) -> None:
set_quiet(True)

echo_info("hello")

assert capsys.readouterr().out == ""


def test_echo_info_prints_when_not_quiet(capsys: pytest.CaptureFixture[str]) -> None:
echo_info("hello")

assert capsys.readouterr().out == "hello\n"


def test_secho_info_is_suppressed_when_quiet(capsys: pytest.CaptureFixture[str]) -> None:
set_quiet(True)

secho_info("hello")

assert capsys.readouterr().out == ""
99 changes: 52 additions & 47 deletions tests/unit/init_cli_test.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from pathlib import Path

import pytest
import yaml
from typer.testing import CliRunner

Expand All @@ -9,6 +10,16 @@
runner = CliRunner()


@pytest.fixture(autouse=True)
def _isolated_cwd(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Run every test in an empty directory.

Click 8.5 dropped ``CliRunner.isolated_filesystem``; ``tmp_path`` gives the same
isolation and pytest cleans it up.
"""
monkeypatch.chdir(tmp_path)


def _questionnaire_input() -> str:
return "\n".join(
[
Expand All @@ -24,75 +35,69 @@ def _questionnaire_input() -> str:


def test_init_writes_config_in_current_dir() -> None:
with runner.isolated_filesystem():
result = runner.invoke(app, ["init"], input=_questionnaire_input())

assert result.exit_code == 0
config_path = Path("scribae.yaml")
assert config_path.exists()
payload = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert payload["site_name"] == "Scribae Blog"
assert payload["domain"] == "https://example.com"
assert payload["audience"] == "developers and writers"
assert payload["tone"] == "friendly and practical"
assert payload["keywords"] == ["seo", "content strategy"]
assert payload["language"] == "en"
assert payload["allowed_tags"] == ["product-analytics", "case-study", "compliance"]
result = runner.invoke(app, ["init"], input=_questionnaire_input())

assert result.exit_code == 0
config_path = Path("scribae.yaml")
assert config_path.exists()
payload = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert payload["site_name"] == "Scribae Blog"
assert payload["domain"] == "https://example.com"
assert payload["audience"] == "developers and writers"
assert payload["tone"] == "friendly and practical"
assert payload["keywords"] == ["seo", "content strategy"]
assert payload["language"] == "en"
assert payload["allowed_tags"] == ["product-analytics", "case-study", "compliance"]


def test_init_prompts_allowed_tag_example() -> None:
with runner.isolated_filesystem():
result = runner.invoke(app, ["init"], input=_questionnaire_input())
result = runner.invoke(app, ["init"], input=_questionnaire_input())

assert result.exit_code == 0
output = strip_ansi(result.output)
assert "Allowed metadata tags" in output
assert "Example: product-analytics, case-study, compliance" in output
assert result.exit_code == 0
output = strip_ansi(result.output)
assert "Allowed metadata tags" in output
assert "Example: product-analytics, case-study, compliance" in output


def test_init_writes_config_in_project_dir() -> None:
with runner.isolated_filesystem():
result = runner.invoke(app, ["init", "--project", "demo"], input=_questionnaire_input())
result = runner.invoke(app, ["init", "--project", "demo"], input=_questionnaire_input())

assert result.exit_code == 0
config_path = Path("demo") / "scribae.yaml"
assert config_path.exists()
assert result.exit_code == 0
config_path = Path("demo") / "scribae.yaml"
assert config_path.exists()


def test_init_writes_config_to_custom_file() -> None:
with runner.isolated_filesystem():
result = runner.invoke(
app,
["init", "--file", "config/custom.yaml"],
input=_questionnaire_input(),
)
result = runner.invoke(
app,
["init", "--file", "config/custom.yaml"],
input=_questionnaire_input(),
)

assert result.exit_code == 0
config_path = Path("config") / "custom.yaml"
assert config_path.exists()
assert result.exit_code == 0
config_path = Path("config") / "custom.yaml"
assert config_path.exists()


def test_init_prompts_before_overwrite() -> None:
with runner.isolated_filesystem():
config_path = Path("scribae.yaml")
config_path.write_text("site_name: old", encoding="utf-8")
config_path = Path("scribae.yaml")
config_path.write_text("site_name: old", encoding="utf-8")

result = runner.invoke(app, ["init"], input="n\n")
result = runner.invoke(app, ["init"], input="n\n")

assert result.exit_code != 0
assert config_path.read_text(encoding="utf-8") == "site_name: old"
assert result.exit_code != 0
assert config_path.read_text(encoding="utf-8") == "site_name: old"


def test_init_force_overwrites_existing_file() -> None:
with runner.isolated_filesystem():
config_path = Path("scribae.yaml")
config_path.write_text("site_name: old", encoding="utf-8")
config_path = Path("scribae.yaml")
config_path.write_text("site_name: old", encoding="utf-8")

result = runner.invoke(app, ["init", "--force"], input=_questionnaire_input())
result = runner.invoke(app, ["init", "--force"], input=_questionnaire_input())

assert result.exit_code == 0
payload = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert payload["site_name"] == "Scribae Blog"
assert result.exit_code == 0
payload = yaml.safe_load(config_path.read_text(encoding="utf-8"))
assert payload["site_name"] == "Scribae Blog"


def test_init_rejects_project_and_file_options() -> None:
Expand Down
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.