Skip to content

Commit a38ac54

Browse files
authored
Merge pull request #222 from pyapp-org/development
Release 4.15
2 parents bd936ab + 5c0da47 commit a38ac54

File tree

7 files changed

+80
-54
lines changed

7 files changed

+80
-54
lines changed

HISTORY

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
4.15
2+
====
3+
4+
Features
5+
--------
6+
7+
- Command decorators can now be supplied with an alternate loglevel to
8+
allow certain commands to use a different level.
9+
10+
Bugfix
11+
------
12+
13+
- Fix initial log replay so the specified log-level is correctly applied.
14+
15+
116
4.14
217
====
318

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
44

55
[tool.poetry]
66
name = "pyapp"
7-
version = "4.14"
7+
version = "4.15"
88
description = "A Python application framework - Let us handle the boring stuff!"
99
authors = ["Tim Savage <[email protected]>"]
1010
license = "BSD-3-Clause"

src/pyapp/app/__init__.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ def my_command(
169169
from .. import conf, extensions, feature_flags
170170
from ..app import builtin_handlers
171171
from ..events import Event
172+
from ..exceptions import ApplicationExit
172173
from ..injection import register_factory
173174
from ..utils.inspect import import_root_module
174175
from . import init_logger
@@ -342,7 +343,7 @@ def _init_parser(self):
342343
)
343344
arg_group.add_argument(
344345
"--log-level",
345-
default=os.environ.get(self.env_loglevel_key, "INFO"),
346+
default=os.environ.get(self.env_loglevel_key, "DEFAULT"),
346347
choices=("DEBUG", "INFO", "WARN", "ERROR", "CRITICAL"),
347348
help="Specify the log level to be used. "
348349
f"Defaults to env variable: {_key_help(self.env_loglevel_key)}",
@@ -497,7 +498,11 @@ def configure_logging(self, opts: CommandOptions):
497498
logging.config.dictConfig(dict_config)
498499

499500
# Configure root log level
500-
logging.root.setLevel(opts.log_level)
501+
loglevel = opts.log_level
502+
if loglevel == "DEFAULT":
503+
handler = self.resolve_handler(opts)
504+
loglevel = handler.loglevel
505+
logging.root.setLevel(loglevel)
501506

502507
# Replay initial entries and remove
503508
self._init_logger.replay()
@@ -540,6 +545,8 @@ def logging_shutdown():
540545

541546
def dispatch(self, args: Sequence[str] = None) -> None:
542547
"""Dispatch command to registered handler."""
548+
logger.info("Starting %s", self.application_summary)
549+
543550
# Initialisation phase
544551
_set_running_application(self)
545552
self.register_factories()
@@ -549,10 +556,6 @@ def dispatch(self, args: Sequence[str] = None) -> None:
549556
argcomplete.autocomplete(self.parser)
550557
opts = self.parser.parse_args(args)
551558

552-
# Set log level from opts
553-
logging.root.setLevel(opts.log_level)
554-
logger.info("Starting %s", self.application_summary)
555-
556559
# Load settings and configure logger
557560
self.configure_settings(opts)
558561
self.configure_feature_flags(opts)
@@ -576,9 +579,14 @@ def dispatch(self, args: Sequence[str] = None) -> None:
576579
if not self.exception_report(ex, opts):
577580
raise
578581

582+
except ApplicationExit as ex:
583+
if ex.message:
584+
print(f"\n\n{ex.message}", file=sys.stderr)
585+
raise
586+
579587
except KeyboardInterrupt:
580588
print("\n\nInterrupted.", file=sys.stderr)
581-
sys.exit(-2)
589+
sys.exit(2)
582590

583591
else:
584592
# Provide exit code.

src/pyapp/app/arguments.py

Lines changed: 29 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import argparse
1212
import asyncio
1313
import inspect
14+
import logging
1415
from enum import Enum
1516
from typing import (
1617
Any,
@@ -46,9 +47,7 @@
4647

4748

4849
class ParserBase:
49-
"""
50-
Base class for handling parsers.
51-
"""
50+
"""Base class for handling parsers."""
5251

5352
def __init__(self, parser: argparse.ArgumentParser):
5453
self.parser = parser
@@ -60,8 +59,7 @@ def argument(self, *name_or_flags, **kwargs) -> argparse.Action:
6059
return self.parser.add_argument(*name_or_flags, **kwargs)
6160

6261
def argument_group(self, *, title: str = None, description: str = None):
63-
"""
64-
Add an argument group to proxy
62+
"""Add an argument group to proxy.
6563
6664
See: https://docs.python.org/3.6/library/argparse.html#argument-groups
6765
@@ -70,8 +68,7 @@ def argument_group(self, *, title: str = None, description: str = None):
7068

7169

7270
class CommandProxy(ParserBase):
73-
"""
74-
Proxy object that wraps a handler.
71+
"""Proxy object that wraps a handler.
7572
7673
.. versionupdated:: 4.4
7774
Determine arguments from handler signature.
@@ -80,15 +77,14 @@ class CommandProxy(ParserBase):
8077

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

83-
def __init__(self, handler: Handler, parser: argparse.ArgumentParser):
84-
"""
85-
Initialise proxy
80+
def __init__(self, handler: Handler, parser: argparse.ArgumentParser, loglevel: int = logging.INFO):
81+
"""Initialise proxy.
8682
8783
:param handler: Callable object that accepts a single argument.
88-
8984
"""
9085
super().__init__(parser)
9186
self.handler = handler
87+
self.loglevel = loglevel
9288

9389
# Copy details
9490
self.__doc__ = handler.__doc__
@@ -108,9 +104,7 @@ def __init__(self, handler: Handler, parser: argparse.ArgumentParser):
108104
self._extract_args(handler)
109105

110106
def _extract_args(self, func):
111-
"""
112-
Extract args from signature and turn into command line args
113-
"""
107+
"""Extract args from signature and turn into command line args."""
114108
sig = inspect.signature(func)
115109

116110
# Backwards compatibility
@@ -144,26 +138,21 @@ def __call__(self, opts: argparse.Namespace):
144138

145139

146140
class AsyncCommandProxy(CommandProxy):
147-
"""
148-
Proxy object that wraps an async handler.
141+
"""Proxy object that wraps an async handler.
149142
150-
Will handle starting a event loop.
143+
Will handle starting an event loop.
151144
"""
152145

153146
def __call__(self, opts: argparse.Namespace):
154147
return async_run(super().__call__(opts))
155148

156149

157150
class ArgumentType(abc.ABC):
158-
"""
159-
Custom argument type
160-
"""
151+
"""Custom argument type."""
161152

162153
@abc.abstractmethod
163154
def __call__(self, value: str) -> Any:
164-
"""
165-
Construct a value from type
166-
"""
155+
"""Construct a value from type."""
167156

168157

169158
class Argument:
@@ -417,9 +406,7 @@ def register_with_proxy(self, proxy: CommandProxy) -> argparse.Action:
417406

418407

419408
class CommandGroup(ParserBase):
420-
"""
421-
Group of commands.
422-
"""
409+
"""Group of commands."""
423410

424411
def __init__(
425412
self,
@@ -436,9 +423,7 @@ def __init__(
436423

437424
@cached_property
438425
def handler_dest(self) -> str:
439-
"""
440-
Destination of handler
441-
"""
426+
"""Destination of handler."""
442427
return f":handler:{self._prefix or ''}"
443428

444429
def _add_handler(self, handler, name, aliases):
@@ -454,8 +439,7 @@ def _add_handler(self, handler, name, aliases):
454439
def create_command_group(
455440
self, name: str, *, aliases: Sequence[str] = (), help_text: str = None
456441
) -> "CommandGroup":
457-
"""
458-
Create a command group
442+
"""Create a command group.
459443
460444
:param name: Name of the command group
461445
:param aliases: A sequence a name aliases for this command group.
@@ -481,19 +465,23 @@ def command(
481465
name: str = None,
482466
aliases: Sequence[str] = (),
483467
help_text: str = None,
468+
loglevel: int = logging.INFO
484469
) -> CommandProxy:
485-
"""
486-
Decorator for registering handlers.
470+
"""Decorator for registering handlers.
487471
488472
:param handler: Handler function
489473
:param name: Optional name to use for CLI; defaults to the function name.
490474
:param aliases: A sequence a name aliases for this command.
491475
:param help_text: Information provided to the user if help is invoked;
492476
default is taken from the handlers doc string.
477+
:param loglevel: The default log-level when using this command.
493478
494479
.. versionchanged:: 4.3
495480
Async handlers supported.
496481
482+
.. versionchanged:: 4.15
483+
Add loglevel option to allow per-command log levels to be set.
484+
497485
"""
498486

499487
def inner(func: Handler) -> CommandProxy:
@@ -520,8 +508,7 @@ def inner(func: Handler) -> CommandProxy:
520508
return inner(handler) if handler else inner
521509

522510
def default(self, handler: Handler):
523-
"""
524-
Decorator for registering a default handler.
511+
"""Decorator for registering a default handler.
525512
526513
.. versionchanged:: 4.3
527514
Async handlers supported.
@@ -534,21 +521,19 @@ def default(self, handler: Handler):
534521
return handler
535522

536523
def default_handler(self, _: argparse.Namespace) -> int:
537-
"""
538-
Handler called if no handler is specified
539-
"""
524+
"""Handler called if no handler is specified."""
540525
print("No command specified!")
541526
self.parser.print_usage()
542527
return 1
543528

544-
def dispatch_handler(self, opts: argparse.Namespace) -> int:
545-
"""
546-
Resolve the correct handler and call it with supplied options namespace.
547-
"""
529+
def resolve_handler(self, opts: argparse.Namespace) -> Handler:
530+
"""Resolve a command handler."""
548531
handler_name = getattr(opts, self.handler_dest, None)
549-
550532
if self._prefix:
551533
handler_name = f"{self._prefix}:{handler_name}"
552-
handler = self._handlers.get(handler_name, self._default_handler)
534+
return self._handlers.get(handler_name, self._default_handler)
553535

536+
def dispatch_handler(self, opts: argparse.Namespace) -> int:
537+
"""Resolve the correct handler and call it with supplied options namespace."""
538+
handler = self.resolve_handler(opts)
554539
return handler(opts)

src/pyapp/app/init_logger.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@ def replay(self):
2525
"""Replay stored log records"""
2626

2727
for record in self._store:
28-
logging.getLogger(record.name).handle(record)
28+
logger = logging.getLogger(record.name)
29+
if logger.isEnabledFor(record.levelno):
30+
logger.handle(record)
2931
self._store.clear()
3032

3133
def emit(self, record: logging.LogRecord) -> None:

src/pyapp/exceptions.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,22 @@
55
Collection of standard exceptions.
66
77
"""
8+
from typing import Optional
9+
10+
11+
class ApplicationExit(SystemExit):
12+
"""Exception used to directly exit a PyApp application.
13+
14+
Will be caught by the CliApplication instance."""
15+
def __init__(self, status_code: int, message: Optional[str] = None):
16+
super().__init__(status_code)
17+
self.status_code = status_code
18+
self.message = message
19+
20+
def __str__(self):
21+
if self.message:
22+
return self.message
23+
return f"Application exit: {self.status_code}"
824

925

1026
class InvalidConfiguration(Exception):

tests/unit/app/test_app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def test_dispatch__keyboard_interrupt(self):
6262
with pytest.raises(SystemExit) as ex:
6363
target.dispatch(args=("cheeky",))
6464

65-
assert ex.value.code == -2
65+
assert ex.value.code == 2
6666

6767
def test_dispatch__return_status(self):
6868
target = tests.unit.sample_app.__main__.app

0 commit comments

Comments
 (0)