Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improvements for logging and testing #235

Merged
merged 14 commits into from
Oct 10, 2024
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
2 changes: 1 addition & 1 deletion .github/workflows/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.8, 3.9, '3.10']
python-version: ['3.10', '3.11', '3.12', '3.13']

name: Test Python ${{ matrix.python-version }}
steps:
Expand Down
17 changes: 17 additions & 0 deletions HISTORY
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
4.16
====

Features
--------

- Feature flags now has a modify context manager that allows for patching flags
that are reverted when the context manager exits (similar to settings).
- Add PyTest fixture `patch_feature_flags` that returns modify context for feature
flags.
- Fully implement split logging configuration. Settings now contains `LOG_HANDLERS`
and `LOG_LOGGERS` that are merged into the logging configuration before it is
applied.
- Add a function `settings_in_module` in the testing package to fetch a list of
setting names from a settings module.


4.15.1
======

Expand Down
29 changes: 15 additions & 14 deletions noxfile.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
from tempfile import TemporaryDirectory
from pathlib import Path

import nox
from nox.sessions import Session

HERE = Path(__file__).parent

@nox.session(python=("3.8", "3.9", "3.10"), reuse_venv=True)

@nox.session(
python=("python3.10", "python3.11", "python3.12", "python3.13", "pypy3.10"),
venv_backend=None,
)
def tests(session: Session):
with TemporaryDirectory() as tmpdir:
session.install("poetry")
session.run("poetry", "build")
session.run(
"poetry",
"export",
"--dev",
"--format=requirements.txt",
f"--output={tmpdir}/requirements.txt",
)
session.install(f"-r{tmpdir}/requirements.txt dist/pytest_pyapp*")
session.run("pytest")
print(f"🪄 Creating poetry environment for {session.python}")
session.run("poetry", "env", "use", session.python, external=True)

print("📦 Install dependencies...")
session.run("poetry", "install", "--with=dev", external=True)

print("▶️ Run tests")
session.run("poetry", "run", "pytest", "--config-file=pytest.ini", external=True)
1,462 changes: 716 additions & 746 deletions poetry.lock

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "pyapp"
version = "4.15.2"
version = "4.16"
description = "A Python application framework - Let us handle the boring stuff!"
authors = ["Tim Savage <[email protected]>"]
license = "BSD-3-Clause"
Expand All @@ -20,9 +20,10 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
"Topic :: Software Development :: Libraries :: Application Frameworks",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
"Programming Language :: Python :: Implementation :: PyPy",
]
Expand All @@ -33,7 +34,7 @@ packages = [
include = ["HISTORY"]

[tool.poetry.dependencies]
python = "^3.8"
python = "^3.10"
argcomplete = "^3.2"
colorama = "*"
yarl = "*"
Expand All @@ -44,10 +45,9 @@ toml = {version = "*", optional = true }

[tool.poetry.dev-dependencies]
pytest = "^8.0"
pytest-cov = "^4.0"
pytest-asyncio = "^0.23"
nox = "*"
sphinx = "^7.1"
pytest-cov = "^5.0"
pytest-asyncio = "^0.24"
sphinx = "^8.0"

[tool.poetry.extras]
yaml = ["pyyaml"]
Expand All @@ -64,8 +64,8 @@ toml = ["toml"]
line-length = 88
indent-width = 4

# Assume Python 3.8
target-version = "py38"
# Assume Python 3.10
target-version = "py310"

[tool.ruff.lint]
select = ["N", "F", "I", "UP", "PL", "A", "G", "S", "E", "SIM", "B"]
Expand Down
2 changes: 2 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
[pytest]
addopts =
--cov=pyapp --cov-branch
asyncio_mode=auto
asyncio_default_fixture_loop_scope="function"
42 changes: 26 additions & 16 deletions src/pyapp/app/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,14 @@ def my_command(
import warnings
from argparse import ArgumentParser
from argparse import Namespace as CommandOptions
from typing import Callable, Optional, Sequence
from collections.abc import Callable, Sequence

import argcomplete
import colorama

from .. import conf, extensions, feature_flags
from ..app import builtin_handlers
from ..conf.base_settings import LoggingSettings
from ..events import Event
from ..exceptions import ApplicationExit
from ..injection import register_factory
Expand Down Expand Up @@ -210,23 +211,23 @@ class CliApplication(CommandGroup): # noqa: F405
"""

default_log_handler = logging.StreamHandler(sys.stderr)
"""Log handler applied by default to root logger."""
"""Log handler applied by default to the root logger."""

default_log_formatter = logging.Formatter(
"%(asctime)s | %(levelname)s | %(name)s | %(message)s"
)
"""Log formatter applied by default to root logger handler."""
"""Log formatter applied by default to the root logger handler."""

default_color_log_formatter = ColourFormatter(
f"{colorama.Fore.YELLOW}%(asctime)s{colorama.Fore.RESET} "
f"%(clevelname)s "
f"{colorama.Fore.LIGHTBLUE_EX}%(name)s{colorama.Fore.RESET} "
f"%(message)s"
)
"""Log formatter applied by default to root logger handler."""
"""Log formatter with colour applied by default to the root logger handler."""

env_settings_key = conf.DEFAULT_ENV_KEY
"""Key used to define settings file in environment."""
"""Key used to define settings reference in environment."""

env_loglevel_key = "PYAPP_LOGLEVEL"
"""Key used to define log level in environment."""
Expand All @@ -240,7 +241,7 @@ class CliApplication(CommandGroup): # noqa: F405

# Events
pre_dispatch = Event[Callable[[argparse.Namespace], None]]()
post_dispatch = Event[Callable[[Optional[int], argparse.Namespace], None]]()
post_dispatch = Event[Callable[[int | None, argparse.Namespace], None]]()
dispatch_error = Event[Callable[[Exception, argparse.Namespace], None]]()

def __init__( # noqa: PLR0913
Expand Down Expand Up @@ -478,24 +479,33 @@ def get_log_formatter(self, log_color) -> logging.Formatter:

return self.default_log_formatter

@staticmethod
def _apply_logging_settings():
"""Build dict-config from settings and apply to logging."""

dict_config = LoggingSettings.LOGGING.copy() or {}

# Merge in other settings
if LoggingSettings.LOG_HANDLERS:
dict_config.setdefault("handlers", {}).update(LoggingSettings.LOG_HANDLERS)
if LoggingSettings.LOG_LOGGERS:
dict_config.setdefault("loggers", {}).update(LoggingSettings.LOG_LOGGERS)

# Only apply config if we have something to apply
if dict_config:
dict_config.setdefault("version", 1)
logging.config.dictConfig(dict_config)

def configure_logging(self, opts: CommandOptions):
"""Configure the logging framework."""
# Prevent duplicate runs
if hasattr(self, "_init_logger"):
self.default_log_handler.formatter = self.get_log_formatter(opts.log_color)

if conf.settings.LOGGING:
logger.info("Applying logging configuration.")

# Replace root handler with the default handler
logging.root.handlers.pop(0)
logging.root.handlers.append(self.default_log_handler)

if conf.settings.LOGGING:
# Set a default version if not supplied by settings
dict_config = conf.settings.LOGGING.copy()
dict_config.setdefault("version", 1)
logging.config.dictConfig(dict_config)
self._apply_logging_settings()

# Configure root log level
loglevel = opts.log_level
Expand Down Expand Up @@ -598,7 +608,7 @@ def dispatch(self, args: Sequence[str] = None) -> None:
self.logging_shutdown()


CURRENT_APP: Optional[CliApplication] = None
CURRENT_APP: CliApplication | None = None


def _set_running_application(app: CliApplication):
Expand Down
30 changes: 21 additions & 9 deletions src/pyapp/app/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
Any,
Awaitable,
Callable,
Coroutine,
Dict,
Mapping,
Optional,
Sequence,
Tuple,
Type,
Union,
cast,
)

from argcomplete.completers import BaseCompleter
Expand Down Expand Up @@ -77,7 +79,12 @@ class CommandProxy(ParserBase):

__slots__ = ("__name__", "handler", "_args", "_require_namespace")

def __init__(self, handler: Handler, parser: argparse.ArgumentParser, loglevel: int = logging.INFO):
def __init__(
self,
handler: Handler,
parser: argparse.ArgumentParser,
loglevel: int = logging.INFO,
):
"""Initialise proxy.

:param handler: Callable object that accepts a single argument.
Expand Down Expand Up @@ -144,7 +151,8 @@ class AsyncCommandProxy(CommandProxy):
"""

def __call__(self, opts: argparse.Namespace):
return async_run(super().__call__(opts))
func = cast(Coroutine, super().__call__(opts))
return async_run(func)


class ArgumentType(abc.ABC):
Expand Down Expand Up @@ -352,7 +360,7 @@ def __init__( # noqa: PLR0913
nargs: Union[int, str] = None,
const: Any = None,
default: Any = EMPTY,
type: Optional[Type[Any]] = None, # noqa
type: Optional[Callable[[Any], Any]] = None, # noqa: A002
choices: Sequence[Any] = None,
required: bool = None,
help_text: str = None,
Expand Down Expand Up @@ -401,8 +409,8 @@ def register_with_proxy(self, proxy: CommandProxy) -> argparse.Action:
return action


Arg = Argument.arg # pylint: disable=invalid-name
argument = Argument # pylint: disable=invalid-name
Arg = Argument.arg
argument = Argument


class CommandGroup(ParserBase):
Expand Down Expand Up @@ -458,14 +466,14 @@ def create_command_group(

return group

def command(
def command( # noqa: PLR0913
self,
handler: Handler = None,
*,
name: str = None,
aliases: Sequence[str] = (),
help_text: str = None,
loglevel: int = logging.INFO
loglevel: int = logging.INFO,
) -> CommandProxy:
"""Decorator for registering handlers.

Expand Down Expand Up @@ -494,11 +502,15 @@ def inner(func: Handler) -> CommandProxy:
name_ = name or func.__name__
if asyncio.iscoroutinefunction(func):
proxy = AsyncCommandProxy(
func, self._sub_parsers.add_parser(name_, **kwargs)
func,
self._sub_parsers.add_parser(name_, **kwargs),
loglevel=loglevel,
)
else:
proxy = CommandProxy(
func, self._sub_parsers.add_parser(name_, **kwargs)
func,
self._sub_parsers.add_parser(name_, **kwargs),
loglevel=loglevel,
)

self._add_handler(proxy, name_, aliases)
Expand Down
Loading