diff --git a/typer-cli-python/README.md b/typer-cli-python/README.md new file mode 100644 index 0000000000..ed554f8e8d --- /dev/null +++ b/typer-cli-python/README.md @@ -0,0 +1,96 @@ +# RP To-Do + +**RP To-Do** is a command-line interface application built with [Typer](https://typer.tiangolo.com/) to help you manage your to-do list. + +## Installation + +To run **RP To-Do**, you need to run the following steps: + +1. Download the application's source code to a `rptodo_project/` directory +2. Create a Python virtual environment and activate it + +```sh +$ cd rptodo_project/ +$ python -m venv ./venv +$ source venv/bin/activate +(venv) $ +``` + +2. Install the dependencies + +```sh +(venv) $ python -m pip install -r requirements.txt +``` + +3. Initialize the application + +```sh +(venv) $ python -m rptodo init +``` + +This command asks you to introduce the file path to store the application's database. You can also accept the default file path by pressing Enter. + +## Usage + +Once you've download the source code and run the installation steps, you can run the following command to access the application's usage description: + +```sh +$ python -m rptodo --help +Usage: rptodo [OPTIONS] COMMAND [ARGS]... + +Options: + -v, --version Show the application's version and exit. + --install-completion Install completion for the current shell. + --show-completion Show completion for the current shell, to copy it or + customize the installation. + + --help Show this message and exit. + +Commands: + add Add a new to-do with a DESCRIPTION. + clear Remove all to-dos. + complete Complete a to-do by setting it as done using its TODO_ID. + init Initialize the to-do database. + list List all to-dos. + remove Remove a to-do using its TODO_ID. +``` + +You can also access the help message for specific commands by typing the command and then `--help`. For example, to display the help content for the `add` command, you can run the following: + +```sh +$ python -m rptodo add --help +Usage: rptodo add [OPTIONS] DESCRIPTION... + + Add a new to-do with a DESCRIPTION. + +Arguments: + DESCRIPTION... [required] + +Options: + -p, --priority INTEGER RANGE [default: 2] + --help Show this message and exit. +``` + +Calling `--help` on each command provides specific and useful information about how to use the command at hand. + +## Features + +**RP To-Do** has the following features: + +| Command | Description | +| ------------------ | ------------------------------------------------------------ | +| `init` | Initializes the application's to-do database. | +| `add DESCRIPTION` | Adds a new to-do to the database with a `DESCRIPTION`. | +| `list` | Lists all the to-dos in the database. | +| `complete TODO_ID` | Completes a to-do by setting it as done using its `TODO_ID`. | +| `remove TODO_ID` | Removes a to-do from the database using its `TODO_ID`. | +| `clear` | Removes all the to-dos by clearing the database. | + +## Release History + +- 0.1.0 + - A work in progress + +## About the Author + +Leodanis Pozo Ramos - Email: leodanis@realpython.com diff --git a/typer-cli-python/source_code_final/README.md b/typer-cli-python/source_code_final/README.md new file mode 100644 index 0000000000..ff68e807b1 --- /dev/null +++ b/typer-cli-python/source_code_final/README.md @@ -0,0 +1,96 @@ +# RP To-Do + +**RP To-Do** is a command-line interface application built with [Typer](https://typer.tiangolo.com/) to help you manage your to-do list. + +## Installation + +To run **RP To-Do**, you need to run the following steps: + +1. Download the application's source code to a `rptodo_project/` directory +2. Create a Python virtual environment and activate it + +```sh +$ cd rptodo_project/ +$ python -m venv ./venv +$ source venv/bin/activate +(venv) $ +``` + +2. Install the dependencies + +```sh +(venv) $ python -m pip install -r requirements.txt +``` + +3. Initialize the application + +```sh +(venv) $ python -m rptodo init +``` + +This command asks you to introduce the file path to store the application's database. You can also accept the default file path by pressing enter. + +## Usage + +Once you've download the source code and run the installation steps, you can run the following command to access the application's usage description: + +```sh +$ python -m rptodo --help +Usage: rptodo [OPTIONS] COMMAND [ARGS]... + +Options: + -v, --version Show the application's version and exit. + --install-completion Install completion for the current shell. + --show-completion Show completion for the current shell, to copy it or + customize the installation. + + --help Show this message and exit. + +Commands: + add Add a new to-do with a DESCRIPTION. + clear Remove all to-dos. + complete Complete a to-do by setting it as done using its TODO_ID. + init Initialize the to-do database. + list List all to-dos. + remove Remove a to-do using its TODO_ID. +``` + +You can also access the help message for specific commands by typing the command and then `--help`. For example, to display the help content for the `add` command, you can run the following: + +```sh +$ python -m rptodo add --help +Usage: rptodo add [OPTIONS] DESCRIPTION... + + Add a new to-do with a DESCRIPTION. + +Arguments: + DESCRIPTION... [required] + +Options: + -p, --priority INTEGER RANGE [default: 2] + --help Show this message and exit. +``` + +Calling `--help` on each command provides specific and useful information about how to use the command at hand. + +## Features + +**RP To-Do** has the following features: + +| Command | Description | +| ------------------ | ------------------------------------------------------------ | +| `init` | Initializes the application's to-do database. | +| `add DESCRIPTION` | Adds a new to-do to the database with a `DESCRIPTION`. | +| `list` | Lists all the to-dos in the database. | +| `complete TODO_ID` | Completes a to-do by setting it as done using its `TODO_ID`. | +| `remove TODO_ID` | Removes a to-do from the database using its `TODO_ID`. | +| `clear` | Removes all the to-dos by clearing the database. | + +## Release History + +- 0.1.0 + - A work in progress + +## About the Author + +Leodanis Pozo Ramos - Email: leodanis@realpython.com diff --git a/typer-cli-python/source_code_final/requirements.txt b/typer-cli-python/source_code_final/requirements.txt new file mode 100644 index 0000000000..e5e0199018 --- /dev/null +++ b/typer-cli-python/source_code_final/requirements.txt @@ -0,0 +1,4 @@ +typer==0.3.2 +colorama==0.4.4 +shellingham==1.4.0 +pytest==6.2.4 diff --git a/typer-cli-python/source_code_final/rptodo/__init__.py b/typer-cli-python/source_code_final/rptodo/__init__.py new file mode 100644 index 0000000000..346d9d75ed --- /dev/null +++ b/typer-cli-python/source_code_final/rptodo/__init__.py @@ -0,0 +1,22 @@ +"""Top-level package for RP To-Do.""" + +__app_name__ = "rptodo" +__version__ = "0.1.0" + +( + SUCCESS, + DIR_ERROR, + FILE_ERROR, + DB_READ_ERROR, + DB_WRITE_ERROR, + JSON_ERROR, + ID_ERROR, +) = range(7) + +ERRORS = { + DIR_ERROR: "config directory error", + FILE_ERROR: "config file error", + DB_READ_ERROR: "database read error", + DB_WRITE_ERROR: "database write error", + ID_ERROR: "to-do id error", +} diff --git a/typer-cli-python/source_code_final/rptodo/__main__.py b/typer-cli-python/source_code_final/rptodo/__main__.py new file mode 100644 index 0000000000..57c45b04a2 --- /dev/null +++ b/typer-cli-python/source_code_final/rptodo/__main__.py @@ -0,0 +1,11 @@ +"""RP To-Do entry point script.""" + +from rptodo import cli, __app_name__ + + +def main(): + cli.app(prog_name=__app_name__) + + +if __name__ == "__main__": + main() diff --git a/typer-cli-python/source_code_final/rptodo/cli.py b/typer-cli-python/source_code_final/rptodo/cli.py new file mode 100644 index 0000000000..9e9d84944a --- /dev/null +++ b/typer-cli-python/source_code_final/rptodo/cli.py @@ -0,0 +1,217 @@ +"""This module provides the RP To-Do CLI.""" + +from pathlib import Path +from typing import List, Optional + +import typer + +from rptodo import ERRORS, __app_name__, __version__, config, database, rptodo + +app = typer.Typer() + + +@app.command() +def init( + db_path: str = typer.Option( + str(database.DEFAULT_DB_FILE_PATH), + "--db-path", + "-db", + prompt="to-do database location?", + ), +) -> None: + """Initialize the to-do database.""" + app_init_error = config.init_app(db_path) + if app_init_error: + typer.secho( + f'Creating config file failed with "{ERRORS[app_init_error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + db_init_error = database.init_database(Path(db_path)) + if db_init_error: + typer.secho( + f'Creating database failed with "{ERRORS[db_init_error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + else: + typer.secho(f"The to-do database is {db_path}", fg=typer.colors.GREEN) + + +def get_todoer() -> rptodo.Todoer: + if config.CONFIG_FILE_PATH.exists(): + db_path = database.get_database_path(config.CONFIG_FILE_PATH) + else: + typer.secho( + 'Config file not found. Please, run "rptodo init"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + if db_path.exists(): + return rptodo.Todoer(db_path) + else: + typer.secho( + 'Database not found. Please, run "rptodo init"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + + +@app.command() +def add( + description: List[str] = typer.Argument(...), + priority: int = typer.Option(2, "--priority", "-p", min=1, max=3), +) -> None: + """Add a new to-do with a DESCRIPTION.""" + todoer = get_todoer() + todo, error = todoer.add(description, priority) + if error: + typer.secho( + f'Adding to-do failed with "{ERRORS[error]}"', fg=typer.colors.RED + ) + raise typer.Exit(1) + else: + typer.secho( + f"""to-do: "{todo['Description']}" was added """ + f"""with priority: {priority}""", + fg=typer.colors.GREEN, + ) + + +@app.command(name="list") +def list_all() -> None: + """List all to-dos.""" + todoer = get_todoer() + todo_list = todoer.get_todo_list() + if len(todo_list) == 0: + typer.secho( + "There are no tasks in the to-do list yet", fg=typer.colors.RED + ) + raise typer.Exit() + typer.secho("\nto-do list:\n", fg=typer.colors.BLUE, bold=True) + columns = ( + "ID. ", + "| Priority ", + "| Done ", + "| Description ", + ) + headers = "".join(columns) + typer.secho(headers, fg=typer.colors.BLUE, bold=True) + typer.secho("-" * len(headers), fg=typer.colors.BLUE) + for id, todo in enumerate(todo_list, 1): + desc, priority, done = todo.values() + typer.secho( + f"{id}{(len(columns[0]) - len(str(id))) * ' '}" + f"| ({priority}){(len(columns[1]) - len(str(priority)) - 4) * ' '}" + f"| {done}{(len(columns[2]) - len(str(done)) - 2) * ' '}" + f"| {desc}", + fg=typer.colors.BLUE, + ) + typer.secho("-" * len(headers) + "\n", fg=typer.colors.BLUE) + + +@app.command(name="complete") +def set_done(todo_id: int = typer.Argument(...)) -> None: + """Complete a to-do by setting it as done using its TODO_ID.""" + todoer = get_todoer() + todo, error = todoer.set_done(todo_id) + if error: + typer.secho( + f'Completing to-do # "{todo_id}" failed with "{ERRORS[error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + else: + typer.secho( + f"""to-do # {todo_id} "{todo['Description']}" completed!""", + fg=typer.colors.GREEN, + ) + + +@app.command() +def remove( + todo_id: int = typer.Argument(...), + force: bool = typer.Option( + False, + "--force", + "-f", + help="Force deletion without confirmation.", + ), +) -> None: + """Remove a to-do using its TODO_ID.""" + todoer = get_todoer() + + def _remove(): + todo, error = todoer.remove(todo_id) + if error: + typer.secho( + f'Removing to-do # {todo_id} failed with "{ERRORS[error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + else: + typer.secho( + f"""to-do # {todo_id}: '{todo["Description"]}' was removed""", + fg=typer.colors.GREEN, + ) + + if force: + _remove() + else: + todo_list = todoer.get_todo_list() + try: + todo = todo_list[todo_id - 1] + except IndexError: + typer.secho("Invalid TODO_ID", fg=typer.colors.RED) + raise typer.Exit(1) + delete = typer.confirm( + f"Delete to-do # {todo_id}: {todo['Description']}?" + ) + if delete: + _remove() + else: + typer.echo("Operation canceled") + + +@app.command(name="clear") +def remove_all( + force: bool = typer.Option( + ..., + prompt="Delete all to-dos?", + help="Force deletion without confirmation.", + ), +) -> None: + """Remove all to-dos.""" + todoer = get_todoer() + if force: + error = todoer.remove_all().error + if error: + typer.secho( + f'Removing to-dos failed with "{ERRORS[error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + else: + typer.secho("All to-dos were removed", fg=typer.colors.GREEN) + else: + typer.echo("Operation canceled") + + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"{__app_name__} v{__version__}") + raise typer.Exit() + + +@app.callback() +def main( + version: Optional[bool] = typer.Option( + None, + "--version", + "-v", + help="Show the application's version and exit.", + callback=_version_callback, + is_eager=True, + ) +) -> None: + return diff --git a/typer-cli-python/source_code_final/rptodo/config.py b/typer-cli-python/source_code_final/rptodo/config.py new file mode 100644 index 0000000000..22d8a30467 --- /dev/null +++ b/typer-cli-python/source_code_final/rptodo/config.py @@ -0,0 +1,45 @@ +"""This module provides the RP To-Do config functionality.""" + +import configparser +from pathlib import Path + +import typer + +from rptodo import DB_WRITE_ERROR, DIR_ERROR, FILE_ERROR, SUCCESS, __app_name__ + +CONFIG_DIR_PATH = Path(typer.get_app_dir(__app_name__)) +CONFIG_FILE_PATH = CONFIG_DIR_PATH / "config.ini" + + +def init_app(db_path: str) -> int: + """Initialize the application.""" + config_code = _init_config_file() + if config_code != SUCCESS: + return config_code + database_code = _create_database(db_path) + if database_code != SUCCESS: + return database_code + return SUCCESS + + +def _init_config_file() -> int: + try: + CONFIG_DIR_PATH.mkdir(exist_ok=True) + except OSError: + return DIR_ERROR + try: + CONFIG_FILE_PATH.touch(exist_ok=True) + except OSError: + return FILE_ERROR + return SUCCESS + + +def _create_database(db_path: str) -> int: + config_parser = configparser.ConfigParser() + config_parser["General"] = {"database": db_path} + try: + with CONFIG_FILE_PATH.open("w") as file: + config_parser.write(file) + except OSError: + return DB_WRITE_ERROR + return SUCCESS diff --git a/typer-cli-python/source_code_final/rptodo/database.py b/typer-cli-python/source_code_final/rptodo/database.py new file mode 100644 index 0000000000..b703fafc25 --- /dev/null +++ b/typer-cli-python/source_code_final/rptodo/database.py @@ -0,0 +1,56 @@ +"""This module provides the RP To-Do database functionality.""" + +import configparser +import json +from pathlib import Path +from typing import Any, Dict, List, NamedTuple + +from rptodo import DB_READ_ERROR, DB_WRITE_ERROR, JSON_ERROR, SUCCESS + +DEFAULT_DB_FILE_PATH = Path.home().joinpath( + "." + Path.home().stem + "_todo.json" +) + + +def get_database_path(config_file: Path) -> Path: + """Return the current path to the to-do database.""" + config_parser = configparser.ConfigParser() + config_parser.read(config_file) + return Path(config_parser["General"]["database"]) + + +def init_database(db_path: Path) -> int: + """Create the to-do database.""" + try: + db_path.write_text("[]") # Empty to-do list + return SUCCESS + except OSError: + return DB_WRITE_ERROR + + +class DBResponse(NamedTuple): + todo_list: List[Dict[str, Any]] + error: int + + +class DatabaseHandler: + def __init__(self, db_path: Path) -> None: + self._db_path = db_path + + def read_todos(self) -> DBResponse: + try: + with self._db_path.open("r") as db: + try: + return DBResponse(json.load(db), SUCCESS) + except json.JSONDecodeError: # Catch wrong JSON format + return DBResponse([], JSON_ERROR) + except OSError: # Catch file IO problems + return DBResponse([], DB_READ_ERROR) + + def write_todos(self, todo_list: List[Dict[str, Any]]) -> DBResponse: + try: + with self._db_path.open("w") as db: + json.dump(todo_list, db, indent=4) + return DBResponse(todo_list, SUCCESS) + except OSError: # Catch file IO problems + return DBResponse(todo_list, DB_WRITE_ERROR) diff --git a/typer-cli-python/source_code_final/rptodo/rptodo.py b/typer-cli-python/source_code_final/rptodo/rptodo.py new file mode 100644 index 0000000000..ec53186132 --- /dev/null +++ b/typer-cli-python/source_code_final/rptodo/rptodo.py @@ -0,0 +1,69 @@ +"""This module provides the RP To-Do model-controller.""" + +from pathlib import Path +from typing import Any, Dict, List, NamedTuple + +from rptodo import DB_READ_ERROR, ID_ERROR +from rptodo.database import DatabaseHandler + + +class CurrentTodo(NamedTuple): + todo: Dict[str, Any] + error: int + + +class Todoer: + def __init__(self, db_path: Path) -> None: + self._db_handler = DatabaseHandler(db_path) + + def add(self, description: List[str], priority: int = 2) -> CurrentTodo: + """Add a new to-do to the database.""" + description_text = " ".join(description) + if not description_text.endswith("."): + description_text += "." + todo = { + "Description": description_text, + "Priority": priority, + "Done": False, + } + read = self._db_handler.read_todos() + if read.error == DB_READ_ERROR: + return CurrentTodo(todo, read.error) + read.todo_list.append(todo) + write = self._db_handler.write_todos(read.todo_list) + return CurrentTodo(todo, write.error) + + def get_todo_list(self) -> List[Dict[str, Any]]: + """Return the current to-do list.""" + read = self._db_handler.read_todos() + return read.todo_list + + def set_done(self, todo_id: int) -> CurrentTodo: + """Set a to-do as done.""" + read = self._db_handler.read_todos() + if read.error: + return CurrentTodo({}, read.error) + try: + todo = read.todo_list[todo_id - 1] + except IndexError: + return CurrentTodo({}, ID_ERROR) + todo["Done"] = True + write = self._db_handler.write_todos(read.todo_list) + return CurrentTodo(todo, write.error) + + def remove(self, todo_id: int) -> CurrentTodo: + """Remove a to-do from the database using its id or index.""" + read = self._db_handler.read_todos() + if read.error: + return CurrentTodo({}, read.error) + try: + todo = read.todo_list.pop(todo_id - 1) + except IndexError: + return CurrentTodo({}, ID_ERROR) + write = self._db_handler.write_todos(read.todo_list) + return CurrentTodo(todo, write.error) + + def remove_all(self) -> CurrentTodo: + """Remove all to-dos from the database.""" + write = self._db_handler.write_todos([]) + return CurrentTodo({}, write.error) diff --git a/typer-cli-python/source_code_final/tests/__init__.py b/typer-cli-python/source_code_final/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_final/tests/test_rptodo.py b/typer-cli-python/source_code_final/tests/test_rptodo.py new file mode 100644 index 0000000000..88e98142a5 --- /dev/null +++ b/typer-cli-python/source_code_final/tests/test_rptodo.py @@ -0,0 +1,151 @@ +import json + +import pytest +from typer.testing import CliRunner + +from rptodo import ( + DB_READ_ERROR, + ID_ERROR, + SUCCESS, + __app_name__, + __version__, + cli, + rptodo, +) + + +runner = CliRunner() + + +def test_version(): + result = runner.invoke(cli.app, ["--version"]) + assert result.exit_code == 0 + assert f"{__app_name__} v{__version__}\n" in result.stdout + + +@pytest.fixture +def mock_json_file(tmp_path): + todo = [{"Description": "Get some milk.", "Priority": 2, "Done": False}] + db_file = tmp_path / "todo.json" + with db_file.open("w") as db: + json.dump(todo, db, indent=4) + return db_file + + +test_data1 = { + "description": ["Clean", "the", "house"], + "priority": 1, + "todo": { + "Description": "Clean the house.", + "Priority": 1, + "Done": False, + }, +} +test_data2 = { + "description": ["Wash the car"], + "priority": 2, + "todo": { + "Description": "Wash the car.", + "Priority": 2, + "Done": False, + }, +} + + +@pytest.mark.parametrize( + "description, priority, expected", + [ + pytest.param( + test_data1["description"], + test_data1["priority"], + (test_data1["todo"], SUCCESS), + ), + pytest.param( + test_data2["description"], + test_data2["priority"], + (test_data2["todo"], SUCCESS), + ), + ], +) +def test_add(mock_json_file, description, priority, expected): + todoer = rptodo.Todoer(mock_json_file) + assert todoer.add(description, priority) == expected + read = todoer._db_handler.read_todos() + assert len(read.todo_list) == 2 + + +@pytest.fixture +def mock_wrong_json_file(tmp_path): + db_file = tmp_path / "todo.json" + return db_file + + +def test_add_wrong_json_file(mock_wrong_json_file): + todoer = rptodo.Todoer(mock_wrong_json_file) + response = todoer.add(["test task"], 1) + assert response.error == DB_READ_ERROR + read = todoer._db_handler.read_todos() + assert len(read.todo_list) == 0 + + +@pytest.fixture +def mock_wrong_json_format(tmp_path): + db_file = tmp_path / "todo.json" + with db_file.open("w") as db: + db.write("") + return db_file + + +def test_add_wrong_json_format(mock_wrong_json_format): + todoer = rptodo.Todoer(mock_wrong_json_format) + assert todoer.add(test_data1["description"], test_data1["priority"]) == ( + test_data1["todo"], + SUCCESS, + ) + read = todoer._db_handler.read_todos() + assert len(read.todo_list) == 1 + + +test_todo1 = { + "Description": "Get some milk.", + "Priority": 2, + "Done": True, +} +test_todo2 = {} + + +@pytest.mark.parametrize( + "todo_id, expected", + [ + pytest.param(1, (test_todo1, SUCCESS)), + pytest.param(3, (test_todo2, ID_ERROR)), + ], +) +def test_set_done(mock_json_file, todo_id, expected): + todoer = rptodo.Todoer(mock_json_file) + assert todoer.set_done(todo_id) == expected + + +test_todo3 = { + "Description": "Get some milk.", + "Priority": 2, + "Done": False, +} +test_todo4 = {} + + +@pytest.mark.parametrize( + "todo_id, expected", + [ + pytest.param(1, (test_todo3, SUCCESS)), + pytest.param(3, (test_todo4, ID_ERROR)), + ], +) +def test_remove(mock_json_file, todo_id, expected): + todoer = rptodo.Todoer(mock_json_file) + assert todoer.remove(todo_id) == expected + + +def test_remove_all(mock_json_file): + todoer = rptodo.Todoer(mock_json_file) + assert todoer.remove_all() == ({}, SUCCESS) diff --git a/typer-cli-python/source_code_step_1/README.md b/typer-cli-python/source_code_step_1/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_1/requirements.txt b/typer-cli-python/source_code_step_1/requirements.txt new file mode 100644 index 0000000000..e5e0199018 --- /dev/null +++ b/typer-cli-python/source_code_step_1/requirements.txt @@ -0,0 +1,4 @@ +typer==0.3.2 +colorama==0.4.4 +shellingham==1.4.0 +pytest==6.2.4 diff --git a/typer-cli-python/source_code_step_1/rptodo/__init__.py b/typer-cli-python/source_code_step_1/rptodo/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_1/rptodo/__main__.py b/typer-cli-python/source_code_step_1/rptodo/__main__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_1/rptodo/cli.py b/typer-cli-python/source_code_step_1/rptodo/cli.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_1/rptodo/config.py b/typer-cli-python/source_code_step_1/rptodo/config.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_1/rptodo/database.py b/typer-cli-python/source_code_step_1/rptodo/database.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_1/rptodo/rptodo.py b/typer-cli-python/source_code_step_1/rptodo/rptodo.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_1/tests/__init__.py b/typer-cli-python/source_code_step_1/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_1/tests/test_rptodo.py b/typer-cli-python/source_code_step_1/tests/test_rptodo.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_2/README.md b/typer-cli-python/source_code_step_2/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_2/requirements.txt b/typer-cli-python/source_code_step_2/requirements.txt new file mode 100644 index 0000000000..e5e0199018 --- /dev/null +++ b/typer-cli-python/source_code_step_2/requirements.txt @@ -0,0 +1,4 @@ +typer==0.3.2 +colorama==0.4.4 +shellingham==1.4.0 +pytest==6.2.4 diff --git a/typer-cli-python/source_code_step_2/rptodo/__init__.py b/typer-cli-python/source_code_step_2/rptodo/__init__.py new file mode 100644 index 0000000000..346d9d75ed --- /dev/null +++ b/typer-cli-python/source_code_step_2/rptodo/__init__.py @@ -0,0 +1,22 @@ +"""Top-level package for RP To-Do.""" + +__app_name__ = "rptodo" +__version__ = "0.1.0" + +( + SUCCESS, + DIR_ERROR, + FILE_ERROR, + DB_READ_ERROR, + DB_WRITE_ERROR, + JSON_ERROR, + ID_ERROR, +) = range(7) + +ERRORS = { + DIR_ERROR: "config directory error", + FILE_ERROR: "config file error", + DB_READ_ERROR: "database read error", + DB_WRITE_ERROR: "database write error", + ID_ERROR: "to-do id error", +} diff --git a/typer-cli-python/source_code_step_2/rptodo/__main__.py b/typer-cli-python/source_code_step_2/rptodo/__main__.py new file mode 100644 index 0000000000..57c45b04a2 --- /dev/null +++ b/typer-cli-python/source_code_step_2/rptodo/__main__.py @@ -0,0 +1,11 @@ +"""RP To-Do entry point script.""" + +from rptodo import cli, __app_name__ + + +def main(): + cli.app(prog_name=__app_name__) + + +if __name__ == "__main__": + main() diff --git a/typer-cli-python/source_code_step_2/rptodo/cli.py b/typer-cli-python/source_code_step_2/rptodo/cli.py new file mode 100644 index 0000000000..d9733cb8dd --- /dev/null +++ b/typer-cli-python/source_code_step_2/rptodo/cli.py @@ -0,0 +1,29 @@ +"""This module provides the RP To-Do CLI.""" + +from typing import Optional + +import typer + +from rptodo import __app_name__, __version__ + +app = typer.Typer() + + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"{__app_name__} v{__version__}") + raise typer.Exit() + + +@app.callback() +def main( + version: Optional[bool] = typer.Option( + None, + "--version", + "-v", + help="Show the application's version and exit.", + callback=_version_callback, + is_eager=True, + ) +) -> None: + return diff --git a/typer-cli-python/source_code_step_2/rptodo/config.py b/typer-cli-python/source_code_step_2/rptodo/config.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_2/rptodo/database.py b/typer-cli-python/source_code_step_2/rptodo/database.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_2/rptodo/rptodo.py b/typer-cli-python/source_code_step_2/rptodo/rptodo.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_2/tests/__init__.py b/typer-cli-python/source_code_step_2/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_2/tests/test_rptodo.py b/typer-cli-python/source_code_step_2/tests/test_rptodo.py new file mode 100644 index 0000000000..d86d142ec2 --- /dev/null +++ b/typer-cli-python/source_code_step_2/tests/test_rptodo.py @@ -0,0 +1,11 @@ +from typer.testing import CliRunner + +from rptodo import __app_name__, __version__, cli + +runner = CliRunner() + + +def test_version(): + result = runner.invoke(cli.app, ["--version"]) + assert result.exit_code == 0 + assert f"{__app_name__} v{__version__}\n" in result.stdout diff --git a/typer-cli-python/source_code_step_3/README.md b/typer-cli-python/source_code_step_3/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_3/requirements.txt b/typer-cli-python/source_code_step_3/requirements.txt new file mode 100644 index 0000000000..e5e0199018 --- /dev/null +++ b/typer-cli-python/source_code_step_3/requirements.txt @@ -0,0 +1,4 @@ +typer==0.3.2 +colorama==0.4.4 +shellingham==1.4.0 +pytest==6.2.4 diff --git a/typer-cli-python/source_code_step_3/rptodo/__init__.py b/typer-cli-python/source_code_step_3/rptodo/__init__.py new file mode 100644 index 0000000000..346d9d75ed --- /dev/null +++ b/typer-cli-python/source_code_step_3/rptodo/__init__.py @@ -0,0 +1,22 @@ +"""Top-level package for RP To-Do.""" + +__app_name__ = "rptodo" +__version__ = "0.1.0" + +( + SUCCESS, + DIR_ERROR, + FILE_ERROR, + DB_READ_ERROR, + DB_WRITE_ERROR, + JSON_ERROR, + ID_ERROR, +) = range(7) + +ERRORS = { + DIR_ERROR: "config directory error", + FILE_ERROR: "config file error", + DB_READ_ERROR: "database read error", + DB_WRITE_ERROR: "database write error", + ID_ERROR: "to-do id error", +} diff --git a/typer-cli-python/source_code_step_3/rptodo/__main__.py b/typer-cli-python/source_code_step_3/rptodo/__main__.py new file mode 100644 index 0000000000..57c45b04a2 --- /dev/null +++ b/typer-cli-python/source_code_step_3/rptodo/__main__.py @@ -0,0 +1,11 @@ +"""RP To-Do entry point script.""" + +from rptodo import cli, __app_name__ + + +def main(): + cli.app(prog_name=__app_name__) + + +if __name__ == "__main__": + main() diff --git a/typer-cli-python/source_code_step_3/rptodo/cli.py b/typer-cli-python/source_code_step_3/rptodo/cli.py new file mode 100644 index 0000000000..89531fcd5b --- /dev/null +++ b/typer-cli-python/source_code_step_3/rptodo/cli.py @@ -0,0 +1,58 @@ +"""This module provides the RP To-Do CLI.""" + +from pathlib import Path +from typing import Optional + +import typer + +from rptodo import ERRORS, __app_name__, __version__, config, database + +app = typer.Typer() + + +@app.command() +def init( + db_path: str = typer.Option( + str(database.DEFAULT_DB_FILE_PATH), + "--db-path", + "-db", + prompt="to-do database location?", + ), +) -> None: + """Initialize the to-do database.""" + app_init_error = config.init_app(db_path) + if app_init_error: + typer.secho( + f'Creating config file failed with "{ERRORS[app_init_error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + db_init_error = database.init_database(Path(db_path)) + if db_init_error: + typer.secho( + f'Creating database failed with "{ERRORS[db_init_error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + else: + typer.secho(f"The to-do database is {db_path}", fg=typer.colors.GREEN) + + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"{__app_name__} v{__version__}") + raise typer.Exit() + + +@app.callback() +def main( + version: Optional[bool] = typer.Option( + None, + "--version", + "-v", + help="Show the application's version and exit.", + callback=_version_callback, + is_eager=True, + ) +) -> None: + return diff --git a/typer-cli-python/source_code_step_3/rptodo/config.py b/typer-cli-python/source_code_step_3/rptodo/config.py new file mode 100644 index 0000000000..22d8a30467 --- /dev/null +++ b/typer-cli-python/source_code_step_3/rptodo/config.py @@ -0,0 +1,45 @@ +"""This module provides the RP To-Do config functionality.""" + +import configparser +from pathlib import Path + +import typer + +from rptodo import DB_WRITE_ERROR, DIR_ERROR, FILE_ERROR, SUCCESS, __app_name__ + +CONFIG_DIR_PATH = Path(typer.get_app_dir(__app_name__)) +CONFIG_FILE_PATH = CONFIG_DIR_PATH / "config.ini" + + +def init_app(db_path: str) -> int: + """Initialize the application.""" + config_code = _init_config_file() + if config_code != SUCCESS: + return config_code + database_code = _create_database(db_path) + if database_code != SUCCESS: + return database_code + return SUCCESS + + +def _init_config_file() -> int: + try: + CONFIG_DIR_PATH.mkdir(exist_ok=True) + except OSError: + return DIR_ERROR + try: + CONFIG_FILE_PATH.touch(exist_ok=True) + except OSError: + return FILE_ERROR + return SUCCESS + + +def _create_database(db_path: str) -> int: + config_parser = configparser.ConfigParser() + config_parser["General"] = {"database": db_path} + try: + with CONFIG_FILE_PATH.open("w") as file: + config_parser.write(file) + except OSError: + return DB_WRITE_ERROR + return SUCCESS diff --git a/typer-cli-python/source_code_step_3/rptodo/database.py b/typer-cli-python/source_code_step_3/rptodo/database.py new file mode 100644 index 0000000000..a8f706d582 --- /dev/null +++ b/typer-cli-python/source_code_step_3/rptodo/database.py @@ -0,0 +1,26 @@ +"""This module provides the RP To-Do database functionality.""" + +import configparser +from pathlib import Path + +from rptodo import DB_WRITE_ERROR, SUCCESS + +DEFAULT_DB_FILE_PATH = Path.home().joinpath( + "." + Path.home().stem + "_todo.json" +) + + +def get_database_path(config_file: Path) -> Path: + """Return the current path to the to-do database.""" + config_parser = configparser.ConfigParser() + config_parser.read(config_file) + return Path(config_parser["General"]["database"]) + + +def init_database(db_path: Path) -> int: + """Create the to-do database.""" + try: + db_path.write_text("[]") # Empty to-do list + return SUCCESS + except OSError: + return DB_WRITE_ERROR diff --git a/typer-cli-python/source_code_step_3/rptodo/rptodo.py b/typer-cli-python/source_code_step_3/rptodo/rptodo.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_3/tests/__init__.py b/typer-cli-python/source_code_step_3/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_3/tests/test_rptodo.py b/typer-cli-python/source_code_step_3/tests/test_rptodo.py new file mode 100644 index 0000000000..71946f5760 --- /dev/null +++ b/typer-cli-python/source_code_step_3/tests/test_rptodo.py @@ -0,0 +1,11 @@ +from typer.testing import CliRunner + +from rptodo import cli, __app_name__, __version__ + +runner = CliRunner() + + +def test_version(): + result = runner.invoke(cli.app, ["--version"]) + assert result.exit_code == 0 + assert f"{__app_name__} v{__version__}\n" in result.stdout diff --git a/typer-cli-python/source_code_step_4/README.md b/typer-cli-python/source_code_step_4/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_4/requirements.txt b/typer-cli-python/source_code_step_4/requirements.txt new file mode 100644 index 0000000000..e5e0199018 --- /dev/null +++ b/typer-cli-python/source_code_step_4/requirements.txt @@ -0,0 +1,4 @@ +typer==0.3.2 +colorama==0.4.4 +shellingham==1.4.0 +pytest==6.2.4 diff --git a/typer-cli-python/source_code_step_4/rptodo/__init__.py b/typer-cli-python/source_code_step_4/rptodo/__init__.py new file mode 100644 index 0000000000..346d9d75ed --- /dev/null +++ b/typer-cli-python/source_code_step_4/rptodo/__init__.py @@ -0,0 +1,22 @@ +"""Top-level package for RP To-Do.""" + +__app_name__ = "rptodo" +__version__ = "0.1.0" + +( + SUCCESS, + DIR_ERROR, + FILE_ERROR, + DB_READ_ERROR, + DB_WRITE_ERROR, + JSON_ERROR, + ID_ERROR, +) = range(7) + +ERRORS = { + DIR_ERROR: "config directory error", + FILE_ERROR: "config file error", + DB_READ_ERROR: "database read error", + DB_WRITE_ERROR: "database write error", + ID_ERROR: "to-do id error", +} diff --git a/typer-cli-python/source_code_step_4/rptodo/__main__.py b/typer-cli-python/source_code_step_4/rptodo/__main__.py new file mode 100644 index 0000000000..57c45b04a2 --- /dev/null +++ b/typer-cli-python/source_code_step_4/rptodo/__main__.py @@ -0,0 +1,11 @@ +"""RP To-Do entry point script.""" + +from rptodo import cli, __app_name__ + + +def main(): + cli.app(prog_name=__app_name__) + + +if __name__ == "__main__": + main() diff --git a/typer-cli-python/source_code_step_4/rptodo/cli.py b/typer-cli-python/source_code_step_4/rptodo/cli.py new file mode 100644 index 0000000000..89531fcd5b --- /dev/null +++ b/typer-cli-python/source_code_step_4/rptodo/cli.py @@ -0,0 +1,58 @@ +"""This module provides the RP To-Do CLI.""" + +from pathlib import Path +from typing import Optional + +import typer + +from rptodo import ERRORS, __app_name__, __version__, config, database + +app = typer.Typer() + + +@app.command() +def init( + db_path: str = typer.Option( + str(database.DEFAULT_DB_FILE_PATH), + "--db-path", + "-db", + prompt="to-do database location?", + ), +) -> None: + """Initialize the to-do database.""" + app_init_error = config.init_app(db_path) + if app_init_error: + typer.secho( + f'Creating config file failed with "{ERRORS[app_init_error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + db_init_error = database.init_database(Path(db_path)) + if db_init_error: + typer.secho( + f'Creating database failed with "{ERRORS[db_init_error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + else: + typer.secho(f"The to-do database is {db_path}", fg=typer.colors.GREEN) + + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"{__app_name__} v{__version__}") + raise typer.Exit() + + +@app.callback() +def main( + version: Optional[bool] = typer.Option( + None, + "--version", + "-v", + help="Show the application's version and exit.", + callback=_version_callback, + is_eager=True, + ) +) -> None: + return diff --git a/typer-cli-python/source_code_step_4/rptodo/config.py b/typer-cli-python/source_code_step_4/rptodo/config.py new file mode 100644 index 0000000000..22d8a30467 --- /dev/null +++ b/typer-cli-python/source_code_step_4/rptodo/config.py @@ -0,0 +1,45 @@ +"""This module provides the RP To-Do config functionality.""" + +import configparser +from pathlib import Path + +import typer + +from rptodo import DB_WRITE_ERROR, DIR_ERROR, FILE_ERROR, SUCCESS, __app_name__ + +CONFIG_DIR_PATH = Path(typer.get_app_dir(__app_name__)) +CONFIG_FILE_PATH = CONFIG_DIR_PATH / "config.ini" + + +def init_app(db_path: str) -> int: + """Initialize the application.""" + config_code = _init_config_file() + if config_code != SUCCESS: + return config_code + database_code = _create_database(db_path) + if database_code != SUCCESS: + return database_code + return SUCCESS + + +def _init_config_file() -> int: + try: + CONFIG_DIR_PATH.mkdir(exist_ok=True) + except OSError: + return DIR_ERROR + try: + CONFIG_FILE_PATH.touch(exist_ok=True) + except OSError: + return FILE_ERROR + return SUCCESS + + +def _create_database(db_path: str) -> int: + config_parser = configparser.ConfigParser() + config_parser["General"] = {"database": db_path} + try: + with CONFIG_FILE_PATH.open("w") as file: + config_parser.write(file) + except OSError: + return DB_WRITE_ERROR + return SUCCESS diff --git a/typer-cli-python/source_code_step_4/rptodo/database.py b/typer-cli-python/source_code_step_4/rptodo/database.py new file mode 100644 index 0000000000..b703fafc25 --- /dev/null +++ b/typer-cli-python/source_code_step_4/rptodo/database.py @@ -0,0 +1,56 @@ +"""This module provides the RP To-Do database functionality.""" + +import configparser +import json +from pathlib import Path +from typing import Any, Dict, List, NamedTuple + +from rptodo import DB_READ_ERROR, DB_WRITE_ERROR, JSON_ERROR, SUCCESS + +DEFAULT_DB_FILE_PATH = Path.home().joinpath( + "." + Path.home().stem + "_todo.json" +) + + +def get_database_path(config_file: Path) -> Path: + """Return the current path to the to-do database.""" + config_parser = configparser.ConfigParser() + config_parser.read(config_file) + return Path(config_parser["General"]["database"]) + + +def init_database(db_path: Path) -> int: + """Create the to-do database.""" + try: + db_path.write_text("[]") # Empty to-do list + return SUCCESS + except OSError: + return DB_WRITE_ERROR + + +class DBResponse(NamedTuple): + todo_list: List[Dict[str, Any]] + error: int + + +class DatabaseHandler: + def __init__(self, db_path: Path) -> None: + self._db_path = db_path + + def read_todos(self) -> DBResponse: + try: + with self._db_path.open("r") as db: + try: + return DBResponse(json.load(db), SUCCESS) + except json.JSONDecodeError: # Catch wrong JSON format + return DBResponse([], JSON_ERROR) + except OSError: # Catch file IO problems + return DBResponse([], DB_READ_ERROR) + + def write_todos(self, todo_list: List[Dict[str, Any]]) -> DBResponse: + try: + with self._db_path.open("w") as db: + json.dump(todo_list, db, indent=4) + return DBResponse(todo_list, SUCCESS) + except OSError: # Catch file IO problems + return DBResponse(todo_list, DB_WRITE_ERROR) diff --git a/typer-cli-python/source_code_step_4/rptodo/rptodo.py b/typer-cli-python/source_code_step_4/rptodo/rptodo.py new file mode 100644 index 0000000000..fa9bc43c9d --- /dev/null +++ b/typer-cli-python/source_code_step_4/rptodo/rptodo.py @@ -0,0 +1,16 @@ +"""This module provides the RP To-Do model-controller.""" + +from pathlib import Path +from typing import Any, Dict, NamedTuple + +from rptodo.database import DatabaseHandler + + +class CurrentTodo(NamedTuple): + todo: Dict[str, Any] + error: int + + +class Todoer: + def __init__(self, db_path: Path) -> None: + self._db_handler = DatabaseHandler(db_path) diff --git a/typer-cli-python/source_code_step_4/tests/__init__.py b/typer-cli-python/source_code_step_4/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_4/tests/test_rptodo.py b/typer-cli-python/source_code_step_4/tests/test_rptodo.py new file mode 100644 index 0000000000..71946f5760 --- /dev/null +++ b/typer-cli-python/source_code_step_4/tests/test_rptodo.py @@ -0,0 +1,11 @@ +from typer.testing import CliRunner + +from rptodo import cli, __app_name__, __version__ + +runner = CliRunner() + + +def test_version(): + result = runner.invoke(cli.app, ["--version"]) + assert result.exit_code == 0 + assert f"{__app_name__} v{__version__}\n" in result.stdout diff --git a/typer-cli-python/source_code_step_5/README.md b/typer-cli-python/source_code_step_5/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_5/requirements.txt b/typer-cli-python/source_code_step_5/requirements.txt new file mode 100644 index 0000000000..e5e0199018 --- /dev/null +++ b/typer-cli-python/source_code_step_5/requirements.txt @@ -0,0 +1,4 @@ +typer==0.3.2 +colorama==0.4.4 +shellingham==1.4.0 +pytest==6.2.4 diff --git a/typer-cli-python/source_code_step_5/rptodo/__init__.py b/typer-cli-python/source_code_step_5/rptodo/__init__.py new file mode 100644 index 0000000000..346d9d75ed --- /dev/null +++ b/typer-cli-python/source_code_step_5/rptodo/__init__.py @@ -0,0 +1,22 @@ +"""Top-level package for RP To-Do.""" + +__app_name__ = "rptodo" +__version__ = "0.1.0" + +( + SUCCESS, + DIR_ERROR, + FILE_ERROR, + DB_READ_ERROR, + DB_WRITE_ERROR, + JSON_ERROR, + ID_ERROR, +) = range(7) + +ERRORS = { + DIR_ERROR: "config directory error", + FILE_ERROR: "config file error", + DB_READ_ERROR: "database read error", + DB_WRITE_ERROR: "database write error", + ID_ERROR: "to-do id error", +} diff --git a/typer-cli-python/source_code_step_5/rptodo/__main__.py b/typer-cli-python/source_code_step_5/rptodo/__main__.py new file mode 100644 index 0000000000..57c45b04a2 --- /dev/null +++ b/typer-cli-python/source_code_step_5/rptodo/__main__.py @@ -0,0 +1,11 @@ +"""RP To-Do entry point script.""" + +from rptodo import cli, __app_name__ + + +def main(): + cli.app(prog_name=__app_name__) + + +if __name__ == "__main__": + main() diff --git a/typer-cli-python/source_code_step_5/rptodo/cli.py b/typer-cli-python/source_code_step_5/rptodo/cli.py new file mode 100644 index 0000000000..b3277ecc12 --- /dev/null +++ b/typer-cli-python/source_code_step_5/rptodo/cli.py @@ -0,0 +1,130 @@ +"""This module provides the RP To-Do CLI.""" + +from pathlib import Path +from typing import List, Optional + +import typer + +from rptodo import ERRORS, __app_name__, __version__, config, database, rptodo + +app = typer.Typer() + + +@app.command() +def init( + db_path: str = typer.Option( + str(database.DEFAULT_DB_FILE_PATH), + "--db-path", + "-db", + prompt="to-do database location?", + ), +) -> None: + """Initialize the to-do database.""" + app_init_error = config.init_app(db_path) + if app_init_error: + typer.secho( + f'Creating config file failed with "{ERRORS[app_init_error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + db_init_error = database.init_database(Path(db_path)) + if db_init_error: + typer.secho( + f'Creating database failed with "{ERRORS[db_init_error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + else: + typer.secho(f"The to-do database is {db_path}", fg=typer.colors.GREEN) + + +def get_todoer() -> rptodo.Todoer: + if config.CONFIG_FILE_PATH.exists(): + db_path = database.get_database_path(config.CONFIG_FILE_PATH) + else: + typer.secho( + 'Config file not found. Please, run "rptodo init"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + if db_path.exists(): + return rptodo.Todoer(db_path) + else: + typer.secho( + 'Database not found. Please, run "rptodo init"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + + +@app.command() +def add( + description: List[str] = typer.Argument(...), + priority: int = typer.Option(2, "--priority", "-p", min=1, max=3), +) -> None: + """Add a new to-do with a DESCRIPTION.""" + todoer = get_todoer() + todo, error = todoer.add(description, priority) + if error: + typer.secho( + f'Adding to-do failed with "{ERRORS[error]}"', fg=typer.colors.RED + ) + raise typer.Exit(1) + else: + typer.secho( + f"""to-do: "{todo['Description']}" was added """ + f"""with priority: {priority}""", + fg=typer.colors.GREEN, + ) + + +@app.command(name="list") +def list_all() -> None: + """List all to-dos.""" + todoer = get_todoer() + todo_list = todoer.get_todo_list() + if len(todo_list) == 0: + typer.secho( + "There are no tasks in the to-do list yet", fg=typer.colors.RED + ) + raise typer.Exit() + typer.secho("\nto-do list:\n", fg=typer.colors.BLUE, bold=True) + columns = ( + "ID. ", + "| Priority ", + "| Done ", + "| Description ", + ) + headers = "".join(columns) + typer.secho(headers, fg=typer.colors.BLUE, bold=True) + typer.secho("-" * len(headers), fg=typer.colors.BLUE) + for id, todo in enumerate(todo_list, 1): + desc, priority, done = todo.values() + typer.secho( + f"{id}{(len(columns[0]) - len(str(id))) * ' '}" + f"| ({priority}){(len(columns[1]) - len(str(priority)) - 4) * ' '}" + f"| {done}{(len(columns[2]) - len(str(done)) - 2) * ' '}" + f"| {desc}", + fg=typer.colors.BLUE, + ) + typer.secho("-" * len(headers) + "\n", fg=typer.colors.BLUE) + + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"{__app_name__} v{__version__}") + raise typer.Exit() + + +@app.callback() +def main( + version: Optional[bool] = typer.Option( + None, + "--version", + "-v", + help="Show the application's version and exit.", + callback=_version_callback, + is_eager=True, + ) +) -> None: + return diff --git a/typer-cli-python/source_code_step_5/rptodo/config.py b/typer-cli-python/source_code_step_5/rptodo/config.py new file mode 100644 index 0000000000..22d8a30467 --- /dev/null +++ b/typer-cli-python/source_code_step_5/rptodo/config.py @@ -0,0 +1,45 @@ +"""This module provides the RP To-Do config functionality.""" + +import configparser +from pathlib import Path + +import typer + +from rptodo import DB_WRITE_ERROR, DIR_ERROR, FILE_ERROR, SUCCESS, __app_name__ + +CONFIG_DIR_PATH = Path(typer.get_app_dir(__app_name__)) +CONFIG_FILE_PATH = CONFIG_DIR_PATH / "config.ini" + + +def init_app(db_path: str) -> int: + """Initialize the application.""" + config_code = _init_config_file() + if config_code != SUCCESS: + return config_code + database_code = _create_database(db_path) + if database_code != SUCCESS: + return database_code + return SUCCESS + + +def _init_config_file() -> int: + try: + CONFIG_DIR_PATH.mkdir(exist_ok=True) + except OSError: + return DIR_ERROR + try: + CONFIG_FILE_PATH.touch(exist_ok=True) + except OSError: + return FILE_ERROR + return SUCCESS + + +def _create_database(db_path: str) -> int: + config_parser = configparser.ConfigParser() + config_parser["General"] = {"database": db_path} + try: + with CONFIG_FILE_PATH.open("w") as file: + config_parser.write(file) + except OSError: + return DB_WRITE_ERROR + return SUCCESS diff --git a/typer-cli-python/source_code_step_5/rptodo/database.py b/typer-cli-python/source_code_step_5/rptodo/database.py new file mode 100644 index 0000000000..b703fafc25 --- /dev/null +++ b/typer-cli-python/source_code_step_5/rptodo/database.py @@ -0,0 +1,56 @@ +"""This module provides the RP To-Do database functionality.""" + +import configparser +import json +from pathlib import Path +from typing import Any, Dict, List, NamedTuple + +from rptodo import DB_READ_ERROR, DB_WRITE_ERROR, JSON_ERROR, SUCCESS + +DEFAULT_DB_FILE_PATH = Path.home().joinpath( + "." + Path.home().stem + "_todo.json" +) + + +def get_database_path(config_file: Path) -> Path: + """Return the current path to the to-do database.""" + config_parser = configparser.ConfigParser() + config_parser.read(config_file) + return Path(config_parser["General"]["database"]) + + +def init_database(db_path: Path) -> int: + """Create the to-do database.""" + try: + db_path.write_text("[]") # Empty to-do list + return SUCCESS + except OSError: + return DB_WRITE_ERROR + + +class DBResponse(NamedTuple): + todo_list: List[Dict[str, Any]] + error: int + + +class DatabaseHandler: + def __init__(self, db_path: Path) -> None: + self._db_path = db_path + + def read_todos(self) -> DBResponse: + try: + with self._db_path.open("r") as db: + try: + return DBResponse(json.load(db), SUCCESS) + except json.JSONDecodeError: # Catch wrong JSON format + return DBResponse([], JSON_ERROR) + except OSError: # Catch file IO problems + return DBResponse([], DB_READ_ERROR) + + def write_todos(self, todo_list: List[Dict[str, Any]]) -> DBResponse: + try: + with self._db_path.open("w") as db: + json.dump(todo_list, db, indent=4) + return DBResponse(todo_list, SUCCESS) + except OSError: # Catch file IO problems + return DBResponse(todo_list, DB_WRITE_ERROR) diff --git a/typer-cli-python/source_code_step_5/rptodo/rptodo.py b/typer-cli-python/source_code_step_5/rptodo/rptodo.py new file mode 100644 index 0000000000..04636de081 --- /dev/null +++ b/typer-cli-python/source_code_step_5/rptodo/rptodo.py @@ -0,0 +1,39 @@ +"""This module provides the RP To-Do model-controller.""" + +from pathlib import Path +from typing import Any, Dict, List, NamedTuple + +from rptodo import DB_READ_ERROR +from rptodo.database import DatabaseHandler + + +class CurrentTodo(NamedTuple): + todo: Dict[str, Any] + error: int + + +class Todoer: + def __init__(self, db_path: Path) -> None: + self._db_handler = DatabaseHandler(db_path) + + def add(self, description: List[str], priority: int = 2) -> CurrentTodo: + """Add a new to-do to the database.""" + description_text = " ".join(description) + if not description_text.endswith("."): + description_text += "." + todo = { + "Description": description_text, + "Priority": priority, + "Done": False, + } + read = self._db_handler.read_todos() + if read.error == DB_READ_ERROR: + return CurrentTodo(todo, read.error) + read.todo_list.append(todo) + write = self._db_handler.write_todos(read.todo_list) + return CurrentTodo(todo, write.error) + + def get_todo_list(self) -> List[Dict[str, Any]]: + """Return the current to-do list.""" + read = self._db_handler.read_todos() + return read.todo_list diff --git a/typer-cli-python/source_code_step_5/tests/__init__.py b/typer-cli-python/source_code_step_5/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_5/tests/test_rptodo.py b/typer-cli-python/source_code_step_5/tests/test_rptodo.py new file mode 100644 index 0000000000..daa4fcb4f4 --- /dev/null +++ b/typer-cli-python/source_code_step_5/tests/test_rptodo.py @@ -0,0 +1,104 @@ +import json + +import pytest +from typer.testing import CliRunner + +from rptodo import ( + DB_READ_ERROR, + SUCCESS, + __app_name__, + __version__, + cli, + rptodo, +) + +runner = CliRunner() + + +def test_version(): + result = runner.invoke(cli.app, ["--version"]) + assert result.exit_code == 0 + assert f"{__app_name__} v{__version__}\n" in result.stdout + + +@pytest.fixture +def mock_json_file(tmp_path): + todo = [{"Description": "Get some milk.", "Priority": 2, "Done": False}] + db_file = tmp_path / "todo.json" + with db_file.open("w") as db: + json.dump(todo, db, indent=4) + return db_file + + +test_data1 = { + "description": ["Clean", "the", "house"], + "priority": 1, + "todo": { + "Description": "Clean the house.", + "Priority": 1, + "Done": False, + }, +} +test_data2 = { + "description": ["Wash the car"], + "priority": 2, + "todo": { + "Description": "Wash the car.", + "Priority": 2, + "Done": False, + }, +} + + +@pytest.mark.parametrize( + "description, priority, expected", + [ + pytest.param( + test_data1["description"], + test_data1["priority"], + (test_data1["todo"], SUCCESS), + ), + pytest.param( + test_data2["description"], + test_data2["priority"], + (test_data2["todo"], SUCCESS), + ), + ], +) +def test_add(mock_json_file, description, priority, expected): + todoer = rptodo.Todoer(mock_json_file) + assert todoer.add(description, priority) == expected + read = todoer._db_handler.read_todos() + assert len(read.todo_list) == 2 + + +@pytest.fixture +def mock_wrong_json_file(tmp_path): + db_file = tmp_path / "todo.json" + return db_file + + +def test_add_wrong_json_file(mock_wrong_json_file): + todoer = rptodo.Todoer(mock_wrong_json_file) + response = todoer.add(["test task"], 1) + assert response.error == DB_READ_ERROR + read = todoer._db_handler.read_todos() + assert len(read.todo_list) == 0 + + +@pytest.fixture +def mock_wrong_json_format(tmp_path): + db_file = tmp_path / "todo.json" + with db_file.open("w") as db: + db.write("") + return db_file + + +def test_add_wrong_json_format(mock_wrong_json_format): + todoer = rptodo.Todoer(mock_wrong_json_format) + assert todoer.add(test_data1["description"], test_data1["priority"]) == ( + test_data1["todo"], + SUCCESS, + ) + read = todoer._db_handler.read_todos() + assert len(read.todo_list) == 1 diff --git a/typer-cli-python/source_code_step_6/README.md b/typer-cli-python/source_code_step_6/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_6/requirements.txt b/typer-cli-python/source_code_step_6/requirements.txt new file mode 100644 index 0000000000..e5e0199018 --- /dev/null +++ b/typer-cli-python/source_code_step_6/requirements.txt @@ -0,0 +1,4 @@ +typer==0.3.2 +colorama==0.4.4 +shellingham==1.4.0 +pytest==6.2.4 diff --git a/typer-cli-python/source_code_step_6/rptodo/__init__.py b/typer-cli-python/source_code_step_6/rptodo/__init__.py new file mode 100644 index 0000000000..346d9d75ed --- /dev/null +++ b/typer-cli-python/source_code_step_6/rptodo/__init__.py @@ -0,0 +1,22 @@ +"""Top-level package for RP To-Do.""" + +__app_name__ = "rptodo" +__version__ = "0.1.0" + +( + SUCCESS, + DIR_ERROR, + FILE_ERROR, + DB_READ_ERROR, + DB_WRITE_ERROR, + JSON_ERROR, + ID_ERROR, +) = range(7) + +ERRORS = { + DIR_ERROR: "config directory error", + FILE_ERROR: "config file error", + DB_READ_ERROR: "database read error", + DB_WRITE_ERROR: "database write error", + ID_ERROR: "to-do id error", +} diff --git a/typer-cli-python/source_code_step_6/rptodo/__main__.py b/typer-cli-python/source_code_step_6/rptodo/__main__.py new file mode 100644 index 0000000000..57c45b04a2 --- /dev/null +++ b/typer-cli-python/source_code_step_6/rptodo/__main__.py @@ -0,0 +1,11 @@ +"""RP To-Do entry point script.""" + +from rptodo import cli, __app_name__ + + +def main(): + cli.app(prog_name=__app_name__) + + +if __name__ == "__main__": + main() diff --git a/typer-cli-python/source_code_step_6/rptodo/cli.py b/typer-cli-python/source_code_step_6/rptodo/cli.py new file mode 100644 index 0000000000..3956d0561c --- /dev/null +++ b/typer-cli-python/source_code_step_6/rptodo/cli.py @@ -0,0 +1,148 @@ +"""This module provides the RP To-Do CLI.""" + +from pathlib import Path +from typing import List, Optional + +import typer + +from rptodo import ERRORS, __app_name__, __version__, config, database, rptodo + +app = typer.Typer() + + +@app.command() +def init( + db_path: str = typer.Option( + str(database.DEFAULT_DB_FILE_PATH), + "--db-path", + "-db", + prompt="to-do database location?", + ), +) -> None: + """Initialize the to-do database.""" + app_init_error = config.init_app(db_path) + if app_init_error: + typer.secho( + f'Creating config file failed with "{ERRORS[app_init_error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + db_init_error = database.init_database(Path(db_path)) + if db_init_error: + typer.secho( + f'Creating database failed with "{ERRORS[db_init_error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + else: + typer.secho(f"The to-do database is {db_path}", fg=typer.colors.GREEN) + + +def get_todoer() -> rptodo.Todoer: + if config.CONFIG_FILE_PATH.exists(): + db_path = database.get_database_path(config.CONFIG_FILE_PATH) + else: + typer.secho( + 'Config file not found. Please, run "rptodo init"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + if db_path.exists(): + return rptodo.Todoer(db_path) + else: + typer.secho( + 'Database not found. Please, run "rptodo init"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + + +@app.command() +def add( + description: List[str] = typer.Argument(...), + priority: int = typer.Option(2, "--priority", "-p", min=1, max=3), +) -> None: + """Add a new to-do with a DESCRIPTION.""" + todoer = get_todoer() + todo, error = todoer.add(description, priority) + if error: + typer.secho( + f'Adding to-do failed with "{ERRORS[error]}"', fg=typer.colors.RED + ) + raise typer.Exit(1) + else: + typer.secho( + f"""to-do: "{todo['Description']}" was added """ + f"""with priority: {priority}""", + fg=typer.colors.GREEN, + ) + + +@app.command(name="list") +def list_all() -> None: + """List all to-dos.""" + todoer = get_todoer() + todo_list = todoer.get_todo_list() + if len(todo_list) == 0: + typer.secho( + "There are no tasks in the to-do list yet", fg=typer.colors.RED + ) + raise typer.Exit() + typer.secho("\nto-do list:\n", fg=typer.colors.BLUE, bold=True) + columns = ( + "ID. ", + "| Priority ", + "| Done ", + "| Description ", + ) + headers = "".join(columns) + typer.secho(headers, fg=typer.colors.BLUE, bold=True) + typer.secho("-" * len(headers), fg=typer.colors.BLUE) + for id, todo in enumerate(todo_list, 1): + desc, priority, done = todo.values() + typer.secho( + f"{id}{(len(columns[0]) - len(str(id))) * ' '}" + f"| ({priority}){(len(columns[1]) - len(str(priority)) - 4) * ' '}" + f"| {done}{(len(columns[2]) - len(str(done)) - 2) * ' '}" + f"| {desc}", + fg=typer.colors.BLUE, + ) + typer.secho("-" * len(headers) + "\n", fg=typer.colors.BLUE) + + +@app.command(name="complete") +def set_done(todo_id: int = typer.Argument(...)) -> None: + """Complete a to-do by setting it as done using its TODO_ID.""" + todoer = get_todoer() + todo, error = todoer.set_done(todo_id) + if error: + typer.secho( + f'Completing to-do # "{todo_id}" failed with "{ERRORS[error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + else: + typer.secho( + f"""to-do # {todo_id} "{todo['Description']}" completed!""", + fg=typer.colors.GREEN, + ) + + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"{__app_name__} v{__version__}") + raise typer.Exit() + + +@app.callback() +def main( + version: Optional[bool] = typer.Option( + None, + "--version", + "-v", + help="Show the application's version and exit.", + callback=_version_callback, + is_eager=True, + ) +) -> None: + return diff --git a/typer-cli-python/source_code_step_6/rptodo/config.py b/typer-cli-python/source_code_step_6/rptodo/config.py new file mode 100644 index 0000000000..22d8a30467 --- /dev/null +++ b/typer-cli-python/source_code_step_6/rptodo/config.py @@ -0,0 +1,45 @@ +"""This module provides the RP To-Do config functionality.""" + +import configparser +from pathlib import Path + +import typer + +from rptodo import DB_WRITE_ERROR, DIR_ERROR, FILE_ERROR, SUCCESS, __app_name__ + +CONFIG_DIR_PATH = Path(typer.get_app_dir(__app_name__)) +CONFIG_FILE_PATH = CONFIG_DIR_PATH / "config.ini" + + +def init_app(db_path: str) -> int: + """Initialize the application.""" + config_code = _init_config_file() + if config_code != SUCCESS: + return config_code + database_code = _create_database(db_path) + if database_code != SUCCESS: + return database_code + return SUCCESS + + +def _init_config_file() -> int: + try: + CONFIG_DIR_PATH.mkdir(exist_ok=True) + except OSError: + return DIR_ERROR + try: + CONFIG_FILE_PATH.touch(exist_ok=True) + except OSError: + return FILE_ERROR + return SUCCESS + + +def _create_database(db_path: str) -> int: + config_parser = configparser.ConfigParser() + config_parser["General"] = {"database": db_path} + try: + with CONFIG_FILE_PATH.open("w") as file: + config_parser.write(file) + except OSError: + return DB_WRITE_ERROR + return SUCCESS diff --git a/typer-cli-python/source_code_step_6/rptodo/database.py b/typer-cli-python/source_code_step_6/rptodo/database.py new file mode 100644 index 0000000000..b703fafc25 --- /dev/null +++ b/typer-cli-python/source_code_step_6/rptodo/database.py @@ -0,0 +1,56 @@ +"""This module provides the RP To-Do database functionality.""" + +import configparser +import json +from pathlib import Path +from typing import Any, Dict, List, NamedTuple + +from rptodo import DB_READ_ERROR, DB_WRITE_ERROR, JSON_ERROR, SUCCESS + +DEFAULT_DB_FILE_PATH = Path.home().joinpath( + "." + Path.home().stem + "_todo.json" +) + + +def get_database_path(config_file: Path) -> Path: + """Return the current path to the to-do database.""" + config_parser = configparser.ConfigParser() + config_parser.read(config_file) + return Path(config_parser["General"]["database"]) + + +def init_database(db_path: Path) -> int: + """Create the to-do database.""" + try: + db_path.write_text("[]") # Empty to-do list + return SUCCESS + except OSError: + return DB_WRITE_ERROR + + +class DBResponse(NamedTuple): + todo_list: List[Dict[str, Any]] + error: int + + +class DatabaseHandler: + def __init__(self, db_path: Path) -> None: + self._db_path = db_path + + def read_todos(self) -> DBResponse: + try: + with self._db_path.open("r") as db: + try: + return DBResponse(json.load(db), SUCCESS) + except json.JSONDecodeError: # Catch wrong JSON format + return DBResponse([], JSON_ERROR) + except OSError: # Catch file IO problems + return DBResponse([], DB_READ_ERROR) + + def write_todos(self, todo_list: List[Dict[str, Any]]) -> DBResponse: + try: + with self._db_path.open("w") as db: + json.dump(todo_list, db, indent=4) + return DBResponse(todo_list, SUCCESS) + except OSError: # Catch file IO problems + return DBResponse(todo_list, DB_WRITE_ERROR) diff --git a/typer-cli-python/source_code_step_6/rptodo/rptodo.py b/typer-cli-python/source_code_step_6/rptodo/rptodo.py new file mode 100644 index 0000000000..0e338cfdbe --- /dev/null +++ b/typer-cli-python/source_code_step_6/rptodo/rptodo.py @@ -0,0 +1,52 @@ +"""This module provides the RP To-Do model-controller.""" + +from pathlib import Path +from typing import Any, Dict, List, NamedTuple + +from rptodo import DB_READ_ERROR, ID_ERROR +from rptodo.database import DatabaseHandler + + +class CurrentTodo(NamedTuple): + todo: Dict[str, Any] + error: int + + +class Todoer: + def __init__(self, db_path: Path) -> None: + self._db_handler = DatabaseHandler(db_path) + + def add(self, description: List[str], priority: int = 2) -> CurrentTodo: + """Add a new to-do to the database.""" + description_text = " ".join(description) + if not description_text.endswith("."): + description_text += "." + todo = { + "Description": description_text, + "Priority": priority, + "Done": False, + } + read = self._db_handler.read_todos() + if read.error == DB_READ_ERROR: + return CurrentTodo(todo, read.error) + read.todo_list.append(todo) + write = self._db_handler.write_todos(read.todo_list) + return CurrentTodo(todo, write.error) + + def get_todo_list(self) -> List[Dict[str, Any]]: + """Return the current to-do list.""" + read = self._db_handler.read_todos() + return read.todo_list + + def set_done(self, todo_id: int) -> CurrentTodo: + """Set a to-do as done.""" + read = self._db_handler.read_todos() + if read.error: + return CurrentTodo({}, read.error) + try: + todo = read.todo_list[todo_id - 1] + except IndexError: + return CurrentTodo({}, ID_ERROR) + todo["Done"] = True + write = self._db_handler.write_todos(read.todo_list) + return CurrentTodo(todo, write.error) diff --git a/typer-cli-python/source_code_step_6/tests/__init__.py b/typer-cli-python/source_code_step_6/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_6/tests/test_rptodo.py b/typer-cli-python/source_code_step_6/tests/test_rptodo.py new file mode 100644 index 0000000000..0109e1a9fd --- /dev/null +++ b/typer-cli-python/source_code_step_6/tests/test_rptodo.py @@ -0,0 +1,125 @@ +import json + +import pytest +from typer.testing import CliRunner + +from rptodo import ( + DB_READ_ERROR, + ID_ERROR, + SUCCESS, + __app_name__, + __version__, + cli, + rptodo, +) + +runner = CliRunner() + + +def test_version(): + result = runner.invoke(cli.app, ["--version"]) + assert result.exit_code == 0 + assert f"{__app_name__} v{__version__}\n" in result.stdout + + +@pytest.fixture +def mock_json_file(tmp_path): + todo = [{"Description": "Get some milk.", "Priority": 2, "Done": False}] + db_file = tmp_path / "todo.json" + with db_file.open("w") as db: + json.dump(todo, db, indent=4) + return db_file + + +test_data1 = { + "description": ["Clean", "the", "house"], + "priority": 1, + "todo": { + "Description": "Clean the house.", + "Priority": 1, + "Done": False, + }, +} +test_data2 = { + "description": ["Wash the car"], + "priority": 2, + "todo": { + "Description": "Wash the car.", + "Priority": 2, + "Done": False, + }, +} + + +@pytest.mark.parametrize( + "description, priority, expected", + [ + pytest.param( + test_data1["description"], + test_data1["priority"], + (test_data1["todo"], SUCCESS), + ), + pytest.param( + test_data2["description"], + test_data2["priority"], + (test_data2["todo"], SUCCESS), + ), + ], +) +def test_add(mock_json_file, description, priority, expected): + todoer = rptodo.Todoer(mock_json_file) + assert todoer.add(description, priority) == expected + read = todoer._db_handler.read_todos() + assert len(read.todo_list) == 2 + + +@pytest.fixture +def mock_wrong_json_file(tmp_path): + db_file = tmp_path / "todo.json" + return db_file + + +def test_add_wrong_json_file(mock_wrong_json_file): + todoer = rptodo.Todoer(mock_wrong_json_file) + response = todoer.add(["test task"], 1) + assert response.error == DB_READ_ERROR + read = todoer._db_handler.read_todos() + assert len(read.todo_list) == 0 + + +@pytest.fixture +def mock_wrong_json_format(tmp_path): + db_file = tmp_path / "todo.json" + with db_file.open("w") as db: + db.write("") + return db_file + + +def test_add_wrong_json_format(mock_wrong_json_format): + todoer = rptodo.Todoer(mock_wrong_json_format) + assert todoer.add(test_data1["description"], test_data1["priority"]) == ( + test_data1["todo"], + SUCCESS, + ) + read = todoer._db_handler.read_todos() + assert len(read.todo_list) == 1 + + +test_todo1 = { + "Description": "Get some milk.", + "Priority": 2, + "Done": True, +} +test_todo2 = {} + + +@pytest.mark.parametrize( + "todo_id, expected", + [ + pytest.param(1, (test_todo1, SUCCESS)), + pytest.param(3, (test_todo2, ID_ERROR)), + ], +) +def test_set_done(mock_json_file, todo_id, expected): + todoer = rptodo.Todoer(mock_json_file) + assert todoer.set_done(todo_id) == expected diff --git a/typer-cli-python/source_code_step_7/README.md b/typer-cli-python/source_code_step_7/README.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_7/requirements.txt b/typer-cli-python/source_code_step_7/requirements.txt new file mode 100644 index 0000000000..e5e0199018 --- /dev/null +++ b/typer-cli-python/source_code_step_7/requirements.txt @@ -0,0 +1,4 @@ +typer==0.3.2 +colorama==0.4.4 +shellingham==1.4.0 +pytest==6.2.4 diff --git a/typer-cli-python/source_code_step_7/rptodo/__init__.py b/typer-cli-python/source_code_step_7/rptodo/__init__.py new file mode 100644 index 0000000000..346d9d75ed --- /dev/null +++ b/typer-cli-python/source_code_step_7/rptodo/__init__.py @@ -0,0 +1,22 @@ +"""Top-level package for RP To-Do.""" + +__app_name__ = "rptodo" +__version__ = "0.1.0" + +( + SUCCESS, + DIR_ERROR, + FILE_ERROR, + DB_READ_ERROR, + DB_WRITE_ERROR, + JSON_ERROR, + ID_ERROR, +) = range(7) + +ERRORS = { + DIR_ERROR: "config directory error", + FILE_ERROR: "config file error", + DB_READ_ERROR: "database read error", + DB_WRITE_ERROR: "database write error", + ID_ERROR: "to-do id error", +} diff --git a/typer-cli-python/source_code_step_7/rptodo/__main__.py b/typer-cli-python/source_code_step_7/rptodo/__main__.py new file mode 100644 index 0000000000..57c45b04a2 --- /dev/null +++ b/typer-cli-python/source_code_step_7/rptodo/__main__.py @@ -0,0 +1,11 @@ +"""RP To-Do entry point script.""" + +from rptodo import cli, __app_name__ + + +def main(): + cli.app(prog_name=__app_name__) + + +if __name__ == "__main__": + main() diff --git a/typer-cli-python/source_code_step_7/rptodo/cli.py b/typer-cli-python/source_code_step_7/rptodo/cli.py new file mode 100644 index 0000000000..9e9d84944a --- /dev/null +++ b/typer-cli-python/source_code_step_7/rptodo/cli.py @@ -0,0 +1,217 @@ +"""This module provides the RP To-Do CLI.""" + +from pathlib import Path +from typing import List, Optional + +import typer + +from rptodo import ERRORS, __app_name__, __version__, config, database, rptodo + +app = typer.Typer() + + +@app.command() +def init( + db_path: str = typer.Option( + str(database.DEFAULT_DB_FILE_PATH), + "--db-path", + "-db", + prompt="to-do database location?", + ), +) -> None: + """Initialize the to-do database.""" + app_init_error = config.init_app(db_path) + if app_init_error: + typer.secho( + f'Creating config file failed with "{ERRORS[app_init_error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + db_init_error = database.init_database(Path(db_path)) + if db_init_error: + typer.secho( + f'Creating database failed with "{ERRORS[db_init_error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + else: + typer.secho(f"The to-do database is {db_path}", fg=typer.colors.GREEN) + + +def get_todoer() -> rptodo.Todoer: + if config.CONFIG_FILE_PATH.exists(): + db_path = database.get_database_path(config.CONFIG_FILE_PATH) + else: + typer.secho( + 'Config file not found. Please, run "rptodo init"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + if db_path.exists(): + return rptodo.Todoer(db_path) + else: + typer.secho( + 'Database not found. Please, run "rptodo init"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + + +@app.command() +def add( + description: List[str] = typer.Argument(...), + priority: int = typer.Option(2, "--priority", "-p", min=1, max=3), +) -> None: + """Add a new to-do with a DESCRIPTION.""" + todoer = get_todoer() + todo, error = todoer.add(description, priority) + if error: + typer.secho( + f'Adding to-do failed with "{ERRORS[error]}"', fg=typer.colors.RED + ) + raise typer.Exit(1) + else: + typer.secho( + f"""to-do: "{todo['Description']}" was added """ + f"""with priority: {priority}""", + fg=typer.colors.GREEN, + ) + + +@app.command(name="list") +def list_all() -> None: + """List all to-dos.""" + todoer = get_todoer() + todo_list = todoer.get_todo_list() + if len(todo_list) == 0: + typer.secho( + "There are no tasks in the to-do list yet", fg=typer.colors.RED + ) + raise typer.Exit() + typer.secho("\nto-do list:\n", fg=typer.colors.BLUE, bold=True) + columns = ( + "ID. ", + "| Priority ", + "| Done ", + "| Description ", + ) + headers = "".join(columns) + typer.secho(headers, fg=typer.colors.BLUE, bold=True) + typer.secho("-" * len(headers), fg=typer.colors.BLUE) + for id, todo in enumerate(todo_list, 1): + desc, priority, done = todo.values() + typer.secho( + f"{id}{(len(columns[0]) - len(str(id))) * ' '}" + f"| ({priority}){(len(columns[1]) - len(str(priority)) - 4) * ' '}" + f"| {done}{(len(columns[2]) - len(str(done)) - 2) * ' '}" + f"| {desc}", + fg=typer.colors.BLUE, + ) + typer.secho("-" * len(headers) + "\n", fg=typer.colors.BLUE) + + +@app.command(name="complete") +def set_done(todo_id: int = typer.Argument(...)) -> None: + """Complete a to-do by setting it as done using its TODO_ID.""" + todoer = get_todoer() + todo, error = todoer.set_done(todo_id) + if error: + typer.secho( + f'Completing to-do # "{todo_id}" failed with "{ERRORS[error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + else: + typer.secho( + f"""to-do # {todo_id} "{todo['Description']}" completed!""", + fg=typer.colors.GREEN, + ) + + +@app.command() +def remove( + todo_id: int = typer.Argument(...), + force: bool = typer.Option( + False, + "--force", + "-f", + help="Force deletion without confirmation.", + ), +) -> None: + """Remove a to-do using its TODO_ID.""" + todoer = get_todoer() + + def _remove(): + todo, error = todoer.remove(todo_id) + if error: + typer.secho( + f'Removing to-do # {todo_id} failed with "{ERRORS[error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + else: + typer.secho( + f"""to-do # {todo_id}: '{todo["Description"]}' was removed""", + fg=typer.colors.GREEN, + ) + + if force: + _remove() + else: + todo_list = todoer.get_todo_list() + try: + todo = todo_list[todo_id - 1] + except IndexError: + typer.secho("Invalid TODO_ID", fg=typer.colors.RED) + raise typer.Exit(1) + delete = typer.confirm( + f"Delete to-do # {todo_id}: {todo['Description']}?" + ) + if delete: + _remove() + else: + typer.echo("Operation canceled") + + +@app.command(name="clear") +def remove_all( + force: bool = typer.Option( + ..., + prompt="Delete all to-dos?", + help="Force deletion without confirmation.", + ), +) -> None: + """Remove all to-dos.""" + todoer = get_todoer() + if force: + error = todoer.remove_all().error + if error: + typer.secho( + f'Removing to-dos failed with "{ERRORS[error]}"', + fg=typer.colors.RED, + ) + raise typer.Exit(1) + else: + typer.secho("All to-dos were removed", fg=typer.colors.GREEN) + else: + typer.echo("Operation canceled") + + +def _version_callback(value: bool) -> None: + if value: + typer.echo(f"{__app_name__} v{__version__}") + raise typer.Exit() + + +@app.callback() +def main( + version: Optional[bool] = typer.Option( + None, + "--version", + "-v", + help="Show the application's version and exit.", + callback=_version_callback, + is_eager=True, + ) +) -> None: + return diff --git a/typer-cli-python/source_code_step_7/rptodo/config.py b/typer-cli-python/source_code_step_7/rptodo/config.py new file mode 100644 index 0000000000..22d8a30467 --- /dev/null +++ b/typer-cli-python/source_code_step_7/rptodo/config.py @@ -0,0 +1,45 @@ +"""This module provides the RP To-Do config functionality.""" + +import configparser +from pathlib import Path + +import typer + +from rptodo import DB_WRITE_ERROR, DIR_ERROR, FILE_ERROR, SUCCESS, __app_name__ + +CONFIG_DIR_PATH = Path(typer.get_app_dir(__app_name__)) +CONFIG_FILE_PATH = CONFIG_DIR_PATH / "config.ini" + + +def init_app(db_path: str) -> int: + """Initialize the application.""" + config_code = _init_config_file() + if config_code != SUCCESS: + return config_code + database_code = _create_database(db_path) + if database_code != SUCCESS: + return database_code + return SUCCESS + + +def _init_config_file() -> int: + try: + CONFIG_DIR_PATH.mkdir(exist_ok=True) + except OSError: + return DIR_ERROR + try: + CONFIG_FILE_PATH.touch(exist_ok=True) + except OSError: + return FILE_ERROR + return SUCCESS + + +def _create_database(db_path: str) -> int: + config_parser = configparser.ConfigParser() + config_parser["General"] = {"database": db_path} + try: + with CONFIG_FILE_PATH.open("w") as file: + config_parser.write(file) + except OSError: + return DB_WRITE_ERROR + return SUCCESS diff --git a/typer-cli-python/source_code_step_7/rptodo/database.py b/typer-cli-python/source_code_step_7/rptodo/database.py new file mode 100644 index 0000000000..b703fafc25 --- /dev/null +++ b/typer-cli-python/source_code_step_7/rptodo/database.py @@ -0,0 +1,56 @@ +"""This module provides the RP To-Do database functionality.""" + +import configparser +import json +from pathlib import Path +from typing import Any, Dict, List, NamedTuple + +from rptodo import DB_READ_ERROR, DB_WRITE_ERROR, JSON_ERROR, SUCCESS + +DEFAULT_DB_FILE_PATH = Path.home().joinpath( + "." + Path.home().stem + "_todo.json" +) + + +def get_database_path(config_file: Path) -> Path: + """Return the current path to the to-do database.""" + config_parser = configparser.ConfigParser() + config_parser.read(config_file) + return Path(config_parser["General"]["database"]) + + +def init_database(db_path: Path) -> int: + """Create the to-do database.""" + try: + db_path.write_text("[]") # Empty to-do list + return SUCCESS + except OSError: + return DB_WRITE_ERROR + + +class DBResponse(NamedTuple): + todo_list: List[Dict[str, Any]] + error: int + + +class DatabaseHandler: + def __init__(self, db_path: Path) -> None: + self._db_path = db_path + + def read_todos(self) -> DBResponse: + try: + with self._db_path.open("r") as db: + try: + return DBResponse(json.load(db), SUCCESS) + except json.JSONDecodeError: # Catch wrong JSON format + return DBResponse([], JSON_ERROR) + except OSError: # Catch file IO problems + return DBResponse([], DB_READ_ERROR) + + def write_todos(self, todo_list: List[Dict[str, Any]]) -> DBResponse: + try: + with self._db_path.open("w") as db: + json.dump(todo_list, db, indent=4) + return DBResponse(todo_list, SUCCESS) + except OSError: # Catch file IO problems + return DBResponse(todo_list, DB_WRITE_ERROR) diff --git a/typer-cli-python/source_code_step_7/rptodo/rptodo.py b/typer-cli-python/source_code_step_7/rptodo/rptodo.py new file mode 100644 index 0000000000..ec53186132 --- /dev/null +++ b/typer-cli-python/source_code_step_7/rptodo/rptodo.py @@ -0,0 +1,69 @@ +"""This module provides the RP To-Do model-controller.""" + +from pathlib import Path +from typing import Any, Dict, List, NamedTuple + +from rptodo import DB_READ_ERROR, ID_ERROR +from rptodo.database import DatabaseHandler + + +class CurrentTodo(NamedTuple): + todo: Dict[str, Any] + error: int + + +class Todoer: + def __init__(self, db_path: Path) -> None: + self._db_handler = DatabaseHandler(db_path) + + def add(self, description: List[str], priority: int = 2) -> CurrentTodo: + """Add a new to-do to the database.""" + description_text = " ".join(description) + if not description_text.endswith("."): + description_text += "." + todo = { + "Description": description_text, + "Priority": priority, + "Done": False, + } + read = self._db_handler.read_todos() + if read.error == DB_READ_ERROR: + return CurrentTodo(todo, read.error) + read.todo_list.append(todo) + write = self._db_handler.write_todos(read.todo_list) + return CurrentTodo(todo, write.error) + + def get_todo_list(self) -> List[Dict[str, Any]]: + """Return the current to-do list.""" + read = self._db_handler.read_todos() + return read.todo_list + + def set_done(self, todo_id: int) -> CurrentTodo: + """Set a to-do as done.""" + read = self._db_handler.read_todos() + if read.error: + return CurrentTodo({}, read.error) + try: + todo = read.todo_list[todo_id - 1] + except IndexError: + return CurrentTodo({}, ID_ERROR) + todo["Done"] = True + write = self._db_handler.write_todos(read.todo_list) + return CurrentTodo(todo, write.error) + + def remove(self, todo_id: int) -> CurrentTodo: + """Remove a to-do from the database using its id or index.""" + read = self._db_handler.read_todos() + if read.error: + return CurrentTodo({}, read.error) + try: + todo = read.todo_list.pop(todo_id - 1) + except IndexError: + return CurrentTodo({}, ID_ERROR) + write = self._db_handler.write_todos(read.todo_list) + return CurrentTodo(todo, write.error) + + def remove_all(self) -> CurrentTodo: + """Remove all to-dos from the database.""" + write = self._db_handler.write_todos([]) + return CurrentTodo({}, write.error) diff --git a/typer-cli-python/source_code_step_7/tests/__init__.py b/typer-cli-python/source_code_step_7/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/typer-cli-python/source_code_step_7/tests/test_rptodo.py b/typer-cli-python/source_code_step_7/tests/test_rptodo.py new file mode 100644 index 0000000000..6833cf8e4a --- /dev/null +++ b/typer-cli-python/source_code_step_7/tests/test_rptodo.py @@ -0,0 +1,150 @@ +import json + +import pytest +from typer.testing import CliRunner + +from rptodo import ( + DB_READ_ERROR, + ID_ERROR, + SUCCESS, + __app_name__, + __version__, + cli, + rptodo, +) + +runner = CliRunner() + + +def test_version(): + result = runner.invoke(cli.app, ["--version"]) + assert result.exit_code == 0 + assert f"{__app_name__} v{__version__}\n" in result.stdout + + +@pytest.fixture +def mock_json_file(tmp_path): + todo = [{"Description": "Get some milk.", "Priority": 2, "Done": False}] + db_file = tmp_path / "todo.json" + with db_file.open("w") as db: + json.dump(todo, db, indent=4) + return db_file + + +test_data1 = { + "description": ["Clean", "the", "house"], + "priority": 1, + "todo": { + "Description": "Clean the house.", + "Priority": 1, + "Done": False, + }, +} +test_data2 = { + "description": ["Wash the car"], + "priority": 2, + "todo": { + "Description": "Wash the car.", + "Priority": 2, + "Done": False, + }, +} + + +@pytest.mark.parametrize( + "description, priority, expected", + [ + pytest.param( + test_data1["description"], + test_data1["priority"], + (test_data1["todo"], SUCCESS), + ), + pytest.param( + test_data2["description"], + test_data2["priority"], + (test_data2["todo"], SUCCESS), + ), + ], +) +def test_add(mock_json_file, description, priority, expected): + todoer = rptodo.Todoer(mock_json_file) + assert todoer.add(description, priority) == expected + read = todoer._db_handler.read_todos() + assert len(read.todo_list) == 2 + + +@pytest.fixture +def mock_wrong_json_file(tmp_path): + db_file = tmp_path / "todo.json" + return db_file + + +def test_add_wrong_json_file(mock_wrong_json_file): + todoer = rptodo.Todoer(mock_wrong_json_file) + response = todoer.add(["test task"], 1) + assert response.error == DB_READ_ERROR + read = todoer._db_handler.read_todos() + assert len(read.todo_list) == 0 + + +@pytest.fixture +def mock_wrong_json_format(tmp_path): + db_file = tmp_path / "todo.json" + with db_file.open("w") as db: + db.write("") + return db_file + + +def test_add_wrong_json_format(mock_wrong_json_format): + todoer = rptodo.Todoer(mock_wrong_json_format) + assert todoer.add(test_data1["description"], test_data1["priority"]) == ( + test_data1["todo"], + SUCCESS, + ) + read = todoer._db_handler.read_todos() + assert len(read.todo_list) == 1 + + +test_todo1 = { + "Description": "Get some milk.", + "Priority": 2, + "Done": True, +} +test_todo2 = {} + + +@pytest.mark.parametrize( + "todo_id, expected", + [ + pytest.param(1, (test_todo1, SUCCESS)), + pytest.param(3, (test_todo2, ID_ERROR)), + ], +) +def test_set_done(mock_json_file, todo_id, expected): + todoer = rptodo.Todoer(mock_json_file) + assert todoer.set_done(todo_id) == expected + + +test_todo3 = { + "Description": "Get some milk.", + "Priority": 2, + "Done": False, +} +test_todo4 = {} + + +@pytest.mark.parametrize( + "todo_id, expected", + [ + pytest.param(1, (test_todo3, SUCCESS)), + pytest.param(3, (test_todo4, ID_ERROR)), + ], +) +def test_remove(mock_json_file, todo_id, expected): + todoer = rptodo.Todoer(mock_json_file) + assert todoer.remove(todo_id) == expected + + +def test_remove_all(mock_json_file): + todoer = rptodo.Todoer(mock_json_file) + assert todoer.remove_all() == ({}, SUCCESS)