diff --git a/requirements.txt b/requirements.txt index 1411a4a..af50ce2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ -pandas \ No newline at end of file +pandas +typer diff --git a/worklog/cmd/__init__.py b/worklog/cmd/__init__.py new file mode 100644 index 0000000..82180b0 --- /dev/null +++ b/worklog/cmd/__init__.py @@ -0,0 +1,178 @@ +import typer +from typing import Optional +from datetime import datetime, timezone, timedelta +import re +import logging +import sys + +from worklog.cmd.session import app as session_app +from worklog.cmd.status import app as status_app +from worklog.cmd.task import app as task_app +from worklog.cmd.utils import configure_worklog +from worklog.constants import Category +import worklog.constants as wc + +app = typer.Typer() + +now = datetime.now(timezone.utc).astimezone(tz=wc.LOCAL_TIMEZONE).replace(microsecond=0) +current_month: str = now.replace(day=1).isoformat()[: len("2000-01-01")] +next_month: str = (now.replace(day=1) + timedelta(days=31)).replace(day=1).isoformat()[ + : len("2000-01-01") +] + + +def _positive_int(value: int): + if value < 0: + raise typer.BadParameter("Value must be larger or equal to zero") + return value + + +def _combined_month_or_day_or_week_parser(value: str) -> str: + if re.match(r"^\d{4}\-\d{2}$", value): + return _year_month_parser(value).isoformat() + elif re.match(r"^\d{4}\-\d{2}\-\d{2}$", value): + return _year_month_day_parser(value).isoformat() + elif re.match(r"^\d{4}-W\d{2}$", value): + return _calendar_week_parser(value).isoformat() + raise typer.BadParameter(f"{value} is not a valid format") + + +def _year_month_parser(value: str) -> datetime: + if not re.match(r"^\d{4}\-\d{2}$", value): + raise typer.BadParameter(f"{value} is not in the format YYYY-MM") + year, month = [int(x) for x in value.split("-")] + return datetime(year=year, month=month, day=1, tzinfo=wc.LOCAL_TIMEZONE) + + +def _year_month_day_parser(value: str) -> datetime: + if not re.match(r"^\d{4}\-\d{2}\-\d{2}$", value): + raise typer.BadParameter(f"{value} is not in the format YYYY-MM-DD") + year, month, day = [int(x) for x in value.split("-")] + return datetime(year=year, month=month, day=day, tzinfo=wc.LOCAL_TIMEZONE) + + +def _calendar_week_parser(value: str) -> datetime: + if not re.match(r"^\d{4}-W\d{2}$", value): + raise typer.BadParameter(f"{value} is not in the format cwWW") + dt = datetime.strptime(value + "-1", "%Y-W%W-%w").replace(tzinfo=wc.LOCAL_TIMEZONE) + return dt + + +@app.callback() +def callback(): + """Simple CLI tool to log work and projects.""" + + # Configure the standard logger to output to stdout + std_logger = logging.getLogger(wc.STD_LOGGER_NAME) + std_logger.setLevel(logging.INFO) + std_logger.addHandler(logging.StreamHandler(sys.stdout)) + + +@app.command() +def doctor(): + """ + The doctor command checks the worklog for missing or problematic entries. \ + It will report the following issues: non-closed working sessions. + """ + log, _ = configure_worklog() + log.doctor() + + +@app.command() +def log( + number: int = typer.Option( + 10, + "--number", + "-n", + help="Defines many log entries should be shown. System pager will be used if n > 20.", + callback=_positive_int, + ), + show_all: bool = typer.Option( + False, "--all", "-a", help="Show all entries. System pager will be used.", + ), + category: Category = typer.Option(None, help="Filter category"), + pager: bool = typer.Option( + True, + help=( + "Use a the system pager. " + "Prints all output to STDOUT regardless of how many entries will be shown. " + "This flag should be used if there are problems with the system pager." + ), + ), +): + """Shows the content of the worklog file sorted after the date and time of \ + the entry. Use this command to manually review the content of the \ + worklog.""" + log, cfg = configure_worklog() + no_pager_max_entries = int(cfg.get("worklog", "no_pager_max_entries")) + use_pager = pager and (show_all or number > no_pager_max_entries) + category_str: Optional[str] = None if not category else category.value + if not show_all: + log.log(number, use_pager, category_str) + else: + log.log(-1, use_pager, category_str) + + +@app.command() +def report( + date_from: str = typer.Option( + current_month, + callback=_combined_month_or_day_or_week_parser, + help=( + "Date from which the aggregation should be started (inclusive). " + "By default the start of the current calendar month is selected. " + "Allowed input formats are YYYY-MM-DD, YYYY-MM and YYYY-WXX, with " + "XX referring to the week number, e.g. 35." + ), + ), + date_to: str = typer.Option( + next_month, + callback=_combined_month_or_day_or_week_parser, + help=( + "Date to which the aggregation should be started (exclusive). " + "By default the next calendar month is selected. " + "Allowed input formats are YYYY-MM-DD, YYYY-MM and YYYY-WXX, with " + "XX referring to the week number, e.g. 35." + ), + ), +): + """ + Creates a report for a given time window. Working time will be aggregated on a \ + monthly, weekly and daily basis. Tasks will be aggregated separately. By \ + default the current month will be used for the report. + """ + log, _ = configure_worklog() + dt_date_from = datetime.fromisoformat(date_from) + dt_date_to = datetime.fromisoformat(date_to) + log.report(dt_date_from, dt_date_to) + + +app.add_typer( + session_app, + name="session", + help="""\ + Commit the start or end of a new working session to the worklog file. \ + Use this function to stamp in the morning and stamp out in the \ + evening.""", +) +app.add_typer( + status_app, + name="status", + help="""\ + Creates a report for a given time window. Working time will be aggregated on \ + a monthly, weekly and daily basis. Tasks will be aggregated separately. By \ + default the current month will be used for the report. + """, +) +app.add_typer( + task_app, + name="task", + help="""\ + Tasks are pieces of work to be done or undertaken. A task can only be \ + started during an ongoing session. Use 'wl session start' to start a new \ + working session.""", +) + + +if __name__ == "__main__": + app() diff --git a/worklog/cmd/session.py b/worklog/cmd/session.py new file mode 100644 index 0000000..519f6ab --- /dev/null +++ b/worklog/cmd/session.py @@ -0,0 +1,54 @@ +import typer +from typing import Optional +from datetime import datetime + +from worklog.cmd.utils import ( + MutuallyExclusiveGroup, + configure_worklog, + offset_minutes_opt, + time_opt, +) +import worklog.constants as wc + +app = typer.Typer() + + +_force_opt = typer.Option( + False, "--force", "-f", help="Force command, will auto-stop running tasks." +) + + +@app.command() +def start( + force: bool = _force_opt, + offset_minutes: Optional[int] = offset_minutes_opt, + time: Optional[str] = time_opt, +): + log, cfg = configure_worklog() + log.commit( + wc.TOKEN_SESSION, + wc.TOKEN_START, + offset_min=0 if offset_minutes is None else offset_minutes, + time=time, + force=force, + ) + + +@app.command() +def stop( + force: bool = _force_opt, + offset_minutes: Optional[int] = offset_minutes_opt, + time: Optional[str] = time_opt, +): + log, cfg = configure_worklog() + log.commit( + wc.TOKEN_SESSION, + wc.TOKEN_STOP, + offset_min=0 if offset_minutes is None else offset_minutes, + time=time, + force=force, + ) + + +if __name__ == "__main__": + app() diff --git a/worklog/cmd/status.py b/worklog/cmd/status.py new file mode 100644 index 0000000..e85240a --- /dev/null +++ b/worklog/cmd/status.py @@ -0,0 +1,7 @@ +import typer + +app = typer.Typer() + + +if __name__ == "__main__": + app() diff --git a/worklog/cmd/task.py b/worklog/cmd/task.py new file mode 100644 index 0000000..ae83679 --- /dev/null +++ b/worklog/cmd/task.py @@ -0,0 +1,76 @@ +import typer +from typing import Optional + +from worklog.cmd.utils import ( + configure_worklog, + offset_minutes_opt, + time_opt, + configure_worklog, +) +from worklog.utils.time import calc_log_time +import worklog.constants as wc + +app = typer.Typer() + + +@app.command() +def start( + task_id: str = typer.Argument(..., help="Task identifier, can be freely chosen"), + auto_stop: bool = typer.Option( + False, "--auto-stop", "-as", help="Automatically stops open tasks.", + ), + offset_minutes: Optional[int] = offset_minutes_opt, + time: Optional[str] = time_opt, +): + log, _ = configure_worklog() + + offset_min = 0 if offset_minutes is None else offset_minutes + + if auto_stop: + commit_dt = calc_log_time(offset_min, time) + stopped_tasks = log.stop_active_tasks(commit_dt) + + log.commit( + wc.TOKEN_TASK, + wc.TOKEN_START, + offset_min=offset_min, + time=time, + identifier=task_id, + ) + + +@app.command() +def stop( + task_id: str = typer.Argument(..., help="Task identifier of a running task"), + offset_minutes: Optional[int] = offset_minutes_opt, + time: Optional[str] = time_opt, +): + log, _ = configure_worklog() + + offset_min = 0 if offset_minutes is None else offset_minutes + + log.commit( + wc.TOKEN_TASK, + wc.TOKEN_STOP, + offset_min=offset_min, + time=time, + identifier=task_id, + ) + + +@app.command() +def list(): + log, _ = configure_worklog() + log.list_tasks() + + +@app.command() +def report( + task_id: str = typer.Argument(..., help="Task identifier of a recorded task"), +): + log, _ = configure_worklog() + log.task_report(task_id) + + +if __name__ == "__main__": + app() diff --git a/worklog/cmd/utils.py b/worklog/cmd/utils.py new file mode 100644 index 0000000..7f7fbd5 --- /dev/null +++ b/worklog/cmd/utils.py @@ -0,0 +1,88 @@ +import os +from configparser import ConfigParser +from io import StringIO +import json +from typing import Tuple, Optional +import typer +from datetime import datetime + +from worklog.breaks import AutoBreak +import worklog.constants as wc +from worklog.log import Log +from worklog.parser import get_arg_parser +from worklog.utils.time import calc_log_time +from worklog.utils.logger import configure_logger +from worklog.dispatcher import dispatch + + +def configure_worklog() -> Tuple[Log, ConfigParser]: + """ Main method """ + logger = configure_logger() + + # log_level = wc.LOG_LEVELS[min(cli_args.verbosity, len(wc.LOG_LEVELS) - 1)] + # logger.setLevel(log_level) + + # logger.debug(f"Parsed CLI arguments: {cli_args}") + # logger.debug(f"Path to config files: {wc.CONFIG_FILES}") + + # if cli_args.subcmd is None: + # parser.print_help() + # return + + cfg = ConfigParser() + cfg.read(wc.CONFIG_FILES) + + with StringIO() as ss: + cfg.write(ss) + ss.seek(0) + logger.debug(f"Config content:\n{ss.read()}\nEOF") + + worklog_fp = os.path.expanduser(cfg.get("worklog", "path")) + log = Log(worklog_fp) + + limits = json.loads(cfg.get("workday", "auto_break_limit_minutes")) + durations = json.loads(cfg.get("workday", "auto_break_duration_minutes")) + log.auto_break = AutoBreak(limits, durations) + + return log, cfg + + +# Taken from https://github.com/tiangolo/typer/issues/140#issuecomment-898937671 +def MutuallyExclusiveGroup(size=2): + group = set() + + def callback(ctx: typer.Context, param: typer.CallbackParam, value: str): + # Add cli option to group if it was called with a value + if value is not None and param.name not in group: + group.add(param.name) + if len(group) > size - 1: + raise typer.BadParameter( + f"{param.name} is mutually exclusive with {group.pop()}" + ) + return value + + return callback + + +timeshift_grp = MutuallyExclusiveGroup() +offset_minutes_opt: Optional[int] = typer.Option( + None, + "--offset-minutes", + "-om", + callback=timeshift_grp, + help=( + "Offset of the start/stop time in minutes. " + "Positive values shift the timestamp into the future, negative " + "values shift it into the past." + ), +) +time_opt: Optional[str] = typer.Option( + None, + callback=timeshift_grp, + help=( + "Exact point in time. " + "Can be a either hours and minutes (format: 'hh:mm') on the same day or a full ISO " + "format string, such as '2020-08-05T08:15:00+02:00'. " + "In the latter case the local timezone is used if the timezone part is left empty." + ), +) diff --git a/worklog/constants.py b/worklog/constants.py index dbc1341..b281332 100644 --- a/worklog/constants.py +++ b/worklog/constants.py @@ -2,6 +2,7 @@ import logging import os from datetime import datetime, timezone, tzinfo +import enum LOG_FORMAT: str = logging.BASIC_FORMAT LOG_LEVELS: List[int] = [logging.ERROR, logging.WARN, logging.INFO, logging.DEBUG] @@ -14,6 +15,9 @@ LOCAL_TIMEZONE: Optional[tzinfo] = datetime.now(timezone.utc).astimezone().tzinfo DEFAULT_LOGGER_NAME = "worklog" +"""Logger that can be configured in CLI mode with verbosity flags""" +STD_LOGGER_NAME = "std_logger" +"""Logger that writes always to stdout (in CLI mode)""" SUBCMD_SESSION = "session" SUBCMD_DOCTOR = "doctor" @@ -35,3 +39,8 @@ TOKEN_SESSION = "session" TOKEN_TASK = "task" + +class Category(enum.Enum): + Session = "session" + Task = "task" + diff --git a/worklog/log.py b/worklog/log.py index ada6ab6..443add6 100644 --- a/worklog/log.py +++ b/worklog/log.py @@ -31,6 +31,8 @@ ) from worklog.errors import ErrMsg +std_logger = logging.getLogger(wc.STD_LOGGER_NAME) + class Log(object): # In-memory representation of log @@ -108,9 +110,7 @@ def list_tasks(self): count = task_counter[task] sys.stdout.write(f"{task} ({count})\n") - def log( - self, n: int, use_pager: bool, filter_category: Optional[List[str]] - ) -> None: + def log(self, n: int, use_pager: bool, filter_category: Optional[str]) -> None: """Display the content of the logfile.""" if self._log_df.shape[0] == 0: sys.stdout.write("No data available\n") @@ -275,13 +275,16 @@ def status( ) def stop_active_tasks(self, log_dt: datetime): - """Stop all active tasks by commiting changes to the logfile.""" + """ + Stop all active tasks by commiting changes to the logfile. + Returns the list of all stopped task ids. + """ query_date = log_dt.date() task_mask = self._log_df[wc.COL_CATEGORY] == wc.TOKEN_TASK date_mask = self._log_df["date"] == query_date mask = task_mask & date_mask - active_task_ids = get_active_task_ids(self._log_df[mask]) - for task_id in active_task_ids: + tasks_to_stop = get_active_task_ids(self._log_df[mask]) + for task_id in tasks_to_stop: self._commit(wc.TOKEN_TASK, wc.TOKEN_STOP, log_dt, identifier=task_id) def task_report(self, task_id): @@ -418,6 +421,15 @@ def _commit( # and persist to disk self._persist(record_t, mode="a") + today = datetime.today().date() + fmt = "%H:%M:%S" if log_dt.date() == today else "%Y-%m-%d %H:%M:%S" + start_stop = "started" if type_ == wc.TOKEN_START else "stopped" + category_and_id = ( + "Session" if category == wc.TOKEN_SESSION else "Task " + (identifier or "") + ) + msg = "{} {} at {}".format(category_and_id, start_stop, log_dt.strftime(fmt),) + std_logger.info(msg) + # Because we allow for time offsets sorting is not guaranteed at this point. # Update sorting of values in-memory. self._log_df = self._log_df.sort_values(by=[wc.COL_LOG_DATETIME]) diff --git a/worklog/utils/tasks.py b/worklog/utils/tasks.py index 0f7e542..d0fe382 100644 --- a/worklog/utils/tasks.py +++ b/worklog/utils/tasks.py @@ -46,7 +46,7 @@ def get_all_task_ids_with_duration(df: DataFrame): return s.to_dict() -def get_active_task_ids(df: DataFrame): +def get_active_task_ids(df: DataFrame) -> List[str]: """ Returns a list of active tasks. Note: This method can only handle DataFrames that have entries where the