Compare commits

...

64 Commits

Author SHA1 Message Date
KKlochko 10114da0ee Update the version.
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/tag Build is passing Details
10 months ago
KKlochko 0b723c8770 Update the requirements files and the changelog.
continuous-integration/drone Build is passing Details
10 months ago
KKlochko 62ff10dec4 Add tests to check responses for showing and deleting a backup plan.
11 months ago
KKlochko 7ef5b9975c Add the helpers and tests for updating the backup plan.
11 months ago
KKlochko e0f8ec9db5 Update the tests names.
11 months ago
KKlochko 1ec7c6464a Add the response for create command and the test.
11 months ago
KKlochko a16e43462a Add tests for the replace command.
11 months ago
KKlochko 2438dead07 Update the replace command to show responses.
11 months ago
KKlochko cabcde00c7 Add tests for the remove all backup plans service.
11 months ago
KKlochko 2874dc76ad Update the remove all backup plans command.
11 months ago
KKlochko 76e555aefb Refactor the test steps to show stdout.
11 months ago
KKlochko c1268abc1d Add tests for CLI errors.
11 months ago
KKlochko 9729f442c2 Update the show all plans command to show if there're no plans.
11 months ago
KKlochko b999975695 Add the tests for the read query.
11 months ago
KKlochko 476a0ef8cf Add the query exception and refactor get_by_id.
11 months ago
KKlochko d652469748 Update to refactor the delete action.
11 months ago
KKlochko cae4ce50c5 Update the repository to use exception for the delete action.
11 months ago
KKlochko 769edabbd3 Add the exceptions and helpers.
11 months ago
KKlochko a4a0f32f52 Add the service's test for deleting.
11 months ago
KKlochko 5e606b0e83 Update to step to test the result of CLI execution.
11 months ago
KKlochko add3c3d59e Add test for the `plan remove` command.
11 months ago
KKlochko 2d1870535f Add the fixture and steps for seeds.
11 months ago
KKlochko 7094a0d866 Add the support helpers to use fake data for tests.
11 months ago
KKlochko 18eb9b4d91 Add the E2E tests for the CLI interface.
11 months ago
KKlochko 6b247fbe4e Add the TestingConfiguration to simplify the testing configuration.
11 months ago
KKlochko ecb495a22a Add the replace command to update all data about a backup plan.
11 months ago
KKlochko f9b49c69bd Update the repository and service to implement the update action.
11 months ago
KKlochko bc22e8a8bd Add command to remove backup plan's destinations.
11 months ago
KKlochko b32e5d32b0 Add the restore command.
11 months ago
KKlochko 87682a056f Add the sync command.
11 months ago
KKlochko 359dcdfca6 Add the backup sync service.
11 months ago
KKlochko 192f930f8a Update the structure of the backup component.
11 months ago
KKlochko f708cdf3d4 Add the backup commands to sync the data.
11 months ago
KKlochko 70f60ff715 Update the configuration to use queries and commands.
11 months ago
KKlochko a0846b93a1 Add the injector and configurations.
11 months ago
KKlochko eb01a26713 Fix a typo.
11 months ago
KKlochko ca350eef80 Update the email information.
11 months ago
KKlochko f3915d93a9 Add the configuration to get user data.
11 months ago
KKlochko ea0cc3dab0 Add the remove commands.
11 months ago
KKlochko ea5d98490d Add the show commands for backup plans.
11 months ago
KKlochko b9564d75e7 Add the query to get all backup plans.
11 months ago
KKlochko 027fee47a6 Add the CLI command to add a backup plan.
11 months ago
KKlochko 4bfdcf066b Add the application service for backup plans.
11 months ago
KKlochko c8d2b84cfc Update the test to test models in a memory database.
11 months ago
KKlochko 0abefda95a Add the repository for the backup plan.
11 months ago
KKlochko 72f650fd64 Add the DTOs to convert the entities to theorm models.
11 months ago
KKlochko b10726796d Update to simplify the connection management.
11 months ago
KKlochko ccede52883 Update the models names and id types to uuids.
11 months ago
KKlochko 690cc7de9c Add the ORM.
11 months ago
KKlochko 2834ad8bee Update the README.
11 months ago
KKlochko f3c600bb8f Update the test to verify creating the backup plan.
11 months ago
KKlochko 594d9dea3f Update the Source to BackupPlan component and shared.
11 months ago
KKlochko c67dc14003 Update the test for the creating source feature to add destinations.
2 years ago
KKlochko 66c389abfa Add a getter for destinations to the Source.
2 years ago
KKlochko d3bc4bd5a2 Update the test for the creating source feature to add arguments.
2 years ago
KKlochko befdc9ea93 Update the test for the creating source feature to have examples.
2 years ago
KKlochko 9280cdaf7d Add a test for the creating source feature.
2 years ago
KKlochko e04b9894b9 Add the check for failed adding a path, because of path uniqueness.
2 years ago
KKlochko 9c88462bc0 Update the CI/CD configuration.
2 years ago
KKlochko 5758ff277c Add the requirements files.
2 years ago
KKlochko f12cd7d6f7 Add .gitignore.
2 years ago
KKlochko 9dec626025 Add a simple feature test to test path uniqueness.
2 years ago
KKlochko 7f9a29190a Add the behave for testing.
2 years ago
KKlochko 64fa91bbc1 Update the CI/CD configuration to deploy only for the tag events.
2 years ago

@ -3,6 +3,12 @@ type: docker
name: default name: default
steps: steps:
- name: test
image: python:3.10-alpine
commands:
- pip install -r requirements_dev.txt --cache-dir=/package_cache
- behave --stop
- name: publishing - name: publishing
image: python:3.10-alpine image: python:3.10-alpine
environment: environment:
@ -17,5 +23,5 @@ steps:
- $POETRY_HOME/bin/poetry install - $POETRY_HOME/bin/poetry install
- $POETRY_HOME/bin/poetry publish --build --username $PYPI_USERNAME --password $PYPI_PASSWORD - $POETRY_HOME/bin/poetry publish --build --username $PYPI_USERNAME --password $PYPI_PASSWORD
when: when:
branch: event: tag
- main

181
.gitignore vendored

@ -0,0 +1,181 @@
pyrightconfig.json
*~
.\#*
\#*\#
.*.~undo-tree~
*.db
# Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# End of https://www.toptal.com/developers/gitignore/api/python

@ -61,4 +61,7 @@
Added *show all* option for Source. Added *show all* option for Source.
** 0.8.16 <2023-07-01 Sat> ** 0.8.16 <2023-07-01 Sat>
Added *show all* option for Group. Added *show all* option for Group.
** 1.0.0 <2025-02-02 Sun>
Rewrite the application to update the architecture.
Updated Groups temporary removed.

@ -15,7 +15,7 @@ Dependencies
Author Author
====== ======
Kostiantyn Klochko (c) 2023 Kostiantyn Klochko (c) 2023-2025
License License
======= =======

@ -0,0 +1,28 @@
Feature: Creating a backup plan with the CLI
@fixture.injector
@fixture.in_memory_database
@fixture.cli
Scenario Outline: Create an new unique backup plan with the CLI
Given the CLI arguments are "<arguments>"
When I run the CLI
Then the CLI executed with "success"
Examples:
| arguments |
| plans add --label my_label --source /mnt -d /mnt2 -d /mnt3 |
| plans add --label label2 --source /mnt -d /mnt2 -d /mnt3 |
@fixture.injector
@fixture.in_memory_database
@fixture.cli
Scenario Outline: Response of creating an new unique backup plan with the CLI
Given the CLI arguments are "<arguments>"
When I run the CLI
Then the CLI executed with "<result>"
And the CLI output contains "<result_message>"
Examples:
| arguments | result | result_message | description |
| plans add --label my_label --source /mnt -d /mnt2 -d /mnt3 | success | The backup plan is added. | add a plan |

@ -0,0 +1,72 @@
Feature: Deleting a backup plan with the CLI
@fixture.injector
@fixture.in_memory_database
@fixture.seeds
@fixture.cli
Scenario Outline: Deleting a backup plan with the CLI
Given the CLI arguments are "<arguments>"
And I have a backup plan with id="<existing_backup_plan_id>"
When I run the CLI
Then the CLI executed with "<result>"
Examples:
| arguments | existing_backup_plan_id | result | description |
| plans remove one -i 8aa59e7e-dc75-459b-beb5-b710b39be583 | 8aa59e7e-dc75-459b-beb5-b710b39be583 | success | delete an existing plan |
| plans remove one -i 8aa59e7e-dc75-459b-aeb5-b710b39be583 | 8aa59e7e-dc75-459b-beb5-b710b39be512 | error | delete a non-existing plan |
@fixture.injector
@fixture.in_memory_database
@fixture.cli
Scenario Outline: Response of deleting non-existing backup plan with the CLI
Given the CLI arguments are "<arguments>"
When I run the CLI
Then the CLI executed with "<result>"
And the CLI contains the error: "<result_error>"
Examples:
| arguments | result | result_error | description |
| plans remove one -i 8aa59e7e-dc75-459b-aeb5-b710b39be583 | error | [ERROR] Failed to delete the backup plan, because it doesn't exist. | delete a non-existing plan |
@fixture.injector
@fixture.in_memory_database
@fixture.seeds
@fixture.cli
Scenario Outline: Response of deleting existing backup plans with the CLI
Given the CLI arguments are "<arguments>"
And I have a backup plan with id="<existing_backup_plan_id>"
When I run the CLI
Then the CLI executed with "<result>"
And the CLI output contains "<result_message>"
And the CLI output contains "<existing_backup_plan_id>"
Examples:
| arguments | existing_backup_plan_id | result | result_message | description |
| plans remove one -i 8aa59e7e-dc75-459b-aeb5-b710b39be583 | 8aa59e7e-dc75-459b-aeb5-b710b39be583 | success | Removed the backup plan with | delete the backup plan |
@fixture.injector
@fixture.in_memory_database
@fixture.cli
Scenario Outline: Response of deleting no backup plans with the CLI
Given the CLI arguments are "<arguments>"
When I run the CLI
Then the CLI executed with "<result>"
And the CLI output contains "<result_message>"
Examples:
| arguments | result | result_message | description |
| plans remove all | success | Nothing to remove. No backup plans. | delete no plans |
@fixture.injector
@fixture.in_memory_database
@fixture.seeds
@fixture.cli
Scenario Outline: Response of deleting all backup plans with the CLI
Given the CLI arguments are "<arguments>"
When I run the CLI
Then the CLI executed with "<result>"
And the CLI output contains "<result_message>"
Examples:
| arguments | result | result_message | description |
| plans remove all | success | All backup plans removed. | delete all backup plans |

@ -0,0 +1,75 @@
Feature: Show a backup plan with the CLI
@fixture.injector
@fixture.in_memory_database
@fixture.seeds
@fixture.cli
Scenario Outline: Show a backup plan with the CLI
Given the CLI arguments are "<arguments>"
And I have a backup plan with id="<existing_backup_plan_id>"
When I run the CLI
Then the CLI executed with "<result>"
Examples:
| arguments | existing_backup_plan_id | result | description |
| plans show one -i 8aa59e7e-dc75-459b-beb5-b710b39be583 | 8aa59e7e-dc75-459b-beb5-b710b39be583 | success | show an existing plan |
| plans show one -i 8aa59e7e-dc75-459b-aeb5-b710b39be583 | 8aa59e7e-dc75-459b-beb5-b710b39be512 | error | show a non-existing plan |
@fixture.injector
@fixture.in_memory_database
@fixture.seeds
@fixture.cli
Scenario Outline: Response of showing existing backup plans with the CLI
Given the CLI arguments are "<arguments>"
And I have a backup plan with id="<existing_backup_plan_id>"
When I run the CLI
Then the CLI executed with "<result>"
And the CLI output doesn't contains "<result_message>"
And the CLI output contains "<existing_backup_plan_id>"
Examples:
| arguments | existing_backup_plan_id | result | result_message | description |
| plans show one -i 8aa59e7e-dc75-459b-aeb5-b710b39be583 | 8aa59e7e-dc75-459b-aeb5-b710b39be583 | success | Removed the backup plan with | delete the backup plan |
@fixture.injector
@fixture.in_memory_database
@fixture.cli
Scenario Outline: Response of showing non-existing backup plans with the CLI
Given the CLI arguments are "<arguments>"
When I run the CLI
Then the CLI executed with "<result>"
And the CLI contains the error: "<result_error>"
Examples:
| arguments | result | result_error | description |
| plans show one -i 8aa59e7e-dc75-459b-aeb5-b710b39be583 | error | [ERROR] The backup plan was not found. | show a non-existing plan |
@fixture.injector
@fixture.in_memory_database
@fixture.cli
Scenario Outline: Response of showing no backup plans with the CLI
Given the CLI arguments are "<arguments>"
When I run the CLI
Then the CLI executed with "<result>"
And the CLI output contains "<result_message>"
Examples:
| arguments | result | result_message | description |
| plans show all | success | No backup plans. | shows no plans |
@fixture.injector
@fixture.in_memory_database
@fixture.seeds
@fixture.cli
Scenario Outline: Response of showing backup plans with the CLI
Given the CLI arguments are "<arguments>"
And I have a backup plan with id="8aa59e7e-dc75-459b-beb5-b710b39be583"
When I run the CLI
Then the CLI executed with "<result>"
And the CLI output contains "8aa59e7e-dc75-459b-beb5-b710b39be583"
And the CLI output doesn't contains "<no_message>"
Examples:
| arguments | result | no_message | description |
| plans show all | success | No backup plans. | shows plans |

@ -0,0 +1,45 @@
Feature: Update a backup plan with the CLI
@fixture.injector
@fixture.in_memory_database
@fixture.seeds
@fixture.cli
Scenario Outline: Replace a backup plan with the CLI
Given the CLI arguments are "<arguments>"
And I have a backup plan with id="<existing_backup_plan_id>"
When I run the CLI
Then the CLI executed with "<result>"
Examples:
| arguments | existing_backup_plan_id | result | description |
| plans update replace -i 8aa59e7e-dc75-459b-beb5-b710b39be583 -l test -s /mnt -d /mnt2 -d /mnt3 | 8aa59e7e-dc75-459b-beb5-b710b39be583 | success | replace an existing plan |
| plans update replace -i 8aa59e7e-dc75-459b-beb5-b710b39be583 -l test -s /mnt -d /mnt2 -d /mnt3 | 8aa59e7e-dc75-459b-beb5-b710b39be512 | error | replace a non-existing plan |
@fixture.injector
@fixture.in_memory_database
@fixture.cli
Scenario Outline: Response of replacing non-existing backup plans with the CLI
Given the CLI arguments are "<arguments>"
When I run the CLI
Then the CLI executed with "<result>"
And the CLI contains the error: "<result_error>"
Examples:
| arguments | result | result_error | description |
| plans update replace -i 8aa59e7e-dc75-459b-beb5-b710b39be583 -l test -s /mnt -d /mnt2 -d /mnt3 | error | [ERROR] The backup plan was not found. | replace a non-existing plan |
@fixture.injector
@fixture.in_memory_database
@fixture.seeds
@fixture.cli
Scenario Outline: Response of replacing an existing backup plans with the CLI
Given the CLI arguments are "<arguments>"
And I have a backup plan with id="<existing_backup_plan_id>"
When I run the CLI
Then the CLI executed with "<result>"
And the CLI output contains "<result_message>"
Examples:
| arguments | existing_backup_plan_id | result | result_message | description |
| plans update replace -i 8aa59e7e-dc75-459b-beb5-b710b39be583 -l test -s /mnt -d /mnt2 -d /mnt3 | 8aa59e7e-dc75-459b-beb5-b710b39be583 | success | The backup plan updated. | replace an existing plan |

@ -0,0 +1,17 @@
Feature: Creating a backup plan
@fixture.injector
@fixture.in_memory_database
@fixture.backup_plan_service
Scenario Outline: Create an new unique backup plan
Given the label "<label>"
And the path "<source_path>"
And the destinations <destinations>
When I create the backup plan
Then it should be created successfully
Examples:
| label | source_path | destinations |
| usb | /mnt/usb | [] |
| db | /db | ["/backup/db"] |
| temp | /tmp | ["/backup/tmp1", "/backup/tmp2"] |

@ -0,0 +1,28 @@
Feature: Delete a backup plan
@fixture.injector
@fixture.in_memory_database
@fixture.seeds
Scenario Outline: Deleting a backup plan
Given I have a backup plan with id="<existing_backup_plan_id>"
When I remove the backup plan with id="<backup_plan_id>"
Then the result should be "<result>"
Examples:
| backup_plan_id | existing_backup_plan_id | result | description |
| 8aa59e7e-dc75-459b-beb5-b710b39be583 | 8aa59e7e-dc75-459b-beb5-b710b39be583 | success | delete an existing plan |
| 8aa59e7e-dc75-459b-aeb5-b710b39be583 | 8aa59e7e-dc75-459b-beb5-b710b39be512 | error | delete a non-existing plan |
@fixture.injector
@fixture.in_memory_database
Scenario: Delete no backup plans
When I remove all backup plans
Then the result value should be "False"
@fixture.injector
@fixture.in_memory_database
@fixture.seeds
Scenario: Delete all backup plans
Given I have a backup plan with id="8aa59e7e-dc75-459b-beb5-b710b39be583"
When I remove all backup plans
Then the result value should be "True"

@ -0,0 +1,14 @@
Feature: Read a backup plan by an id
@fixture.injector
@fixture.in_memory_database
@fixture.seeds
Scenario Outline: Read a backup plan with the CLI
Given I have a backup plan with id="<existing_backup_plan_id>"
When I read the backup plan with id="<backup_plan_id>"
Then the result should be "<result>"
Examples:
| backup_plan_id | existing_backup_plan_id | result | description |
| 8aa59e7e-dc75-459b-beb5-b710b39be583 | 8aa59e7e-dc75-459b-beb5-b710b39be583 | success | delete an existing plan |
| 8aa59e7e-dc75-459b-aeb5-b710b39be583 | 8aa59e7e-dc75-459b-beb5-b710b39be512 | error | delete a non-existing plan |

@ -0,0 +1,30 @@
Feature: Update a backup plan
@fixture.injector
@fixture.in_memory_database
@fixture.seeds
Scenario Outline: Update a backup plan's fields
Given I have a backup plan with id="<existing_backup_plan_id>"
When I update the backup plan with id="<backup_plan_id>" and data="<updated_data>"
Then the result should be "<result>"
Examples:
| backup_plan_id | existing_backup_plan_id | updated_data | result | description |
| 8aa59e7e-dc75-459b-beb5-b710b39be583 | 8aa59e7e-dc75-459b-beb5-b710b39be583 | {"label": "new_label"} | success | update an existing plan |
| 8aa59e7e-dc75-459b-aeb5-b710b39be583 | 8aa59e7e-dc75-459b-beb5-b710b39be512 | {"label": "new_label"} | error | update a non-existing plan |
@fixture.injector
@fixture.in_memory_database
@fixture.seeds
Scenario Outline: Updated backup plan's fields must be updated
Given I have a backup plan with id="<existing_backup_plan_id>"
When I update the backup plan with id="<backup_plan_id>" and data="<updated_data>"
And I read the backup plan with id="<backup_plan_id>"
Then the result should be "<result>"
And the backup_plan must have same field: "<updated_data>"
Examples:
| backup_plan_id | existing_backup_plan_id | updated_data | result | description |
| 8aa59e7e-dc75-459b-beb5-b710b39be583 | 8aa59e7e-dc75-459b-beb5-b710b39be583 | {"label": "new_label"} | success | update the label |
| 8aa59e7e-dc75-459b-beb5-b710b39be583 | 8aa59e7e-dc75-459b-beb5-b710b39be583 | {"source": "/new/mnt"} | success | update the source |
| 8aa59e7e-dc75-459b-beb5-b710b39be583 | 8aa59e7e-dc75-459b-beb5-b710b39be583 | {"destinations": ["/mnt2", "/mnt3"]} | success | update the destinations |

@ -0,0 +1,62 @@
from behave import fixture, use_fixture
from injector import Injector
from typer.testing import CliRunner
from features.support import DataSeeds
from tui_rsync.core.components.backup_plan.application.repository.backup_plan_repository import BackupPlanRepository
from tui_rsync.core.components.backup_plan.application.services.backup_plan_service import BackupPlanService
from tui_rsync.infrastructure.configuration import TestingConfiguration, CurrentConfiguration
from tui_rsync.infrastructure.orm import InMemoryDatabaseManager
from tui_rsync.user_interface.cli.cli import cli_app
@fixture
def setup_cli_runner(context):
context.cli_runner = CliRunner()
yield context.cli_runner
@fixture
def setup_cli_app(context):
context.cli_app = cli_app
yield context.cli_app
@fixture
def setup_in_memory_database_manager(context):
context.db = context.injector.get(InMemoryDatabaseManager)
yield context.db
@fixture
def setup_backup_plan_service(context):
context.backup_plan_service = context.injector.get(BackupPlanService)
yield context.backup_plan_service
@fixture
def setup_injector_for_testing(context):
context.injector = Injector([TestingConfiguration()])
CurrentConfiguration.set_injector(context.injector)
yield context.injector
@fixture
def setup_seeds(context):
context.data_seeds = DataSeeds(context.injector)
context.data_seeds.seeds(context)
yield context.data_seeds
def before_tag(context, tag):
if tag == "fixture.injector":
use_fixture(setup_injector_for_testing, context)
if tag == "fixture.in_memory_database":
use_fixture(setup_in_memory_database_manager, context)
if tag == "fixture.backup_plan_service":
use_fixture(setup_backup_plan_service, context)
if tag == "fixture.cli":
use_fixture(setup_cli_app, context)
use_fixture(setup_cli_runner, context)
if tag == "fixture.seeds":
use_fixture(setup_seeds, context)

@ -0,0 +1,126 @@
from behave import given, when, then
import json
from features.support.backup_plan_helpers import compare_destinations, update_backup_plan, compare_backup_plan_fields
from features.support.helpers import json_to_dict
from tui_rsync.core.components.backup_plan.application.services import RemoveAllBackupPlansService
from tui_rsync.core.components.backup_plan.application.services.backup_plan_service import BackupPlanService
from tui_rsync.core.components.backup_plan.domain import BackupPlan, Source, Destination
from tui_rsync.core.shared_kernel.components.common import UUID
from tui_rsync.core.shared_kernel.ports.Exceptions import CommandException
from tui_rsync.core.shared_kernel.ports.Exceptions.query_exception import QueryException
@given('the label "{label}"')
def given_source_label(context, label):
context.label = label
@given('the path "{source_path}"')
def given_source_path(context, source_path):
context.source_path = source_path
@given('the destinations {destinations_json}')
def given_source_destinations(context, destinations_json):
context.destinations = json.loads(destinations_json)
@when('I create the backup plan')
def add_backup_plan(context):
try:
context.backup_plan = BackupPlan(
label=context.label,
source=Source(context.source_path),
destinations=list(map(lambda path: Destination(path), context.destinations)),
)
context.backup_plan_service.add(context.backup_plan)
context.backup_plan = context.backup_plan_service.get_by_id(context.backup_plan.id)
except Exception as e:
context.exception_raised = True
else:
context.exception_raised = False
@when('I update the backup plan with id="{backup_plan_id}" and data="{updated_data_json}"')
def when_update_backup_plan_with_id(context, backup_plan_id, updated_data_json):
try:
context.backup_plan_service = context.injector.get(BackupPlanService)
context.backup_plan = context.backup_plan_service.get_by_id(UUID(backup_plan_id))
update_backup_plan(context.backup_plan, json_to_dict(updated_data_json))
context.backup_plan_service.update(context.backup_plan)
except QueryException as e:
context.exception_raised = True
else:
context.exception_raised = False
@when('I remove the backup plan with id="{backup_plan_id}"')
def when_remove_backup_plan_with_id(context, backup_plan_id):
try:
context.backup_plan_service = context.injector.get(BackupPlanService)
context.backup_plan_service.delete(UUID(backup_plan_id))
except CommandException as e:
context.exception_raised = True
else:
context.exception_raised = False
@when('I remove all backup plans')
def when_remove_all_backup_plans(context):
try:
context.backup_plan_service = context.injector.get(RemoveAllBackupPlansService)
context.result_value = context.backup_plan_service.remove_all()
except CommandException as e:
context.exception_raised = True
else:
context.exception_raised = False
@when('I read the backup plan with id="{backup_plan_id}"')
def when_read_backup_plan_with_id(context, backup_plan_id):
try:
context.backup_plan_service = context.injector.get(BackupPlanService)
context.backup_plan = context.backup_plan_service.get_by_id(UUID(backup_plan_id))
except QueryException as e:
context.exception_raised = True
else:
context.exception_raised = False
@then('it should be created successfully')
def backup_plan_has_added(context):
assert context.exception_raised == False
assert context.backup_plan is not None
assert context.backup_plan.label == context.label
assert context.backup_plan.source == Source(context.source_path)
assert compare_destinations(
context.backup_plan.destinations,
context.destinations
)
@then('the result should be "{result}"')
def then_cli_executed_successfully(context, result):
match result:
case "success":
assert context.exception_raised == False
case "error":
assert context.exception_raised
@then('the result value should be "{result}"')
def then_cli_executed_successfully(context, result):
assert result == str(context.result_value)
@then('the backup_plan must have same field: "{fields_json}"')
def backup_plan_has_added(context, fields_json):
assert compare_backup_plan_fields(context.backup_plan, json_to_dict(fields_json))

@ -0,0 +1,39 @@
from behave import given, when, then
@given('the CLI arguments are "{arguments}"')
def given_cli_arguments(context, arguments):
context.arguments = arguments.split()
@when('I run the CLI')
def when_run_cli(context):
context.cli_result = context.cli_runner.invoke(context.cli_app, context.arguments)
@then('the CLI executed with "{result}"')
def then_cli_executed_successfully(context, result):
match result:
case "success":
assert context.cli_result.exit_code == 0
case "error":
assert context.cli_result.exit_code != 0
@then('the CLI output contains "{string}"')
def then_cli_output_contains(context, string):
print(f"Got: {context.cli_result.stdout}")
assert string in context.cli_result.stdout
@then('the CLI output doesn\'t contains "{string}"')
def then_cli_output_contains(context, string):
print(f"Got: {context.cli_result.stdout}")
assert not (string in context.cli_result.stdout)
@then('the CLI contains the error: "{string}"')
def then_cli_output_contains_error(context, string):
print(f"Got: {context.cli_result.stdout}")
assert string in context.cli_result.stdout

@ -0,0 +1,7 @@
from behave import given
@given('I have a backup plan with id="{existing_backup_plan_id}"')
def given_existing_backup_plan_id_seed(context, existing_backup_plan_id):
context.data_seeds.create_backup_plan(existing_backup_plan_id)

@ -0,0 +1,4 @@
from .fake_factories import FakeFactories
from .data_seeds import DataSeeds
__all__ = ['FakeFactories', 'DataSeeds']

@ -0,0 +1,57 @@
from features.support.helpers import json_to_dict
from tui_rsync.core.components.backup_plan.domain import Destination, BackupPlan, Source
def json_to_backup_plan(backup_plan_json):
fields = json_to_dict(backup_plan_json)
backup_plan = BackupPlan(
label=fields['label'],
source=Source(fields['source']),
destinations=list(map(lambda path: Destination(path), fields['destinations'])),
)
return backup_plan
def update_backup_plan(backup_plan, fields: dict):
for field, value in fields.items():
match field:
case 'label':
backup_plan.label = value
case 'source':
backup_plan.source = Source(value)
case 'destinations':
backup_plan.destinations = list(map(lambda path: Destination(path), value))
return backup_plan
def compare_backup_plan_fields(backup_plan, fields: dict):
for field, value in fields.items():
match field:
case 'label':
if backup_plan.label != value:
return False
case 'source':
if backup_plan.source.path != value:
return False
case 'destinations':
if not compare_destinations(backup_plan.destinations, value):
return False
return True
def compare_destinations(actual: list[Destination], expected: list[str]) -> bool:
actual_path_set = {destionation.path for destionation in actual}
return actual_path_set == set(expected)
def compare_backup_plans(backup_plan, expected):
return backup_plan.label == expected.label \
and backup_plan.source == expected.source_path \
and compare_destinations(
backup_plan.destinations,
expected.destinations
)

@ -0,0 +1,19 @@
from injector import Injector
from tui_rsync.core.components.backup_plan.application.services.backup_plan_service import BackupPlanService
from . import FakeFactories
class DataSeeds:
def __init__(self, configuration: Injector):
self.fake_factories = FakeFactories()
self.configuration = configuration
def create_backup_plan(self, uuid: str | None = None):
service = self.configuration.get(BackupPlanService)
backup_plan = self.fake_factories.backup_plan(uuid)
service.add(backup_plan)
return backup_plan
def seeds(self, context):
context.backup_plan_seed = self.create_backup_plan()

@ -0,0 +1,34 @@
from faker import Faker
from tui_rsync.core.components.backup_plan.domain import BackupPlan, Path, BackupPlanId, Source, Destination
from tui_rsync.core.shared_kernel.components.common import UUID, Label
class FakeFactories:
def __init__(self):
self.faker = Faker()
def uuid(self):
return UUID(self.faker.uuid4())
def backup_plan_id(self):
return BackupPlanId(self.uuid().id)
def label(self) -> Label:
return Label(self.faker.sentence(nb_words=4))
def path(self) -> Path:
return Path(self.faker.file_path())
def source(self) -> Source:
return Source(self.path().path)
def destination(self) -> Destination:
return Destination(self.path().path)
def backup_plan(self, uuid: str | None = None):
uuid = self.backup_plan_id() if uuid is None else UUID(uuid)
return BackupPlan(id=uuid,
label=self.label(),
source=self.source(),
destinations=[self.destination()])

@ -0,0 +1,5 @@
import json
def json_to_dict(json_data):
return json.loads(json_data)

128
poetry.lock generated

@ -1,4 +1,25 @@
# This file is automatically @generated by Poetry and should not be changed by hand. # This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand.
[[package]]
name = "behave"
version = "1.2.6"
description = "behave is behaviour-driven development, Python style"
category = "dev"
optional = false
python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
{file = "behave-1.2.6-py2.py3-none-any.whl", hash = "sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c"},
{file = "behave-1.2.6.tar.gz", hash = "sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86"},
]
[package.dependencies]
parse = ">=1.8.2"
parse-type = ">=0.4.2"
six = ">=1.11"
[package.extras]
develop = ["coverage", "invoke (>=0.21.0)", "modernize (>=0.5)", "path.py (>=8.1.2)", "pathlib", "pycmd", "pylint", "pytest (>=3.0)", "pytest-cov", "tox"]
docs = ["sphinx (>=1.6)", "sphinx-bootstrap-theme (>=0.6)"]
[[package]] [[package]]
name = "click" name = "click"
@ -27,6 +48,37 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
[[package]]
name = "faker"
version = "35.0.0"
description = "Faker is a Python package that generates fake data for you."
category = "dev"
optional = false
python-versions = ">=3.8"
files = [
{file = "Faker-35.0.0-py3-none-any.whl", hash = "sha256:926d2301787220e0554c2e39afc4dc535ce4b0a8d0a089657137999f66334ef4"},
{file = "faker-35.0.0.tar.gz", hash = "sha256:42f2da8cf561e38c72b25e9891168b1e25fec42b6b0b5b0b6cd6041da54af885"},
]
[package.dependencies]
python-dateutil = ">=2.4"
typing-extensions = "*"
[[package]]
name = "injector"
version = "0.22.0"
description = "Injector - Python dependency injection framework, inspired by Guice"
category = "main"
optional = false
python-versions = "*"
files = [
{file = "injector-0.22.0-py2.py3-none-any.whl", hash = "sha256:74379ccef3b893bc7d0b7d504c255b5160c5a55e97dc7bdcc73cb33cc7dce3a1"},
{file = "injector-0.22.0.tar.gz", hash = "sha256:79b2d4a0874c75d3aa735f11c5b32b89d9542711ca07071161882c5e9cc15ed6"},
]
[package.extras]
dev = ["black (==24.3.0)", "build (==1.0.3)", "check-manifest (==0.49)", "click (==8.1.7)", "coverage[toml] (==7.3.2)", "exceptiongroup (==1.2.0)", "importlib-metadata (==7.0.0)", "iniconfig (==2.0.0)", "mypy (==1.7.1)", "mypy-extensions (==1.0.0)", "packaging (==23.2)", "pathspec (==0.12.1)", "platformdirs (==4.1.0)", "pluggy (==1.3.0)", "pyproject-hooks (==1.0.0)", "pytest (==7.4.3)", "pytest-cov (==4.1.0)", "tomli (==2.0.1)", "typing-extensions (==4.9.0)", "zipp (==3.17.0)"]
[[package]] [[package]]
name = "markdown-it-py" name = "markdown-it-py"
version = "2.2.0" version = "2.2.0"
@ -64,6 +116,39 @@ files = [
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
] ]
[[package]]
name = "parse"
version = "1.19.1"
description = "parse() is the opposite of format()"
category = "dev"
optional = false
python-versions = "*"
files = [
{file = "parse-1.19.1-py2.py3-none-any.whl", hash = "sha256:371ed3800dc63983832159cc9373156613947707bc448b5215473a219dbd4362"},
{file = "parse-1.19.1.tar.gz", hash = "sha256:cc3a47236ff05da377617ddefa867b7ba983819c664e1afe46249e5b469be464"},
]
[[package]]
name = "parse-type"
version = "0.6.2"
description = "Simplifies to build parse types based on the parse module"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*"
files = [
{file = "parse_type-0.6.2-py2.py3-none-any.whl", hash = "sha256:06d39a8b70fde873eb2a131141a0e79bb34a432941fb3d66fad247abafc9766c"},
{file = "parse_type-0.6.2.tar.gz", hash = "sha256:79b1f2497060d0928bc46016793f1fca1057c4aacdf15ef876aa48d75a73a355"},
]
[package.dependencies]
parse = {version = ">=1.18.0", markers = "python_version >= \"3.0\""}
six = ">=1.15"
[package.extras]
develop = ["build (>=0.5.1)", "coverage (>=4.4)", "pylint", "pytest (<5.0)", "pytest (>=5.0)", "pytest-cov", "pytest-html (>=1.19.0)", "ruff", "tox (>=2.8,<4.0)", "twine (>=1.13.0)", "virtualenv (<20.22.0)", "virtualenv (>=20.0.0)"]
docs = ["Sphinx (>=1.6)", "sphinx-bootstrap-theme (>=0.6.0)"]
testing = ["pytest (<5.0)", "pytest (>=5.0)", "pytest-html (>=1.19.0)"]
[[package]] [[package]]
name = "peewee" name = "peewee"
version = "3.15.4" version = "3.15.4"
@ -118,6 +203,21 @@ files = [
[package.extras] [package.extras]
plugins = ["importlib-metadata"] plugins = ["importlib-metadata"]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
description = "Extensions to the standard Python datetime module"
category = "dev"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
files = [
{file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
{file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
]
[package.dependencies]
six = ">=1.5"
[[package]] [[package]]
name = "rich" name = "rich"
version = "13.3.1" version = "13.3.1"
@ -137,6 +237,18 @@ pygments = ">=2.14.0,<3.0.0"
[package.extras] [package.extras]
jupyter = ["ipywidgets (>=7.5.1,<9)"] jupyter = ["ipywidgets (>=7.5.1,<9)"]
[[package]]
name = "six"
version = "1.16.0"
description = "Python 2 and 3 compatibility utilities"
category = "dev"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
files = [
{file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
{file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
]
[[package]] [[package]]
name = "typer" name = "typer"
version = "0.7.0" version = "0.7.0"
@ -158,7 +270,19 @@ dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2
doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"] doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"]
test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)"] test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)"]
[[package]]
name = "typing-extensions"
version = "4.12.2"
description = "Backported and Experimental Type Hints for Python 3.8+"
category = "dev"
optional = false
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "63d5db59d0d54f5818a8274de848b03549d5df693c2310b67743e7e6c9e1968e" content-hash = "a120a93b02cdb7d460b634c268f377711e70a391395715d5635058193c108084"

@ -1,8 +1,8 @@
[tool.poetry] [tool.poetry]
name = "tui-rsync" name = "tui-rsync"
version = "0.8.16" version = "1.0.0"
description = "tui-rsync will help you to manage yours backups." description = "tui-rsync will help you to manage yours backups."
authors = ["Kostiantyn Klochko <kostya_klochko@ukr.net>"] authors = ["Kostiantyn Klochko <kklochko@protonmail.com>"]
readme = "README.rst" readme = "README.rst"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
packages = [{include = "tui_rsync"}] packages = [{include = "tui_rsync"}]
@ -19,10 +19,15 @@ typer = "^0.7.0"
peewee = "^3.15.4" peewee = "^3.15.4"
pyfzf = "^0.3.1" pyfzf = "^0.3.1"
platformdirs = "^3.1.1" platformdirs = "^3.1.1"
injector = "^0.22.0"
[tool.poetry.scripts] [tool.poetry.scripts]
tui-rsync = "tui_rsync.main:main" tui-rsync = "tui_rsync.main:main"
[tool.poetry.group.dev.dependencies]
behave = "^1.2.6"
faker = "^35.0.0"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"

@ -0,0 +1,32 @@
click==8.1.3 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e \
--hash=sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows" \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
injector==0.22.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:74379ccef3b893bc7d0b7d504c255b5160c5a55e97dc7bdcc73cb33cc7dce3a1 \
--hash=sha256:79b2d4a0874c75d3aa735f11c5b32b89d9542711ca07071161882c5e9cc15ed6
markdown-it-py==2.2.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30 \
--hash=sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1
mdurl==0.1.2 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
peewee==3.15.4 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:2581520c8dfbacd9d580c2719ae259f0637a9e46eda47dfc0ce01864c6366205
platformdirs==3.1.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:024996549ee88ec1a9aa99ff7f8fc819bb59e2c3477b410d90a16d32d6e707aa \
--hash=sha256:e5986afb596e4bb5bde29a79ac9061aa955b94fca2399b7aaac4090860920dd8
pyfzf==0.3.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:736f71563461b75f6f85b55345bdc638fa0dc14c32c857c59e8b1ca1cfa3cf4a \
--hash=sha256:dd902e34cffeca9c3082f96131593dd20b4b3a9bba5b9dde1b0688e424b46bd2
pygments==2.14.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297 \
--hash=sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717
rich==13.3.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:125d96d20c92b946b983d0d392b84ff945461e5a06d3867e9f9e575f8697b67f \
--hash=sha256:8aa57747f3fc3e977684f0176a88e789be314a99f99b43b75d1e9cb5dc6db9e9
typer==0.7.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:b5e704f4e48ec263de1c0b3a2387cd405a13767d2f907f44c1a08cbad96f606d \
--hash=sha256:ff797846578a9f2a201b53442aedeb543319466870fbe1c701eab66dd7681165

@ -0,0 +1,53 @@
behave==1.2.6 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:b9662327aa53294c1351b0a9c369093ccec1d21026f050c3bd9b3e5cccf81a86 \
--hash=sha256:ebda1a6c9e5bfe95c5f9f0a2794e01c7098b3dde86c10a95d8621c5907ff6f1c
click==8.1.3 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e \
--hash=sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48
colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" and platform_system == "Windows" \
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
faker==35.0.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:42f2da8cf561e38c72b25e9891168b1e25fec42b6b0b5b0b6cd6041da54af885 \
--hash=sha256:926d2301787220e0554c2e39afc4dc535ce4b0a8d0a089657137999f66334ef4
injector==0.22.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:74379ccef3b893bc7d0b7d504c255b5160c5a55e97dc7bdcc73cb33cc7dce3a1 \
--hash=sha256:79b2d4a0874c75d3aa735f11c5b32b89d9542711ca07071161882c5e9cc15ed6
markdown-it-py==2.2.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30 \
--hash=sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1
mdurl==0.1.2 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
parse-type==0.6.2 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:06d39a8b70fde873eb2a131141a0e79bb34a432941fb3d66fad247abafc9766c \
--hash=sha256:79b1f2497060d0928bc46016793f1fca1057c4aacdf15ef876aa48d75a73a355
parse==1.19.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:371ed3800dc63983832159cc9373156613947707bc448b5215473a219dbd4362 \
--hash=sha256:cc3a47236ff05da377617ddefa867b7ba983819c664e1afe46249e5b469be464
peewee==3.15.4 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:2581520c8dfbacd9d580c2719ae259f0637a9e46eda47dfc0ce01864c6366205
platformdirs==3.1.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:024996549ee88ec1a9aa99ff7f8fc819bb59e2c3477b410d90a16d32d6e707aa \
--hash=sha256:e5986afb596e4bb5bde29a79ac9061aa955b94fca2399b7aaac4090860920dd8
pyfzf==0.3.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:736f71563461b75f6f85b55345bdc638fa0dc14c32c857c59e8b1ca1cfa3cf4a \
--hash=sha256:dd902e34cffeca9c3082f96131593dd20b4b3a9bba5b9dde1b0688e424b46bd2
pygments==2.14.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297 \
--hash=sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717
python-dateutil==2.9.0.post0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
rich==13.3.1 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:125d96d20c92b946b983d0d392b84ff945461e5a06d3867e9f9e575f8697b67f \
--hash=sha256:8aa57747f3fc3e977684f0176a88e789be314a99f99b43b75d1e9cb5dc6db9e9
six==1.16.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \
--hash=sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254
typer==0.7.0 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:b5e704f4e48ec263de1c0b3a2387cd405a13767d2f907f44c1a08cbad96f606d \
--hash=sha256:ff797846578a9f2a201b53442aedeb543319466870fbe1c701eab66dd7681165
typing-extensions==4.12.2 ; python_version >= "3.10" and python_version < "4.0" \
--hash=sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d \
--hash=sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8

@ -1,4 +0,0 @@
from tui_rsync.cli.cli import cli_app
from tui_rsync.cli.sync import sync
from tui_rsync.cli.rsync import Rsync
from tui_rsync.cli.label_prompt import LabelPrompt

@ -1 +0,0 @@
from tui_rsync.cli.groups.groups import groups

@ -1,56 +0,0 @@
################################################################################
# Copyright (C) 2023 Kostiantyn Klochko <kostya_klochko@ukr.net> #
# #
# This file is part of tui-rsync. #
# #
# tui-rsync is free software: you can redistribute it and/or modify it under #
# uthe terms of the GNU General Public License as published by the Free #
# Software Foundation, either version 3 of the License, or (at your option) #
# any later version. #
# #
# tui-rsync is distributed in the hope that it will be useful, but WITHOUT ANY #
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS #
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more #
# details. #
# #
# You should have received a copy of the GNU General Public License along with #
# tui-rsync. If not, see <https://www.gnu.org/licenses/>. #
################################################################################
from rich.console import Console
from rich.prompt import Prompt
from typing import List, Optional
import typer
from tui_rsync.models.models import Group
from tui_rsync.models.models import all_group_labels
from tui_rsync.cli.groups.group_prompt import GroupPrompt
console = Console()
group_remove = typer.Typer()
@group_remove.command()
def one(
group_label: str = typer.Option(
None, "--group-label", "-g",
help="[b]The label[/] is a uniq identification of a [b]group[/].",
show_default=False
),
):
"""
[red b]Remove[/] an [yellow]existing group[/].
"""
if group_label is None:
group_label = GroupPrompt.get_label_fzf()
if Group.is_exist(group_label):
group = Group.get_group(group_label)
group.delete_instance()
@group_remove.command()
def all():
"""
[red b]Remove[/] [yellow] all existing groups[/].
"""
for label in all_group_labels().iterator():
one(label.label)

@ -1,63 +0,0 @@
################################################################################
# Copyright (C) 2023 Kostiantyn Klochko <kostya_klochko@ukr.net> #
# #
# This file is part of tui-rsync. #
# #
# tui-rsync is free software: you can redistribute it and/or modify it under #
# uthe terms of the GNU General Public License as published by the Free #
# Software Foundation, either version 3 of the License, or (at your option) #
# any later version. #
# #
# tui-rsync is distributed in the hope that it will be useful, but WITHOUT ANY #
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS #
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more #
# details. #
# #
# You should have received a copy of the GNU General Public License along with #
# tui-rsync. If not, see <https://www.gnu.org/licenses/>. #
################################################################################
from rich.console import Console
from rich.prompt import Prompt
from typing import List, Optional
import typer
from tui_rsync.models.models import Group, GroupSource
from tui_rsync.cli.groups.group_prompt import GroupPrompt
from tui_rsync.models.models import all_group_labels
console = Console()
group_show = typer.Typer()
@group_show.command()
def one(
group_label: str = typer.Option(
None, "--group-label", "-l",
help="[b]The label[/] is a uniq identification of a [b]group[/].",
show_default=False
),
):
"""
[green b]Show[/] an [yellow]existing group[/].
"""
if group_label is None:
console.print("What is the [yellow b]label of the group[/]? ")
group_label = GroupPrompt.get_label_fzf()
if not Group.is_exist(group_label):
console.print("[red b][ERROR][/] Source does not exists!!!")
return
group = Group.get_group(group_label)
console.print(group.show_format())
@group_show.command()
def all():
"""
[green b]Show[/] [yellow]all existing groups[/].
"""
for label in all_group_labels().iterator():
group = Group.get_group(label.label)
console.print(group.show_format())

@ -1,89 +0,0 @@
################################################################################
# Copyright (C) 2023 Kostiantyn Klochko <kostya_klochko@ukr.net> #
# #
# This file is part of tui-rsync. #
# #
# tui-rsync is free software: you can redistribute it and/or modify it under #
# uthe terms of the GNU General Public License as published by the Free #
# Software Foundation, either version 3 of the License, or (at your option) #
# any later version. #
# #
# tui-rsync is distributed in the hope that it will be useful, but WITHOUT ANY #
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS #
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more #
# details. #
# #
# You should have received a copy of the GNU General Public License along with #
# tui-rsync. If not, see <https://www.gnu.org/licenses/>. #
################################################################################
from rich.console import Console
from rich.prompt import Prompt
from typing import List, Optional
import typer
from tui_rsync.models.models import Group, GroupSource
from tui_rsync.cli.label_prompt import LabelPrompt
from tui_rsync.cli.groups.group_prompt import GroupPrompt
from typer.main import get_group
console = Console()
group_update = typer.Typer()
@group_update.command()
def label(
group_label: str = typer.Option(
None, "--group-label", "-l",
help="[b]The label[/] is a uniq identification of a [b]group[/].",
show_default=False
),
new_group_label: str = typer.Option(
None, "--new-group-label", "-nl",
help="[b]The new label[/] will replace the [b]old group label[/].",
show_default=False
),
):
"""
[green b]Update[/] an [yellow]existing group label[/].
"""
if group_label is None:
console.print("What is the [yellow b]old label of group[/]? ")
group_label = GroupPrompt.get_label_fzf()
if new_group_label is None:
question = "What is the [yellow b]new label of the group[/]? "
new_group_label = GroupPrompt.ask_uuid(question)
if Group.is_exist(group_label):
group = Group.get_group(group_label)
group.update_label(new_group_label)
@group_update.command()
def labels(
group_label: str = typer.Option(
None, "--group-label", "-g",
help="[b]The label[/] is a uniq identification of a [b]group[/].",
show_default=False
),
new_labels: str = typer.Option(
None, "--new-labels", "-nl",
help="[b]The new label[/] will replace the [b]old source label[/].",
show_default=False
),
):
"""
[green b]Update[/] [yellow]the group[/] with a [bold]the label[/].
[b]The chosen sources[/] will be updated for [b]the group[/].
"""
if group_label is None:
group_label = GroupPrompt.get_label_fzf()
if new_labels is None:
new_labels = LabelPrompt.get_labels()
group = Group.get_group(group_label)
group.remove_sources()
GroupSource.create_group_sources(group, new_labels)
group.save()

@ -1,56 +0,0 @@
################################################################################
# Copyright (C) 2023 Kostiantyn Klochko <kostya_klochko@ukr.net> #
# #
# This file is part of tui-rsync. #
# #
# tui-rsync is free software: you can redistribute it and/or modify it under #
# uthe terms of the GNU General Public License as published by the Free #
# Software Foundation, either version 3 of the License, or (at your option) #
# any later version. #
# #
# tui-rsync is distributed in the hope that it will be useful, but WITHOUT ANY #
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS #
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more #
# details. #
# #
# You should have received a copy of the GNU General Public License along with #
# tui-rsync. If not, see <https://www.gnu.org/licenses/>. #
################################################################################
from rich.console import Console
from rich.prompt import Confirm, Prompt
from typing import List, Optional
import typer
from tui_rsync.cli.label_prompt import LabelPrompt
from tui_rsync.cli.rsync import Rsync
from tui_rsync.models.models import Group, count_all_labels_except
from tui_rsync.cli.groups.group_show import group_show
from tui_rsync.cli.groups.group_update import group_update
from tui_rsync.cli.groups.group_remove import group_remove
console = Console()
groups = typer.Typer()
groups.add_typer(group_show, name="show", help="Show groups")
groups.add_typer(group_update, name="update", help="Update groups")
groups.add_typer(group_remove, name="remove", help="Remove groups")
@groups.command()
def add(
group_label: str = typer.Option(
None, "--group-label", "-g",
help="[b]The label[/] is a uniq identification of a [b]group[/].",
show_default=False
),
):
"""
[green b]Create[/] a [yellow]new group[/] with a [bold]uniq[/] label.
[b]The chosen sources[/] will be united into [b]the group[/].
"""
if group_label is None:
question = "Would you like to change [yellow b]the group label[/]?"
group_label = LabelPrompt.ask_uuid(question)
labels = LabelPrompt.get_labels()
Group.create_save(group_label, labels)

@ -1,70 +0,0 @@
################################################################################
# Copyright (C) 2023 Kostiantyn Klochko <kostya_klochko@ukr.net> #
# #
# This file is part of tui-rsync. #
# #
# tui-rsync is free software: you can redistribute it and/or modify it under #
# uthe terms of the GNU General Public License as published by the Free #
# Software Foundation, either version 3 of the License, or (at your option) #
# any later version. #
# #
# tui-rsync is distributed in the hope that it will be useful, but WITHOUT ANY #
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS #
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more #
# details. #
# #
# You should have received a copy of the GNU General Public License along with #
# tui-rsync. If not, see <https://www.gnu.org/licenses/>. #
################################################################################
from rich.console import Console
from rich.prompt import Confirm, Prompt, IntPrompt
from pyfzf import FzfPrompt
import uuid
from tui_rsync.models.models import all_labels, all_labels_except
from tui_rsync.models.models import count_all_labels_except
console = Console()
class LabelPrompt:
@staticmethod
def ask_uuid(
question: str = "Would you like to change [yellow b]the label[/]?"
) -> str:
"""
Return the label or the default uuid value.
"""
uid = uuid.uuid4().hex
label = Prompt.ask(question, default=uid)
return label
@staticmethod
def get_label_fzf() -> str:
fzf = FzfPrompt()
return fzf.prompt(all_labels().iterator())[0]
@staticmethod
def get_label_except_fzf(labels = None) -> str:
fzf = FzfPrompt()
return fzf.prompt(all_labels_except(labels).iterator())[0]
@staticmethod
def get_labels(labels = None) -> list:
confirm_question = "Would you like to add a source/sources to the group?"
is_fzf = Confirm.ask(confirm_question, default=True)
if not is_fzf:
return []
count_question = "How much would you like to add sources to the group?"
count = IntPrompt.ask(count_question, default=1)
count_max = count_all_labels_except(labels)
count = count_max if count > count_max else count
labels = []
for i in range(count):
option = LabelPrompt.get_label_except_fzf(labels)
labels.append(option)
return labels

@ -1,40 +0,0 @@
################################################################################
# Copyright (C) 2023 Kostiantyn Klochko <kostya_klochko@ukr.net> #
# #
# This file is part of tui-rsync. #
# #
# tui-rsync is free software: you can redistribute it and/or modify it #
# under the terms of the GNU General Public License as published by the Free #
# Software Foundation, either version 3 of the License, or (at your option) #
# any later version. #
# #
# tui-rsync is distributed in the hope that it will be useful, but #
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY #
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for #
# more details. #
# #
# You should have received a copy of the GNU General Public License along with #
# tui-rsync. If not, see <https://www.gnu.org/licenses/>. #
################################################################################
from sys import stderr
from rich.console import Console
from rich.prompt import Prompt
from pyfzf import FzfPrompt
import os
from tui_rsync.models.models import Destination, Path
console = Console()
err_console = Console(stderr=True)
class PathPrompt:
@staticmethod
def get_backup_fzf(label:str) -> str:
dests_count = len(Destination.get_all(label))
if dests_count == 0:
err_console.print("[red b]No backups!!![/]")
return ""
if dests_count == 1:
return Destination.get_all(label).get()
fzf = FzfPrompt()
return fzf.prompt(Destination.get_all(label).iterator())[0]

@ -1 +0,0 @@
from tui_rsync.cli.source.source import source

@ -1,56 +0,0 @@
################################################################################
# Copyright (C) 2023 Kostiantyn Klochko <kostya_klochko@ukr.net> #
# #
# This file is part of tui-rsync. #
# #
# tui-rsync is free software: you can redistribute it and/or modify it under #
# uthe terms of the GNU General Public License as published by the Free #
# Software Foundation, either version 3 of the License, or (at your option) #
# any later version. #
# #
# tui-rsync is distributed in the hope that it will be useful, but WITHOUT ANY #
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS #
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more #
# details. #
# #
# You should have received a copy of the GNU General Public License along with #
# tui-rsync. If not, see <https://www.gnu.org/licenses/>. #
################################################################################
from rich.console import Console
from rich.prompt import Prompt
from typing import List, Optional
import typer
from tui_rsync.models.models import Source, Destination, SyncCommand, Path
from tui_rsync.cli.label_prompt import LabelPrompt
from tui_rsync.models.models import all_labels
console = Console()
source_remove = typer.Typer()
@source_remove.command()
def one(
label: str = typer.Option(
None, "--label", "-l",
help="[b]The label[/] is a uniq identification of a [b]source[/].",
show_default=False
)
):
"""
[red b]Remove[/] an [yellow]existing source[/].
"""
if label is None:
label = LabelPrompt.get_label_fzf()
if Source.is_exist(label):
src = Source.get_source(label)
src.delete_instance()
@source_remove.command()
def all():
"""
[red b]Remove[/] [yellow] all existing sources[/].
"""
for label in all_labels().iterator():
one(label.label)

@ -1,64 +0,0 @@
################################################################################
# Copyright (C) 2023 Kostiantyn Klochko <kostya_klochko@ukr.net> #
# #
# This file is part of tui-rsync. #
# #
# tui-rsync is free software: you can redistribute it and/or modify it under #
# uthe terms of the GNU General Public License as published by the Free #
# Software Foundation, either version 3 of the License, or (at your option) #
# any later version. #
# #
# tui-rsync is distributed in the hope that it will be useful, but WITHOUT ANY #
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS #
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more #
# details. #
# #
# You should have received a copy of the GNU General Public License along with #
# tui-rsync. If not, see <https://www.gnu.org/licenses/>. #
################################################################################
from rich.console import Console
from rich.prompt import Prompt
from typing import List, Optional
import typer
from tui_rsync.models.models import Source, Destination, SyncCommand, Path
from tui_rsync.cli.label_prompt import LabelPrompt
from tui_rsync.models.models import all_labels
console = Console()
source_show = typer.Typer()
@source_show.command()
def one(
label: str = typer.Option(
None, "--label", "-l",
help="[b]The label[/] is a uniq identification of a [b]source[/].",
show_default=False
),
):
"""
[green b]Show[/] an [yellow]existing source[/].
"""
if label is None:
console.print("What is the [yellow b]label of source[/]? ")
label = LabelPrompt.get_label_fzf()
if not Source.is_exist(label):
console.print("[red b][ERROR][/] Source does not exists!!!")
return
source = Source.get_source(label)
console.print(source.show_format())
@source_show.command()
def all():
"""
[green b]Show[/] [yellow]all existing sources[/].
"""
for label in all_labels().iterator():
source = Source.get_source(label.label)
console.print(source.show_format())

@ -1,111 +0,0 @@
################################################################################
# Copyright (C) 2023 Kostiantyn Klochko <kostya_klochko@ukr.net> #
# #
# This file is part of tui-rsync. #
# #
# tui-rsync is free software: you can redistribute it and/or modify it under #
# uthe terms of the GNU General Public License as published by the Free #
# Software Foundation, either version 3 of the License, or (at your option) #
# any later version. #
# #
# tui-rsync is distributed in the hope that it will be useful, but WITHOUT ANY #
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS #
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more #
# details. #
# #
# You should have received a copy of the GNU General Public License along with #
# tui-rsync. If not, see <https://www.gnu.org/licenses/>. #
################################################################################
from rich.console import Console
from rich.prompt import Prompt
from typing import List, Optional
import typer
from tui_rsync.models.models import Source, Destination, SyncCommand, Path
from tui_rsync.cli.label_prompt import LabelPrompt
console = Console()
source_update = typer.Typer()
@source_update.command()
def label(
label: str = typer.Option(
None, "--label", "-l",
help="[b]The label[/] is a uniq identification of a [b]source[/].",
show_default=False
),
new_label: str = typer.Option(
None, "--new-label", "-nl",
help="[b]The new label[/] will replace the [b]old source label[/].",
show_default=False
),
):
"""
[green b]Update[/] an [yellow]existing source label[/].
"""
if label is None:
console.print("What is the [yellow b]old label of source[/]? ")
label = LabelPrompt.get_label_fzf()
if new_label is None:
question = "What is the [yellow b]new label of the source[/]? "
new_label = LabelPrompt.ask_uuid(question)
if Source.is_exist(label):
src = Source.get_source(label)
src.update_label(new_label)
@source_update.command()
def source(
label: str = typer.Option(
None, "--label", "-l",
help="[b]The label[/] is a uniq identification of a [b]source[/].",
show_default=False
),
new_source_path: str = typer.Option(
None, "--new-label", "-nl",
help="[b]The new source[/] will replace the [b]old source[/].",
show_default=False
),
):
"""
[green b]Update[/] a source path of an [yellow]existing source[/].
"""
if label is None:
console.print("What is the [yellow b]label of source[/]? ")
label = LabelPrompt.get_label_fzf()
if new_source_path is None:
question = "What is the [yellow b]new source path of the source[/]? "
new_source_path = console.input(question)
if Source.is_exist(label):
src = Source.get_source(label)
src.update_source_path(new_source_path)
@source_update.command()
def args(
label: str = typer.Option(
None, "--label", "-l",
help="[b]The label[/] is a uniq identification of a [b]source[/].",
show_default=False
),
args: str = typer.Option(
None, "--args", "-a",
help="[b yellow]rsync[/] [b]arguments[/].",
show_default=False
)
):
"""
[green b]Update[/] an [yellow]existing source args[/].
"""
if args is None:
args = console.input("What is the [yellow b]rsync args of source[/]? ")
if Source.is_exist(label):
src = Source.get_source(label)
src.update_args(args)

@ -1,115 +0,0 @@
################################################################################
# Copyright (C) 2023 Kostiantyn Klochko <kostya_klochko@ukr.net> #
# #
# This file is part of tui-rsync. #
# #
# tui-rsync is free software: you can redistribute it and/or modify it under #
# uthe terms of the GNU General Public License as published by the Free #
# Software Foundation, either version 3 of the License, or (at your option) #
# any later version. #
# #
# tui-rsync is distributed in the hope that it will be useful, but WITHOUT ANY #
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS #
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more #
# details. #
# #
# You should have received a copy of the GNU General Public License along with #
# tui-rsync. If not, see <https://www.gnu.org/licenses/>. #
################################################################################
from rich.console import Console
from rich.prompt import Prompt
from typing import List, Optional
import typer
from tui_rsync.models.models import Source, Group, Destination, SyncCommand
from tui_rsync.models.models import Path
from tui_rsync.models.models import all_labels
from tui_rsync.cli.label_prompt import LabelPrompt
from tui_rsync.cli.groups.group_prompt import GroupPrompt
from tui_rsync.cli.path_prompt import PathPrompt
from tui_rsync.cli.rsync import Rsync
console = Console()
sync = typer.Typer()
skip_error = "[yellow b]Skippped[/] because the [red b]path was unavailable[/]."
@sync.command()
def one(
label: str = typer.Option(
None, "--label", "-l",
help="[b]The label[/] is a uniq identification of a [b]source[/].",
show_default=False
),
dry: bool = typer.Option(
False, "-d", "--dry-run",
help="The command will [b]show[/] information about what will be changed.",
)
):
"""
[green b]Sync[/] a [yellow]source[/] with the [yellow b]label[/] and its backups.
[yellow b]Skips[/] if not available.
"""
if label is None:
label = LabelPrompt.get_label_fzf()
src = Source.get_source(label)
rsync = Rsync(str(src.args))
for dest in src.destinations:
if not dest.path.is_exists():
console.print(skip_error)
continue
if dry:
response = rsync.dry_one(str(src.source), str(dest))
out, err = response
console.print(f"{bstr_nonan(out)} {bstr_nonan(err)}")
else:
rsync.run_one(str(src.source), str(dest))
@sync.command()
def group(
group_label: str = typer.Option(
None, "--group-label", "-g",
help="[b]The label[/] is a uniq identification of a [b]group[/].",
show_default=False
),
):
"""
[green b]Sync[/] a [yellow]group[/] with the [yellow b] label[/].
"""
if group_label is None:
group_label = GroupPrompt.get_label_fzf()
group = Group.get_group(group_label)
for src in group.get_sources():
one(src.label)
@sync.command()
def all():
"""
[green b]Sync[/] [yellow]all sources[/] with theirs backups.
"""
for label in all_labels().iterator():
one(label.label)
@sync.command()
def recover(
label: str = typer.Option(
None, "--label", "-l",
help="[b]The label[/] is a uniq identification of a [b]source[/].",
show_default=False
),
):
"""
[green b]Sync[/] the [yellow]chosen backup[/] with its source.
"""
if label is None:
label = LabelPrompt.get_label_fzf()
src = Source.get(Source.label == label)
dest = PathPrompt.get_backup_fzf(label)
rsync = Rsync(str(src.args))
rsync.run_one(str(dest), str(src.source))
def bstr_nonan(obj):
return "" if obj is None else obj.decode()

@ -1,2 +0,0 @@
from tui_rsync.config.app import App

@ -0,0 +1,5 @@
from .backup_command_port import BackupCommandPort
from .backup_sync_command import BackupSyncCommand
from .backup_sync_command_dry_run import BackupSyncCommandDryRun
__all__ = ['BackupCommandPort', 'BackupSyncCommand', 'BackupSyncCommandDryRun']

@ -0,0 +1,7 @@
from abc import ABC, abstractmethod
class BackupCommandPort(ABC):
@abstractmethod
def run(self):
pass

@ -0,0 +1,14 @@
import shlex
from subprocess import Popen, PIPE
from . import BackupCommandPort
class BackupSyncCommand(BackupCommandPort):
def __init__(self, source: str, destination: str, args: str):
self._args = ["rsync"] + shlex.split(args) + [source, destination]
def run(self):
output = Popen(self._args, stdout=PIPE)
response = output.communicate()

@ -0,0 +1,14 @@
import shlex
from subprocess import Popen, PIPE
from . import BackupCommandPort
class BackupSyncCommandDryRun(BackupCommandPort):
def __init__(self, source: str, destination: str, args: str):
self._args = ["rsync"] + shlex.split(args) + ['--dry-run', source, destination]
def run(self):
output = Popen(self._args, stdout=PIPE)
return output.communicate()

@ -0,0 +1,5 @@
from .remove_all_backup_plans_command import RemoveAllBackupPlansCommand
from .remove_backup_plan_destinations_command import RemoveBackupPlanDestinationsCommand
from .remove_backup_plan_command import RemoveBackupPlanCommand
__all__ = ['RemoveAllBackupPlansCommand', 'RemoveBackupPlanCommand', 'RemoveBackupPlanDestinationsCommand']

@ -0,0 +1,12 @@
from tui_rsync.core.ports.orm import DatabaseManagerPort
from tui_rsync.infrastructure.orm.models import BackupPlanModel, DestinationModel
class RemoveAllBackupPlansCommand:
def __init__(self, database_manager: DatabaseManagerPort):
self.databaseManager = database_manager
def execute(self) -> bool:
rows = BackupPlanModel.delete().execute()
rows = DestinationModel.delete().execute() + rows
return rows != 0

@ -0,0 +1,18 @@
from tui_rsync.core.ports.orm import DatabaseManagerPort
from tui_rsync.core.shared_kernel.components.common import UUID
from tui_rsync.core.shared_kernel.ports.Exceptions import CommandException
from tui_rsync.infrastructure.orm.models import DestinationModel, BackupPlanModel
class RemoveBackupPlanCommand:
def __init__(self, database_manager: DatabaseManagerPort):
self.databaseManager = database_manager
def execute(self, uuid: UUID) -> bool:
deleted = BackupPlanModel.delete().where(BackupPlanModel.id == uuid.id).execute()
deleted = DestinationModel.delete().where(DestinationModel.source == uuid.id).execute() | deleted
if deleted == 0:
raise CommandException("Failed to delete the backup plan, because it doesn't exist.")
return True

@ -0,0 +1,12 @@
from tui_rsync.core.ports.orm import DatabaseManagerPort
from tui_rsync.core.shared_kernel.components.common import UUID
from tui_rsync.infrastructure.orm.models import DestinationModel
class RemoveBackupPlanDestinationsCommand:
def __init__(self, database_manager: DatabaseManagerPort):
self.databaseManager = database_manager
def execute(self, backup_plan_uuid: UUID) -> bool:
rows = DestinationModel.delete().where(DestinationModel.source == backup_plan_uuid.id).execute()
return rows != 0

@ -0,0 +1,5 @@
from .get_all_backup_plans_query import GetAllBackupPlansQuery
from .get_backup_plan_by_id_query import GetBackupPlanByIdQuery
from .get_backup_plan_count_query import GetBackupPlanCountQuery
__all__ = ['GetAllBackupPlansQuery', 'GetBackupPlanByIdQuery', 'GetBackupPlanCountQuery']

@ -0,0 +1,12 @@
from tui_rsync.core.ports.orm import DatabaseManagerPort
from tui_rsync.infrastructure.orm.dto.dtos import BackupPlanDTO
from tui_rsync.infrastructure.orm.models import BackupPlanModel
class GetAllBackupBackupPlansQuery:
def __init__(self, database_manager: DatabaseManagerPort):
self.databaseManager = database_manager
def execute(self):
return (BackupPlanDTO.to_domain(model) for model in BackupPlanModel.select().iterator())

@ -0,0 +1,12 @@
from tui_rsync.core.ports.orm import DatabaseManagerPort
from tui_rsync.infrastructure.orm.dto.dtos import BackupPlanDTO
from tui_rsync.infrastructure.orm.models import BackupPlanModel
class GetAllBackupPlansQuery:
def __init__(self, database_manager: DatabaseManagerPort):
self.databaseManager = database_manager
def execute(self):
return (BackupPlanDTO.to_domain(model) for model in BackupPlanModel.select().iterator())

@ -0,0 +1,20 @@
from tui_rsync.core.components.backup_plan.domain import BackupPlan
from tui_rsync.core.ports.orm import DatabaseManagerPort
from tui_rsync.core.shared_kernel.components.common import UUID
from tui_rsync.core.shared_kernel.ports.Exceptions.query_exception import QueryException
from tui_rsync.infrastructure.orm.dto.dtos import BackupPlanDTO
from tui_rsync.infrastructure.orm.models import BackupPlanModel
class GetBackupPlanByIdQuery:
def __init__(self, database_manager: DatabaseManagerPort):
self.databaseManager = database_manager
def execute(self, uuid: UUID) -> BackupPlan:
model = BackupPlanModel.get_or_none(BackupPlanModel.id == uuid.id)
if model is None:
raise QueryException("The backup plan was not found.")
return BackupPlanDTO.to_domain(model)

@ -0,0 +1,11 @@
from tui_rsync.core.ports.orm import DatabaseManagerPort
from tui_rsync.infrastructure.orm.models import BackupPlanModel
class GetBackupPlanCountQuery:
def __init__(self, database_manager: DatabaseManagerPort):
self.databaseManager = database_manager
def execute(self):
return BackupPlanModel.select().count()

@ -0,0 +1,4 @@
from .backup_plan_repository_port import BackupPlanRepositoryPort
from .backup_plan_repository import BackupPlanRepository
__all__ = ['BackupPlanRepositoryPort', 'BackupPlanRepository']

@ -0,0 +1,49 @@
from typing import Optional
from tui_rsync.core.ports.orm import DatabaseManagerPort
from tui_rsync.infrastructure.orm.dto.dtos import BackupPlanDTO
from .backup_plan_repository_port import BackupPlanRepositoryPort
from tui_rsync.core.components.backup_plan.domain import BackupPlan
from tui_rsync.core.shared_kernel.components.common import UUID
from tui_rsync.infrastructure.orm.models import BackupPlanModel
from ..commands import RemoveBackupPlanDestinationsCommand, RemoveBackupPlanCommand
from ..queries import GetBackupPlanByIdQuery
class BackupPlanRepository(BackupPlanRepositoryPort):
def __init__(self, database_manager: DatabaseManagerPort):
self.databaseManager = database_manager
def add(self, backup_plan: BackupPlan):
model = BackupPlanDTO.to_model(backup_plan)
model.save(force_insert=True)
for destination in model.destinations:
destination.save(force_insert=True)
def get_by_id(self, uuid: UUID) -> BackupPlan:
query = GetBackupPlanByIdQuery(self.databaseManager)
return query.execute(uuid)
def update(self, backup_plan: BackupPlan):
updated_model = BackupPlanDTO.to_model(backup_plan)
query = GetBackupPlanByIdQuery(self.databaseManager)
old_plan = query.execute(backup_plan.id)
model = BackupPlanDTO.to_model(old_plan)
remove_backup_plan_destinations_command = RemoveBackupPlanDestinationsCommand(self.databaseManager)
model.label = updated_model.label
model.source = updated_model.source
remove_backup_plan_destinations_command.execute(backup_plan.id)
model.save()
for destination in updated_model.destinations:
destination.save(force_insert=True)
def delete(self, uuid: UUID) -> bool:
command = RemoveBackupPlanCommand(self.databaseManager)
return command.execute(uuid)

@ -0,0 +1,23 @@
from abc import ABC, abstractmethod
from typing import Optional
from tui_rsync.core.components.backup_plan.domain import BackupPlan
from tui_rsync.core.shared_kernel.components.common import UUID
class BackupPlanRepositoryPort(ABC):
@abstractmethod
def add(self, backup_plan: BackupPlan):
pass
@abstractmethod
def get_by_id(self, uuid: UUID) -> Optional[BackupPlan]:
pass
@abstractmethod
def update(self, backup_plan: BackupPlan):
pass
@abstractmethod
def delete(self, uuid: UUID) -> bool:
pass

@ -0,0 +1,11 @@
from .backup_plan_service import BackupPlanService
from .get_all_backup_plans_service import GetAllBackupPlansService
from .get_backup_plan_count_service import GetBackupPlanCountService
from .remove_all_backup_plans_service import RemoveAllBackupPlansService
__all__ = [
'BackupPlanService',
'GetAllBackupPlansService',
'GetBackupPlanCountService',
'RemoveAllBackupPlansService',
]

@ -0,0 +1,23 @@
from typing import Optional
from tui_rsync.core.components.backup_plan.application.repository import BackupPlanRepositoryPort
from tui_rsync.core.components.backup_plan.domain import BackupPlan
from tui_rsync.core.shared_kernel.components.common import UUID
class BackupPlanService:
def __init__(self, backup_plan_repository: BackupPlanRepositoryPort):
self.backup_plan_repository = backup_plan_repository
def add(self, backup_plan: BackupPlan):
self.backup_plan_repository.add(backup_plan)
def get_by_id(self, uuid: UUID) -> Optional[BackupPlan]:
return self.backup_plan_repository.get_by_id(uuid)
def update(self, backup_plan: BackupPlan):
return self.backup_plan_repository.update(backup_plan)
def delete(self, uuid: UUID) -> bool:
return self.backup_plan_repository.delete(uuid)

@ -0,0 +1,13 @@
from typing import List
from tui_rsync.core.components.backup_plan.application.queries import GetAllBackupPlansQuery
from tui_rsync.core.components.backup_plan.domain import BackupPlan
class GetAllBackupPlansService:
def __init__(self, get_all_backup_plan_query: GetAllBackupPlansQuery):
self.get_all_backup_plan_query = get_all_backup_plan_query
def get_all(self) -> List[BackupPlan]:
return self.get_all_backup_plan_query.execute()

@ -0,0 +1,16 @@
from typing import List
from tui_rsync.core.components.backup_plan.application.queries import GetBackupPlanCountQuery
from tui_rsync.core.components.backup_plan.domain import BackupPlan
class GetBackupPlanCountService:
def __init__(self, get_backup_plan_count_query: GetBackupPlanCountQuery):
self.get_backup_plan_count_query = get_backup_plan_count_query
def count(self) -> int:
return self.get_backup_plan_count_query.execute()
def is_empty(self) -> bool:
return self.count() == 0

@ -0,0 +1,9 @@
from tui_rsync.core.components.backup_plan.application.commands import RemoveAllBackupPlansCommand
class RemoveAllBackupPlansService:
def __init__(self, remove_all_backup_plan_command: RemoveAllBackupPlansCommand):
self.remove_all_backup_plan_command = remove_all_backup_plan_command
def remove_all(self) -> bool:
return self.remove_all_backup_plan_command.execute()

@ -0,0 +1,4 @@
from .entities import BackupPlan
from .value_objects import BackupPlanId, Path, Source, Destination
__all__ = ['BackupPlan', 'BackupPlanId', 'Path', 'Source', 'Destination']

@ -0,0 +1,3 @@
from .backup_plan import BackupPlan
__all__ = ['BackupPlan']

@ -0,0 +1,14 @@
from dataclasses import dataclass, field
from typing import List
from ..value_objects import BackupPlanId, Source, Destination
from tui_rsync.core.shared_kernel.components.common.domain import Label
@dataclass
class BackupPlan:
source: Source
destinations: List[Destination]
id: BackupPlanId = field(default_factory=lambda: BackupPlanId.generate())
label: Label = field(default='')

@ -0,0 +1,6 @@
from .backup_plan_id import BackupPlanId
from .path import Path
from .source import Source
from .destionation import Destination
__all__ = ['BackupPlanId', 'Path', 'Source', 'Destination']

@ -0,0 +1,5 @@
from tui_rsync.core.shared_kernel.components.common.domain import UUID
class BackupPlanId(UUID):
pass

@ -0,0 +1,5 @@
from .path import Path
class Destination(Path):
pass

@ -0,0 +1,6 @@
from dataclasses import dataclass
@dataclass
class Path:
path: str

@ -0,0 +1,5 @@
from .path import Path
class Source(Path):
pass

@ -0,0 +1,3 @@
from .user_data_paths_port import UserDataPathsPort
__all__ = ['UserDataPathsPort']

@ -0,0 +1,15 @@
from abc import ABC, abstractmethod
class UserDataPathsPort(ABC):
@abstractmethod
def safe_create_user_data_dir(self):
pass
@abstractmethod
def get_user_db_path(self):
pass
@abstractmethod
def get_user_data_dir(self):
pass

@ -0,0 +1,3 @@
from .database_manager_port import DatabaseManagerPort
__all__ = ['DatabaseManagerPort']

@ -0,0 +1,11 @@
from abc import ABC, abstractmethod
class DatabaseManagerPort(ABC):
@abstractmethod
def get_connection(self):
pass
@abstractmethod
def create_tables(self):
pass

@ -0,0 +1,3 @@
from .backup_sync_service import BackupSyncService
__all__ = ['BackupSyncService']

@ -0,0 +1,22 @@
from tui_rsync.core.components.backup.application.backup_commands import BackupSyncCommand, BackupSyncCommandDryRun
from tui_rsync.core.components.backup_plan.application.repository import BackupPlanRepositoryPort
from tui_rsync.core.shared_kernel.components.common import UUID
from tui_rsync.user_interface.cli.shared_kernel.components.prompts.applications.prompts import ChoosePromptPort
class BackupRestoreService:
def __init__(self, backup_plan_repository: BackupPlanRepositoryPort):
self.backup_plan_repository = backup_plan_repository
def sync_by_plan_id(self, uuid: UUID, choose_prompt: ChoosePromptPort, args: str = '', dry_run: bool = False):
backup_plan = self.backup_plan_repository.get_by_id(uuid)
destinations = (destination.path for destination in backup_plan.destinations)
destination = choose_prompt.choose(destinations)
if dry_run:
backup_command = BackupSyncCommandDryRun(destination, backup_plan.source.path, args)
backup_command.run()
else:
backup_command = BackupSyncCommand(destination, backup_plan.source.path, args)
backup_command.run()

@ -0,0 +1,20 @@
from tui_rsync.core.components.backup.application.backup_commands import BackupSyncCommand, BackupSyncCommandDryRun
from tui_rsync.core.components.backup_plan.application.repository import BackupPlanRepositoryPort
from tui_rsync.core.shared_kernel.components.common import UUID
class BackupSyncService:
def __init__(self, backup_plan_repository: BackupPlanRepositoryPort):
self.backup_plan_repository = backup_plan_repository
def sync_by_plan_id(self, uuid: UUID, args: str = '', dry_run: bool = False):
backup_plan = self.backup_plan_repository.get_by_id(uuid)
if dry_run:
for destination in backup_plan.destinations:
backup_command = BackupSyncCommandDryRun(backup_plan.source.path, destination.path, args)
backup_command.run()
else:
for destination in backup_plan.destinations:
backup_command = BackupSyncCommand(backup_plan.source.path, destination.path, args)
backup_command.run()

@ -0,0 +1,3 @@
from .domain import UUID, Label
__all__ = ['UUID', 'Label']

@ -0,0 +1,3 @@
from .value_objects import UUID, Label
__all__ = ['UUID', 'Label']

@ -0,0 +1,4 @@
from .uuid import UUID
from .label import Label
__all__ = ['UUID', 'Label']

@ -0,0 +1,6 @@
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Label:
label: str = field(default='')

@ -0,0 +1,14 @@
from dataclasses import dataclass, field
import uuid
@dataclass(frozen=True)
class UUID:
id: str = field(default_factory=lambda: UUID.generate().id)
@classmethod
def generate(cls) -> 'UUID':
return cls(str(uuid.uuid4()))
def __str__(self):
return self.id

@ -0,0 +1,4 @@
from .app_exception import AppException
from .command_exception import CommandException
__all__ = ['AppException', 'CommandException']

@ -0,0 +1,6 @@
from . import AppException
class CommandException(AppException):
"""Command failed to change persistence data."""
pass

@ -0,0 +1,6 @@
from . import AppException
class QueryException(AppException):
"""Query failed."""
pass

@ -0,0 +1,6 @@
from .user_data_paths import UserDataPaths
from .current_configuration import CurrentConfiguration
from .configuration import Configuration
from .testing_configuration import TestingConfiguration
__all__ = ['UserDataPaths', 'CurrentConfiguration', 'Configuration', 'TestingConfiguration']

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save