sigye 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sigye-0.1.0/.gitignore ADDED
@@ -0,0 +1,13 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ .python-version
13
+ .coverage
sigye-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.3
2
+ Name: sigye
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Author-email: swilcox <steven@wilcoxzone.com>
6
+ Requires-Python: >=3.12
7
+ Requires-Dist: click>=8.1.7
8
+ Requires-Dist: humanize>=4.11.0
9
+ Requires-Dist: pydantic>=2.9.2
10
+ Requires-Dist: pyyaml>=6.0.2
11
+ Requires-Dist: rich>=13.9.4
12
+ Description-Content-Type: text/markdown
13
+
14
+ # sigye (시계)
15
+
16
+ A simple, command-line time tracking program.
17
+
18
+ ## Overview
19
+
20
+ sigye (시계 Korean for hour(s)/time) is a CLI program to help you track your time. With sigye, there are basic operations:
21
+ * start (start tracking time towards a project)
22
+ * stop (stop tracking time)
23
+ * status (get the current status)
24
+ * edit (edit a time entry record using the current default EDITOR)
25
+ * list (list entries)
26
+ * can filter entries by time range ("today", "week", "month") or fixed start and end dates.
27
+ * can filter entries by project name(s) or a project starts with.
28
+ * can filter entries by tags name(s).
29
+
30
+ The default storage of time entries is a YAML file (near future will be sqlite support). Using YAML makes manual editing of the entire file possible using any editor.
31
+
32
+ ## Installation
33
+
34
+ ### Via `uv`
35
+ ```shell
36
+ uv tool install sigye
37
+ ```
38
+
39
+ ### Via `pipx`
40
+ ```shell
41
+ pipx install sigye
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ### Start tracking
47
+ ```shell
48
+ sigye start <project-name> "<optional comment>" --tag "optional_tag"
49
+ ```
50
+
51
+ ### Check status
52
+ ```shell
53
+ sigye status
54
+ ```
55
+
56
+ ### Stop tracking
57
+ ```shell
58
+ sigye stop
59
+ ```
60
+
61
+ ### List Entries
62
+ #### List All Entries
63
+ ```shell
64
+ sigye list
65
+ ```
66
+ #### List Filtered Entries
67
+
68
+ All entries from a named time frame (options: `today`, `week` and `month`):
69
+ ```shell
70
+ sigye list TIMEFRAME
71
+ ```
72
+
73
+ All entries for a certain project (or list of projects)
74
+ ```shell
75
+ sigye list --project abc-1234 --project abc-1233
76
+ ```
77
+
78
+ Entries that "start with" a project name (note: you can use `+` or `.` or `*`):
79
+ ```
80
+ sigye list --project abc+
81
+ ```
82
+
83
+ All entries with any tag matching a tag or multiple tags:
84
+ ```
85
+ sigye list --tag mytag
86
+ ```
87
+
88
+ ### Edit Entries
89
+ To edit an entry, use the full or partial ID (just has to be enough digits for it to be unique among your time entry file or data). By default, sigye shows the first 4 digits from an entry ID.
90
+ ```shell
91
+ sigye edit ID
92
+ ```
93
+
94
+ ## Development
95
+
96
+ ### Install requirements
97
+
98
+ This project uses `uv` for dependency management.
99
+
100
+ ### Running tests
101
+
102
+ ```shell
103
+ uv run pytest
104
+ ```
sigye-0.1.0/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # sigye (시계)
2
+
3
+ A simple, command-line time tracking program.
4
+
5
+ ## Overview
6
+
7
+ sigye (시계 Korean for hour(s)/time) is a CLI program to help you track your time. With sigye, there are basic operations:
8
+ * start (start tracking time towards a project)
9
+ * stop (stop tracking time)
10
+ * status (get the current status)
11
+ * edit (edit a time entry record using the current default EDITOR)
12
+ * list (list entries)
13
+ * can filter entries by time range ("today", "week", "month") or fixed start and end dates.
14
+ * can filter entries by project name(s) or a project starts with.
15
+ * can filter entries by tags name(s).
16
+
17
+ The default storage of time entries is a YAML file (near future will be sqlite support). Using YAML makes manual editing of the entire file possible using any editor.
18
+
19
+ ## Installation
20
+
21
+ ### Via `uv`
22
+ ```shell
23
+ uv tool install sigye
24
+ ```
25
+
26
+ ### Via `pipx`
27
+ ```shell
28
+ pipx install sigye
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ### Start tracking
34
+ ```shell
35
+ sigye start <project-name> "<optional comment>" --tag "optional_tag"
36
+ ```
37
+
38
+ ### Check status
39
+ ```shell
40
+ sigye status
41
+ ```
42
+
43
+ ### Stop tracking
44
+ ```shell
45
+ sigye stop
46
+ ```
47
+
48
+ ### List Entries
49
+ #### List All Entries
50
+ ```shell
51
+ sigye list
52
+ ```
53
+ #### List Filtered Entries
54
+
55
+ All entries from a named time frame (options: `today`, `week` and `month`):
56
+ ```shell
57
+ sigye list TIMEFRAME
58
+ ```
59
+
60
+ All entries for a certain project (or list of projects)
61
+ ```shell
62
+ sigye list --project abc-1234 --project abc-1233
63
+ ```
64
+
65
+ Entries that "start with" a project name (note: you can use `+` or `.` or `*`):
66
+ ```
67
+ sigye list --project abc+
68
+ ```
69
+
70
+ All entries with any tag matching a tag or multiple tags:
71
+ ```
72
+ sigye list --tag mytag
73
+ ```
74
+
75
+ ### Edit Entries
76
+ To edit an entry, use the full or partial ID (just has to be enough digits for it to be unique among your time entry file or data). By default, sigye shows the first 4 digits from an entry ID.
77
+ ```shell
78
+ sigye edit ID
79
+ ```
80
+
81
+ ## Development
82
+
83
+ ### Install requirements
84
+
85
+ This project uses `uv` for dependency management.
86
+
87
+ ### Running tests
88
+
89
+ ```shell
90
+ uv run pytest
91
+ ```
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "sigye"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ authors = [{ name = "swilcox", email = "steven@wilcoxzone.com" }]
7
+ requires-python = ">=3.12"
8
+ dependencies = [
9
+ "click>=8.1.7",
10
+ "humanize>=4.11.0",
11
+ "pydantic>=2.9.2",
12
+ "pyyaml>=6.0.2",
13
+ "rich>=13.9.4",
14
+ ]
15
+
16
+ [project.scripts]
17
+ sigye = "sigye.cli:cli"
18
+
19
+ [build-system]
20
+ requires = ["hatchling"]
21
+ build-backend = "hatchling.build"
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ "freezegun>=1.5.1",
26
+ "pytest-cov>=6.0.0",
27
+ "pytest>=8.3.3",
28
+ ]
File without changes
@@ -0,0 +1,171 @@
1
+ from datetime import datetime, timedelta
2
+ import functools
3
+ import tempfile
4
+ import os
5
+ import subprocess
6
+ import yaml
7
+
8
+ import click
9
+
10
+ from .models import EntryListFilter, TimeEntry
11
+ from .output.text_output import list_output, single_entry_output
12
+ from .services import TimeTrackingService
13
+ from .repositories.time_entry_repo import TimeEntryRepository
14
+ from .repositories.time_entry_repo_yaml import TimeEntryRepositoryYaml
15
+
16
+
17
+ def _get_repo_from_filename(filename: str) -> TimeEntryRepository:
18
+ # TODO: add logic here to swap to sqlite or something else based on the filename
19
+ return TimeEntryRepositoryYaml(filename)
20
+
21
+
22
+ def config_params(func):
23
+ @click.option("--filename", "-f", default="time_entries.yaml")
24
+ @functools.wraps(func)
25
+ def wrapper(*args, **kwargs):
26
+ return func(*args, **kwargs)
27
+
28
+ return wrapper
29
+
30
+
31
+ def _get_editor_command():
32
+ return os.environ.get("EDITOR", "vim")
33
+
34
+
35
+ def _format_entry_for_edit(entry: TimeEntry) -> str:
36
+ """Format a time entry as YAML for editing"""
37
+ # Convert to dict and format as YAML
38
+ entry_dict = entry.model_dump(mode="json")
39
+ return yaml.dump(entry_dict)
40
+
41
+
42
+ def _parse_edited_entry(content: str) -> TimeEntry:
43
+ """Parse edited YAML content back into a TimeEntry"""
44
+ try:
45
+ data = yaml.safe_load(content)
46
+ return TimeEntry(**data)
47
+ except Exception as e:
48
+ raise click.ClickException(f"Invalid entry format: {str(e)}")
49
+
50
+
51
+ @click.group()
52
+ @click.version_option()
53
+ @click.pass_context
54
+ def cli(ctx): ...
55
+
56
+
57
+ @cli.command()
58
+ @click.argument("project", required=True, type=str)
59
+ @click.option("--tag", multiple=True)
60
+ @click.argument("comment", required=False, type=str, default="")
61
+ @config_params
62
+ def start(project, tag, comment, filename):
63
+ """start tracking work on a project"""
64
+ tts = TimeTrackingService(_get_repo_from_filename(filename))
65
+ time_entry = tts.start_tracking(project, comment=comment, tags=tag)
66
+ single_entry_output(time_entry)
67
+
68
+
69
+ @cli.command()
70
+ @config_params
71
+ def stop(filename):
72
+ """stop tracking work on a project"""
73
+ tts = TimeTrackingService(_get_repo_from_filename(filename))
74
+ time_entry = tts.stop_tracking()
75
+ if time_entry:
76
+ single_entry_output(time_entry)
77
+ else:
78
+ print("No active time entry to stop.")
79
+
80
+
81
+ @cli.command()
82
+ @config_params
83
+ def status(filename):
84
+ """displays currently tracked (if active)"""
85
+ tts = TimeTrackingService(_get_repo_from_filename(filename))
86
+ time_entry = tts.get_active_entry()
87
+ if time_entry:
88
+ single_entry_output(time_entry)
89
+ else:
90
+ print("No active time entry.")
91
+
92
+
93
+ @cli.command()
94
+ @click.argument("id", required=True, type=str)
95
+ @config_params
96
+ def edit(id, filename):
97
+ """edit a time entry using the system editor"""
98
+ tts = TimeTrackingService(_get_repo_from_filename(filename))
99
+
100
+ # Get the entry to edit
101
+ try:
102
+ if entries := tts.list_entries(EntryListFilter(id=id)):
103
+ if len(entries) > 1:
104
+ raise IndexError
105
+ entry = entries[0]
106
+ else:
107
+ raise KeyError
108
+ except KeyError:
109
+ raise click.ClickException(f"No entry found with id {id}")
110
+ except IndexError:
111
+ raise click.ClickException(f"Multiple records found starting with id {id}")
112
+
113
+ # Create temp file with entry content
114
+ with tempfile.NamedTemporaryFile(suffix=".yaml", mode="w+", delete=False) as tmp:
115
+ tmp.write(_format_entry_for_edit(entry))
116
+ tmp.flush()
117
+ tmp_path = tmp.name
118
+
119
+ try:
120
+ # Open editor
121
+ editor = _get_editor_command()
122
+ subprocess.run([editor, tmp_path], check=True)
123
+
124
+ # Read and parse edited content
125
+ with open(tmp_path, "r") as f:
126
+ edited_content = f.read()
127
+ updated_entry = _parse_edited_entry(edited_content)
128
+
129
+ # Save the updated entry
130
+ tts.update_entry(updated_entry)
131
+ single_entry_output(updated_entry)
132
+
133
+ finally:
134
+ # Clean up temp file
135
+ os.unlink(tmp_path)
136
+
137
+
138
+ @cli.command()
139
+ @click.argument(
140
+ "time_period",
141
+ required=False,
142
+ type=click.Choice(["today", "week", "month", ""]),
143
+ default="",
144
+ )
145
+ @click.option("--start_date")
146
+ @click.option("--end_date")
147
+ @click.option("--tag", multiple=True)
148
+ @click.option("--project", multiple=True)
149
+ @click.option("--format")
150
+ @config_params
151
+ def list(time_period, start_date, end_date, tag, project, format, filename):
152
+ """display list of time entries for a time period"""
153
+ tts = TimeTrackingService(_get_repo_from_filename(filename))
154
+
155
+ filter_params = {}
156
+ if time_period:
157
+ filter_params["time_period"] = time_period
158
+ if start_date:
159
+ filter_params["start_date"] = datetime.strptime(start_date, "%Y-%m-%d").date()
160
+ if end_date:
161
+ filter_params["end_date"] = datetime.strptime(end_date, "%Y-%m-%d").date()
162
+ if tag:
163
+ filter_params["tags"] = set(tag)
164
+ if project:
165
+ filter_params["projects"] = set(project)
166
+ if format:
167
+ filter_params["output_format"] = format
168
+
169
+ filter = EntryListFilter(**filter_params)
170
+ time_list = tts.list_entries(filter=filter)
171
+ list_output(time_list)
@@ -0,0 +1,87 @@
1
+ from datetime import datetime, date, timedelta
2
+ from uuid import uuid4
3
+ from typing import Literal
4
+
5
+ from pydantic import BaseModel, Field
6
+ import humanize
7
+
8
+
9
+ class TimeEntry(BaseModel):
10
+ id: str = Field(default_factory=lambda: uuid4().hex)
11
+ start_time: datetime
12
+ end_time: datetime | None = None
13
+ project: str
14
+ tags: set[str] = Field(default_factory=set)
15
+ comment: str = ""
16
+
17
+ @property
18
+ def humanized_duration(self):
19
+ td = (
20
+ self.end_time - self.start_time
21
+ if self.end_time
22
+ else datetime.now().astimezone() - self.start_time
23
+ )
24
+ return humanize.precisedelta(
25
+ td,
26
+ suppress=("seconds", "milliseconds", "microseconds"),
27
+ minimum_unit="hours",
28
+ format="%0.1f",
29
+ )
30
+
31
+ @staticmethod
32
+ def _get_naive_time(ts: datetime) -> datetime | None:
33
+ """remove the tzinfo so we get the original hour"""
34
+ return ts.replace(tzinfo=None) if ts else None
35
+
36
+ @property
37
+ def naive_start_time(self):
38
+ return self._get_naive_time(self.start_time)
39
+
40
+ @property
41
+ def naive_end_time(self):
42
+ return self._get_naive_time(self.end_time)
43
+
44
+ def stop(self, end_time: datetime = None):
45
+ if self.end_time is None or (
46
+ self.end_time is not None and self.end_time < self.start_time
47
+ ):
48
+ self.end_time = end_time or datetime.now().astimezone()
49
+ else:
50
+ raise ValueError("already stopped")
51
+
52
+ @property
53
+ def duration(self):
54
+ return (
55
+ self.end_time - self.start_time
56
+ if self.end_time
57
+ else datetime.now().astimezone() - self.start_time
58
+ )
59
+
60
+
61
+ class EntryListFilter(BaseModel):
62
+ id: str = ""
63
+ projects: set[str] = Field(default_factory=set)
64
+ start_date: date | None = None
65
+ end_date: date | None = None
66
+ tags: set[str] = Field(default_factory=set)
67
+ time_period: Literal["today", "week", "month"] | None = None
68
+ output_format: str | None = None
69
+
70
+ def __init__(self, **data):
71
+ super().__init__(**data)
72
+ if self.time_period:
73
+ self._apply_time_period()
74
+
75
+ def _apply_time_period(self):
76
+ """Apply date filters based on time period"""
77
+ now = datetime.now()
78
+ if self.time_period == "today":
79
+ self.start_date = now.date()
80
+ elif self.time_period == "week":
81
+ self.start_date = now.date() - timedelta(
82
+ days=now.date().weekday()
83
+ ) # Monday
84
+ elif self.time_period == "month":
85
+ self.start_date = now.date() - timedelta(
86
+ days=(now.date().day - 1)
87
+ ) # 1st of current month
File without changes
@@ -0,0 +1,60 @@
1
+ from datetime import date
2
+ from rich.console import Console
3
+ from rich.table import Table
4
+ from ..models import TimeEntry
5
+
6
+
7
+ ABBR_ID_LENGTH = 4
8
+
9
+
10
+ def single_entry_output(entry: TimeEntry):
11
+ table = Table(title="Time Entry")
12
+ table.add_column("field")
13
+ table.add_column("value")
14
+ table.add_row(
15
+ "ID",
16
+ f"[magenta]{entry.id[0:ABBR_ID_LENGTH]}[/magenta]{entry.id[ABBR_ID_LENGTH:]}",
17
+ )
18
+ table.add_row(
19
+ "start date/time", f"[cyan]{entry.naive_start_time:%Y-%m-%d %H:%M:%S}[/cyan]"
20
+ )
21
+ table.add_row(
22
+ "end time",
23
+ f"[magenta]{entry.naive_end_time:%H:%M:%S}[/magenta]"
24
+ if entry.end_time
25
+ else "-",
26
+ )
27
+ table.add_row("duration", f"[cyan]{entry.humanized_duration}[/cyan]")
28
+ table.add_row("project", f"[green]{entry.project}[/green]")
29
+ table.add_row("comments", f"[blue]{entry.comment}[/blue]")
30
+ table.add_row("tags", "[red]" + ", ".join(tag for tag in entry.tags) + "[/red]")
31
+ console = Console()
32
+ console.print(table)
33
+
34
+
35
+ def list_output(entry_list: list[TimeEntry]):
36
+ table = Table(title="Time Entries")
37
+ table.add_column("id", justify="left", style="#707070")
38
+ table.add_column("start", justify="right", style="cyan")
39
+ table.add_column("end", justify="right", style="magenta")
40
+ table.add_column("delta", style="cyan")
41
+ table.add_column("project", justify="left", style="green")
42
+ table.add_column("comments", style="blue")
43
+ table.add_column("tags", style="red")
44
+ current_date = date(1970, 1, 1)
45
+ for entry in entry_list:
46
+ if entry.start_time.date() != current_date:
47
+ current_date = entry.start_time.date()
48
+ table.add_section()
49
+ table.add_row("", f"{current_date:%Y-%m-%d}", style="yellow")
50
+ table.add_row(
51
+ f"{entry.id[0:ABBR_ID_LENGTH]}",
52
+ f"{entry.naive_start_time:%H:%M:%S}",
53
+ f"{entry.naive_end_time:%H:%M:%S}" if entry.end_time else "-",
54
+ f"{entry.humanized_duration}",
55
+ entry.project,
56
+ entry.comment,
57
+ ", ".join(tag for tag in entry.tags),
58
+ )
59
+ console = Console()
60
+ console.print(table)
File without changes
@@ -0,0 +1,28 @@
1
+ from abc import ABC, abstractmethod
2
+ from ..models import TimeEntry, EntryListFilter
3
+
4
+
5
+ class TimeEntryRepository(ABC):
6
+ @abstractmethod
7
+ def save(self, entry: TimeEntry) -> TimeEntry:
8
+ pass
9
+
10
+ @abstractmethod
11
+ def get_active_entry(self) -> TimeEntry | None:
12
+ pass
13
+
14
+ @abstractmethod
15
+ def get_by_project(self, project: str) -> list[TimeEntry]:
16
+ pass
17
+
18
+ @abstractmethod
19
+ def get_all(self) -> list[TimeEntry]:
20
+ pass
21
+
22
+ @abstractmethod
23
+ def filter(self, *, filter: EntryListFilter):
24
+ pass
25
+
26
+ @abstractmethod
27
+ def get_entry_by_id(self) -> TimeEntry:
28
+ pass
@@ -0,0 +1,99 @@
1
+ import os
2
+ import yaml
3
+ from ..models import TimeEntry, EntryListFilter
4
+ from .time_entry_repo import TimeEntryRepository
5
+
6
+
7
+ class TimeEntryRepositoryYaml(TimeEntryRepository):
8
+ def __init__(self, filename: str = "timesheet.yaml"):
9
+ self.filename = filename
10
+ self._ensure_file_exists()
11
+
12
+ def _ensure_file_exists(self):
13
+ if not os.path.exists(self.filename):
14
+ self._save_data({"entries": []})
15
+
16
+ def _load_data(self) -> list[TimeEntry]:
17
+ with open(self.filename, "r") as f:
18
+ return yaml.load(f, yaml.FullLoader)
19
+
20
+ def _save_data(self, data: dict):
21
+ with open(self.filename, "w") as f:
22
+ yaml.dump(data, f)
23
+
24
+ def get_active_entry(self) -> TimeEntry | None:
25
+ data = self._load_data()
26
+ active = next((e for e in data["entries"] if not e.get("end_time")), None)
27
+ return TimeEntry(**active) if active else None
28
+
29
+ def get_all(self):
30
+ data = self._load_data()
31
+ return [TimeEntry(**entry) for entry in data["entries"]]
32
+
33
+ def get_by_project(self, project):
34
+ data = self._load_data()
35
+ return [
36
+ TimeEntry(**entry)
37
+ for entry in data["entries"]
38
+ if entry["project"] == project
39
+ ]
40
+
41
+ def get_entry_by_id(self, id: str):
42
+ data = self._load_data()
43
+ for entry in data["entries"]:
44
+ if entry["id"] == id:
45
+ return TimeEntry(**entry)
46
+ raise KeyError("record id not found")
47
+
48
+ @staticmethod
49
+ def _project_matching(filter_projects: set[str], project: str) -> bool:
50
+ for f_proj in filter_projects:
51
+ if (
52
+ f_proj[-1] in "*+." and project.startswith(f_proj[0:-1])
53
+ ) or f_proj == project:
54
+ return True
55
+ return False
56
+
57
+ def _check_against_filter(self, filter: EntryListFilter, entry: TimeEntry) -> bool:
58
+ conditions = [
59
+ # id filter
60
+ (not filter.id or entry.id.startswith(filter.id)),
61
+ # Project filters
62
+ (
63
+ not filter.projects
64
+ or self._project_matching(filter.projects, entry.project)
65
+ ),
66
+ # Date filters
67
+ (not filter.start_date or entry.start_time.date() >= filter.start_date),
68
+ (
69
+ not filter.end_date
70
+ or (entry.end_time and entry.end_time.date() <= filter.end_date)
71
+ ),
72
+ # Tag filter
73
+ (not filter.tags or any(tag in entry.tags for tag in filter.tags)),
74
+ ]
75
+ return all(conditions)
76
+
77
+ def filter(self, *, filter: EntryListFilter | None = None) -> list[TimeEntry]:
78
+ time_entries = self.get_all()
79
+ if filter is None:
80
+ return time_entries
81
+ return [
82
+ entry for entry in time_entries if self._check_against_filter(filter, entry)
83
+ ]
84
+
85
+ def save(self, entry: TimeEntry) -> None:
86
+ data = self._load_data()
87
+ entry_dict = entry.model_dump(mode="json")
88
+ found = False
89
+ entries = []
90
+ for e in data["entries"]:
91
+ if e["id"] == entry.id:
92
+ entries.append(entry_dict)
93
+ found = True
94
+ else:
95
+ entries.append(e)
96
+ if not found:
97
+ entries.append(entry_dict)
98
+ data["entries"] = entries
99
+ self._save_data(data)