From 59b93ed112853205d10448255582796d6fda0883 Mon Sep 17 00:00:00 2001 From: Denis Laxalde Date: Wed, 21 Feb 2024 08:43:05 +0100 Subject: [PATCH 1/9] Drop support for Python 3.7 This version has reached its end-of-life last year, https://peps.python.org/pep-0537/#lifespan. --- .github/workflows/tests.yml | 2 +- CHANGELOG.md | 4 ++++ README.md | 2 +- pgactivity/compat.py | 7 +------ pyproject.toml | 2 +- 5 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 83563394..4d1777ba 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: include: - - python: "3.7" + - python: "3.8" psycopg: "psycopg2" - python: "3.12" psycopg: "psycopg3" diff --git a/CHANGELOG.md b/CHANGELOG.md index cb1b000f..9d1e3a3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ * Fix retrieval of I/O statistics on BSD systems (#393). * Fix spelling mistakes in the man page. +### Removed + +* Python 3.7 is no longer supported. + ### Misc * Document how to *hack* on pg\_activity in the `README`. diff --git a/README.md b/README.md index 4f7d8fcd..532c076b 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ pg\_activity releases. Before submitting a bug report here: ## From PyPI -pg\_activity can be installed using pip on Python 3.7 or later along with +pg\_activity can be installed using pip on Python 3.8 or later along with psycopg: $ python3 -m pip install "pg_activity[psycopg]" diff --git a/pgactivity/compat.py b/pgactivity/compat.py index a15048a4..4be71a25 100644 --- a/pgactivity/compat.py +++ b/pgactivity/compat.py @@ -1,16 +1,11 @@ import operator -import sys +from importlib.metadata import version from typing import Any, Dict import attr import attr.validators import blessed -if sys.version_info < (3, 8): - from importlib_metadata import version -else: - from importlib.metadata import version - ATTR_VERSION = tuple(int(x) for x in version("attrs").split(".", 2)[:2]) BLESSED_VERSION = tuple(int(x) for x in version("blessed").split(".", 2)[:2]) diff --git a/pyproject.toml b/pyproject.toml index bc71136d..ff3c1c2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "Command line tool for PostgreSQL server activity monitoring." readme = "README.md" license = { text = "PostgreSQL" } -requires-python = ">=3.7" +requires-python = ">=3.8" authors = [ { name = "Julien Tachoires", email = "julmon@gmail.com" }, { name = "Benoit Lobréau", email = "benoit.lobreau@dalibo.com" }, From 6b0edb86c014cf1a32e2138be09ee472960b03e6 Mon Sep 17 00:00:00 2001 From: Denis Laxalde Date: Wed, 21 Feb 2024 08:50:40 +0100 Subject: [PATCH 2/9] Configure usage of pyupgrade and upgrade our code base --- .pre-commit-config.yaml | 5 +++++ pgactivity/types.py | 2 +- pyproject.toml | 1 + tox.ini | 3 +++ 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc2a8bf2..8705a7c1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,6 +12,11 @@ repos: exclude: tests/test_*.txt - repo: local hooks: + - id: pyupgrade + name: pyupgrade + entry: pyupgrade --py38-plus --exit-zero-even-if-changed + language: system + types: [python] - id: black name: black entry: black --check . diff --git a/pgactivity/types.py b/pgactivity/types.py index beb3def9..4a442c1a 100644 --- a/pgactivity/types.py +++ b/pgactivity/types.py @@ -278,7 +278,7 @@ def add_column(key: str, name: str, **kwargs: Any) -> None: key="database", name="DATABASE(*)" if filters.dbname else "DATABASE", min_width=max_db_length, - transform=functools.lru_cache()( + transform=functools.lru_cache( lambda v: utils.ellipsis(v, width=16) if v else "", ), sort_key=None, diff --git a/pyproject.toml b/pyproject.toml index ff3c1c2e..c3e525bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ dev = [ "flake8", "isort", "pre-commit", + "pyupgrade", ] typing = [ "mypy", diff --git a/tox.ini b/tox.ini index 37031df9..79820d39 100644 --- a/tox.ini +++ b/tox.ini @@ -27,11 +27,14 @@ deps = black >= 24.2.0 flake8 isort + pre-commit + pyupgrade commands = codespell {toxinidir} black --check --diff {toxinidir} flake8 {toxinidir} isort --check --diff {toxinidir} + pre-commit run --all-files --show-diff-on-failure pyupgrade [testenv:mypy] extras = From fa9e5c32462c7b1540fc0f0fd2793b2ee8375ebf Mon Sep 17 00:00:00 2001 From: Denis Laxalde Date: Wed, 21 Feb 2024 09:43:57 +0100 Subject: [PATCH 3/9] Upgrade type annotations syntax We use the syntax from PEP 585 and PEP 604 and rely on 'from __future__ import annotations' to modernize the code base. However, not all generic aliases (e.g. Dict, List) can be replaced before Python 3.9. --- pgactivity/activities.py | 18 ++--- pgactivity/cli.py | 5 +- pgactivity/compat.py | 6 +- pgactivity/config.py | 26 +++---- pgactivity/data.py | 37 +++++----- pgactivity/handlers.py | 10 ++- pgactivity/keys.py | 12 ++-- pgactivity/pg.py | 84 +++++++++++------------ pgactivity/types.py | 142 +++++++++++++++++++-------------------- pgactivity/ui.py | 12 ++-- pgactivity/utils.py | 16 +++-- pgactivity/views.py | 28 ++++---- pgactivity/widgets.py | 4 +- tests/conftest.py | 10 +-- tests/test_config.py | 6 +- tests/test_data.py | 5 +- 16 files changed, 214 insertions(+), 207 deletions(-) diff --git a/pgactivity/activities.py b/pgactivity/activities.py index a24e1d53..b0662266 100644 --- a/pgactivity/activities.py +++ b/pgactivity/activities.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import builtins import os import time -from typing import Dict, List, Optional, Sequence, Tuple, TypeVar +from typing import Sequence, TypeVar from warnings import catch_warnings, simplefilter import attr @@ -21,7 +23,7 @@ ) -def sys_get_proc(pid: int) -> Optional[SystemProcess]: +def sys_get_proc(pid: int) -> SystemProcess | None: """Return a SystemProcess instance matching given pid or None if access with psutil is not possible. """ @@ -53,9 +55,9 @@ def sys_get_proc(pid: int) -> Optional[SystemProcess]: def ps_complete( pg_processes: Sequence[RunningProcess], - processes: Dict[int, SystemProcess], + processes: dict[int, SystemProcess], fs_blocksize: int, -) -> Tuple[List[LocalRunningProcess], IOCounter, IOCounter]: +) -> tuple[list[LocalRunningProcess], IOCounter, IOCounter]: """Transform the sequence of 'pg_processes' (RunningProcess) as LocalRunningProcess with local system information from the 'processes' map. Return LocalRunningProcess list, as well as read and write IO counters. @@ -139,7 +141,7 @@ def ps_complete( T = TypeVar("T", RunningProcess, WaitingProcess, BlockingProcess, LocalRunningProcess) -def sorted(processes: List[T], *, key: SortKey, reverse: bool = False) -> List[T]: +def sorted(processes: list[T], *, key: SortKey, reverse: bool = False) -> list[T]: """Return processes sorted. >>> from ipaddress import IPv4Interface, ip_address @@ -314,12 +316,12 @@ def update_max_iops(max_iops: int, read_count: float, write_count: float) -> int return max(int(read_count + write_count), max_iops) -def get_load_average() -> Tuple[float, float, float]: +def get_load_average() -> tuple[float, float, float]: """Get load average""" return os.getloadavg() -def get_mem_swap() -> Tuple[MemoryInfo, SwapInfo]: +def get_mem_swap() -> tuple[MemoryInfo, SwapInfo]: """Get memory and swap usage""" with catch_warnings(): simplefilter("ignore", RuntimeWarning) @@ -335,7 +337,7 @@ def get_mem_swap() -> Tuple[MemoryInfo, SwapInfo]: ) -def mem_swap_load() -> Tuple[MemoryInfo, SwapInfo, LoadAverage]: +def mem_swap_load() -> tuple[MemoryInfo, SwapInfo, LoadAverage]: """Read memory, swap and load average from Data object.""" memory, swap = get_mem_swap() load = LoadAverage(*get_load_average()) diff --git a/pgactivity/cli.py b/pgactivity/cli.py index 48a09b9b..7aec79e5 100755 --- a/pgactivity/cli.py +++ b/pgactivity/cli.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import logging import os import socket @@ -5,7 +7,6 @@ import time from argparse import ArgumentParser from io import StringIO -from typing import Optional from blessed import Terminal @@ -14,7 +15,7 @@ from .pg import OperationalError -def configure_logger(debug_file: Optional[str] = None) -> StringIO: +def configure_logger(debug_file: str | None = None) -> StringIO: logger = logging.getLogger("pgactivity") logger.setLevel(logging.DEBUG) diff --git a/pgactivity/compat.py b/pgactivity/compat.py index 4be71a25..d615664c 100644 --- a/pgactivity/compat.py +++ b/pgactivity/compat.py @@ -1,6 +1,8 @@ +from __future__ import annotations + import operator from importlib.metadata import version -from typing import Any, Dict +from typing import Any import attr import attr.validators @@ -11,7 +13,7 @@ if ATTR_VERSION < (18, 1): - def fields_dict(cls: Any) -> Dict[str, Any]: + def fields_dict(cls: Any) -> dict[str, Any]: return {a.name: a for a in cls.__attrs_attrs__} else: diff --git a/pgactivity/config.py b/pgactivity/config.py index 280b93fa..b60fa1a5 100644 --- a/pgactivity/config.py +++ b/pgactivity/config.py @@ -1,8 +1,10 @@ +from __future__ import annotations + import configparser import enum import os from pathlib import Path -from typing import IO, Any, Dict, List, Optional, Type, TypeVar +from typing import IO, Any, Dict, TypeVar import attr from attr import validators @@ -70,7 +72,7 @@ class Flag(enum.Flag): PID = enum.auto() @classmethod - def names(cls) -> List[str]: + def names(cls) -> list[str]: rv = [] for f in cls: assert f.name @@ -78,14 +80,14 @@ def names(cls) -> List[str]: return rv @classmethod - def all(cls) -> "Flag": + def all(cls) -> Flag: value = cls(0) for f in cls: value |= f return value @classmethod - def from_config(cls, config: "Configuration") -> "Flag": + def from_config(cls, config: Configuration) -> Flag: value = cls(0) for f in cls: assert f.name is not None @@ -102,7 +104,7 @@ def from_config(cls, config: "Configuration") -> "Flag": @classmethod def load( cls, - config: Optional["Configuration"], + config: Configuration | None, *, is_local: bool, noappname: bool, @@ -117,7 +119,7 @@ def load( nowait: bool, nowrite: bool, **kwargs: Any, - ) -> "Flag": + ) -> Flag: """Build a Flag value from command line options.""" if config: flag = cls.from_config(config) @@ -163,13 +165,13 @@ def load( @attr.s(auto_attribs=True, frozen=True, slots=True) class UISection: hidden: bool = False - width: Optional[int] = attr.ib(default=None, validator=validators.optional(gt(0))) + width: int | None = attr.ib(default=None, validator=validators.optional(gt(0))) _T = TypeVar("_T", bound="UISection") @classmethod - def from_config_section(cls: Type[_T], section: configparser.SectionProxy) -> _T: - values: Dict[str, Any] = {} + def from_config_section(cls: type[_T], section: configparser.SectionProxy) -> _T: + values: dict[str, Any] = {} known_options = {f.name: f for f in attr.fields(cls)} unknown_options = set(section) - set(known_options) if unknown_options: @@ -196,7 +198,7 @@ class Configuration(Dict[str, UISection]): _T = TypeVar("_T", bound="Configuration") @classmethod - def parse(cls: Type[_T], f: IO[str], name: str) -> _T: + def parse(cls: type[_T], f: IO[str], name: str) -> _T: r"""Parse configuration from 'f'. >>> from io import StringIO @@ -260,11 +262,11 @@ def parse(cls: Type[_T], f: IO[str], name: str) -> _T: @classmethod def lookup( - cls: Type[_T], + cls: type[_T], *, user_config_home: Path = USER_CONFIG_HOME, etc: Path = ETC, - ) -> Optional[_T]: + ) -> _T | None: for base in (user_config_home, etc): fpath = base / "pg_activity.conf" if fpath.exists(): diff --git a/pgactivity/data.py b/pgactivity/data.py index 0af58676..58f6a741 100644 --- a/pgactivity/data.py +++ b/pgactivity/data.py @@ -1,9 +1,10 @@ +from __future__ import annotations + import getpass import logging import re from argparse import Namespace from functools import partial -from typing import Dict, List, Optional import attr import psutil @@ -70,7 +71,7 @@ class Data: server_encoding: bytes min_duration: float filters: Filters - dsn_parameters: Dict[str, str] + dsn_parameters: dict[str, str] failed_queries: FailedQueriesInfo @classmethod @@ -78,16 +79,16 @@ def pg_connect( cls, min_duration: float = 0.0, *, - host: Optional[str] = None, + host: str | None = None, port: int = 5432, user: str = "postgres", - password: Optional[str] = None, + password: str | None = None, database: str = "postgres", rds_mode: bool = False, dsn: str = "", hide_queries_in_logs: bool = False, filters: Filters = NO_FILTER, - ) -> "Data": + ) -> Data: """Create an instance by connecting to a PostgreSQL server.""" pg_conn = pg.connect( dsn, @@ -116,7 +117,7 @@ def pg_connect( dsn_parameters=pg.connection_parameters(pg_conn), ) - def try_reconnect(self) -> Optional["Data"]: + def try_reconnect(self) -> Data | None: try: pg_conn = pg.connect(**self.dsn_parameters) except (pg.InterfaceError, pg.OperationalError): @@ -189,7 +190,7 @@ def pg_terminate_backend(self, pid: int) -> bool: ret = pg.fetchone(self.pg_conn, query, {"pid": pid}) return ret["is_stopped"] # type: ignore[no-any-return] - def pg_get_temporary_file(self) -> Optional[TempFileInfo]: + def pg_get_temporary_file(self) -> TempFileInfo | None: """ Count the number of temporary files and get their total size """ @@ -233,7 +234,7 @@ def pg_get_temporary_file(self) -> Optional[TempFileInfo]: finally: pg.execute(self.pg_conn, queries.get("reset_statement_timeout")) - def pg_get_wal_senders(self) -> Optional[int]: + def pg_get_wal_senders(self) -> int | None: """ Count the number of wal senders """ @@ -244,7 +245,7 @@ def pg_get_wal_senders(self) -> Optional[int]: ret = pg.fetchone(self.pg_conn, query) return int(ret["wal_senders"]) - def pg_get_wal_receivers(self) -> Optional[int]: + def pg_get_wal_receivers(self) -> int | None: """ Count the number of wal receivers """ @@ -271,7 +272,7 @@ def pg_get_wal_receivers(self) -> Optional[int]: return int(ret["wal_receivers"]) - def pg_get_replication_slots(self) -> Optional[int]: + def pg_get_replication_slots(self) -> int | None: """ Count the number of replication slots """ @@ -290,7 +291,7 @@ def dbname_filter(self) -> sql.Composable: def pg_get_server_information( self, - prev_server_info: Optional[ServerInformation] = None, + prev_server_info: ServerInformation | None = None, using_rds: bool = False, skip_db_size: bool = False, skip_tempfile: bool = False, @@ -338,17 +339,17 @@ def pg_get_server_information( ) raise - temporary_file_info: Optional[TempFileInfo] = None + temporary_file_info: TempFileInfo | None = None if not skip_tempfile: temporary_file_info = self.pg_get_temporary_file() wal_senders = self.pg_get_wal_senders() - wal_receivers: Optional[int] = None + wal_receivers: int | None = None if not skip_walreceiver: wal_receivers = self.pg_get_wal_receivers() replication_slots = self.pg_get_replication_slots() - hr: Optional[Pct] = None - rr: Optional[Pct] = None + hr: Pct | None = None + rr: Pct | None = None tps, ips, ups, dps, rps = 0, 0, 0, 0, 0 size_ev = 0.0 if prev_server_info is not None: @@ -390,7 +391,7 @@ def pg_get_server_information( **ret, ) - def pg_get_activities(self, duration_mode: int = 1) -> List[RunningProcess]: + def pg_get_activities(self, duration_mode: int = 1) -> list[RunningProcess]: """ Get activity from pg_stat_activity view. """ @@ -425,7 +426,7 @@ def pg_get_activities(self, duration_mode: int = 1) -> List[RunningProcess]: text_as_bytes=True, ) - def pg_get_waiting(self, duration_mode: int = 1) -> List[WaitingProcess]: + def pg_get_waiting(self, duration_mode: int = 1) -> list[WaitingProcess]: """ Get waiting queries. """ @@ -452,7 +453,7 @@ def pg_get_waiting(self, duration_mode: int = 1) -> List[WaitingProcess]: text_as_bytes=True, ) - def pg_get_blocking(self, duration_mode: int = 1) -> List[BlockingProcess]: + def pg_get_blocking(self, duration_mode: int = 1) -> list[BlockingProcess]: """ Get blocking queries """ diff --git a/pgactivity/handlers.py b/pgactivity/handlers.py index 661bb5c7..79939053 100644 --- a/pgactivity/handlers.py +++ b/pgactivity/handlers.py @@ -1,4 +1,4 @@ -from typing import Optional +from __future__ import annotations from blessed.keyboard import Keystroke @@ -8,7 +8,7 @@ def refresh_time( - key: Optional[str], value: float, minimum: float = 0.5, maximum: float = 5 + key: str | None, value: float, minimum: float = 0.5, maximum: float = 5 ) -> float: """Return an updated refresh time interval from input key respecting bounds. @@ -66,7 +66,7 @@ def wrap_query(key: Keystroke, wrap: bool) -> bool: return wrap -def query_mode(key: Keystroke) -> Optional[QueryMode]: +def query_mode(key: Keystroke) -> QueryMode | None: """Return the query mode matching input key or None. >>> import curses @@ -86,9 +86,7 @@ def query_mode(key: Keystroke) -> Optional[QueryMode]: return keys.QUERYMODE_FROM_KEYS.get(key) -def sort_key_for( - key: Keystroke, query_mode: QueryMode, flag: Flag -) -> Optional[SortKey]: +def sort_key_for(key: Keystroke, query_mode: QueryMode, flag: Flag) -> SortKey | None: """Return the sort key matching input key or None. >>> from blessed.keyboard import Keystroke as k diff --git a/pgactivity/keys.py b/pgactivity/keys.py index 59b28c0f..1758b7b5 100644 --- a/pgactivity/keys.py +++ b/pgactivity/keys.py @@ -1,5 +1,7 @@ +from __future__ import annotations + import curses -from typing import Any, List, Optional, Tuple +from typing import Any import attr from blessed.keyboard import Keystroke @@ -11,7 +13,7 @@ class Key: value: str description: str - name: Optional[str] = None + name: str | None = None local_only: bool = False def __eq__(self, other: Any) -> bool: @@ -97,7 +99,7 @@ def is_toggle_header_worker_info(key: Keystroke) -> bool: EXIT_KEY = Key(EXIT, "quit") PAUSE_KEY = Key(SPACE, "pause/unpause", "Space") -BINDINGS: List[Key] = [ +BINDINGS: list[Key] = [ Key("Up/Down", "scroll process list"), PAUSE_KEY, Key(SORTBY_CPU, "sort by CPU% desc. (activities)", local_only=True), @@ -118,7 +120,7 @@ def is_toggle_header_worker_info(key: Keystroke) -> bool: ] -def _sequence_by_int(v: int) -> Tuple[str, str, int]: +def _sequence_by_int(v: int) -> tuple[str, str, int]: """ >>> _sequence_by_int(11) ('F11', '11', 275) @@ -137,6 +139,6 @@ def _sequence_by_int(v: int) -> Tuple[str, str, int]: } -MODES: List[Key] = [ +MODES: list[Key] = [ Key("/".join(KEYS_BY_QUERYMODE[qm][:-1]), qm.value) for qm in QueryMode ] diff --git a/pgactivity/pg.py b/pgactivity/pg.py index 8206bad4..98cc26b2 100644 --- a/pgactivity/pg.py +++ b/pgactivity/pg.py @@ -1,16 +1,8 @@ +from __future__ import annotations + import logging import os -from typing import ( - Any, - Callable, - Dict, - List, - Optional, - Sequence, - TypeVar, - Union, - overload, -) +from typing import Any, Callable, Dict, Sequence, TypeVar, overload Row = TypeVar("Row") @@ -71,13 +63,13 @@ def connect(dsn: str = "", **kwargs: Any) -> Connection: def server_version(conn: Connection) -> int: return conn.info.server_version - def connection_parameters(conn: Connection) -> Dict[str, Any]: + def connection_parameters(conn: Connection) -> dict[str, Any]: return conn.info.get_parameters() def execute( conn: Connection, - query: Union[str, sql.Composed], - args: Union[None, Sequence[Any], Dict[str, Any]] = None, + query: str | sql.Composed, + args: None | Sequence[Any] | dict[str, Any] = None, ) -> None: conn.execute(query, args, prepare=True) @@ -92,8 +84,8 @@ def cursor( ) -> psycopg.Cursor[psycopg.rows.DictRow]: ... def cursor( - conn: Connection, mkrow: Optional[Callable[..., Row]], text_as_bytes: bool - ) -> Union[psycopg.Cursor[psycopg.rows.DictRow], psycopg.Cursor[Row]]: + conn: Connection, mkrow: Callable[..., Row] | None, text_as_bytes: bool + ) -> psycopg.Cursor[psycopg.rows.DictRow] | psycopg.Cursor[Row]: if mkrow is not None: cur = conn.cursor(row_factory=psycopg.rows.kwargs_row(mkrow)) else: @@ -105,8 +97,8 @@ def cursor( @overload def fetchone( conn: Connection, - query: Union[str, sql.Composed], - args: Union[None, Sequence[Any], Dict[str, Any]] = None, + query: str | sql.Composed, + args: None | Sequence[Any] | dict[str, Any] = None, *, mkrow: Callable[..., Row], text_as_bytes: bool = False, @@ -115,20 +107,20 @@ def fetchone( @overload def fetchone( conn: Connection, - query: Union[str, sql.Composed], - args: Union[None, Sequence[Any], Dict[str, Any]] = None, + query: str | sql.Composed, + args: None | Sequence[Any] | dict[str, Any] = None, *, text_as_bytes: bool = False, - ) -> Dict[str, Any]: ... + ) -> dict[str, Any]: ... def fetchone( conn: Connection, - query: Union[str, sql.Composed], - args: Union[None, Sequence[Any], Dict[str, Any]] = None, + query: str | sql.Composed, + args: None | Sequence[Any] | dict[str, Any] = None, *, - mkrow: Optional[Callable[..., Row]] = None, + mkrow: Callable[..., Row] | None = None, text_as_bytes: bool = False, - ) -> Union[Dict[str, Any], Row]: + ) -> dict[str, Any] | Row: with cursor(conn, mkrow, text_as_bytes) as cur: row = cur.execute(query, args, prepare=True).fetchone() assert row is not None @@ -137,30 +129,30 @@ def fetchone( @overload def fetchall( conn: Connection, - query: Union[str, sql.Composed], - args: Union[None, Sequence[Any], Dict[str, Any]] = None, + query: str | sql.Composed, + args: None | Sequence[Any] | dict[str, Any] = None, *, mkrow: Callable[..., Row], text_as_bytes: bool = False, - ) -> List[Row]: ... + ) -> list[Row]: ... @overload def fetchall( conn: Connection, - query: Union[str, sql.Composed], - args: Union[None, Sequence[Any], Dict[str, Any]] = None, + query: str | sql.Composed, + args: None | Sequence[Any] | dict[str, Any] = None, *, text_as_bytes: bool = False, - ) -> List[Dict[str, Any]]: ... + ) -> list[dict[str, Any]]: ... def fetchall( conn: Connection, - query: Union[str, sql.Composed], - args: Union[None, Sequence[Any], Dict[str, Any]] = None, + query: str | sql.Composed, + args: None | Sequence[Any] | dict[str, Any] = None, *, text_as_bytes: bool = False, - mkrow: Optional[Callable[..., Row]] = None, - ) -> Union[List[Dict[str, Any]], List[Row]]: + mkrow: Callable[..., Row] | None = None, + ) -> list[dict[str, Any]] | list[Row]: with cursor(conn, mkrow, text_as_bytes) as cur: return cur.execute(query, args, prepare=True).fetchall() @@ -212,25 +204,25 @@ def connect(dsn: str = "", **kwargs: Any) -> Connection: def server_version(conn: Connection) -> int: return conn.server_version # type: ignore[attr-defined, no-any-return] - def connection_parameters(conn: Connection) -> Dict[str, Any]: + def connection_parameters(conn: Connection) -> dict[str, Any]: return conn.info.dsn_parameters # type: ignore[attr-defined, no-any-return] def execute( conn: Connection, - query: Union[str, sql.Composed], - args: Union[None, Sequence[Any], Dict[str, Any]] = None, + query: str | sql.Composed, + args: None | Sequence[Any] | dict[str, Any] = None, ) -> None: with conn.cursor() as cur: cur.execute(query, args) def fetchone( # type: ignore[no-redef] conn: Connection, - query: Union[str, sql.Composed], - args: Union[None, Sequence[Any], Dict[str, Any]] = None, + query: str | sql.Composed, + args: None | Sequence[Any] | dict[str, Any] = None, *, - mkrow: Optional[Callable[..., Row]] = None, + mkrow: Callable[..., Row] | None = None, text_as_bytes: bool = False, - ) -> Union[Dict[str, Any], Row]: + ) -> dict[str, Any] | Row: with conn.cursor() as cur: if text_as_bytes: psycopg2.extensions.register_type(psycopg2.extensions.BYTES, cur) # type: ignore[arg-type] @@ -243,12 +235,12 @@ def fetchone( # type: ignore[no-redef] def fetchall( # type: ignore[no-redef] conn: Connection, - query: Union[str, sql.Composed], - args: Union[None, Sequence[Any], Dict[str, Any]] = None, + query: str | sql.Composed, + args: None | Sequence[Any] | dict[str, Any] = None, *, - mkrow: Optional[Callable[..., Row]] = None, + mkrow: Callable[..., Row] | None = None, text_as_bytes: bool = False, - ) -> Union[List[Dict[str, Any]], List[Row]]: + ) -> list[dict[str, Any]] | list[Row]: with conn.cursor() as cur: if text_as_bytes: psycopg2.extensions.register_type(psycopg2.extensions.BYTES, cur) # type: ignore[arg-type] diff --git a/pgactivity/types.py b/pgactivity/types.py index 4a442c1a..aa7da2cc 100644 --- a/pgactivity/types.py +++ b/pgactivity/types.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import enum import functools from datetime import timedelta @@ -5,16 +7,12 @@ from typing import ( Any, Callable, - Dict, Iterable, Iterator, - List, Mapping, MutableSet, - Optional, Sequence, Tuple, - Type, TypeVar, Union, overload, @@ -57,10 +55,10 @@ def enum_next(e: E) -> E: @attr.s(auto_attribs=True, frozen=True, slots=True) class Filters: - dbname: Optional[str] = None + dbname: str | None = None @classmethod - def from_options(cls, filters: Sequence[str]) -> "Filters": + def from_options(cls, filters: Sequence[str]) -> Filters: fields = compat.fields_dict(cls) attrs = {} for f in filters: @@ -89,7 +87,7 @@ class SortKey(enum.Enum): duration = enum.auto() @classmethod - def default(cls) -> "SortKey": + def default(cls) -> SortKey: return cls.duration @@ -100,7 +98,7 @@ class QueryMode(enum.Enum): blocking = "blocking queries" @classmethod - def default(cls) -> "QueryMode": + def default(cls) -> QueryMode: return cls.activities @@ -155,14 +153,14 @@ class Column: key: str = attr.ib(repr=False) name: str mandatory: bool = False - sort_key: Optional[SortKey] = None + sort_key: SortKey | None = None min_width: int = attr.ib(default=0, repr=False) - max_width: Optional[int] = attr.ib(default=None, repr=False) + max_width: int | None = attr.ib(default=None, repr=False) justify: str = attr.ib( "left", validator=validators.in_(["left", "center", "right"]) ) transform: Callable[[Any], str] = attr.ib(default=if_none(""), repr=False) - color_key: Union[str, Callable[[Any], str]] = attr.ib( + color_key: str | Callable[[Any], str] = attr.ib( default=_color_key_marker, repr=False ) @@ -210,7 +208,7 @@ def color(self, value: Any) -> str: class UI: """State of the UI.""" - columns_by_querymode: Mapping[QueryMode, Tuple[Column, ...]] + columns_by_querymode: Mapping[QueryMode, tuple[Column, ...]] min_duration: float = 0.0 duration_mode: DurationMode = attr.ib( default=DurationMode.query, converter=DurationMode @@ -218,9 +216,9 @@ class UI: wrap_query: bool = False sort_key: SortKey = attr.ib(default=SortKey.default(), converter=SortKey) query_mode: QueryMode = attr.ib(default=QueryMode.activities, converter=QueryMode) - refresh_time: Union[float, int] = 2 + refresh_time: float | int = 2 in_pause: bool = False - interactive_timeout: Optional[int] = None + interactive_timeout: int | None = None show_instance_info_in_header: bool = True show_system_info_in_header: bool = True show_worker_info_in_header: bool = True @@ -228,14 +226,14 @@ class UI: @classmethod def make( cls, - config: Optional[Configuration] = None, + config: Configuration | None = None, flag: Flag = Flag.all(), *, max_db_length: int = 16, filters: Filters = NO_FILTER, **kwargs: Any, - ) -> "UI": - possible_columns: Dict[str, Column] = {} + ) -> UI: + possible_columns: dict[str, Column] = {} def add_column(key: str, name: str, **kwargs: Any) -> None: if config is not None: @@ -388,7 +386,7 @@ def add_column(key: str, name: str, **kwargs: Any) -> None: transform=utils.naturalsize, ) - columns_key_by_querymode: Mapping[QueryMode, List[str]] = { + columns_key_by_querymode: Mapping[QueryMode, list[str]] = { QueryMode.activities: [ "pid", "database", @@ -604,7 +602,7 @@ def column(self, key: str) -> Column: else: raise ValueError(key) - def columns(self) -> Tuple[Column, ...]: + def columns(self) -> tuple[Column, ...]: """Return the tuple of Column for current mode. >>> flag = Flag.PID | Flag.DATABASE | Flag.APPNAME | Flag.RELATION @@ -631,18 +629,18 @@ class SwapInfo: total: int @classmethod - def default(cls) -> "SwapInfo": + def default(cls) -> SwapInfo: return cls(0, 0, 0) @property - def pct_used(self) -> Optional[Pct]: + def pct_used(self) -> Pct | None: if self.total == 0: # account for the zero swap case (#318) return None return Pct(self.used * 100 / self.total) @property - def pct_free(self) -> Optional[Pct]: + def pct_free(self) -> Pct | None: if self.total == 0: # account for the zero swap case (#318) return None @@ -657,23 +655,23 @@ class MemoryInfo: total: int @classmethod - def default(cls) -> "MemoryInfo": + def default(cls) -> MemoryInfo: return cls(0, 0, 0, 0) @property - def pct_used(self) -> Optional[Pct]: + def pct_used(self) -> Pct | None: if self.total == 0: return None return Pct(self.used * 100 / self.total) @property - def pct_free(self) -> Optional[Pct]: + def pct_free(self) -> Pct | None: if self.total == 0: return None return Pct(self.free * 100 / self.total) @property - def pct_bc(self) -> Optional[Pct]: + def pct_bc(self) -> Pct | None: if self.total == 0: return None return Pct(self.buff_cached * 100 / self.total) @@ -686,7 +684,7 @@ class LoadAverage: avg15: float @classmethod - def default(cls) -> "LoadAverage": + def default(cls) -> LoadAverage: return cls(0.0, 0.0, 0.0) @@ -696,7 +694,7 @@ class IOCounter: bytes: int @classmethod - def default(cls) -> "IOCounter": + def default(cls) -> IOCounter: return cls(0, 0) @@ -713,10 +711,10 @@ class SystemInfo: def default( cls, *, - memory: Optional[MemoryInfo] = None, - swap: Optional[SwapInfo] = None, - load: Optional[LoadAverage] = None, - ) -> "SystemInfo": + memory: MemoryInfo | None = None, + swap: SwapInfo | None = None, + load: LoadAverage | None = None, + ) -> SystemInfo: """Zero-value builder. >>> SystemInfo.default() # doctest: +NORMALIZE_WHITESPACE @@ -771,19 +769,19 @@ class ServerInformation: total: int waiting: int max_connections: int - autovacuum_workers: Optional[int] + autovacuum_workers: int | None autovacuum_max_workers: int - logical_replication_workers: Optional[int] - parallel_workers: Optional[int] - max_logical_replication_workers: Optional[int] - max_parallel_workers: Optional[int] - max_worker_processes: Optional[int] - max_wal_senders: Optional[int] - max_replication_slots: Optional[int] - wal_senders: Optional[int] - wal_receivers: Optional[int] - replication_slots: Optional[int] - temporary_file: Optional[TempFileInfo] + logical_replication_workers: int | None + parallel_workers: int | None + max_logical_replication_workers: int | None + max_parallel_workers: int | None + max_worker_processes: int | None + max_wal_senders: int | None + max_replication_slots: int | None + wal_senders: int | None + wal_receivers: int | None + replication_slots: int | None + temporary_file: TempFileInfo | None # Computed in data.pg_get_server_information() size_evolution: float tps: int @@ -791,15 +789,15 @@ class ServerInformation: update_per_second: int delete_per_second: int tuples_returned_per_second: int - cache_hit_ratio_last_snap: Optional[Pct] = attr.ib( + cache_hit_ratio_last_snap: Pct | None = attr.ib( converter=attr.converters.optional(Pct) ) - rollback_ratio_last_snap: Optional[Pct] = attr.ib( + rollback_ratio_last_snap: Pct | None = attr.ib( converter=attr.converters.optional(Pct) ) @property - def worker_processes(self) -> Optional[int]: + def worker_processes(self) -> int | None: if self.parallel_workers is None and self.logical_replication_workers is None: return None else: @@ -844,24 +842,24 @@ def locktype(value: str) -> LockType: class BaseProcess: pid: int application_name: str - database: Optional[str] + database: str | None user: str - client: Union[None, IPv4Address, IPv6Address] - duration: Optional[float] + client: None | IPv4Address | IPv6Address + duration: float | None state: str - query: Optional[str] - encoding: Optional[str] - query_leader_pid: Optional[int] + query: str | None + encoding: str | None + query_leader_pid: int | None is_parallel_worker: bool _P = TypeVar("_P", bound="BaseProcess") @classmethod def from_bytes( - cls: Type[_P], + cls: type[_P], server_encoding: bytes, *, - encoding: Optional[Union[str, bytes]], + encoding: str | bytes | None, **kwargs: Any, ) -> _P: if encoding is None: @@ -880,8 +878,8 @@ def from_bytes( class RunningProcess(BaseProcess): """Process for a running query.""" - wait: Union[bool, None, str] - query_leader_pid: Optional[int] + wait: bool | None | str + query_leader_pid: int | None is_parallel_worker: bool @@ -896,7 +894,7 @@ class WaitingProcess(BaseProcess): relation: str # TODO: update queries to select/compute these column. - query_leader_pid: Optional[int] = attr.ib(default=None, init=False) + query_leader_pid: int | None = attr.ib(default=None, init=False) is_parallel_worker: bool = attr.ib(default=False, init=False) @@ -909,26 +907,26 @@ class BlockingProcess(BaseProcess): mode: str type: LockType = attr.ib(converter=locktype) relation: str - wait: Union[bool, None, str] + wait: bool | None | str # TODO: update queries to select/compute these column. - query_leader_pid: Optional[int] = attr.ib(default=None, init=False) + query_leader_pid: int | None = attr.ib(default=None, init=False) is_parallel_worker: bool = attr.ib(default=False, init=False) @attr.s(auto_attribs=True, frozen=True, slots=True) class SystemProcess: - meminfo: Tuple[int, ...] + meminfo: tuple[int, ...] io_read: IOCounter io_write: IOCounter io_time: float mem_percent: float cpu_percent: float - cpu_times: Tuple[float, ...] + cpu_times: tuple[float, ...] read_delta: float write_delta: float io_wait: bool - psutil_proc: Optional[psutil.Process] + psutil_proc: psutil.Process | None @attr.s(auto_attribs=True, frozen=True, slots=True) @@ -941,8 +939,8 @@ class LocalRunningProcess(RunningProcess): @classmethod def from_process( - cls, process: RunningProcess, **kwargs: Union[float, str] - ) -> "LocalRunningProcess": + cls, process: RunningProcess, **kwargs: float | str + ) -> LocalRunningProcess: return cls(**dict(attr.asdict(process), **kwargs)) @@ -1047,8 +1045,8 @@ class SelectableProcesses: """ - items: List[BaseProcess] - focused: Optional[int] = None + items: list[BaseProcess] + focused: int | None = None pinned: MutableSet[int] = attr.ib(default=attr.Factory(set)) def __len__(self) -> int: @@ -1061,15 +1059,13 @@ def __iter__(self) -> Iterator[BaseProcess]: def __getitem__(self, i: int) -> BaseProcess: ... @overload - def __getitem__(self, s: slice) -> List[BaseProcess]: ... + def __getitem__(self, s: slice) -> list[BaseProcess]: ... - def __getitem__( - self, val: Union[int, slice] - ) -> Union[BaseProcess, List[BaseProcess]]: + def __getitem__(self, val: int | slice) -> BaseProcess | list[BaseProcess]: return self.items[val] @property - def selected(self) -> List[int]: + def selected(self) -> list[int]: if self.pinned: return list(self.pinned) elif self.focused: @@ -1084,7 +1080,7 @@ def reset(self) -> None: def set_items(self, new_items: Sequence[BaseProcess]) -> None: self.items[:] = list(new_items) - def position(self) -> Optional[int]: + def position(self) -> int | None: if self.focused is None: return None for idx, proc in enumerate(self.items): diff --git a/pgactivity/ui.py b/pgactivity/ui.py index ee6f3144..3befcb95 100644 --- a/pgactivity/ui.py +++ b/pgactivity/ui.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import time from argparse import Namespace from functools import partial -from typing import Dict, List, Optional, cast +from typing import List, cast import attr from blessed import Terminal @@ -13,15 +15,15 @@ def main( term: Terminal, - config: Optional[Configuration], + config: Configuration | None, data: Data, host: types.Host, options: Namespace, *, render_header: bool = True, render_footer: bool = True, - width: Optional[int] = None, - wait_on_actions: Optional[float] = None, + width: int | None = None, + wait_on_actions: float | None = None, ) -> None: fs_blocksize = options.blocksize @@ -52,7 +54,7 @@ def main( ) key, in_help = None, False - sys_procs: Dict[int, types.SystemProcess] = {} + sys_procs: dict[int, types.SystemProcess] = {} pg_procs = types.SelectableProcesses([]) activity_stats: types.ActivityStats diff --git a/pgactivity/utils.py b/pgactivity/utils.py index 888c4fb6..4495892b 100644 --- a/pgactivity/utils.py +++ b/pgactivity/utils.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import functools import re from datetime import datetime, timedelta -from typing import IO, Any, Iterable, List, Mapping, Optional, Tuple, Union +from typing import IO, Any, Iterable, Mapping import attr import humanize @@ -49,12 +51,12 @@ class MessagePile: """ n: int - messages: List[str] = attr.ib(default=attr.Factory(list), init=False) + messages: list[str] = attr.ib(default=attr.Factory(list), init=False) def send(self, message: str) -> None: self.messages[:] = [message] * self.n - def get(self) -> Optional[str]: + def get(self) -> str | None: if self.messages: return self.messages.pop() return None @@ -106,7 +108,7 @@ def ellipsis(v: str, width: int) -> str: return v[: wl + 1] + "..." + v[-wl:] -def get_duration(duration: Optional[float]) -> float: +def get_duration(duration: float | None) -> float: """Return 0 if the given duration is negative else, return the duration. >>> get_duration(None) @@ -122,7 +124,7 @@ def get_duration(duration: Optional[float]) -> float: @functools.lru_cache(maxsize=2) -def format_duration(duration: Optional[float]) -> Tuple[str, str]: +def format_duration(duration: float | None) -> tuple[str, str]: """Return a string from 'duration' value along with the color for rendering. >>> format_duration(None) @@ -165,7 +167,7 @@ def format_duration(duration: Optional[float]) -> Tuple[str, str]: return ctime, color -def wait_status(value: Union[None, bool, str]) -> str: +def wait_status(value: None | bool | str) -> str: """Display the waiting status of query. >>> wait_status(None) @@ -272,7 +274,7 @@ def clean_str_csv(s: str) -> str: + "\n" ) - def yn_na(value: Optional[bool]) -> str: + def yn_na(value: bool | None) -> str: if value is None: return "N/A" return yn(value) diff --git a/pgactivity/views.py b/pgactivity/views.py index 69c41ff1..a5110506 100644 --- a/pgactivity/views.py +++ b/pgactivity/views.py @@ -1,8 +1,10 @@ +from __future__ import annotations + import functools import inspect import itertools from textwrap import TextWrapper, dedent -from typing import Any, Callable, Iterable, Iterator, List, Optional, Sequence, Tuple +from typing import Any, Callable, Iterable, Iterator, Sequence from blessed import Terminal @@ -47,7 +49,7 @@ def __next__(self) -> int: @functools.lru_cache(maxsize=512) -def shorten(term: Terminal, text: str, width: Optional[int] = None) -> str: +def shorten(term: Terminal, text: str, width: int | None = None) -> str: r"""Truncate 'text' to fit in the given 'width' (or term.width). This is similar to textwrap.shorten() but sequence-aware. @@ -157,7 +159,7 @@ def header( host: Host, pg_version: str, server_information: ServerInformation, - system_info: Optional[SystemInfo] = None, + system_info: SystemInfo | None = None, ) -> Iterator[str]: @functools.singledispatch def render(x: Any) -> str: @@ -188,7 +190,7 @@ def render_iocounter(i: IOCounter) -> str: return f"{term.bold_green(hbytes)} - {term.bold_green(counts)}" def render_columns( - columns: Sequence[List[str]], *, delimiter: str = f"{term.blue(',')} " + columns: Sequence[list[str]], *, delimiter: str = f"{term.blue(',')} " ) -> Iterator[str]: column_widths = [ max(len(column_row) for column_row in column) for column in columns @@ -402,7 +404,7 @@ def processes_rows( ui: UI, processes: SelectableProcesses, maxlines: int, - width: Optional[int], + width: int | None, ) -> Iterator[str]: """Display table rows with processes information.""" @@ -449,7 +451,7 @@ def cell( color_type = "yellow" else: color_type = "default" - text: List[str] = [] + text: list[str] = [] for column in ui.columns(): field = column.key if field != "query": @@ -472,13 +474,13 @@ def cell( yield from (" ".join(text) + term.normal).splitlines() -def footer_message(term: Terminal, message: str, width: Optional[int] = None) -> None: +def footer_message(term: Terminal, message: str, width: int | None = None) -> None: if width is None: width = term.width print(term.center(message[:width]) + term.normal, end="") -def footer_help(term: Terminal, width: Optional[int] = None) -> None: +def footer_help(term: Terminal, width: int | None = None) -> None: """Footer line with help keys.""" query_modes_help = [ ("/".join(keys[:-1]), qm.value) for qm, keys in KEYS_BY_QUERYMODE.items() @@ -493,7 +495,7 @@ def footer_help(term: Terminal, width: Optional[int] = None) -> None: def render_footer( - term: Terminal, footer_values: List[Tuple[str, str]], width: Optional[int] + term: Terminal, footer_values: list[tuple[str, str]], width: int | None ) -> None: if width is None: width = term.width @@ -514,7 +516,7 @@ def render_column(key: str, desc: str) -> str: print(term.ljust(row, width=width, fillchar=term.cyan_reverse(" ")), end="") -def footer_interative_help(term: Terminal, width: Optional[int] = None) -> None: +def footer_interative_help(term: Terminal, width: int | None = None) -> None: """Footer line with help keys for interactive mode.""" assert PROCESS_PIN.name is not None footer_values = [ @@ -535,14 +537,14 @@ def screen( pg_version: str, server_information: ServerInformation, activity_stats: ActivityStats, - message: Optional[str], + message: str | None, render_header: bool = True, render_footer: bool = True, - width: Optional[int] = None, + width: int | None = None, ) -> None: """Display the screen.""" - system_info: Optional[SystemInfo] + system_info: SystemInfo | None if isinstance(activity_stats, tuple): processes, system_info = activity_stats else: diff --git a/pgactivity/widgets.py b/pgactivity/widgets.py index e3bda772..bc8c3ee8 100644 --- a/pgactivity/widgets.py +++ b/pgactivity/widgets.py @@ -1,4 +1,4 @@ -from typing import Optional +from __future__ import annotations from blessed import Terminal @@ -10,7 +10,7 @@ def boxed( border: bool = True, border_color: str = "white", center: bool = False, - width: Optional[int] = None, + width: int | None = None, ) -> str: if border: border_width = term.length(content) + 2 diff --git a/tests/conftest.py b/tests/conftest.py index 94da03cc..27548fc4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import logging import pathlib import threading -from typing import Any, List, Optional +from typing import Any import psycopg import psycopg.errors @@ -15,7 +17,7 @@ LOGGER.setLevel(logging.DEBUG) -def pytest_report_header(config: Any) -> List[str]: +def pytest_report_header(config: Any) -> list[str]: return [f"psycopg: {pg.__version__}"] @@ -28,7 +30,7 @@ def datadir() -> pathlib.Path: def database_factory(postgresql): dbnames = set() - def createdb(dbname: str, encoding: str, locale: Optional[str] = None) -> None: + def createdb(dbname: str, encoding: str, locale: str | None = None) -> None: with psycopg.connect(postgresql.info.dsn, autocommit=True) as conn: qs = sql.SQL( "CREATE DATABASE {dbname} ENCODING {encoding} TEMPLATE template0" @@ -67,7 +69,7 @@ def execute( query: str, commit: bool = False, autocommit: bool = False, - dbname: Optional[str] = None, + dbname: str | None = None, ) -> None: dsn, kwargs = postgresql.info.dsn, {} if dbname: diff --git a/tests/test_config.py b/tests/test_config.py index 93909b88..9d6b6e21 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from pathlib import Path -from typing import Any, Dict +from typing import Any import attr @@ -75,7 +77,7 @@ def test_flag_load(): def test_lookup(tmp_path: Path) -> None: - def asdict(cfg: Configuration) -> Dict[str, Any]: + def asdict(cfg: Configuration) -> dict[str, Any]: return {k: attr.asdict(v) for k, v in cfg.items()} cfg = Configuration.lookup(user_config_home=tmp_path) diff --git a/tests/test_data.py b/tests/test_data.py index 7fcd6673..4aca9783 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -1,5 +1,6 @@ +from __future__ import annotations + import time -from typing import Optional import attr import psycopg @@ -163,7 +164,7 @@ def test_client_encoding(postgresql, encoding: str) -> None: ], ) def test_postgres_and_python_encoding( - database_factory, pyenc: str, pgenc: str, locale: Optional[str], data, postgresql + database_factory, pyenc: str, pgenc: str, locale: str | None, data, postgresql ) -> None: dbname = pyenc try: From 360af5df3f6f85cfedebca39255a9c007f155a16 Mon Sep 17 00:00:00 2001 From: Denis Laxalde Date: Wed, 21 Feb 2024 11:24:14 +0100 Subject: [PATCH 4/9] Handle new collections.abc imports through the compat module --- pgactivity/activities.py | 3 ++- pgactivity/compat.py | 25 +++++++++++++++++++++++++ pgactivity/pg.py | 4 +++- pgactivity/types.py | 15 ++------------- pgactivity/views.py | 4 ++-- 5 files changed, 34 insertions(+), 17 deletions(-) diff --git a/pgactivity/activities.py b/pgactivity/activities.py index b0662266..6cc596f0 100644 --- a/pgactivity/activities.py +++ b/pgactivity/activities.py @@ -3,12 +3,13 @@ import builtins import os import time -from typing import Sequence, TypeVar +from typing import TypeVar from warnings import catch_warnings, simplefilter import attr import psutil +from .compat import Sequence from .types import ( BlockingProcess, IOCounter, diff --git a/pgactivity/compat.py b/pgactivity/compat.py index d615664c..4fd50731 100644 --- a/pgactivity/compat.py +++ b/pgactivity/compat.py @@ -1,6 +1,7 @@ from __future__ import annotations import operator +import sys from importlib.metadata import version from typing import Any @@ -8,6 +9,30 @@ import attr.validators import blessed +__all__ = [ + "Callable", + "Dict", + "Iterable", + "Iterator", + "Mapping", + "MutableSet", + "Sequence", +] + +if sys.version_info >= (3, 9): + from collections.abc import ( + Callable, + Iterable, + Iterator, + Mapping, + MutableSet, + Sequence, + ) + + Dict = dict +else: + from typing import Callable, Dict, Iterable, Iterator, Mapping, MutableSet, Sequence + ATTR_VERSION = tuple(int(x) for x in version("attrs").split(".", 2)[:2]) BLESSED_VERSION = tuple(int(x) for x in version("blessed").split(".", 2)[:2]) diff --git a/pgactivity/pg.py b/pgactivity/pg.py index 98cc26b2..d90200fd 100644 --- a/pgactivity/pg.py +++ b/pgactivity/pg.py @@ -2,7 +2,9 @@ import logging import os -from typing import Any, Callable, Dict, Sequence, TypeVar, overload +from typing import Any, TypeVar, overload + +from .compat import Callable, Dict, Sequence Row = TypeVar("Row") diff --git a/pgactivity/types.py b/pgactivity/types.py index aa7da2cc..0ceccf42 100644 --- a/pgactivity/types.py +++ b/pgactivity/types.py @@ -4,25 +4,14 @@ import functools from datetime import timedelta from ipaddress import IPv4Address, IPv6Address -from typing import ( - Any, - Callable, - Iterable, - Iterator, - Mapping, - MutableSet, - Sequence, - Tuple, - TypeVar, - Union, - overload, -) +from typing import Any, Tuple, TypeVar, Union, overload import attr import psutil from attr import validators from . import colors, compat, pg, utils +from .compat import Callable, Iterable, Iterator, Mapping, MutableSet, Sequence from .config import Configuration, Flag diff --git a/pgactivity/views.py b/pgactivity/views.py index a5110506..a73f7614 100644 --- a/pgactivity/views.py +++ b/pgactivity/views.py @@ -4,13 +4,13 @@ import inspect import itertools from textwrap import TextWrapper, dedent -from typing import Any, Callable, Iterable, Iterator, Sequence +from typing import Any from blessed import Terminal from . import colors, utils from .activities import sorted as sorted_processes -from .compat import link +from .compat import Callable, Iterable, Iterator, Sequence, link from .keys import BINDINGS, EXIT_KEY from .keys import HELP as HELP_KEY from .keys import ( From 12663684d55e81999f01844b6cc752dad7dbac84 Mon Sep 17 00:00:00 2001 From: Denis Laxalde Date: Tue, 20 Feb 2024 13:44:09 +0100 Subject: [PATCH 5/9] Improve --no-{inst,sys,proc}-info options Rephrase their help text and move then to a dedicated options group. --- CHANGELOG.md | 2 ++ README.md | 8 +++++--- docs/man/pg_activity.1 | 14 ++++++++------ docs/man/pg_activity.pod | 14 ++++++++++---- pgactivity/cli.py | 26 ++++++++++++++------------ 5 files changed, 39 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d1e3a3f..eb66a7ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ * The help text for `K` action, displayed in the footer, has been rephrased as "terminate underlying session". +* Rephrase the help text of `--no-{inst,sys,proc}-info` options and group them + into a dedicated section of `--help` output. ### Fixed diff --git a/README.md b/README.md index 532c076b..4427b433 100644 --- a/README.md +++ b/README.md @@ -130,12 +130,14 @@ ex: --no-wait Disable W. --no-app-name Disable App. + Header display options: + --no-inst-info Display instance information. + --no-sys-info Display system information. + --no-proc-info Display workers process information. + Other display options: --hide-queries-in-logs Disable log_min_duration_statements and log_min_duration_sample for pg_activity. - --no-inst-info Display instance information in header. - --no-sys-info Display system information in header. - --no-proc-info Display workers process information in header. --refresh REFRESH Refresh rate. Values: 0.5, 1, 2, 3, 4, 5 (default: 2). ## Configuration diff --git a/docs/man/pg_activity.1 b/docs/man/pg_activity.1 index 43b7ed86..97d172c0 100644 --- a/docs/man/pg_activity.1 +++ b/docs/man/pg_activity.1 @@ -133,7 +133,7 @@ .\" ======================================================================== .\" .IX Title "PG_ACTIVITY 1" -.TH PG_ACTIVITY 1 "2023-06-01" "pg_activity 3.4.2" "Command line tool for PostgreSQL server activity monitoring." +.TH PG_ACTIVITY 1 "2024-02-20" "pg_activity 3.4.2" "Command line tool for PostgreSQL server activity monitoring." .\" For nroff, turn off justification. Always turn off hyphenation; it makes .\" way too many mistakes in technical documents. .if n .ad l @@ -490,23 +490,25 @@ required by another session. It shows following information: .Vb 1 \& Disable App. .Ve -.SS "\s-1OTHER DISPLAY OPTIONS\s0" -.IX Subsection "OTHER DISPLAY OPTIONS" +.SS "\s-1HEADER DISPLAY OPTIONS\s0" +.IX Subsection "HEADER DISPLAY OPTIONS" .IP "\fB\-\-no\-inst\-info\fR" 2 .IX Item "--no-inst-info" .Vb 1 -\& Display instance information in header. +\& Hide instance information. .Ve .IP "\fB\-\-no\-sys\-info\fR" 2 .IX Item "--no-sys-info" .Vb 1 -\& Display system information in header. +\& Hide system information. .Ve .IP "\fB\-\-no\-proc\-info\fR" 2 .IX Item "--no-proc-info" .Vb 1 -\& Display workers process information in header. +\& Hide workers process information. .Ve +.SS "\s-1OTHER DISPLAY OPTIONS\s0" +.IX Subsection "OTHER DISPLAY OPTIONS" .IP "\fB\-\-refresh\fR" 2 .IX Item "--refresh" .Vb 1 diff --git a/docs/man/pg_activity.pod b/docs/man/pg_activity.pod index 99572cfb..36b8b06a 100644 --- a/docs/man/pg_activity.pod +++ b/docs/man/pg_activity.pod @@ -358,21 +358,27 @@ required by another session. It shows following information: =back -=head2 OTHER DISPLAY OPTIONS +=head2 HEADER DISPLAY OPTIONS =over 2 =item B<--no-inst-info> - Display instance information in header. + Hide instance information. =item B<--no-sys-info> - Display system information in header. + Hide system information. =item B<--no-proc-info> - Display workers process information in header. + Hide workers process information. + +=back + +=head2 OTHER DISPLAY OPTIONS + +=over 2 =item B<--refresh> diff --git a/pgactivity/cli.py b/pgactivity/cli.py index 7aec79e5..673ea111 100755 --- a/pgactivity/cli.py +++ b/pgactivity/cli.py @@ -315,21 +315,13 @@ def get_parser() -> ArgumentParser: default=False, ) - group = parser.add_argument_group("Other display options") - # --hide-queries-in-logs - group.add_argument( - "--hide-queries-in-logs", - dest="hide_queries_in_logs", - action="store_true", - help="Disable log_min_duration_statements and log_min_duration_sample for pg_activity.", - default=False, - ) + group = parser.add_argument_group("Header display options") # --no-inst-info group.add_argument( "--no-inst-info", dest="show_instance_info_in_header", action="store_false", - help="Display instance information in header.", + help="Hide instance information.", default=True, ) # --no-sys-info @@ -337,7 +329,7 @@ def get_parser() -> ArgumentParser: "--no-sys-info", dest="show_system_info_in_header", action="store_false", - help="Display system information in header.", + help="Hide system information.", default=True, ) # --no-proc-info @@ -345,9 +337,19 @@ def get_parser() -> ArgumentParser: "--no-proc-info", dest="show_worker_info_in_header", action="store_false", - help="Display workers process information in header.", + help="Hide workers process information.", default=True, ) + + group = parser.add_argument_group("Other display options") + # --hide-queries-in-logs + group.add_argument( + "--hide-queries-in-logs", + dest="hide_queries_in_logs", + action="store_true", + help="Disable log_min_duration_statements and log_min_duration_sample for pg_activity.", + default=False, + ) # --refresh group.add_argument( "--refresh", From abb981b3bf63e7e13ed1528e79fa141565b2c6e2 Mon Sep 17 00:00:00 2001 From: Denis Laxalde Date: Tue, 20 Feb 2024 15:17:56 +0100 Subject: [PATCH 6/9] Move UI header options into a dedicated class We introduce UIHeader class to hold header-specific options. Along the way, many names (variables, functions) are changed in order to make things more explicit. --- pgactivity/cli.py | 6 +-- pgactivity/keys.py | 12 ++--- pgactivity/types.py | 109 ++++++++++++++++++++++++------------------- pgactivity/ui.py | 20 ++++---- pgactivity/views.py | 6 +-- tests/test_ui.txt | 12 ++--- tests/test_views.txt | 14 +++--- 7 files changed, 96 insertions(+), 83 deletions(-) diff --git a/pgactivity/cli.py b/pgactivity/cli.py index 673ea111..45c7abfc 100755 --- a/pgactivity/cli.py +++ b/pgactivity/cli.py @@ -319,7 +319,7 @@ def get_parser() -> ArgumentParser: # --no-inst-info group.add_argument( "--no-inst-info", - dest="show_instance_info_in_header", + dest="header_show_instance", action="store_false", help="Hide instance information.", default=True, @@ -327,7 +327,7 @@ def get_parser() -> ArgumentParser: # --no-sys-info group.add_argument( "--no-sys-info", - dest="show_system_info_in_header", + dest="header_show_system", action="store_false", help="Hide system information.", default=True, @@ -335,7 +335,7 @@ def get_parser() -> ArgumentParser: # --no-proc-info group.add_argument( "--no-proc-info", - dest="show_worker_info_in_header", + dest="header_show_workers", action="store_false", help="Hide workers process information.", default=True, diff --git a/pgactivity/keys.py b/pgactivity/keys.py index 1758b7b5..b2930653 100644 --- a/pgactivity/keys.py +++ b/pgactivity/keys.py @@ -49,7 +49,7 @@ def __eq__(self, other: Any) -> bool: SORTBY_CPU = "c" HEADER_TOGGLE_SYSTEM = "s" HEADER_TOGGLE_INSTANCE = "i" -HEADER_TOGGLE_WORKER = "o" +HEADER_TOGGLE_WORKERS = "o" def is_process_next(key: Keystroke) -> bool: @@ -84,16 +84,16 @@ def is_process_last(key: Keystroke) -> bool: return key.name == PROCESS_LAST -def is_toggle_header_sys_info(key: Keystroke) -> bool: +def is_toggle_header_system(key: Keystroke) -> bool: return key == HEADER_TOGGLE_SYSTEM -def is_toggle_header_inst_info(key: Keystroke) -> bool: +def is_toggle_header_instance(key: Keystroke) -> bool: return key == HEADER_TOGGLE_INSTANCE -def is_toggle_header_worker_info(key: Keystroke) -> bool: - return key == HEADER_TOGGLE_WORKER +def is_toggle_header_workers(key: Keystroke) -> bool: + return key == HEADER_TOGGLE_WORKERS EXIT_KEY = Key(EXIT, "quit") @@ -115,7 +115,7 @@ def is_toggle_header_worker_info(key: Keystroke) -> bool: Key("R", "force refresh"), Key(HEADER_TOGGLE_SYSTEM, "Display system information in header", local_only=True), Key(HEADER_TOGGLE_INSTANCE, "Display general instance information in header"), - Key(HEADER_TOGGLE_WORKER, "Display worker information in header"), + Key(HEADER_TOGGLE_WORKERS, "Display worker information in header"), EXIT_KEY, ] diff --git a/pgactivity/types.py b/pgactivity/types.py index 0ceccf42..4589a242 100644 --- a/pgactivity/types.py +++ b/pgactivity/types.py @@ -193,10 +193,65 @@ def color(self, value: Any) -> str: return self.color_key +@attr.s(auto_attribs=True, slots=True) +class UIHeader: + """Configuration for the header of the UI.""" + + show_instance: bool = True + show_system: bool = True + show_workers: bool = True + + def toggle_system(self) -> None: + """Toggle the 'show_system' attribute. + + >>> h = UIHeader() + >>> h.show_system + True + >>> h.toggle_system() + >>> h.show_system + False + >>> h.toggle_system() + >>> h.show_system + True + """ + self.show_system = not self.show_system + + def toggle_instance(self) -> None: + """Toggle the 'show_instance' attribute. + + >>> h = UIHeader() + >>> h.show_instance + True + >>> h.toggle_instance() + >>> h.show_instance + False + >>> h.toggle_instance() + >>> h.show_instance + True + """ + self.show_instance = not self.show_instance + + def toggle_workers(self) -> None: + """Toggle the 'show_workers' attribute. + + >>> h = UIHeader() + >>> h.show_workers + True + >>> h.toggle_workers() + >>> h.show_workers + False + >>> h.toggle_workers() + >>> h.show_workers + True + """ + self.show_workers = not self.show_workers + + @attr.s(auto_attribs=True, slots=True) class UI: """State of the UI.""" + header: UIHeader columns_by_querymode: Mapping[QueryMode, tuple[Column, ...]] min_duration: float = 0.0 duration_mode: DurationMode = attr.ib( @@ -208,13 +263,11 @@ class UI: refresh_time: float | int = 2 in_pause: bool = False interactive_timeout: int | None = None - show_instance_info_in_header: bool = True - show_system_info_in_header: bool = True - show_worker_info_in_header: bool = True @classmethod def make( cls, + header: UIHeader | None = None, config: Configuration | None = None, flag: Flag = Flag.all(), *, @@ -222,6 +275,9 @@ def make( filters: Filters = NO_FILTER, **kwargs: Any, ) -> UI: + if header is None: + header = UIHeader() + possible_columns: dict[str, Column] = {} def add_column(key: str, name: str, **kwargs: Any) -> None: @@ -429,7 +485,7 @@ def make_columns_for(query_mode: QueryMode) -> Iterator[Column]: pass columns_by_querymode = {qm: tuple(make_columns_for(qm)) for qm in QueryMode} - return cls(columns_by_querymode=columns_by_querymode, **kwargs) + return cls(header=header, columns_by_querymode=columns_by_querymode, **kwargs) def interactive(self) -> bool: return self.interactive_timeout is not None @@ -500,51 +556,6 @@ def toggle_pause(self) -> None: """ self.in_pause = not self.in_pause - def toggle_system_info_in_header(self) -> None: - """Toggle the 'show_system_info_in_header' attribute. - - >>> ui = UI.make() - >>> ui.show_system_info_in_header - True - >>> ui.toggle_system_info_in_header() - >>> ui.show_system_info_in_header - False - >>> ui.toggle_system_info_in_header() - >>> ui.show_system_info_in_header - True - """ - self.show_system_info_in_header = not self.show_system_info_in_header - - def toggle_instance_info_in_header(self) -> None: - """Toggle the 'show_instance_info_in_header' attribute. - - >>> ui = UI.make() - >>> ui.show_instance_info_in_header - True - >>> ui.toggle_instance_info_in_header() - >>> ui.show_instance_info_in_header - False - >>> ui.toggle_instance_info_in_header() - >>> ui.show_instance_info_in_header - True - """ - self.show_instance_info_in_header = not self.show_instance_info_in_header - - def toggle_worker_info_in_header(self) -> None: - """Toggle the 'show_worker_info_in_header' attribute. - - >>> ui = UI.make() - >>> ui.show_worker_info_in_header - True - >>> ui.toggle_worker_info_in_header() - >>> ui.show_worker_info_in_header - False - >>> ui.toggle_worker_info_in_header() - >>> ui.show_worker_info_in_header - True - """ - self.show_worker_info_in_header = not self.show_worker_info_in_header - def evolve(self, **changes: Any) -> None: """Return a new UI with 'changes' applied. diff --git a/pgactivity/ui.py b/pgactivity/ui.py index 3befcb95..2dd2dd86 100644 --- a/pgactivity/ui.py +++ b/pgactivity/ui.py @@ -40,6 +40,11 @@ def main( flag = Flag.load(config, is_local=is_local, **vars(options)) ui = types.UI.make( + header=types.UIHeader( + show_instance=options.header_show_instance, + show_system=options.header_show_system, + show_workers=options.header_show_workers, + ), config=config, flag=flag, refresh_time=options.refresh, @@ -48,9 +53,6 @@ def main( wrap_query=options.wrap_query, max_db_length=min(max(server_information.max_dbname_length, 8), 16), filters=data.filters, - show_instance_info_in_header=options.show_instance_info_in_header, - show_worker_info_in_header=options.show_worker_info_in_header, - show_system_info_in_header=options.show_system_info_in_header, ) key, in_help = None, False @@ -97,12 +99,12 @@ def main( elif key.name == keys.CANCEL_SELECTION: pg_procs.reset() ui.end_interactive() - elif keys.is_toggle_header_sys_info(key): - ui.toggle_system_info_in_header() - elif keys.is_toggle_header_inst_info(key): - ui.toggle_instance_info_in_header() - elif keys.is_toggle_header_worker_info(key): - ui.toggle_worker_info_in_header() + elif keys.is_toggle_header_system(key): + ui.header.toggle_system() + elif keys.is_toggle_header_instance(key): + ui.header.toggle_instance() + elif keys.is_toggle_header_workers(key): + ui.header.toggle_workers() elif pg_procs.selected and key in ( keys.PROCESS_CANCEL, keys.PROCESS_KILL, diff --git a/pgactivity/views.py b/pgactivity/views.py index a73f7614..b5375f4c 100644 --- a/pgactivity/views.py +++ b/pgactivity/views.py @@ -232,7 +232,7 @@ def indent(text: str) -> str: size_ev = f"{utils.naturalsize(si.size_evolution)}/s" uptime = utils.naturaltimedelta(si.uptime) - if ui.show_instance_info_in_header: + if ui.header.show_instance: # First rows are always displayed, as the underlying data is always available. columns = [ [f"* Global: {render(uptime)} uptime"], @@ -268,7 +268,7 @@ def indent(text: str) -> str: [f"{render(temp_size)} temp size"], ] yield from render_columns(columns) - if ui.show_worker_info_in_header: + if ui.header.show_workers: columns = [ [ f"* Worker processes: {render(si.worker_processes)}/{render(si.max_worker_processes)} total" @@ -295,7 +295,7 @@ def indent(text: str) -> str: yield from render_columns(columns) # System information, only available in "local" mode. - if system_info is not None and ui.show_system_info_in_header: + if system_info is not None and ui.header.show_system: used, bc, free, total = ( utils.naturalsize(system_info.memory.used), utils.naturalsize(system_info.memory.buff_cached), diff --git a/tests/test_ui.txt b/tests/test_ui.txt index 7a14c917..06f71ea6 100644 --- a/tests/test_ui.txt +++ b/tests/test_ui.txt @@ -46,9 +46,9 @@ Default CLI options, passed to ui.main(): ... "rds": False, ... "username": f"{postgres.info.user}", ... "wrap_query": False, -... "show_instance_info_in_header": True, -... "show_worker_info_in_header": True, -... "show_system_info_in_header": True, +... "header_show_instance": True, +... "header_show_workers": True, +... "header_show_system": True, ... } >>> options = argparse.Namespace(**defaults) @@ -428,9 +428,9 @@ PID DATABASE USER CLIENT state Query ------------------------------------------------------------------------------------------- sending key 'q' -------------------------------------------------------------------------------------------- >>> defaults["nopid"] = True ->>> defaults["show_instance_info_in_header"] = False ->>> defaults["show_worker_info_in_header"] = False ->>> defaults["show_system_info_in_header"] = False +>>> defaults["header_show_instance"] = False +>>> defaults["header_show_workers"] = False +>>> defaults["header_show_system"] = False >>> options = argparse.Namespace(**defaults) One query, idle in transaction: diff --git a/tests/test_views.txt b/tests/test_views.txt index 42b3bb11..46fe2ef8 100644 --- a/tests/test_views.txt +++ b/tests/test_views.txt @@ -158,7 +158,7 @@ PostgreSQL 9.6 - localhost - tester@host:5432/postgres - Ref.: 10s - Duration mo IO: 300/s max iops, 200B/s - 100/s read, 300B/s - 200/s write Load average: 1 5 15 ->>> ui.toggle_system_info_in_header() +>>> ui.header.toggle_system() >>> header(term, ui, host=host, server_information=serverinfo, ... system_info=sysinfo, pg_version="PostgreSQL 9.6", ... width=200) @@ -170,8 +170,8 @@ PostgreSQL 9.6 - localhost - tester@host:5432/postgres - Ref.: 10s - Duration mo Other processes & info: 3/3 autovacuum workers, 1/10 wal senders, 0 wal receivers, 10/10 repl. slots >>> ui = UI.make(refresh_time=10, duration_mode=DurationMode.query) ->>> ui.toggle_system_info_in_header() ->>> ui.toggle_worker_info_in_header() +>>> ui.header.toggle_system() +>>> ui.header.toggle_workers() >>> header(term, ui, host=host, server_information=serverinfo, ... system_info=sysinfo, pg_version="PostgreSQL 9.6", ... width=200) @@ -181,9 +181,9 @@ PostgreSQL 9.6 - localhost - tester@host:5432/postgres - Ref.: 10s - Duration mo Activity: 15 tps, 10 insert/s, 20 update/s, 30 delete/s, 40 tuples returned/s, 5 temp files, 11.50M temp size >>> ui = UI.make(refresh_time=2, min_duration=1.2, duration_mode=DurationMode.transaction) ->>> ui.toggle_system_info_in_header() ->>> ui.toggle_worker_info_in_header() ->>> ui.toggle_instance_info_in_header() +>>> ui.header.toggle_system() +>>> ui.header.toggle_workers() +>>> ui.header.toggle_instance() >>> header(term, ui, host=host, server_information=serverinfo, ... system_info=sysinfo, pg_version="PostgreSQL 9.6", ... width=200) @@ -196,7 +196,7 @@ PostgreSQL 9.6 - localhost - tester@host:5432/postgres - Ref.: 2s - Duration mod ... io_read=IOCounter(0,0), ... io_write=IOCounter(0,0), ... max_iops=0) ->>> ui.toggle_system_info_in_header() +>>> ui.header.toggle_system() >>> header(term, ui, host=host, server_information=serverinfo, ... system_info=sysinfo, pg_version="PostgreSQL 9.6", ... width=200) From e45a2053d2b386afd9bc319920227c2e6cbbabb6 Mon Sep 17 00:00:00 2001 From: Denis Laxalde Date: Tue, 20 Feb 2024 15:48:41 +0100 Subject: [PATCH 7/9] Make headers info display configurable In the command-line parser, options related to header information have their default value set to None in order to distinguish when the option was not specified from when it got, as it would store False then. This makes it possible to have a precedence of command-line options over the configuration file (implemented in types.UIHeader.make()). The validation of configuration options, previously in UISection, is extracted into a BaseSectionMixin and reused in new HeaderSection. --- CHANGELOG.md | 2 ++ README.md | 8 +++++- pgactivity/cli.py | 6 ++-- pgactivity/config.py | 65 ++++++++++++++++++++++++++++++++++++-------- pgactivity/types.py | 21 ++++++++++++-- pgactivity/ui.py | 3 +- tests/test_config.py | 16 +++++++++-- 7 files changed, 101 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb66a7ca..cf13c70f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ ### Added * The *rollback ratio* is now displayed in the "global" header (#385). +* Make header's sections display configurable through the `[header]` section of + the configuration file. ### Changed diff --git a/README.md b/README.md index 4427b433..0f27ab7a 100644 --- a/README.md +++ b/README.md @@ -146,8 +146,14 @@ ex: read from `${XDG_CONFIG_HOME:~/.config}/pg_activity.conf` or `/etc/pg_activity.conf` in that order. Command-line options may override configuration file settings. -This is used to control how columns in the processes table are rendered, e.g.: +This is used to control how columns in the processes table are rendered or which +items of the header should be displayed, e.g.: ```ini +[header] +show_instance = yes +show_system = yes +show_workers = no + [client] hidden = yes diff --git a/pgactivity/cli.py b/pgactivity/cli.py index 45c7abfc..7f743e68 100755 --- a/pgactivity/cli.py +++ b/pgactivity/cli.py @@ -322,7 +322,7 @@ def get_parser() -> ArgumentParser: dest="header_show_instance", action="store_false", help="Hide instance information.", - default=True, + default=None, ) # --no-sys-info group.add_argument( @@ -330,7 +330,7 @@ def get_parser() -> ArgumentParser: dest="header_show_system", action="store_false", help="Hide system information.", - default=True, + default=None, ) # --no-proc-info group.add_argument( @@ -338,7 +338,7 @@ def get_parser() -> ArgumentParser: dest="header_show_workers", action="store_false", help="Hide workers process information.", - default=True, + default=None, ) group = parser.add_argument_group("Other display options") diff --git a/pgactivity/config.py b/pgactivity/config.py index b60fa1a5..23029fc4 100644 --- a/pgactivity/config.py +++ b/pgactivity/config.py @@ -4,7 +4,7 @@ import enum import os from pathlib import Path -from typing import IO, Any, Dict, TypeVar +from typing import IO, Any, Dict, TypeVar, Union import attr from attr import validators @@ -96,6 +96,7 @@ def from_config(cls, config: Configuration) -> Flag: except KeyError: pass else: + assert isinstance(cfg, UISection) if cfg.hidden: continue value |= f @@ -162,8 +163,44 @@ def load( return flag +class BaseSectionMixin: + @classmethod + def check_options( + cls: type[attr.AttrsInstance], section: configparser.SectionProxy + ) -> list[str]: + """Check that items of 'section' conform to known attributes of this class and + return the list of know options. + """ + known_options = {f.name for f in attr.fields(cls)} + unknown_options = set(section) - set(known_options) + if unknown_options: + raise ValueError(f"invalid option(s): {', '.join(sorted(unknown_options))}") + return list(sorted(known_options)) + + +@attr.s(auto_attribs=True, frozen=True, slots=True) +class HeaderSection(BaseSectionMixin): + show_instance: bool = True + show_system: bool = True + show_workers: bool = True + + _T = TypeVar("_T", bound="HeaderSection") + + @classmethod + def from_config_section(cls: type[_T], section: configparser.SectionProxy) -> _T: + values: dict[str, bool] = {} + for optname in cls.check_options(section): + try: + value = section.getboolean(optname) + except configparser.NoOptionError: + continue + if value is not None: + values[optname] = value + return cls(**values) + + @attr.s(auto_attribs=True, frozen=True, slots=True) -class UISection: +class UISection(BaseSectionMixin): hidden: bool = False width: int | None = attr.ib(default=None, validator=validators.optional(gt(0))) @@ -171,11 +208,8 @@ class UISection: @classmethod def from_config_section(cls: type[_T], section: configparser.SectionProxy) -> _T: + cls.check_options(section) values: dict[str, Any] = {} - known_options = {f.name: f for f in attr.fields(cls)} - unknown_options = set(section) - set(known_options) - if unknown_options: - raise ValueError(f"invalid option(s): {', '.join(sorted(unknown_options))}") try: hidden = section.getboolean("hidden") except configparser.NoOptionError: @@ -194,18 +228,24 @@ def from_config_section(cls: type[_T], section: configparser.SectionProxy) -> _T ETC = Path("/etc") -class Configuration(Dict[str, UISection]): +class Configuration(Dict[str, Union[HeaderSection, UISection]]): _T = TypeVar("_T", bound="Configuration") + def header(self) -> HeaderSection | None: + return self.get("header") # type: ignore[return-value] + @classmethod def parse(cls: type[_T], f: IO[str], name: str) -> _T: r"""Parse configuration from 'f'. >>> from io import StringIO + >>> from pprint import pprint - >>> f = StringIO('[client]\nhidden=true\n') - >>> Configuration.parse(f, "f.ini") - {'client': UISection(hidden=True, width=None)} + >>> f = StringIO('[header]\nshow_workers=false\n[client]\nhidden=true\n') + >>> cfg = Configuration.parse(f, "f.ini") + >>> pprint(cfg) + {'client': UISection(hidden=True, width=None), + 'header': HeaderSection(show_instance=True, show_system=True, show_workers=False)} >>> bad = StringIO("[global]\nx=1") >>> Configuration.parse(bad, "bad.ini") @@ -246,12 +286,15 @@ def parse(cls: type[_T], f: IO[str], name: str) -> _T: except configparser.Error as e: raise ConfigurationError(name, f"failed to parse INI: {e}") from None known_sections = set(Flag.names()) - config = {} + config: dict[str, HeaderSection | UISection] = {} for sname, section in p.items(): if sname == p.default_section: if section: raise InvalidSection(p.default_section, name) continue + if sname == "header": + config[sname] = HeaderSection.from_config_section(section) + continue if sname not in known_sections: raise InvalidSection(sname, name) try: diff --git a/pgactivity/types.py b/pgactivity/types.py index 4589a242..2c6b309a 100644 --- a/pgactivity/types.py +++ b/pgactivity/types.py @@ -12,7 +12,7 @@ from . import colors, compat, pg, utils from .compat import Callable, Iterable, Iterator, Mapping, MutableSet, Sequence -from .config import Configuration, Flag +from .config import Configuration, Flag, HeaderSection, UISection class Pct(float): @@ -201,6 +201,22 @@ class UIHeader: show_system: bool = True show_workers: bool = True + @classmethod + def make(cls, config: HeaderSection | None, **options: bool | None) -> UIHeader: + """Return a UIHeader built from configuration and command-line options. + + Command-line options take precedence over configuration. + + >>> config = HeaderSection(show_instance=False, show_system=False) + >>> UIHeader.make(config, show_instance=True, show_workers=False) + UIHeader(show_instance=True, show_system=False, show_workers=False) + """ + values = {} + if config is not None: + values.update(attr.asdict(config)) + values.update({k: v for k, v in options.items() if v is not None}) + return cls(**values) + def toggle_system(self) -> None: """Toggle the 'show_system' attribute. @@ -276,7 +292,7 @@ def make( **kwargs: Any, ) -> UI: if header is None: - header = UIHeader() + header = UIHeader.make(config.header() if config else None) possible_columns: dict[str, Column] = {} @@ -287,6 +303,7 @@ def add_column(key: str, name: str, **kwargs: Any) -> None: except KeyError: pass else: + assert isinstance(cfg, UISection), cfg if cfg.width is not None: kwargs["min_width"] = kwargs["max_width"] = cfg.width assert key not in possible_columns, f"duplicated key {key}" diff --git a/pgactivity/ui.py b/pgactivity/ui.py index 2dd2dd86..9a13bfbf 100644 --- a/pgactivity/ui.py +++ b/pgactivity/ui.py @@ -40,7 +40,8 @@ def main( flag = Flag.load(config, is_local=is_local, **vars(options)) ui = types.UI.make( - header=types.UIHeader( + header=types.UIHeader.make( + config.header() if config else None, show_instance=options.header_show_instance, show_system=options.header_show_system, show_workers=options.header_show_workers, diff --git a/tests/test_config.py b/tests/test_config.py index 9d6b6e21..a09b6c09 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -83,6 +83,18 @@ def asdict(cfg: Configuration) -> dict[str, Any]: cfg = Configuration.lookup(user_config_home=tmp_path) assert cfg is None - (tmp_path / "pg_activity.conf").write_text("\n".join(["[client]", "width=5"])) + (tmp_path / "pg_activity.conf").write_text( + "\n".join( + [ + "[client]", + "width=5", + "[header]", + "show_instance=no", + ] + ) + ) cfg = Configuration.lookup(user_config_home=tmp_path) - assert cfg is not None and asdict(cfg) == {"client": {"hidden": False, "width": 5}} + assert cfg is not None and asdict(cfg) == { + "client": {"hidden": False, "width": 5}, + "header": {"show_instance": False, "show_system": True, "show_workers": True}, + } From 25b0d8a17f3d27072141726dbb7ebb00c28ff2ac Mon Sep 17 00:00:00 2001 From: Denis Laxalde Date: Fri, 23 Feb 2024 11:14:00 +0100 Subject: [PATCH 8/9] Simplify from_config_section() implementation SectionProxy.get*() would never actually raise NoOptionError because the 'fallback' argument defaults to None. --- pgactivity/config.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/pgactivity/config.py b/pgactivity/config.py index 23029fc4..8402f72e 100644 --- a/pgactivity/config.py +++ b/pgactivity/config.py @@ -190,10 +190,7 @@ class HeaderSection(BaseSectionMixin): def from_config_section(cls: type[_T], section: configparser.SectionProxy) -> _T: values: dict[str, bool] = {} for optname in cls.check_options(section): - try: - value = section.getboolean(optname) - except configparser.NoOptionError: - continue + value = section.getboolean(optname) if value is not None: values[optname] = value return cls(**values) @@ -210,17 +207,10 @@ class UISection(BaseSectionMixin): def from_config_section(cls: type[_T], section: configparser.SectionProxy) -> _T: cls.check_options(section) values: dict[str, Any] = {} - try: - hidden = section.getboolean("hidden") - except configparser.NoOptionError: - pass - else: - if hidden is not None: - values["hidden"] = hidden - try: - values["width"] = section.getint("width") - except configparser.NoOptionError: - pass + hidden = section.getboolean("hidden") + if hidden is not None: + values["hidden"] = hidden + values["width"] = section.getint("width") return cls(**values) From c5ed376c4414ec40a5f0e26af15df7d5a66f4521 Mon Sep 17 00:00:00 2001 From: Denis Laxalde Date: Thu, 24 Aug 2023 09:36:44 +0200 Subject: [PATCH 9/9] Add support for configuration profiles --- CHANGELOG.md | 4 ++++ README.md | 14 +++++++++++++- docs/man/pg_activity.1 | 7 +++++++ docs/man/pg_activity.pod | 10 ++++++++++ pgactivity/cli.py | 15 +++++++++++++-- pgactivity/config.py | 21 ++++++++++++++++----- tests/test_config.py | 20 ++++++++++++++++++-- 7 files changed, 81 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf13c70f..68e484f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ * The *rollback ratio* is now displayed in the "global" header (#385). * Make header's sections display configurable through the `[header]` section of the configuration file. +* Configuration profiles can now be defined at + `${XDG_CONFIG_HOME:~/.config}/pg_activity/.conf` or + `/etc/pg_activity/.conf` as selected from the command line through + `--profile `. ### Changed diff --git a/README.md b/README.md index 0f27ab7a..36f16809 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,11 @@ ex: pg_activity [options] [connection string] + Configuration: + --profile PROFILE Configuration profile matching a PROFILE.conf file in + ${XDG_CONFIG_HOME:~/.config}/pg_activity/ or + /etc/pg_activity/. + Options: --blocksize BLOCKSIZE Filesystem blocksize (default: 4096). @@ -142,7 +147,7 @@ ex: ## Configuration -`pg_activity` may be configured through configuration file, in [INI format][], +`pg_activity` may be configured through a configuration file, in [INI format][], read from `${XDG_CONFIG_HOME:~/.config}/pg_activity.conf` or `/etc/pg_activity.conf` in that order. Command-line options may override configuration file settings. @@ -161,6 +166,13 @@ hidden = yes width = 9 ``` +Alternatively, the user might define *configuration profiles* in the form of +files located at `${XDG_CONFIG_HOME:~/.config}/pg_activity/.conf` or +`/etc/pg_activity/.conf`; these can then be used through the +`--profile ` command-line option. The format of these files is the +same as the main configuration file. + + [INI format]: https://docs.python.org/3/library/configparser.html#supported-ini-file-structure ## Notes diff --git a/docs/man/pg_activity.1 b/docs/man/pg_activity.1 index 97d172c0..ced34290 100644 --- a/docs/man/pg_activity.1 +++ b/docs/man/pg_activity.1 @@ -347,6 +347,13 @@ required by another session. It shows following information: .PD .SH "COMMAND-LINE OPTIONS" .IX Header "COMMAND-LINE OPTIONS" +.SS "\s-1CONFIGURATION\s0" +.IX Subsection "CONFIGURATION" +.IP "\fB\-\-profile=PROFILE\fR" 2 +.IX Item "--profile=PROFILE" +.Vb 1 +\& Configuration profile matching a PROFILE.conf file in ${XDG_CONFIG_HOME:~/.config}/pg_activity/ or /etc/pg_activity/. +.Ve .SS "\s-1OPTIONS\s0" .IX Subsection "OPTIONS" .IP "\fB\-\-blocksize=BLOCKSIZE\fR" 2 diff --git a/docs/man/pg_activity.pod b/docs/man/pg_activity.pod index 36b8b06a..28204e84 100644 --- a/docs/man/pg_activity.pod +++ b/docs/man/pg_activity.pod @@ -230,6 +230,16 @@ required by another session. It shows following information: =head1 COMMAND-LINE OPTIONS +=head2 CONFIGURATION + +=over 2 + +=item B<--profile=PROFILE> + + Configuration profile matching a PROFILE.conf file in ${XDG_CONFIG_HOME:~/.config}/pg_activity/ or /etc/pg_activity/. + +=back + =head2 OPTIONS =over 2 diff --git a/pgactivity/cli.py b/pgactivity/cli.py index 7f743e68..ecd6b30b 100755 --- a/pgactivity/cli.py +++ b/pgactivity/cli.py @@ -60,6 +60,17 @@ def get_parser() -> ArgumentParser: add_help=False, ) + group = parser.add_argument_group( + "Configuration", + ) + group.add_argument( + "--profile", + help=( + "Configuration profile matching a PROFILE.conf file in " + "${XDG_CONFIG_HOME:~/.config}/pg_activity/ or /etc/pg_activity/." + ), + ) + group = parser.add_argument_group( "Options", ) @@ -390,8 +401,8 @@ def main() -> None: args.notempfile = True try: - cfg = Configuration.lookup() - except ConfigurationError as e: + cfg = Configuration.lookup(args.profile) + except (ConfigurationError, FileNotFoundError) as e: parser.error(str(e)) try: diff --git a/pgactivity/config.py b/pgactivity/config.py index 8402f72e..612b5e44 100644 --- a/pgactivity/config.py +++ b/pgactivity/config.py @@ -296,14 +296,25 @@ def parse(cls: type[_T], f: IO[str], name: str) -> _T: @classmethod def lookup( cls: type[_T], + profile: str | None, *, user_config_home: Path = USER_CONFIG_HOME, etc: Path = ETC, ) -> _T | None: - for base in (user_config_home, etc): - fpath = base / "pg_activity.conf" + if profile is None: + for base in (user_config_home, etc): + fpath = base / "pg_activity.conf" + if fpath.exists(): + with fpath.open() as f: + return cls.parse(f, str(fpath)) + return None + + assert profile # per argument validation + fname = f"{profile}.conf" + bases = (user_config_home / "pg_activity", etc / "pg_activity") + for base in bases: + fpath = base / fname if fpath.exists(): with fpath.open() as f: - value = cls.parse(f, str(fpath)) - return value - return None + return cls.parse(f, str(fpath)) + raise FileNotFoundError(f"profile {profile!r} not found") diff --git a/tests/test_config.py b/tests/test_config.py index a09b6c09..849e8af1 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -4,6 +4,7 @@ from typing import Any import attr +import pytest from pgactivity.config import Configuration, Flag, UISection @@ -80,7 +81,7 @@ def test_lookup(tmp_path: Path) -> None: def asdict(cfg: Configuration) -> dict[str, Any]: return {k: attr.asdict(v) for k, v in cfg.items()} - cfg = Configuration.lookup(user_config_home=tmp_path) + cfg = Configuration.lookup(None, user_config_home=tmp_path) assert cfg is None (tmp_path / "pg_activity.conf").write_text( @@ -93,8 +94,23 @@ def asdict(cfg: Configuration) -> dict[str, Any]: ] ) ) - cfg = Configuration.lookup(user_config_home=tmp_path) + cfg = Configuration.lookup(None, user_config_home=tmp_path) assert cfg is not None and asdict(cfg) == { "client": {"hidden": False, "width": 5}, "header": {"show_instance": False, "show_system": True, "show_workers": True}, } + + (tmp_path / "pg_activity").mkdir() + (tmp_path / "pg_activity" / "x.conf").write_text( + "\n".join( + ["[database]", "hidden= on", "width = 3 ", "[header]", "show_workers=no"] + ) + ) + cfg = Configuration.lookup("x", user_config_home=tmp_path) + assert cfg is not None and asdict(cfg) == { + "database": {"hidden": True, "width": 3}, + "header": {"show_instance": True, "show_system": True, "show_workers": False}, + } + + with pytest.raises(FileNotFoundError): + Configuration.lookup("y", user_config_home=tmp_path)