diff --git a/CHANGELOG.md b/CHANGELOG.md index fa8c50d..be90e2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,12 @@ ### Added -- Support for the new PyYAML version. +- CLI interface. +- Support for the new minor PyYAML version. + +### Changed + +- Menu has been removed. ## 1.2.3 - 2023-02-11 diff --git a/fastimer/app.py b/fastimer/app.py index 8b375a7..90565a9 100644 --- a/fastimer/app.py +++ b/fastimer/app.py @@ -5,168 +5,87 @@ """ import datetime +import os import sys +from sys import stdout +import click from vkostyanetsky import cliutils # type: ignore -from fastimer import datafile, statistics, utils +from fastimer import constants, datafile, statistics, utils from fastimer.browser import FastsBrowser +from fastimer.commands import command_show, command_start from fastimer.menu import FastimerMenu -def main() -> None: +def __get_path(path: str | None) -> str: """ - Main entry point of the application. Displays the main menu by default. + Determines the path to a working directory. It is supposed to be the "Fastimer" folder + in user's home directory in case it's not specified via app's options. """ - main_menu() + if path is None: + path = os.path.expanduser("~") + path = os.path.join(path, "Fastimer") + return path -def main_menu() -> None: - """ - Draws the main menu of the application. - """ - - fasts = datafile.read_fasts() - active_fast = utils.get_active_fast(fasts) - - menu = FastimerMenu(active_fast) - - if active_fast is None: - menu.add_item("Start New Fast", start_fast) - else: - menu.add_item("Stop Active Fast", stop_fast) - - menu.add_item("Fasts Browser", show_fasts_browser) - menu.add_item("Statistics", show_statistics) - menu.add_item("Exit", sys.exit) - - menu.choose() - - -def start_fast() -> None: - """ - Starts a new fast. - """ - - fasts = datafile.read_fasts() - fast = utils.get_active_fast(fasts) - if fast is not None: - print("Fast is already on.") - print() +def __path_help() -> str: + return "Set path to working directory." - cliutils.ask_for_enter() - else: - length = None +def __path_type() -> click.Path: + return click.Path(exists=True) - while length is None: - user_input = input("Enter fast duration in hours: ") - if user_input.isdigit(): - length = int(user_input) - fast = { - "length": length, - "started": datetime.datetime.now(), - } +@click.group(help="CLI tool that helps with fasting.") +def cli(): + stdout.reconfigure(encoding=constants.ENCODING) - fasts.append(fast) - datafile.write_fasts(fasts) +@cli.command(help="Start a new fast.") +@click.option("-p", "--path", type=__path_type(), help=__path_help()) +def start(path: str | None) -> None: + path = __get_path(path) + command_start.main(path) - else: - print("Please enter a valid number.") - print() - main_menu() - - -def stop_fast() -> None: - """ - Stops the active fast. - """ - - fasts = datafile.read_fasts() - active_fast = utils.get_active_fast(fasts) - - menu = FastimerMenu(active_fast) - - menu.add_item("Finish Fast", finish_fast) - menu.add_item("Cancel Fast", cancel_fast) - menu.add_item("Back", main_menu) - - menu.choose() - - -def cancel_fast() -> None: - """ - Cancels the active fast. - """ - - fasts = datafile.read_fasts() - active_fast = utils.get_active_fast(fasts) - - if active_fast is not None: - cliutils.clear_terminal() - - prompt = "Do you want to CANCEL the active fast? It cannot be undone." - - if cliutils.ask_for_yes_or_no(prompt): - fasts.remove(active_fast) - datafile.write_fasts(fasts) - - main_menu() - - -def finish_fast() -> None: - """ - Finishes the active fast. - """ - - fasts = datafile.read_fasts() - - cliutils.clear_terminal() - - if cliutils.ask_for_yes_or_no("Do you want to end your ongoing fast?"): - fasts[-1]["stopped"] = datetime.datetime.now() - datafile.write_fasts(fasts) - - main_menu() - - -def show_fasts_browser() -> None: - """ - Runs the fast browser to help a user to be proud of their efforts. - """ - - fasts = datafile.read_fasts() - FastsBrowser(fasts).open() - - main_menu() - - -def show_statistics() -> None: - """ - Draws fasting statistics accumulated so far. - """ +@cli.command(help="Show fasts by date.") +@click.option("-p", "--path", type=__path_type(), help=__path_help()) +def show(path: str | None) -> None: + path = __get_path(path) + command_show.main(path) - fasts = datafile.read_fasts() - print("FASTING STATISTICS") - print() +# @cli.command(help="Check that data files have no mistakes.") +# @click.option("-p", "--path", type=__path_type(), help=__path_help()) +# def test(path: str | None) -> None: +# path = __get_path(path) +# command_test.main(path) +# +# +# @cli.command(help="Set alarm according to notification settings.") +# @click.option("-p", "--path", type=__path_type(), help=__path_help()) +# def beep(path: str | None): +# path = __get_path(path) +# command_beep.main(path) - statistics.print_completed_fasts(fasts) - statistics.print_total_fasting_time(fasts) - statistics.print_average_fast_length(fasts) - statistics.print_longest_fast_length(fasts) - statistics.print_longest_fasting_streak(fasts) - statistics.print_current_fasting_streak(fasts) - print() - statistics.print_achievements(fasts) - print() +# @cli.command(help="Display tasks for a given day (or days).") +# @click.argument( +# "period", default="today", type=click.Choice(["today", "last", "next", "date"]) +# ) +# @click.argument("value", default="") +# @click.option("-p", "--path", type=__path_type(), help=__path_help()) +# @click.option( +# "-t", "--timesheet", is_flag=True, help="Show only tasks with time logged." +# ) +# @click.option("-l", "--logs", is_flag=True, help="Show time logged for each task.") +# def show(path: str | None, timesheet: bool, logs: bool, period: str, value: str): +# path = __get_path(path) +# command_show.main(period, value, path, timesheet, logs) - cliutils.ask_for_enter() - main_menu() +if __name__ == "__main__": + cli() diff --git a/fastimer/commands/command_cancel.py b/fastimer/commands/command_cancel.py new file mode 100644 index 0000000..f4a2510 --- /dev/null +++ b/fastimer/commands/command_cancel.py @@ -0,0 +1,22 @@ +def cancel_fast() -> None: + """ + Cancels the active fast. + """ + + pass + + +# +# fasts = datafile.read_fasts() +# active_fast = utils.get_active_fast(fasts) +# +# if active_fast is not None: +# cliutils.clear_terminal() +# +# prompt = "Do you want to CANCEL the active fast? It cannot be undone." +# +# if cliutils.ask_for_yes_or_no(prompt): +# fasts.remove(active_fast) +# datafile.write_fasts(fasts) +# +# main_menu() diff --git a/fastimer/commands/command_show.py b/fastimer/commands/command_show.py new file mode 100644 index 0000000..9195f95 --- /dev/null +++ b/fastimer/commands/command_show.py @@ -0,0 +1,187 @@ +import datetime +import typing + +import click + +from fastimer import datafile, utils + + +def main(path: str) -> None: + """ + Generates a detailed view of a fast. + """ + + fasts = datafile.read_fasts(path) + index = len(fasts) - 1 + fast = fasts[index] + + time = fast["stopped"] if utils.is_fast_stopped(fast) else datetime.datetime.now() + + goal = fast["started"] + datetime.timedelta(hours=fast["length"]) + + __echo_fast_title(fast) + click.echo() + + __echo_fast_from(fast) + __echo_fast_goal(fast, goal) + click.echo() + + __echo_fasting_zones(fast, time) + click.echo() + + __echo_fast_progress_bar(fast, goal, time) + click.echo() + + __echo_fast_elapsed_time(fast, time) + + if time <= goal: + __echo_fast_remaining_time(time, goal) + else: + __echo_fast_extra_time(time, goal) + + +def __echo_fast_title(fast: dict[str, typing.Any]) -> None: + if utils.is_fast_stopped(fast): + + if utils.is_fast_completed(fast): + click.echo(click.style(text="COMPLETED FAST", fg="green")) + else: + click.echo(click.style(text="FAILED FAST", fg="red")) + else: + click.echo(click.style(text="ACTIVE FAST", fg="yellow")) + + +def __echo_fast_from(fast: dict[str, typing.Any]) -> None: + value = __get_day(fast["started"]) + value = utils.title_and_value("From", value, 5) + + click.echo(value) + + +def __echo_fast_goal(fast: dict[str, typing.Any], goal: datetime.datetime) -> None: + length = fast["length"] + goal_string = __get_day(goal) + goal_string = f"{goal_string} ({length} hours)" + goal_string = utils.title_and_value("Goal", goal_string, 5) + + click.echo(goal_string) + + +def __echo_fast_elapsed_time( + fast: dict[str, typing.Any], time: datetime.datetime +) -> None: + date1 = fast["started"] + date2 = time if fast.get("stopped") is None else fast["stopped"] + + value = __get_time_difference(date1, date2) + value = utils.title_and_value("Elapsed time", value, 15) + + click.echo(str(value)) + + +def __echo_fast_extra_time(time: datetime.datetime, goal: datetime.datetime) -> None: + value = __get_time_difference(goal, time) if time >= goal else None + value = utils.title_and_value("Extra time", value, 15) + + click.echo(str(value)) + + +def __echo_fast_remaining_time( + time: datetime.datetime, goal: datetime.datetime +) -> None: + value = ( + __get_time_difference(time - datetime.timedelta(minutes=1), goal) + if time < goal + else None + ) + + value = utils.title_and_value("Remaining", value, 15) + + click.echo(str(value)) + + +def __line_for_zone( + note: str, + time: datetime.datetime, + title: str, + start_time: datetime.datetime, + end_time: datetime.datetime | None, +) -> str: + zone_from = f"from {__get_day(start_time)}" + + if end_time is None: + zone_note = note if start_time <= time else "" + else: + zone_note = note if start_time <= time < end_time else "" + + zone_note = utils.title_and_value(f"- {title}", f"{zone_from}{zone_note}") + + return str(zone_note) + + +def __echo_fasting_zones(fast: dict[str, typing.Any], time: datetime.datetime) -> None: + note = " <-- you were here" if utils.is_fast_stopped(fast) else " <-- you are here" + + anabolic_zone = fast["started"] + catabolic_zone = anabolic_zone + datetime.timedelta(hours=4) + fat_burning_zone = catabolic_zone + datetime.timedelta(hours=12) + ketosis_zone = fat_burning_zone + datetime.timedelta(hours=8) + deep_ketosis_zone = ketosis_zone + datetime.timedelta(hours=48) + + click.echo("Fasting zones:") + click.echo("") + + line = __line_for_zone(note, time, "Anabolic", anabolic_zone, catabolic_zone) + click.echo(line) + + line = __line_for_zone(note, time, "Catabolic", catabolic_zone, fat_burning_zone) + click.echo(line) + + line = __line_for_zone(note, time, "Fat burning", fat_burning_zone, ketosis_zone) + click.echo(line) + + line = __line_for_zone(note, time, "Ketosis", ketosis_zone, deep_ketosis_zone) + click.echo(line) + + line = __line_for_zone(note, time, "Anabolic", deep_ketosis_zone, None) + click.echo(line) + + +def __echo_fast_progress_bar( + fast: dict[str, typing.Any], goal: datetime.datetime, time: datetime.datetime +) -> None: + seconds_now = (time - fast["started"]).total_seconds() + seconds_all = (goal - fast["started"]).total_seconds() + + percent = round(seconds_now / seconds_all * 100, 1) + + done_len = int(percent // 2.5) + done_len = min(done_len, 40) + + left_len = int(40 - done_len) + + left = "-" * left_len + done = "#" * done_len + tail = str(percent) + + click.echo(f"{done}{left} {tail}%") + + +def __get_time_difference( + start_date: datetime.datetime, end_date: datetime.datetime +) -> str: + hours, minutes = utils.get_time_difference(start_date, end_date) + + hours_string = str(hours).zfill(2) + minutes_string = str(minutes).zfill(2) + + return f"{hours_string}h {minutes_string}m" + + +def __get_day(date: datetime.datetime) -> str: + if (datetime.datetime.now() - date).days > 7: + fmt = "%Y-%m-%d %H:%M" + else: + fmt = "%a, %H:%M" + + return date.strftime(fmt) diff --git a/fastimer/commands/command_start.py b/fastimer/commands/command_start.py new file mode 100644 index 0000000..9ad5591 --- /dev/null +++ b/fastimer/commands/command_start.py @@ -0,0 +1,38 @@ +def main(path: str) -> None: + """ + Starts a new fast. + """ + + pass + + +# +# fasts = datafile.read_fasts() +# fast = utils.get_active_fast(fasts) +# +# if fast is not None: +# print("Fast is already on.") +# print() +# +# cliutils.ask_for_enter() +# +# else: +# length = None +# +# while length is None: +# user_input = input("Enter fast duration in hours: ") +# +# if user_input.isdigit(): +# length = int(user_input) +# fast = { +# "length": length, +# "started": datetime.datetime.now(), +# } +# +# fasts.append(fast) +# +# datafile.write_fasts(fasts) +# +# else: +# print("Please enter a valid number.") +# print() diff --git a/fastimer/commands/command_stat.py b/fastimer/commands/command_stat.py new file mode 100644 index 0000000..5f45396 --- /dev/null +++ b/fastimer/commands/command_stat.py @@ -0,0 +1,28 @@ +def main(path: str) -> None: + pass + + +# def show_statistics() -> None: +# """ +# Draws fasting statistics accumulated so far. +# """ +# +# fasts = datafile.read_fasts() +# +# print("FASTING STATISTICS") +# print() +# +# statistics.print_completed_fasts(fasts) +# statistics.print_total_fasting_time(fasts) +# statistics.print_average_fast_length(fasts) +# statistics.print_longest_fast_length(fasts) +# statistics.print_longest_fasting_streak(fasts) +# statistics.print_current_fasting_streak(fasts) +# print() +# +# statistics.print_achievements(fasts) +# print() +# +# cliutils.ask_for_enter() +# +# main_menu() diff --git a/fastimer/commands/command_stop.py b/fastimer/commands/command_stop.py new file mode 100644 index 0000000..278ab90 --- /dev/null +++ b/fastimer/commands/command_stop.py @@ -0,0 +1,18 @@ +def main(path: str) -> None: + """ + Finishes the active fast. + """ + + pass + + +# +# fasts = datafile.read_fasts() +# +# cliutils.clear_terminal() +# +# if cliutils.ask_for_yes_or_no("Do you want to end your ongoing fast?"): +# fasts[-1]["stopped"] = datetime.datetime.now() +# datafile.write_fasts(fasts) +# +# main_menu() diff --git a/fastimer/constants.py b/fastimer/constants.py new file mode 100644 index 0000000..51c4ee1 --- /dev/null +++ b/fastimer/constants.py @@ -0,0 +1,6 @@ +#!/usr/bin/env python3 + +"""String literals that are used in app's components.""" + +VERSION = "1.2.3" +ENCODING = "utf-8" diff --git a/fastimer/datafile.py b/fastimer/datafile.py index 8d08d38..9721133 100644 --- a/fastimer/datafile.py +++ b/fastimer/datafile.py @@ -4,22 +4,22 @@ Method to work with the fasts journal. """ +import os import typing -from os.path import isfile from yaml import parser, safe_dump, safe_load -def read_fasts() -> list[dict[str, typing.Any]]: +def read_fasts(path: str) -> list[dict[str, typing.Any]]: """ Reads the fasts journal. """ - yaml_file_name = __get_file_name() + yaml_file_name = os.path.join(path, __get_file_name()) fasts = [] - if isfile(yaml_file_name): + if os.path.isfile(yaml_file_name): try: with open(yaml_file_name, encoding="utf-8-sig") as yaml_file: fasts = safe_load(yaml_file) @@ -30,7 +30,7 @@ def read_fasts() -> list[dict[str, typing.Any]]: return fasts -def write_fasts(fasts: list[dict[str, typing.Any]]) -> None: +def write_fasts(path: str, fasts: list[dict[str, typing.Any]]) -> None: """ Writes the fasts journal. """ @@ -41,7 +41,7 @@ def write_fasts(fasts: list[dict[str, typing.Any]]) -> None: if fast.get("stopped") is not None: fast["stopped"] = fast["stopped"].replace(microsecond=0) - yaml_file_name = __get_file_name() + yaml_file_name = os.path.join(path, __get_file_name()) with open(yaml_file_name, encoding="utf-8-sig", mode="w") as yaml_file: safe_dump(fasts, yaml_file) diff --git a/fastimer/utils.py b/fastimer/utils.py index 1ecf8fe..e2109ef 100644 --- a/fastimer/utils.py +++ b/fastimer/utils.py @@ -79,3 +79,16 @@ def get_fast_length(fast: dict[str, typing.Any]) -> tuple[int, int]: result = 0, 0 return result + + +def title_and_value(title: str, value: str, width: int = 15) -> str: + """ + Aligns title & value by a given position. + """ + + if len(title) > width: + width = len(title) + + title = f"{title}:".ljust(width) + + return f"{title} {value}" diff --git a/fastimer/view.py b/fastimer/view.py index 2282552..61cc689 100644 --- a/fastimer/view.py +++ b/fastimer/view.py @@ -8,183 +8,3 @@ from vkostyanetsky.cliutils import title_and_value # type: ignore from .utils import get_time_difference, is_fast_completed, is_fast_stopped - - -def get(fast: dict[str, typing.Any], include_zones: bool = False) -> list[str]: - """ - Generates a detailed view of a fast. - """ - - time = fast["stopped"] if is_fast_stopped(fast) else datetime.datetime.now() - - goal = fast["started"] + datetime.timedelta(hours=fast["length"]) - - description = [ - __get_fast_title(fast), - "", - __get_fast_from(fast), - __get_fast_goal(fast, goal), - "", - ] - - if include_zones: - __include_fasting_zones(description, fast, time) - description.append("") - - description.append(__get_fast_progress_bar(fast, goal, time)) - - description.append("") - - description.append(__get_fast_elapsed_time(fast, time)) - - if time <= goal: - description.append(__get_fast_remaining_time(time, goal)) - else: - description.append(__get_fast_extra_time(time, goal)) - - return description - - -def __get_fast_title(fast: dict[str, typing.Any]) -> str: - if is_fast_stopped(fast): - title = "COMPLETED FAST" if is_fast_completed(fast) else "FAILED FAST" - else: - title = "ACTIVE FAST" - - return title - - -def __get_fast_from(fast: dict[str, typing.Any]) -> str: - value = __get_day(fast["started"]) - value = title_and_value("From", value, 5) - - return str(value) - - -def __get_fast_goal(fast: dict[str, typing.Any], goal: datetime.datetime) -> str: - length = fast["length"] - goal_string = __get_day(goal) - goal_string = f"{goal_string} ({length} hours)" - goal_string = title_and_value("Goal", goal_string, 5) - - return str(goal_string) - - -def __get_fast_elapsed_time( - fast: dict[str, typing.Any], time: datetime.datetime -) -> str: - date1 = fast["started"] - date2 = time if fast.get("stopped") is None else fast["stopped"] - - value = __get_time_difference(date1, date2) - value = title_and_value("Elapsed time", value, 15) - - return str(value) - - -def __get_fast_extra_time(time: datetime.datetime, goal: datetime.datetime) -> str: - value = __get_time_difference(goal, time) if time >= goal else None - value = title_and_value("Extra time", value, 15) - - return str(value) - - -def __get_fast_remaining_time(time: datetime.datetime, goal: datetime.datetime) -> str: - value = ( - __get_time_difference(time - datetime.timedelta(minutes=1), goal) - if time < goal - else None - ) - - value = title_and_value("Remaining", value, 15) - - return str(value) - - -def __line_for_zone( - note: str, - time: datetime.datetime, - title: str, - start_time: datetime.datetime, - end_time: datetime.datetime | None, -) -> str: - zone_from = f"from {__get_day(start_time)}" - - if end_time is None: - zone_note = note if start_time <= time else "" - else: - zone_note = note if start_time <= time < end_time else "" - - zone_note = title_and_value(f"- {title}", f"{zone_from}{zone_note}") - - return str(zone_note) - - -def __include_fasting_zones( - lines: list[str], fast: dict[str, typing.Any], time: datetime.datetime -) -> None: - note = " <-- you were here" if is_fast_stopped(fast) else " <-- you are here" - - anabolic_zone = fast["started"] - catabolic_zone = anabolic_zone + datetime.timedelta(hours=4) - fat_burning_zone = catabolic_zone + datetime.timedelta(hours=12) - ketosis_zone = fat_burning_zone + datetime.timedelta(hours=8) - deep_ketosis_zone = ketosis_zone + datetime.timedelta(hours=48) - - lines.append("Fasting zones:") - lines.append("") - - line = __line_for_zone(note, time, "Anabolic", anabolic_zone, catabolic_zone) - lines.append(line) - - line = __line_for_zone(note, time, "Catabolic", catabolic_zone, fat_burning_zone) - lines.append(line) - - line = __line_for_zone(note, time, "Fat burning", fat_burning_zone, ketosis_zone) - lines.append(line) - - line = __line_for_zone(note, time, "Ketosis", ketosis_zone, deep_ketosis_zone) - lines.append(line) - - line = __line_for_zone(note, time, "Anabolic", deep_ketosis_zone, None) - lines.append(line) - - -def __get_fast_progress_bar( - fast: dict[str, typing.Any], goal: datetime.datetime, time: datetime.datetime -) -> str: - seconds_now = (time - fast["started"]).total_seconds() - seconds_all = (goal - fast["started"]).total_seconds() - - percent = round(seconds_now / seconds_all * 100, 1) - - done_len = int(percent // 2.5) - done_len = min(done_len, 40) - - left_len = int(40 - done_len) - - left = "-" * left_len - done = "#" * done_len - tail = str(percent) - - return f"{done}{left} {tail}%" - - -def __get_time_difference( - start_date: datetime.datetime, end_date: datetime.datetime -) -> str: - hours, minutes = get_time_difference(start_date, end_date) - - hours_string = str(hours).zfill(2) - minutes_string = str(minutes).zfill(2) - - return f"{hours_string}h {minutes_string}m" - - -def __get_day(date: datetime.datetime) -> str: - if (datetime.datetime.now() - date).days > 7: - fmt = "%Y-%m-%d %H:%M" - else: - fmt = "%a, %H:%M" - - return date.strftime(fmt) diff --git a/requirements.txt b/requirements.txt index 4048a63..8417ef5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -keyboard==0.13.5 -PyYAML==6.0.1 -setuptools>=58.1.0 -vkostyanetsky.cliutils==0.2.0 +PyYAML~=6.0.1 +setuptools==68.0.0 +click~=8.1.6 diff --git a/setup.py b/setup.py index 22fd3ba..8deff1b 100644 --- a/setup.py +++ b/setup.py @@ -5,14 +5,14 @@ """ from setuptools import setup # type: ignore -from fastimer.version import __version__ +from fastimer import constants with open("README.md", encoding="utf-8-sig") as readme_file: long_description = readme_file.read() setup( name="fastimer", - version=__version__, + version=constants.VERSION, description="A simple CLI tool to track food you consume", long_description=long_description, long_description_content_type="text/markdown", @@ -21,11 +21,10 @@ python_requires=">=3.10", packages=["fastimer"], install_requires=[ - "PyYAML==6.0.1", - "keyboard~=0.13.5", - "vkostyanetsky.cliutils~=0.2.0", + "pyyaml~=6.0.1", + "click~=8.1.6", ], - entry_points={"console_scripts": ["fastimer=fastimer.app:main"]}, + entry_points={"console_scripts": ["fastimer=fastimer.app:cli"]}, author="Vlad Kostyanetsky", author_email="vlad@kostyanetsky.me", classifiers=[