diff --git a/.claude/rules/copier/template-conventions.md b/.claude/rules/copier/template-conventions.md index 04392a6..363a65b 100644 --- a/.claude/rules/copier/template-conventions.md +++ b/.claude/rules/copier/template-conventions.md @@ -112,7 +112,7 @@ Tasks use `/bin/sh` (POSIX shell), not bash. Use POSIX-compatible syntax. - `copier update` uses these tags to select the appropriate template version. - Introduce `_migrations` in `copier.yml` when a new version renames or removes template files, to guide users through the update. -- See `scripts/bump_version.py` and `.github/workflows/release.yml` for the release +- See `src/{{ package_name }}/common/bump_version.py` and `.github/workflows/release.yml` for the release automation workflow. ## Dual-hierarchy maintenance diff --git a/copier.yml b/copier.yml index 6305ba4..71f5cf4 100644 --- a/copier.yml +++ b/copier.yml @@ -34,9 +34,10 @@ _skip_if_exists: - mkdocs.yml - pyproject.toml - README.md - - scripts/bump_version.py - SECURITY.md - src/{{ package_name }}/__init__.py + - src/{{ package_name }}/common/bump_version.py + # ------------------------------------------------------------------------- # Questions / Prompts @@ -140,15 +141,10 @@ include_cli: help: Generate a Typer-based CLI entry point and console script? default: false -include_logging_setup: - type: bool - help: Add a logging_config module with configure_logging() (stdlib logging + structlog)? - default: false - include_git_cliff: type: bool help: Add git-cliff, cliff.toml, and a just changelog recipe for release notes? - default: false + default: true # ------------------------------------------------------------------------- # Computed values @@ -182,7 +178,7 @@ github_actions_python_versions: # when the host Python lacks `ensurepip`/`pip` (common on minimal distro Pythons). # - To avoid template generation failing on machines without `uv`, we bootstrap `uv` first. # We try `pip` if available; otherwise we fall back to Astral's installer script. -# - Keep tasks idempotent. It's okay if `uv self update` fails (offline / restricted envs). +# - Keep tasks idempotent. # # Copy vs update: these tasks run after both `copier copy` and `copier update` (not gated # with _copier_operation) so the lockfile and local env stay aligned with the template after @@ -226,7 +222,6 @@ _tasks: command -v uv >/dev/null 2>&1 ' - - command: uv self update || true - command: uv lock - command: >- uv sync --frozen --extra dev --extra test @@ -238,14 +233,14 @@ _tasks: - command: uv run basedpyright - command: rm -f cliff.toml when: "{{ not include_git_cliff }}" - - command: rm -f src/{{ package_name }}/logging_config.py - when: "{{ not include_logging_setup }}" - command: rm -f src/{{ package_name }}/cli.py when: "{{ not include_cli }}" - command: rm -f mkdocs.yml when: "{{ not include_docs }}" - command: sh -c 'rm -f docs/index.md docs/ci.md 2>/dev/null; rmdir docs 2>/dev/null || true' when: "{{ not include_docs }}" + - command: >- + sh -c 'find . -type f -empty ! -path "./.git/*" ! -path "./.venv/*" -delete' - command: echo "โœ… Project {{ project_name }} created successfully!" - command: echo "๐Ÿ“ Location $(pwd)" - command: echo "๐Ÿš€ Run 'just ci' to verify everything works" diff --git a/pyproject.toml b/pyproject.toml index 90e5ef5..df095bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ skip_covered = false pythonVersion = "3.11" typeCheckingMode = "standard" reportMissingImports = true +include = ["tests", "scripts"] reportImplicitOverride = false reportMissingTypeStubs = false reportUnknownMemberType = false diff --git a/scripts/sync_skip_if_exists.py b/scripts/sync_skip_if_exists.py index 5d49b9c..082d602 100644 --- a/scripts/sync_skip_if_exists.py +++ b/scripts/sync_skip_if_exists.py @@ -27,7 +27,9 @@ "template/.github/CODE_OF_CONDUCT.md.jinja": ".github/CODE_OF_CONDUCT.md", "template/.github/ISSUE_TEMPLATE/bug_report.md.jinja": ".github/ISSUE_TEMPLATE/bug_report.md", "template/.github/ISSUE_TEMPLATE/feature_request.md.jinja": ".github/ISSUE_TEMPLATE/feature_request.md", - "template/scripts/bump_version.py.jinja": "scripts/bump_version.py", + "template/src/{{ package_name }}/common/bump_version.py.jinja": ( + "src/{{ package_name }}/common/bump_version.py" + ), } # Always include these (user customization hotspots even if not in the map above). @@ -44,7 +46,7 @@ ".github/CODE_OF_CONDUCT.md", ".github/ISSUE_TEMPLATE/bug_report.md", ".github/ISSUE_TEMPLATE/feature_request.md", - "scripts/bump_version.py", + "src/{{ package_name }}/common/bump_version.py", ] # Paths touched at least this often in recent history are added to the skip list. diff --git a/template/.github/workflows/release.yml.jinja b/template/.github/workflows/release.yml.jinja index 1d984cf..3e91802 100644 --- a/template/.github/workflows/release.yml.jinja +++ b/template/.github/workflows/release.yml.jinja @@ -92,9 +92,9 @@ jobs: fi if [ -n "{% raw %}${{ inputs.version }}{% endraw %}" ]; then - NEW_VERSION="$(uv run python scripts/bump_version.py --new-version "{% raw %}${{ inputs.version }}{% endraw %}")" + NEW_VERSION="$(uv run python src/{{ package_name }}/common/bump_version.py --new-version "{% raw %}${{ inputs.version }}{% endraw %}")" else - NEW_VERSION="$(uv run python scripts/bump_version.py --bump "{% raw %}${{ inputs.bump }}{% endraw %}")" + NEW_VERSION="$(uv run python src/{{ package_name }}/common/bump_version.py --bump "{% raw %}${{ inputs.bump }}{% endraw %}")" fi echo "version=${NEW_VERSION}" >> "${GITHUB_OUTPUT}" diff --git a/template/justfile.jinja b/template/justfile.jinja index 83a96b5..950a146 100644 --- a/template/justfile.jinja +++ b/template/justfile.jinja @@ -58,15 +58,43 @@ type: # ------------------------------------------------------------------------- # Testing +# +# Default: Minimal logging (quiet mode, dots only, warnings/errors only) +# For verbose output with test names, use: just test-verbose +# For full debug output, use: just test-debug +# For failed tests only, use: just test-lf # ------------------------------------------------------------------------- -test: _set_env +# Run tests with minimal output (default, fast, token-efficient) +test: + @uv run --active pytest tests/ + +# Run only slow tests +slow: + @uv run --active pytest tests/ -m slow + +# Run tests in parallel with minimal output +test-parallel: + @uv run --active pytest tests/ -n auto + +# Run tests with verbose output (shows test names, INFO logs) +test-verbose: @uv run --active pytest tests/ -v -test-parallel: _set_env - @uv run --active pytest tests/ -v -n auto +# Run tests with full debug output (shows all DEBUG logs) +test-debug: + @uv run --active pytest tests/ -vv --show-debug + +# Re-run only the tests that failed in the last run +test-lf: + @uv run --active pytest tests/ --lf + +# Stop on first test failure (fast feedback) +test-first-fail: + @uv run --active pytest tests/ -x -coverage: _set_env +# Run tests with coverage report (full HTML/XML/term output) +coverage: @uv run --active pytest tests/ \ --cov={{ package_name }} \ --cov-report=term-missing \ diff --git a/template/pyproject.toml.jinja b/template/pyproject.toml.jinja index cc2b0a9..4c94b1f 100644 --- a/template/pyproject.toml.jinja +++ b/template/pyproject.toml.jinja @@ -159,11 +159,20 @@ addopts = [ "--cov={{ package_name }}", "--cov-report=term-missing", "--cov-report=xml", + "--tb=short", + "--log-level=WARNING", + "-m", "not slow", + "-p", "no:cacheprovider", + "-ra", ] markers = [ "slow: marks tests as slow (deselect with '-m \"not slow\"')", "integration: marks tests as integration tests", ] +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", +] # ========================================================================== # Coverage Configuration @@ -177,7 +186,6 @@ omit = [ "**/__init__.py", "*/tests/*", "*/__pycache__/*", - "*/common/*", ] [tool.coverage.report] diff --git a/template/scripts/bump_version.py.jinja b/template/src/{{ package_name }}/common/bump_version.py.jinja similarity index 100% rename from template/scripts/bump_version.py.jinja rename to template/src/{{ package_name }}/common/bump_version.py.jinja diff --git a/template/src/{{ package_name }}/common/logging_manager.py.jinja b/template/src/{{ package_name }}/common/logging_manager.py.jinja index 05f290e..ba9af1b 100644 --- a/template/src/{{ package_name }}/common/logging_manager.py.jinja +++ b/template/src/{{ package_name }}/common/logging_manager.py.jinja @@ -41,10 +41,14 @@ from __future__ import annotations import logging import os import sys -from typing import Any +import textwrap +import threading +from typing import TYPE_CHECKING, Any import structlog -from structlog.types import EventDict, WrappedLogger + +if TYPE_CHECKING: + from structlog.types import EventDict, WrappedLogger # --------------------------------------------------------------------------- # Execution-context detection @@ -79,6 +83,33 @@ EXECUTION_CONTEXT: str = _detect_context() #: ``True`` when running in LLM mode. Use for cheap conditional checks. IS_LLM: bool = EXECUTION_CONTEXT == "llm" +#: Column width used when wrapping or drawing separator lines in human mode. +LOG_LINE_WIDTH: int = 50 + +#: Key column width used to align ``:`` separators in :func:`log_fields`. +PADDING_WIDTH: int = 15 + +# --------------------------------------------------------------------------- +# One-time configuration state (thread-safe) +# --------------------------------------------------------------------------- + +_configured = False +_config_signature: tuple[str, str] | None = None +_config_lock = threading.Lock() + + +def _reset_configuration() -> None: + """Reset configuration state for testing purposes. + + This function is private and should only be used in test fixtures to + allow configure_logging() to be called with different settings in separate + tests without raising RuntimeError. + """ + global _configured, _config_signature + with _config_lock: + _configured = False + _config_signature = None + # --------------------------------------------------------------------------- # Custom processors @@ -90,22 +121,6 @@ def _filter_llm_events( method: str, # noqa: ARG001 event_dict: EventDict, ) -> EventDict: - """Drop events that are not tagged ``llm=True`` in LLM mode. - - Removes the ``llm`` sentinel key before the event reaches the renderer so - it does not appear in final output. - - Args: - logger: Structlog-wrapped logger (unused). - method: Log method name (e.g. ``"info"``, ``"warning"``). - event_dict: Mutable event dictionary for this log call. - - Returns: - ``event_dict`` with the ``llm`` key stripped, when the event passes. - - Raises: - structlog.DropEvent: When ``llm`` is absent or falsy. - """ if not event_dict.pop("llm", False): raise structlog.DropEvent() return event_dict @@ -116,24 +131,40 @@ def _drop_debug_events( method: str, event_dict: EventDict, ) -> EventDict: - """Suppress DEBUG and TRACE events unconditionally in LLM mode. + if method in ("debug", "trace"): + raise structlog.DropEvent() + return event_dict - Args: - logger: Structlog-wrapped logger (unused). - method: Log method name. - event_dict: Mutable event dictionary. - Returns: - ``event_dict`` unchanged for non-debug methods. +_LEVEL_SYMBOLS: dict[str, str] = { + "debug": "ยท", + "info": ">", + "warning": "โš ", + "error": "โœ–", + "critical": "โœ–โœ–", +} - Raises: - structlog.DropEvent: For ``"debug"`` or ``"trace"`` log methods. - """ - if method in ("debug", "trace"): - raise structlog.DropEvent() + +def _human_level_indicator( + logger: WrappedLogger, # noqa: ARG001 + method: str, + event_dict: EventDict, +) -> EventDict: + symbol = _LEVEL_SYMBOLS.get(method, "?") + event_dict["event"] = f"{symbol} {event_dict.get('event', '')}" return event_dict +def _llm_promote_level( + logger: WrappedLogger, # noqa: ARG001 + method: str, + event_dict: EventDict, +) -> EventDict: + level = event_dict.pop("level", method) + event = event_dict.pop("event", "") + return {"level": level, "event": event, **event_dict} + + def _truncate_long_values( logger: WrappedLogger, # noqa: ARG001 method: str, # noqa: ARG001 @@ -142,21 +173,6 @@ def _truncate_long_values( max_len: int = 200, truncate_to: int = 100, ) -> EventDict: - """Shorten string values that exceed *max_len* characters. - - The replacement format is ``"โ€ฆ[len=]"``. - The ``event`` key is never truncated to preserve the event name. - - Args: - logger: Structlog-wrapped logger (unused). - method: Log method name (unused). - event_dict: Mutable event dictionary. - max_len: Threshold above which a string value is truncated. - truncate_to: Number of characters to retain from the start. - - Returns: - ``event_dict`` with oversized string values shortened. - """ for key, value in event_dict.items(): if key == "event": continue @@ -170,27 +186,12 @@ def _flatten_nested_dicts( method: str, # noqa: ARG001 event_dict: EventDict, ) -> EventDict: - """Inline one level of nested dict values using dot-separated keys. - - Keeps the schema flat as required by the LLM logging conventions. - Only the first level of nesting is expanded; deeper nesting is left as-is. - The ``exception`` key is excluded from flattening. - - Args: - logger: Structlog-wrapped logger (unused). - method: Log method name (unused). - event_dict: Mutable event dictionary. - - Returns: - ``event_dict`` with one level of nested dicts inlined. - """ to_expand = [ - key - for key, value in event_dict.items() + key for key, value in event_dict.items() if isinstance(value, dict) and key != "exception" ] for key in to_expand: - nested: dict[str, Any] = event_dict.pop(key) # type: ignore[assignment] + nested: dict[str, Any] = event_dict.pop(key) for nested_key, nested_value in nested.items(): event_dict[f"{key}.{nested_key}"] = nested_value return event_dict @@ -216,9 +217,14 @@ def configure_logging( ``EXECUTION_CONTEXT`` Set to ``"llm"`` to activate LLM mode, ``"human"`` to force human mode. ``HUMAN_LOG_LEVEL`` - Log level for human mode (default ``"DEBUG"``). + Log level for human mode (default ``"INFO"``). ``LLM_LOG_LEVEL`` Log level for LLM mode (default ``"WARNING"``). + Accepted values: ``"DEBUG"``, ``"INFO"``, ``"WARNING"``. + + * ``WARNING`` โ€” only WARNING/ERROR events tagged ``llm=True`` (default, minimal tokens). + * ``INFO`` โ€” adds INFO stage-summary events tagged ``llm=True`` (pipeline trace). + * ``DEBUG`` โ€” adds DEBUG events tagged ``llm=True`` (full diagnostic trace). Args: level: Override the effective log level (e.g. ``"DEBUG"``, ``"WARNING"``). @@ -230,6 +236,8 @@ def configure_logging( >>> configure_logging() # auto-detect >>> configure_logging(level="ERROR", context="llm") # explicit LLM """ + global _configured, _config_signature + resolved_context = context if context is not None else EXECUTION_CONTEXT is_llm = resolved_context == "llm" @@ -239,7 +247,22 @@ def configure_logging( elif is_llm: level_str = os.getenv("LLM_LOG_LEVEL", "WARNING").upper() else: - level_str = os.getenv("HUMAN_LOG_LEVEL", "DEBUG").upper() + level_str = os.getenv("HUMAN_LOG_LEVEL", "INFO").upper() + + signature = (resolved_context, level_str) + + # Idempotent + thread-safe guard + with _config_lock: + if _configured: + if signature != _config_signature: + raise RuntimeError( + "configure_logging() called multiple times with different settings: " + f"first={_config_signature}, second={signature}" + ) + return + + _configured = True + _config_signature = signature numeric_level = getattr(logging, level_str, logging.INFO) @@ -251,7 +274,6 @@ def configure_logging( # ---- Build processor chain -------------------------------------------- _shared: list[Any] = [ structlog.contextvars.merge_contextvars, - structlog.stdlib.add_logger_name, structlog.stdlib.add_log_level, structlog.processors.TimeStamper(fmt="iso", utc=True), structlog.stdlib.PositionalArgumentsFormatter(), @@ -260,19 +282,34 @@ def configure_logging( if is_llm: # LLM MODE: filter aggressively, emit compact JSON only. + # Key ordering: level โ†’ event โ†’ remaining fields so severity is + # immediately visible when scanning raw JSON output. + # + # _drop_debug_events is only inserted when the effective level is + # above DEBUG. When LLM_LOG_LEVEL=DEBUG the stdlib level-filter on + # the bound logger already lets debug events through; the processor + # must not block them a second time, so that llm=True debug events + # are visible to the agent. processors: list[Any] = [ *_shared, - _drop_debug_events, # drop DEBUG/TRACE first (cheap) - _filter_llm_events, # drop events without llm=True - _truncate_long_values, # cap string lengths - _flatten_nested_dicts, # enforce flat schema + *([_drop_debug_events] if numeric_level > logging.DEBUG else []), + _filter_llm_events, # drop events without llm=True + _truncate_long_values, # cap string lengths + _flatten_nested_dicts, # enforce flat schema + _llm_promote_level, # level โ†’ event โ†’ ... ordering structlog.processors.ExceptionRenderer(), structlog.processors.JSONRenderer(sort_keys=False), ] else: # HUMAN MODE: full verbosity with coloured console output. + # Each level gets a distinct symbol prefix in addition to colour: + # ยท debug (quiet, secondary) + # > info (normal) + # โš  warning (attention) + # โœ– error/critical (failure) processors = [ *_shared, + _human_level_indicator, # symbol prefix before rendering structlog.processors.ExceptionRenderer(), structlog.dev.ConsoleRenderer( colors=sys.stderr.isatty(), @@ -284,7 +321,7 @@ def configure_logging( processors=processors, wrapper_class=structlog.make_filtering_bound_logger(numeric_level), context_class=dict, - logger_factory=structlog.PrintLoggerFactory(file=sys.stderr), + logger_factory=structlog.stdlib.LoggerFactory(), cache_logger_on_first_use=True, ) @@ -320,6 +357,8 @@ def get_logger(name: str | None = None, **initial_values: Any) -> Any: >>> log = get_logger(__name__, service="payment") >>> log.info("payment_started", order_id=123) """ + configure_logging() + bound = structlog.get_logger() if name: bound = bound.bind(logger=name) @@ -328,19 +367,6 @@ def get_logger(name: str | None = None, **initial_values: Any) -> Any: return bound -def log_message(message: str) -> None: - """Emit a human-readable diagnostic line at INFO level. - - Prefer structured events via :func:`get_logger` and keyword arguments for - application logging. This helper supports decorators and startup utilities - that need a simple string record. - - Args: - message: Plain-text message to log. - """ - structlog.get_logger().info("diagnostic", message=message) - - def bind_context(**kwargs: Any) -> None: """Bind key-value pairs to the current thread / async-task context. @@ -369,3 +395,144 @@ def clear_context() -> None: >>> clear_context() """ structlog.contextvars.clear_contextvars() + + +# --------------------------------------------------------------------------- +# Human-mode formatting helpers +# --------------------------------------------------------------------------- + + +def wrap_text(text: str, *, width: int = LOG_LINE_WIDTH) -> str: + """Wrap *text* to *width* columns, preserving existing newlines. + + Args: + text: Input string to wrap. + width: Maximum column width (default :data:`LOG_LINE_WIDTH`). + + Returns: + Wrapped string. + """ + return textwrap.fill(text, width=width) + + +def list_to_numbered_string(items: list[Any]) -> str: + r"""Convert a list to a newline-prefixed numbered string. + + Args: + items: List of items to format. + + Returns: + A string starting with ``\n`` followed by ``1. item`` lines. + """ + if not items: + return "" + lines = "\n".join(f"{i + 1}. {item}" for i, item in enumerate(items)) + return f"\n{lines}" + + +# --------------------------------------------------------------------------- +# Public logging helpers +# --------------------------------------------------------------------------- + + +def log_section(title: str, level: str = "info") -> None: + """Emit a prominent section header surrounded by ``=`` rule lines. + + No-op in LLM mode to avoid decorative noise in machine-readable output. + + Args: + title: Heading text. + level: ``"info"`` or ``"debug"``. + """ + if IS_LLM: + return + log = structlog.get_logger() + separator = "=" * int(LOG_LINE_WIDTH * 1.1) + emit = getattr(log, level) + emit(separator) + emit(title) + emit(separator) + + +def log_sub_section(title: str, level: str = "info") -> None: + """Emit a sub-section header using ``-`` rule lines. + + No-op in LLM mode. + + Args: + title: Sub-heading text. + level: ``"info"`` or ``"debug"``. + """ + if IS_LLM: + return + log = structlog.get_logger() + separator = "-" * LOG_LINE_WIDTH + emit = getattr(log, level) + emit(separator) + emit(title) + emit(separator) + + +def log_section_divider(level: str = "info") -> None: + """Emit a single ``-`` horizontal rule line. + + No-op in LLM mode. + + Args: + level: ``"info"`` or ``"debug"``. + """ + if IS_LLM: + return + getattr(structlog.get_logger(), level)("-" * LOG_LINE_WIDTH) + + +def log_fields(fields: dict[str, Any], level: str = "info") -> None: + """Log a mapping of key/value pairs. + + Human mode: keys are padded for aligned ``:`` columns, strings are + wrapped via :func:`wrap_text`, and lists are rendered as numbered lines + via :func:`list_to_numbered_string`. + + LLM mode: fields are emitted as a single structured event with no + decorative formatting, keeping output machine-readable. + + Args: + fields: Mapping to log. + level: ``"info"`` or ``"debug"``. + """ + log = structlog.get_logger() + + if not fields: + getattr(log, level)("no_fields") + return + + if IS_LLM: + getattr(log, level)("fields", **fields) + return + + emit = getattr(log, level) + for key, value in fields.items(): + formatted: Any = list_to_numbered_string(value) if isinstance(value, list) else value + if isinstance(formatted, str): + if formatted.startswith("\n"): + lines = formatted.split("\n") + wrapped = [lines[0]] + [wrap_text(ln, width=LOG_LINE_WIDTH) for ln in lines[1:]] + formatted = "\n" + "\n".join(wrapped[1:]) + else: + formatted = wrap_text(formatted, width=LOG_LINE_WIDTH) + emit(" %-*s : %s", PADDING_WIDTH, key, formatted) + + +def log_message(message: str, level: str = "info") -> None: + """Log a plain string message. + + In human mode the message is wrapped to :data:`LOG_LINE_WIDTH` columns + via :func:`wrap_text`. In LLM mode it is passed through unchanged. + + Args: + message: Free-form text to log. + level: Log level name (e.g. ``"info"``, ``"debug"``). + """ + log = structlog.get_logger() + text = message if IS_LLM else wrap_text(message, width=LOG_LINE_WIDTH) + getattr(log, level)(text) diff --git a/template/src/{{ package_name }}/logging_config.py.jinja b/template/src/{{ package_name }}/logging_config.py.jinja deleted file mode 100644 index 556e114..0000000 --- a/template/src/{{ package_name }}/logging_config.py.jinja +++ /dev/null @@ -1,34 +0,0 @@ -{% if include_logging_setup %} -"""Central logging configuration (stdlib logging + structlog).""" - -from __future__ import annotations - -import logging -import sys - -import structlog - - -def configure_logging(level: str = "INFO") -> None: - """Configure stdlib logging and structlog for console output. - - Args: - level: Root log level name (e.g. ``INFO``, ``DEBUG``). - """ - lvl = getattr(logging, level.upper(), logging.INFO) - logging.basicConfig(format="%(message)s", stream=sys.stdout, level=lvl) - structlog.configure( - wrapper_class=structlog.make_filtering_bound_logger(lvl), - processors=[ - structlog.contextvars.merge_contextvars, - structlog.processors.add_log_level, - structlog.processors.StackInfoRenderer(), - structlog.dev.set_exc_info, - structlog.processors.TimeStamper(fmt="iso"), - structlog.dev.ConsoleRenderer(), - ], - context_class=dict, - logger_factory=structlog.PrintLoggerFactory(file=sys.stdout), - cache_logger_on_first_use=False, - ) -{% endif %} diff --git a/template/tests/conftest.py.jinja b/template/tests/conftest.py.jinja new file mode 100644 index 0000000..12b0ff6 --- /dev/null +++ b/template/tests/conftest.py.jinja @@ -0,0 +1,21 @@ +"""Pytest configuration and fixtures for all tests.""" + +from collections.abc import Generator + +import pytest + +from {{ package_name }}.common.logging_manager import ( # pyright: ignore[reportPrivateUsage] + _reset_configuration, +) + + +@pytest.fixture(autouse=True) +def _reset_logging_state() -> Generator[None, None, None]: + """Reset logging configuration before each test. + + This allows tests to call configure_logging() with different settings + without conflicts due to the idempotent guard. + """ + _reset_configuration() + yield + _reset_configuration() diff --git a/template/tests/{{ package_name }}/test_support.py.jinja b/template/tests/{{ package_name }}/test_support.py.jinja index 8ce7172..8ccf079 100644 --- a/template/tests/{{ package_name }}/test_support.py.jinja +++ b/template/tests/{{ package_name }}/test_support.py.jinja @@ -3,29 +3,27 @@ from __future__ import annotations import json -import logging -import os -from io import StringIO -from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import pytest import structlog from {{ package_name }}.common.file_manager import load_json, load_markdown from {{ package_name }}.common.logging_manager import ( # pyright: ignore[reportPrivateUsage] - IS_LLM, EXECUTION_CONTEXT, - bind_context, - clear_context, - configure_logging, - get_logger, + IS_LLM, _drop_debug_events, _filter_llm_events, _flatten_nested_dicts, _truncate_long_values, + bind_context, + clear_context, + configure_logging, + get_logger, ) +if TYPE_CHECKING: + from pathlib import Path # --------------------------------------------------------------------------- # File manager tests @@ -62,7 +60,7 @@ def test_execution_context_is_valid_string() -> None: def test_is_llm_matches_execution_context() -> None: """IS_LLM must be consistent with EXECUTION_CONTEXT.""" - assert IS_LLM == (EXECUTION_CONTEXT == "llm") + assert (EXECUTION_CONTEXT == "llm") == IS_LLM def test_context_forced_to_llm(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_template.py b/tests/test_template.py index 3960b28..5aa3b1b 100644 --- a/tests/test_template.py +++ b/tests/test_template.py @@ -115,10 +115,6 @@ def _remove_empty_optional_artifacts(dest: Path, data: dict[str, str | bool]) -> return pairs: list[tuple[bool, Path]] = [ (not bool(data.get("include_cli", False)), dest / "src" / pkg / "cli.py"), - ( - not bool(data.get("include_logging_setup", False)), - dest / "src" / pkg / "logging_config.py", - ), (not bool(data.get("include_git_cliff", False)), dest / "cliff.toml"), ] for should_drop, path in pairs: @@ -266,18 +262,32 @@ def test_generate_defaults_only_cli(tmp_path: Path) -> None: Pass explicit ``--data`` when you need a different distribution name. """ test_dir = tmp_path / "defaults_only" - _ = run_command(["copier", "copy", ".", str(test_dir), "--trust", "--defaults", "--skip-tasks"]) + _ = run_command( + [ + "copier", + "copy", + "--vcs-ref", + "HEAD", + ".", + str(test_dir), + "--trust", + "--defaults", + "--skip-tasks", + ] + ) _remove_empty_optional_artifacts( test_dir, { "package_name": "my_library", "include_cli": False, - "include_logging_setup": False, - "include_git_cliff": False, + "include_git_cliff": True, }, ) assert (test_dir / "pyproject.toml").exists(), "Missing pyproject.toml" + assert (test_dir / "cliff.toml").is_file(), ( + "cliff.toml expected when include_git_cliff defaults to true" + ) answers = load_copier_answers(test_dir) assert answers.get("project_name") == "My Library" assert answers.get("package_name") == "my_library" @@ -325,8 +335,7 @@ def test_computed_values_not_recorded_in_answers_file(tmp_path: Path) -> None: { "package_name": "my_library", "include_cli": False, - "include_logging_setup": False, - "include_git_cliff": False, + "include_git_cliff": True, }, ) answers_text = (test_dir / ".copier-answers.yml").read_text(encoding="utf-8") @@ -343,8 +352,7 @@ def test_answers_file_warns_never_edit_manually(tmp_path: Path) -> None: { "package_name": "my_library", "include_cli": False, - "include_logging_setup": False, - "include_git_cliff": False, + "include_git_cliff": True, }, ) first_line = (test_dir / ".copier-answers.yml").read_text(encoding="utf-8").splitlines()[0] @@ -367,8 +375,7 @@ def test_generate_programmatic_run_copy_local(tmp_path: Path) -> None: { "package_name": "my_library", "include_cli": False, - "include_logging_setup": False, - "include_git_cliff": False, + "include_git_cliff": True, }, ) @@ -404,8 +411,7 @@ def test_generate_from_vcs_git_file_url(tmp_path: Path) -> None: { "package_name": "my_library", "include_cli": False, - "include_logging_setup": False, - "include_git_cliff": False, + "include_git_cliff": True, }, ) assert (dest_dir / "pyproject.toml").exists(), "Missing pyproject.toml" @@ -665,8 +671,7 @@ def test_copier_update_exits_zero_after_copy_and_commit(tmp_path: Path) -> None: { "package_name": "update_smoke_test", "include_cli": False, - "include_logging_setup": False, - "include_git_cliff": False, + "include_git_cliff": True, }, ) _prune_docs_when_disabled( @@ -702,7 +707,6 @@ def test_answers_file_matches_explicit_copy_data(tmp_path: Path) -> None: "include_pandas_support": True, "include_numpy": True, "include_cli": False, - "include_logging_setup": False, "include_git_cliff": False, } copy_with_data(test_dir, expected) @@ -801,23 +805,21 @@ def test_include_git_cliff_adds_dependency_group(tmp_path: Path) -> None: assert "git-cliff" in raw -def test_include_logging_setup_adds_module(tmp_path: Path) -> None: - """``include_logging_setup=true`` must ship ``logging_config.py`` with configure_logging.""" - test_dir = tmp_path / "with_logging" +def test_no_logging_config_module_logging_in_common(tmp_path: Path) -> None: + """Logging setup lives only in ``common/logging_manager.py`` โ€” no ``logging_config.py``.""" + test_dir = tmp_path / "logging_single_source" copy_with_data( test_dir, { "project_name": "Log Project", "package_name": "log_project", "include_docs": False, - "include_logging_setup": True, }, ) - mod = test_dir / "src" / "log_project" / "logging_config.py" - assert mod.is_file() - text = mod.read_text(encoding="utf-8") - assert "configure_logging" in text - assert "structlog" in text + assert not (test_dir / "src" / "log_project" / "logging_config.py").exists() + lm = test_dir / "src" / "log_project" / "common" / "logging_manager.py" + assert lm.is_file() + assert "def configure_logging" in lm.read_text(encoding="utf-8") def test_root_contributing_and_security_rendered(tmp_path: Path) -> None: @@ -886,7 +888,6 @@ def test_generated_renovate_enables_pre_commit(tmp_path: Path) -> None: "include_numpy": True, "include_pandas_support": False, "include_cli": True, - "include_logging_setup": True, "include_git_cliff": True, }, { @@ -894,7 +895,6 @@ def test_generated_renovate_enables_pre_commit(tmp_path: Path) -> None: "include_numpy": False, "include_pandas_support": True, "include_cli": False, - "include_logging_setup": False, "include_git_cliff": False, }, ], @@ -954,7 +954,7 @@ def test_release_workflow_generated_by_default(tmp_path: Path) -> None: assert release_yml.is_file(), "release.yml must exist when include_release_workflow=true" content = release_yml.read_text(encoding="utf-8") assert "${{ true }}" in content, "release job must be enabled" - assert "bump_version.py" in content, "release.yml must reference bump_version.py" + assert "common/bump_version.py" in content, "release.yml must reference common/bump_version.py" assert "--generate-notes" in content, ( "release must use gh --generate-notes (no CHANGELOG.md required)" ) @@ -1101,19 +1101,20 @@ def test_pre_commit_update_workflow_generated(tmp_path: Path) -> None: assert "create-pull-request" in content -def test_scripts_bump_version_generated(tmp_path: Path) -> None: - """scripts/bump_version.py must exist in the generated project.""" +def test_common_bump_version_generated(tmp_path: Path) -> None: + """``src//common/bump_version.py`` must exist in the generated project.""" test_dir = tmp_path / "bump_version" copy_with_data( test_dir, { "project_name": "Bump Version", + "package_name": "bump_version_pkg", "include_release_workflow": True, "include_docs": False, }, ) - bump_script = test_dir / "scripts" / "bump_version.py" - assert bump_script.is_file(), "scripts/bump_version.py must exist in generated projects" + bump_script = test_dir / "src" / "bump_version_pkg" / "common" / "bump_version.py" + assert bump_script.is_file(), "common/bump_version.py must exist in generated projects" content = bump_script.read_text(encoding="utf-8") assert "BumpKind" in content, "bump_version.py must contain BumpKind type alias" assert "[project]" in content, "bump_version.py must look for [project] section"