diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 12c02b8..9bef1ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "shinylive-deploy" -version = "2024.7.1" +version = "2024.7.8" authors = [ {name="darrida", email="darrida.py@gmail.com"} ] @@ -27,7 +27,7 @@ tests = [ ] [project.scripts] -shinylive_deploy = "shinylive_deploy.app:main" +shinylive_deploy = "shinylive_deploy.app:cli" [project.urls] Homepage = "https://github.com/darrida/py-shinylive-deploy-webserver" diff --git a/src/shinylive_deploy/data.py b/shinylive_deploy.toml similarity index 66% rename from src/shinylive_deploy/data.py rename to shinylive_deploy.toml index dac6e18..c785f5a 100644 --- a/src/shinylive_deploy/data.py +++ b/shinylive_deploy.toml @@ -1,6 +1,4 @@ -from pathlib import Path -toml_text = """ [general] app_name = "app1" @@ -24,10 +22,3 @@ port = 2222 directory = "shinyapps" base_url = "http://localhost:5000" -""" - - -def create_config_file(): - filepath = Path.cwd() / "shinylive_deploy.toml" - with open(filepath, "w") as f: - f.write(toml_text) diff --git a/src/shinylive_deploy/app.py b/src/shinylive_deploy/app.py index 1997baa..a56e796 100644 --- a/src/shinylive_deploy/app.py +++ b/src/shinylive_deploy/app.py @@ -1,63 +1,36 @@ -import sys -from getpass import getpass -from pathlib import Path - -from pydantic import SecretStr -from shinylive_deploy.models import LocalShinyDeploy, ServerShinyDeploy, toml - - -def main(): - config_file = Path.cwd() / "shinylive_deploy.toml" - if not config_file.exists(): - from shinylive_deploy.data import create_config_file - create_config_file() - return - - mode, rollback = _parse_arguments() - shinylive_ = _initialize_configuration(mode) - - if rollback is True: - shinylive_.rollback() - else: - shinylive_.deploy() - - -def _parse_arguments(test_argvs: list = None) -> tuple[str, bool]: - if test_argvs: - sys.argv = test_argvs - if len(sys.argv) < 2 or sys.argv[1] not in ("test", "beta", "prod", "local"): - raise ValueError("\nERROR: One of the following arguments is required -> [ local | test | beta | prod ]\n") - deploy_mode = sys.argv[1] - try: - rollback = sys.argv[2] - if rollback not in ("-r", "--rollback"): - raise ValueError("2nd optional argument must be `-r` or `--rollback`") - rollback = True - except IndexError: - rollback = False - return deploy_mode, rollback - - -def _initialize_configuration(deploy_mode: str) -> LocalShinyDeploy | ServerShinyDeploy: - if deploy_mode in ("test", "beta", "prod"): - config = toml["deploy"]["server"] - return ServerShinyDeploy( - mode=deploy_mode, - base_url=config["base_url"], - dir_deployment=config["directory"], - host=config["host"], - user=config["user"], - port=config.get("port", 22), - password=SecretStr(value=getpass(f"SSH password for [{config["user"]}]: ")) - ) - else: # local - config = toml["deploy"]["local"] - return LocalShinyDeploy( - mode=deploy_mode, - base_url=config["base_url"], - dir_deployment=config["directory"] - ) - - -if __name__ == "__main__": - main() \ No newline at end of file +import click + +from .process import initialize + + +@click.group() +def cli(): + ... + + +@cli.command() +@click.argument("deploy_mode") +def deploy(deploy_mode: str): + shinylive_ = initialize(deploy_mode) + shinylive_.deploy() + + +@cli.command() +@click.argument("deploy_mode") +def rollback(deploy_mode: str): + shinylive_ = initialize(deploy_mode) + shinylive_.rollback() + + +@cli.command() +@click.argument("deploy_mode") +def clean_rollback(deploy_mode: str): + shinylive_ = initialize(deploy_mode) + shinylive_.clean_rollback() + + +@cli.command() +@click.argument("deploy_mode") +def remove(deploy_mode: str): + shinylive_ = initialize(deploy_mode) + shinylive_.remove() diff --git a/src/shinylive_deploy/config.py b/src/shinylive_deploy/config.py new file mode 100644 index 0000000..aeb190b --- /dev/null +++ b/src/shinylive_deploy/config.py @@ -0,0 +1,64 @@ +import os +from pathlib import Path + +import tomllib + +CONFIG_FILEPATH = os.environ.get("SHINYLIVE_DEPLOY_CONFIG", Path.cwd() / "shinylive_deploy.toml") + + +toml_text = """ +[general] +app_name = "app1" + +[development] +directory = "src" + +[deploy.gitbranch] +prod = "main" +beta = "main" + +[deploy.staging] +directory = "staging" + +[deploy.local] +directory = "src_test_webserver/shinyapps/" +base_url = "http://localhost:8000/apps" + +[deploy.server] +host = "127.0.0.1" +user = "shinylive" +port = 2222 +directory = "shinyapps" +base_url = "http://localhost:5000" +""" + + +def create_config(): + if not CONFIG_FILEPATH.exists(): + with open(CONFIG_FILEPATH, "w") as f: + f.write(toml_text) + + +def read_config() -> dict: + if not CONFIG_FILEPATH.exists(): + create_config() + print(f"\n>>> WARNING <<<: {CONFIG_FILEPATH.name} did not yet exist. Default config file created. Please update, then run deploy again.\n") + exit() + with open(CONFIG_FILEPATH, "rb") as f: + return tomllib.load(f) + + +# if not CONFIG_FILEPATH.exists(): +# create_config() +toml = read_config() + + +class Config: + app_name = toml["general"]["app_name"] + deploy_local = toml["deploy"]["local"] + deploy_server = toml["deploy"]["server"] + development: dict = toml.get("development", {}) + staging: dict = toml["deploy"].get("staging", {}) + gitbranch: dict = toml["deploy"].get("gitbranch", {}) + +config = Config() diff --git a/src/shinylive_deploy/models/__init__.py b/src/shinylive_deploy/models/__init__.py deleted file mode 100644 index 8d15471..0000000 --- a/src/shinylive_deploy/models/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .base import toml -from .local import LocalShinyDeploy -from .server import ServerShinyDeploy - -__all__ = [toml, LocalShinyDeploy, ServerShinyDeploy] diff --git a/src/shinylive_deploy/process/__init__.py b/src/shinylive_deploy/process/__init__.py new file mode 100644 index 0000000..807239c --- /dev/null +++ b/src/shinylive_deploy/process/__init__.py @@ -0,0 +1,35 @@ +from getpass import getpass + +from pydantic import SecretStr +from shinylive_deploy.config import CONFIG_FILEPATH, create_config +from shinylive_deploy.config import config as loaded_config + +from .local import LocalShinyDeploy +from .server import ServerShinyDeploy + + +def initialize(deploy_mode: str) -> LocalShinyDeploy | ServerShinyDeploy: + if deploy_mode not in ("local", "test", "beta", "prod"): + raise ValueError('`DEPLOY_MODE` must be on of the following: "local", "test", "beta", "prod"') + + if not CONFIG_FILEPATH.exists(): + create_config() + return + if deploy_mode in ("test", "beta", "prod"): + config = loaded_config.deploy_server + return ServerShinyDeploy( + mode=deploy_mode, + base_url=config["base_url"], + dir_deployment=config["directory"], + host=config["host"], + user=config["user"], + port=config.get("port", 22), + password=SecretStr(value=getpass(f"SSH password for [{config["user"]}]: ")) + ) + else: # local + config = loaded_config.deploy_local + return LocalShinyDeploy( + mode=deploy_mode, + base_url=config["base_url"], + dir_deployment=config["directory"] + ) \ No newline at end of file diff --git a/src/shinylive_deploy/models/base.py b/src/shinylive_deploy/process/base.py similarity index 67% rename from src/shinylive_deploy/models/base.py rename to src/shinylive_deploy/process/base.py index 8cc04a4..8c95ca2 100644 --- a/src/shinylive_deploy/models/base.py +++ b/src/shinylive_deploy/process/base.py @@ -1,42 +1,29 @@ +# ruff: noqa: S602 import subprocess from dataclasses import dataclass from pathlib import Path from typing import Literal import git -import tomllib - -filepath = Path.cwd() / "shinylive_deploy.toml" -if not filepath.exists(): - from shinylive_deploy.data import create_config_file - create_config_file() - -with open("shinylive_deploy.toml", "rb") as f: - toml = tomllib.load(f) - -deploy_local = toml["deploy"]["local"] -deploy_server = toml["deploy"]["server"] -development: dict = toml.get("development", {}) -staging: dict = toml["deploy"].get("staging", {}) -gitbranch: dict = toml["deploy"].get("gitbranch", {}) +from shinylive_deploy.config import config @dataclass class ShinyDeploy: base_url: str = None - app_name: str = toml["general"]["app_name"] + app_name: str = config.app_name dir_deployment: str = None - dir_development: str = development.get("directory", "src") - dir_staging: str = staging.get("directory", "staging") - prod_branch: str = gitbranch.get("prod", "main") - beta_branch: str = gitbranch.get("beta", "main") + dir_development: str = config.development.get("directory", "src") + dir_staging: str = config.staging.get("directory", "staging") + prod_branch: str = config.gitbranch.get("prod", "main") + beta_branch: str = config.gitbranch.get("beta", "main") mode: Literal["local", "test", "beta", "prod"] = None @property def deploy_name(self): modes = {"prod": "", "beta": "-beta", "test": "-test", "local": ""} return self.app_name + modes[self.mode] - + def _message(self): print( "\n##################################" @@ -63,4 +50,4 @@ def _compile(self): class DeployException(Exception): - pass \ No newline at end of file + pass diff --git a/src/shinylive_deploy/models/local.py b/src/shinylive_deploy/process/local.py similarity index 65% rename from src/shinylive_deploy/models/local.py rename to src/shinylive_deploy/process/local.py index 7d8965d..b6ae892 100644 --- a/src/shinylive_deploy/models/local.py +++ b/src/shinylive_deploy/process/local.py @@ -1,3 +1,4 @@ +# ruff: noqa: S602 S603 import subprocess from pathlib import Path @@ -24,13 +25,13 @@ def deploy(self): f"\n- Available at {self.base_url}/{self.deploy_name}" ) if has_backup: - print(f"\n- Backup available at {self.base_url}/{self.deploy_name}-backup") + print(f"- Backup available at {self.base_url}/{self.deploy_name}-backup") def rollback(self): self._check_git_requirements() existing_deploy_dir = Path(self.dir_deployment) / self.deploy_name if not self._deployed_dir_exists(): - print("\n>>> WARNING <<<: Backback STOPPED. No app directory exists to rollback from.\n") + print("\n>>> WARNING <<<: Backback STOPPED. No app directory exists to rollback to.\n") return if not self._backup_dir_exists(): print("\n>>> WARNING <<<: Backback STOPPED. No backup directory exists for rollback.\n") @@ -46,6 +47,27 @@ def rollback(self): f"\n- Available at {self.base_url}/{self.deploy_name}" ) + def clean_rollback(self): + self._check_git_requirements() + existing_deploy_dir = Path(self.dir_deployment) / self.deploy_name + if not self._backup_dir_exists(): + print("\n>>> WARNING <<<: Rollback cleanup STOPPED. No backup directory exists to remove.\n") + return + subprocess.run(f"rm -r {existing_deploy_dir}-backup", **subprocess_config) + print(f"\nRemoved `{self.base_url}/{self.deploy_name}-backup`") + print("\nROLLBACK CLEANUP COMPLETE") + + def remove(self): + self._check_git_requirements() + self._check_git_requirements() + existing_deploy_dir = Path(self.dir_deployment) / self.deploy_name + if not self._deployed_dir_exists(): + print("\n>>> WARNING <<<: App removal STOPPED. No app directory exists to remove.\n") + return + subprocess.run(f"rm -r {existing_deploy_dir}", **subprocess_config) + print(f"\nRemoved `{self.deploy_name}`") + print("\nAPPLICATION REMOVAL COMPLETE") + def _deployed_dir_exists(self): deploy_dirs = [x.name for x in Path(self.dir_deployment).iterdir()] # result = subprocess.run(f"ls {self.dir_deployment}", **subprocess_config) @@ -66,7 +88,11 @@ def _manage_backup(self): existing_deploy_dir = Path(self.dir_deployment) / self.deploy_name if self._deployed_dir_exists(): if self._backup_dir_exists(): - print("\n>>> WARNING <<<: Deployment STOPPED. Backup directory already exists. Delete backup directory, or rollback before redeploying.\n") + print( + "\n>>> WARNING <<<: Deployment STOPPED. Backup directory already exists. " + "Delete current backup directory using `shinylive_deploy --clean-rollback`, " + "or rollback before redeploying using `shinylive_deploy --rollback`.\n" + ) return None subprocess.run(f"mv {existing_deploy_dir} {existing_deploy_dir}-backup", shell=True) return True diff --git a/src/shinylive_deploy/models/server.py b/src/shinylive_deploy/process/server.py similarity index 72% rename from src/shinylive_deploy/models/server.py rename to src/shinylive_deploy/process/server.py index c67cc23..a19e3bd 100644 --- a/src/shinylive_deploy/models/server.py +++ b/src/shinylive_deploy/process/server.py @@ -1,3 +1,4 @@ +# ruff: noqa: S602 import subprocess from dataclasses import dataclass from pathlib import Path, PurePosixPath @@ -5,7 +6,7 @@ from paramiko import AutoAddPolicy, SFTPClient, SSHClient from pydantic import SecretStr -from .base import ShinyDeploy, DeployException +from .base import DeployException, ShinyDeploy subprocess_config = {"capture_output": True, "text": True, "shell": True, "check": True} @@ -44,7 +45,7 @@ def deploy(self, testing: bool = False): f"\n- App available at {self.base_url}/{self.deploy_name}" ) if has_backup is True: - print(f"\n- Backup available at {self.base_url}/{self.deploy_name}-backup") + print(f"- Backup available at {self.base_url}/{self.deploy_name}-backup") def rollback(self): self._check_git_requirements() @@ -71,6 +72,36 @@ def rollback(self): f"\n- Available at {self.base_url}/{self.deploy_name}" ) + def clean_rollback(self): + self._check_git_requirements() + deployment_dir = PurePosixPath(self.dir_deployment) / self.deploy_name + + with SSHClient() as ssh: + ssh = self._ssh_connection(ssh) + sftp = ssh.open_sftp() + if not self._backup_dir_exists(sftp): + print("\n>>> WARNING <<<: Backback STOPPED. No backup directory exists to remove.\n") + return + + ssh.exec_command(f"rm -rf {deployment_dir}-backup") + print(f"\nRemoved `{deployment_dir}-backup`") + print("\nROLLBACK CLEANUP COMPLETE") + + def remove(self): + self._check_git_requirements() + deployment_dir = PurePosixPath(self.dir_deployment) / self.deploy_name + + with SSHClient() as ssh: + ssh = self._ssh_connection(ssh) + sftp = ssh.open_sftp() + if not self._deployed_dir_exists(sftp): + print("\n>>> WARNING <<<: App removal STOPPED. No app directory exists to remove.\n") + return + + ssh.exec_command(f"rm -rf {deployment_dir}") + print(f"\nRemoved `{deployment_dir}`") + print("\nAPPLICATION REMOVAL COMPLETE") + def _ssh_connection(self, client: SSHClient) -> SSHClient: client.set_missing_host_key_policy(AutoAddPolicy()) client.connect( @@ -101,7 +132,11 @@ def _manage_backup(self, sftp: SFTPClient): print(deployment_filepath) if self._deployed_dir_exists(sftp): if self._backup_dir_exists(sftp): - print("\n>>> WARNING <<<: Deployment STOPPED. Backup directory already exists. Delete backup directory, or rollback before redeploying.\n") + print( + "\n>>> WARNING <<<: Deployment STOPPED. Backup directory already exists. " + "Delete current backup directory using `shinylive_deploy --clean-rollback`, " + "or rollback before redeploying using `shinylive_deploy --rollback`.\n" + ) return None sftp.rename(str(deployment_filepath), f"{deployment_filepath}-backup") return True diff --git a/testing/src_test_webserver/shinyapps/.gitignore b/testing/src_test_webserver/shinyapps/.gitignore index 0318876..c96a04f 100644 --- a/testing/src_test_webserver/shinyapps/.gitignore +++ b/testing/src_test_webserver/shinyapps/.gitignore @@ -1,3 +1,2 @@ * -!.gitignore -!.gitkeep \ No newline at end of file +!.gitignore \ No newline at end of file diff --git a/testing/staging/.gitignore b/testing/staging/.gitignore index 0318876..c96a04f 100644 --- a/testing/staging/.gitignore +++ b/testing/staging/.gitignore @@ -1,3 +1,2 @@ * -!.gitignore -!.gitkeep \ No newline at end of file +!.gitignore \ No newline at end of file diff --git a/testing/test/conftest.py b/testing/test/conftest.py index 70b91d2..b294e20 100644 --- a/testing/test/conftest.py +++ b/testing/test/conftest.py @@ -1,10 +1,12 @@ +# ruff: noqa: S603 S607 +import os import shutil import subprocess from pathlib import Path -from shinylive_deploy.data import create_config_file +from shinylive_deploy.config import create_config -create_config_file() +create_config() def pytest_sessionstart(session): """Execute before all tests""" diff --git a/testing/test/test__app.py b/testing/test/test__app.py index 6862524..8c25ded 100644 --- a/testing/test/test__app.py +++ b/testing/test/test__app.py @@ -1,48 +1,11 @@ -import re - +# ruff: noqa: S101 import pytest from pydantic import SecretStr -from shinylive_deploy.app import _initialize_configuration, _parse_arguments - - -def test_parse_argements_local(capfd): - deploy_mode, rollback = _parse_arguments(["", "local"]) - assert deploy_mode == "local" - assert rollback is False - -def test_parse_argements_prod(capfd): - deploy_mode, rollback = _parse_arguments(["", "prod"]) - assert deploy_mode == "prod" - assert rollback is False - -def test_parse_argements_test(capfd): - deploy_mode, rollback = _parse_arguments(["", "test"]) - assert deploy_mode == "test" - assert rollback is False - -def test_parse_argements_beta(capfd): - deploy_mode, rollback = _parse_arguments(["", "beta"]) - assert deploy_mode == "beta" - assert rollback is False - -def test_parse_argements_rollback_and_r(capfd): - deploy_mode, rollback = _parse_arguments(["", "local", "--rollback"]) - assert deploy_mode == "local" - assert rollback is True - deploy_mode, rollback = _parse_arguments(["", "local", "-r"]) - assert deploy_mode == "local" - assert rollback is True - -def test_parse_argements_invalid_mode(capfd): - with pytest.raises(ValueError, match=re.escape("\nERROR: One of the following arguments is required -> [ local | test | beta | prod ]\n")): - _parse_arguments(["", "super"]) +from shinylive_deploy.process import initialize -def test_parse_argements_invalid_rollback(capfd): - with pytest.raises(ValueError, match=re.escape("2nd optional argument must be `-r` or `--rollback`")): - _parse_arguments(["", "local", "rollback"]) def test_initalize_config_local(): - config = _initialize_configuration("local") + config = initialize("local") assert config.app_name == "app1" assert config.deploy_name == "app1" assert config.mode == "local" @@ -63,9 +26,9 @@ def test_initalize_config_local(): def test_initalize_config_prod(monkeypatch): """temporarily patches the object in the test context""" - monkeypatch.setattr('shinylive_deploy.app.getpass', lambda _: "password") + monkeypatch.setattr('shinylive_deploy.process.getpass', lambda _: "password") - config = _initialize_configuration("prod") + config = initialize("prod") assert config.app_name == "app1" assert config.deploy_name == "app1" assert config.mode == "prod" @@ -83,9 +46,9 @@ def test_initalize_config_prod(monkeypatch): def test_initalize_config_test(monkeypatch): """temporarily patches the object in the test context""" - monkeypatch.setattr('shinylive_deploy.app.getpass', lambda _: "password") + monkeypatch.setattr('shinylive_deploy.process.getpass', lambda _: "password") - config = _initialize_configuration("test") + config = initialize("test") assert config.app_name == "app1" assert config.deploy_name == "app1-test" assert config.mode == "test" @@ -103,9 +66,9 @@ def test_initalize_config_test(monkeypatch): def test_initalize_config_beta(monkeypatch): """temporarily patches the object in the test context""" - monkeypatch.setattr('shinylive_deploy.app.getpass', lambda _: "password") + monkeypatch.setattr('shinylive_deploy.process.getpass', lambda _: "password") - config = _initialize_configuration("beta") + config = initialize("beta") assert config.app_name == "app1" assert config.deploy_name == "app1-beta" assert config.mode == "beta" diff --git a/testing/test/test__base.py b/testing/test/test__base.py index 72168f1..bbf9e37 100644 --- a/testing/test/test__base.py +++ b/testing/test/test__base.py @@ -1,10 +1,11 @@ +# ruff: noqa: S101 S603 S607 import re import shutil import subprocess from pathlib import Path import pytest -from shinylive_deploy.models.base import DeployException, ShinyDeploy +from shinylive_deploy.process.base import DeployException, ShinyDeploy subprocess_config = {"capture_output": True, "text": True, "shell": True, "check": True} diff --git a/testing/test/test__data.py b/testing/test/test__data.py index 5ea6ccf..91be32e 100644 --- a/testing/test/test__data.py +++ b/testing/test/test__data.py @@ -1,12 +1,13 @@ +# ruff: noqa: S101 from pathlib import Path import tomllib -from shinylive_deploy.data import create_config_file +from shinylive_deploy.config import create_config # TESTS def test_create_config_file(): - create_config_file() + create_config() with open(Path.cwd() / "shinylive_deploy.toml", 'rb') as f: toml = tomllib.load(f) assert toml["general"]["app_name"] == "app1" diff --git a/testing/test/test__local.py b/testing/test/test__local.py index fbfb362..42a1693 100644 --- a/testing/test/test__local.py +++ b/testing/test/test__local.py @@ -1,13 +1,18 @@ +# ruff: noqa: S101 import shutil -import subprocess from pathlib import Path import pytest -from shinylive_deploy.app import _initialize_configuration, _parse_arguments +from shinylive_deploy.process import initialize -deployment_stopped_msg = ">>> WARNING <<<: Deployment STOPPED. Backup directory already exists. Delete backup directory, or rollback before redeploying." +deployment_stopped_msg = ( + ">>> WARNING <<<: Deployment STOPPED. Backup directory already exists. " + "Delete current backup directory using `shinylive_deploy --clean-rollback`, " + "or rollback before redeploying using `shinylive_deploy --rollback`." +) -def reset_local_dirs(recreate: bool): + +def reset_local_dirs(): deploy_dir = Path(__file__).parent.parent / "src_test_webserver" / "shinyapps" staging_dir = Path(__file__).parent.parent / "staging" @@ -19,21 +24,24 @@ def reset_local_dirs(recreate: bool): deploy_dir.mkdir() staging_dir.mkdir() - with open(deploy_dir / ".gitkeep", "w") as f: f.write("") - with open(staging_dir / ".gitkeep", "w") as f: f.write("") + with open(deploy_dir / ".gitignore", "w") as f: + f.write("*\n!.gitignore") + with open(staging_dir / ".gitignore", "w") as f: + f.write("*\n!.gitignore") + @pytest.fixture() def dirs_session(): - reset_local_dirs(recreate=True) + reset_local_dirs() yield - reset_local_dirs(recreate=False) + reset_local_dirs() + def test_deploy_local(capfd, dirs_session): - mode, rollback = _parse_arguments(["", "local"]) - shinylive_ = _initialize_configuration(mode) + mode = "local" + shinylive_ = initialize(mode) shinylive_.deploy() out, _ = capfd.readouterr() - assert rollback is False assert "DEPLOYMENT MODE: local" in out assert "DEPLOYMENT NAME: app1" in out assert "Export Command: shinylive export src staging/app1" in out @@ -53,12 +61,11 @@ def test_deploy_local(capfd, dirs_session): def test_deploy_local_create_backup(capfd, dirs_session): - mode, rollback = _parse_arguments(["", "local"]) - shinylive_ = _initialize_configuration(mode) + mode = "local" + shinylive_ = initialize(mode) shinylive_.deploy() shinylive_.deploy() out, _ = capfd.readouterr() - assert rollback is False assert "DEPLOYMENT MODE: local" in out assert "DEPLOYMENT NAME: app1" in out assert "Export Command: shinylive export src staging/app1" in out @@ -80,9 +87,10 @@ def test_deploy_local_create_backup(capfd, dirs_session): assert Path(deploy_dir / "app1-backup").exists() is True assert Path(deploy_dir / "app1-backup" / "app.json").exists() is True + def test_deploy_local_blocked(capfd, dirs_session): - mode, rollback = _parse_arguments(["", "local"]) - shinylive_ = _initialize_configuration(mode) + mode = "local" + shinylive_ = initialize(mode) shinylive_.deploy() out, _ = capfd.readouterr() # confirm blocked message NOT displayed @@ -95,7 +103,68 @@ def test_deploy_local_blocked(capfd, dirs_session): out, _ = capfd.readouterr() # confirm blocked message IS displayed assert deployment_stopped_msg in out - assert rollback is False staging_dir = Path(__file__).parent.parent / "staging" / "app1" assert staging_dir.exists() is True - assert deployment_stopped_msg in out \ No newline at end of file + assert deployment_stopped_msg in out + + +def test_deploy_local_rollback(capfd, dirs_session): + mode = "local" + shinylive_ = initialize(mode) + shinylive_.deploy() + out, _ = capfd.readouterr() + # confirm blocked message NOT displayed + assert deployment_stopped_msg not in out + shinylive_.deploy() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert deployment_stopped_msg not in out + shinylive_.deploy() + out, _ = capfd.readouterr() + # confirm blocked message IS displayed + assert deployment_stopped_msg in out + shinylive_.rollback() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert deployment_stopped_msg not in out + + +def test_deploy_local_rollback_no_deployment(capfd, dirs_session): + mode = "local" + shinylive_ = initialize(mode) + shinylive_.rollback() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert "\n>>> WARNING <<<: Backback STOPPED. No app directory exists to rollback from.\n\n." != out + + +def test_deploy_local_rollback_no_backup(capfd, dirs_session): + mode = "local" + shinylive_ = initialize(mode) + shinylive_.deploy() + shinylive_.rollback() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert "\n>>> WARNING <<<: Backback STOPPED. No backup directory exists for rollback.\n\n." != out + + +def test_deploy_local_remove(capfd, dirs_session): + mode = "local" + shinylive_ = initialize(mode) + shinylive_.deploy() + shinylive_.remove() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert "Removed `app1`" + assert "APPLICATION REMOVAL COMPLETE" in out + assert "\n>>> WARNING <<<: Backback STOPPED. No backup directory exists for rollback.\n\n." != out + + +def test_deploy_local_remove_no_app(capfd, dirs_session): + mode = "local" + shinylive_ = initialize(mode) + shinylive_.deploy() + shinylive_.remove() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert ">>> WARNING <<<: App removal STOPPED. No app directory exists to remove." != out diff --git a/testing/test/test__server.py b/testing/test/test__server.py index b2d9896..71115a7 100644 --- a/testing/test/test__server.py +++ b/testing/test/test__server.py @@ -1,15 +1,16 @@ +# ruff: noqa: S101 import shutil -import subprocess from pathlib import Path import pytest from paramiko import SFTPClient, SSHClient -from shinylive_deploy.app import ( - _initialize_configuration, - _parse_arguments, -) +from shinylive_deploy.app import initialize -deployment_stopped_msg = ">>> WARNING <<<: Deployment STOPPED. Backup directory already exists. Delete backup directory, or rollback before redeploying." +deployment_stopped_msg = ( + ">>> WARNING <<<: Deployment STOPPED. Backup directory already exists. " + "Delete current backup directory using `shinylive_deploy --clean-rollback`, " + "or rollback before redeploying using `shinylive_deploy --rollback`." +) def reset_ssh_dirs(ssh: SSHClient, sftp: SFTPClient): @@ -23,15 +24,16 @@ def reset_ssh_dirs(ssh: SSHClient, sftp: SFTPClient): if staging_dir.exists(): shutil.rmtree(staging_dir.resolve()) staging_dir.mkdir() - with open(staging_dir / ".gitkeep", "w") as f: f.write("") + with open(staging_dir / ".gitignore", "w") as f: + f.write("*\n!.gitignore") @pytest.fixture() def sftp_session(monkeypatch): """temporarily patches the object in the test context""" - monkeypatch.setattr('shinylive_deploy.app.getpass', lambda _: "docker") + monkeypatch.setattr('shinylive_deploy.process.getpass', lambda _: "docker") - shinylive_ = _initialize_configuration("test") + shinylive_ = initialize("test") with SSHClient() as ssh: ssh = shinylive_._ssh_connection(ssh) sftp = ssh.open_sftp() @@ -41,11 +43,10 @@ def sftp_session(monkeypatch): def test_deploy_test(capfd, sftp_session): - mode, rollback = _parse_arguments(["", "test"]) - shinylive_ = _initialize_configuration(mode) + mode = "test" + shinylive_ = initialize(mode) shinylive_.deploy() out, err = capfd.readouterr() - assert rollback is False assert "DEPLOYMENT MODE: test" in out assert "DEPLOYMENT NAME: app1-test" in out assert "Export Command: shinylive export src staging/app1" in out @@ -65,13 +66,13 @@ def test_deploy_test(capfd, sftp_session): assert "app1-test" in directories assert "app1-test-backup" not in directories + def test_deploy_test_create_backup(capfd, sftp_session): - mode, rollback = _parse_arguments(["", "test"]) - shinylive_ = _initialize_configuration(mode) + mode = "test" + shinylive_ = initialize(mode) shinylive_.deploy() shinylive_.deploy() out, err = capfd.readouterr() - assert rollback is False assert "DEPLOYMENT MODE: test" in out assert "DEPLOYMENT NAME: app1-test" in out assert "Export Command: shinylive export src staging/app1" in out @@ -91,9 +92,10 @@ def test_deploy_test_create_backup(capfd, sftp_session): assert "app1-test" in directories assert "app1-test-backup" in directories + def test_deploy_test_blocked(capfd, sftp_session): - mode, rollback = _parse_arguments(["", "test"]) - shinylive_ = _initialize_configuration(mode) + mode = "test" + shinylive_ = initialize(mode) shinylive_.deploy() out, err = capfd.readouterr() # confirm blocked message NOT displayed @@ -106,17 +108,78 @@ def test_deploy_test_blocked(capfd, sftp_session): out, err = capfd.readouterr() # confirm blocked message IS displayed assert deployment_stopped_msg in out - assert rollback is False staging_dir = Path(__file__).parent.parent / "staging" / "app1-test" assert staging_dir.exists() is True assert deployment_stopped_msg in out + +def test_deploy_test_rollback(capfd, sftp_session): + mode = "test" + shinylive_ = initialize(mode) + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message NOT displayed + assert deployment_stopped_msg not in out + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message still NOT displayed + assert deployment_stopped_msg not in out + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message IS displayed + assert deployment_stopped_msg in out + shinylive_.rollback() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert deployment_stopped_msg not in out + + +def test_deploy_test_rollback_no_deployment(capfd, sftp_session): + mode = "test" + shinylive_ = initialize(mode) + shinylive_.rollback() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert "\n>>> WARNING <<<: Backback STOPPED. No app directory exists to rollback from.\n\n." != out + + +def test_deploy_test_rollback_no_backup(capfd, sftp_session): + mode = "test" + shinylive_ = initialize(mode) + shinylive_.deploy() + shinylive_.rollback() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert "\n>>> WARNING <<<: Backback STOPPED. No backup directory exists for rollback.\n\n." != out + + +def test_deploy_test_remove(capfd, sftp_session): + mode = "test" + shinylive_ = initialize(mode) + shinylive_.deploy() + shinylive_.remove() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert "Removed `app1-test`" + assert "APPLICATION REMOVAL COMPLETE" in out + assert "\n>>> WARNING <<<: Backback STOPPED. No backup directory exists for rollback.\n\n." != out + + +def test_deploy_test_remove_no_app(capfd, sftp_session): + mode = "test" + shinylive_ = initialize(mode) + shinylive_.deploy() + shinylive_.remove() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert ">>> WARNING <<<: App removal STOPPED. No app directory exists to remove." != out + + def test_deploy_prod(capfd, sftp_session): - mode, rollback = _parse_arguments(["", "prod"]) - shinylive_ = _initialize_configuration(mode) + mode = "prod" + shinylive_ = initialize(mode) shinylive_.deploy() out, err = capfd.readouterr() - assert rollback is False assert "DEPLOYMENT MODE: prod" in out assert "DEPLOYMENT NAME: app1" in out assert "Export Command: shinylive export src staging/app1" in out @@ -136,13 +199,13 @@ def test_deploy_prod(capfd, sftp_session): assert "app1" in directories assert "app1-backup" not in directories + def test_deploy_prod_create_backup(capfd, sftp_session): - mode, rollback = _parse_arguments(["", "prod"]) - shinylive_ = _initialize_configuration(mode) + mode = "prod" + shinylive_ = initialize(mode) shinylive_.deploy() shinylive_.deploy() out, err = capfd.readouterr() - assert rollback is False assert "DEPLOYMENT MODE: prod" in out assert "DEPLOYMENT NAME: app1" in out assert "Export Command: shinylive export src staging/app1" in out @@ -163,8 +226,8 @@ def test_deploy_prod_create_backup(capfd, sftp_session): assert "app1-backup" in directories def test_deploy_prod_blocked(capfd, sftp_session): - mode, rollback = _parse_arguments(["", "prod"]) - shinylive_ = _initialize_configuration(mode) + mode = "prod" + shinylive_ = initialize(mode) shinylive_.deploy() out, err = capfd.readouterr() # confirm blocked message NOT displayed @@ -177,17 +240,78 @@ def test_deploy_prod_blocked(capfd, sftp_session): out, err = capfd.readouterr() # confirm blocked message IS displayed assert deployment_stopped_msg in out - assert rollback is False staging_dir = Path(__file__).parent.parent / "staging" / "app1" assert staging_dir.exists() is True assert deployment_stopped_msg in out + +def test_deploy_prod_rollback(capfd, sftp_session): + mode = "prod" + shinylive_ = initialize(mode) + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message NOT displayed + assert deployment_stopped_msg not in out + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message still NOT displayed + assert deployment_stopped_msg not in out + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message IS displayed + assert deployment_stopped_msg in out + shinylive_.rollback() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert deployment_stopped_msg not in out + + +def test_deploy_prod_rollback_no_deployment(capfd, sftp_session): + mode = "prod" + shinylive_ = initialize(mode) + shinylive_.rollback() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert "\n>>> WARNING <<<: Backback STOPPED. No app directory exists to rollback from.\n\n." != out + + +def test_deploy_prod_rollback_no_backup(capfd, sftp_session): + mode = "prod" + shinylive_ = initialize(mode) + shinylive_.deploy() + shinylive_.rollback() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert "\n>>> WARNING <<<: Backback STOPPED. No backup directory exists for rollback.\n\n." != out + + +def test_deploy_prod_remove(capfd, sftp_session): + mode = "prod" + shinylive_ = initialize(mode) + shinylive_.deploy() + shinylive_.remove() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert "Removed `app1`" + assert "APPLICATION REMOVAL COMPLETE" in out + assert "\n>>> WARNING <<<: Backback STOPPED. No backup directory exists for rollback.\n\n." != out + + +def test_deploy_prod_remove_no_app(capfd, sftp_session): + mode = "prod" + shinylive_ = initialize(mode) + shinylive_.deploy() + shinylive_.remove() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert ">>> WARNING <<<: App removal STOPPED. No app directory exists to remove." != out + + def test_deploy_beta(capfd, sftp_session): - mode, rollback = _parse_arguments(["", "beta"]) - shinylive_ = _initialize_configuration(mode) + mode = "beta" + shinylive_ = initialize(mode) shinylive_.deploy() out, err = capfd.readouterr() - assert rollback is False assert "DEPLOYMENT MODE: beta" in out assert "DEPLOYMENT NAME: app1-beta" in out assert "Export Command: shinylive export src staging/app1-beta" in out @@ -207,13 +331,13 @@ def test_deploy_beta(capfd, sftp_session): assert "app1-beta" in directories assert "app1-beta-backup" not in directories + def test_deploy_beta_create_backup(capfd, sftp_session): - mode, rollback = _parse_arguments(["", "beta"]) - shinylive_ = _initialize_configuration(mode) + mode = "beta" + shinylive_ = initialize(mode) shinylive_.deploy() shinylive_.deploy() out, err = capfd.readouterr() - assert rollback is False assert "DEPLOYMENT MODE: beta" in out assert "DEPLOYMENT NAME: app1" in out assert "Export Command: shinylive export src staging/app1-beta" in out @@ -233,9 +357,10 @@ def test_deploy_beta_create_backup(capfd, sftp_session): assert "app1-beta" in directories assert "app1-beta-backup" in directories + def test_deploy_beta_blocked(capfd, sftp_session): - mode, rollback = _parse_arguments(["", "beta"]) - shinylive_ = _initialize_configuration(mode) + mode = "beta" + shinylive_ = initialize(mode) shinylive_.deploy() out, err = capfd.readouterr() # confirm blocked message NOT displayed @@ -248,7 +373,68 @@ def test_deploy_beta_blocked(capfd, sftp_session): out, err = capfd.readouterr() # confirm blocked message IS displayed assert deployment_stopped_msg in out - assert rollback is False staging_dir = Path(__file__).parent.parent / "staging" / "app1-beta" assert staging_dir.exists() is True - assert deployment_stopped_msg in out \ No newline at end of file + assert deployment_stopped_msg in out + + +def test_deploy_beta_rollback(capfd, sftp_session): + mode = "beta" + shinylive_ = initialize(mode) + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message NOT displayed + assert deployment_stopped_msg not in out + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message still NOT displayed + assert deployment_stopped_msg not in out + shinylive_.deploy() + out, err = capfd.readouterr() + # confirm blocked message IS displayed + assert deployment_stopped_msg in out + shinylive_.rollback() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert deployment_stopped_msg not in out + + +def test_deploy_beta_rollback_no_deployment(capfd, sftp_session): + mode = "beta" + shinylive_ = initialize(mode) + shinylive_.rollback() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert "\n>>> WARNING <<<: Backback STOPPED. No app directory exists to rollback from.\n\n." != out + + +def test_deploy_beta_rollback_no_backup(capfd, sftp_session): + mode = "beta" + shinylive_ = initialize(mode) + shinylive_.deploy() + shinylive_.rollback() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert "\n>>> WARNING <<<: Backback STOPPED. No backup directory exists for rollback.\n\n." != out + + +def test_deploy_beta_remove(capfd, sftp_session): + mode = "beta" + shinylive_ = initialize(mode) + shinylive_.deploy() + shinylive_.remove() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert "Removed `app1-beta`" + assert "APPLICATION REMOVAL COMPLETE" in out + assert "\n>>> WARNING <<<: Backback STOPPED. No backup directory exists for rollback.\n\n." != out + + +def test_deploy_beta_remove_no_app(capfd, sftp_session): + mode = "beta" + shinylive_ = initialize(mode) + shinylive_.deploy() + shinylive_.remove() + out, _ = capfd.readouterr() + # confirm blocked message still NOT displayed + assert ">>> WARNING <<<: App removal STOPPED. No app directory exists to remove." != out \ No newline at end of file