diff --git a/README.md b/README.md index 9b857eb..699b113 100644 --- a/README.md +++ b/README.md @@ -152,7 +152,8 @@ Options: -g, --no-page Don't page the output. -l, --text-only Output the work log text only. -T, --tag TEXT Tag to filter by. Can be used multiple times to filter - by multiple tags. + by multiple tags. Tags are normalized (trimmed and + lowercased). -D, --duration TEXT Duration to filter by. [default: ""] --date-format TEXT Set the date format of the output. Must be a valid Python strftime string. [env var: @@ -189,6 +190,7 @@ Options: - Tags can contain alphanumeric characters, underscores, and hyphens only. - Query logged work by tags using the `--tag/-T` option. Using it multiple times will match any of the specified tags. + - Filter tags are normalized the same way as saved tags (trimmed, lowercased, and empty values are ignored). - Specify duration while adding work. - Duration can be specified in two ways: - The `--duration/-D` option, e.g. `--duration 1h30m` or `--duration 90m`. diff --git a/tests/conftest.py b/tests/conftest.py index 06f484a..9f85582 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,13 +20,10 @@ def runner() -> CliRunner: @pytest.fixture(autouse=True, scope="session") def freeze_clock_at_2359() -> Generator[None, None, None]: """ - Freeze every test at today's date but at 23:59:00, - so relative date parsing (“yesterday”, “tomorrow”, etc.) - is always based off of 11:59 PM local time. + Freeze every test at a fixed date (23:59:00) so relative date parsing + (“yesterday”, “tomorrow”, etc.) is deterministic across runs. """ - # capture now, then move to 23:59:00 of the same day - now = datetime.now() - target = now.replace(hour=23, minute=59, second=0, microsecond=0) + target = datetime(2024, 1, 10, 23, 59, 0) with freeze_time(target): yield diff --git a/tests/test_cli.py b/tests/test_cli.py index 976d078..c909a84 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,11 +1,14 @@ from __future__ import annotations from datetime import datetime +import importlib import re +import warnings from click.testing import CliRunner, Result import pytest +import workedon from workedon import __version__, cli, exceptions from workedon.conf import CONF_PATH @@ -58,6 +61,38 @@ def test_empty_fetch(runner: CliRunner) -> None: assert "Nothing to show" in result.output +def test_main_with_subcommand_returns_early(runner: CliRunner) -> None: + result = runner.invoke(cli.main, ["what", "--no-page"]) + assert result.exit_code == 0 + + +def test_cli_import_skips_warning_filter_in_debug(monkeypatch: pytest.MonkeyPatch) -> None: + called = {"value": False} + + def record_call(*_args: object, **_kwargs: object) -> None: + called["value"] = True + + monkeypatch.setenv("WORKEDON_DEBUG", "1") + monkeypatch.setattr(warnings, "filterwarnings", record_call) + importlib.reload(cli) + importlib.reload(workedon) + + assert called["value"] is False + + +def test_cli_import_filters_warnings_without_debug(monkeypatch: pytest.MonkeyPatch) -> None: + called = {"value": False} + + def record_call(*_args: object, **_kwargs: object) -> None: + called["value"] = True + + monkeypatch.delenv("WORKEDON_DEBUG", raising=False) + monkeypatch.setattr(warnings, "filterwarnings", record_call) + importlib.reload(cli) + importlib.reload(workedon) + assert called["value"] is True + + # -- Basic save & fetch scenarios ------------------------------------------------ @@ -100,6 +135,15 @@ def test_fetch_last(runner: CliRunner, command: str, description: str, valid: bo assert description not in result.output +def test_fetch_last_returns_most_recent_entry(runner: CliRunner) -> None: + save_and_verify(runner, "first thing @ 3 days ago", "first thing") + save_and_verify(runner, "second thing @ yesterday", "second thing") + + result = runner.invoke(cli.what, ["--no-page", "--last"]) + verify_work_output(result, "second thing") + assert "first thing" not in result.output + + # -- Fetch by ID ---------------------------------------------------------------- @@ -206,10 +250,6 @@ def test_timezone_option( ("learning to cook @ 3pm yesterday", ["-f", "2 days ago", "-t", "3:05pm yesterday"]), # 36 ("watching tv @ 9am", ["-g"]), # 37 ("taking wife shopping @ 3pm", ["--no-page"]), # 38 - ( - "weights at the gym", - ["--count", "1"], - ), ], ) def test_save_and_fetch_others(runner: CliRunner, command: str, flag: list[str]) -> None: @@ -220,6 +260,15 @@ def test_save_and_fetch_others(runner: CliRunner, command: str, flag: list[str]) verify_work_output(result, description) +def test_default_fetch_excludes_entries_older_than_week(runner: CliRunner) -> None: + save_and_verify(runner, "ancient history @ 10 days ago", "ancient history") + save_and_verify(runner, "fresh work @ yesterday", "fresh work") + + result = runner.invoke(cli.what, ["--no-page"]) + verify_work_output(result, "fresh work") + assert "ancient history" not in result.output + + # -- Deletion -------------------------------------------------------------------- @@ -458,6 +507,7 @@ def test_weird_tag_parsing(runner: CliRunner, input_text: str, expected_tags: se ("working on #devtools", ["--tag", "devtools"], True), ("#in-progress cleanup", ["--tag", "in-progress"], True), ("writing code #DEV", ["--tag", "dev"], True), # case-insensitive + ("writing code #DEV", ["--tag", " dev "], True), # surrounding whitespace ignored ("refactoring #code_review", ["--tag", "code_review"], True), ("invalid ##doubletag", ["--tag", "doubletag"], True), ("emoji #🔥", ["--tag", "🔥"], False), @@ -477,6 +527,16 @@ def test_cli_tag_filter( assert "Nothing to show" in result_fetch.output +def test_list_tags_outputs_saved_tags(runner: CliRunner) -> None: + runner.invoke(cli.main, ["first", "tag", "#alpha"]) + runner.invoke(cli.main, ["second", "tag", "#beta"]) + + result = runner.invoke(cli.main, ["--list-tags"]) + assert result.exit_code == 0 + assert "alpha" in result.output.lower() + assert "beta" in result.output.lower() + + # -- Duration ------------------------------------------------------------ @@ -494,13 +554,10 @@ def test_cli_tag_filter( def test_cli_duration_parsing_and_display( runner: CliRunner, input_text: str, expected_duration: str, xargs: list ) -> None: - result_save = runner.invoke(cli.main, input_text.split()) + result_save = runner.invoke(cli.main, [*input_text.split(), *xargs]) assert result_save.exit_code == 0 assert "Work saved." in result_save.output - - result_fetch = runner.invoke(cli.what, ["--no-page", "--last", *xargs]) - assert result_fetch.exit_code == 0 - assert expected_duration in result_fetch.output + assert expected_duration in result_save.output @pytest.mark.parametrize( @@ -561,3 +618,20 @@ def test_cli_duration_filter( def test_invalid_duration_filter(runner: CliRunner, invalid_filter_flag: list[str]) -> None: result = runner.invoke(cli.what, ["--no-page", *invalid_filter_flag]) assert result.exit_code == 1 + assert "Invalid duration" in result.output + + +def test_cli_duration_option_overrides_inline_value(runner: CliRunner) -> None: + save_result = runner.invoke(cli.main, ["overridden task", "[30m]", "--duration", "2h"]) + verify_work_output(save_result, "overridden task") + + fetch_result = runner.invoke(cli.what, ["--no-page", "--last"]) + assert "Duration: 120.0 minutes" in fetch_result.output + + +def test_cli_duration_option_ignored_when_invalid(runner: CliRunner) -> None: + save_result = runner.invoke(cli.main, ["keep inline", "[30m]", "--duration", "abc"]) + verify_work_output(save_result, "keep inline") + + fetch_result = runner.invoke(cli.what, ["--no-page", "--last"]) + assert "Duration: 30.0 minutes" in fetch_result.output diff --git a/tests/test_conf.py b/tests/test_conf.py new file mode 100644 index 0000000..97912b9 --- /dev/null +++ b/tests/test_conf.py @@ -0,0 +1,88 @@ +from pathlib import Path + +import pytest + +from workedon import conf +from workedon.conf import Settings +from workedon.constants import SETTINGS_HEADER +from workedon.exceptions import CannotCreateSettingsError, CannotLoadSettingsError + + +def test_settings_getattr_and_setattr() -> None: + settings = Settings() + settings.FOO = "bar" + assert settings.FOO == "bar" + assert settings["FOO"] == "bar" + + +def test_configure_creates_settings_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + conf_path = tmp_path / "wonfile.py" + monkeypatch.setattr(conf, "CONF_PATH", conf_path) + + settings = Settings() + settings.configure() + + assert conf_path.exists() + assert SETTINGS_HEADER.strip() in conf_path.read_text() + + +def test_configure_loads_user_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + conf_path = tmp_path / "wonfile.py" + conf_path.write_text('TIME_FORMAT = "%H:%M"\n') + monkeypatch.setattr(conf, "CONF_PATH", conf_path) + + settings = Settings() + settings.configure() + + assert settings.TIME_FORMAT == "%H:%M" + + +def test_configure_merges_user_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + conf_path = tmp_path / "wonfile.py" + conf_path.write_text('DATE_FORMAT = "%Y"\n') + monkeypatch.setattr(conf, "CONF_PATH", conf_path) + + settings = Settings() + settings.configure(user_settings={"DATE_FORMAT": "%d"}) + + assert settings.DATE_FORMAT == "%d" + + +def test_configure_raises_on_bad_spec(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + conf_path = tmp_path / "wonfile.py" + conf_path.write_text("# ok\n") + monkeypatch.setattr(conf, "CONF_PATH", conf_path) + monkeypatch.setattr(conf, "spec_from_file_location", lambda *args, **kwargs: None) + + settings = Settings() + with pytest.raises(CannotLoadSettingsError): + settings.configure() + + +def test_configure_raises_on_exec_module_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + conf_path = tmp_path / "wonfile.py" + conf_path.write_text("raise RuntimeError('boom')\n") + monkeypatch.setattr(conf, "CONF_PATH", conf_path) + + settings = Settings() + with pytest.raises(CannotLoadSettingsError) as excinfo: + settings.configure() + assert "boom" in str(excinfo.value) + + +def test_configure_raises_on_settings_file_creation_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + conf_path = tmp_path / "wonfile.py" + monkeypatch.setattr(conf, "CONF_PATH", conf_path) + + settings = Settings() + + def blow_up() -> None: + raise OSError("nope") + + monkeypatch.setattr(Settings, "_create_settings_file", blow_up) + with pytest.raises(CannotCreateSettingsError): + settings.configure() diff --git a/tests/test_constants.py b/tests/test_constants.py new file mode 100644 index 0000000..a4e5df2 --- /dev/null +++ b/tests/test_constants.py @@ -0,0 +1,8 @@ +from workedon import constants + + +def test_constants_have_expected_values() -> None: + assert constants.APP_NAME == "workedon" + assert constants.CURRENT_DB_VERSION > 0 + assert constants.WORK_CHUNK_SIZE > 0 + assert "workedon settings file" in constants.SETTINGS_HEADER diff --git a/tests/test_default_settings.py b/tests/test_default_settings.py new file mode 100644 index 0000000..de58a1e --- /dev/null +++ b/tests/test_default_settings.py @@ -0,0 +1,10 @@ +from workedon import default_settings + + +def test_default_settings_are_sane() -> None: + assert isinstance(default_settings.DATE_FORMAT, str) + assert isinstance(default_settings.TIME_FORMAT, str) + assert default_settings.DATETIME_FORMAT == "" + assert isinstance(default_settings.TIME_ZONE, str) + assert default_settings.TIME_ZONE + assert default_settings.DURATION_UNIT == "minutes" diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..c24a77f --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,32 @@ +import pytest + +from workedon import exceptions + + +@pytest.mark.parametrize( + "exc_cls, detail", + [ + (exceptions.DBInitializationError, "Unable to initialize the database."), + (exceptions.CannotCreateSettingsError, "Unable to create settings file."), + (exceptions.CannotLoadSettingsError, "Unable to load settings file."), + (exceptions.InvalidWorkError, "The provided work text is invalid."), + ( + exceptions.InvalidDateTimeError, + "The provided date/time is invalid. Please refer the docs for valid phrases.", + ), + (exceptions.DateTimeInFutureError, "The provided date/time is in the future."), + (exceptions.StartDateAbsentError, "Please provide a start date/time."), + ( + exceptions.StartDateGreaterError, + "The provided start date/time is greater than the end date/time.", + ), + (exceptions.CannotSaveWorkError, "Unable to save your work."), + (exceptions.CannotFetchWorkError, "Unable to fetch your work."), + ], +) +def test_exception_details_and_string_formatting( + exc_cls: type[exceptions.WorkedOnError], detail: str +) -> None: + assert str(exc_cls()) == detail + assert str(exc_cls(extra_detail="extra")) == f"{detail} :: extra" + assert str(exc_cls(extra_detail="")) == detail diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..2745ae2 --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,115 @@ +"""Integration tests covering complete workflows.""" + +from datetime import datetime +import zoneinfo + +from click.testing import CliRunner +from freezegun import freeze_time +import pytest + +from workedon import cli + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def test_full_workflow_save_modify_fetch_delete(runner: CliRunner) -> None: + # Save work + save_result = runner.invoke(cli.main, ["working on feature #dev [2h] @ yesterday"]) + assert save_result.exit_code == 0 + assert "Work saved." in save_result.output + + # Fetch by tag + fetch_result = runner.invoke(cli.what, ["--no-page", "--tag", "dev"]) + assert fetch_result.exit_code == 0 + assert "working on feature" in fetch_result.output + + # Fetch by duration + duration_result = runner.invoke(cli.what, ["--no-page", "--duration", "=2h"]) + assert duration_result.exit_code == 0 + assert "working on feature" in duration_result.output + + # Delete + delete_result = runner.invoke(cli.what, ["--no-page", "--tag", "dev", "--delete"], input="y") + assert delete_result.exit_code == 0 + assert "deleted successfully" in delete_result.output + + # Verify deletion + verify_result = runner.invoke(cli.what, ["--no-page", "--tag", "dev"]) + assert verify_result.exit_code == 0 + assert "Nothing to show" in verify_result.output + + +def test_multiple_tags_filtering(runner: CliRunner) -> None: + # Save work with multiple tags + runner.invoke(cli.main, ["task1 #dev #frontend @ 1pm yesterday"]) + runner.invoke(cli.main, ["task2 #dev #backend @ 2pm yesterday"]) + runner.invoke(cli.main, ["task3 #qa #frontend @ 3pm yesterday"]) + + # Filter by single tag + dev_result = runner.invoke(cli.what, ["--no-page", "--tag", "dev", "--yesterday"]) + assert "task1" in dev_result.output + assert "task2" in dev_result.output + assert "task3" not in dev_result.output + + # Filter by multiple tags (OR logic) + multi_result = runner.invoke( + cli.what, ["--no-page", "--tag", "dev", "--tag", "frontend", "--yesterday"] + ) + assert "task1" in multi_result.output + assert "task2" in multi_result.output + assert "task3" in multi_result.output + + +def test_duration_with_timezone_changes(runner: CliRunner) -> None: + # Save in one timezone + runner.invoke(cli.main, ["work [90m] @ 3pm yesterday", "--time-zone", "UTC"]) + + # Fetch in different timezone + result = runner.invoke(cli.what, ["--no-page", "--last", "--time-zone", "Asia/Tokyo"]) + assert result.exit_code == 0 + assert "Duration:" in result.output + + +def test_pagination_with_large_dataset(runner: CliRunner) -> None: + # Create many entries + for i in range(50): + runner.invoke(cli.main, [f"work item {i} @ {i} hours ago"]) + + # Fetch without pagination + no_page = runner.invoke(cli.what, ["--no-page", "--count", "50"]) + assert no_page.exit_code == 0 + + # Fetch with pagination (default behavior, harder to test) + with_page = runner.invoke(cli.what, ["--count", "50"]) + assert with_page.exit_code == 0 + + +def test_edge_case_midnight_boundary(runner: CliRunner) -> None: + # Save at midnight + runner.invoke(cli.main, ["midnight task @ 12:00am"]) + + # Should appear in today's results + today_result = runner.invoke(cli.what, ["--no-page", "--today"]) + assert "midnight task" in today_result.output + + +@freeze_time("2024-01-10 15:00:00") +def test_complex_datetime_parsing(runner: CliRunner) -> None: + fmt = "%Y-%m-%d %H:%M %z" + tz = zoneinfo.ZoneInfo("UTC") + test_cases = [ + ("meeting @ 3pm 5 days ago", datetime(2024, 1, 5, 15, 0, tzinfo=tz)), + ("call @ 9:30am yesterday", datetime(2024, 1, 9, 9, 30, tzinfo=tz)), + ("email @ noon 3 days ago", datetime(2024, 1, 7, 12, 0, tzinfo=tz)), + ("standup @ 10am this week", datetime(2024, 1, 10, 10, 0, tzinfo=tz)), + ] + env = {"WORKEDON_TIME_ZONE": "UTC", "WORKEDON_DATETIME_FORMAT": fmt} + + for case, expected_dt in test_cases: + result = runner.invoke(cli.main, case.split(), env=env) + assert result.exit_code == 0 + assert "Work saved." in result.output + assert f"Date: {expected_dt.strftime(fmt)}" in result.output diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..b0f62d1 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,315 @@ +from pathlib import Path + +from peewee import IntegrityError, OperationalError, SqliteDatabase +import pytest + +from workedon import models +from workedon.conf import settings +from workedon.constants import CURRENT_DB_VERSION +from workedon.exceptions import DBInitializationError +from workedon.models import Tag, Work, WorkTag, init_db + + +@pytest.fixture(autouse=True) +def configure_settings(monkeypatch: pytest.MonkeyPatch) -> None: + settings.configure() + monkeypatch.setattr(settings, "TIME_ZONE", "UTC") + monkeypatch.setattr(settings, "internal_tz", "UTC") + + +def test_get_or_create_db_creates_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + db_path = tmp_path / "won.db" + monkeypatch.setattr(models, "DB_PATH", db_path) + + db = models._get_or_create_db() + try: + assert db_path.exists() + assert db.database == str(db_path) + finally: + db.close() + + +def test_get_or_create_db_uses_existing_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + db_path = tmp_path / "won.db" + db_path.parent.mkdir(parents=True, exist_ok=True) + db_path.write_text("") + monkeypatch.setattr(models, "DB_PATH", db_path) + + db = models._get_or_create_db() + try: + assert db_path.exists() + assert db.database == str(db_path) + finally: + db.close() + + +def test_get_and_set_db_user_version(tmp_path: Path) -> None: + db = SqliteDatabase(str(tmp_path / "version.db")) + db.connect() + try: + models._set_db_user_version(db, 5) + assert models.get_db_user_version(db) == 5 + finally: + db.close() + + +def test_apply_pending_migrations_from_zero(tmp_path: Path) -> None: + db = SqliteDatabase(str(tmp_path / "fresh.db")) + db.connect() + try: + with db.bind_ctx([Work, Tag, WorkTag]): + models._apply_pending_migrations(db) + assert models.get_db_user_version(db) == CURRENT_DB_VERSION + assert {"work", "tag", "work_tag"}.issubset(set(db.get_tables())) + finally: + db.close() + + +def test_apply_pending_migrations_from_v1(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + db = SqliteDatabase(str(tmp_path / "v1-pending.db")) + db.connect() + try: + db.execute_sql( + "CREATE TABLE work (uuid TEXT PRIMARY KEY, created DATETIME, work TEXT, " + "timestamp DATETIME);" + ) + models._set_db_user_version(db, 1) + + def fake_migrate_v2_to_v3(database: SqliteDatabase) -> None: + models._set_db_user_version(database, CURRENT_DB_VERSION) + + monkeypatch.setattr(models, "_migrate_v2_to_v3", fake_migrate_v2_to_v3) + with db.bind_ctx([Work, Tag, WorkTag]): + models._apply_pending_migrations(db) + + assert models.get_db_user_version(db) == CURRENT_DB_VERSION + cols = [row[1] for row in db.execute_sql("PRAGMA table_info(work);").fetchall()] + assert "duration" in cols + assert {"tag", "work_tag"}.issubset(set(db.get_tables())) + finally: + db.close() + + +def test_apply_pending_migrations_from_v2(tmp_path: Path) -> None: + db = SqliteDatabase(str(tmp_path / "v2-pending.db")) + db.connect() + try: + db.execute_sql( + "CREATE TABLE work (uuid TEXT PRIMARY KEY, created DATETIME, work TEXT, " + "timestamp DATETIME, duration REAL);" + ) + db.execute_sql("CREATE TABLE tag (uuid TEXT PRIMARY KEY, name TEXT, created DATETIME);") + db.execute_sql("CREATE TABLE work_tag (work TEXT, tag TEXT);") + models._set_db_user_version(db, 2) + with db.bind_ctx([Work, Tag, WorkTag]): + models._apply_pending_migrations(db) + + assert models.get_db_user_version(db) == CURRENT_DB_VERSION + indexes = [row[1] for row in db.execute_sql("PRAGMA index_list(work);").fetchall()] + assert any("duration" in name for name in indexes) + finally: + db.close() + + +def test_migrate_v1_to_v2_adds_tables_and_duration(tmp_path: Path) -> None: + db = SqliteDatabase(str(tmp_path / "v1.db")) + db.connect() + try: + db.execute_sql( + "CREATE TABLE work (uuid TEXT PRIMARY KEY, created DATETIME, work TEXT, " + "timestamp DATETIME);" + ) + models._set_db_user_version(db, 1) + with db.bind_ctx([Work, Tag, WorkTag]): + models._migrate_v1_to_v2(db) + + assert models.get_db_user_version(db) == 2 + assert {"tag", "work_tag"}.issubset(set(db.get_tables())) + cols = [row[1] for row in db.execute_sql("PRAGMA table_info(work);").fetchall()] + assert "duration" in cols + finally: + db.close() + + +def test_migrate_v2_to_v3_adds_duration_index(tmp_path: Path) -> None: + db = SqliteDatabase(str(tmp_path / "v2.db")) + db.connect() + try: + db.execute_sql( + "CREATE TABLE work (uuid TEXT PRIMARY KEY, created DATETIME, work TEXT, " + "timestamp DATETIME, duration REAL);" + ) + db.execute_sql("CREATE TABLE tag (uuid TEXT PRIMARY KEY, name TEXT, created DATETIME);") + db.execute_sql("CREATE TABLE work_tag (work TEXT, tag TEXT);") + models._set_db_user_version(db, 2) + with db.bind_ctx([Work, Tag, WorkTag]): + models._migrate_v2_to_v3(db) + + assert models.get_db_user_version(db) == CURRENT_DB_VERSION + indexes = [row[1] for row in db.execute_sql("PRAGMA index_list(work);").fetchall()] + assert any("duration" in name for name in indexes) + finally: + db.close() + + +def test_apply_pending_migrations_raises_on_mismatch( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + db = SqliteDatabase(str(tmp_path / "bad.db")) + db.connect() + try: + with db.bind_ctx([Work, Tag, WorkTag]): + monkeypatch.setattr(models, "get_db_user_version", lambda *_: CURRENT_DB_VERSION + 1) + with pytest.raises(DBInitializationError): + models._apply_pending_migrations(db) + finally: + db.close() + + +def test_apply_pending_migrations_wraps_operational_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + db = SqliteDatabase(str(tmp_path / "bad-op.db")) + db.connect() + try: + with db.bind_ctx([Work, Tag, WorkTag]): + + def raise_operational_error(*_args: object, **_kwargs: object) -> None: + raise OperationalError("fail") + + monkeypatch.setattr(db, "execute_sql", raise_operational_error) + with pytest.raises(DBInitializationError) as excinfo: + models._apply_pending_migrations(db) + assert "fail" in str(excinfo.value) + finally: + db.close() + + +def test_truncate_all_tables_clears_rows() -> None: + with init_db(): + work = Work.create(work="cleanup test") + tag = Tag.create(name="cleanup") + WorkTag.create(work=work.uuid, tag=tag.uuid) + + models.truncate_all_tables() + + assert Work.select().count() == 0 + assert Tag.select().count() == 0 + assert WorkTag.select().count() == 0 + + +def test_tag_str_and_work_text_only_str() -> None: + tag = Tag(name="alpha") + assert "* alpha" in str(tag) + + work = Work(uuid=None, work="text only") + assert "* text only" in str(work) + + +# ---edge-cases--- + + +def test_work_requires_uuid() -> None: + with init_db(), pytest.raises(IntegrityError): + Work.create(uuid=None, work="test") + + +def test_work_requires_work_text() -> None: + with init_db(), pytest.raises(IntegrityError): + Work.create(work=None) + + +def test_work_allows_null_duration() -> None: + with init_db(): + work = Work.create(work="test work", duration=None) + assert work.duration is None + + +def test_work_allows_zero_duration() -> None: + with init_db(): + work = Work.create(work="test work", duration=0) + assert work.duration == 0 + + +def test_work_allows_large_duration() -> None: + with init_db(): + work = Work.create(work="test work", duration=999999.99) + assert work.duration == 999999.99 + + +def test_work_string_representation_with_no_tags() -> None: + with init_db(): + work = Work.create(work="simple work") + output = str(work) + assert "simple work" in output + assert "Tags:" not in output + + +def test_work_string_representation_with_no_duration() -> None: + with init_db(): + work = Work.create(work="simple work", duration=None) + output = str(work) + assert "Duration:" not in output + + +def test_tag_requires_unique_name() -> None: + with init_db(): + Tag.create(name="unique") + with pytest.raises(IntegrityError): + Tag.create(name="unique") + + +def test_work_tag_cascade_delete() -> None: + with init_db(): + work = Work.create(work="test work") + tag = Tag.create(name="test_tag") + WorkTag.create(work=work, tag=tag) + + # Delete work should cascade to WorkTag + work.delete_instance() + assert WorkTag.select().where(WorkTag.work == work.uuid).count() == 0 + + +def test_work_tag_requires_both_keys() -> None: + with init_db(): + work = Work.create(work="test") + with pytest.raises((IntegrityError, TypeError)): + WorkTag.create(work=work) + + +def test_work_with_very_long_text() -> None: + with init_db(): + long_text = "a" * 100000 + work = Work.create(work=long_text) + assert work.work == long_text + + +def test_work_with_special_characters() -> None: + with init_db(): + special_text = "Test with symbols !@# and\nnewlines\tand\ttabs" + work = Work.create(work=special_text) + assert work.work == special_text + + +def test_multiple_tags_per_work() -> None: + with init_db(): + work = Work.create(work="test work") + tags = [Tag.create(name=f"tag{i}") for i in range(10)] + + for tag in tags: + WorkTag.create(work=work, tag=tag) + + assert len(list(work.tags)) == 10 + + +def test_same_tag_multiple_works() -> None: + with init_db(): + tag = Tag.create(name="shared") + works = [Work.create(work=f"work{i}") for i in range(5)] + + for work in works: + WorkTag.create(work=work, tag=tag) + + assert len(list(tag.works)) == 5 diff --git a/tests/test_parser.py b/tests/test_parser.py new file mode 100644 index 0000000..486255d --- /dev/null +++ b/tests/test_parser.py @@ -0,0 +1,435 @@ +from datetime import datetime, timedelta +import zoneinfo + +from freezegun import freeze_time +import pytest + +from workedon.conf import settings +from workedon.exceptions import ( + DateTimeInFutureError, + InvalidDateTimeError, + InvalidWorkError, +) +from workedon.parser import InputParser +from workedon.utils import now + + +@pytest.fixture(autouse=True) +def configure_settings() -> None: + # Ensure defaults are loaded so timezone-aware parsing works in unit tests. + settings.configure() + + +def test_parse_datetime_defaults_to_now() -> None: + parser = InputParser() + assert parser.parse_datetime("") == now() + + +def test_parse_datetime_future_time_moves_to_previous_day() -> None: + with freeze_time("2024-01-02 10:00:00"): + parser = InputParser() + parsed = parser.parse_datetime("11:30pm") + assert parsed.hour == 23 + assert parsed.minute == 30 + assert parsed.date() == (now() - timedelta(days=1)).date() + + +def test_parse_duration_handles_hours_and_minutes() -> None: + parser = InputParser() + assert parser.parse_duration("[1.234h]") == 74.04 + assert parser.parse_duration("[ 45 MINs ]") == 45 + + +def test_clean_work_strips_tags_and_duration() -> None: + parser = InputParser() + cleaned = parser.clean_work(" Fix bug [30m] #dev #QA ") + assert cleaned == "Fix bug" + + +def test_parse_requires_non_empty_work_text() -> None: + parser = InputParser() + with pytest.raises(InvalidWorkError): + parser.parse("#devops #prod @ yesterday") + + +def test_parse_extracts_all_components() -> None: + parser = InputParser() + work, dt, duration, tags = parser.parse("Write docs [90m] #Dev #Docs @ yesterday") + assert work == "Write docs" + assert duration == 90 + assert tags == {"Dev", "Docs"} + assert dt.date() == (now() - timedelta(days=1)).date() + + +def test_as_datetime_returns_none_when_parser_has_no_result( + monkeypatch: pytest.MonkeyPatch, +) -> None: + parser = InputParser() + monkeypatch.setattr(parser._date_parser, "get_date_data", lambda *_: None) + assert parser._as_datetime("not a date") is None + + +def test_parse_duration_returns_none_for_unknown_unit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + parser = InputParser() + monkeypatch.setattr( + parser, + "_DURATION_REGEX", + r"\[\s*(\d+(?:\.\d+)?)\s*(sec)\s*\]", + ) + assert parser.parse_duration("[5 sec]") is None + + +# ---edge-cases--- + + +@pytest.fixture +def parser() -> InputParser: + return InputParser() + + +@pytest.mark.parametrize( + "input_str", + [ + "tomorrow at 5pm", + "next week", + "in 3 days", + "2099-12-31", + ], +) +def test_parse_datetime_future_raises_error(parser: InputParser, input_str: str) -> None: + with pytest.raises(DateTimeInFutureError): + parser.parse_datetime(input_str) + + +@pytest.mark.parametrize( + "input_str", + [ + "!@#$%^&*()", + "asdfghjkl", + "random gibberish", + "123abc456def", + ], +) +def test_parse_datetime_invalid_string_raises_error(parser: InputParser, input_str: str) -> None: + with pytest.raises(InvalidDateTimeError): + parser.parse_datetime(input_str) + + +def test_parse_datetime_whitespace_only_returns_now(parser: InputParser) -> None: + assert parser.parse_datetime(" ") == now() + assert parser.parse_datetime("\t\n") == now() + + +@pytest.mark.parametrize( + "input_str", + [ + "midnight", + "noon", + "3am", + "11:59pm", + "00:00", + "23:59", + ], +) +def test_parse_datetime_various_time_formats(parser: InputParser, input_str: str) -> None: + result = parser.parse_datetime(input_str) + assert result <= now() + + +def test_parse_datetime_edge_of_midnight(monkeypatch: pytest.MonkeyPatch) -> None: + # We avoid `freeze_time` here because the session-wide freeze already sets a "now". + # In a prior version of this test, `InputParser` was created outside the inner + # freeze_time block via the `parser` fixture, while `now()` in the assertion used + # the inner freeze. That created two different "now" values and a flaky mismatch. + # By stubbing `workedon.parser.now` directly, both parsing and assertions share + # the same time source and the midnight edge case stays deterministic. + monkeypatch.setattr(settings, "TIME_ZONE", "UTC") + midnight_plus = datetime(2024, 1, 15, 0, 1, tzinfo=zoneinfo.ZoneInfo("UTC")) + monkeypatch.setattr("workedon.parser.now", lambda: midnight_plus) + parser = InputParser() + try: + result = parser.parse_datetime("11:59pm") + except DateTimeInFutureError: + assert True + else: + assert result.hour == 23 + assert result.minute == 59 + assert result.date() == (midnight_plus - timedelta(days=1)).date() + + +@pytest.mark.parametrize( + "relative_time", + [ + "1 second ago", + "30 seconds ago", + "1 minute ago", + "59 minutes ago", + "1 hour ago", + "23 hours ago", + ], +) +def test_parse_datetime_relative_times(parser: InputParser, relative_time: str) -> None: + result = parser.parse_datetime(relative_time) + assert result <= now() + + +@pytest.mark.parametrize( + "input_str, expected", + [ + ("[0.5h]", 30), + ("[0.25hr]", 15), + ("[0.1hours]", 6), + ("[1000m]", 1000), + ("[0.01h]", 0.6), + ("[99999min]", 99999), + ], +) +def test_parse_duration_edge_values(parser: InputParser, input_str: str, expected: float) -> None: + assert parser.parse_duration(input_str) == expected + + +@pytest.mark.parametrize( + "input_str", + [ + "[]", + "[h]", + "[min]", + "[hours]", + "[minutes]", + "[0h]", # Valid but zero + ], +) +def test_parse_duration_no_numeric_value(parser: InputParser, input_str: str) -> None: + result = parser.parse_duration(input_str) + assert result is None or result == 0 + + +@pytest.mark.parametrize( + "input_str", + [ + "[1.2.3h]", + "[1..5m]", + "[.5.h]", + "[-5h]", + "[+3m]", + ], +) +def test_parse_duration_malformed_numbers(parser: InputParser, input_str: str) -> None: + assert parser.parse_duration(input_str) is None + + +@pytest.mark.parametrize( + "input_str", + [ + "[3x]", + "[5d]", + "[2s]", + "[10k]", + "[1.5days]", + ], +) +def test_parse_duration_invalid_units(parser: InputParser, input_str: str) -> None: + assert parser.parse_duration(input_str) is None + + +def test_parse_duration_multiple_brackets_uses_first(parser: InputParser) -> None: + assert parser.parse_duration("[30m] [60m] [90m]") == 30 + + +def test_parse_duration_case_insensitive(parser: InputParser) -> None: + assert parser.parse_duration("[2H]") == 120 + assert parser.parse_duration("[2Hr]") == 120 + assert parser.parse_duration("[2HRS]") == 120 + assert parser.parse_duration("[30MIN]") == 30 + assert parser.parse_duration("[30Minutes]") == 30 + + +def test_parse_duration_with_spaces(parser: InputParser) -> None: + assert parser.parse_duration("[ 2 h ]") == 120 + assert parser.parse_duration("[\t30\tm\t]") == 30 + + +def test_parse_duration_no_brackets(parser: InputParser) -> None: + assert parser.parse_duration("30m") is None + assert parser.parse_duration("2h") is None + + +@pytest.mark.parametrize( + "input_str, expected", + [ + ("#tag1 #tag2 #tag3", {"tag1", "tag2", "tag3"}), + ("#TAG #Tag #tag", {"TAG", "Tag", "tag"}), # Case preserved + ("#a #b #c #a #b", {"a", "b", "c"}), + ("#123 #456", {"123", "456"}), + ("#under_score #dash-tag", {"under_score", "dash-tag"}), + ("#mix123abc", {"mix123abc"}), + ], +) +def test_parse_tags_various_formats(parser: InputParser, input_str: str, expected: set) -> None: + assert parser.parse_tags(input_str) == expected + + +@pytest.mark.parametrize( + "input_str", + [ + "no tags here", + "has # space", + "##", + "###", + "#", + ], +) +def test_parse_tags_no_valid_tags(parser: InputParser, input_str: str) -> None: + assert parser.parse_tags(input_str) == set() + + +@pytest.mark.parametrize( + "input_str, expected", + [ + ("#tag!", {"tag"}), # Stops at special char + ("#tag@email", {"tag"}), + ("#tag.with.dots", {"tag"}), + ("#tag(parentheses)", {"tag"}), + ("#tag[brackets]", {"tag"}), + ("#tag{braces}", {"tag"}), + ], +) +def test_parse_tags_with_special_chars(parser: InputParser, input_str: str, expected: set) -> None: + assert parser.parse_tags(input_str) == expected + + +def test_parse_tags_ignores_non_word_symbols(parser: InputParser) -> None: + assert parser.parse_tags("#$ #! #?") == set() + + +def test_parse_tags_back_to_back(parser: InputParser) -> None: + assert parser.parse_tags("#one#two#three") == {"one", "two", "three"} + + +def test_parse_tags_empty_string(parser: InputParser) -> None: + assert parser.parse_tags("") == set() + + +@pytest.mark.parametrize( + "input_str, expected", + [ + ("work [30m] #tag", "work"), + ("#tag1 #tag2 work", "work"), + ("work #tag [60m]", "work"), + ("[2h] #dev work #qa [30m]", "work [30m]"), + (" multiple spaces ", "multiple spaces"), + ("\ttabs\tand\nnewlines\n", "tabs and newlines"), + ], +) +def test_clean_work_removes_tags_and_duration( + parser: InputParser, input_str: str, expected: str +) -> None: + assert parser.clean_work(input_str) == expected + + +def test_clean_work_preserves_special_chars(parser: InputParser) -> None: + assert parser.clean_work("work with @mentions") == "work with @mentions" + assert parser.clean_work("work & more stuff") == "work & more stuff" + assert parser.clean_work("work (in parens)") == "work (in parens)" + + +def test_clean_work_empty_after_cleaning(parser: InputParser) -> None: + assert parser.clean_work("#tag1 #tag2 [30m]") == "" + assert parser.clean_work(" [2h] ") == "" + + +def test_parse_work_without_separator(parser: InputParser) -> None: + work, dt, duration, tags = parser.parse("simple work") + assert work == "simple work" + assert dt == now() + assert duration is None + assert tags == set() + + +def test_parse_work_with_all_components(parser: InputParser) -> None: + work, dt, duration, tags = parser.parse("complex work [90m] #dev #qa @ yesterday") + assert work == "complex work" + assert duration == 90 + assert tags == {"dev", "qa"} + assert dt.date() == (now() - timedelta(days=1)).date() + + +def test_parse_separator_in_work_text(parser: InputParser) -> None: + # Last @ should be the separator + work, dt, _duration, _tags = parser.parse("email to john@example.com @ yesterday") + assert work == "email to john@example.com" + assert dt.date() == (now() - timedelta(days=1)).date() + + +def test_parse_multiple_separators(parser: InputParser) -> None: + # Should partition on the last @ + work, dt, _duration, _tags = parser.parse("work @ 3pm @ yesterday") + assert work == "work @ 3pm" + assert dt.date() == (now() - timedelta(days=1)).date() + + +def test_parse_empty_work_after_cleaning_raises(parser: InputParser) -> None: + with pytest.raises(InvalidWorkError): + parser.parse("#tag [30m] @ yesterday") + + +def test_parse_separator_with_no_work_raises(parser: InputParser) -> None: + with pytest.raises(InvalidWorkError): + parser.parse("@ yesterday") + + +def test_parse_whitespace_only_raises(parser: InputParser) -> None: + with pytest.raises(InvalidWorkError): + parser.parse(" ") + + +def test_parse_multiple_durations_uses_first(parser: InputParser) -> None: + _work, _dt, duration, _tags = parser.parse("work [30m] [60m] [90m]") + assert duration == 30 + + +def test_parse_preserves_work_punctuation(parser: InputParser) -> None: + work, _, _, _ = parser.parse("Work! With? Punctuation.") + assert work == "Work! With? Punctuation." + + +@pytest.mark.parametrize( + "input_str", + [ + "work @ tomorrow", + "work @ next week", + "work @ in 5 days", + ], +) +def test_parse_future_datetime_raises(parser: InputParser, input_str: str) -> None: + with pytest.raises(DateTimeInFutureError): + parser.parse(input_str) + + +def test_parse_invalid_datetime_raises(parser: InputParser) -> None: + with pytest.raises(InvalidDateTimeError): + parser.parse("work @ gibberish datetime") + + +def test_parse_extremely_long_work_text(parser: InputParser) -> None: + long_text = "a" * 10000 + work, _, _, _ = parser.parse(long_text) + assert work == long_text + + +def test_parse_many_tags(parser: InputParser) -> None: + tags_str = " ".join(f"#tag{i}" for i in range(100)) + _work, _, _, tags = parser.parse(f"work {tags_str}") + assert len(tags) == 100 + + +def test_parse_very_precise_duration(parser: InputParser) -> None: + _work, _, duration, _ = parser.parse("work [1.123456789h]") + assert duration == 67.41 # Rounded to 2 decimals + + +def test_parse_zero_duration(parser: InputParser) -> None: + _work, _, duration, _ = parser.parse("work [0h]") + assert duration == 0 diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..a38c5a5 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,145 @@ +from datetime import datetime, timezone +from typing import cast +import zoneinfo + +import click +from freezegun import freeze_time +import pytest + +from workedon.conf import Settings, settings +from workedon.utils import ( + add_options, + get_default_time, + get_unique_hash, + load_settings, + now, + to_internal_dt, +) + + +def test_get_unique_hash_is_hex_and_unique() -> None: + first = get_unique_hash() + second = get_unique_hash() + assert first != second + assert len(first) == 32 + int(first, 16) + + +def test_to_internal_dt_trims_seconds_and_uses_internal_tz( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "internal_tz", "UTC") + dt = datetime(2024, 1, 1, 12, 34, 56, 123456, tzinfo=zoneinfo.ZoneInfo("UTC")) + result = to_internal_dt(dt) + assert result.second == 0 + assert result.microsecond == 0 + tzinfo = result.tzinfo + assert isinstance(tzinfo, zoneinfo.ZoneInfo) + assert tzinfo.key == "UTC" + + +def test_now_uses_settings_time_zone(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(settings, "TIME_ZONE", "UTC") + result = now() + tzinfo = result.tzinfo + assert isinstance(tzinfo, zoneinfo.ZoneInfo) + assert tzinfo.key == "UTC" + + +def test_get_default_time_matches_to_internal_dt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(settings, "internal_tz", "UTC") + monkeypatch.setattr(settings, "TIME_ZONE", "UTC") + result = get_default_time() + assert result.second == 0 + assert result.microsecond == 0 + + +def test_load_settings_merges_uppercase_kwargs(monkeypatch: pytest.MonkeyPatch) -> None: + captured: dict[str, str] = {} + + def fake_configure(self: Settings, *, user_settings: dict[str, str] | None = None) -> None: + captured.update(user_settings or {}) + self.update(user_settings or {}) + + monkeypatch.setattr(type(settings), "configure", fake_configure) + + @load_settings + def handler(**kwargs: str) -> str: + return cast(str, settings.DATE_FORMAT) + + result = handler(DATE_FORMAT="%Y-%m-%d", lower="skip") + assert captured == {"DATE_FORMAT": "%Y-%m-%d"} + assert result == "%Y-%m-%d" + + +def test_load_settings_wraps_errors_in_click_exception( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def noop_configure(*_args: object, **_kwargs: object) -> None: + return None + + monkeypatch.setattr(type(settings), "configure", noop_configure) + + @load_settings + def handler() -> None: + raise ValueError("boom") + + with pytest.raises(click.ClickException) as excinfo: + handler() + assert "boom" in str(excinfo.value) + + +def test_add_options_applies_click_options() -> None: + options = [ + click.option("--alpha", is_flag=True, default=False), + click.option("--beta", type=click.INT, default=1), + ] + + @add_options(options) + def handler() -> None: + return None + + assert hasattr(handler, "__click_params__") + assert {param.name for param in handler.__click_params__} == {"alpha", "beta"} + + +# ---edge-cases--- + + +def test_now_returns_timezone_aware() -> None: + settings.configure() + current = now() + assert current.tzinfo is not None + + +def test_to_internal_dt_converts_timezone() -> None: + settings.configure() + dt = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + internal = to_internal_dt(dt) + assert internal.tzinfo == zoneinfo.ZoneInfo(settings.internal_tz) + + +def test_to_internal_dt_preserves_date() -> None: + dt = datetime(2024, 6, 15, 23, 59, 59, tzinfo=timezone.utc) + internal = to_internal_dt(dt) + # Date might shift due to timezone conversion + assert abs((internal.date() - dt.date()).days) <= 1 + + +def test_now_frozen_time(monkeypatch: pytest.MonkeyPatch) -> None: + settings.configure() + monkeypatch.setattr(settings, "TIME_ZONE", "UTC") + with freeze_time("2024-01-15 12:00:00"): + current = now() + assert current.hour == 12 + assert current.minute == 0 + + +def test_to_internal_dt_sets_internal_timezone_for_naive_datetime() -> None: + settings.configure() + dt = datetime(2024, 1, 1, 12, 0, 0) # No timezone + internal = to_internal_dt(dt) + assert internal.tzinfo is not None + assert internal.tzinfo == zoneinfo.ZoneInfo(settings.internal_tz) diff --git a/tests/test_version_main.py b/tests/test_version_main.py new file mode 100644 index 0000000..d319e4e --- /dev/null +++ b/tests/test_version_main.py @@ -0,0 +1,37 @@ +import importlib +import importlib.metadata +import runpy + +import pytest + +import workedon +import workedon._version +import workedon.cli + + +def test_package_exports() -> None: + assert workedon.__all__ == ["__version__", "main"] + assert workedon.main is workedon.cli.main + assert isinstance(workedon.__version__, str) + assert workedon.__version__ + + +def test_version_fallback_when_package_missing(monkeypatch: pytest.MonkeyPatch) -> None: + def raise_not_found(_name: str) -> str: + raise importlib.metadata.PackageNotFoundError("workedon") + + monkeypatch.setattr(importlib.metadata, "version", raise_not_found) + fallback = importlib.reload(workedon._version) + assert fallback.__version__ == "0.0.0" + importlib.reload(workedon._version) + + +def test___main__invokes_cli_main(monkeypatch: pytest.MonkeyPatch) -> None: + called = {"value": False} + + def fake_main() -> None: + called["value"] = True + + monkeypatch.setattr(workedon.cli, "main", fake_main) + runpy.run_module("workedon.__main__", run_name="__main__") + assert called["value"] is True diff --git a/tests/test_workedon.py b/tests/test_workedon.py new file mode 100644 index 0000000..b05d899 --- /dev/null +++ b/tests/test_workedon.py @@ -0,0 +1,374 @@ +from __future__ import annotations + +from collections.abc import Generator, Iterable, Iterator +import contextlib +from datetime import datetime, timedelta +from typing import cast + +import click +from freezegun import freeze_time +import pytest + +from workedon import workedon +from workedon.conf import settings +from workedon.exceptions import ( + CannotFetchWorkError, + CannotSaveWorkError, + StartDateAbsentError, + StartDateGreaterError, +) +from workedon.models import Tag, Work, WorkTag, init_db + + +@pytest.fixture(autouse=True) +def configure_settings(monkeypatch: pytest.MonkeyPatch) -> None: + settings.configure() + monkeypatch.setattr(settings, "TIME_ZONE", "UTC") + monkeypatch.setattr(settings, "internal_tz", "UTC") + monkeypatch.setattr(settings, "DURATION_UNIT", "minutes") + + +class DummyWork: + def __init__(self, value: str) -> None: + self.value = value + + def __str__(self) -> str: + return self.value + + +def test_generate_work_yields_strings() -> None: + items = [DummyWork("first"), DummyWork("second")] + work_iter = cast(Iterator[Work], iter(items)) + assert list(workedon._generate_work(work_iter)) == ["first", "second"] + + +def test_chunked_prefetch_generator_text_only() -> None: + with init_db(): + Work.create(work="alpha") + Work.create(work="beta") + + work_set = Work.select(Work.work) + output = "".join(workedon.chunked_prefetch_generator(work_set, [Work.work], True)) + + assert "* alpha" in output + assert "* beta" in output + assert "id:" not in output + + +def test_chunked_prefetch_generator_prefetches_tags() -> None: + with init_db(): + work = Work.create(work="tagged entry") + tag = Tag.create(name="tag1") + WorkTag.create(work=work.uuid, tag=tag.uuid) + + work_set = Work.select(Work.uuid, Work.timestamp, Work.work, Work.duration) + output = "".join( + workedon.chunked_prefetch_generator( + work_set, + [Work.uuid, Work.timestamp, Work.work, Work.duration], + False, + ) + ) + + assert "Tags:" in output + assert "tag1" in output + + +def test_chunked_prefetch_generator_skips_missing_work( + monkeypatch: pytest.MonkeyPatch, +) -> None: + with init_db(): + Work.create(work="missing prefetch") + work_set = Work.select(Work.uuid, Work.timestamp, Work.work, Work.duration) + + monkeypatch.setattr(workedon, "prefetch", lambda *_args, **_kwargs: []) + output = list( + workedon.chunked_prefetch_generator( + work_set, + [Work.uuid, Work.timestamp, Work.work, Work.duration], + False, + ) + ) + + assert output == [] + + +def test_get_date_range_requires_start_when_end_provided() -> None: + with pytest.raises(StartDateAbsentError): + workedon._get_date_range("", "yesterday", "", None, None, None) + + +def test_get_date_range_raises_when_start_after_end() -> None: + with pytest.raises(StartDateGreaterError): + workedon._get_date_range("yesterday", "2 days ago", "", None, None, None) + + +def test_get_date_range_at_returns_single_point() -> None: + start, end = workedon._get_date_range("", "", "", None, None, "3pm yesterday") + assert start == end + + +def test_get_date_range_yesterday_period() -> None: + with freeze_time("2024-01-05 12:00:00"): + start, end = workedon._get_date_range("", "", "", "yesterday", None, None) + assert start.date() == datetime(2024, 1, 4).date() + assert end.date() == datetime(2024, 1, 4).date() + assert (end - start) >= timedelta(hours=23, minutes=59) + + +def test_get_date_range_since_uses_now_as_end() -> None: + with freeze_time("2024-01-05 12:00:00"): + start, end = workedon._get_date_range("", "", "yesterday", None, None, None) + assert start.date() == datetime(2024, 1, 4).date() + assert end.date() == datetime(2024, 1, 5).date() + + +def test_save_work_creates_tags_from_option() -> None: + workedon.save_work(("build", "feature"), ("DevOps",), "") + with init_db(): + assert Tag.select().where(Tag.name == "devops").exists() + assert WorkTag.select().count() == 1 + + +def test_save_work_ignores_empty_tag_option() -> None: + workedon.save_work(("build", "feature"), ("", " ", "dev"), "") + with init_db(): + assert Tag.select().where(Tag.name == "dev").exists() + assert Tag.select().where(Tag.name == "").count() == 0 + assert WorkTag.select().count() == 1 + + +def test_normalize_tags_trims_lowercases_dedupes_and_drops_empty() -> None: + result = workedon._normalize_tags([" Dev ", "DEV", "dev", "", " "]) + assert result == {"dev"} + + +def test_save_work_raises_cannot_save_on_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def raise_error(*_args: object, **_kwargs: object) -> None: + raise RuntimeError("boom") + + monkeypatch.setattr(workedon.Work, "create", raise_error) + with pytest.raises(CannotSaveWorkError) as excinfo: + workedon.save_work(("fail",), (), "") + assert "boom" in str(excinfo.value) + + +def test_fetch_work_raises_cannot_fetch_on_db_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class DBDownError(RuntimeError): + def __init__(self) -> None: + super().__init__("db down") + + class BrokenDB: + def __enter__(self) -> None: + raise DBDownError() + + def __exit__(self, *_args: object) -> None: + return None + + monkeypatch.setattr(workedon, "init_db", lambda: BrokenDB()) + with pytest.raises(CannotFetchWorkError) as excinfo: + workedon.fetch_work( + count=None, + work_id="", + start_date="", + end_date="", + since="", + period=None, + on=None, + at=None, + delete=False, + no_page=True, + reverse=False, + text_only=False, + tags=(), + duration="", + ) + assert "db down" in str(excinfo.value) + + +def test_fetch_work_delete_declined_keeps_data( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class DummySelect: + def where(self, *_args: object, **_kwargs: object) -> DummySelect: + return self + + def order_by(self, *_args: object, **_kwargs: object) -> DummySelect: + return self + + def limit(self, *_args: object, **_kwargs: object) -> DummySelect: + return self + + def exists(self) -> bool: + return True + + class DummyDelete: + def where(self, *_args: object, **_kwargs: object) -> DummyDelete: + return self + + def execute(self) -> int: + return 0 + + deleted = {"called": False} + + def fake_delete() -> DummyDelete: + deleted["called"] = True + return DummyDelete() + + @contextlib.contextmanager + def fake_db() -> Generator[None, None, None]: + yield None + + def fake_select(*_args: object, **_kwargs: object) -> DummySelect: + return DummySelect() + + def fake_confirm(*_args: object, **_kwargs: object) -> bool: + return False + + monkeypatch.setattr(workedon.Work, "select", fake_select) + monkeypatch.setattr(workedon.Work, "delete", fake_delete) + monkeypatch.setattr(workedon, "init_db", fake_db) + monkeypatch.setattr(click, "confirm", fake_confirm) + + workedon.fetch_work( + count=None, + work_id="", + start_date="", + end_date="", + since="", + period=None, + on=None, + at=None, + delete=True, + no_page=True, + reverse=False, + text_only=False, + tags=(), + duration="", + ) + + assert deleted["called"] is False + + +def test_fetch_work_uses_pager_for_multiple_rows( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, list[str]] = {} + + def fake_pager(gen: Iterable[str]) -> None: + captured["output"] = list(gen) + + class DummySelect: + def where(self, *_args: object, **_kwargs: object) -> DummySelect: + return self + + def order_by(self, *_args: object, **_kwargs: object) -> DummySelect: + return self + + def limit(self, *_args: object, **_kwargs: object) -> DummySelect: + return self + + def exists(self) -> bool: + return True + + def count(self) -> int: + return 2 + + @contextlib.contextmanager + def fake_db() -> Generator[None, None, None]: + yield None + + def fake_generator(*_args: object, **_kwargs: object) -> Iterator[str]: + return iter(["id: first\n", "id: second\n"]) + + monkeypatch.setattr(click, "echo_via_pager", fake_pager) + + def fake_select(*_args: object, **_kwargs: object) -> DummySelect: + return DummySelect() + + monkeypatch.setattr(workedon.Work, "select", fake_select) + monkeypatch.setattr(workedon, "chunked_prefetch_generator", fake_generator) + monkeypatch.setattr(workedon, "init_db", fake_db) + workedon.fetch_work( + count=None, + work_id="", + start_date="", + end_date="", + since="", + period=None, + on=None, + at=None, + delete=False, + no_page=False, + reverse=False, + text_only=False, + tags=(), + duration="", + ) + + assert any("id:" in line for line in captured.get("output", [])) + + +def test_fetch_tags_returns_saved_tags(monkeypatch: pytest.MonkeyPatch) -> None: + class DummyTag: + name = "alpha" + + monkeypatch.setattr(workedon.Tag, "select", lambda *_args, **_kwargs: [DummyTag()]) + tags = [tag.name for tag in workedon.fetch_tags()] + assert tags == ["alpha"] + + +def test_fetch_work_rejects_invalid_duration_filter( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(workedon.re, "match", lambda *_args, **_kwargs: None) + with pytest.raises(CannotFetchWorkError) as excinfo: + workedon.fetch_work( + count=None, + work_id="", + start_date="", + end_date="", + since="", + period=None, + on=None, + at=None, + delete=False, + no_page=True, + reverse=False, + text_only=False, + tags=(), + duration="n/a", + ) + assert "Invalid duration filter" in str(excinfo.value) + + +def test_fetch_work_rejects_invalid_duration_operator( + monkeypatch: pytest.MonkeyPatch, +) -> None: + class DummyMatch: + def groups(self) -> tuple[str, str]: + return "?!", "5m" + + monkeypatch.setattr(workedon.re, "match", lambda *_args, **_kwargs: DummyMatch()) + with pytest.raises(CannotFetchWorkError) as excinfo: + workedon.fetch_work( + count=None, + work_id="", + start_date="", + end_date="", + since="", + period=None, + on=None, + at=None, + delete=False, + no_page=True, + reverse=False, + text_only=False, + tags=(), + duration="?!5m", + ) + assert "Invalid duration operator" in str(excinfo.value) diff --git a/uv.lock b/uv.lock index 7390b3c..d74b580 100644 --- a/uv.lock +++ b/uv.lock @@ -736,11 +736,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.20.1" +version = "3.20.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/23/ce7a1126827cedeb958fc043d61745754464eb56c5937c35bbf2b8e26f34/filelock-3.20.1.tar.gz", hash = "sha256:b8360948b351b80f420878d8516519a2204b07aefcdcfd24912a5d33127f188c", size = 19476, upload-time = "2025-12-15T23:54:28.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/7f/a1a97644e39e7316d850784c642093c99df1290a460df4ede27659056834/filelock-3.20.1-py3-none-any.whl", hash = "sha256:15d9e9a67306188a44baa72f569d2bfd803076269365fdea0934385da4dc361a", size = 16666, upload-time = "2025-12-15T23:54:26.874Z" }, + { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, ] [[package]] @@ -990,14 +990,14 @@ wheels = [ [[package]] name = "jaraco-context" -version = "6.0.1" +version = "6.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/9c/a788f5bb29c61e456b8ee52ce76dbdd32fd72cd73dd67bc95f42c7a8d13c/jaraco_context-6.1.0.tar.gz", hash = "sha256:129a341b0a85a7db7879e22acd66902fda67882db771754574338898b2d5d86f", size = 15850, upload-time = "2026-01-13T02:53:53.847Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/db/0c52c4cf5e4bd9f5d7135ec7669a3a767af21b3a308e1ed3674881e52b62/jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4", size = 6825, upload-time = "2024-08-20T03:39:25.966Z" }, + { url = "https://files.pythonhosted.org/packages/8d/48/aa685dbf1024c7bd82bede569e3a85f82c32fd3d79ba5fea578f0159571a/jaraco_context-6.1.0-py3-none-any.whl", hash = "sha256:a43b5ed85815223d0d3cfdb6d7ca0d2bc8946f28f30b6f3216bda070f68badda", size = 7065, upload-time = "2026-01-13T02:53:53.031Z" }, ] [[package]] @@ -1273,11 +1273,11 @@ wheels = [ [[package]] name = "pip" -version = "25.3" +version = "26.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/fe/6e/74a3f0179a4a73a53d66ce57fdb4de0080a8baa1de0063de206d6167acc2/pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343", size = 1803014, upload-time = "2025-10-25T00:55:41.394Z" } +sdist = { url = "https://files.pythonhosted.org/packages/44/c2/65686a7783a7c27a329706207147e82f23c41221ee9ae33128fc331670a0/pip-26.0.tar.gz", hash = "sha256:3ce220a0a17915972fbf1ab451baae1521c4539e778b28127efa79b974aff0fa", size = 1812654, upload-time = "2026-01-31T01:40:54.361Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd", size = 1778622, upload-time = "2025-10-25T00:55:39.247Z" }, + { url = "https://files.pythonhosted.org/packages/69/00/5ac7aa77688ec4d34148b423d34dc0c9bc4febe0d872a9a1ad9860b2f6f1/pip-26.0-py3-none-any.whl", hash = "sha256:98436feffb9e31bc9339cf369fd55d3331b1580b6a6f1173bacacddcf9c34754", size = 1787564, upload-time = "2026-01-31T01:40:52.252Z" }, ] [[package]] @@ -2013,27 +2013,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/9d/59e955cc39206a0d58df5374808785c45ec2a8a2a230eb1638fbb4fe5c5d/ty-0.0.8.tar.gz", hash = "sha256:352ac93d6e0050763be57ad1e02087f454a842887e618ec14ac2103feac48676", size = 4828477, upload-time = "2025-12-29T13:50:07.193Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/2b/dd61f7e50a69c72f72c625d026e9ab64a0db62b2dd32e7426b520e2429c6/ty-0.0.8-py3-none-linux_armv6l.whl", hash = "sha256:a289d033c5576fa3b4a582b37d63395edf971cdbf70d2d2e6b8c95638d1a4fcd", size = 9853417, upload-time = "2025-12-29T13:50:08.979Z" }, - { url = "https://files.pythonhosted.org/packages/90/72/3f1d3c64a049a388e199de4493689a51fc6aa5ff9884c03dea52b4966657/ty-0.0.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:788ea97dc8153a94e476c4d57b2551a9458f79c187c4aba48fcb81f05372924a", size = 9657890, upload-time = "2025-12-29T13:50:27.867Z" }, - { url = "https://files.pythonhosted.org/packages/71/d1/08ac676bd536de3c2baba0deb60e67b3196683a2fabebfd35659d794b5e9/ty-0.0.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1b5f1f3d3e230f35a29e520be7c3d90194a5229f755b721e9092879c00842d31", size = 9180129, upload-time = "2025-12-29T13:50:22.842Z" }, - { url = "https://files.pythonhosted.org/packages/af/93/610000e2cfeea1875900f73a375ba917624b0a008d4b8a6c18c894c8dbbc/ty-0.0.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6da9ed377fbbcec0a3b60b2ca5fd30496e15068f47cef2344ba87923e78ba996", size = 9683517, upload-time = "2025-12-29T13:50:18.658Z" }, - { url = "https://files.pythonhosted.org/packages/05/04/bef50ba7d8580b0140be597de5cc0ba9a63abe50d3f65560235f23658762/ty-0.0.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7d0a2bdce5e701d19eb8d46d9da0fe31340f079cecb7c438f5ac6897c73fc5ba", size = 9676279, upload-time = "2025-12-29T13:50:25.207Z" }, - { url = "https://files.pythonhosted.org/packages/aa/b9/2aff1ef1f41b25898bc963173ae67fc8f04ca666ac9439a9c4e78d5cc0ff/ty-0.0.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef9078799d26d3cc65366e02392e2b78f64f72911b599e80a8497d2ec3117ddb", size = 10073015, upload-time = "2025-12-29T13:50:35.422Z" }, - { url = "https://files.pythonhosted.org/packages/df/0e/9feb6794b6ff0a157c3e6a8eb6365cbfa3adb9c0f7976e2abdc48615dd72/ty-0.0.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:54814ac39b4ab67cf111fc0a236818155cf49828976152378347a7678d30ee89", size = 10961649, upload-time = "2025-12-29T13:49:58.717Z" }, - { url = "https://files.pythonhosted.org/packages/f4/3b/faf7328b14f00408f4f65c9d01efe52e11b9bcc4a79e06187b370457b004/ty-0.0.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c4baf0a80398e8b6c68fa36ff85045a50ede1906cd4edb41fb4fab46d471f1d4", size = 10676190, upload-time = "2025-12-29T13:50:01.11Z" }, - { url = "https://files.pythonhosted.org/packages/64/a5/cfeca780de7eeab7852c911c06a84615a174d23e9ae08aae42a645771094/ty-0.0.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ac8e23c3faefc579686799ef1649af8d158653169ad5c3a7df56b152781eeb67", size = 10438641, upload-time = "2025-12-29T13:50:29.664Z" }, - { url = "https://files.pythonhosted.org/packages/0e/8d/8667c7e0ac9f13c461ded487c8d7350f440cd39ba866d0160a8e1b1efd6c/ty-0.0.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b558a647a073d0c25540aaa10f8947de826cb8757d034dd61ecf50ab8dbd77bf", size = 10214082, upload-time = "2025-12-29T13:50:31.531Z" }, - { url = "https://files.pythonhosted.org/packages/f8/11/e563229870e2c1d089e7e715c6c3b7605a34436dddf6f58e9205823020c2/ty-0.0.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:8c0104327bf480508bd81f320e22074477df159d9eff85207df39e9c62ad5e96", size = 9664364, upload-time = "2025-12-29T13:50:05.443Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ad/05b79b778bf5237bcd7ee08763b226130aa8da872cbb151c8cfa2e886203/ty-0.0.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:496f1cb87261dd1a036a5609da80ee13de2e6ee4718a661bfa2afb91352fe528", size = 9679440, upload-time = "2025-12-29T13:50:11.289Z" }, - { url = "https://files.pythonhosted.org/packages/12/b5/23ba887769c4a7b8abfd1b6395947dc3dcc87533fbf86379d3a57f87ae8f/ty-0.0.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2c488031f92a075ae39d13ac6295fdce2141164ec38c5d47aa8dc24ee3afa37e", size = 9808201, upload-time = "2025-12-29T13:50:21.003Z" }, - { url = "https://files.pythonhosted.org/packages/f8/90/5a82ac0a0707db55376922aed80cd5fca6b2e6d6e9bcd8c286e6b43b4084/ty-0.0.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:90d6f08c5982fa3e802b8918a32e326153519077b827f91c66eea4913a86756a", size = 10313262, upload-time = "2025-12-29T13:50:03.306Z" }, - { url = "https://files.pythonhosted.org/packages/14/f7/ff97f37f0a75db9495ddbc47738ec4339837867c4bfa145bdcfbd0d1eb2f/ty-0.0.8-py3-none-win32.whl", hash = "sha256:d7f460ad6fc9325e9cc8ea898949bbd88141b4609d1088d7ede02ce2ef06e776", size = 9254675, upload-time = "2025-12-29T13:50:33.35Z" }, - { url = "https://files.pythonhosted.org/packages/af/51/eba5d83015e04630002209e3590c310a0ff1d26e1815af204a322617a42e/ty-0.0.8-py3-none-win_amd64.whl", hash = "sha256:1641fb8dedc3d2da43279d21c3c7c1f80d84eae5c264a1e8daa544458e433c19", size = 10131382, upload-time = "2025-12-29T13:50:13.719Z" }, - { url = "https://files.pythonhosted.org/packages/38/1c/0d8454ff0f0f258737ecfe84f6e508729191d29663b404832f98fa5626b7/ty-0.0.8-py3-none-win_arm64.whl", hash = "sha256:ec74f022f315bede478ecae1277a01ab618e6500c1d68450d7883f5cd6ed554a", size = 9636374, upload-time = "2025-12-29T13:50:16.344Z" }, +version = "0.0.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/78/ba1a4ad403c748fbba8be63b7e774a90e80b67192f6443d624c64fe4aaab/ty-0.0.12.tar.gz", hash = "sha256:cd01810e106c3b652a01b8f784dd21741de9fdc47bd595d02c122a7d5cefeee7", size = 4981303, upload-time = "2026-01-14T22:30:48.537Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/8f/c21314d074dda5fb13d3300fa6733fd0d8ff23ea83a721818740665b6314/ty-0.0.12-py3-none-linux_armv6l.whl", hash = "sha256:eb9da1e2c68bd754e090eab39ed65edf95168d36cbeb43ff2bd9f86b4edd56d1", size = 9614164, upload-time = "2026-01-14T22:30:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/09/28/f8a4d944d13519d70c486e8f96d6fa95647ac2aa94432e97d5cfec1f42f6/ty-0.0.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:c181f42aa19b0ed7f1b0c2d559980b1f1d77cc09419f51c8321c7ddf67758853", size = 9542337, upload-time = "2026-01-14T22:30:05.687Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9c/f576e360441de7a8201daa6dc4ebc362853bc5305e059cceeb02ebdd9a48/ty-0.0.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1f829e1eecd39c3e1b032149db7ae6a3284f72fc36b42436e65243a9ed1173db", size = 8909582, upload-time = "2026-01-14T22:30:46.089Z" }, + { url = "https://files.pythonhosted.org/packages/d6/13/0898e494032a5d8af3060733d12929e3e7716db6c75eac63fa125730a3e7/ty-0.0.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f45162e7826e1789cf3374627883cdeb0d56b82473a0771923e4572928e90be3", size = 9384932, upload-time = "2026-01-14T22:30:13.769Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1a/b35b6c697008a11d4cedfd34d9672db2f0a0621ec80ece109e13fca4dfef/ty-0.0.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d11fec40b269bec01e751b2337d1c7ffa959a2c2090a950d7e21c2792442cccd", size = 9453140, upload-time = "2026-01-14T22:30:11.131Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/71c9edbc79a3c88a0711324458f29c7dbf6c23452c6e760dc25725483064/ty-0.0.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09d99e37e761a4d2651ad9d5a610d11235fbcbf35dc6d4bc04abf54e7cf894f1", size = 9960680, upload-time = "2026-01-14T22:30:33.621Z" }, + { url = "https://files.pythonhosted.org/packages/0e/75/39375129f62dd22f6ad5a99cd2a42fd27d8b91b235ce2db86875cdad397d/ty-0.0.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d9ca0cdb17bd37397da7b16a7cd23423fc65c3f9691e453ad46c723d121225a1", size = 10904518, upload-time = "2026-01-14T22:30:08.464Z" }, + { url = "https://files.pythonhosted.org/packages/32/5e/26c6d88fafa11a9d31ca9f4d12989f57782ec61e7291d4802d685b5be118/ty-0.0.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcf2757b905e7eddb7e456140066335b18eb68b634a9f72d6f54a427ab042c64", size = 10525001, upload-time = "2026-01-14T22:30:16.454Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a5/2f0b91894af13187110f9ad7ee926d86e4e6efa755c9c88a820ed7f84c85/ty-0.0.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00cf34c1ebe1147efeda3021a1064baa222c18cdac114b7b050bbe42deb4ca80", size = 10307103, upload-time = "2026-01-14T22:30:41.221Z" }, + { url = "https://files.pythonhosted.org/packages/4b/77/13d0410827e4bc713ebb7fdaf6b3590b37dcb1b82e0a81717b65548f2442/ty-0.0.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb3a655bd869352e9a22938d707631ac9fbca1016242b1f6d132d78f347c851", size = 10072737, upload-time = "2026-01-14T22:30:51.783Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dd/fc36d8bac806c74cf04b4ca735bca14d19967ca84d88f31e121767880df1/ty-0.0.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4658e282c7cb82be304052f8f64f9925f23c3c4f90eeeb32663c74c4b095d7ba", size = 9368726, upload-time = "2026-01-14T22:30:18.683Z" }, + { url = "https://files.pythonhosted.org/packages/54/70/9e8e461647550f83e2fe54bc632ccbdc17a4909644783cdbdd17f7296059/ty-0.0.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:c167d838eaaa06e03bb66a517f75296b643d950fbd93c1d1686a187e5a8dbd1f", size = 9454704, upload-time = "2026-01-14T22:30:22.759Z" }, + { url = "https://files.pythonhosted.org/packages/04/9b/6292cf7c14a0efeca0539cf7d78f453beff0475cb039fbea0eb5d07d343d/ty-0.0.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:2956e0c9ab7023533b461d8a0e6b2ea7b78e01a8dde0688e8234d0fce10c4c1c", size = 9649829, upload-time = "2026-01-14T22:30:31.234Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/472a5d2013371e4870886cff791c94abdf0b92d43d305dd0f8e06b6ff719/ty-0.0.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5c6a3fd7479580009f21002f3828320621d8a82d53b7ba36993234e3ccad58c8", size = 10162814, upload-time = "2026-01-14T22:30:36.174Z" }, + { url = "https://files.pythonhosted.org/packages/31/e9/2ecbe56826759845a7c21d80aa28187865ea62bc9757b056f6cbc06f78ed/ty-0.0.12-py3-none-win32.whl", hash = "sha256:a91c24fd75c0f1796d8ede9083e2c0ec96f106dbda73a09fe3135e075d31f742", size = 9140115, upload-time = "2026-01-14T22:30:38.903Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6d/d9531eff35a5c0ec9dbc10231fac21f9dd6504814048e81d6ce1c84dc566/ty-0.0.12-py3-none-win_amd64.whl", hash = "sha256:df151894be55c22d47068b0f3b484aff9e638761e2267e115d515fcc9c5b4a4b", size = 9884532, upload-time = "2026-01-14T22:30:25.112Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f3/20b49e75967023b123a221134548ad7000f9429f13fdcdda115b4c26305f/ty-0.0.12-py3-none-win_arm64.whl", hash = "sha256:cea99d334b05629de937ce52f43278acf155d3a316ad6a35356635f886be20ea", size = 9313974, upload-time = "2026-01-14T22:30:27.44Z" }, ] [[package]] @@ -2068,11 +2068,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.2" +version = "2.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, ] [[package]] @@ -2115,7 +2115,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "20.35.4" +version = "20.36.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -2123,9 +2123,9 @@ dependencies = [ { name = "platformdirs" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/a3/4d310fa5f00863544e1d0f4de93bddec248499ccf97d4791bc3122c9d4f3/virtualenv-20.36.1.tar.gz", hash = "sha256:8befb5c81842c641f8ee658481e42641c68b5eab3521d8e092d18320902466ba", size = 6032239, upload-time = "2026-01-09T18:21:01.296Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, + { url = "https://files.pythonhosted.org/packages/6a/2a/dc2228b2888f51192c7dc766106cd475f1b768c10caaf9727659726f7391/virtualenv-20.36.1-py3-none-any.whl", hash = "sha256:575a8d6b124ef88f6f51d56d656132389f961062a9177016a50e4f507bbcc19f", size = 6008258, upload-time = "2026-01-09T18:20:59.425Z" }, ] [[package]] diff --git a/workedon/cli.py b/workedon/cli.py index b5c336d..140d771 100644 --- a/workedon/cli.py +++ b/workedon/cli.py @@ -15,8 +15,8 @@ from .utils import add_options, load_settings from .workedon import fetch_tags, fetch_work, save_work -# Only ignore warnings if not in debug mode -if not os.environ.get("WORKEDON_DEBUG"): +# Only ignore warnings when the debug flag is set to "0" or not set +if os.environ.get("WORKEDON_DEBUG", "0") == "0": import warnings warnings.filterwarnings("ignore") @@ -417,7 +417,10 @@ def workedon(stuff: tuple[str, ...], **kwargs: Any) -> None: multiple=True, required=False, type=click.STRING, - help="Tag to filter by. Can be used multiple times to filter by multiple tags.", + help=( + "Tag to filter by. Can be used multiple times to filter by multiple tags. " + "Tags are normalized (trimmed and lowercased)." + ), ) @click.option( "--duration", diff --git a/workedon/models.py b/workedon/models.py index 152925d..8436bea 100644 --- a/workedon/models.py +++ b/workedon/models.py @@ -121,7 +121,10 @@ class Tag(Model): """ uuid: CharField = CharField(primary_key=True, null=False, default=get_unique_hash) - name: CharField = CharField(unique=True, null=False) + name: CharField = CharField( + unique=True, + null=False, + ) created: DateTimeField = DateTimeField( null=False, formats=[settings.internal_dt_format], default=get_default_time ) diff --git a/workedon/workedon.py b/workedon/workedon.py index f39165d..7d42e3e 100644 --- a/workedon/workedon.py +++ b/workedon/workedon.py @@ -2,7 +2,7 @@ from __future__ import annotations -from collections.abc import Iterator +from collections.abc import Iterable, Iterator import datetime import operator as op import re @@ -23,6 +23,14 @@ from .utils import now, to_internal_dt +def _normalize_tags(tags: Iterable[str]) -> set[str]: + """ + Normalize tags to lowercase and drop empty/whitespace-only values. + """ + normalized = {tag.strip().lower() for tag in tags} + return {tag for tag in normalized if tag} + + def save_work(work: tuple[str, ...], tags_opt: tuple[str, ...], duration_opt: str) -> None: """ Save work from user input @@ -32,7 +40,7 @@ def save_work(work: tuple[str, ...], tags_opt: tuple[str, ...], duration_opt: st work_text, dt, duration, tags = parser.parse(work_desc) if tags_opt: tags.update(set(tags_opt)) - tags = {tag.lower() for tag in tags} + tags = _normalize_tags(tags) if duration_opt: minutes = parser.parse_duration(f"[{duration_opt.strip()}]") @@ -175,10 +183,11 @@ def fetch_work( else: # tag if tags: - normalized = [t.lower() for t in tags] - tag_ids = Tag.select(Tag.uuid).where(Tag.name.in_(normalized)) - work_ids = WorkTag.select(WorkTag.work).where(WorkTag.tag.in_(tag_ids)) - work_set = work_set.where(Work.uuid.in_(work_ids)) + normalized_tags = _normalize_tags(tags) + if normalized_tags: + tag_ids = Tag.select(Tag.uuid).where(Tag.name.in_(normalized_tags)) + work_ids = WorkTag.select(WorkTag.work).where(WorkTag.tag.in_(tag_ids)) + work_set = work_set.where(Work.uuid.in_(work_ids)) # duration if duration: # Match optional comparison operator and value (e.g., '>=3h', '<= 45min', '2h') @@ -198,8 +207,7 @@ def fetch_work( work_set = work_set.where(op_map[comp_op](Work.duration, minutes)) # date range start, end = _get_date_range(start_date, end_date, since, period, on, at) - if start and end: - work_set = work_set.where((Work.timestamp >= start) & (Work.timestamp <= end)) + work_set = work_set.where((Work.timestamp >= start) & (Work.timestamp <= end)) # order sort_order = Work.timestamp.asc() if reverse else Work.timestamp.desc() work_set = work_set.order_by(sort_order)