From 75ca33e0bd253f1f9607afb42d4420fd8a5893de Mon Sep 17 00:00:00 2001 From: loganthomas Date: Fri, 28 Nov 2025 11:52:32 -0600 Subject: [PATCH 1/7] initial commit --- .gitignore | 97 +----- .pre-commit-config.yaml | 32 ++ README.md | 265 +++++++++++++++ pyproject.toml | 71 ++++ src/utt/plugins/project_summary.py | 343 +++++++++++++++++++ src/utt_project_summary/__init__.py | 42 +++ tests/__init__.py | 1 + tests/test_project_summary.py | 495 ++++++++++++++++++++++++++++ 8 files changed, 1266 insertions(+), 80 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/utt/plugins/project_summary.py create mode 100644 src/utt_project_summary/__init__.py create mode 100644 tests/__init__.py create mode 100644 tests/test_project_summary.py diff --git a/.gitignore b/.gitignore index b7faf40..f60e131 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # Byte-compiled / optimized / DLL files __pycache__/ -*.py[codz] +*.py[cod] *$py.class # C extensions @@ -27,8 +27,6 @@ share/python-wheels/ MANIFEST # PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec @@ -46,7 +44,7 @@ htmlcov/ nosetests.xml coverage.xml *.cover -*.py.cover +*.py,cover .hypothesis/ .pytest_cache/ cover/ @@ -83,48 +81,12 @@ profile_default/ ipython_config.py # pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version +.python-version # pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +Pipfile.lock + +# PEP 582 __pypackages__/ # Celery stuff @@ -136,7 +98,6 @@ celerybeat.pid # Environments .env -.envrc .venv env/ venv/ @@ -168,40 +129,16 @@ dmypy.json # Cython debug symbols cython_debug/ -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc +# IDEs +.idea/ +.vscode/ +*.swp +*.swo +*~ -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore +# OS +.DS_Store +Thumbs.db -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ +# Project specific +.ruff_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..569d259 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + exclude: ^\.bumpversion\.cfg$ + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.0 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + + - repo: local + hooks: + - id: ty + name: ty type check + entry: ty check src/ + language: system + types: [python] + pass_filenames: false + + - id: pytest + name: pytest + entry: pytest + language: system + types: [python] + pass_filenames: false diff --git a/README.md b/README.md new file mode 100644 index 0000000..77b999f --- /dev/null +++ b/README.md @@ -0,0 +1,265 @@ +# utt-project-summary + +A [`utt`](https://github.com/larose/utt) plugin that shows projects sorted by time spent. + +## Why utt-project-summary? + +This plugin provides a quick overview of how your time is distributed across different projects. It groups all activities by project and displays them sorted by total duration, giving you instant visibility into where your time is going. + +**Key features:** + +- 📊 **Project Breakdown** — See all projects sorted by time spent (highest to lowest) +- 📈 **Percentage View** — Optionally show percentage of total time for each project +- ⏱️ **Current Activity** — Shows your current activity and includes it in totals +- 📅 **Flexible Date Ranges** — Report by day, week, month, or custom date ranges + +## Features + +- 📊 **Project-Based View** - Activities grouped by project, sorted by duration +- 🔢 **Optional Percentages** - Add `--show-perc` to see time distribution percentages +- 📅 **Date Range Support** - Use `--from`, `--to`, `--week`, or `--month` flags +- 🔌 **Native `utt` Integration** - Uses `utt`'s plugin API for seamless integration + +## Installation + +### Step 1: Install `utt` + +First, install [`utt` (Ultimate Time Tracker)](https://github.com/larose/utt): + +```bash +pip install utt +``` + +Verify the installation: + +```bash +utt --version +``` + +### Step 2: Install utt-project-summary + +Install the plugin: + +```bash +pip install utt-project-summary +``` + +That's it! The plugin is automatically discovered by `utt`. No additional configuration needed. + +### Verify Installation + +Confirm the `project-summary` command is available: + +```bash +utt project-summary --help +``` + +**Requirements:** +- Python 3.10+ +- `utt` >= 1.0 + +## Usage + +After installation, a new `project-summary` command is available in `utt`: + +```bash +utt project-summary +``` + +### Example Output + +``` +Project Summary +--------------- + +backend : 4h30 +frontend: 2h15 +meetings: 1h45 +docs : 0h30 + +Total : 9h00 +``` + +### With Percentages + +```bash +utt project-summary --show-perc +``` + +``` +Project Summary +--------------- + +backend : 4h30 ( 50.0%) +frontend: 2h15 ( 25.0%) +meetings: 1h45 ( 19.4%) +docs : 0h30 ( 5.6%) + +Total : 9h00 (100.0%) +``` + +### Options + +| Option | Default | Description | +|---------------------|---------|------------------------------------------------| +| `--show-perc` | false | Show percentage of total time for each project | +| `--from` | none | Inclusive start date for the report | +| `--to` | none | Inclusive end date for the report | +| `--week` | none | Report for a specific week (`this`, `prev`, or week number) | +| `--month` | none | Report for a specific month (`this`, `prev`, `2024-10`, `Oct`) | +| `--project` | none | Filter to show only a specific project | +| `--current-activity`| `-- Current Activity --` | Set the current activity name | +| `--no-current-activity` | false | Do not display the current activity | + +### Examples + +**Default usage** (today's activities): +```bash +utt project-summary +``` + +**Show with percentages**: +```bash +utt project-summary --show-perc +``` + +**This week's summary**: +```bash +utt project-summary --week this +``` + +**Last month's summary**: +```bash +utt project-summary --month prev +``` + +**Custom date range**: +```bash +utt project-summary --from 2024-01-01 --to 2024-01-31 +``` + +## How It Works + +This plugin uses `utt`'s native plugin API to: +1. Access your time entries directly (no subprocess calls) +2. Filter activities based on date range arguments +3. Group activities by project name +4. Sort projects by total duration (descending) +5. Optionally calculate percentages of total time + +## License + +This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) file for details. + +## Development + +### Running Tests + +To run the test suite, first install the development dependencies: + +```bash +pip install -e ".[dev]" +``` + +Then run the tests with pytest: + +```bash +pytest +``` + +For coverage reporting: + +```bash +pytest --cov=utt_project_summary --cov-report=term-missing +``` + +### Linting & Formatting + +**Run ruff** (linter, formatter, and import sorting): +```bash +# Check for linting errors +ruff check . + +# Auto-fix linting errors (including import sorting) +ruff check --fix . + +# Format code +ruff format . +``` + +### Type Checking + +**Run ty** (type checker): +```bash +ty check src/ +``` + +### Run All Checks + +```bash +ruff check --fix . && ruff format . && ty check src/ && pytest +``` + +### Pre-commit Hooks + +Install pre-commit hooks to automatically run checks before each commit: + +```bash +pre-commit install +``` + +Run hooks manually on all files: + +```bash +pre-commit run --all-files +``` + +## Contributing + +Contributions are welcome! Here's how to get started: + +### Setting Up for Development + +1. **Clone the repository:** + ```bash + git clone https://github.com/loganthomas/utt-project-summary.git + cd utt-project-summary + ``` + +2. **Create a virtual environment:** + ```bash + python -m venv .venv + source .venv/bin/activate # On Windows: .venv\Scripts\activate + ``` + +3. **Install in editable mode with dev dependencies:** + ```bash + pip install -e ".[dev]" + ``` + +4. **Install pre-commit hooks:** + ```bash + pre-commit install + ``` + +### Submitting Changes + +1. Create a new branch for your feature or fix +2. Make your changes following the code style guidelines +3. Ensure all tests pass: `pytest` +4. Ensure code passes linting: `ruff check . && ruff format --check .` +5. Submit a pull request with a clear description of your changes + +### Code Style Guidelines + +- Follow [PEP 8](https://peps.python.org/pep-0008/) conventions +- Use type hints for all function signatures +- Write docstrings in [NumPy style](https://numpydoc.readthedocs.io/en/latest/format.html) +- Keep functions focused and single-purpose +- Prefer explicit over implicit + +## Related + +- [`utt` (Ultimate Time Tracker)](https://github.com/larose/utt) - The time tracking tool this plugin extends +- [`utt` Plugin Documentation](https://github.com/larose/utt/blob/master/docs/PLUGINS.md) - How to create `utt` plugins +- [`utt-balance`](https://github.com/loganthomas/utt-balance) - Another `utt` plugin for checking work-life balance diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e731bd1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,71 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "utt-project-summary" +version = "0.1.0-rc.1" +description = "A utt plugin to show projects sorted by time spent" +readme = "README.md" +license = "GPL-3.0-only" +requires-python = ">=3.10" +authors = [ + {name = "Logan Thomas", email = "logan@datacentriq.net"}, +] +keywords = ["utt", "time-tracking", "productivity", "plugin", "project-summary"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: Console", + "Intended Audience :: End Users/Desktop", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Office/Business :: Scheduling", + "Topic :: Utilities", +] +dependencies = [ + "utt>=1.0", +] + +[project.urls] +Homepage = "https://github.com/loganthomas/utt-project-summary" +Repository = "https://github.com/loganthomas/utt-project-summary" +Issues = "https://github.com/loganthomas/utt-project-summary/issues" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", + "pytest-cov>=4.0", + "ruff>=0.4", + "ty>=0.0.1a1", + "pre-commit>=3.0", + "pytz>=2024.1", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/utt_project_summary"] + +[tool.hatch.build.targets.wheel.force-include] +"src/utt/plugins/project_summary.py" = "utt/plugins/project_summary.py" + +[tool.hatch.build.targets.editable] +dev-mode-dirs = ["src"] + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "C4", "SIM"] +ignore = [] + +[tool.ruff.lint.isort] +known-first-party = ["utt_project_summary"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_functions = ["test_*"] diff --git a/src/utt/plugins/project_summary.py b/src/utt/plugins/project_summary.py new file mode 100644 index 0000000..7b72cdd --- /dev/null +++ b/src/utt/plugins/project_summary.py @@ -0,0 +1,343 @@ +""" +utt Project Summary Plugin - Show projects sorted by time spent. + +This plugin adds a 'project-summary' command to utt that displays all projects +grouped and sorted by total duration, with optional percentage breakdown. + +Example +------- +>>> utt project-summary +>>> utt project-summary --show-perc +>>> utt project-summary --week this --show-perc +""" + +from __future__ import annotations + +import argparse +import itertools +from datetime import timedelta +from typing import TYPE_CHECKING + +from utt.api import _v1 + +if TYPE_CHECKING: + from collections.abc import Sequence + + +def format_duration(duration: timedelta) -> str: + """ + Format a timedelta as 'XhYY' (e.g., '6h30' or '25h00'). + + Parameters + ---------- + duration : timedelta + The time duration to format. + + Returns + ------- + str + Formatted string in hours and zero-padded minutes. + """ + total_seconds = int(duration.total_seconds()) + hours, remainder = divmod(total_seconds, 3600) + minutes = remainder // 60 + return f"{hours}h{minutes:02d}" + + +def format_title(title: str) -> str: + """ + Format a title with an underline. + + Parameters + ---------- + title : str + The title to format. + + Returns + ------- + str + Title with dashed underline. + """ + return f"{title}\n{'-' * len(title)}" + + +class ProjectSummaryModel: + """ + Model containing project summary data. + + Groups activities by project and calculates total durations. + + Parameters + ---------- + activities : Sequence[_v1.Activity] + List of activities to summarize. + + Attributes + ---------- + projects : list[dict] + List of project dictionaries with 'project', 'duration', and 'duration_obj' keys, + sorted by duration descending. + current_activity : dict | None + Current activity info if present, with 'name', 'duration', and 'duration_obj' keys. + total_duration : str + Formatted total duration string. + """ + + def __init__(self, activities: Sequence[_v1.Activity]) -> None: + work_activities = self._filter_work_activities(activities) + self.projects = self._groupby_project_sorted_by_duration(work_activities) + self.current_activity = self._get_current_activity_info(activities) + self.total_duration = self._calculate_total_duration() + + def _filter_work_activities(self, activities: Sequence[_v1.Activity]) -> list[_v1.Activity]: + """Filter to only WORK type activities.""" + return [act for act in activities if act.type == _v1.Activity.Type.WORK] + + def _calculate_total_duration(self) -> str: + """Calculate and format total duration including current activity.""" + total = sum((project["duration_obj"] for project in self.projects), timedelta()) + if self.current_activity: + total += self.current_activity["duration_obj"] + return format_duration(total) + + def _get_current_activity_info(self, activities: Sequence[_v1.Activity]) -> dict | None: + """Extract current activity information if present.""" + for activity in activities: + if activity.is_current_activity: + return { + "name": activity.name.name, + "duration": format_duration(activity.duration), + "duration_obj": activity.duration, + } + return None + + def _groupby_project_sorted_by_duration(self, activities: Sequence[_v1.Activity]) -> list[dict]: + """Group activities by project and sort by total duration descending.""" + + def key(act: _v1.Activity) -> str: + return act.name.project + + non_current_activities = [act for act in activities if not act.is_current_activity] + result = [] + sorted_activities = sorted(non_current_activities, key=key) + + for project, project_activities in itertools.groupby(sorted_activities, key): + activities_list = list(project_activities) + total_duration = sum((act.duration for act in activities_list), timedelta()) + result.append( + { + "duration": format_duration(total_duration), + "project": project, + "duration_obj": total_duration, + } + ) + + return sorted(result, key=lambda r: r["duration_obj"], reverse=True) + + +class ProjectSummaryView: + """ + View for rendering project summary output. + + Parameters + ---------- + model : ProjectSummaryModel + The model containing project summary data. + show_perc : bool, optional + Whether to show percentages, by default False. + """ + + def __init__(self, model: ProjectSummaryModel, show_perc: bool = False) -> None: + self._model = model + self._show_perc = show_perc + + def render(self, output: _v1.Output) -> None: + """ + Render the project summary to the output stream. + + Parameters + ---------- + output : _v1.Output + Output stream to write to. + """ + print(file=output) + print(format_title("Project Summary"), file=output) + print(file=output) + + max_project_length = max((len(p["project"]) for p in self._model.projects), default=0) + + total_seconds = sum( + (p["duration_obj"] for p in self._model.projects), timedelta() + ).total_seconds() + if self._model.current_activity: + total_seconds += self._model.current_activity["duration_obj"].total_seconds() + + max_duration_length = 0 + if self._show_perc: + durations = [len(p["duration"]) for p in self._model.projects] + durations.append(len(self._model.total_duration)) + max_duration_length = max(durations, default=0) + + for project in self._model.projects: + duration_str = project["duration"] + if self._show_perc and total_seconds > 0: + perc = (project["duration_obj"].total_seconds() / total_seconds) * 100 + duration_str = f"{duration_str:<{max_duration_length}} ({perc:5.1f}%)" + print(f"{project['project']:<{max_project_length}}: {duration_str}", file=output) + + if self._model.current_activity: + name = self._model.current_activity["name"] + duration_str = self._model.current_activity["duration"] + if self._show_perc and total_seconds > 0: + perc = ( + self._model.current_activity["duration_obj"].total_seconds() / total_seconds + ) * 100 + duration_str = f"{duration_str} ({perc:5.1f}%)" + print(f"{name:<{max_project_length}}: {duration_str}", file=output) + + print(file=output) + total_str = self._model.total_duration + if self._show_perc: + total_str = f"{total_str:<{max_duration_length}} (100.0%)" + print(f"{'Total':<{max_project_length}}: {total_str}", file=output) + + print(file=output) + + +class ProjectSummaryHandler: + """ + Handler for the project-summary command. + + Parameters + ---------- + args : argparse.Namespace + Parsed command-line arguments. + filtered_activities : _v1.Activities + Activities filtered by the report date range. + output : _v1.Output + Output stream for rendering results. + """ + + def __init__( + self, + args: argparse.Namespace, + filtered_activities: _v1.Activities, + output: _v1.Output, + ) -> None: + self._args = args + self._activities = filtered_activities + self._output = output + + def __call__(self) -> None: + """Execute the project-summary command and display results.""" + model = ProjectSummaryModel(self._activities) + view = ProjectSummaryView(model, show_perc=self._args.show_perc) + view.render(self._output) + + +def add_args(parser: argparse.ArgumentParser) -> None: + """ + Add command-line arguments for the project-summary command. + + Parameters + ---------- + parser : argparse.ArgumentParser + The argument parser to add arguments to. + """ + parser.add_argument("report_date", metavar="date", type=str, nargs="?") + + # Set defaults for report_args attributes that project-summary doesn't use + # but are required by the ReportArgs component + parser.set_defaults(csv_section=None, comments=False, details=False, per_day=False) + + parser.add_argument( + "--show-perc", + action="store_true", + default=False, + help="Show percentage of total time for each project", + ) + + parser.add_argument( + "--current-activity", + default="-- Current Activity --", + type=str, + help="Set the current activity", + ) + + parser.add_argument( + "--no-current-activity", + action="store_true", + default=False, + help="Do not display the current activity", + ) + + parser.add_argument( + "--from", + default=None, + dest="from_date", + type=str, + help="Specify an inclusive start date to report.", + ) + + parser.add_argument( + "--to", + default=None, + dest="to_date", + type=str, + help=( + "Specify an inclusive end date to report. " + "If this is a day of the week, then it is the next occurrence " + "from the start date of the report, including the start date " + "itself." + ), + ) + + parser.add_argument( + "--project", + default=None, + type=str, + help="Show activities only for the specified project.", + ) + + parser.add_argument( + "--month", + default=None, + nargs="?", + const="this", + type=str, + help=( + "Specify a month. " + "Allowed formats include, '2019-10', 'Oct', 'this' 'prev'. " + "The report will start on the first day of the month and end " + "on the last. '--from' or '--to' if present will override " + "start and end, respectively. If the month is the current " + "month, 'today' will be the last day of the report." + ), + ) + + parser.add_argument( + "--week", + default=None, + nargs="?", + const="this", + type=str, + help=( + "Specify a week. " + "Allowed formats include, 'this' 'prev', or week number. " + "The report will start on the first day of the week (Monday) " + "and end on the last (Sunday). '--from' or '--to' if present " + "will override start and end, respectively. If the week is " + "the current week, 'today' will be the last day of the report." + ), + ) + + +# Register the project-summary command with utt +project_summary_command = _v1.Command( + name="project-summary", + description="Show projects sorted by time spent", + handler_class=ProjectSummaryHandler, # type: ignore[arg-type] + add_args=add_args, +) + +_v1.register_command(project_summary_command) diff --git a/src/utt_project_summary/__init__.py b/src/utt_project_summary/__init__.py new file mode 100644 index 0000000..b35772e --- /dev/null +++ b/src/utt_project_summary/__init__.py @@ -0,0 +1,42 @@ +""" +utt-project-summary: A utt plugin to show projects sorted by time spent. + +This plugin adds a 'project-summary' command to utt that shows: + +- All projects sorted by time spent (highest to lowest) +- Optional percentage breakdown of time per project +- Current activity included in totals +- Support for various date range filters + +Installation +------------ +Install via pip:: + + pip install utt-project-summary + +Usage +----- +After installation, the project-summary command is available via utt:: + + utt project-summary [--show-perc] [--from DATE] [--to DATE] [--week WEEK] [--month MONTH] + +Examples +-------- +Show today's project summary:: + + utt project-summary + +Show with percentages:: + + utt project-summary --show-perc + +Show this week's summary:: + + utt project-summary --week this + +For more information, see: https://github.com/loganthomas/utt-project-summary +""" + +__version__ = "0.1.0-rc.1" + +__all__ = ["__version__"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..46c5414 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for utt-project-summary plugin.""" diff --git a/tests/test_project_summary.py b/tests/test_project_summary.py new file mode 100644 index 0000000..ed3cc8a --- /dev/null +++ b/tests/test_project_summary.py @@ -0,0 +1,495 @@ +"""Unit tests for the project-summary plugin.""" + +import argparse +import io +from datetime import datetime, timedelta + +import pytz + +from utt.api import _v1 +from utt.plugins.project_summary import ( + ProjectSummaryHandler, + ProjectSummaryModel, + ProjectSummaryView, + add_args, + format_duration, + format_title, + project_summary_command, +) + + +def create_activity( + name: str, + start_time: datetime, + duration_minutes: int, + is_current: bool = False, +) -> _v1.Activity: + """Helper to create test activities.""" + start = pytz.UTC.localize(start_time) + end = start + timedelta(minutes=duration_minutes) + return _v1.Activity(name, start, end, is_current) + + +class TestFormatDuration: + """Tests for the format_duration function.""" + + def test_zero_time(self): + td = timedelta(hours=0) + assert format_duration(td) == "0h00" + + def test_whole_hours(self): + td = timedelta(hours=8) + assert format_duration(td) == "8h00" + + def test_hours_and_minutes(self): + td = timedelta(hours=6, minutes=30) + assert format_duration(td) == "6h30" + + def test_minutes_only(self): + td = timedelta(minutes=45) + assert format_duration(td) == "0h45" + + def test_large_hours(self): + td = timedelta(hours=40) + assert format_duration(td) == "40h00" + + def test_single_digit_minutes_padded(self): + td = timedelta(hours=1, minutes=5) + assert format_duration(td) == "1h05" + + +class TestFormatTitle: + """Tests for the format_title function.""" + + def test_simple_title(self): + result = format_title("Project Summary") + assert result == "Project Summary\n---------------" + + def test_short_title(self): + result = format_title("Test") + assert result == "Test\n----" + + +class TestProjectSummaryModel: + """Tests for the ProjectSummaryModel class.""" + + def test_empty_activities(self): + model = ProjectSummaryModel([]) + assert model.projects == [] + assert model.current_activity is None + assert model.total_duration == "0h00" + + def test_single_project(self): + activities = [ + create_activity("backend: api work", datetime(2024, 1, 1, 9, 0), 180), + ] + model = ProjectSummaryModel(activities) + assert len(model.projects) == 1 + assert model.projects[0]["project"] == "backend" + assert model.projects[0]["duration"] == "3h00" + + def test_multiple_projects_sorted_by_duration(self): + activities = [ + create_activity("alpha: task1", datetime(2024, 1, 1, 9, 0), 30), + create_activity("beta: task1", datetime(2024, 1, 1, 10, 0), 90), + create_activity("gamma: task1", datetime(2024, 1, 1, 12, 0), 60), + ] + model = ProjectSummaryModel(activities) + + assert len(model.projects) == 3 + assert model.projects[0]["project"] == "beta" + assert model.projects[1]["project"] == "gamma" + assert model.projects[2]["project"] == "alpha" + + def test_activities_grouped_by_project(self): + activities = [ + create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 60), + create_activity("project1: task2", datetime(2024, 1, 1, 10, 0), 60), + create_activity("project2: task1", datetime(2024, 1, 1, 11, 0), 30), + ] + model = ProjectSummaryModel(activities) + + assert len(model.projects) == 2 + project1 = next(p for p in model.projects if p["project"] == "project1") + assert project1["duration"] == "2h00" + + def test_current_activity_extracted(self): + activities = [ + create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 60), + create_activity( + "-- Current Activity --", datetime(2024, 1, 1, 10, 0), 30, is_current=True + ), + ] + model = ProjectSummaryModel(activities) + + assert model.current_activity is not None + assert model.current_activity["name"] == "-- Current Activity --" + assert model.current_activity["duration"] == "0h30" + + def test_current_activity_not_in_projects(self): + activities = [ + create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 60), + create_activity( + "-- Current Activity --", datetime(2024, 1, 1, 10, 0), 30, is_current=True + ), + ] + model = ProjectSummaryModel(activities) + + assert len(model.projects) == 1 + assert all(p["project"] != "-- Current Activity --" for p in model.projects) + + def test_total_duration_includes_current_activity(self): + activities = [ + create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 60), + create_activity( + "-- Current Activity --", datetime(2024, 1, 1, 10, 0), 30, is_current=True + ), + ] + model = ProjectSummaryModel(activities) + + assert model.total_duration == "1h30" + + +class TestProjectSummaryView: + """Tests for the ProjectSummaryView class.""" + + def test_view_output_with_aligned_colons(self): + activities = [ + create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 240), + create_activity("project2: task1", datetime(2024, 1, 1, 13, 0), 165), + create_activity("project3: task1", datetime(2024, 1, 1, 16, 0), 30), + create_activity("project4: task1", datetime(2024, 1, 1, 17, 0), 30), + create_activity("project5: task1", datetime(2024, 1, 1, 18, 0), 15), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model) + output = io.StringIO() + + view.render(output) + result = output.getvalue() + + lines = result.split("\n") + assert "Project Summary" in lines[1] + assert "project1: 4h00" in lines[4] + assert "project2: 2h45" in lines[5] + assert "project3: 0h30" in lines[6] + assert "project4: 0h30" in lines[7] + assert "project5: 0h15" in lines[8] + assert "Total : 8h00" in lines[10] + + def test_view_output_with_current_activity(self): + activities = [ + create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 240), + create_activity("project2: task1", datetime(2024, 1, 1, 13, 0), 165), + create_activity("project3: task1", datetime(2024, 1, 1, 16, 0), 30), + create_activity("project4: task1", datetime(2024, 1, 1, 17, 0), 30), + create_activity("project5: task1", datetime(2024, 1, 1, 18, 0), 15), + create_activity( + "-- Current Activity --", datetime(2024, 1, 1, 19, 0), 220, is_current=True + ), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model) + output = io.StringIO() + + view.render(output) + result = output.getvalue() + + lines = result.split("\n") + assert "project1: 4h00" in lines[4] + assert "project2: 2h45" in lines[5] + assert "project3: 0h30" in lines[6] + assert "project4: 0h30" in lines[7] + assert "project5: 0h15" in lines[8] + assert "-- Current Activity --: 3h40" in lines[9] + assert "Total : 11h40" in lines[11] + + def test_view_colons_aligned_with_varying_project_lengths(self): + activities = [ + create_activity("a: task1", datetime(2024, 1, 1, 9, 0), 60), + create_activity("medium-name: task1", datetime(2024, 1, 1, 10, 0), 120), + create_activity("very-long-project-name: task1", datetime(2024, 1, 1, 12, 0), 30), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model) + output = io.StringIO() + + view.render(output) + result = output.getvalue() + + lines = result.split("\n") + colon_positions = [] + for line in lines[4:7]: + if ":" in line and "---" not in line: + colon_positions.append(line.index(":")) + + assert len(set(colon_positions)) == 1, "All colons should be at the same position" + + def test_view_empty_activities(self): + model = ProjectSummaryModel([]) + view = ProjectSummaryView(model) + output = io.StringIO() + + view.render(output) + result = output.getvalue() + + assert "Project Summary" in result + assert "Total: 0h00" in result + + def test_view_single_project(self): + activities = [ + create_activity("backend: api work", datetime(2024, 1, 1, 9, 0), 180), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model) + output = io.StringIO() + + view.render(output) + result = output.getvalue() + + assert "backend: 3h00" in result + assert "Total : 3h00" in result + + def test_view_projects_without_names(self): + activities = [ + create_activity("standalone task", datetime(2024, 1, 1, 9, 0), 60), + create_activity("another task", datetime(2024, 1, 1, 10, 0), 30), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model) + output = io.StringIO() + + view.render(output) + result = output.getvalue() + + lines = result.split("\n") + assert ": 1h30" in lines[4] + assert "Total: 1h30" in lines[6] + + def test_view_sorting_by_duration(self): + activities = [ + create_activity("alpha: task1", datetime(2024, 1, 1, 9, 0), 30), + create_activity("beta: task1", datetime(2024, 1, 1, 10, 0), 90), + create_activity("gamma: task1", datetime(2024, 1, 1, 12, 0), 60), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model) + output = io.StringIO() + + view.render(output) + result = output.getvalue() + + lines = [ + line + for line in result.split("\n") + if ":" in line and "Project Summary" not in line and "---" not in line + ] + project_lines = [line for line in lines if "Total" not in line] + + assert "beta" in project_lines[0] + assert "gamma" in project_lines[1] + assert "alpha" in project_lines[2] + + def test_view_large_durations(self): + activities = [ + create_activity("marathon: task1", datetime(2024, 1, 1, 9, 0), 1500), + create_activity("sprint: task1", datetime(2024, 1, 2, 10, 0), 600), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model) + output = io.StringIO() + + view.render(output) + result = output.getvalue() + + assert "marathon: 25h00" in result + assert "sprint : 10h00" in result + assert "Total : 35h00" in result + + def test_view_mixed_named_and_unnamed_projects(self): + activities = [ + create_activity("asd: A-526", datetime(2024, 1, 1, 9, 0), 195), + create_activity("qwer: b-73", datetime(2024, 1, 1, 12, 15), 45), + create_activity("hard work", datetime(2024, 1, 1, 13, 0), 60), + create_activity("A: z-8", datetime(2024, 1, 1, 14, 0), 30), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model) + output = io.StringIO() + + view.render(output) + result = output.getvalue() + + lines = result.split("\n") + assert "asd : 3h15" in lines[4] + assert " : 1h00" in lines[5] + assert "qwer: 0h45" in lines[6] + assert "A : 0h30" in lines[7] + assert "Total: 5h30" in lines[9] + + def test_view_with_percentages(self): + activities = [ + create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 240), + create_activity("project2: task1", datetime(2024, 1, 1, 13, 0), 120), + create_activity("project3: task1", datetime(2024, 1, 1, 15, 0), 60), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model, show_perc=True) + output = io.StringIO() + + view.render(output) + result = output.getvalue() + + lines = result.split("\n") + assert "project1: 4h00 ( 57.1%)" in lines[4] + assert "project2: 2h00 ( 28.6%)" in lines[5] + assert "project3: 1h00 ( 14.3%)" in lines[6] + assert "Total : 7h00 (100.0%)" in lines[8] + + def test_view_with_percentages_and_current_activity(self): + activities = [ + create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 240), + create_activity("project2: task1", datetime(2024, 1, 1, 13, 0), 120), + create_activity( + "-- Current Activity --", datetime(2024, 1, 1, 15, 0), 60, is_current=True + ), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model, show_perc=True) + output = io.StringIO() + + view.render(output) + result = output.getvalue() + + lines = result.split("\n") + assert "project1: 4h00 ( 57.1%)" in lines[4] + assert "project2: 2h00 ( 28.6%)" in lines[5] + assert "-- Current Activity --: 1h00 ( 14.3%)" in lines[6] + assert "Total : 7h00 (100.0%)" in lines[8] + + def test_view_percentages_without_flag(self): + activities = [ + create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 240), + create_activity("project2: task1", datetime(2024, 1, 1, 13, 0), 120), + ] + model = ProjectSummaryModel(activities) + view = ProjectSummaryView(model, show_perc=False) + output = io.StringIO() + + view.render(output) + result = output.getvalue() + + assert "%" not in result + assert "project1: 4h00" in result + assert "project2: 2h00" in result + + +class TestAddArgs: + """Tests for the add_args function.""" + + def test_adds_show_perc_argument(self): + parser = argparse.ArgumentParser() + add_args(parser) + + args = parser.parse_args([]) + assert args.show_perc is False + + def test_show_perc_enabled(self): + parser = argparse.ArgumentParser() + add_args(parser) + + args = parser.parse_args(["--show-perc"]) + assert args.show_perc is True + + def test_adds_current_activity_argument(self): + parser = argparse.ArgumentParser() + add_args(parser) + + args = parser.parse_args([]) + assert args.current_activity == "-- Current Activity --" + + def test_custom_current_activity(self): + parser = argparse.ArgumentParser() + add_args(parser) + + args = parser.parse_args(["--current-activity", "Working"]) + assert args.current_activity == "Working" + + def test_adds_no_current_activity_argument(self): + parser = argparse.ArgumentParser() + add_args(parser) + + args = parser.parse_args([]) + assert args.no_current_activity is False + + def test_no_current_activity_enabled(self): + parser = argparse.ArgumentParser() + add_args(parser) + + args = parser.parse_args(["--no-current-activity"]) + assert args.no_current_activity is True + + def test_adds_from_date_argument(self): + parser = argparse.ArgumentParser() + add_args(parser) + + args = parser.parse_args(["--from", "2024-01-01"]) + assert args.from_date == "2024-01-01" + + def test_adds_to_date_argument(self): + parser = argparse.ArgumentParser() + add_args(parser) + + args = parser.parse_args(["--to", "2024-01-31"]) + assert args.to_date == "2024-01-31" + + def test_adds_project_argument(self): + parser = argparse.ArgumentParser() + add_args(parser) + + args = parser.parse_args(["--project", "backend"]) + assert args.project == "backend" + + def test_adds_month_argument(self): + parser = argparse.ArgumentParser() + add_args(parser) + + args = parser.parse_args(["--month", "2024-01"]) + assert args.month == "2024-01" + + def test_month_defaults_to_this(self): + parser = argparse.ArgumentParser() + add_args(parser) + + args = parser.parse_args(["--month"]) + assert args.month == "this" + + def test_adds_week_argument(self): + parser = argparse.ArgumentParser() + add_args(parser) + + args = parser.parse_args(["--week", "prev"]) + assert args.week == "prev" + + def test_week_defaults_to_this(self): + parser = argparse.ArgumentParser() + add_args(parser) + + args = parser.parse_args(["--week"]) + assert args.week == "this" + + +class TestProjectSummaryCommand: + """Tests for the project-summary command registration.""" + + def test_command_name(self): + assert project_summary_command.name == "project-summary" + + def test_command_description(self): + assert "project" in project_summary_command.description.lower() + + def test_command_handler_class(self): + assert project_summary_command.handler_class == ProjectSummaryHandler + + def test_command_add_args(self): + assert project_summary_command.add_args == add_args From 0a166418ffa373d156c626f6808e6db9fda232d1 Mon Sep 17 00:00:00 2001 From: loganthomas Date: Fri, 28 Nov 2025 12:11:16 -0600 Subject: [PATCH 2/7] add bump2ver and pytest.ini --- .bumpversion.cfg | 26 +++ pytest.ini | 2 + src/utt/plugins/project_summary.py | 291 +++++++++------------------- src/utt_project_summary/__init__.py | 41 +--- tests/test_project_summary.py | 138 ++++++------- 5 files changed, 177 insertions(+), 321 deletions(-) create mode 100644 .bumpversion.cfg create mode 100644 pytest.ini diff --git a/.bumpversion.cfg b/.bumpversion.cfg new file mode 100644 index 0000000..c0cde74 --- /dev/null +++ b/.bumpversion.cfg @@ -0,0 +1,26 @@ +[bumpversion] +current_version = 0.1.0-rc.1 +commit = True +tag_name = v{new_version} +parse = (?P\d+)\.(?P\d+)\.(?P\d+)(-(?Prc)\.(?P\d+))? +serialize = + {major}.{minor}.{patch}-{release}.{build} + {major}.{minor}.{patch} + +[bumpversion:part:release] +optional_value = final +first_value = rc +values = + rc + final + +[bumpversion:part:build] +first_value = 1 + +[bumpversion:file:pyproject.toml] +search = version = "{current_version}" +replace = version = "{new_version}" + +[bumpversion:file:src/utt_balance/__init__.py] +search = __version__ = "{current_version}" +replace = __version__ = "{new_version}" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..6c3bd5c --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +addopts = --cov=utt.plugins.project_summary --cov-report=term-missing diff --git a/src/utt/plugins/project_summary.py b/src/utt/plugins/project_summary.py index 7b72cdd..bc24041 100644 --- a/src/utt/plugins/project_summary.py +++ b/src/utt/plugins/project_summary.py @@ -1,22 +1,11 @@ -""" -utt Project Summary Plugin - Show projects sorted by time spent. - -This plugin adds a 'project-summary' command to utt that displays all projects -grouped and sorted by total duration, with optional percentage breakdown. - -Example -------- ->>> utt project-summary ->>> utt project-summary --show-perc ->>> utt project-summary --week this --show-perc -""" +"""utt project-summary plugin: Show projects sorted by time spent.""" from __future__ import annotations import argparse import itertools from datetime import timedelta -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple from utt.api import _v1 @@ -24,199 +13,139 @@ from collections.abc import Sequence -def format_duration(duration: timedelta) -> str: - """ - Format a timedelta as 'XhYY' (e.g., '6h30' or '25h00'). +class ProjectDuration(NamedTuple): + """Project with its total duration.""" - Parameters - ---------- - duration : timedelta - The time duration to format. + name: str + duration: timedelta - Returns - ------- - str - Formatted string in hours and zero-padded minutes. - """ - total_seconds = int(duration.total_seconds()) - hours, remainder = divmod(total_seconds, 3600) - minutes = remainder // 60 - return f"{hours}h{minutes:02d}" + @property + def formatted(self) -> str: + """Return duration as 'XhYY' string.""" + return format_duration(self.duration) -def format_title(title: str) -> str: - """ - Format a title with an underline. +class CurrentActivity(NamedTuple): + """Current activity info.""" - Parameters - ---------- - title : str - The title to format. + name: str + duration: timedelta - Returns - ------- - str - Title with dashed underline. - """ - return f"{title}\n{'-' * len(title)}" + @property + def formatted(self) -> str: + """Return duration as 'XhYY' string.""" + return format_duration(self.duration) + + +def format_duration(duration: timedelta) -> str: + """Format timedelta as 'XhYY' (e.g., '6h30').""" + total_seconds = int(duration.total_seconds()) + hours, remainder = divmod(total_seconds, 3600) + minutes = remainder // 60 + return f"{hours}h{minutes:02d}" class ProjectSummaryModel: """ - Model containing project summary data. - - Groups activities by project and calculates total durations. + Aggregate activities by project and calculate durations. Parameters ---------- activities : Sequence[_v1.Activity] - List of activities to summarize. + Activities to summarize. Attributes ---------- - projects : list[dict] - List of project dictionaries with 'project', 'duration', and 'duration_obj' keys, - sorted by duration descending. - current_activity : dict | None - Current activity info if present, with 'name', 'duration', and 'duration_obj' keys. - total_duration : str - Formatted total duration string. + projects : list[ProjectDuration] + Projects sorted by duration (descending). + current_activity : CurrentActivity | None + Current activity if present. + total_duration : timedelta + Sum of all durations including current activity. """ def __init__(self, activities: Sequence[_v1.Activity]) -> None: - work_activities = self._filter_work_activities(activities) - self.projects = self._groupby_project_sorted_by_duration(work_activities) - self.current_activity = self._get_current_activity_info(activities) - self.total_duration = self._calculate_total_duration() - - def _filter_work_activities(self, activities: Sequence[_v1.Activity]) -> list[_v1.Activity]: - """Filter to only WORK type activities.""" - return [act for act in activities if act.type == _v1.Activity.Type.WORK] - - def _calculate_total_duration(self) -> str: - """Calculate and format total duration including current activity.""" - total = sum((project["duration_obj"] for project in self.projects), timedelta()) - if self.current_activity: - total += self.current_activity["duration_obj"] - return format_duration(total) + work_activities = [a for a in activities if a.type == _v1.Activity.Type.WORK] + non_current = [a for a in work_activities if not a.is_current_activity] - def _get_current_activity_info(self, activities: Sequence[_v1.Activity]) -> dict | None: - """Extract current activity information if present.""" - for activity in activities: - if activity.is_current_activity: - return { - "name": activity.name.name, - "duration": format_duration(activity.duration), - "duration_obj": activity.duration, - } - return None + self.projects = self._aggregate_projects(non_current) + self.current_activity = self._extract_current(activities) + self.total_duration = self._compute_total() - def _groupby_project_sorted_by_duration(self, activities: Sequence[_v1.Activity]) -> list[dict]: + def _aggregate_projects(self, activities: Sequence[_v1.Activity]) -> list[ProjectDuration]: """Group activities by project and sort by total duration descending.""" + sorted_acts = sorted(activities, key=lambda a: a.name.project) + result = [] - def key(act: _v1.Activity) -> str: - return act.name.project + for project, group in itertools.groupby(sorted_acts, key=lambda a: a.name.project): + total = sum((a.duration for a in group), timedelta()) + result.append(ProjectDuration(project, total)) - non_current_activities = [act for act in activities if not act.is_current_activity] - result = [] - sorted_activities = sorted(non_current_activities, key=key) + return sorted(result, key=lambda p: p.duration, reverse=True) - for project, project_activities in itertools.groupby(sorted_activities, key): - activities_list = list(project_activities) - total_duration = sum((act.duration for act in activities_list), timedelta()) - result.append( - { - "duration": format_duration(total_duration), - "project": project, - "duration_obj": total_duration, - } - ) + def _extract_current(self, activities: Sequence[_v1.Activity]) -> CurrentActivity | None: + """Extract current activity if present.""" + for activity in activities: + if activity.is_current_activity: + return CurrentActivity(activity.name.name, activity.duration) + return None - return sorted(result, key=lambda r: r["duration_obj"], reverse=True) + def _compute_total(self) -> timedelta: + """Sum all project durations plus current activity.""" + total = sum((p.duration for p in self.projects), timedelta()) + if self.current_activity: + total += self.current_activity.duration + return total class ProjectSummaryView: - """ - View for rendering project summary output. - - Parameters - ---------- - model : ProjectSummaryModel - The model containing project summary data. - show_perc : bool, optional - Whether to show percentages, by default False. - """ + """Render project summary output.""" def __init__(self, model: ProjectSummaryModel, show_perc: bool = False) -> None: self._model = model self._show_perc = show_perc def render(self, output: _v1.Output) -> None: - """ - Render the project summary to the output stream. - - Parameters - ---------- - output : _v1.Output - Output stream to write to. - """ + """Render the project summary to output stream.""" print(file=output) - print(format_title("Project Summary"), file=output) + print("Project Summary", file=output) + print("---------------", file=output) print(file=output) - max_project_length = max((len(p["project"]) for p in self._model.projects), default=0) + max_name_len = max((len(p.name) for p in self._model.projects), default=0) + total_secs = self._model.total_duration.total_seconds() - total_seconds = sum( - (p["duration_obj"] for p in self._model.projects), timedelta() - ).total_seconds() - if self._model.current_activity: - total_seconds += self._model.current_activity["duration_obj"].total_seconds() - - max_duration_length = 0 + max_dur_len = 0 if self._show_perc: - durations = [len(p["duration"]) for p in self._model.projects] - durations.append(len(self._model.total_duration)) - max_duration_length = max(durations, default=0) + durations = [len(p.formatted) for p in self._model.projects] + durations.append(len(format_duration(self._model.total_duration))) + max_dur_len = max(durations, default=0) for project in self._model.projects: - duration_str = project["duration"] - if self._show_perc and total_seconds > 0: - perc = (project["duration_obj"].total_seconds() / total_seconds) * 100 - duration_str = f"{duration_str:<{max_duration_length}} ({perc:5.1f}%)" - print(f"{project['project']:<{max_project_length}}: {duration_str}", file=output) + dur_str = project.formatted + if self._show_perc and total_secs > 0: + perc = (project.duration.total_seconds() / total_secs) * 100 + dur_str = f"{dur_str:<{max_dur_len}} ({perc:5.1f}%)" + print(f"{project.name:<{max_name_len}}: {dur_str}", file=output) if self._model.current_activity: - name = self._model.current_activity["name"] - duration_str = self._model.current_activity["duration"] - if self._show_perc and total_seconds > 0: - perc = ( - self._model.current_activity["duration_obj"].total_seconds() / total_seconds - ) * 100 - duration_str = f"{duration_str} ({perc:5.1f}%)" - print(f"{name:<{max_project_length}}: {duration_str}", file=output) + ca = self._model.current_activity + dur_str = ca.formatted + if self._show_perc and total_secs > 0: + perc = (ca.duration.total_seconds() / total_secs) * 100 + dur_str = f"{dur_str} ({perc:5.1f}%)" + print(f"{ca.name:<{max_name_len}}: {dur_str}", file=output) print(file=output) - total_str = self._model.total_duration + total_str = format_duration(self._model.total_duration) if self._show_perc: - total_str = f"{total_str:<{max_duration_length}} (100.0%)" - print(f"{'Total':<{max_project_length}}: {total_str}", file=output) - + total_str = f"{total_str:<{max_dur_len}} (100.0%)" + print(f"{'Total':<{max_name_len}}: {total_str}", file=output) print(file=output) class ProjectSummaryHandler: - """ - Handler for the project-summary command. - - Parameters - ---------- - args : argparse.Namespace - Parsed command-line arguments. - filtered_activities : _v1.Activities - Activities filtered by the report date range. - output : _v1.Output - Output stream for rendering results. - """ + """Handler for the project-summary command.""" def __init__( self, @@ -229,25 +158,14 @@ def __init__( self._output = output def __call__(self) -> None: - """Execute the project-summary command and display results.""" + """Execute command.""" model = ProjectSummaryModel(self._activities) - view = ProjectSummaryView(model, show_perc=self._args.show_perc) - view.render(self._output) + ProjectSummaryView(model, self._args.show_perc).render(self._output) def add_args(parser: argparse.ArgumentParser) -> None: - """ - Add command-line arguments for the project-summary command. - - Parameters - ---------- - parser : argparse.ArgumentParser - The argument parser to add arguments to. - """ + """Add command-line arguments for project-summary.""" parser.add_argument("report_date", metavar="date", type=str, nargs="?") - - # Set defaults for report_args attributes that project-summary doesn't use - # but are required by the ReportArgs component parser.set_defaults(csv_section=None, comments=False, details=False, per_day=False) parser.add_argument( @@ -256,83 +174,56 @@ def add_args(parser: argparse.ArgumentParser) -> None: default=False, help="Show percentage of total time for each project", ) - parser.add_argument( "--current-activity", default="-- Current Activity --", type=str, help="Set the current activity", ) - parser.add_argument( "--no-current-activity", action="store_true", default=False, help="Do not display the current activity", ) - parser.add_argument( "--from", default=None, dest="from_date", type=str, - help="Specify an inclusive start date to report.", + help="Inclusive start date for the report", ) - parser.add_argument( "--to", default=None, dest="to_date", type=str, - help=( - "Specify an inclusive end date to report. " - "If this is a day of the week, then it is the next occurrence " - "from the start date of the report, including the start date " - "itself." - ), + help="Inclusive end date for the report", ) - parser.add_argument( "--project", default=None, type=str, - help="Show activities only for the specified project.", + help="Show activities only for the specified project", ) - parser.add_argument( "--month", default=None, nargs="?", const="this", type=str, - help=( - "Specify a month. " - "Allowed formats include, '2019-10', 'Oct', 'this' 'prev'. " - "The report will start on the first day of the month and end " - "on the last. '--from' or '--to' if present will override " - "start and end, respectively. If the month is the current " - "month, 'today' will be the last day of the report." - ), + help="Report for a specific month (e.g., '2024-10', 'Oct', 'this', 'prev')", ) - parser.add_argument( "--week", default=None, nargs="?", const="this", type=str, - help=( - "Specify a week. " - "Allowed formats include, 'this' 'prev', or week number. " - "The report will start on the first day of the week (Monday) " - "and end on the last (Sunday). '--from' or '--to' if present " - "will override start and end, respectively. If the week is " - "the current week, 'today' will be the last day of the report." - ), + help="Report for a specific week (e.g., 'this', 'prev', or week number)", ) -# Register the project-summary command with utt project_summary_command = _v1.Command( name="project-summary", description="Show projects sorted by time spent", diff --git a/src/utt_project_summary/__init__.py b/src/utt_project_summary/__init__.py index b35772e..c0b911f 100644 --- a/src/utt_project_summary/__init__.py +++ b/src/utt_project_summary/__init__.py @@ -1,42 +1,3 @@ -""" -utt-project-summary: A utt plugin to show projects sorted by time spent. - -This plugin adds a 'project-summary' command to utt that shows: - -- All projects sorted by time spent (highest to lowest) -- Optional percentage breakdown of time per project -- Current activity included in totals -- Support for various date range filters - -Installation ------------- -Install via pip:: - - pip install utt-project-summary - -Usage ------ -After installation, the project-summary command is available via utt:: - - utt project-summary [--show-perc] [--from DATE] [--to DATE] [--week WEEK] [--month MONTH] - -Examples --------- -Show today's project summary:: - - utt project-summary - -Show with percentages:: - - utt project-summary --show-perc - -Show this week's summary:: - - utt project-summary --week this - -For more information, see: https://github.com/loganthomas/utt-project-summary -""" +"""utt-project-summary: A utt plugin to show projects sorted by time spent.""" __version__ = "0.1.0-rc.1" - -__all__ = ["__version__"] diff --git a/tests/test_project_summary.py b/tests/test_project_summary.py index ed3cc8a..e8b059f 100644 --- a/tests/test_project_summary.py +++ b/tests/test_project_summary.py @@ -13,7 +13,6 @@ ProjectSummaryView, add_args, format_duration, - format_title, project_summary_command, ) @@ -24,60 +23,38 @@ def create_activity( duration_minutes: int, is_current: bool = False, ) -> _v1.Activity: - """Helper to create test activities.""" + """Create a test activity.""" start = pytz.UTC.localize(start_time) end = start + timedelta(minutes=duration_minutes) return _v1.Activity(name, start, end, is_current) class TestFormatDuration: - """Tests for the format_duration function.""" - def test_zero_time(self): - td = timedelta(hours=0) - assert format_duration(td) == "0h00" + assert format_duration(timedelta(hours=0)) == "0h00" def test_whole_hours(self): - td = timedelta(hours=8) - assert format_duration(td) == "8h00" + assert format_duration(timedelta(hours=8)) == "8h00" def test_hours_and_minutes(self): - td = timedelta(hours=6, minutes=30) - assert format_duration(td) == "6h30" + assert format_duration(timedelta(hours=6, minutes=30)) == "6h30" def test_minutes_only(self): - td = timedelta(minutes=45) - assert format_duration(td) == "0h45" + assert format_duration(timedelta(minutes=45)) == "0h45" def test_large_hours(self): - td = timedelta(hours=40) - assert format_duration(td) == "40h00" + assert format_duration(timedelta(hours=40)) == "40h00" def test_single_digit_minutes_padded(self): - td = timedelta(hours=1, minutes=5) - assert format_duration(td) == "1h05" - - -class TestFormatTitle: - """Tests for the format_title function.""" - - def test_simple_title(self): - result = format_title("Project Summary") - assert result == "Project Summary\n---------------" - - def test_short_title(self): - result = format_title("Test") - assert result == "Test\n----" + assert format_duration(timedelta(hours=1, minutes=5)) == "1h05" class TestProjectSummaryModel: - """Tests for the ProjectSummaryModel class.""" - def test_empty_activities(self): model = ProjectSummaryModel([]) assert model.projects == [] assert model.current_activity is None - assert model.total_duration == "0h00" + assert model.total_duration == timedelta() def test_single_project(self): activities = [ @@ -85,8 +62,8 @@ def test_single_project(self): ] model = ProjectSummaryModel(activities) assert len(model.projects) == 1 - assert model.projects[0]["project"] == "backend" - assert model.projects[0]["duration"] == "3h00" + assert model.projects[0].name == "backend" + assert model.projects[0].formatted == "3h00" def test_multiple_projects_sorted_by_duration(self): activities = [ @@ -97,9 +74,9 @@ def test_multiple_projects_sorted_by_duration(self): model = ProjectSummaryModel(activities) assert len(model.projects) == 3 - assert model.projects[0]["project"] == "beta" - assert model.projects[1]["project"] == "gamma" - assert model.projects[2]["project"] == "alpha" + assert model.projects[0].name == "beta" + assert model.projects[1].name == "gamma" + assert model.projects[2].name == "alpha" def test_activities_grouped_by_project(self): activities = [ @@ -110,8 +87,8 @@ def test_activities_grouped_by_project(self): model = ProjectSummaryModel(activities) assert len(model.projects) == 2 - project1 = next(p for p in model.projects if p["project"] == "project1") - assert project1["duration"] == "2h00" + project1 = next(p for p in model.projects if p.name == "project1") + assert project1.formatted == "2h00" def test_current_activity_extracted(self): activities = [ @@ -123,8 +100,8 @@ def test_current_activity_extracted(self): model = ProjectSummaryModel(activities) assert model.current_activity is not None - assert model.current_activity["name"] == "-- Current Activity --" - assert model.current_activity["duration"] == "0h30" + assert model.current_activity.name == "-- Current Activity --" + assert model.current_activity.formatted == "0h30" def test_current_activity_not_in_projects(self): activities = [ @@ -136,7 +113,7 @@ def test_current_activity_not_in_projects(self): model = ProjectSummaryModel(activities) assert len(model.projects) == 1 - assert all(p["project"] != "-- Current Activity --" for p in model.projects) + assert all(p.name != "-- Current Activity --" for p in model.projects) def test_total_duration_includes_current_activity(self): activities = [ @@ -147,13 +124,11 @@ def test_total_duration_includes_current_activity(self): ] model = ProjectSummaryModel(activities) - assert model.total_duration == "1h30" + assert format_duration(model.total_duration) == "1h30" class TestProjectSummaryView: - """Tests for the ProjectSummaryView class.""" - - def test_view_output_with_aligned_colons(self): + def test_output_with_aligned_colons(self): activities = [ create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 240), create_activity("project2: task1", datetime(2024, 1, 1, 13, 0), 165), @@ -177,7 +152,7 @@ def test_view_output_with_aligned_colons(self): assert "project5: 0h15" in lines[8] assert "Total : 8h00" in lines[10] - def test_view_output_with_current_activity(self): + def test_output_with_current_activity(self): activities = [ create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 240), create_activity("project2: task1", datetime(2024, 1, 1, 13, 0), 165), @@ -204,7 +179,7 @@ def test_view_output_with_current_activity(self): assert "-- Current Activity --: 3h40" in lines[9] assert "Total : 11h40" in lines[11] - def test_view_colons_aligned_with_varying_project_lengths(self): + def test_colons_aligned_with_varying_project_lengths(self): activities = [ create_activity("a: task1", datetime(2024, 1, 1, 9, 0), 60), create_activity("medium-name: task1", datetime(2024, 1, 1, 10, 0), 120), @@ -225,7 +200,7 @@ def test_view_colons_aligned_with_varying_project_lengths(self): assert len(set(colon_positions)) == 1, "All colons should be at the same position" - def test_view_empty_activities(self): + def test_empty_activities(self): model = ProjectSummaryModel([]) view = ProjectSummaryView(model) output = io.StringIO() @@ -236,7 +211,7 @@ def test_view_empty_activities(self): assert "Project Summary" in result assert "Total: 0h00" in result - def test_view_single_project(self): + def test_single_project(self): activities = [ create_activity("backend: api work", datetime(2024, 1, 1, 9, 0), 180), ] @@ -250,7 +225,7 @@ def test_view_single_project(self): assert "backend: 3h00" in result assert "Total : 3h00" in result - def test_view_projects_without_names(self): + def test_projects_without_names(self): activities = [ create_activity("standalone task", datetime(2024, 1, 1, 9, 0), 60), create_activity("another task", datetime(2024, 1, 1, 10, 0), 30), @@ -266,7 +241,7 @@ def test_view_projects_without_names(self): assert ": 1h30" in lines[4] assert "Total: 1h30" in lines[6] - def test_view_sorting_by_duration(self): + def test_sorting_by_duration(self): activities = [ create_activity("alpha: task1", datetime(2024, 1, 1, 9, 0), 30), create_activity("beta: task1", datetime(2024, 1, 1, 10, 0), 90), @@ -290,7 +265,7 @@ def test_view_sorting_by_duration(self): assert "gamma" in project_lines[1] assert "alpha" in project_lines[2] - def test_view_large_durations(self): + def test_large_durations(self): activities = [ create_activity("marathon: task1", datetime(2024, 1, 1, 9, 0), 1500), create_activity("sprint: task1", datetime(2024, 1, 2, 10, 0), 600), @@ -306,7 +281,7 @@ def test_view_large_durations(self): assert "sprint : 10h00" in result assert "Total : 35h00" in result - def test_view_mixed_named_and_unnamed_projects(self): + def test_mixed_named_and_unnamed_projects(self): activities = [ create_activity("asd: A-526", datetime(2024, 1, 1, 9, 0), 195), create_activity("qwer: b-73", datetime(2024, 1, 1, 12, 15), 45), @@ -327,7 +302,7 @@ def test_view_mixed_named_and_unnamed_projects(self): assert "A : 0h30" in lines[7] assert "Total: 5h30" in lines[9] - def test_view_with_percentages(self): + def test_with_percentages(self): activities = [ create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 240), create_activity("project2: task1", datetime(2024, 1, 1, 13, 0), 120), @@ -346,7 +321,7 @@ def test_view_with_percentages(self): assert "project3: 1h00 ( 14.3%)" in lines[6] assert "Total : 7h00 (100.0%)" in lines[8] - def test_view_with_percentages_and_current_activity(self): + def test_with_percentages_and_current_activity(self): activities = [ create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 240), create_activity("project2: task1", datetime(2024, 1, 1, 13, 0), 120), @@ -367,7 +342,7 @@ def test_view_with_percentages_and_current_activity(self): assert "-- Current Activity --: 1h00 ( 14.3%)" in lines[6] assert "Total : 7h00 (100.0%)" in lines[8] - def test_view_percentages_without_flag(self): + def test_percentages_without_flag(self): activities = [ create_activity("project1: task1", datetime(2024, 1, 1, 9, 0), 240), create_activity("project2: task1", datetime(2024, 1, 1, 13, 0), 120), @@ -385,103 +360,104 @@ def test_view_percentages_without_flag(self): class TestAddArgs: - """Tests for the add_args function.""" - - def test_adds_show_perc_argument(self): + def test_show_perc_default_false(self): parser = argparse.ArgumentParser() add_args(parser) - args = parser.parse_args([]) assert args.show_perc is False def test_show_perc_enabled(self): parser = argparse.ArgumentParser() add_args(parser) - args = parser.parse_args(["--show-perc"]) assert args.show_perc is True - def test_adds_current_activity_argument(self): + def test_current_activity_default(self): parser = argparse.ArgumentParser() add_args(parser) - args = parser.parse_args([]) assert args.current_activity == "-- Current Activity --" def test_custom_current_activity(self): parser = argparse.ArgumentParser() add_args(parser) - args = parser.parse_args(["--current-activity", "Working"]) assert args.current_activity == "Working" - def test_adds_no_current_activity_argument(self): + def test_no_current_activity_default_false(self): parser = argparse.ArgumentParser() add_args(parser) - args = parser.parse_args([]) assert args.no_current_activity is False def test_no_current_activity_enabled(self): parser = argparse.ArgumentParser() add_args(parser) - args = parser.parse_args(["--no-current-activity"]) assert args.no_current_activity is True - def test_adds_from_date_argument(self): + def test_from_date(self): parser = argparse.ArgumentParser() add_args(parser) - args = parser.parse_args(["--from", "2024-01-01"]) assert args.from_date == "2024-01-01" - def test_adds_to_date_argument(self): + def test_to_date(self): parser = argparse.ArgumentParser() add_args(parser) - args = parser.parse_args(["--to", "2024-01-31"]) assert args.to_date == "2024-01-31" - def test_adds_project_argument(self): + def test_project_filter(self): parser = argparse.ArgumentParser() add_args(parser) - args = parser.parse_args(["--project", "backend"]) assert args.project == "backend" - def test_adds_month_argument(self): + def test_month_with_value(self): parser = argparse.ArgumentParser() add_args(parser) - args = parser.parse_args(["--month", "2024-01"]) assert args.month == "2024-01" def test_month_defaults_to_this(self): parser = argparse.ArgumentParser() add_args(parser) - args = parser.parse_args(["--month"]) assert args.month == "this" - def test_adds_week_argument(self): + def test_week_with_value(self): parser = argparse.ArgumentParser() add_args(parser) - args = parser.parse_args(["--week", "prev"]) assert args.week == "prev" def test_week_defaults_to_this(self): parser = argparse.ArgumentParser() add_args(parser) - args = parser.parse_args(["--week"]) assert args.week == "this" -class TestProjectSummaryCommand: - """Tests for the project-summary command registration.""" +class TestProjectSummaryHandler: + def test_handler_renders_output(self): + activities = [ + create_activity("backend: task1", datetime(2024, 1, 1, 9, 0), 120), + create_activity("frontend: task1", datetime(2024, 1, 1, 11, 0), 60), + ] + args = argparse.Namespace(show_perc=False) + output = io.StringIO() + + handler = ProjectSummaryHandler(args, activities, output) + handler() + result = output.getvalue() + assert "backend : 2h00" in result + assert "frontend: 1h00" in result + assert "Total : 3h00" in result + + +class TestProjectSummaryCommand: def test_command_name(self): assert project_summary_command.name == "project-summary" From acd151519b964ce9454a8006a8ba6d757ca442ad Mon Sep 17 00:00:00 2001 From: loganthomas Date: Fri, 28 Nov 2025 12:14:34 -0600 Subject: [PATCH 3/7] add github actions --- .github/workflows/unit-tests.yml | 38 ++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 .github/workflows/unit-tests.yml diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 0000000..611d709 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,38 @@ +name: unit-tests + +on: + push: + branches: + - dev + pull_request: + +jobs: + test: + name: run unit tests via pytest + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Display Python version + run: python -c "import sys; print(sys.version)" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run tests + run: pytest + + - name: Complete messsge + run: echo "unit tests completed" From d440fa1fac4bf21d3e135fec08b981b14aee5c81 Mon Sep 17 00:00:00 2001 From: loganthomas Date: Sat, 29 Nov 2025 07:42:56 -0600 Subject: [PATCH 4/7] fix: bump2version --- .bumpversion.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index c0cde74..5273a93 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -21,6 +21,6 @@ first_value = 1 search = version = "{current_version}" replace = version = "{new_version}" -[bumpversion:file:src/utt_balance/__init__.py] +[bumpversion:file:src/utt_project_summary/__init__.py] search = __version__ = "{current_version}" replace = __version__ = "{new_version}" From c816a23c4a2932436086af6dd4d10415e065d237 Mon Sep 17 00:00:00 2001 From: loganthomas Date: Sat, 29 Nov 2025 07:43:00 -0600 Subject: [PATCH 5/7] =?UTF-8?q?Bump=20version:=200.1.0-rc.1=20=E2=86=92=20?= =?UTF-8?q?0.1.0-rc.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .bumpversion.cfg | 2 +- pyproject.toml | 2 +- src/utt_project_summary/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 5273a93..75d63ca 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 0.1.0-rc.1 +current_version = 0.1.0-rc.2 commit = True tag_name = v{new_version} parse = (?P\d+)\.(?P\d+)\.(?P\d+)(-(?Prc)\.(?P\d+))? diff --git a/pyproject.toml b/pyproject.toml index e731bd1..33dc0f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "utt-project-summary" -version = "0.1.0-rc.1" +version = "0.1.0-rc.2" description = "A utt plugin to show projects sorted by time spent" readme = "README.md" license = "GPL-3.0-only" diff --git a/src/utt_project_summary/__init__.py b/src/utt_project_summary/__init__.py index c0b911f..ff3bfb2 100644 --- a/src/utt_project_summary/__init__.py +++ b/src/utt_project_summary/__init__.py @@ -1,3 +1,3 @@ """utt-project-summary: A utt plugin to show projects sorted by time spent.""" -__version__ = "0.1.0-rc.1" +__version__ = "0.1.0-rc.2" From 1ad92f8f62121ce1cf23cd0d65a02ac7204acf90 Mon Sep 17 00:00:00 2001 From: loganthomas Date: Sat, 29 Nov 2025 07:51:11 -0600 Subject: [PATCH 6/7] doc: add badges to readme --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index 77b999f..518f895 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # utt-project-summary +[![CI - Test](https://github.com/loganthomas/utt-project-summary/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/loganthomas/utt-project-summary/actions/workflows/unit-tests.yml) +[![PyPI Latest Release](https://img.shields.io/pypi/v/utt-project-summary.svg)](https://pypi.org/project/utt-project-summary/) +[![PyPI Downloads](https://img.shields.io/pypi/dm/utt-project-summary.svg?label=PyPI%20downloads)](https://pypi.org/project/utt-project-summary/) +[![License - GPL-3.0](https://img.shields.io/pypi/l/utt-project-summary.svg)](https://github.com/loganthomas/utt-project-summary/blob/main/LICENSE) +[![Python Versions](https://img.shields.io/pypi/pyversions/utt-project-summary.svg)](https://pypi.org/project/utt-project-summary/) + A [`utt`](https://github.com/larose/utt) plugin that shows projects sorted by time spent. ## Why utt-project-summary? From 4cfc951dc786c33d4c65e734a3d7718b77d4f1d3 Mon Sep 17 00:00:00 2001 From: loganthomas Date: Sat, 29 Nov 2025 08:21:51 -0600 Subject: [PATCH 7/7] doc: add explicit main branch to ci badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 518f895..a2599e7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # utt-project-summary -[![CI - Test](https://github.com/loganthomas/utt-project-summary/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/loganthomas/utt-project-summary/actions/workflows/unit-tests.yml) +[![CI - Test](https://github.com/loganthomas/utt-project-summary/actions/workflows/unit-tests.yml/badge.svg?branch=main)](https://github.com/loganthomas/utt-project-summary/actions/workflows/unit-tests.yml) [![PyPI Latest Release](https://img.shields.io/pypi/v/utt-project-summary.svg)](https://pypi.org/project/utt-project-summary/) [![PyPI Downloads](https://img.shields.io/pypi/dm/utt-project-summary.svg?label=PyPI%20downloads)](https://pypi.org/project/utt-project-summary/) [![License - GPL-3.0](https://img.shields.io/pypi/l/utt-project-summary.svg)](https://github.com/loganthomas/utt-project-summary/blob/main/LICENSE)