Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,11 @@ repos:
language: system
require_serial: true
args: ['--filter-files']
- id: mypy
name: mypy
entry: mypy
types: [python]
language: system
require_serial: true
pass_filenames: false
args: ['financeager']
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,62 @@ Please adhere to test-driven development, if possible: When adding a feature, or

If you added a non-cosmetic change (i.e. a change in functionality, e.g. a bug fix or a new feature), please update `Changelog.md` accordingly as well. Check this README whether the content is still up to date.

### Type Checking

This project uses type annotations throughout the codebase to improve code safety and developer experience. We use [mypy](https://mypy-lang.org/) for static type checking.

#### Running Type Checks

Type checking is integrated into the development workflow:

```bash
# Run mypy manually
mypy financeager

# Type checks are also run automatically via:
# - pre-commit hooks (before each commit)
# - CI pipeline (on pull requests and pushes)
```

#### Type Annotation Guidelines

When contributing to the codebase, please follow these typing practices:

- **All new functions and methods should have type annotations** for parameters and return values
- Use **modern Python 3.10+ type syntax** (e.g., `list[str]` instead of `List[str]`, `str | None` instead of `Optional[str]`)
- Import types from `typing` when needed: `from typing import Any, Dict, List, Optional`
- For complex cases, `# type: ignore` comments are acceptable with specific error codes
- Abstract methods and complex external library integrations may use gradual typing with `# type: ignore`

#### Type Configuration

The mypy configuration in `pyproject.toml` uses a gradual typing approach:
- Basic type safety is enforced
- Some strict checks are disabled during the migration period
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please explain to me in a PR comment how I should proceed with the migration

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comprehensive migration guide to the README explaining how to progressively tighten the typing configuration:

  1. Enable stricter checks by uncommenting options in pyproject.toml
  2. Re-enable unused ignore warnings once annotations are complete
  3. Remove type: ignore comments as underlying issues are fixed
  4. Update to use more specific types instead of Any

The current configuration provides a solid foundation for gradual migration. (3cc87c7)

- External dependencies without type stubs are ignored

#### Migration to Strict Typing

To progressively tighten the typing configuration:

1. **Enable stricter checks gradually** by uncommenting options in `pyproject.toml`:
```toml
disallow_untyped_defs = true # Require all functions to have type annotations
disallow_incomplete_defs = true # Disallow partially typed function definitions
disallow_untyped_decorators = true # Require decorators to be typed
```

2. **Re-enable unused ignore warnings** once type annotations are complete:
```toml
warn_unused_ignores = true
```

3. **Remove type: ignore comments** as you fix the underlying type issues

4. **Update to use more specific types** instead of `Any` where possible

For more information about Python typing, see the [official documentation](https://docs.python.org/3/library/typing.html).

## Releasing

1. Tag the latest commit on master by incrementing the current version accordingly (scheme `vmajor.minor.patch`).
Expand Down
12 changes: 6 additions & 6 deletions financeager/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os.path
from importlib.metadata import version
from logging import DEBUG, WARN, Formatter, StreamHandler, getLogger, handlers
from logging import DEBUG, WARN, Formatter, Logger, StreamHandler, getLogger, handlers

import platformdirs

Expand Down Expand Up @@ -47,14 +47,14 @@
FORMATTER = Formatter(fmt="%(levelname)s %(asctime)s %(name)s:%(lineno)d %(message)s")


def init_logger(name):
def init_logger(name: str) -> Logger:
"""Set up module logger. Library loggers are assigned the package logger as
parent. Any records are propagated to the parent package logger.
"""
logger = getLogger(name)
logger.setLevel(DEBUG)

if logger.parent.name == "root":
if logger.parent is not None and logger.parent.name == "root":
# Library logger; probably has NullHandler
logger.parent = LOGGER

Expand All @@ -64,19 +64,19 @@ def init_logger(name):
return logger


def setup_log_file_handler(log_dir=LOG_DIR):
def setup_log_file_handler(log_dir: str = LOG_DIR) -> None:
"""Create RotatingFileHandler for package logger, storing logs in
'log_dir' (default: LOG_DIR). The directory is created if not existing.
"""
os.makedirs(log_dir, exist_ok=True)
file_handler = handlers.RotatingFileHandler(
os.path.join(log_dir, "log"), maxBytes=5e6, backupCount=5
os.path.join(log_dir, "log"), maxBytes=int(5e6), backupCount=5
)
file_handler.setFormatter(FORMATTER)
LOGGER.addHandler(file_handler)


def make_log_stream_handler_verbose():
def make_log_stream_handler_verbose() -> None:
"""Make handler show debug messages using more informative format."""
_stream_handler.setLevel(DEBUG)
_stream_handler.setFormatter(FORMATTER)
16 changes: 9 additions & 7 deletions financeager/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import time
from datetime import datetime
from importlib.metadata import entry_points
from typing import Any

import argcomplete
from dateutil import parser as du_parser
Expand Down Expand Up @@ -92,7 +93,7 @@ def run(command, configuration, plugins=None, verbose=False, sinks=None, **param
if verbose:
make_log_stream_handler_verbose()

formatting_options = {}
formatting_options: dict[str, Any] = {}

def _info(message):
"""Wrapper to format message and propagate it to stdout. The original
Expand Down Expand Up @@ -296,9 +297,11 @@ def _parse_command(args=None, plugins=None):

add_parser.add_argument("name", help="entry name")
add_parser.add_argument("value", type=float, help="entry value")
add_parser.add_argument(
category_add_arg = add_parser.add_argument(
"-c", "--category", default=None, help="entry category"
).completer = argcomplete.ChoicesCompleter(categories)
)
cat_arg = category_add_arg
cat_arg.completer = argcomplete.ChoicesCompleter(categories) # type: ignore
add_parser.add_argument("-d", "--date", default=None, help="entry date")

add_parser.add_argument(
Expand Down Expand Up @@ -360,9 +363,8 @@ def _parse_command(args=None, plugins=None):
)
update_parser.add_argument("-n", "--name", help="new name")
update_parser.add_argument("-v", "--value", type=float, help="new value")
update_parser.add_argument("-c", "--category", help="new category").completer = (
argcomplete.ChoicesCompleter(categories)
)
category_arg = update_parser.add_argument("-c", "--category", help="new category")
category_arg.completer = argcomplete.ChoicesCompleter(categories) # type: ignore
update_parser.add_argument(
"-d", "--date", help="new date (for standard entries only)"
)
Expand Down Expand Up @@ -507,7 +509,7 @@ def _parse_command(args=None, plugins=None):
]:
subparser.add_argument(
"-p", "--pocket", help="name of pocket to modify or query"
).completer = argcomplete.ChoicesCompleter(
).completer = argcomplete.ChoicesCompleter( # type: ignore[attr-defined]
pocket_names(financeager.DATA_DIR)
)

Expand Down
26 changes: 15 additions & 11 deletions financeager/clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,25 @@
import os.path
import traceback
from collections import namedtuple
from typing import Any

import financeager

from . import exceptions, init_logger, localserver, plugin
from .config import Configuration

logger = init_logger(__name__)


def create(*, configuration, sinks, plugins):
def create(
*, configuration: Configuration, sinks: Any, plugins: list[plugin.PluginBase] | None
) -> "Client":
"""Factory to create the Client subclass suitable to the given
configuration.
Clients of service plugins are taken into account if specified.
The sinks are passed into the Client.
"""
clients = {
clients: dict[str, type[Client]] = {
"local": LocalServerClient,
}

Expand All @@ -40,16 +44,16 @@ class Client:

Sinks = namedtuple("Sinks", ["info", "error"])

def __init__(self, *, configuration, sinks):
def __init__(self, *, configuration: Configuration, sinks: "Client.Sinks") -> None:
"""Store the specified configuration and sinks as attributes.
The subclass implementation must set up the proxy.
"""
self.proxy = None
self.proxy: Any = None
self.configuration = configuration
self.sinks = sinks
self.latest_exception = None
self.latest_exception: Exception | None = None

def safely_run(self, command, **params):
def safely_run(self, command: str, **params: Any) -> bool:
"""Execute self.proxy.run() while handling any errors.
A caught exception is stored in the 'latest_exception' attribute.
Return whether execution was successful.
Expand All @@ -76,20 +80,20 @@ def safely_run(self, command, **params):

return success

def shutdown(self):
def shutdown(self) -> None:
"""Routine to run at the end of the Client lifecycle."""


class LocalServerClient(Client):
"""Client for communicating with the financeager localserver."""

def __init__(self, *, configuration, sinks):
def __init__(self, *, configuration: Configuration, sinks: "Client.Sinks") -> None:
"""Set up proxy."""
super().__init__(configuration=configuration, sinks=sinks)

self.proxy = localserver.Proxy(data_dir=financeager.DATA_DIR)

def safely_run(self, command, **params):
def safely_run(self, command: str, **params: Any) -> bool:
"""Run the parent method, and for certain modifying commands, fetch category
names from the server and store them in the cache.
"""
Expand All @@ -103,7 +107,7 @@ def safely_run(self, command, **params):
logger.debug(str(e))
return success

def _write_categories_for_cli_completion(self):
def _write_categories_for_cli_completion(self) -> None:
# There might be different categories for each pocket. However when reading the
# cache at the time of building the CLI completion, the target pocket cannot be
# determined. It's assumed the default pocket is most relevant, hence its
Expand All @@ -114,6 +118,6 @@ def _write_categories_for_cli_completion(self):
# The category cache is a line-separated list of names
f.write("\n".join(categories))

def shutdown(self):
def shutdown(self) -> None:
"""Instruct stopping of Server."""
self.proxy.run("stop")
23 changes: 14 additions & 9 deletions financeager/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Configuration of the financeager application."""

from configparser import ConfigParser, NoOptionError, NoSectionError
from typing import Any

from financeager import plugin

Expand All @@ -16,7 +17,11 @@ class Configuration:
configuration can be customized by settings specified in a config file.
"""

def __init__(self, filepath=None, plugins=None):
def __init__(
self,
filepath: str | None = None,
plugins: list[plugin.PluginBase] | None = None,
) -> None:
"""Initialize the default configuration, overwrite with custom
configuration from file if available, and eventually validate the loaded
configuration.
Expand All @@ -30,15 +35,15 @@ def __init__(self, filepath=None, plugins=None):

self._plugins = plugins or []
self._parser = ConfigParser()
self._option_types = {}
self._option_types: dict[str, dict[str, str]] = {}

self._init_defaults()
self._init_option_types()

self._load_custom_config()
self._validate()

def _init_defaults(self):
def _init_defaults(self) -> None:
self._parser["SERVICE"] = {
"name": "local",
}
Expand All @@ -49,11 +54,11 @@ def _init_defaults(self):
for p in self._plugins:
p.config.init_defaults(self._parser)

def _init_option_types(self):
def _init_option_types(self) -> None:
for p in self._plugins:
p.config.init_option_types(self._option_types)

def _load_custom_config(self):
def _load_custom_config(self) -> None:
"""Update config values according to customization in config file."""
if self._filepath is None:
return
Expand All @@ -78,13 +83,13 @@ def _load_custom_config(self):
continue
self._parser[section][item] = custom_value

def get_section(self, section):
def get_section(self, section: str) -> dict[str, Any]:
"""Return a dictionary of options of the requested section.
If an option is typed, a converted value is returned.
"""
return {o: self.get_option(section, o) for o in self._parser.options(section)}

def get_option(self, section, option):
def get_option(self, section: str, option: str) -> Any:
"""Return the requested option of the configuration.
If an option is typed, a converted value is returned.
"""
Expand All @@ -94,14 +99,14 @@ def get_option(self, section, option):
# Option type not specified, assuming str
option_type = None

if option_type in ("int", "float", "boolean"):
if option_type is not None and option_type in ("int", "float", "boolean"):
get = getattr(self._parser, f"get{option_type}")
else:
get = self._parser.get

return get(section, option)

def _validate(self):
def _validate(self) -> None:
"""Validate certain options of the configuration. Typed options are
validated for possible conversion.

Expand Down
Loading