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
3 months ago
KKlochko 0b723c8770 Update the requirements files and the changelog.
continuous-integration/drone Build is passing Details
3 months ago
KKlochko 62ff10dec4 Add tests to check responses for showing and deleting a backup plan.
3 months ago
KKlochko 7ef5b9975c Add the helpers and tests for updating the backup plan.
3 months ago
KKlochko e0f8ec9db5 Update the tests names.
3 months ago
KKlochko 1ec7c6464a Add the response for create command and the test.
3 months ago
KKlochko a16e43462a Add tests for the replace command.
3 months ago
KKlochko 2438dead07 Update the replace command to show responses.
3 months ago
KKlochko cabcde00c7 Add tests for the remove all backup plans service.
3 months ago
KKlochko 2874dc76ad Update the remove all backup plans command.
3 months ago
KKlochko 76e555aefb Refactor the test steps to show stdout.
3 months ago
KKlochko c1268abc1d Add tests for CLI errors.
3 months ago
KKlochko 9729f442c2 Update the show all plans command to show if there're no plans.
3 months ago
KKlochko b999975695 Add the tests for the read query.
3 months ago
KKlochko 476a0ef8cf Add the query exception and refactor get_by_id.
3 months ago
KKlochko d652469748 Update to refactor the delete action.
3 months ago
KKlochko cae4ce50c5 Update the repository to use exception for the delete action.
3 months ago
KKlochko 769edabbd3 Add the exceptions and helpers.
3 months ago
KKlochko a4a0f32f52 Add the service's test for deleting.
3 months ago
KKlochko 5e606b0e83 Update to step to test the result of CLI execution.
3 months ago
KKlochko add3c3d59e Add test for the `plan remove` command.
3 months ago
KKlochko 2d1870535f Add the fixture and steps for seeds.
3 months ago
KKlochko 7094a0d866 Add the support helpers to use fake data for tests.
3 months ago
KKlochko 18eb9b4d91 Add the E2E tests for the CLI interface.
3 months ago
KKlochko 6b247fbe4e Add the TestingConfiguration to simplify the testing configuration.
3 months ago
KKlochko ecb495a22a Add the replace command to update all data about a backup plan.
3 months ago
KKlochko f9b49c69bd Update the repository and service to implement the update action.
3 months ago
KKlochko bc22e8a8bd Add command to remove backup plan's destinations.
3 months ago
KKlochko b32e5d32b0 Add the restore command.
4 months ago
KKlochko 87682a056f Add the sync command.
4 months ago
KKlochko 359dcdfca6 Add the backup sync service.
4 months ago
KKlochko 192f930f8a Update the structure of the backup component.
4 months ago
KKlochko f708cdf3d4 Add the backup commands to sync the data.
4 months ago
KKlochko 70f60ff715 Update the configuration to use queries and commands.
4 months ago
KKlochko a0846b93a1 Add the injector and configurations.
4 months ago
KKlochko eb01a26713 Fix a typo.
4 months ago
KKlochko ca350eef80 Update the email information.
4 months ago
KKlochko f3915d93a9 Add the configuration to get user data.
4 months ago
KKlochko ea0cc3dab0 Add the remove commands.
4 months ago
KKlochko ea5d98490d Add the show commands for backup plans.
4 months ago
KKlochko b9564d75e7 Add the query to get all backup plans.
4 months ago
KKlochko 027fee47a6 Add the CLI command to add a backup plan.
4 months ago
KKlochko 4bfdcf066b Add the application service for backup plans.
4 months ago
KKlochko c8d2b84cfc Update the test to test models in a memory database.
4 months ago
KKlochko 0abefda95a Add the repository for the backup plan.
4 months ago
KKlochko 72f650fd64 Add the DTOs to convert the entities to theorm models.
4 months ago
KKlochko b10726796d Update to simplify the connection management.
4 months ago
KKlochko ccede52883 Update the models names and id types to uuids.
4 months ago
KKlochko 690cc7de9c Add the ORM.
4 months ago
KKlochko 2834ad8bee Update the README.
4 months ago
KKlochko f3c600bb8f Update the test to verify creating the backup plan.
4 months ago
KKlochko 594d9dea3f Update the Source to BackupPlan component and shared.
4 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
steps:
- name: test
image: python:3.10-alpine
commands:
- pip install -r requirements_dev.txt --cache-dir=/package_cache
- behave --stop
- name: publishing
image: python:3.10-alpine
environment:
@ -17,5 +23,5 @@ steps:
- $POETRY_HOME/bin/poetry install
- $POETRY_HOME/bin/poetry publish --build --username $PYPI_USERNAME --password $PYPI_PASSWORD
when:
branch:
- main
event: tag

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.
** 0.8.16 <2023-07-01 Sat>
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
======
Kostiantyn Klochko (c) 2023
Kostiantyn Klochko (c) 2023-2025
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]]
name = "click"
@ -27,6 +48,37 @@ files = [
{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]]
name = "markdown-it-py"
version = "2.2.0"
@ -64,6 +116,39 @@ files = [
{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]]
name = "peewee"
version = "3.15.4"
@ -118,6 +203,21 @@ files = [
[package.extras]
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]]
name = "rich"
version = "13.3.1"
@ -137,6 +237,18 @@ pygments = ">=2.14.0,<3.0.0"
[package.extras]
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]]
name = "typer"
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)"]
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]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "63d5db59d0d54f5818a8274de848b03549d5df693c2310b67743e7e6c9e1968e"
content-hash = "a120a93b02cdb7d460b634c268f377711e70a391395715d5635058193c108084"

@ -1,8 +1,8 @@
[tool.poetry]
name = "tui-rsync"
version = "0.8.16"
version = "1.0.0"
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"
license = "GPL-3.0-or-later"
packages = [{include = "tui_rsync"}]
@ -19,10 +19,15 @@ typer = "^0.7.0"
peewee = "^3.15.4"
pyfzf = "^0.3.1"
platformdirs = "^3.1.1"
injector = "^0.22.0"
[tool.poetry.scripts]
tui-rsync = "tui_rsync.main:main"
[tool.poetry.group.dev.dependencies]
behave = "^1.2.6"
faker = "^35.0.0"
[build-system]
requires = ["poetry-core"]
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