diff --git a/Makefile b/Makefile index 579d9a9..902a94b 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,7 @@ format: poetry run isort $(SOURCE_DIRS) .PHONY: test -test: test.format test.integration test.unit +test: test.format test.integration test.types test.unit .PHONY: test.format test.format: @@ -77,7 +77,10 @@ test.integration: clean build docker build --tag $(TEST_DOCKER_IMAGE) --file $(GENERATED_DOCKERFILE) $(INTEGRATION_DIR) docker run --rm $(TEST_DOCKER_IMAGE) $(INTEGRATION_CMD) +.PHONY: test.types +test.types: + poetry run pyright + .PHONY: test.unit test.unit: poetry run pytest --verbose - diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 165a46c..c8ec959 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -49,7 +49,7 @@ To run utt from local source: This section is very important as most code changes need tests. -You can run all tests (including format checks, unit tests, and integration tests) with this command: +You can run all tests with this command: `$ make test` diff --git a/poetry.lock b/poetry.lock index 0a9511f..71578dc 100644 --- a/poetry.lock +++ b/poetry.lock @@ -322,6 +322,18 @@ files = [ {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, ] +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + [[package]] name = "packaging" version = "23.2" @@ -402,6 +414,27 @@ files = [ {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, ] +[[package]] +name = "pyright" +version = "1.1.407" +description = "Command line wrapper for pyright" +optional = false +python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "pyright-1.1.407-py3-none-any.whl", hash = "sha256:6dd419f54fcc13f03b52285796d65e639786373f433e243f8b94cf93a7444d21"}, + {file = "pyright-1.1.407.tar.gz", hash = "sha256:099674dba5c10489832d4a4b2d302636152a9a42d317986c38474c76fe562262"}, +] + +[package.dependencies] +nodeenv = ">=1.6.0" +typing-extensions = ">=4.1" + +[package.extras] +all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] +dev = ["twine (>=3.4.1)"] +nodejs = ["nodejs-wheel-binaries"] + [[package]] name = "pytest" version = "7.4.4" @@ -447,6 +480,27 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "setuptools" +version = "80.9.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, + {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, +] + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] + [[package]] name = "tomli" version = "2.0.1" @@ -467,7 +521,6 @@ description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" groups = ["dev"] -markers = "python_version == \"3.10\"" files = [ {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, @@ -493,4 +546,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "3deafc93e96ef3263326dfdccb7244fdfa089e48667c1a9f60f6780aec551483" +content-hash = "4982f99a2271f4be000bd7bfe753330690c6a760fb443d24fd07693874eaddc5" diff --git a/pyproject.toml b/pyproject.toml index dca37ed..34c9c0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,9 @@ line-length = 120 profile = "black" line_length = 120 +[tool.pyright] +typeCheckingMode = "standard" + [tool.poetry] authors = ["Mathieu Larose "] description = "A simple command-line time tracker" @@ -31,5 +34,7 @@ black = "^23.12.1" ddt = "^1.7.1" flake8 = "^7.0.0" isort = "^5.13.2" +pyright = "^1.1.407" pytest = "^7.4.4" requests = "^2.31.0" +setuptools = "^80.9.0" diff --git a/scripts/update_version_in_pyproject.py b/scripts/update_version_in_pyproject.py index dbd4de7..285df1f 100644 --- a/scripts/update_version_in_pyproject.py +++ b/scripts/update_version_in_pyproject.py @@ -38,14 +38,10 @@ def main(): version = get_version(changelog_filename) print(f"Version: {version}") - if version == UNRELEASED_VERSION_NAME: + if version is None or version == UNRELEASED_VERSION_NAME: version = "0" - print( - subprocess.check_output( - ["poetry", "version", version], stderr=sys.stderr - ).decode() - ) + print(subprocess.check_output(["poetry", "version", version]).decode()) if __name__ == "__main__": diff --git a/test/integration/utt_example_plugin/setup.py b/test/integration/utt_example_plugin/setup.py index bcbac05..492b95a 100644 --- a/test/integration/utt_example_plugin/setup.py +++ b/test/integration/utt_example_plugin/setup.py @@ -1,4 +1,4 @@ -from distutils.core import setup +from setuptools import setup setup( name="utt_foo", diff --git a/test/unit/test_entry.py b/test/unit/test_entry.py index a18c227..6e8e389 100644 --- a/test/unit/test_entry.py +++ b/test/unit/test_entry.py @@ -67,6 +67,9 @@ class ValidEntry(unittest.TestCase): def test(self, name, expected_datetime, expected_name, expected_comment): entry_parser = EntryParser() entry = entry_parser.parse(name) + if entry is None: + self.fail("EntryParser returned None for valid entry") + self.assertEqual(entry.datetime, expected_datetime) self.assertEqual(entry.name, expected_name) self.assertEqual(entry.comment, expected_comment) diff --git a/utt/command.py b/utt/command.py index e5b2b5b..2773d6a 100644 --- a/utt/command.py +++ b/utt/command.py @@ -1,4 +1,5 @@ import argparse +import typing from dataclasses import dataclass from typing import Callable @@ -7,5 +8,5 @@ class Command: name: str description: str - handler_class: Callable[..., Callable[[None], None]] + handler_class: typing.Type add_args: Callable[[argparse.ArgumentParser], None] diff --git a/utt/components/activities.py b/utt/components/activities.py index 70a48ad..1cf0a8c 100644 --- a/utt/components/activities.py +++ b/utt/components/activities.py @@ -43,7 +43,7 @@ def get_current_activity( if not now_is_between_last_activity_and_end_report_range: return - return Activity(current_activity_name, last_activity_end, now, True) + return Activity(current_activity_name, last_activity_end, now, True, comment=None) def remove_hello_activities(activities): diff --git a/utt/components/config_dirname.py b/utt/components/config_dirname.py index fab5b82..60a014a 100644 --- a/utt/components/config_dirname.py +++ b/utt/components/config_dirname.py @@ -1,9 +1,10 @@ import os -import typing from ..constants import DATA_CONFIG_DEFAULT_DIRNAME, DATA_CONFIG_ENV_VAR_NAME, DATA_CONFIG_SUB_DIRNAME -ConfigDirname = typing.NewType("ConfigDirname", str) + +class ConfigDirname(str): + pass def config_dirname() -> ConfigDirname: diff --git a/utt/components/config_filename.py b/utt/components/config_filename.py index a994f4f..dd647eb 100644 --- a/utt/components/config_filename.py +++ b/utt/components/config_filename.py @@ -1,10 +1,11 @@ import os -import typing from ..constants import CONFIG_FILENAME from .config_dirname import ConfigDirname -ConfigFilename = typing.NewType("ConfigFilename", str) + +class ConfigFilename(str): + pass def config_filename(config_dirname: ConfigDirname) -> ConfigFilename: diff --git a/utt/components/data_dirname.py b/utt/components/data_dirname.py index 908dc28..8f8940c 100644 --- a/utt/components/data_dirname.py +++ b/utt/components/data_dirname.py @@ -1,9 +1,10 @@ import os -import typing from ..constants import DATA_HOME_DEFAULT_DIRNAME, DATA_HOME_ENV_VAR_NAME, DATA_HOME_SUB_DIRNAME -DataDirname = typing.NewType("DataDirname", str) + +class DataDirname(str): + pass def data_dirname() -> DataDirname: diff --git a/utt/components/data_filename.py b/utt/components/data_filename.py index 799799d..63aba5d 100644 --- a/utt/components/data_filename.py +++ b/utt/components/data_filename.py @@ -1,11 +1,12 @@ import argparse import os -import typing from ..constants import ENTRY_FILENAME from .data_dirname import DataDirname -DataFilename = typing.NewType("DataFilename", str) + +class DataFilename(str): + pass def data_filename(args: argparse.Namespace, data_dirname: DataDirname) -> DataFilename: diff --git a/utt/components/entries.py b/utt/components/entries.py index c0b041b..2c4e476 100644 --- a/utt/components/entries.py +++ b/utt/components/entries.py @@ -1,4 +1,4 @@ -from typing import Generator, List, Tuple +from typing import Generator, List, Optional, Tuple from ..data_structures.entry import Entry from .entry_lines import EntryLines @@ -21,7 +21,7 @@ def _parse_log(lines: List[Tuple[int, str]], entry_parser: EntryParser) -> Gener yield entry -def _parse_line(previous_entry: Entry, line_number: int, line: str, entry_parser: EntryParser): +def _parse_line(previous_entry: Optional[Entry], line_number: int, line: str, entry_parser: EntryParser): # Ignore empty lines if not line: return None @@ -30,7 +30,8 @@ def _parse_line(previous_entry: Entry, line_number: int, line: str, entry_parser if new_entry is None: raise SyntaxError("Invalid syntax at line %d: %s" % (line_number, line)) - if previous_entry and previous_entry.datetime > new_entry.datetime: + if previous_entry is not None and previous_entry.datetime > new_entry.datetime: raise Exception("Error line %d. Not in chronological order: %s > %s" % (line_number, previous_entry, new_entry)) + previous_entry = new_entry return previous_entry, new_entry diff --git a/utt/components/now.py b/utt/components/now.py index 49847f1..0e91653 100644 --- a/utt/components/now.py +++ b/utt/components/now.py @@ -1,12 +1,14 @@ import argparse import datetime -import typing -Now = typing.NewType("Now", datetime.datetime) + +class Now(datetime.datetime): + pass def now(args: argparse.Namespace) -> Now: if args.now: - return Now(args.now) + return Now.fromtimestamp(args.now.timestamp()) - return Now(datetime.datetime.now()) + dt = datetime.datetime.now() + return Now.fromtimestamp(dt.timestamp()) diff --git a/utt/components/output.py b/utt/components/output.py index f680042..cc2fb59 100644 --- a/utt/components/output.py +++ b/utt/components/output.py @@ -1,4 +1,5 @@ import io -import typing -Output = typing.NewType("Output", io.TextIOWrapper) + +class Output(io.TextIOWrapper): + pass diff --git a/utt/components/report_args.py b/utt/components/report_args.py index 3704a39..ac270ab 100644 --- a/utt/components/report_args.py +++ b/utt/components/report_args.py @@ -46,7 +46,7 @@ def parse_report_range_arguments( if unparsed_report_date is None: report_date = today else: - report_date = parse_date(today, unparsed_report_date) + report_date = parse_date(today, unparsed_report_date, is_past=True) if unparsed_month: report_start_date, report_end_date = parse_month(report_date, unparsed_month) @@ -65,7 +65,7 @@ def parse_report_range_arguments( return DateRange(start=report_start_date, end=report_end_date) -def parse_date(today, datestring, is_past=True): +def parse_date(today: datetime.date, datestring: str, is_past: bool): day = parse_relative_day(today, datestring) if day is not None: return day @@ -217,10 +217,13 @@ def parse_week_number(today, weekstring): return datetime.date.fromisocalendar(year, weeknum, 1) -def parse_week(today, weekstring): +def parse_week(today: datetime.date, weekstring: str): week = parse_relative_week(today, weekstring) if week is None: week = parse_week_number(today, weekstring) + if week is None: + raise ValueError(f"Invalid week string: {weekstring}") + start = week end = week + datetime.timedelta(days=6) diff --git a/utt/data_structures/activity.py b/utt/data_structures/activity.py index cc969b8..98ade5c 100644 --- a/utt/data_structures/activity.py +++ b/utt/data_structures/activity.py @@ -1,5 +1,6 @@ import copy from datetime import datetime +from typing import Optional from .name import Name @@ -10,12 +11,13 @@ class Type: BREAK = 1 IGNORED = 2 + @staticmethod def name(type: int) -> str: return { Activity.Type.WORK: "WORK", Activity.Type.BREAK: "BREAK", Activity.Type.IGNORED: "IGNORED", - }.get(type) + }[type] def __init__( self, @@ -23,7 +25,7 @@ def __init__( start: datetime, end: datetime, is_current_activity: bool, - comment: str = None, + comment: Optional[str], ): self.name = Name(name) self.start = start diff --git a/utt/data_structures/entry.py b/utt/data_structures/entry.py index cfddebe..fd6f1bb 100644 --- a/utt/data_structures/entry.py +++ b/utt/data_structures/entry.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Optional class Entry: @@ -7,7 +8,7 @@ def __init__( entry_datetime: datetime, name: str, is_current_entry: bool, - comment: str = None, + comment: Optional[str], ): self.datetime = entry_datetime self.name = name diff --git a/utt/plugins/0_hello.py b/utt/plugins/0_hello.py index 6f5e374..8497707 100644 --- a/utt/plugins/0_hello.py +++ b/utt/plugins/0_hello.py @@ -15,7 +15,7 @@ def __init__( self._add_entry = add_entry def __call__(self): - self._add_entry(_v1.Entry(self._now, _v1.HELLO_ENTRY_NAME, False)) + self._add_entry(_v1.Entry(self._now, _v1.HELLO_ENTRY_NAME, False, comment=None)) hello_command = _v1.Command( diff --git a/utt/report/activities/model.py b/utt/report/activities/model.py index d2c03e8..c9c49e6 100644 --- a/utt/report/activities/model.py +++ b/utt/report/activities/model.py @@ -19,8 +19,8 @@ def key(act): result = [] sorted_activities = sorted(activities, key=key) - for _, activities in itertools.groupby(sorted_activities, key): - activities = list(activities) + for _, _activities in itertools.groupby(sorted_activities, key): + activities = list(_activities) project = activities[0].name.project result.append( { diff --git a/utt/report/common.py b/utt/report/common.py index b2d3c38..7ce354f 100644 --- a/utt/report/common.py +++ b/utt/report/common.py @@ -16,19 +16,7 @@ def print_dicts(dcts: List[Dict], output: Output) -> None: print(format_string.format(**dict(context, **dct)), file=output) -def filter_activities_by_type(activities: List[Activity], activity_type: str) -> List[Activity]: - """Filter a list of Activity with the given activity type. - - Parameters - ---------- - activities : list of Activity - activity_type : str - An activity type defined in Activity.Type - - Returns - ------- - filtered: list of Activity - """ +def filter_activities_by_type(activities: List[Activity], activity_type: int) -> List[Activity]: return list(filter(lambda act: act.type == activity_type, activities)) diff --git a/utt/report/details/view.py b/utt/report/details/view.py index 9a6c13e..3deabdc 100644 --- a/utt/report/details/view.py +++ b/utt/report/details/view.py @@ -1,5 +1,4 @@ import csv - from datetime import datetime from ...components.output import Output diff --git a/utt/report/per_day/model.py b/utt/report/per_day/model.py index e113e0d..1401f56 100644 --- a/utt/report/per_day/model.py +++ b/utt/report/per_day/model.py @@ -20,8 +20,8 @@ def key(act): result = [] sorted_activities = sorted(activities, key=key) - for date, activities in itertools.groupby(sorted_activities, key): - activities = list(activities) + for date, _activities in itertools.groupby(sorted_activities, key): + activities = list(_activities) duration = sum((act.duration for act in activities), datetime.timedelta()) result.append( { diff --git a/utt/report/projects/model.py b/utt/report/projects/model.py index 19f3bdc..aab3abe 100644 --- a/utt/report/projects/model.py +++ b/utt/report/projects/model.py @@ -19,8 +19,8 @@ def key(act): result = [] sorted_activities = sorted(activities, key=key) - for project, activities in itertools.groupby(sorted_activities, key): - activities = list(activities) + for project, _activities in itertools.groupby(sorted_activities, key): + activities = list(_activities) result.append( { "duration": formatter.format_duration(sum((act.duration for act in activities), datetime.timedelta())),