├── cli ├── __init__.py ├── pa.py ├── students.py ├── django.py ├── website.py ├── path.py ├── webapp.py └── schedule.py ├── tests ├── __init__.py ├── test_pa_create_scheduled_task.py ├── test_cli_pa.py ├── test_utils.py ├── test_pa_get_scheduled_tasks_list.py ├── test_virtualenvs.py ├── test_pa_update_scheduled_task.py ├── test_pa_get_scheduled_task_specs.py ├── test_students.py ├── test_pa_start_django_webapp_with_virtualenv.py ├── test_pa_delete_scheduled_task.py ├── conftest.py ├── test_pa_autoconfigure_django.py ├── test_cli_students.py ├── test_project.py ├── test_scripts_commons.py ├── test_cli_django.py ├── test_task.py ├── test_cli_webapp.py └── test_cli_path.py ├── MANIFEST.in ├── mypy.ini ├── pythonanywhere ├── __init__.py ├── exceptions.pyi ├── exceptions.py ├── utils.pyi ├── students.pyi ├── virtualenvs.pyi ├── wsgi_file_template.py ├── project.pyi ├── django_project.pyi ├── files.pyi ├── scripts_commons.pyi ├── utils.py ├── virtualenvs.py ├── task.pyi ├── launch_bash_in_virtualenv.py ├── students.py ├── project.py ├── scripts_commons.py ├── django_project.py ├── files.py └── task.py ├── scripts ├── pa_reload_webapp.pyi ├── pa_delete_webapp_logs.pyi ├── pa_create_webapp_with_virtualenv.pyi ├── pa_autoconfigure_django.pyi ├── pa_start_django_webapp_with_virtualenv.pyi ├── pa_install_webapp_ssl.pyi ├── pa_get_scheduled_tasks_list.pyi ├── pa_get_scheduled_task_specs.pyi ├── pa_create_scheduled_task.pyi ├── pa_delete_scheduled_task.pyi ├── pa_update_scheduled_task.pyi ├── pa_reload_webapp.py ├── pa_delete_scheduled_task.py ├── pa_start_django_webapp_with_virtualenv.py ├── pa_get_scheduled_tasks_list.py ├── pa_create_webapp_with_virtualenv.py ├── pa_delete_webapp_logs.py ├── pa_autoconfigure_django.py ├── pa_create_scheduled_task.py ├── pa_install_webapp_ssl.py ├── pa_install_webapp_letsencrypt_ssl.py ├── pa_update_scheduled_task.py └── pa_get_scheduled_task_specs.py ├── .gitmodules ├── renovate.json ├── .travis.yml ├── pytest.ini ├── requirements.txt ├── .github └── workflows │ └── tests.yml ├── LICENSE ├── .gitignore ├── legacy.md ├── setup.py └── README.md /cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | -------------------------------------------------------------------------------- /pythonanywhere/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.18.0" 2 | -------------------------------------------------------------------------------- /pythonanywhere/exceptions.pyi: -------------------------------------------------------------------------------- 1 | class SanityException(Exception): ... 2 | -------------------------------------------------------------------------------- /scripts/pa_reload_webapp.pyi: -------------------------------------------------------------------------------- 1 | def main(domain_name: str) -> None: ... 2 | -------------------------------------------------------------------------------- /pythonanywhere/exceptions.py: -------------------------------------------------------------------------------- 1 | class SanityException(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /scripts/pa_delete_webapp_logs.pyi: -------------------------------------------------------------------------------- 1 | def main(domain: str, log_type: str, log_index: str) -> None: ... 2 | -------------------------------------------------------------------------------- /scripts/pa_create_webapp_with_virtualenv.pyi: -------------------------------------------------------------------------------- 1 | def main(domain: str, python_version: str, nuke: bool) -> None: ... 2 | -------------------------------------------------------------------------------- /scripts/pa_autoconfigure_django.pyi: -------------------------------------------------------------------------------- 1 | def main(repo_url: str, branch: str, domain: str, python_version: str, nuke: bool) -> None: ... 2 | -------------------------------------------------------------------------------- /scripts/pa_start_django_webapp_with_virtualenv.pyi: -------------------------------------------------------------------------------- 1 | def main(domain: str, django_version: str, python_version: str, nuke: bool) -> None: ... 2 | -------------------------------------------------------------------------------- /scripts/pa_install_webapp_ssl.pyi: -------------------------------------------------------------------------------- 1 | def main(domain_name: str, certificate_file: str, private_key_file: str, suppress_reload: bool) -> None: ... 2 | -------------------------------------------------------------------------------- /pythonanywhere/utils.pyi: -------------------------------------------------------------------------------- 1 | def ensure_domain(domain: str) -> str: ... 2 | 3 | def format_log_deletion_message(domain: str, log_type: str, log_index: int) -> str: ... 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "submodules/example-django-project"] 2 | path = submodules/example-django-project 3 | url = https://github.com/pythonanywhere/example-django-project 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>anaconda/renovate-config" 5 | ], 6 | "ignoreDeps": ["schema"] 7 | } 8 | -------------------------------------------------------------------------------- /scripts/pa_get_scheduled_tasks_list.pyi: -------------------------------------------------------------------------------- 1 | from pythonanywhere.task import Task 2 | 3 | def main(tableftm: str) -> None: 4 | def get_right_value(task: Task, attr: str) -> str: ... 5 | ... 6 | -------------------------------------------------------------------------------- /scripts/pa_get_scheduled_task_specs.pyi: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from typing_extensions import Literal 4 | 5 | def main(*, task_id: int, **kwargs: Optional[Literal[True]]) -> None: ... 6 | -------------------------------------------------------------------------------- /scripts/pa_create_scheduled_task.pyi: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from typing_extensions import Literal 4 | 5 | def main( 6 | command: str, hour: Optional[str], minute: str, disabled: Optional[Literal[True]] 7 | ) -> None: ... 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: python 4 | 5 | python: 6 | - 3.6 7 | - 3.7 8 | - 3.8 9 | 10 | install: 11 | - pip install -r requirements.txt 12 | - pip install -e . 13 | 14 | script: 15 | - pytest 16 | - pytest --cov=cli --cov=pythonanywhere --cov=scripts --cov-fail-under=65 17 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | 3 | addopts = --tb=native 4 | markers = 5 | slowtest: marks tests as one of slowest (deselect with '-m "not slowtest"') 6 | tasks: marks test as one of related to tasks 7 | files: marks test as one of related to files 8 | students: marks test as one of related to students 9 | -------------------------------------------------------------------------------- /pythonanywhere/students.pyi: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | from pythonanywhere.api.students_api import Students_Api 3 | 4 | class Students: 5 | api: StudentsAPI = ... 6 | def __init__(self) -> None: ... 7 | def get(self) -> Optional[list]: ... 8 | def delete(self, username: str) -> bool: ... 9 | -------------------------------------------------------------------------------- /scripts/pa_delete_scheduled_task.pyi: -------------------------------------------------------------------------------- 1 | from typing import List, Optional 2 | 3 | from typing_extensions import Literal 4 | 5 | def _delete_all(force: Optional[Literal[True]]) -> None: ... 6 | def _delete_by_id(id_numbers: List[str]) -> None: ... 7 | def main(*, id_numbers: List[str], nuke: bool, force: Optional[Literal[True]]) -> None: ... 8 | -------------------------------------------------------------------------------- /scripts/pa_update_scheduled_task.pyi: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | from typing_extensions import Literal 4 | 5 | def main( 6 | *, 7 | task_id: str, 8 | command: Optional[str], 9 | hour: Optional[str], 10 | minute: Optional[str], 11 | **kwargs: Optional[Literal[True]] 12 | ) -> None: 13 | def parse_opts(*opts: str) -> str: ... 14 | ... 15 | -------------------------------------------------------------------------------- /pythonanywhere/virtualenvs.pyi: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import List 3 | 4 | 5 | class Virtualenv: 6 | domain: str = ... 7 | python_version: str = ... 8 | path: Path = ... 9 | def __init__(self, domain: str, python_version: str) -> None: ... 10 | def __eq__(self, other: Virtualenv) -> bool: ... 11 | def create(self, nuke: bool) -> None: ... 12 | def pip_install(self, packages: List[str]) -> None: ... 13 | def get_version(self, package_name: str) -> str: ... 14 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | python-dateutil==2.9.0.post0 2 | click==8.3.0 3 | docopt==0.6.2 4 | importlib-metadata==8.7.0 5 | packaging 6 | psutil==7.1.3 7 | pytest==8.4.2 8 | pytest-cov==7.0.0 9 | pytest-mock==3.15.1 10 | pytest-mypy==1.0.1 11 | pythonanywhere_core==0.2.9 12 | requests==2.32.5 13 | responses==0.25.8 14 | schema==0.7.2 15 | snakesay==0.10.4 16 | tabulate==0.9.0 17 | typer==0.20.0 18 | urllib3==2.5.0 19 | virtualenvwrapper==6.1.1 20 | zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability 21 | -------------------------------------------------------------------------------- /tests/test_pa_create_scheduled_task.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import call 2 | 3 | import pytest 4 | from scripts.pa_create_scheduled_task import main 5 | 6 | 7 | @pytest.mark.tasks 8 | def test_checks_method_calls_and_args(mocker): 9 | mock_Task = mocker.patch("scripts.pa_create_scheduled_task.Task.to_be_created") 10 | 11 | main(command="echo foo", hour=8, minute=10, disabled=False) 12 | 13 | assert mock_Task.call_args == call(command="echo foo", hour=8, minute=10, disabled=False) 14 | assert mock_Task.return_value.method_calls == [call.create_schedule()] 15 | -------------------------------------------------------------------------------- /pythonanywhere/wsgi_file_template.py: -------------------------------------------------------------------------------- 1 | # This file contains the WSGI configuration required to serve up your 2 | # Django app 3 | import os 4 | import sys 5 | 6 | # Add your project directory to the sys.path 7 | settings_path = '{project.settings_path.parent.parent}' 8 | sys.path.insert(0, settings_path) 9 | 10 | # Set environment variable to tell django where your settings.py is 11 | os.environ['DJANGO_SETTINGS_MODULE'] = '{project.settings_path.parent.name}.settings' 12 | 13 | # Set the 'application' variable to the Django wsgi app 14 | from django.core.wsgi import get_wsgi_application 15 | application = get_wsgi_application() 16 | -------------------------------------------------------------------------------- /pythonanywhere/project.pyi: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pythonanywhere_core.webapp import Webapp 4 | 5 | from pythonanywhere.virtualenvs import Virtualenv 6 | 7 | class Project: 8 | domain: str = ... 9 | python_version: str = ... 10 | project_path: Path = ... 11 | virtualenv: Virtualenv = ... 12 | wsgi_file_path: Path = ... 13 | webapp: Webapp = ... 14 | def __init__(self, domain: str, python_version: str) -> None: ... 15 | def sanity_checks(self, nuke: bool) -> None: ... 16 | def create_webapp(self, nuke: bool) -> None: ... 17 | def add_static_file_mappings(self) -> None: ... 18 | def reload_webapp(self) -> None: ... 19 | def start_bash(self) -> None: ... 20 | -------------------------------------------------------------------------------- /pythonanywhere/django_project.pyi: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from pythonanywhere.project import Project 4 | from typing import Optional 5 | 6 | class DjangoProject(Project): 7 | def download_repo(self, repo: str, nuke: bool) -> None: ... 8 | def ensure_branch(self, branch: str) -> None: ... 9 | def create_virtualenv(self, django_version: Optional[str] = ..., nuke: bool = ...) -> None: ... 10 | def detect_requirements(self): ... 11 | def run_startproject(self, nuke: bool) -> None: ... 12 | settings_path: Path = ... 13 | manage_py_path: Path = ... 14 | def find_django_files(self) -> None: ... 15 | def update_settings_file(self) -> None: ... 16 | def run_collectstatic(self) -> None: ... 17 | def run_migrate(self) -> None: ... 18 | def update_wsgi_file(self) -> None: ... 19 | -------------------------------------------------------------------------------- /pythonanywhere/files.pyi: -------------------------------------------------------------------------------- 1 | from typing import Optional, Union 2 | 3 | from typer import FileBinaryRead 4 | 5 | from pythonanywhere_core.files import Files 6 | 7 | 8 | class PAPath: 9 | path: str = ... 10 | api: Files = ... 11 | def __init__(self, path: str) -> None: ... 12 | def __repr__(self) -> str: ... 13 | def _make_sharing_url(self, path: str) -> str: ... 14 | @staticmethod 15 | def _standarize_path(path: str) -> str: ... 16 | @property 17 | def url(self) -> str: ... 18 | @property 19 | def contents(self) -> Optional[Union[dict, str]]: ... 20 | @property 21 | def tree(self) -> Optional[list]: ... 22 | def delete(self) -> bool: ... 23 | def upload(self, content: FileBinaryRead) -> bool: ... 24 | def get_sharing_url(self) -> str: ... 25 | def share(self) -> str: ... 26 | def unshare(self) -> bool: ... 27 | -------------------------------------------------------------------------------- /pythonanywhere/scripts_commons.pyi: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Dict, List, Optional 3 | 4 | import schema 5 | 6 | from pythonanywhere.task import Task 7 | 8 | logger: logging.Logger = ... 9 | tabulate_formats: List[str] = ... 10 | 11 | class ScriptSchema(schema.Schema): 12 | boolean: schema.Or = ... 13 | hour: schema.Or = ... 14 | id_multi: schema.Or = ... 15 | id_required: schema.And = ... 16 | minute: schema.Or = ... 17 | minute_required: schema.And = ... 18 | string: schema.Or = ... 19 | tabulate_format: schema.Or = ... 20 | replacements: Dict[str] = ... 21 | def convert(self, string: str) -> str: ... 22 | def validate_user_input(self, arguments: dict, *, conversions: Optional[dict]) -> dict: ... 23 | 24 | def get_logger(set_info: bool) -> logging.Logger: ... 25 | def get_task_from_id(task_id: int, no_exit: bool) -> Task: ... 26 | -------------------------------------------------------------------------------- /scripts/pa_reload_webapp.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3.8 2 | """Reloads the given site 3 | 4 | Usage: 5 | pa_reload_webapp.py 6 | 7 | Options: 8 | Domain name, eg www.mydomain.com 9 | """ 10 | 11 | import os 12 | from docopt import docopt 13 | 14 | from pythonanywhere import __version__ 15 | from pythonanywhere_core.exceptions import MissingCNAMEException 16 | 17 | os.environ["PYTHONANYWHERE_CLIENT"] = f"helper-scripts/{__version__}" 18 | from pythonanywhere_core.webapp import Webapp 19 | from snakesay import snakesay 20 | 21 | 22 | def main(domain_name): 23 | webapp = Webapp(domain_name) 24 | try: 25 | webapp.reload() 26 | except MissingCNAMEException as e: 27 | print(snakesay(str(e))) 28 | print(snakesay( 29 | f"{domain_name} has been reloaded" 30 | )) 31 | 32 | 33 | if __name__ == '__main__': 34 | arguments = docopt(__doc__) 35 | main(arguments['']) 36 | -------------------------------------------------------------------------------- /pythonanywhere/utils.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import os 3 | 4 | 5 | def ensure_domain(domain): 6 | if domain == "your-username.pythonanywhere.com": 7 | username = getpass.getuser().lower() 8 | pa_domain = os.environ.get("PYTHONANYWHERE_DOMAIN", "pythonanywhere.com") 9 | return f"{username}.{pa_domain}" 10 | else: 11 | return domain 12 | 13 | 14 | def format_log_deletion_message(domain, log_type, log_index): 15 | """Generate message describing log deletion. 16 | 17 | Args: 18 | domain: Domain name (e.g., 'www.example.com') 19 | log_type: Log type string ('access', 'error', or 'server') 20 | log_index: 0 for current log, >0 for archived log 21 | 22 | Returns: 23 | Formatted message string 24 | """ 25 | if log_index: 26 | return f"Deleting old (archive number {log_index}) {log_type} log file for {domain} via API" 27 | return f"Deleting current {log_type} log file for {domain} via API" 28 | -------------------------------------------------------------------------------- /tests/test_cli_pa.py: -------------------------------------------------------------------------------- 1 | import typer.core 2 | 3 | from typer.testing import CliRunner 4 | 5 | from cli.pa import app 6 | 7 | typer.core.rich = None # Workaround to disable rich output to make testing on github actions easier 8 | # TODO: remove this workaround 9 | runner = CliRunner() 10 | 11 | 12 | def test_main_command_without_args_prints_help(): 13 | result = runner.invoke( 14 | app, 15 | [], 16 | ) 17 | assert result.exit_code == 2 18 | assert "This is a new experimental PythonAnywhere cli client." in result.stdout 19 | assert "Makes Django Girls tutorial projects deployment easy" in result.stdout 20 | assert "Perform some operations on files" in result.stdout 21 | assert "Manage scheduled tasks" in result.stdout 22 | assert "Perform some operations on students" in result.stdout 23 | assert "Everything for web apps: use this if you're not using" in result.stdout 24 | assert "EXPERIMENTAL: create and manage ASGI websites" in result.stdout 25 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: [ "3.10", "3.11", "3.12", "3.13" ] 11 | 12 | name: Python ${{ matrix.python-version }} 13 | steps: 14 | 15 | - uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0 16 | 17 | - name: Set up Python 18 | uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 19 | with: 20 | python-version: ${{ matrix.python-version }} 21 | 22 | 23 | - name: Install Python dependencies 24 | run: | 25 | python3 -m pip install --upgrade pip 26 | pip3 install -r requirements.txt 27 | pip3 install -e . 28 | 29 | - name: Test with pytest 30 | run: | 31 | pytest -vvv 32 | 33 | - name: Check coverage 34 | run: | 35 | pytest --cov=cli --cov=pythonanywhere --cov=scripts --cov-fail-under=65 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 PythonAnywhere LLP 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /cli/pa.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import os 4 | import typer 5 | 6 | from pythonanywhere import __version__ 7 | 8 | os.environ["PYTHONANYWHERE_CLIENT"] = f"pa/{__version__}" 9 | 10 | from cli import django 11 | from cli import path 12 | from cli import schedule 13 | from cli import students 14 | from cli import webapp 15 | from cli import website 16 | 17 | help = """This is a new experimental PythonAnywhere cli client. 18 | 19 | It was build with typer & click under the hood. 20 | """ 21 | 22 | app = typer.Typer(help=help, no_args_is_help=True, context_settings={"help_option_names": ["--help", "-h"]}) 23 | app.add_typer( 24 | django.app, 25 | name="django", 26 | help="Makes Django Girls tutorial projects deployment easy" 27 | ) 28 | app.add_typer( 29 | path.app, 30 | name="path", 31 | help="Perform some operations on files" 32 | ) 33 | app.add_typer( 34 | schedule.app, 35 | name="schedule", 36 | help="Manage scheduled tasks" 37 | ) 38 | app.add_typer( 39 | students.app, 40 | name="students", 41 | help="Perform some operations on students" 42 | ) 43 | app.add_typer( 44 | webapp.app, 45 | name="webapp", 46 | help="Everything for web apps: use this if you're not using our experimental features" 47 | ) 48 | app.add_typer( 49 | website.app, 50 | name="website", 51 | help="EXPERIMENTAL: create and manage ASGI websites" 52 | ) 53 | 54 | 55 | if __name__ == "__main__": 56 | app(prog_name="pa") 57 | -------------------------------------------------------------------------------- /pythonanywhere/virtualenvs.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | from pathlib import Path 4 | 5 | from snakesay import snakesay 6 | 7 | 8 | class Virtualenv: 9 | def __init__(self, domain, python_version): 10 | self.domain = domain 11 | self.python_version = python_version 12 | self.path = Path(os.environ["WORKON_HOME"]) / domain 13 | 14 | def __eq__(self, other): 15 | return self.domain == other.domain and self.python_version == other.python_version 16 | 17 | def create(self, nuke): 18 | print(snakesay(f"Creating virtualenv with Python{self.python_version}")) 19 | command = f"mkvirtualenv --python=python{self.python_version} {self.domain}" 20 | if nuke: 21 | command = f"rmvirtualenv {self.domain} && {command}" 22 | subprocess.check_call(["bash", "-c", f"source virtualenvwrapper.sh && {command}"]) 23 | return self 24 | 25 | def pip_install(self, packages): 26 | print(snakesay(f"Pip installing {packages} (this may take a couple of minutes)")) 27 | commands = [str(self.path / "bin/pip"), "install"] + packages.split() 28 | subprocess.check_call(commands) 29 | 30 | def get_version(self, package_name): 31 | commands = [str(self.path / "bin/pip"), "show", package_name] 32 | output = subprocess.check_output(commands).decode() 33 | for line in output.splitlines(): 34 | if line.startswith("Version: "): 35 | return line.split()[1] 36 | -------------------------------------------------------------------------------- /pythonanywhere/task.pyi: -------------------------------------------------------------------------------- 1 | from typing import List, Optional, Type, TypeVar, Union 2 | 3 | from typing_extensions import Literal 4 | 5 | from pythonanywhere.api.schedule import Schedule 6 | 7 | T = TypeVar("T", bound="Task") 8 | 9 | class Task: 10 | command: Optional[str] = ... 11 | hour: Optional[int] = ... 12 | minute: Optional[int] = ... 13 | interval: Optional[Literal["daily", "hourly"]] = ... 14 | enabled: Optional[bool] = ... 15 | task_id: Optional[int] = ... 16 | can_enable: Optional[bool] = ... 17 | expiry: Optional[str] = ... 18 | extend_url: Optional[str] = ... 19 | logfile: Optional[str] = ... 20 | printable_time: Optional[str] = ... 21 | url: Optional[str] = ... 22 | user: Optional[str] = ... 23 | schedule: Schedule = ... 24 | def __init__(self) -> None: ... 25 | def __repr__(self) -> str: ... 26 | @classmethod 27 | def from_id(cls: Type[T], task_id: int) -> T: ... 28 | @classmethod 29 | def to_be_created( 30 | cls: Type[T], *, command: str, minute: int, hour: Optional[int], disabled: bool, 31 | ) -> T: ... 32 | @classmethod 33 | def from_api_specs(cls: Type[T], specs: dict) -> T: ... 34 | def create_schedule(self) -> None: ... 35 | def delete_schedule(self) -> None: ... 36 | def update_specs(self, specs: dict) -> None: ... 37 | def update_schedule(self, params: dict, *, porcelain: bool) -> None: 38 | def make_spec_str( 39 | key: str, old_spec: Union[str, int], new_spec: Union[str, int] 40 | ) -> str: ... 41 | def make_msg(join_with: str) -> str: ... 42 | ... 43 | 44 | class TaskList: 45 | tasks = List[Task] 46 | def __init__(self) -> None: ... 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # Mypy 73 | .mypy_cache/ 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # dotenv 82 | .env 83 | 84 | # virtualenv 85 | .venv/ 86 | venv/ 87 | ENV/ 88 | 89 | # PyCharm project folder 90 | .idea/ 91 | 92 | # Spyder project settings 93 | .spyderproject 94 | 95 | # Rope project settings 96 | .ropeproject 97 | .venv 98 | 99 | # Emacs local variables 100 | .dir-locals.el 101 | 102 | # pytest 103 | .pytest_cache/ -------------------------------------------------------------------------------- /scripts/pa_delete_scheduled_task.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3.8 2 | """Delete scheduled task(s) by id or nuke'em all. 3 | 4 | Usage: 5 | pa_delete_scheduled_task.py id ... 6 | pa_delete_scheduled_task.py nuke [--force] 7 | 8 | Options: 9 | -h, --help Prints this message 10 | -f, --force Turns off user confirmation before deleting tasks 11 | 12 | Note: 13 | Task id may be acquired with `pa_get_scheduled_tasks_list.py` script.""" 14 | 15 | import os 16 | from docopt import docopt 17 | 18 | from pythonanywhere import __version__ 19 | from pythonanywhere.scripts_commons import ScriptSchema, get_logger, get_task_from_id 20 | 21 | os.environ["PYTHONANYWHERE_CLIENT"] = f"helper-scripts/{__version__}" 22 | from pythonanywhere.task import TaskList 23 | 24 | 25 | def _delete_all(force): 26 | if not force: 27 | if input("This will irrevocably delete all your tasks, proceed? [y/N] ").lower() != "y": 28 | return None 29 | 30 | for task in TaskList().tasks: 31 | task.delete_schedule() 32 | 33 | 34 | def _delete_by_id(id_numbers): 35 | for task_id in id_numbers: 36 | task = get_task_from_id(task_id, no_exit=True) 37 | task.delete_schedule() 38 | 39 | 40 | def main(*, id_numbers, nuke, force): 41 | get_logger(set_info=True) 42 | 43 | if nuke: 44 | _delete_all(force) 45 | else: 46 | _delete_by_id(id_numbers) 47 | 48 | 49 | if __name__ == "__main__": 50 | schema = ScriptSchema( 51 | {"id": bool, "": ScriptSchema.id_multi, "nuke": bool, "--force": ScriptSchema.boolean} 52 | ) 53 | arguments = schema.validate_user_input(docopt(__doc__), conversions={"num": "id_numbers"}) 54 | arguments.pop("id") 55 | 56 | main(**arguments) 57 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | 6 | from pythonanywhere.utils import ensure_domain, format_log_deletion_message 7 | 8 | 9 | class TestEnsureDomain: 10 | def test_domain_defaults_to_using_current_username_and_domain_from_env( 11 | self, monkeypatch 12 | ): 13 | username = getpass.getuser() 14 | monkeypatch.setenv("PYTHONANYWHERE_DOMAIN", "pythonanywhere.domain") 15 | 16 | result = ensure_domain("your-username.pythonanywhere.com") 17 | 18 | assert result == f"{username}.pythonanywhere.domain" 19 | 20 | def test_lowercases_username(self, monkeypatch): 21 | with patch('pythonanywhere.utils.getpass') as mock_getpass: 22 | mock_getpass.getuser.return_value = 'UserName1' 23 | 24 | result = ensure_domain("your-username.pythonanywhere.com") 25 | 26 | assert result == 'username1.pythonanywhere.com' 27 | 28 | def test_custom_domain_remains_unchanged(self): 29 | custom_domain = "foo.bar.baz" 30 | 31 | result = ensure_domain(custom_domain) 32 | 33 | assert result == custom_domain 34 | 35 | 36 | @pytest.mark.parametrize( 37 | "domain,log_type,log_index,expected", 38 | [ 39 | ("foo.com", "access", 0, "Deleting current access log file for foo.com via API"), 40 | ("bar.com", "error", 2, "Deleting old (archive number 2) error log file for bar.com via API"), 41 | ("baz.com", "server", 9, "Deleting old (archive number 9) server log file for baz.com via API"), 42 | ], 43 | ) 44 | def test_format_log_deletion_message_with_various_inputs(domain, log_type, log_index, expected): 45 | result = format_log_deletion_message(domain, log_type, log_index) 46 | 47 | assert result == expected 48 | -------------------------------------------------------------------------------- /pythonanywhere/launch_bash_in_virtualenv.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # Copyright (c) 2018 PythonAnywhere LLP. 3 | # All Rights Reserved 4 | # 5 | 6 | ## FIXME: this file is a pre-release copy of a built-in PythonAnywhere script. 7 | ## remove once the updated version is deployed to live. 8 | 9 | import os 10 | import sys 11 | 12 | HOME = os.path.expanduser('~') 13 | TMP = '/tmp' 14 | 15 | 16 | def write_temporary_bashrc(virtualenv_path, unique_id, source_directory): 17 | bashrc_path = os.path.join(HOME, '.bashrc') 18 | if os.path.exists(bashrc_path): 19 | with open(bashrc_path) as f: 20 | bashrc = f.read() 21 | else: 22 | bashrc = '' 23 | if os.path.dirname(virtualenv_path) == os.path.join(HOME, '.virtualenvs'): 24 | activate_script = f'workon {os.path.basename(virtualenv_path)}' 25 | else: 26 | activate_script_path = os.path.join(virtualenv_path, 'bin', 'activate') 27 | if not os.path.exists(activate_script_path): 28 | print(f'Could not find virtualenv activation script at {activate_script_path}') 29 | sys.exit(-1) 30 | with open(activate_script_path) as f: 31 | activate_script = f.read() 32 | 33 | tmprc = os.path.join(TMP, f'tmprc.{unique_id}') 34 | with open(tmprc, 'w') as f: 35 | f.write(bashrc) 36 | f.write('\n') 37 | f.write(activate_script) 38 | f.write('\n') 39 | f.write(f'cd {source_directory}\n') 40 | f.write('\n') 41 | f.write(f'rm {tmprc}') 42 | return tmprc 43 | 44 | 45 | def launch_bash_in_virtualenv(virtualenv_path, unique_id, source_directory): 46 | tmprc = write_temporary_bashrc(virtualenv_path, unique_id, source_directory) 47 | os.execv('/bin/bash', ['bash', '--rcfile', tmprc, '-i']) 48 | 49 | 50 | if __name__ == '__main__': 51 | launch_bash_in_virtualenv(sys.argv[2], sys.argv[1], sys.argv[3]) 52 | 53 | -------------------------------------------------------------------------------- /scripts/pa_start_django_webapp_with_virtualenv.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3.8 2 | """Create a new Django webapp with a virtualenv. Defaults to 3 | your free domain, the latest version of Django and Python 3.8 4 | 5 | Usage: 6 | pa_start_django_webapp_with_virtualenv.py [--domain= --django= --python=] [--nuke] 7 | 8 | Options: 9 | --domain= Domain name, eg www.mydomain.com [default: your-username.pythonanywhere.com] 10 | --django= Django version, eg "1.8.4" [default: latest] 11 | --python= Python version, eg "2.7" [default: 3.8] 12 | --nuke *Irrevocably* delete any existing web app config on this domain. Irrevocably. 13 | """ 14 | 15 | import os 16 | from docopt import docopt 17 | from snakesay import snakesay 18 | 19 | from pythonanywhere import __version__ 20 | from pythonanywhere.django_project import DjangoProject 21 | 22 | os.environ["PYTHONANYWHERE_CLIENT"] = f"helper-scripts/{__version__}" 23 | from pythonanywhere.utils import ensure_domain 24 | 25 | 26 | def main(domain, django_version, python_version, nuke): 27 | domain = ensure_domain(domain) 28 | project = DjangoProject(domain, python_version) 29 | print(snakesay("Running sanity checks")) 30 | project.sanity_checks(nuke=nuke) 31 | project.create_virtualenv(django_version, nuke=nuke) 32 | project.run_startproject(nuke=nuke) 33 | project.find_django_files() 34 | project.update_settings_file() 35 | project.run_collectstatic() 36 | project.create_webapp(nuke=nuke) 37 | project.add_static_file_mappings() 38 | 39 | project.update_wsgi_file() 40 | 41 | project.reload_webapp() 42 | 43 | print(snakesay(f'All done! Your site is now live at https://{domain}')) 44 | 45 | 46 | if __name__ == '__main__': 47 | arguments = docopt(__doc__) 48 | main(arguments['--domain'], arguments['--django'], arguments['--python'], nuke=arguments.get('--nuke')) 49 | -------------------------------------------------------------------------------- /scripts/pa_get_scheduled_tasks_list.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3.8 2 | """Get list of user's scheduled tasks as a table with columns: 3 | id, interval, at (hour:minute/minute past), status (enabled/disabled), command. 4 | 5 | Usage: 6 | pa_get_scheduled_tasks_list.py [--format TABLEFMT] 7 | 8 | Options: 9 | -h, --help Prints this message 10 | -f, --format TABLEFMT Sets table format supported by tabulate 11 | (defaults to 'simple') 12 | 13 | Note: 14 | This script provides an overview of all tasks. Once a task id is 15 | known and some specific data is required it's more convenient to get 16 | it using `pa_get_scheduled_task_specs.py` script instead of parsing 17 | the table.""" 18 | 19 | import os 20 | from docopt import docopt 21 | from snakesay import snakesay 22 | from tabulate import tabulate 23 | 24 | from pythonanywhere import __version__ 25 | from pythonanywhere.scripts_commons import ScriptSchema, get_logger 26 | 27 | os.environ["PYTHONANYWHERE_CLIENT"] = f"helper-scripts/{__version__}" 28 | from pythonanywhere.task import TaskList 29 | 30 | 31 | def main(tablefmt): 32 | logger = get_logger(set_info=True) 33 | headers = "id", "interval", "at", "status", "command" 34 | attrs = "task_id", "interval", "printable_time", "enabled", "command" 35 | 36 | def get_right_value(task, attr): 37 | value = getattr(task, attr) 38 | if attr == "enabled": 39 | value = "enabled" if value else "disabled" 40 | return value 41 | 42 | table = [[get_right_value(task, attr) for attr in attrs] for task in TaskList().tasks] 43 | msg = tabulate(table, headers, tablefmt=tablefmt) if table else snakesay("No scheduled tasks") 44 | logger.info(msg) 45 | 46 | 47 | if __name__ == "__main__": 48 | schema = ScriptSchema({"--format": ScriptSchema.tabulate_format}) 49 | argument = schema.validate_user_input(docopt(__doc__)) 50 | 51 | main(argument.get("format", "simple")) 52 | -------------------------------------------------------------------------------- /pythonanywhere/students.py: -------------------------------------------------------------------------------- 1 | """User interface for Pythonanywhere students API. 2 | 3 | Provides a class `Students` which should be used by helper scripts 4 | providing features for programmatic listing and removing of the user's 5 | students. 6 | """ 7 | 8 | import logging 9 | 10 | from snakesay import snakesay 11 | 12 | from pythonanywhere_core.students import StudentsAPI 13 | 14 | logger = logging.getLogger("pythonanywhere") 15 | 16 | 17 | class Students: 18 | """Class providing interface for PythonAnywhere students API. 19 | 20 | To perform actions on students related with user's account, use 21 | following methods: 22 | - :method:`Students.get` to get a list of students 23 | - :method:`Students.delete` to remove a student with a given username 24 | """ 25 | 26 | def __init__(self): 27 | self.api = StudentsAPI() 28 | 29 | def get(self): 30 | """ 31 | Returns list of usernames when user has students, otherwise an 32 | empty list. 33 | """ 34 | 35 | try: 36 | result = self.api.get() 37 | student_usernames = [student["username"] for student in result["students"]] 38 | count = len(student_usernames) 39 | if count: 40 | msg = f"You have {count} student{'s' if count > 1 else ''}!" 41 | else: 42 | msg = "Currently you don't have any students." 43 | logger.info(snakesay(msg)) 44 | return student_usernames 45 | except Exception as e: 46 | logger.warning(snakesay(str(e))) 47 | 48 | def delete(self, username): 49 | """ 50 | Returns `True` when user with `username` successfully removed from 51 | user's students list, `False` otherwise. 52 | """ 53 | 54 | try: 55 | self.api.delete(username) 56 | logger.info(snakesay(f"{username!r} removed from the list of students!")) 57 | return True 58 | except Exception as e: 59 | logger.warning(snakesay(str(e))) 60 | return False 61 | -------------------------------------------------------------------------------- /legacy.md: -------------------------------------------------------------------------------- 1 | # Legacy scripts 2 | 3 | We still provide separate scripts for specific actions that are now all integrated 4 | into unified `pa` cli tool. We will keep them available for people who rely on them in 5 | their workflow, but we plan to drop them when we release 1.0. 6 | 7 | There are scripts provided for dealing with web apps: 8 | 9 | * [pa_autoconfigure_django.py](https://github.com/pythonanywhere/helper_scripts/blob/master/scripts/pa_autoconfigure_django.py) 10 | * [pa_create_webapp_with_virtualenv.py](https://github.com/pythonanywhere/helper_scripts/blob/master/scripts/pa_create_webapp_with_virtualenv.py) 11 | * [pa_delete_webapp_logs.py](https://github.com/pythonanywhere/helper_scripts/blob/master/scripts/pa_delete_webapp_logs.py) 12 | * [pa_install_webapp_letsencrypt_ssl.py](https://github.com/pythonanywhere/helper_scripts/blob/master/scripts/pa_install_webapp_letsencrypt_ssl.py) 13 | * [pa_install_webapp_ssl.py](https://github.com/pythonanywhere/helper_scripts/blob/master/scripts/pa_install_webapp_ssl.py) 14 | * [pa_reload_webapp.py](https://github.com/pythonanywhere/helper_scripts/blob/master/scripts/pa_reload_webapp.py) 15 | * [pa_start_django_webapp_with_virtualenv.py](https://github.com/pythonanywhere/helper_scripts/blob/master/scripts/pa_start_django_webapp_with_virtualenv.py) 16 | 17 | and scheduled tasks: 18 | 19 | * [pa_create_scheduled_task.py](https://github.com/pythonanywhere/helper_scripts/blob/master/scripts/pa_create_scheduled_task.py) 20 | * [pa_delete_scheduled_task.py](https://github.com/pythonanywhere/helper_scripts/blob/master/scripts/pa_delete_scheduled_task.py) 21 | * [pa_get_scheduled_tasks_list.py](https://github.com/pythonanywhere/helper_scripts/blob/master/scripts/pa_get_scheduled_tasks_list.py) 22 | * [pa_get_scheduled_task_specs.py](https://github.com/pythonanywhere/helper_scripts/blob/master/scripts/pa_get_scheduled_task_specs.py) 23 | * [pa_update_scheduled_task.py](https://github.com/pythonanywhere/helper_scripts/blob/master/scripts/pa_update_scheduled_task.py) 24 | 25 | Run any of them with `--help` flag to get information about usage. 26 | 27 | See the [blog post](https://blog.pythonanywhere.com/155/) about how it all started. 28 | -------------------------------------------------------------------------------- /scripts/pa_create_webapp_with_virtualenv.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3.8 2 | """Create a web app with a virtualenv 3 | 4 | - creates a simple hello world web app 5 | - creates a virtualenv for it and links the two 6 | - creates a project folder for it at ~/www.domain-name.com 7 | - sets up a default static files mapping for /static -> ~/domain.com/static 8 | 9 | Usage: 10 | pa_create_webapp_with_virtualenv.py [--domain= --python=] [--nuke] 11 | 12 | Options: 13 | --domain= Domain name, eg www.mydomain.com [default: your-username.pythonanywhere.com] 14 | --python= Python version, eg "3.9" [default: 3.8] 15 | --nuke *Irrevocably* delete any existing web app config on this domain. Irrevocably. 16 | """ 17 | 18 | import os 19 | from docopt import docopt 20 | import getpass 21 | 22 | from pythonanywhere import __version__ 23 | 24 | os.environ["PYTHONANYWHERE_CLIENT"] = f"helper-scripts/{__version__}" 25 | from textwrap import dedent 26 | 27 | from snakesay import snakesay 28 | 29 | from pythonanywhere.project import Project 30 | from pythonanywhere.utils import ensure_domain 31 | 32 | 33 | def main(domain, python_version, nuke): 34 | domain = ensure_domain(domain) 35 | project = Project(domain, python_version) 36 | print(snakesay("Running sanity checks")) 37 | project.sanity_checks(nuke=nuke) 38 | project.virtualenv.create(nuke=nuke) 39 | project.create_webapp(nuke=nuke) 40 | project.add_static_file_mappings() 41 | project.reload_webapp() 42 | 43 | print(snakesay(dedent( 44 | ''' 45 | All done! 46 | - Your site is now live at https://{domain} 47 | - Your web app config screen is here: https://www.pythonanywhere.com/user/{username}/webapps/{mangled_domain} 48 | '''.format( 49 | domain=domain, 50 | username=getpass.getuser().lower(), 51 | mangled_domain=domain.replace('.', '_') 52 | ) 53 | ))) 54 | 55 | 56 | 57 | 58 | if __name__ == '__main__': # pragma: no cover 59 | arguments = docopt(__doc__) 60 | main(arguments['--domain'], arguments['--python'], nuke=arguments.get('--nuke')) 61 | -------------------------------------------------------------------------------- /scripts/pa_delete_webapp_logs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3.8 2 | """Deletes webapp logs. 3 | 4 | - gets list of logs via api 5 | - deletes logs via api 6 | 7 | Usage: 8 | pa_delete_webapp_logs.py [--domain=] [--log_type=] [--log_index=] 9 | 10 | Options: 11 | --domain= Domain name, eg www.mydomain.com [default: your-username.pythonanywhere.com] 12 | --log_type= Log type, could be access, error, server or all [default: all] 13 | --log_index= Log index, 0 for current log, 1-9 for one of archive logs or all [default: all] 14 | """ 15 | 16 | import os 17 | from docopt import docopt 18 | from pythonanywhere import __version__ 19 | from pythonanywhere_core.webapp import Webapp 20 | 21 | os.environ["PYTHONANYWHERE_CLIENT"] = f"helper-scripts/{__version__}" 22 | from snakesay import snakesay 23 | 24 | from pythonanywhere.utils import ensure_domain, format_log_deletion_message 25 | 26 | 27 | def main(domain, log_type, log_index): 28 | domain = ensure_domain(domain) 29 | webapp = Webapp(domain) 30 | log_types = ["access", "error", "server"] 31 | logs = webapp.get_log_info() 32 | if log_type == "all" and log_index == "all": 33 | for key in log_types: 34 | for log in logs[key]: 35 | print(snakesay(format_log_deletion_message(domain, key, log))) 36 | webapp.delete_log(key, log) 37 | elif log_type == "all": 38 | for key in log_types: 39 | print(snakesay(format_log_deletion_message(domain, key, int(log_index)))) 40 | webapp.delete_log(key, int(log_index)) 41 | elif log_index == "all": 42 | for i in logs[log_type]: 43 | print(snakesay(format_log_deletion_message(domain, log_type, i))) 44 | webapp.delete_log(log_type, int(i)) 45 | else: 46 | print(snakesay(format_log_deletion_message(domain, log_type, int(log_index)))) 47 | webapp.delete_log(log_type, int(log_index)) 48 | print(snakesay('All Done!')) 49 | 50 | 51 | if __name__ == '__main__': 52 | arguments = docopt(__doc__) 53 | main(arguments['--domain'], arguments['--log_type'], arguments['--log_index']) 54 | -------------------------------------------------------------------------------- /scripts/pa_autoconfigure_django.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3.8 2 | """Autoconfigure a Django project from on a github URL. 3 | 4 | - downloads the repo 5 | - creates a virtualenv and installs django 1.x (or detects a requirements.txt if available) 6 | - creates webapp via api 7 | - creates django wsgi configuration file 8 | - adds static files config 9 | 10 | Usage: 11 | pa_autoconfigure_django.py [--branch= --domain= --python=] [--nuke] 12 | 13 | Options: 14 | --branch= Branch name in case of multiple branches [default: None] 15 | --domain= Domain name, eg www.mydomain.com [default: your-username.pythonanywhere.com] 16 | --python= Python version, eg "3.9" [default: 3.8] 17 | --nuke *Irrevocably* delete any existing web app config on this domain. Irrevocably. 18 | """ 19 | 20 | import os 21 | from docopt import docopt 22 | 23 | from pythonanywhere import __version__ 24 | 25 | os.environ["PYTHONANYWHERE_CLIENT"] = f"helper-scripts/{__version__}" 26 | from snakesay import snakesay 27 | 28 | from pythonanywhere.django_project import DjangoProject 29 | from pythonanywhere.utils import ensure_domain 30 | 31 | 32 | def main(repo_url, branch, domain, python_version, nuke): 33 | domain = ensure_domain(domain) 34 | project = DjangoProject(domain, python_version) 35 | print(snakesay("Running sanity checks")) 36 | project.sanity_checks(nuke=nuke) 37 | project.download_repo(repo_url, nuke=nuke), 38 | project.ensure_branch(branch), 39 | project.create_virtualenv(nuke=nuke) 40 | project.create_webapp(nuke=nuke) 41 | project.add_static_file_mappings() 42 | project.find_django_files() 43 | project.update_wsgi_file() 44 | project.update_settings_file() 45 | project.run_collectstatic() 46 | project.run_migrate() 47 | project.reload_webapp() 48 | print(snakesay(f'All done! Your site is now live at https://{domain}')) 49 | print() 50 | project.start_bash() 51 | 52 | 53 | if __name__ == '__main__': # pragma: no cover 54 | arguments = docopt(__doc__) 55 | main( 56 | arguments[''], 57 | arguments['--branch'], 58 | arguments['--domain'], 59 | arguments['--python'], 60 | nuke=arguments.get('--nuke') 61 | ) 62 | -------------------------------------------------------------------------------- /scripts/pa_create_scheduled_task.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3.8 2 | """Create a scheduled task. 3 | 4 | Two categories of tasks are available: daily and hourly. 5 | Both kinds require a command to run and scheduled time. In order to create a 6 | daily task provide hour and minute; to create hourly task provide only minute. 7 | If task is intended to be enabled later add --disabled flag. 8 | 9 | Usage: 10 | pa_create_scheduled_task.py --command COMMAND [--hour HOUR] --minute MINUTE 11 | [--disabled] 12 | 13 | Options: 14 | -h, --help Prints this message 15 | -c, --command COMMAND Task's command to be scheduled 16 | -o, --hour HOUR Sets the task to be performed daily at HOUR 17 | (otherwise the task will be run hourly) 18 | -m, --minute MINUTE Minute on which the task will be executed 19 | -d, --disabled Creates disabled task (otherwise enabled) 20 | 21 | Example: 22 | Create a daily task to be run at 13:15: 23 | 24 | pa_create_scheduled_task.py --command "echo foo" --hour 13 --minute 15 25 | 26 | Create an inactive hourly task to be run 27 minutes past every hour: 27 | 28 | pa_create_scheduled_task.py --command "echo bar" --minute 27 --disabled 29 | 30 | Note: 31 | Once task is created its behavior may be altered later on with 32 | `pa_update_scheduled_task.py` or deleted with `pa_delete_scheduled_task.py` 33 | scripts.""" 34 | 35 | import os 36 | from docopt import docopt 37 | 38 | from pythonanywhere import __version__ 39 | from pythonanywhere.scripts_commons import ScriptSchema, get_logger 40 | 41 | os.environ["PYTHONANYWHERE_CLIENT"] = f"helper-scripts/{__version__}" 42 | from pythonanywhere.task import Task 43 | 44 | 45 | def main(*, command, hour, minute, disabled): 46 | get_logger(set_info=True) 47 | hour = int(hour) if hour is not None else None 48 | task = Task.to_be_created(command=command, hour=hour, minute=int(minute), disabled=disabled) 49 | task.create_schedule() 50 | 51 | 52 | if __name__ == "__main__": 53 | schema = ScriptSchema( 54 | { 55 | "--command": str, 56 | "--hour": ScriptSchema.hour, 57 | "--minute": ScriptSchema.minute, 58 | "--disabled": ScriptSchema.boolean, 59 | } 60 | ) 61 | arguments = schema.validate_user_input(docopt(__doc__)) 62 | 63 | main(**arguments) 64 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from setuptools import setup 3 | 4 | here = Path(__file__).parent 5 | 6 | # Get the long description from the README file 7 | with (here / "README.md").open(encoding="utf-8") as f: 8 | long_description = f.read() 9 | 10 | setup( 11 | name="pythonanywhere", 12 | version="0.18.0", 13 | description="PythonAnywhere helper tools for users", 14 | long_description=long_description, 15 | long_description_content_type="text/markdown", 16 | url="https://github.com/pythonanywhere/helper_scripts/", 17 | author="PythonAnywhere LLP", 18 | author_email="developers@pythonanywhere.com", 19 | license="MIT", 20 | classifiers=[ 21 | "Development Status :: 3 - Alpha", 22 | "Intended Audience :: Developers", 23 | "Topic :: Software Development :: Libraries", 24 | "License :: OSI Approved :: MIT License", 25 | "Programming Language :: Python :: 3.13", 26 | "Programming Language :: Python :: 3.12", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.10", 29 | ], 30 | keywords="pythonanywhere api cloud web hosting", 31 | packages=[ 32 | "cli", 33 | "pythonanywhere", 34 | ], 35 | install_requires=[ 36 | "docopt", 37 | "packaging", 38 | "python-dateutil", 39 | "pythonanywhere_core==0.2.9", 40 | "requests", 41 | "schema", 42 | "snakesay==0.10.4", 43 | "tabulate", 44 | "typer", 45 | ], 46 | extras_require={}, 47 | python_requires=">=3.10", 48 | package_data={}, 49 | data_files=[], 50 | entry_points={ 51 | "console_scripts": [ 52 | "pa=cli.pa:app", 53 | ] 54 | }, 55 | scripts=[ 56 | "scripts/pa_autoconfigure_django.py", 57 | "scripts/pa_create_scheduled_task.py", 58 | "scripts/pa_create_webapp_with_virtualenv.py", 59 | "scripts/pa_delete_scheduled_task.py", 60 | "scripts/pa_delete_webapp_logs.py", 61 | "scripts/pa_get_scheduled_task_specs.py", 62 | "scripts/pa_get_scheduled_tasks_list.py", 63 | "scripts/pa_install_webapp_letsencrypt_ssl.py", 64 | "scripts/pa_install_webapp_ssl.py", 65 | "scripts/pa_reload_webapp.py", 66 | "scripts/pa_start_django_webapp_with_virtualenv.py", 67 | "scripts/pa_update_scheduled_task.py", 68 | ], 69 | ) 70 | -------------------------------------------------------------------------------- /tests/test_pa_get_scheduled_tasks_list.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | from unittest.mock import call 3 | 4 | import pytest 5 | from scripts.pa_get_scheduled_tasks_list import main 6 | 7 | from pythonanywhere.task import Task 8 | 9 | 10 | @pytest.fixture 11 | def task_list(mocker): 12 | username = getpass.getuser() 13 | specs1 = { 14 | "can_enable": False, 15 | "command": "echo foo", 16 | "enabled": True, 17 | "expiry": None, 18 | "extend_url": f"/user/{username}/schedule/task/42/extend", 19 | "hour": 16, 20 | "id": 42, 21 | "interval": "daily", 22 | "logfile": "/user/{username}/files/var/log/tasklog-126708-daily-at-1600-echo_foo.log", 23 | "minute": 0, 24 | "printable_time": "16:00", 25 | "url": f"/api/v0/user/{username}/schedule/42", 26 | "user": username, 27 | } 28 | specs2 = {**specs1} 29 | specs2.update({"id": 43, "enabled": False}) 30 | mock_task_list = mocker.patch("scripts.pa_get_scheduled_tasks_list.TaskList") 31 | mock_task_list.return_value.tasks = [Task.from_api_specs(specs) for specs in (specs1, specs2)] 32 | return mock_task_list 33 | 34 | 35 | @pytest.mark.tasks 36 | class TestGetScheduledTasksList: 37 | def test_logs_task_list_as_table(self, task_list, mocker): 38 | mock_tabulate = mocker.patch("scripts.pa_get_scheduled_tasks_list.tabulate") 39 | mock_logger = mocker.patch("scripts.pa_get_scheduled_tasks_list.get_logger") 40 | 41 | main(tablefmt="orgtbl") 42 | 43 | headers = "id", "interval", "at", "status", "command" 44 | attrs = "task_id", "interval", "printable_time", "enabled", "command" 45 | table = [[getattr(task, attr) for attr in attrs] for task in task_list.return_value.tasks] 46 | table = [ 47 | ["enabled" if spec == True else "disabled" if spec == False else spec for spec in row] 48 | for row in table 49 | ] 50 | 51 | assert task_list.call_count == 1 52 | assert mock_tabulate.call_args == call(table, headers, tablefmt="orgtbl") 53 | assert mock_logger.call_args == call(set_info=True) 54 | assert mock_logger.return_value.info.call_count == 1 55 | 56 | def test_snakesays_when_no_active_tasks(self, task_list, mocker): 57 | mock_snake = mocker.patch("scripts.pa_get_scheduled_tasks_list.snakesay") 58 | task_list.return_value.tasks = [] 59 | 60 | main(tablefmt="simple") 61 | 62 | assert mock_snake.call_args == call("No scheduled tasks") 63 | -------------------------------------------------------------------------------- /pythonanywhere/project.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import uuid 3 | 4 | from pythonanywhere_core.exceptions import MissingCNAMEException 5 | from pythonanywhere_core.webapp import Webapp 6 | from snakesay import snakesay 7 | 8 | from pythonanywhere.exceptions import SanityException 9 | from pythonanywhere.virtualenvs import Virtualenv 10 | from pythonanywhere.launch_bash_in_virtualenv import launch_bash_in_virtualenv 11 | 12 | 13 | class Project: 14 | def __init__(self, domain, python_version): 15 | self.domain = domain 16 | self.python_version = python_version 17 | self.project_path = Path(f'~/{domain}').expanduser() 18 | self.virtualenv = Virtualenv(self.domain, self.python_version) 19 | self.wsgi_file_path = Path( 20 | f"/var/www/{domain.replace('.', '_')}_wsgi.py" 21 | ) 22 | self.webapp = Webapp(domain) 23 | 24 | def sanity_checks(self, nuke): 25 | self.webapp.sanity_checks(nuke=nuke) 26 | if nuke: 27 | return 28 | if self.virtualenv.path.exists(): 29 | raise SanityException( 30 | "You already have a virtualenv for {domain}.\n\n" 31 | "Use the --nuke option if you want to replace it.".format( 32 | domain=self.domain 33 | ) 34 | ) 35 | if self.project_path.exists(): 36 | raise SanityException( 37 | "You already have a project folder at {project_path}.\n\n" 38 | "Use the --nuke option if you want to replace it.".format( 39 | project_path=self.project_path 40 | ) 41 | ) 42 | 43 | def create_webapp(self, nuke): 44 | print(snakesay("Creating web app via API")) 45 | self.webapp.create(self.python_version, self.virtualenv.path, self.project_path, nuke=nuke) 46 | 47 | def reload_webapp(self): 48 | print(snakesay(f"Reloading web app on {self.domain}")) 49 | try: 50 | self.webapp.reload() 51 | except MissingCNAMEException as e: 52 | print(snakesay(str(e))) 53 | 54 | def add_static_file_mappings(self): 55 | print(snakesay("Adding static files mappings for /static/ and /media/")) 56 | self.webapp.add_default_static_files_mappings(self.project_path) 57 | 58 | def start_bash(self): 59 | print(snakesay('Starting Bash shell with activated virtualenv in project directory. Press Ctrl+D to exit.')) 60 | unique_id = str(uuid.uuid4()) 61 | launch_bash_in_virtualenv(self.virtualenv.path, unique_id, self.project_path) 62 | -------------------------------------------------------------------------------- /cli/students.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import typer 4 | 5 | from pythonanywhere.scripts_commons import get_logger 6 | from pythonanywhere.students import Students 7 | 8 | app = typer.Typer(no_args_is_help=True) 9 | 10 | 11 | def setup(quiet: bool) -> Students: 12 | logger = get_logger(set_info=True) 13 | if quiet: 14 | logger.disabled = True 15 | return Students() 16 | 17 | 18 | @app.command() 19 | def get( 20 | numbered: bool = typer.Option( 21 | False, "-n", "--numbered", help="Add ordering numbers." 22 | ), 23 | quiet: bool = typer.Option( 24 | False, "-q", "--quiet", help="Disable additional logging." 25 | ), 26 | raw: bool = typer.Option( 27 | False, "-a", "--raw", help="Print list of usernames from the API response." 28 | ), 29 | sort: bool = typer.Option(False, "-s", "--sort", help="Sort alphabetically"), 30 | sort_reverse: bool = typer.Option( 31 | False, "-r", "--reverse", help="Sort alphabetically in reverse order" 32 | ), 33 | ): 34 | """ 35 | Get list of student usernames. 36 | """ 37 | 38 | api = setup(quiet) 39 | students = api.get() 40 | 41 | if students is None or students == []: 42 | sys.exit(1) 43 | 44 | if raw: 45 | typer.echo(students) 46 | sys.exit() 47 | 48 | if sort or sort_reverse: 49 | students.sort(reverse=sort_reverse) 50 | 51 | for number, student in enumerate(students, start=1): 52 | line = f"{number:>3}. {student}" if numbered else student 53 | typer.echo(line) 54 | 55 | 56 | @app.command() 57 | def delete( 58 | student: str = typer.Argument(..., help="Username of a student to be removed."), 59 | quiet: bool = typer.Option( 60 | False, "-q", "--quiet", help="Disable additional logging." 61 | ), 62 | ): 63 | """ 64 | Remove a student from the students list. 65 | """ 66 | 67 | api = setup(quiet) 68 | result = 0 if api.delete(student) else 1 69 | sys.exit(result) 70 | 71 | 72 | @app.command() 73 | def holidays( 74 | quiet: bool = typer.Option( 75 | False, "-q", "--quiet", help="Disable additional logging." 76 | ), 77 | ): 78 | """ 79 | School's out for summer! School's out forever! (removes all students) 80 | """ 81 | 82 | api = setup(quiet) 83 | students = api.get() 84 | 85 | if not students: 86 | sys.exit(1) 87 | 88 | result = 0 if all(api.delete(s) for s in students) else 1 89 | if not quiet: 90 | typer.echo( 91 | [ 92 | f"Removed all {len(students)} students!", 93 | f"Something went wrong, try again", 94 | ][result] 95 | ) 96 | sys.exit(result) 97 | -------------------------------------------------------------------------------- /scripts/pa_install_webapp_ssl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3.8 2 | """Set the HTTPS certificate and private key for a website to the contents of two files, and reload the site. 3 | 4 | Usage: 5 | pa_set_webapp_ssl.py [--suppress-reload] 6 | 7 | Options: 8 | Domain name, eg www.mydomain.com 9 | The name of the file containing the combined certificate in PEM format (normally 10 | a number of blocks, each one starting "BEGIN CERTIFICATE" and ending "END CERTIFICATE") 11 | The name of the file containing the private key in PEM format (a file with one block, 12 | starting with something like "BEGIN PRIVATE KEY" and ending with something like 13 | "END PRIVATE KEY") 14 | --suppress-reload The website will need to be reloaded in order to activate the new certificate/key combination 15 | -- this happens by default, use this option to suppress it. 16 | """ 17 | 18 | import os 19 | import sys 20 | 21 | from docopt import docopt 22 | from pythonanywhere import __version__ 23 | from pythonanywhere_core.exceptions import MissingCNAMEException 24 | 25 | os.environ["PYTHONANYWHERE_CLIENT"] = f"helper-scripts/{__version__}" 26 | from pythonanywhere_core.webapp import Webapp 27 | from snakesay import snakesay 28 | 29 | 30 | def main(domain_name, certificate_file, private_key_file, suppress_reload): 31 | if not os.path.exists(certificate_file): 32 | print(f"Could not find certificate file {certificate_file}") 33 | sys.exit(1) 34 | with open(certificate_file, "r") as f: 35 | certificate = f.read() 36 | 37 | if not os.path.exists(private_key_file): 38 | print(f"Could not find private key file {private_key_file}") 39 | sys.exit(1) 40 | with open(private_key_file, "r") as f: 41 | private_key = f.read() 42 | 43 | webapp = Webapp(domain_name) 44 | webapp.set_ssl(certificate, private_key) 45 | if not suppress_reload: 46 | try: 47 | webapp.reload() 48 | except MissingCNAMEException as e: 49 | print(snakesay(str(e))) 50 | 51 | ssl_details = webapp.get_ssl_info() 52 | print(snakesay( 53 | "That's all set up now :-)\n" 54 | "Your new certificate will expire on {expiry:%d %B %Y},\n" 55 | "so shortly before then you should renew it\n" 56 | "and install the new certificate.".format( 57 | expiry=ssl_details["not_after"] 58 | ) 59 | )) 60 | 61 | 62 | if __name__ == '__main__': 63 | arguments = docopt(__doc__) 64 | main( 65 | arguments[''], 66 | arguments[''], arguments[''], 67 | suppress_reload=arguments.get('--suppress-reload') 68 | ) 69 | -------------------------------------------------------------------------------- /tests/test_virtualenvs.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from pathlib import Path 3 | from platform import python_version 4 | 5 | import pytest 6 | from pythonanywhere.virtualenvs import Virtualenv 7 | 8 | 9 | class TestVirtualenv: 10 | def test_path(self, virtualenvs_folder): 11 | v = Virtualenv("domain.com", "python.version") 12 | assert v.path == Path(virtualenvs_folder) / "domain.com" 13 | 14 | def test_create_uses_bash_and_sources_virtualenvwrapper(self, mock_subprocess, virtualenvs_folder): 15 | v = Virtualenv("domain.com", "3.8") 16 | v.create(nuke=False) 17 | args, kwargs = mock_subprocess.check_call.call_args 18 | command_list = args[0] 19 | assert command_list[:2] == ["bash", "-c"] 20 | assert command_list[2].startswith("source virtualenvwrapper.sh && mkvirtualenv") 21 | 22 | def test_create_calls_mkvirtualenv_with_python_version_and_domain(self, mock_subprocess, virtualenvs_folder): 23 | v = Virtualenv("domain.com", "3.8") 24 | v.create(nuke=False) 25 | args, kwargs = mock_subprocess.check_call.call_args 26 | command_list = args[0] 27 | bash_command = command_list[2] 28 | assert "mkvirtualenv --python=python3.8 domain.com" in bash_command 29 | 30 | def test_nuke_option_deletes_virtualenv(self, mock_subprocess, virtualenvs_folder): 31 | v = Virtualenv("domain.com", "3.8") 32 | v.create(nuke=True) 33 | args, kwargs = mock_subprocess.check_call.call_args 34 | command_list = args[0] 35 | assert command_list[:2] == ["bash", "-c"] 36 | assert command_list[2].startswith("source virtualenvwrapper.sh && rmvirtualenv domain.com") 37 | 38 | def test_install_pip_installs_each_package(self, mock_subprocess, virtualenvs_folder): 39 | v = Virtualenv("domain.com", "3.8") 40 | v.create(nuke=False) 41 | v.pip_install("package1 package2==1.1.2") 42 | args, kwargs = mock_subprocess.check_call.call_args_list[-1] 43 | command_list = args[0] 44 | pip_path = str(v.path / "bin/pip") 45 | assert command_list == [pip_path, "install", "package1", "package2==1.1.2"] 46 | 47 | @pytest.mark.slowtest 48 | def test_actually_installing_a_real_package(self, fake_home, virtualenvs_folder, running_python_version): 49 | v = Virtualenv("www.adomain.com", running_python_version) 50 | v.create(nuke=False) 51 | v.pip_install("aafigure") 52 | 53 | subprocess.check_call([str(v.path / "bin/python"), "-c" "import aafigure"]) 54 | 55 | @pytest.mark.slowtest 56 | def test_gets_version(self, fake_home, virtualenvs_folder, running_python_version): 57 | v = Virtualenv("www.adomain.com", running_python_version) 58 | v.create(nuke=False) 59 | v.pip_install("aafigure==0.6") 60 | 61 | assert v.get_version("aafigure") == "0.6" 62 | -------------------------------------------------------------------------------- /tests/test_pa_update_scheduled_task.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | from datetime import datetime 3 | from unittest.mock import call 4 | 5 | import pytest 6 | from scripts.pa_update_scheduled_task import main 7 | 8 | 9 | @pytest.fixture() 10 | def args(): 11 | yield { 12 | "task_id": 42, 13 | "command": None, 14 | "daily": None, 15 | "disable": None, 16 | "enable": None, 17 | "hour": None, 18 | "hourly": None, 19 | "minute": None, 20 | "porcelain": None, 21 | "quiet": None, 22 | "toggle": None, 23 | } 24 | 25 | 26 | @pytest.fixture() 27 | def task_from_id(mocker): 28 | user = getpass.getuser() 29 | specs = { 30 | "can_enable": False, 31 | "command": "echo foo", 32 | "enabled": False, 33 | "hour": 10, 34 | "interval": "daily", 35 | "logfile": f"/user/{user}/files/foo", 36 | "minute": 23, 37 | "printable_time": "10:23", 38 | "task_id": 42, 39 | "username": user, 40 | } 41 | task = mocker.patch("scripts.pa_update_scheduled_task.get_task_from_id") 42 | for spec, value in specs.items(): 43 | setattr(task.return_value, spec, value) 44 | yield task 45 | 46 | 47 | @pytest.mark.tasks 48 | class TestUpdateScheduledTask: 49 | def test_enables_task_and_sets_porcelain(self, task_from_id, args): 50 | args.update({"enable": True, "porcelain": True}) 51 | 52 | main(**args) 53 | 54 | assert task_from_id.return_value.update_schedule.call_args == call( 55 | {"enabled": True}, porcelain="porcelain" 56 | ) 57 | assert task_from_id.return_value.update_schedule.call_count == 1 58 | 59 | def test_turns_off_snakesay(self, mocker, args, task_from_id): 60 | mock_logger = mocker.patch("scripts.pa_update_scheduled_task.get_logger") 61 | args.update({"quiet": True}) 62 | 63 | main(**args) 64 | 65 | assert mock_logger.return_value.setLevel.call_count == 0 66 | 67 | def test_warns_when_task_update_schedule_raises(self, task_from_id, args, mocker): 68 | mock_logger = mocker.patch("scripts.pa_update_scheduled_task.get_logger") 69 | task_from_id.return_value.update_schedule.side_effect = Exception("error") 70 | mock_snake = mocker.patch("scripts.pa_update_scheduled_task.snakesay") 71 | 72 | main(**args) 73 | 74 | assert mock_snake.call_args == call("error") 75 | assert mock_logger.return_value.warning.call_args == call(mock_snake.return_value) 76 | 77 | def test_ensures_proper_daily_params(self, task_from_id, args): 78 | args.update({"hourly": True}) 79 | 80 | main(**args) 81 | 82 | assert task_from_id.return_value.update_schedule.call_args == call( 83 | {"interval": "hourly"}, porcelain=None 84 | ) 85 | 86 | def test_ensures_proper_hourly_params(self, task_from_id, args, mocker): 87 | mock_datetime = mocker.patch("scripts.pa_update_scheduled_task.datetime") 88 | args.update({"daily": True}) 89 | 90 | main(**args) 91 | 92 | assert task_from_id.return_value.update_schedule.call_args == call( 93 | {"interval": "daily", "hour": mock_datetime.now.return_value.hour}, porcelain=None 94 | ) 95 | -------------------------------------------------------------------------------- /tests/test_pa_get_scheduled_task_specs.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | from unittest.mock import call 3 | 4 | import pytest 5 | from scripts.pa_get_scheduled_task_specs import main 6 | 7 | 8 | @pytest.fixture() 9 | def args(): 10 | yield { 11 | "task_id": 42, 12 | "command": None, 13 | "enabled": None, 14 | "hour": None, 15 | "interval": None, 16 | "logfile": None, 17 | "minute": None, 18 | "printable_time": None, 19 | "snake": None, 20 | "no_spec": None, 21 | } 22 | 23 | 24 | @pytest.fixture() 25 | def task_from_id(mocker): 26 | user = getpass.getuser() 27 | specs = { 28 | "can_enable": False, 29 | "command": "echo foo", 30 | "enabled": True, 31 | "hour": 10, 32 | "interval": "daily", 33 | "logfile": f"/user/{user}/files/foo", 34 | "minute": 23, 35 | "printable_time": "10:23", 36 | "task_id": 42, 37 | "username": user, 38 | } 39 | task = mocker.patch("scripts.pa_get_scheduled_task_specs.get_task_from_id") 40 | for spec, value in specs.items(): 41 | setattr(task.return_value, spec, value) 42 | yield task 43 | 44 | 45 | @pytest.mark.tasks 46 | class TestGetScheduledTaskSpecs: 47 | def test_prints_all_specs_using_tabulate(self, task_from_id, args, mocker): 48 | mock_tabulate = mocker.patch("scripts.pa_get_scheduled_task_specs.tabulate") 49 | 50 | main(**args) 51 | 52 | assert task_from_id.call_args == call(42) 53 | assert mock_tabulate.call_args == call( 54 | [ 55 | ["command", "echo foo"], 56 | ["enabled", True], 57 | ["hour", 10], 58 | ["interval", "daily"], 59 | ["logfile", f"/user/{getpass.getuser()}/files/foo"], 60 | ["minute", 23], 61 | ["printable_time", "10:23"], 62 | ], 63 | tablefmt="simple", 64 | ) 65 | 66 | def test_prints_all_specs_using_snakesay(self, task_from_id, args, mocker): 67 | args.update({"snake": True}) 68 | mock_snakesay = mocker.patch("scripts.pa_get_scheduled_task_specs.snakesay") 69 | 70 | main(**args) 71 | 72 | assert task_from_id.call_args == call(42) 73 | expected = ( 74 | "Task 42 specs: : echo foo, : True, : 10, : daily, " 75 | ": /user/{}/files/foo, : 23, : 10:23".format( 76 | getpass.getuser() 77 | ) 78 | ) 79 | assert mock_snakesay.call_args == call(expected) 80 | 81 | def test_logs_only_value(self, task_from_id, args, mocker): 82 | args.update({"no_spec": True, "printable_time": True}) 83 | mock_logger = mocker.patch("scripts.pa_get_scheduled_task_specs.get_logger") 84 | 85 | main(**args) 86 | 87 | assert task_from_id.call_args == call(42) 88 | assert mock_logger.call_args == call(set_info=True) 89 | assert mock_logger.return_value.info.call_args == call("10:23") 90 | 91 | def test_prints_one_spec(self, task_from_id, args, mocker): 92 | args.update({"command": True}) 93 | mock_tabulate = mocker.patch("scripts.pa_get_scheduled_task_specs.tabulate") 94 | 95 | main(**args) 96 | 97 | assert task_from_id.call_args == call(42) 98 | assert mock_tabulate.call_args == call([["command", "echo foo"]], tablefmt="simple") 99 | -------------------------------------------------------------------------------- /scripts/pa_install_webapp_letsencrypt_ssl.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3.8 2 | """Set the HTTPS certificate and private key for a website, assuming that these have been generated by the dehydrated 3 | script that gets them from Let's Encrypt, and that they're in the standard place. This script should normally only 4 | be run on PythonAnywhere. 5 | 6 | Usage: 7 | pa_install_webapp_letsencrypt_ssl.py [--suppress-reload] 8 | 9 | Options: 10 | Domain name, eg www.mydomain.com 11 | --suppress-reload The website will need to be reloaded in order to activate the new certificate/key combination 12 | -- this happens by default, use this option to suppress it. 13 | """ 14 | 15 | from docopt import docopt 16 | from os.path import expanduser 17 | import os 18 | import sys 19 | 20 | from pythonanywhere import __version__ 21 | from pythonanywhere_core.exceptions import MissingCNAMEException 22 | 23 | os.environ["PYTHONANYWHERE_CLIENT"] = f"helper-scripts/{__version__}" 24 | from pythonanywhere_core.webapp import Webapp 25 | from snakesay import snakesay 26 | 27 | 28 | def main(domain_name, suppress_reload): 29 | homedir = expanduser("~") 30 | possible_paths = ( 31 | os.path.join(homedir, 'letsencrypt', domain_name), 32 | os.path.join(homedir, 'letsencrypt', 'certs', domain_name), 33 | ) 34 | done = False 35 | for path in possible_paths: 36 | certificate_file = os.path.join(path, 'fullchain.pem') 37 | private_key_file = os.path.join(path, 'privkey.pem') 38 | if os.path.exists(certificate_file) and os.path.exists(private_key_file): 39 | with open(certificate_file, "r") as f: 40 | certificate = f.read() 41 | with open(private_key_file, "r") as f: 42 | private_key = f.read() 43 | 44 | webapp = Webapp(domain_name) 45 | webapp.set_ssl(certificate, private_key) 46 | if not suppress_reload: 47 | try: 48 | webapp.reload() 49 | except MissingCNAMEException as e: 50 | print(snakesay(str(e))) 51 | 52 | ssl_details = webapp.get_ssl_info() 53 | print( 54 | snakesay( 55 | "This method of handling Let's Encrypt certs\n" 56 | "**************IS DEPRECATED.**************\n\n" 57 | "You can now have a Let's Encrypt certificate managed by PythonAnywhere.\n" 58 | "We handle all the details of getting it, installing it,\n" 59 | "and managing renewals for you. So you don't need to do\n" 60 | "any of the stuff below any more.\n\n" 61 | "Anyway, All is set up for now. \n" 62 | "Your new certificate will expire on {expiry:%d %B %Y},\n" 63 | "so shortly before then you should switch to the new system\n" 64 | "(see https://help.pythonanywhere.com/pages/HTTPSSetup/)\n" 65 | "".format( 66 | expiry=ssl_details["not_after"] 67 | ) 68 | ) 69 | ) 70 | 71 | done = True 72 | break 73 | 74 | if not done: 75 | print(f"Could not find certificate or key files (looked in {possible_paths})") 76 | sys.exit(2) 77 | 78 | 79 | if __name__ == '__main__': 80 | arguments = docopt(__doc__) 81 | main( 82 | arguments[''], 83 | suppress_reload=arguments.get('--suppress-reload') 84 | ) 85 | -------------------------------------------------------------------------------- /tests/test_students.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from unittest.mock import call 3 | 4 | from pythonanywhere_core.students import StudentsAPI 5 | from pythonanywhere.students import Students 6 | 7 | 8 | @pytest.mark.students 9 | class TestStudentsInit: 10 | def test_instantiates_correctly(self): 11 | students = Students() 12 | assert isinstance(students.api, StudentsAPI) 13 | 14 | 15 | @pytest.mark.students 16 | class TestStudentsGet: 17 | def test_returns_list_of_usernames_when_found_in_api_response(self, mocker): 18 | mock_students_api_get = mocker.patch("pythonanywhere.students.StudentsAPI.get") 19 | student_usernames = ["student1", "student2"] 20 | mock_students_api_get.return_value = { 21 | "students": [{"username": s} for s in student_usernames] 22 | } 23 | 24 | result = Students().get() 25 | 26 | assert mock_students_api_get.called 27 | assert result == student_usernames 28 | 29 | def test_returns_empty_list_when_no_usernames_found_in_api_response(self, mocker): 30 | mock_students_api_get = mocker.patch("pythonanywhere.students.StudentsAPI.get") 31 | mock_students_api_get.return_value = {"students": []} 32 | 33 | result = Students().get() 34 | 35 | assert mock_students_api_get.called 36 | assert result == [] 37 | 38 | @pytest.mark.parametrize( 39 | "api_response,expected_wording", 40 | [ 41 | ({"students": [{"username": "one"}, {"username": "two"}]}, "You have 2 students!"), 42 | ({"students": [{"username": "one"}]}, "You have 1 student!"), 43 | ] 44 | ) 45 | def test_uses_correct_grammar_in_log_messages(self, mocker, api_response, expected_wording): 46 | mock_students_api_get = mocker.patch("pythonanywhere.students.StudentsAPI.get") 47 | mock_students_api_get.return_value = api_response 48 | mock_snake = mocker.patch("pythonanywhere.students.snakesay") 49 | mock_info = mocker.patch("pythonanywhere.students.logger.info") 50 | 51 | Students().get() 52 | 53 | assert mock_snake.call_args == call(expected_wording) 54 | assert mock_info.call_args == call(mock_snake.return_value) 55 | 56 | 57 | @pytest.mark.students 58 | class TestStudentsDelete: 59 | def test_returns_true_and_informs_when_student_removed(self, mocker): 60 | mock_students_api_delete = mocker.patch("pythonanywhere.students.StudentsAPI.delete") 61 | mock_students_api_delete.return_value = True 62 | mock_snake = mocker.patch("pythonanywhere.students.snakesay") 63 | mock_info = mocker.patch("pythonanywhere.students.logger.info") 64 | student = "badstudent" 65 | 66 | result = Students().delete(student) 67 | 68 | assert mock_snake.call_args == call(f"{student!r} removed from the list of students!") 69 | assert mock_info.call_args == call(mock_snake.return_value) 70 | assert result is True 71 | 72 | def test_returns_false_and_warns_when_student_not_removed(self, mocker): 73 | mock_students_api_delete = mocker.patch("pythonanywhere.students.StudentsAPI.delete") 74 | mock_students_api_delete.side_effect = Exception("error msg") 75 | mock_students_api_delete.return_value = False 76 | mock_snake = mocker.patch("pythonanywhere.students.snakesay") 77 | mock_warning = mocker.patch("pythonanywhere.students.logger.warning") 78 | student = "badstudent" 79 | 80 | result = Students().delete(student) 81 | 82 | assert mock_snake.call_args == call("error msg") 83 | assert mock_warning.call_args == call(mock_snake.return_value) 84 | assert result is False 85 | -------------------------------------------------------------------------------- /tests/test_pa_start_django_webapp_with_virtualenv.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | from unittest.mock import call, patch, sentinel 4 | 5 | import pytest 6 | from scripts.pa_start_django_webapp_with_virtualenv import main 7 | 8 | 9 | def test_calls_all_stuff_in_right_order(mocker): 10 | mock_DjangoProject = mocker.patch( 11 | "scripts.pa_start_django_webapp_with_virtualenv.DjangoProject" 12 | ) 13 | 14 | main( 15 | sentinel.domain, sentinel.django_version, sentinel.python_version, nuke=sentinel.nuke 16 | ) 17 | assert mock_DjangoProject.call_args == call(sentinel.domain, sentinel.python_version) 18 | assert mock_DjangoProject.return_value.method_calls == [ 19 | call.sanity_checks(nuke=sentinel.nuke), 20 | call.create_virtualenv(sentinel.django_version, nuke=sentinel.nuke), 21 | call.run_startproject(nuke=sentinel.nuke), 22 | call.find_django_files(), 23 | call.update_settings_file(), 24 | call.run_collectstatic(), 25 | call.create_webapp(nuke=sentinel.nuke), 26 | call.add_static_file_mappings(), 27 | call.update_wsgi_file(), 28 | call.reload_webapp(), 29 | ] 30 | 31 | @pytest.mark.slowtest 32 | def test_actually_creates_django_project_in_virtualenv_with_hacked_settings_and_static_files( 33 | fake_home, virtualenvs_folder, api_token, running_python_version, new_django_version 34 | ): 35 | with patch("scripts.pa_start_django_webapp_with_virtualenv.DjangoProject.update_wsgi_file"): 36 | with patch("pythonanywhere_core.webapp.call_api"): 37 | main("mydomain.com", new_django_version, running_python_version, nuke=False) 38 | 39 | output_django_version = ( 40 | subprocess.check_output( 41 | [ 42 | str(virtualenvs_folder / "mydomain.com/bin/python"), 43 | "-c" "import django; print(django.get_version())", 44 | ] 45 | ) 46 | .decode() 47 | .strip() 48 | ) 49 | assert output_django_version == new_django_version 50 | 51 | with (fake_home / "mydomain.com/mysite/settings.py").open() as f: 52 | lines = f.read().split("\n") 53 | assert "MEDIA_ROOT = Path(BASE_DIR / 'media')" in lines 54 | assert "ALLOWED_HOSTS = ['mydomain.com']" in lines 55 | 56 | assert "base.css" in os.listdir(str(fake_home / "mydomain.com/static/admin/css")) 57 | 58 | @pytest.mark.slowtest 59 | def test_nuke_option_lets_you_run_twice( 60 | fake_home, virtualenvs_folder, api_token, running_python_version, new_django_version, old_django_version 61 | ): 62 | 63 | with patch("scripts.pa_start_django_webapp_with_virtualenv.DjangoProject.update_wsgi_file"): 64 | with patch("pythonanywhere_core.webapp.call_api"): 65 | main("mydomain.com", old_django_version, running_python_version, nuke=False) 66 | main("mydomain.com", new_django_version, running_python_version, nuke=True) 67 | 68 | django_version = ( 69 | subprocess.check_output( 70 | [ 71 | str(virtualenvs_folder / "mydomain.com/bin/python"), 72 | "-c" "import django; print(django.get_version())", 73 | ] 74 | ) 75 | .decode() 76 | .strip() 77 | ) 78 | assert django_version == new_django_version 79 | 80 | 81 | def test_shows_sanity_checks_message(mocker): 82 | mock_print = mocker.patch('builtins.print') 83 | mocker.patch("scripts.pa_start_django_webapp_with_virtualenv.DjangoProject") 84 | 85 | main(sentinel.domain, sentinel.django_version, sentinel.python_version, nuke=sentinel.nuke) 86 | 87 | print_calls = [str(call) for call in mock_print.call_args_list] 88 | assert any("Running sanity checks" in str(call) for call in print_calls) 89 | -------------------------------------------------------------------------------- /cli/django.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import typer 4 | from snakesay import snakesay 5 | 6 | from pythonanywhere.django_project import DjangoProject 7 | from pythonanywhere.utils import ensure_domain 8 | 9 | app = typer.Typer(no_args_is_help=True) 10 | 11 | 12 | @app.command() 13 | def autoconfigure( 14 | repo_url: str = typer.Argument(..., help="url of remote git repository of your django project"), 15 | branch: str = typer.Option( 16 | "None", 17 | "-b", 18 | "--branch", 19 | help="Branch name in case of multiple branches", 20 | ), 21 | domain_name: str = typer.Option( 22 | "your-username.pythonanywhere.com", 23 | "-d", 24 | "--domain", 25 | help="Domain name, eg www.mydomain.com", 26 | ), 27 | python_version: str = typer.Option( 28 | "3.8", 29 | "-p", 30 | "--python-version", 31 | help="Python version, eg '3.9'", 32 | ), 33 | nuke: bool = typer.Option( 34 | False, 35 | help="*Irrevocably* delete any existing web app config on this domain. Irrevocably.", 36 | ), 37 | ): 38 | """ 39 | Autoconfigure a Django project from on a github URL. 40 | 41 | \b 42 | - downloads the repo 43 | - creates a virtualenv and installs django (or detects a requirements.txt if available) 44 | - creates webapp via api 45 | - creates django wsgi configuration file 46 | - adds static files config 47 | """ 48 | domain = ensure_domain(domain_name) 49 | project = DjangoProject(domain, python_version) 50 | typer.echo(snakesay("Running sanity checks")) 51 | project.sanity_checks(nuke=nuke) 52 | project.download_repo(repo_url, nuke=nuke), 53 | project.ensure_branch(branch), 54 | project.create_virtualenv(nuke=nuke) 55 | project.create_webapp(nuke=nuke) 56 | project.add_static_file_mappings() 57 | project.find_django_files() 58 | project.update_wsgi_file() 59 | project.update_settings_file() 60 | project.run_collectstatic() 61 | project.run_migrate() 62 | project.reload_webapp() 63 | typer.echo(snakesay(f"All done! Your site is now live at https://{domain_name}\n")) 64 | project.start_bash() 65 | 66 | 67 | @app.command() 68 | def start( 69 | domain_name: str = typer.Option( 70 | "your-username.pythonanywhere.com", 71 | "-d", 72 | "--domain", 73 | help="Domain name, eg www.mydomain.com", 74 | ), 75 | django_version: str = typer.Option( 76 | "latest", 77 | "-j", 78 | "--django-version", 79 | help="Django version, eg '3.1.2'", 80 | ), 81 | python_version: str = typer.Option( 82 | "3.6", 83 | "-p", 84 | "--python-version", 85 | help="Python version, eg '3.8'", 86 | ), 87 | nuke: bool = typer.Option( 88 | False, 89 | help="*Irrevocably* delete any existing web app config on this domain. Irrevocably.", 90 | ), 91 | ): 92 | """ 93 | Create a new Django webapp with a virtualenv. Defaults to 94 | your free domain, the latest version of Django and Python 3.6 95 | """ 96 | domain = ensure_domain(domain_name) 97 | project = DjangoProject(domain, python_version) 98 | typer.echo(snakesay("Running sanity checks")) 99 | project.sanity_checks(nuke=nuke) 100 | project.create_virtualenv(django_version, nuke=nuke) 101 | project.run_startproject(nuke=nuke) 102 | project.find_django_files() 103 | project.update_settings_file() 104 | project.run_collectstatic() 105 | project.create_webapp(nuke=nuke) 106 | project.add_static_file_mappings() 107 | 108 | project.update_wsgi_file() 109 | 110 | project.reload_webapp() 111 | 112 | typer.echo(snakesay(f"All done! Your site is now live at https://{domain}")) 113 | -------------------------------------------------------------------------------- /tests/test_pa_delete_scheduled_task.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import call, Mock 2 | 3 | import pytest 4 | from scripts.pa_delete_scheduled_task import main, _delete_all, _delete_by_id 5 | 6 | 7 | @pytest.fixture 8 | def task_list(mocker): 9 | mock_task_list = mocker.patch("scripts.pa_delete_scheduled_task.TaskList") 10 | mock_task1 = Mock(task_id=1) 11 | mock_task2 = Mock(task_id=2) 12 | mock_task_list.return_value.tasks = [mock_task1, mock_task2] 13 | return mock_task_list 14 | 15 | 16 | @pytest.mark.tasks 17 | class TestDeleteScheduledTaskDeleteALL: 18 | def test_deletes_all_tasks_with_user_permission(self, task_list, mocker): 19 | mock_input = mocker.patch("scripts.pa_delete_scheduled_task.input") 20 | mock_input.return_value = "y" 21 | 22 | _delete_all(force=False) 23 | 24 | assert mock_input.call_args == call( 25 | "This will irrevocably delete all your tasks, proceed? [y/N] " 26 | ) 27 | assert task_list.call_count == 1 28 | for task in task_list.return_value.tasks: 29 | assert task.method_calls == [call.delete_schedule()] 30 | 31 | def test_exits_when_user_changes_mind(self, task_list, mocker): 32 | mock_input = mocker.patch("scripts.pa_delete_scheduled_task.input") 33 | mock_input.return_value = "n" 34 | 35 | _delete_all(force=False) 36 | 37 | assert task_list.call_count == 0 38 | 39 | def test_deletes_all_when_forced(self, task_list, mocker): 40 | mock_input = mocker.patch("scripts.pa_delete_scheduled_task.input") 41 | 42 | _delete_all(force=True) 43 | 44 | assert mock_input.call_count == 0 45 | assert task_list.call_count == 1 46 | for task in task_list.return_value.tasks: 47 | assert task.method_calls == [call.delete_schedule()] 48 | 49 | 50 | @pytest.mark.tasks 51 | class TestDeleteScheduledTaskDeleteById: 52 | def test_deletes_one_task(self, mocker): 53 | mock_task_from_id = mocker.patch("scripts.pa_delete_scheduled_task.get_task_from_id") 54 | 55 | _delete_by_id(id_numbers=[42]) 56 | 57 | assert mock_task_from_id.call_args == call(42, no_exit=True) 58 | assert mock_task_from_id.return_value.method_calls == [call.delete_schedule()] 59 | 60 | def test_deletes_some_tasks(self, mocker): 61 | mock_task_from_id = mocker.patch("scripts.pa_delete_scheduled_task.get_task_from_id") 62 | 63 | _delete_by_id(id_numbers=[24, 42]) 64 | 65 | assert mock_task_from_id.call_count == 2 66 | 67 | 68 | @pytest.mark.tasks 69 | class TestDeleteScheduledTaskMain: 70 | def test_sets_logger(self, mocker): 71 | mock_get_logger = mocker.patch("scripts.pa_delete_scheduled_task.get_logger") 72 | mock_delete_all = mocker.patch("scripts.pa_delete_scheduled_task._delete_all") 73 | mock_delete_by_id = mocker.patch("scripts.pa_delete_scheduled_task._delete_by_id") 74 | 75 | main(id_numbers=[1], nuke=False, force=None) 76 | 77 | mock_get_logger.call_count == 1 78 | 79 | def test_calls_delete_all(self, mocker): 80 | mock_delete_all = mocker.patch("scripts.pa_delete_scheduled_task._delete_all") 81 | mock_delete_by_id = mocker.patch("scripts.pa_delete_scheduled_task._delete_by_id") 82 | 83 | main(id_numbers=[], nuke=True, force=True) 84 | 85 | assert mock_delete_all.call_count == 1 86 | assert mock_delete_all.call_args == call(True) 87 | assert mock_delete_by_id.call_count == 0 88 | 89 | def test_calls_delete_by_id(self, mocker): 90 | mock_delete_all = mocker.patch("scripts.pa_delete_scheduled_task._delete_all") 91 | mock_delete_by_id = mocker.patch("scripts.pa_delete_scheduled_task._delete_by_id") 92 | 93 | main(id_numbers=[24, 42], nuke=False, force=None) 94 | 95 | assert mock_delete_all.call_count == 0 96 | assert mock_delete_by_id.call_count == 1 97 | assert mock_delete_by_id.call_args == call([24, 42]) 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Build Status](https://github.com/pythonanywhere/helper_scripts/actions/workflows/tests.yaml/badge.svg) 2 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) 3 | [![PyPI](https://img.shields.io/pypi/v/pythonanywhere)](https://pypi.org/project/pythonanywhere/) 4 | [![Downloads](https://pepy.tech/badge/pythonanywhere)](https://pepy.tech/project/pythonanywhere) 5 | 6 | # PythonAnywhere cli tool 7 | 8 | `pa` is a single command to manage PythonAnywhere services. 9 | 10 | It is designed to be run from PythonAnywhere consoles, but many subcommands can be executed directly 11 | from your own machine (see [usage](#Usage) below). 12 | 13 | ## Installing 14 | ### On PythonAnywhere 15 | In a PythonAnywhere Bash console, run: 16 | 17 | pip3.10 install --user pythonanywhere 18 | 19 | If there is no `python3.10` on your PythonAnywhere account, 20 | you should upgrade your account to the newest system image. 21 | See [here](https://help.pythonanywhere.com/pages/ChangingSystemImage) how to do that. 22 | `pa` works with python 3.8, 3.9, and 3.10 but we recommend using the latest system image. 23 | 24 | ### On your own machine 25 | Install the `pythonanywhere` package from [PyPI](https://pypi.org/project/pythonanywhere/). 26 | We recommend using `pipx` if you want to use it only as a cli tool, or a virtual environment 27 | if you want to use a programmatic interface in your own code. 28 | 29 | ## Usage 30 | 31 | There are two ways to use the package. You can just run the scripts or use the underlying api wrappers directly in your scripts. 32 | 33 | ### Command line interface 34 | 35 | ``` 36 | pa [OPTIONS] COMMAND [ARGS]... 37 | 38 | Options: 39 | --install-completion Install completion for the current shell. 40 | --show-completion Show completion for the current shell, to copy it or 41 | customize the installation. 42 | -h, --help Show this message and exit. 43 | 44 | Commands: 45 | django Makes Django Girls tutorial projects deployment easy 46 | path Perform some operations on files 47 | schedule Manage scheduled tasks 48 | students Perform some operations on students 49 | webapp Everything for web apps: use this if you're not using our experimental features 50 | website EXPERIMENTAL: create and manage ASGI websites 51 | 52 | ``` 53 | 54 | ### Running `pa` on your local machine 55 | 56 | `pa` expects the presence of some environment variables that are provided when you run your code in a PythonAnywere console. 57 | You need to provide them if you run `pa` on your local machine. 58 | 59 | `API_TOKEN` -- you need to set this to allow `pa` to connect to the [PythonAnywere API](https://help.pythonanywhere.com/pages/API). 60 | To get an API token, log into PythonAnywhere and go to the "Account" page using the link at the top right. 61 | Click on the "API token" tab, and click the "Create a new API token" button to get your token. 62 | 63 | `PYTHONANYWHERE_SITE` is used to connect to PythonAnywhere API and defaults to `www.pythonanywhere.com`, 64 | but you may need to set it to `eu.pythonanywhere.com` if you use our EU site. 65 | 66 | If your username on PythonAnywhere is different from the username on your local machine, 67 | you may need to set `USER` for the environment you run `pa` in. 68 | 69 | ### Programmatic usage in your code 70 | 71 | Take a look at the [`pythonanywhere.task`](https://github.com/pythonanywhere/helper_scripts/blob/master/pythonanywhere/task.py) 72 | module and docstrings of `pythonanywhere.task.Task` class and its methods. 73 | 74 | ### Legacy scripts 75 | 76 | Some legacy [scripts](https://github.com/pythonanywhere/helper_scripts/blob/master/legacy.md) (separate for each action) are still available. 77 | 78 | ## Contributing 79 | 80 | Pull requests are welcome! You'll find tests in the [tests](https://github.com/pythonanywhere/helper_scripts/blob/master/tests) folder... 81 | 82 | # prep your dev environment 83 | mkvirtualenv --python=python3.10 helper_scripts 84 | pip install -r requirements.txt 85 | pip install -e . 86 | 87 | # running the tests: 88 | pytest 89 | 90 | # make sure that the code that you have written is well tested: 91 | pytest --cov=pythonanywhere --cov=scripts 92 | 93 | # to just run the fast tests: 94 | pytest -m 'not slowtest' -v 95 | 96 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | import tempfile 4 | from getpass import getuser 5 | from pathlib import Path 6 | from platform import python_version 7 | from unittest.mock import Mock, patch 8 | 9 | import psutil 10 | import pytest 11 | 12 | import responses 13 | 14 | 15 | def _get_temp_dir(): 16 | return Path(tempfile.mkdtemp()) 17 | 18 | 19 | @pytest.fixture(scope="session") 20 | def local_pip_cache(request): 21 | previous_cache = request.config.cache.get("pythonanywhere/pip-cache", None) 22 | if previous_cache: 23 | return Path(previous_cache) 24 | else: 25 | new_cache = _get_temp_dir() 26 | request.config.cache.set("pythonanywhere/pip-cache", str(new_cache)) 27 | return new_cache 28 | 29 | 30 | @pytest.fixture 31 | def fake_home(local_pip_cache): 32 | tempdir = _get_temp_dir() 33 | cache_dir = tempdir / ".cache" 34 | cache_dir.mkdir() 35 | (cache_dir / "pip").symlink_to(local_pip_cache) 36 | 37 | old_home = os.environ["HOME"] 38 | old_home_contents = set(Path(old_home).iterdir()) 39 | 40 | os.environ["HOME"] = str(tempdir) 41 | yield tempdir 42 | os.environ["HOME"] = old_home 43 | shutil.rmtree(str(tempdir), ignore_errors=True) 44 | 45 | new_stuff = set(Path(old_home).iterdir()) - old_home_contents 46 | if new_stuff: 47 | raise Exception(f"home mocking failed somewehere: {new_stuff}, {tempdir}") 48 | 49 | 50 | @pytest.fixture 51 | def virtualenvs_folder(): 52 | actual_virtualenvs = Path(f"/home/{getuser()}/.virtualenvs") 53 | if actual_virtualenvs.is_dir(): 54 | old_virtualenvs = set(Path(actual_virtualenvs).iterdir()) 55 | else: 56 | old_virtualenvs = {} 57 | 58 | tempdir = _get_temp_dir() 59 | old_workon = os.environ.get("WORKON_HOME") 60 | os.environ["WORKON_HOME"] = str(tempdir) 61 | yield tempdir 62 | if old_workon: 63 | os.environ["WORKON_HOME"] = old_workon 64 | else: 65 | del os.environ["WORKON_HOME"] 66 | shutil.rmtree(str(tempdir), ignore_errors=True) 67 | 68 | if actual_virtualenvs.is_dir(): 69 | new_envs = set(actual_virtualenvs.iterdir()) - set(old_virtualenvs) 70 | if new_envs: 71 | raise Exception(f"virtualenvs path mocking failed somewehere: {new_envs}, {tempdir}") 72 | 73 | 74 | @pytest.fixture 75 | def mock_subprocess(): 76 | mock = Mock() 77 | with patch("subprocess.check_call") as mock_check_call: 78 | mock.check_call = mock_check_call 79 | with patch("subprocess.check_output") as mock_check_output: 80 | mock.check_output = mock_check_output 81 | yield mock 82 | 83 | 84 | @pytest.fixture 85 | def api_responses(monkeypatch): 86 | with responses.RequestsMock() as r: 87 | yield r 88 | 89 | 90 | @pytest.fixture(scope="function") 91 | def api_token(): 92 | old_token = os.environ.get("API_TOKEN") 93 | token = "sekrit.token" 94 | os.environ["API_TOKEN"] = token 95 | 96 | yield token 97 | 98 | if old_token is None: 99 | del os.environ["API_TOKEN"] 100 | else: 101 | os.environ["API_TOKEN"] = old_token 102 | 103 | 104 | @pytest.fixture(scope="function") 105 | def no_api_token(): 106 | if "API_TOKEN" not in os.environ: 107 | yield 108 | 109 | else: 110 | old_token = os.environ["API_TOKEN"] 111 | del os.environ["API_TOKEN"] 112 | yield 113 | os.environ["API_TOKEN"] = old_token 114 | 115 | 116 | @pytest.fixture 117 | def process_killer(): 118 | to_kill = [] 119 | yield to_kill 120 | for p in to_kill: 121 | for child in psutil.Process(p.pid).children(): 122 | child.kill() 123 | p.kill() 124 | 125 | @pytest.fixture 126 | def running_python_version(): 127 | return ".".join(python_version().split(".")[:2]) 128 | 129 | @pytest.fixture 130 | def new_django_version(running_python_version): 131 | if running_python_version in ["3.10", "3.11", "3.12", "3.13"]: 132 | return "5.1.3" 133 | else: 134 | return "4.2.16" 135 | 136 | @pytest.fixture 137 | def old_django_version(running_python_version): 138 | if running_python_version in ["3.10", "3.11", "3.12", "3.13"]: 139 | return "5.1.2" 140 | else: 141 | return "4.2.15" 142 | -------------------------------------------------------------------------------- /tests/test_pa_autoconfigure_django.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import call, patch 2 | import os 3 | import pytest 4 | import subprocess 5 | import requests 6 | import time 7 | 8 | from scripts.pa_autoconfigure_django import main 9 | from tests.conftest import new_django_version 10 | 11 | 12 | class TestMain: 13 | 14 | def test_calls_all_stuff_in_right_order(self): 15 | with patch('scripts.pa_autoconfigure_django.DjangoProject') as mock_DjangoProject: 16 | main('repo.url', 'foo', 'www.domain.com', 'python.version', nuke='nuke option') 17 | assert mock_DjangoProject.call_args == call('www.domain.com', 'python.version') 18 | assert mock_DjangoProject.return_value.method_calls == [ 19 | call.sanity_checks(nuke='nuke option'), 20 | call.download_repo('repo.url', nuke='nuke option'), 21 | call.ensure_branch("foo"), 22 | call.create_virtualenv(nuke='nuke option'), 23 | call.create_webapp(nuke='nuke option'), 24 | call.add_static_file_mappings(), 25 | call.find_django_files(), 26 | call.update_wsgi_file(), 27 | call.update_settings_file(), 28 | call.run_collectstatic(), 29 | call.run_migrate(), 30 | call.reload_webapp(), 31 | call.start_bash(), 32 | ] 33 | 34 | @pytest.mark.slowtest 35 | def test_actually_works_against_example_repo( 36 | self, fake_home, virtualenvs_folder, api_token, process_killer, running_python_version, new_django_version 37 | ): 38 | git_ref = "non-nested-old" if running_python_version in ["3.8", "3.9"] else "master" 39 | repo = 'https://github.com/pythonanywhere/example-django-project.git' 40 | domain = 'mydomain.com' 41 | with patch('scripts.pa_autoconfigure_django.DjangoProject.update_wsgi_file'): 42 | with patch('scripts.pa_autoconfigure_django.DjangoProject.start_bash'): 43 | with patch('pythonanywhere_core.webapp.call_api'): 44 | main( 45 | repo_url=repo, 46 | branch=git_ref, 47 | domain=domain, 48 | python_version=running_python_version, 49 | nuke=False 50 | ) 51 | 52 | expected_virtualenv = virtualenvs_folder / domain 53 | expected_project_path = fake_home / domain 54 | django_project_name = 'myproject' 55 | expected_settings_path = expected_project_path / django_project_name / 'settings.py' 56 | 57 | django_version = subprocess.check_output([ 58 | str(expected_virtualenv / 'bin/python'), 59 | '-c' 60 | 'import django; print(django.get_version())' 61 | ]).decode().strip() 62 | assert django_version == new_django_version 63 | 64 | with expected_settings_path.open() as f: 65 | lines = f.read().split('\n') 66 | assert "MEDIA_ROOT = Path(BASE_DIR / 'media')" in lines 67 | assert "ALLOWED_HOSTS = ['mydomain.com'] # type: List[str]" in lines 68 | 69 | assert 'base.css' in os.listdir(str(fake_home / domain / 'static/admin/css')) 70 | server = subprocess.Popen([ 71 | str(expected_virtualenv / 'bin/python'), 72 | str(expected_project_path / 'manage.py'), 73 | 'runserver' 74 | ]) 75 | process_killer.append(server) 76 | time.sleep(2) 77 | response = requests.get('http://localhost:8000/', headers={'HOST': 'mydomain.com'}) 78 | assert 'Hello from an example django project' in response.text 79 | 80 | def test_shows_sanity_checks_message(self, mocker): 81 | mock_print = mocker.patch('builtins.print') 82 | mocker.patch('scripts.pa_autoconfigure_django.DjangoProject') 83 | 84 | main('repo.url', 'foo', 'www.domain.com', 'python.version', nuke=False) 85 | 86 | print_calls = [str(call) for call in mock_print.call_args_list] 87 | assert any("Running sanity checks" in str(call) for call in print_calls) 88 | 89 | 90 | 91 | 92 | def xtest_todos(): 93 | assert not 'existing-project sanity checks eg requirements empty' 94 | assert not 'find better-hidden requirements files?' 95 | assert not 'what happens if eg collecstatic barfs bc they need to set env vars. shld fail gracefully' 96 | assert not 'nuke option shouldnt barf if nothing to nuke' 97 | assert not 'detect use of env vars??' 98 | assert not 'SECRET_KEY?' 99 | assert not 'database stuff?' 100 | -------------------------------------------------------------------------------- /scripts/pa_update_scheduled_task.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3.8 2 | """Update a scheduled task using id and proper specs. 3 | 4 | Note that logfile name will change after updating the task but it won't be 5 | created until first execution of the task. 6 | To change interval from hourly to daily use --daily flag and provide --hour. 7 | When --daily flag is not accompanied with --hour, new hour for the task 8 | will be automatically set to current hour. 9 | When changing interval from daily to hourly --hour flag is ignored. 10 | 11 | Usage: 12 | pa_update_scheduled_task.py [--command COMMAND] 13 | [--hour HOUR] [--minute MINUTE] 14 | [--disable | --enable | --toggle-enabled] 15 | [--daily | --hourly] 16 | [--quiet | --porcelain] 17 | 18 | Options: 19 | -h, --help Print this message 20 | -c, --command COMMAND Changes command to COMMAND (multiword commands 21 | should be quoted) 22 | -o, --hour HOUR Changes hour to HOUR (in 24h format) 23 | -m, --minute MINUTE Changes minute to MINUTE 24 | -d, --disable Disables task 25 | -e, --enable Enables task 26 | -t, --toggle-enabled Toggles enable/disable state 27 | -a, --daily Switches interval to daily (when --hour is not 28 | provided, sets it automatically to current hour) 29 | -u, --hourly Switches interval to hourly (takes precedence 30 | over --hour, i.e. sets hour to None) 31 | -q, --quiet Turns off messages 32 | -p, --porcelain Prints message in easy-to-parse format 33 | 34 | Example: 35 | Change command for a scheduled task 42: 36 | 37 | pa_update_scheduled_task 42 --command "echo new command" 38 | 39 | Change interval of the task 42 from hourly to daily to be run at 10 am: 40 | 41 | pa_update_scheduled_task 42 --hour 10 42 | 43 | Change interval of the task 42 from daily to hourly and set new minute: 44 | 45 | pa_update_scheduled_task 42 --minute 13 --hourly""" 46 | 47 | import logging 48 | from datetime import datetime 49 | 50 | import os 51 | from docopt import docopt 52 | from snakesay import snakesay 53 | 54 | from pythonanywhere import __version__ 55 | from pythonanywhere.scripts_commons import ScriptSchema, get_logger, get_task_from_id 56 | 57 | os.environ["PYTHONANYWHERE_CLIENT"] = f"helper-scripts/{__version__}" 58 | 59 | 60 | def main(*, task_id, **kwargs): 61 | logger = get_logger() 62 | 63 | if kwargs.pop("hourly"): 64 | kwargs["interval"] = "hourly" 65 | if kwargs.pop("daily"): 66 | kwargs["hour"] = kwargs["hour"] if kwargs["hour"] else datetime.now().hour 67 | kwargs["interval"] = "daily" 68 | 69 | def parse_opts(*opts): 70 | candidates = [key for key in opts if kwargs.pop(key, None)] 71 | return candidates[0] if candidates else None 72 | 73 | if not parse_opts("quiet"): 74 | logger.setLevel(logging.INFO) 75 | 76 | porcelain = parse_opts("porcelain") 77 | enable_opt = parse_opts("toggle_enabled", "disable", "enable") 78 | 79 | task = get_task_from_id(task_id) 80 | 81 | params = {key: val for key, val in kwargs.items() if val} 82 | if enable_opt: 83 | enabled = {"toggle_enabled": not task.enabled, "disable": False, "enable": True}[ 84 | enable_opt 85 | ] 86 | params.update({"enabled": enabled}) 87 | 88 | try: 89 | task.update_schedule(params, porcelain=porcelain) 90 | except Exception as e: 91 | logger.warning(snakesay(str(e))) 92 | 93 | 94 | if __name__ == "__main__": 95 | schema = ScriptSchema( 96 | { 97 | "": ScriptSchema.id_required, 98 | "--command": ScriptSchema.string, 99 | "--daily": ScriptSchema.boolean, 100 | "--disable": ScriptSchema.boolean, 101 | "--enable": ScriptSchema.boolean, 102 | "--hour": ScriptSchema.hour, 103 | "--hourly": ScriptSchema.boolean, 104 | "--minute": ScriptSchema.minute, 105 | "--porcelain": ScriptSchema.boolean, 106 | "--quiet": ScriptSchema.boolean, 107 | "--toggle-enabled": ScriptSchema.boolean, 108 | } 109 | ) 110 | arguments = schema.validate_user_input( 111 | docopt(__doc__), conversions={"id": "task_id", "toggle-": "toggle_"} 112 | ) 113 | 114 | main(**arguments) 115 | -------------------------------------------------------------------------------- /scripts/pa_get_scheduled_task_specs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3.8 2 | """Get current scheduled task's specs file by task id. 3 | 4 | Available specs are: command, enabled, interval, hour, minute, printable-time, 5 | logfile, expiry. If no option specified, script will output all mentioned specs. 6 | 7 | Note that logfile query provides path for current (last) logfile. There may be 8 | several logfiles for each task. 9 | If task has been updated (e.g. by `pa_update_scheduled_task.py` script) logfile 10 | name has been changed too, but the file will not be created until first execution 11 | of the task. Thus getting logfile path via API call does not necessarily mean the 12 | file exists on the server yet. 13 | 14 | Usage: 15 | pa_get_scheduled_task_specs.py [--command] [--enabled] [--interval] 16 | [--hour] [--minute] [--printable-time] 17 | [--logfile] [--expiry] 18 | [--snakesay | --no-spec] 19 | 20 | Options: 21 | -h, --help Prints this message 22 | -c, --command Prints task's command 23 | -e, --enabled Prints task's enabled status (True or False) 24 | -i, --interval Prints task's frequency (daily or hourly) 25 | -l, --logfile Prints task's current log file path 26 | -m, --minute Prints task's scheduled minute 27 | -o, --hour Prints task's scheduled hour (if daily) 28 | -p, --printable-time Prints task's scheduled time 29 | -x, --expiry Prints task's expiry date 30 | -n, --no-spec Prints only values without spec names 31 | -s, --snakesay Turns on snakesay... because why not 32 | 33 | Note: 34 | Task may be found using pa_get_scheduled_tasks_list.py script. 35 | 36 | Example: 37 | Get all specs for task with id 42: 38 | 39 | pa_get_scheduled_task_specs 42 40 | 41 | Get only logfile name for task with id 42: 42 | 43 | pa_get_scheduled_task_specs 42 --logfile --no-spec""" 44 | 45 | import os 46 | from docopt import docopt 47 | from snakesay import snakesay 48 | from tabulate import tabulate 49 | 50 | from pythonanywhere import __version__ 51 | from pythonanywhere.scripts_commons import ScriptSchema, get_logger, get_task_from_id 52 | 53 | os.environ["PYTHONANYWHERE_CLIENT"] = f"helper-scripts/{__version__}" 54 | 55 | 56 | def main(*, task_id, **kwargs): 57 | logger = get_logger(set_info=True) 58 | task = get_task_from_id(task_id) 59 | 60 | print_snake = kwargs.pop("snake") 61 | print_only_values = kwargs.pop("no_spec") 62 | 63 | specs = ( 64 | {spec: getattr(task, spec) for spec in kwargs if kwargs[spec]} 65 | if any([val for val in kwargs.values()]) 66 | else {spec: getattr(task, spec) for spec in kwargs} 67 | ) 68 | 69 | # get user path instead of server path: 70 | if specs.get("logfile"): 71 | specs.update({"logfile": task.logfile.replace(f"/user/{task.user}/files", "")}) 72 | 73 | intro = f"Task {task_id} specs: " 74 | if print_only_values: 75 | specs = "\n".join([str(val) for val in specs.values()]) 76 | logger.info(specs) 77 | elif print_snake: 78 | specs = [f"<{spec}>: {value}" for spec, value in specs.items()] 79 | specs.sort() 80 | logger.info(snakesay(intro + ", ".join(specs))) 81 | else: 82 | table = [[spec, val] for spec, val in specs.items()] 83 | table.sort(key=lambda x: x[0]) 84 | logger.info(intro) 85 | logger.info(tabulate(table, tablefmt="simple")) 86 | 87 | 88 | if __name__ == "__main__": 89 | schema = ScriptSchema( 90 | { 91 | "": ScriptSchema.id_required, 92 | "--command": ScriptSchema.boolean, 93 | "--enabled": ScriptSchema.boolean, 94 | "--expiry": ScriptSchema.boolean, 95 | "--hour": ScriptSchema.boolean, 96 | "--interval": ScriptSchema.boolean, 97 | "--logfile": ScriptSchema.boolean, 98 | "--minute": ScriptSchema.boolean, 99 | "--printable-time": ScriptSchema.boolean, 100 | "--no-spec": ScriptSchema.boolean, 101 | "--snakesay": ScriptSchema.boolean, 102 | } 103 | ) 104 | arguments = schema.validate_user_input( 105 | docopt(__doc__), 106 | conversions={ 107 | "id": "task_id", 108 | "no-": "no_", 109 | "printable-": "printable_", 110 | "snakesay": "snake", 111 | }, 112 | ) 113 | 114 | main(**arguments) 115 | -------------------------------------------------------------------------------- /cli/website.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | from typing_extensions import Annotated 4 | 5 | import typer 6 | from snakesay import snakesay 7 | from tabulate import tabulate 8 | 9 | from pythonanywhere_core.website import Website 10 | from pythonanywhere_core.exceptions import PythonAnywhereApiException, DomainAlreadyExistsException 11 | 12 | 13 | app = typer.Typer(no_args_is_help=True) 14 | 15 | 16 | @app.command() 17 | def create( 18 | domain_name: Annotated[ 19 | str, 20 | typer.Option( 21 | "-d", 22 | "--domain", 23 | help="Domain name, eg. yourusername.pythonanywhere.com or www.mydomain.com", 24 | ) 25 | ], 26 | command: Annotated[ 27 | str, 28 | typer.Option( 29 | "-c", 30 | "--command", 31 | help="The command to start up your server", 32 | ) 33 | ], 34 | ): 35 | """Create an ASGI website""" 36 | try: 37 | Website().create(domain_name=domain_name, command=command) 38 | except DomainAlreadyExistsException: 39 | typer.echo(f"You already have a website for {domain_name}.") 40 | raise typer.Exit(code=1) 41 | except PythonAnywhereApiException as e: 42 | typer.echo(str(e)) 43 | raise typer.Exit(code=1) 44 | 45 | typer.echo( 46 | snakesay( 47 | f"All done! Your site is now live at {domain_name}. " 48 | ) 49 | ) 50 | 51 | 52 | @app.command() 53 | def get( 54 | domain_name: str = typer.Option( 55 | None, 56 | "-d", 57 | "--domain", 58 | help="Get details for domain name, eg. yourusername.pythonanywhere.com or www.mydomain.com", 59 | ) 60 | ): 61 | """If no domain name is specified, list all domains. Otherwise get details for specified domain""" 62 | website = Website() 63 | if domain_name is not None: 64 | website_info = website.get(domain_name=domain_name) 65 | tabular_data = [ 66 | ["domain name", website_info["domain_name"]], 67 | ["cname", website_info["webapp"]["domains"][0].get("cname")], 68 | ["enabled", website_info["enabled"]], 69 | ["command", website_info["webapp"]["command"]], 70 | ] 71 | if "logfiles" in website_info: 72 | tabular_data.extend( 73 | [ 74 | ["access log", website_info["logfiles"]["access"]], 75 | ["error log", website_info["logfiles"]["error"]], 76 | ["server log", website_info["logfiles"]["server"]], 77 | ] 78 | ) 79 | tabular_data = [[k, v] for k, v in tabular_data if v is not None] 80 | 81 | table = tabulate(tabular_data, tablefmt="simple") 82 | else: 83 | websites = website.list() 84 | table = tabulate( 85 | [ 86 | [website_info["domain_name"], website_info["enabled"]] 87 | for website_info in websites 88 | ], 89 | headers=["domain name", "enabled"], 90 | tablefmt="simple" 91 | ) 92 | typer.echo(table) 93 | 94 | 95 | @app.command() 96 | def reload( 97 | domain_name: Annotated[ 98 | str, 99 | typer.Option( 100 | "-d", 101 | "--domain", 102 | help="Domain name, eg. yourusername.pythonanywhere.com or www.mydomain.com", 103 | ) 104 | ], 105 | ): 106 | """Reload the website at the given domain""" 107 | Website().reload(domain_name=domain_name) 108 | typer.echo(snakesay(f"Website {domain_name} has been reloaded!")) 109 | 110 | 111 | @app.command() 112 | def delete( 113 | domain_name: Annotated[ 114 | str, 115 | typer.Option( 116 | "-d", 117 | "--domain", 118 | help="Domain name, eg. yourusername.pythonanywhere.com or www.mydomain.com", 119 | ) 120 | ], 121 | ): 122 | """Delete the website at the given domain""" 123 | Website().delete(domain_name=domain_name) 124 | typer.echo(snakesay(f"Website {domain_name} has been deleted!")) 125 | 126 | 127 | @app.command() 128 | def create_autorenew_cert( 129 | domain_name: Annotated[ 130 | str, 131 | typer.Option( 132 | "-d", 133 | "--domain", 134 | help="Domain name, eg. yourusername.pythonanywhere.com or www.mydomain.com", 135 | ) 136 | ], 137 | ): 138 | """Create and apply an auto-renewing Let's Encrypt certificate for the given domain""" 139 | Website().auto_ssl(domain_name=domain_name) 140 | typer.echo(snakesay(f"Applied auto-renewing SSL certificate for {domain_name}!")) 141 | -------------------------------------------------------------------------------- /pythonanywhere/scripts_commons.py: -------------------------------------------------------------------------------- 1 | """Helpers used by pythonanywhere helper scripts.""" 2 | 3 | import logging 4 | import sys 5 | 6 | from schema import And, Or, Schema, SchemaError, Use 7 | from snakesay import snakesay 8 | 9 | from pythonanywhere.task import Task 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | # fmt: off 14 | tabulate_formats = [ 15 | "plain", "simple", "github", "grid", "fancy_grid", "pipe", "orgtbl", "jira", 16 | "presto", "psql", "rst", "mediawiki", "moinmoin", "youtrack", "html", "latex", 17 | "latex_raw", "latex_booktabs", "textile", 18 | ] 19 | # fmt: on 20 | 21 | 22 | class ScriptSchema(Schema): 23 | """Extends `Schema` adapting it to PA scripts validation strategies. 24 | 25 | Adds predefined schemata as class variables to be used in scripts' 26 | validation schemas as well as `validate_user_input` method which acts 27 | as `Schema.validate` but returns a dictionary with converted keys 28 | ready to be used as function keyword arguments, e.g. validated 29 | arguments {"--foo": bar, "": qux} will be be converted to 30 | {"foo": bar, "baz": qux}. Additional conversion rules may be added as 31 | dictionary passed to `validate_user_input` :method: as `conversions` 32 | :param:. 33 | 34 | Use :method:`ScriptSchema.validate_user_input` to obtain kwarg 35 | dictionary.""" 36 | 37 | # class variables are used in task scripts schemata: 38 | boolean = Or(None, bool) 39 | hour = Or(None, And(Use(int), lambda h: 0 <= h <= 23), error="--hour has to be in 0..23") 40 | id_multi = Or([], And(lambda y: [x.isdigit() for x in y], error=" has to be integer")) 41 | id_required = And(Use(int), error=" has to be an integer") 42 | minute_required = And(Use(int), lambda m: 0 <= m <= 59, error="--minute has to be in 0..59") 43 | minute = Or(None, minute_required) 44 | string = Or(None, str) 45 | tabulate_format = Or( 46 | None, 47 | And(str, lambda f: f in tabulate_formats), 48 | error=f"--format should match one of: {', '.join(tabulate_formats)}", 49 | ) 50 | 51 | replacements = {"--": "", "<": "", ">": ""} 52 | 53 | def convert(self, string): 54 | """Removes cli argument notation characters ('--', '<', '>' etc.). 55 | 56 | :param string: cli argument key to be converted to fit Python 57 | argument syntax.""" 58 | 59 | for key, value in self.replacements.items(): 60 | string = string.replace(key, value) 61 | return string 62 | 63 | def validate_user_input(self, arguments, *, conversions=None): 64 | """Calls `Schema.validate` on provided `arguments`. 65 | 66 | Returns dictionary with keys converted by 67 | `ScriptSchema.convert` :method: to be later used as kwarg 68 | arguments. Universal rules for conversion are stored in 69 | `replacements` class variable and may be updated using 70 | `conversions` kwarg. Use optional `conversions` :param: to add 71 | custom replacement rules. 72 | 73 | :param arguments: dictionary of cli arguments provided be 74 | (e.g.) `docopt` 75 | :param conversions: dictionary of additional rules to 76 | `self.replacements`""" 77 | 78 | if conversions: 79 | self.replacements.update(conversions) 80 | 81 | try: 82 | self.validate(arguments) 83 | return {self.convert(key): val for key, val in arguments.items()} 84 | except SchemaError as e: 85 | logger.warning(snakesay(str(e))) 86 | sys.exit(1) 87 | 88 | 89 | def get_logger(set_info=False): 90 | """Sets logger for 'pythonanywhere' package. 91 | 92 | Returns `logging.Logger` instance with no message formatting which 93 | will stream to stdout. With `set_info` :param: set to `True` 94 | logger defines `logging.INFO` level otherwise it leaves default 95 | `logging.WARNING`. 96 | 97 | To toggle message visibility in scripts use `logger.info` calls 98 | and switch `set_info` value accordingly. 99 | 100 | :param set_info: boolean (defaults to False)""" 101 | 102 | logging.basicConfig(format="%(message)s", stream=sys.stdout) 103 | logger = logging.getLogger("pythonanywhere") 104 | if set_info: 105 | logger.setLevel(logging.INFO) 106 | else: 107 | logger.setLevel(logging.WARNING) 108 | return logger 109 | 110 | 111 | def get_task_from_id(task_id, no_exit=False): 112 | """Get `Task.from_id` instance representing existing task. 113 | 114 | :param task_id: integer (should be a valid task id) 115 | :param no_exit: if (default) False sys.exit will be called when 116 | exception is caught""" 117 | 118 | try: 119 | return Task.from_id(task_id) 120 | except Exception as e: 121 | logger.warning(snakesay(str(e))) 122 | if not no_exit: 123 | sys.exit(1) 124 | -------------------------------------------------------------------------------- /tests/test_cli_students.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from typer.testing import CliRunner 3 | from unittest.mock import call 4 | 5 | from cli.students import app 6 | 7 | runner = CliRunner() 8 | 9 | 10 | @pytest.fixture 11 | def mock_students(mocker): 12 | return mocker.patch("cli.students.Students", autospec=True) 13 | 14 | 15 | @pytest.fixture 16 | def mock_students_get(mock_students): 17 | return mock_students.return_value.get 18 | 19 | 20 | @pytest.fixture 21 | def mock_students_delete(mock_students): 22 | return mock_students.return_value.delete 23 | 24 | 25 | def test_main_subcommand_without_args_prints_help(): 26 | result = runner.invoke( 27 | app, 28 | [], 29 | ) 30 | assert result.exit_code == 2 31 | assert "Show this message and exit." in result.stdout 32 | 33 | 34 | @pytest.mark.students 35 | class TestGet: 36 | def test_exits_early_with_error_when_api_does_not_return_expected_list( 37 | self, mock_students_get 38 | ): 39 | mock_students_get.return_value = None 40 | 41 | result = runner.invoke(app, ["get"]) 42 | 43 | assert result.exit_code == 1 44 | 45 | def test_exits_early_with_error_when_api_returns_empty_list(self, mock_students_get): 46 | mock_students_get.return_value = [] 47 | 48 | result = runner.invoke(app, ["get"]) 49 | 50 | assert result.exit_code == 1 51 | 52 | def test_prints_list_of_students_when_students_found(self, mock_students_get): 53 | students_found = ["one", "two", "three"] 54 | mock_students_get.return_value = students_found 55 | 56 | result = runner.invoke(app, ["get"]) 57 | 58 | assert result.exit_code == 0 59 | assert "\n".join(students_found) in result.stdout 60 | 61 | def test_prints_numbered_list_of_students_when_students_found_and_numbered_flag_used( 62 | self, mock_students_get 63 | ): 64 | students_found = ["one", "two", "three"] 65 | mock_students_get.return_value = students_found 66 | 67 | result = runner.invoke(app, ["get", "--numbered"]) 68 | 69 | assert result.exit_code == 0 70 | assert "1. one" in result.stdout 71 | assert "2. two" in result.stdout 72 | assert "3. three" in result.stdout 73 | 74 | def test_prints_repr_of_list_returned_by_the_api_when_raw_flag_used(self, mock_students_get): 75 | mock_students_get.return_value = ["one", "two", "three"] 76 | 77 | result = runner.invoke(app, ["get", "--raw"]) 78 | 79 | assert result.exit_code == 0 80 | assert "['one', 'two', 'three']" in result.stdout 81 | 82 | def test_prints_sorted_list_of_students_returned_by_the_api_when_sort_flag_used( 83 | self, mock_students_get 84 | ): 85 | mock_students_get.return_value = ["one", "two", "three"] 86 | 87 | result = runner.invoke(app, ["get", "--sort"]) 88 | 89 | assert result.exit_code == 0 90 | assert "one\nthree\ntwo" in result.stdout 91 | 92 | def test_prints_reversed_sorted_list_of_students_returned_by_the_api_when_sort_reverse_flag_used( 93 | self, mock_students_get 94 | ): 95 | mock_students_get.return_value = ["one", "two", "three"] 96 | 97 | result = runner.invoke(app, ["get", "--reverse"]) 98 | 99 | assert result.exit_code == 0 100 | assert "two\nthree\none" in result.stdout 101 | 102 | 103 | @pytest.mark.students 104 | class TestDelete: 105 | def test_exits_with_success_when_provided_student_removed(self, mock_students_delete): 106 | mock_students_delete.return_value = True 107 | 108 | result = runner.invoke(app, ["delete", "thisStudent"]) 109 | 110 | assert result.exit_code == 0 111 | assert mock_students_delete.call_args_list == [call("thisStudent")] 112 | 113 | 114 | def test_exits_with_error_when_no_student_removed(self, mock_students_delete): 115 | mock_students_delete.return_value = False 116 | 117 | result = runner.invoke(app, ["delete", "thisStudent"]) 118 | 119 | assert result.exit_code == 1 120 | 121 | 122 | @pytest.mark.students 123 | class TestHolidays: 124 | def test_exits_with_success_when_all_students_removed( 125 | self, mock_students_get, mock_students_delete 126 | ): 127 | students = ["one", "two", "three"] 128 | mock_students_get.return_value = students 129 | mock_students_delete.side_effect = [True for _ in students] 130 | 131 | result = runner.invoke(app, ["holidays"]) 132 | 133 | assert result.exit_code == 0 134 | assert mock_students_delete.call_args_list == [call(s) for s in students] 135 | assert "Removed all 3 students" in result.stdout 136 | 137 | def test_exits_with_error_when_none_student_removed( 138 | self, mock_students_get, mock_students_delete 139 | ): 140 | mock_students_get.return_value = [] 141 | 142 | result = runner.invoke(app, ["holidays"]) 143 | 144 | assert result.exit_code == 1 145 | assert not mock_students_delete.called 146 | -------------------------------------------------------------------------------- /tests/test_project.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import call, patch, Mock 2 | import pytest 3 | from pathlib import Path 4 | 5 | from pythonanywhere_core.webapp import Webapp 6 | 7 | from pythonanywhere.project import Project 8 | from pythonanywhere.exceptions import SanityException 9 | from pythonanywhere.virtualenvs import Virtualenv 10 | 11 | 12 | def test_project_domain_and_python_version(fake_home, virtualenvs_folder): 13 | project = Project('mydomain.com', 'python.version') 14 | assert project.domain == 'mydomain.com' 15 | assert project.python_version == 'python.version' 16 | 17 | def test_project_path(fake_home, virtualenvs_folder): 18 | project = Project('mydomain.com', 'python.version') 19 | assert project.project_path == fake_home / 'mydomain.com' 20 | 21 | def test_project_wsgi_file_path(fake_home, virtualenvs_folder): 22 | project = Project('mydomain.com', 'python.version') 23 | assert project.wsgi_file_path == Path('/var/www/mydomain_com_wsgi.py') 24 | 25 | def test_project_webapp(fake_home, virtualenvs_folder): 26 | project = Project('mydomain.com', 'python.version') 27 | assert project.webapp == Webapp('mydomain.com') 28 | 29 | def test_project_virtualenv(fake_home, virtualenvs_folder): 30 | project = Project('mydomain.com', 'python.version') 31 | assert project.virtualenv == Virtualenv('mydomain.com', 'python.version') 32 | 33 | 34 | def test_sanity_checks_calls_webapp_sanity_checks(fake_home, virtualenvs_folder): 35 | project = Project('mydomain.com', 'python.version') 36 | project.webapp.sanity_checks = Mock() 37 | project.sanity_checks(nuke='nuke.option') 38 | assert project.webapp.sanity_checks.call_args == call(nuke='nuke.option') 39 | 40 | def test_sanity_checks_raises_if_virtualenv_exists(fake_home, virtualenvs_folder): 41 | project = Project('mydomain.com', 'python.version') 42 | project.webapp.sanity_checks = Mock() 43 | project.virtualenv.path.mkdir() 44 | 45 | with pytest.raises(SanityException) as e: 46 | project.sanity_checks(nuke=False) 47 | 48 | assert "You already have a virtualenv for mydomain.com" in str(e.value) 49 | assert "nuke" in str(e.value) 50 | 51 | def test_sanity_checks_raises_if_project_path_exists(fake_home, virtualenvs_folder): 52 | project = Project('mydomain.com', 'python.version') 53 | project.webapp.sanity_checks = Mock() 54 | project.project_path.mkdir() 55 | 56 | with pytest.raises(SanityException) as e: 57 | project.sanity_checks(nuke=False) 58 | 59 | expected_msg = f"You already have a project folder at {fake_home}/mydomain.com" 60 | assert expected_msg in str(e.value) 61 | assert "nuke" in str(e.value) 62 | 63 | def test_sanity_checks_nuke_option_overrides_directory_checks(fake_home, virtualenvs_folder): 64 | project = Project('mydomain.com', 'python.version') 65 | project.webapp.sanity_checks = Mock() 66 | project.project_path.mkdir() 67 | project.virtualenv.path.mkdir() 68 | 69 | project.sanity_checks(nuke=True) # should not raise 70 | 71 | 72 | def test_create_webapp_calls_webapp_create(virtualenvs_folder): 73 | project = Project('mydomain.com', 'python.version') 74 | project.webapp.create = Mock() 75 | project.python_version = 'python.version' 76 | 77 | project.create_webapp(nuke=True) 78 | assert project.webapp.create.call_args == call( 79 | 'python.version', project.virtualenv.path, project.project_path, nuke=True 80 | ) 81 | 82 | 83 | def test_add_static_file_mappings_calls_webapp_add_default_static_files_mappings(virtualenvs_folder): 84 | project = Project('mydomain.com', 'python.version') 85 | project.webapp.add_default_static_files_mappings = Mock() 86 | project.add_static_file_mappings() 87 | assert project.webapp.add_default_static_files_mappings.call_args == call( 88 | project.project_path, 89 | ) 90 | 91 | 92 | def test_reload_webapp_calls_webapp_reload(virtualenvs_folder, mocker): 93 | mock_webapp_class = mocker.patch('pythonanywhere.project.Webapp', autospec=True) 94 | project = Project('mydomain.com', 'python.version') 95 | project.reload_webapp() 96 | mock_webapp_class.return_value.reload.assert_called_once() 97 | 98 | 99 | def test_start_bash_calls_launch_bash_in_virtualenv_with_virtualenv_and_project_path(fake_home, virtualenvs_folder): 100 | project = Project('mydomain.com', 'python.version') 101 | with patch('pythonanywhere.project.launch_bash_in_virtualenv') as mock_launch_bash_in_virtualenv: 102 | project.start_bash() 103 | args, kwargs = mock_launch_bash_in_virtualenv.call_args 104 | assert args[0] == project.virtualenv.path 105 | assert args[2] == project.project_path 106 | 107 | def test_start_bash_calls_launch_bash_in_virtualenv_with_unique_id(fake_home, virtualenvs_folder): 108 | project = Project('mydomain.com', 'python.version') 109 | with patch('pythonanywhere.project.launch_bash_in_virtualenv') as mock_launch_bash_in_virtualenv: 110 | for _ in range(100): 111 | project.start_bash() 112 | unique_ids = [args[1] for args, kwargs in mock_launch_bash_in_virtualenv.call_args_list] 113 | assert len(set(unique_ids)) == len(unique_ids) 114 | -------------------------------------------------------------------------------- /pythonanywhere/django_project.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import re 3 | import shutil 4 | import subprocess 5 | 6 | from packaging import version 7 | from snakesay import snakesay 8 | 9 | from pythonanywhere.exceptions import SanityException 10 | from .project import Project 11 | 12 | 13 | class DjangoProject(Project): 14 | def django_version_newer_or_equal_than(self, this_version): 15 | return version.parse(self.virtualenv.get_version("django")) >= version.parse(this_version) 16 | 17 | def download_repo(self, repo, nuke): 18 | if nuke and self.project_path.exists(): 19 | shutil.rmtree(str(self.project_path)) 20 | subprocess.check_call(['git', 'clone', repo, str(self.project_path)]) 21 | 22 | def ensure_branch(self, branch): 23 | output = subprocess.check_output( 24 | ["git", "-C", str(self.project_path), "branch", "-r"] 25 | ).decode().rstrip().split("\n") 26 | branches = [x.strip().replace("origin/", "") for x in output if "->" not in x] 27 | if branch == "None" and len(branches) == 1: 28 | return 29 | if branch == "None": 30 | shutil.rmtree(str(self.project_path)) 31 | raise SanityException( 32 | "There are many branches in your repo. " 33 | "You need to specify which branch to use by adding " 34 | "--branch= option to the command." 35 | ) 36 | if branch not in branches: 37 | shutil.rmtree(str(self.project_path)) 38 | raise SanityException(f"You do not have a {branch} branch in your repo") 39 | # 40 | current_branch = subprocess.check_output( 41 | ["git", "-C", str(self.project_path), "rev-parse", "--abbrev-ref HEAD"] 42 | ).decode().strip() 43 | 44 | if current_branch != branch: 45 | subprocess.check_call(["git", "-C", str(self.project_path), "checkout", branch]) 46 | 47 | def create_virtualenv(self, django_version=None, nuke=False): 48 | self.virtualenv.create(nuke=nuke) 49 | if django_version is None: 50 | packages = self.detect_requirements() 51 | elif django_version == 'latest': 52 | packages = 'django' 53 | else: 54 | packages = f'django=={django_version}' 55 | self.virtualenv.pip_install(packages) 56 | 57 | def detect_requirements(self): 58 | requirements_txt = self.project_path / 'requirements.txt' 59 | if requirements_txt.exists(): 60 | return f'-r {requirements_txt.resolve()}' 61 | return 'django' 62 | 63 | def run_startproject(self, nuke): 64 | print(snakesay('Starting Django project')) 65 | if nuke and self.project_path.exists(): 66 | shutil.rmtree(str(self.project_path)) 67 | self.project_path.mkdir() 68 | 69 | new_django = self.django_version_newer_or_equal_than("4.0") 70 | django_admin_executable = "django-admin" if new_django else "django-admin.py" 71 | 72 | subprocess.check_call([ 73 | str(Path(self.virtualenv.path) / "bin" / django_admin_executable), 74 | "startproject", 75 | "mysite", 76 | str(self.project_path), 77 | ]) 78 | 79 | 80 | def find_django_files(self): 81 | try: 82 | self.settings_path = next(self.project_path.glob('**/settings.py')) 83 | except StopIteration: 84 | raise SanityException('Could not find your settings.py') 85 | try: 86 | self.manage_py_path = next(self.project_path.glob('**/manage.py')) 87 | except StopIteration: 88 | raise SanityException('Could not find your manage.py') 89 | 90 | 91 | def update_settings_file(self): 92 | print(snakesay('Updating settings.py')) 93 | 94 | with self.settings_path.open() as f: 95 | settings = f.read() 96 | new_settings = settings.replace( 97 | 'ALLOWED_HOSTS = []', 98 | f'ALLOWED_HOSTS = [{self.domain!r}]' 99 | ) 100 | 101 | new_django = self.django_version_newer_or_equal_than("3.1") 102 | 103 | if re.search(r'^MEDIA_ROOT\s*=', settings, flags=re.MULTILINE) is None: 104 | new_settings += "\nMEDIA_URL = '/media/'" 105 | if re.search(r'^STATIC_ROOT\s*=', settings, flags=re.MULTILINE) is None: 106 | if new_django: 107 | new_settings += "\nSTATIC_ROOT = Path(BASE_DIR / 'static')" 108 | else: 109 | new_settings += "\nSTATIC_ROOT = os.path.join(BASE_DIR, 'static')" 110 | if re.search(r'^MEDIA_ROOT\s*=', settings, flags=re.MULTILINE) is None: 111 | if new_django: 112 | new_settings += "\nMEDIA_ROOT = Path(BASE_DIR / 'media')" 113 | else: 114 | new_settings += "\nMEDIA_ROOT = os.path.join(BASE_DIR, 'media')" 115 | 116 | with self.settings_path.open('w') as f: 117 | f.write(new_settings) 118 | 119 | 120 | def run_collectstatic(self): 121 | print(snakesay('Running collectstatic')) 122 | subprocess.check_call([ 123 | str(Path(self.virtualenv.path) / 'bin/python'), 124 | str(self.manage_py_path), 125 | 'collectstatic', 126 | '--noinput', 127 | ]) 128 | 129 | 130 | def run_migrate(self): 131 | print(snakesay('Running migrate database')) 132 | subprocess.check_call([ 133 | str(Path(self.virtualenv.path) / 'bin/python'), 134 | str(self.manage_py_path), 135 | 'migrate', 136 | ]) 137 | 138 | 139 | def update_wsgi_file(self): 140 | print(snakesay(f'Updating wsgi file at {self.wsgi_file_path}')) 141 | template = (Path(__file__).parent / 'wsgi_file_template.py').open().read() 142 | with self.wsgi_file_path.open('w') as f: 143 | f.write(template.format(project=self)) 144 | -------------------------------------------------------------------------------- /cli/path.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import sys 4 | 5 | from collections import namedtuple 6 | 7 | import typer 8 | 9 | from pythonanywhere.files import PAPath 10 | from pythonanywhere.scripts_commons import get_logger 11 | 12 | app = typer.Typer(no_args_is_help=True) 13 | 14 | 15 | def setup(path: str, quiet: bool) -> PAPath: 16 | logger = get_logger(set_info=True) 17 | if quiet: 18 | logger.disabled = True 19 | return PAPath(path) 20 | 21 | 22 | @app.command() 23 | def get( 24 | path: str = typer.Argument(..., help="Path to PythonAnywhere file or directory."), 25 | only_files: bool = typer.Option(False, "-f", "--files", help="List only files."), 26 | only_dirs: bool = typer.Option(False, "-d", "--dirs", help="List only directories."), 27 | sort_by_type: bool = typer.Option(False, "-t", "--type", help="Sort by type."), 28 | sort_reverse: bool = typer.Option(False, "-r", "--reverse", help="Sort in reverse order."), 29 | raw: bool = typer.Option( 30 | False, "-a", "--raw", help="Print API response (has effect only for directories)." 31 | ), 32 | quiet: bool = typer.Option(False, "-q", "--quiet", help="Disable additional logging."), 33 | ): 34 | """ 35 | Get contents of PATH. 36 | 37 | If PATH points to a directory, show list of it's contents. 38 | If PATH points to a file, print it's contents. 39 | """ 40 | pa_path = setup(path, quiet) 41 | contents = pa_path.contents 42 | 43 | if contents is None: 44 | sys.exit(1) 45 | 46 | if raw or isinstance(contents, str): 47 | {dict: lambda x: print(json.dumps(x)), str: print}[type(contents)](contents) 48 | sys.exit() 49 | 50 | NameToType = namedtuple("NameToType", ["name", "type"]) 51 | item = "file" if only_files else "directory" if only_dirs else "every" 52 | data = [NameToType(k, v["type"]) for k, v in contents.items()] 53 | 54 | if sort_reverse or sort_by_type: 55 | data.sort(key=lambda x: x.type if sort_by_type else x.name, reverse=sort_reverse) 56 | 57 | typer.echo(f"{pa_path.path}:") 58 | for name, type_ in data: 59 | if item == "every": 60 | typer.echo(f"{type_[0].upper()} {name}") 61 | elif type_ == item: 62 | typer.echo(f" {name}") 63 | 64 | 65 | def _format_tree(data, current): 66 | last_child = "└── " 67 | next_child = "├── " 68 | connector = "│ " 69 | filler = " " 70 | 71 | formatted = [] 72 | level_tracker = set() 73 | 74 | for entry in reversed(data): 75 | entry = re.sub(r"/$", "\0", entry.replace(current, "")) 76 | chunks = [cc for cc in entry.split("/") if cc] 77 | item = chunks[-1].replace("\0", "/") 78 | level = len(chunks) - 1 79 | level_tracker = set([lvl for lvl in level_tracker if lvl <= level]) 80 | indents = [connector if lvl in level_tracker else filler for lvl in range(level)] 81 | indents.append(last_child if level not in level_tracker else next_child) 82 | level_tracker.add(level) 83 | formatted.append("".join(indents) + item) 84 | 85 | return "\n".join(reversed(formatted)) 86 | 87 | 88 | @app.command() 89 | def tree( 90 | path: str = typer.Argument(..., help="Path to PythonAnywhere directory."), 91 | quiet: bool = typer.Option(False, "-q", "--quiet", help="Disable additional logging.") 92 | ): 93 | """Show preview of directory contents at PATH in tree-like format (2 levels deep).""" 94 | pa_path = setup(path, quiet) 95 | tree = pa_path.tree 96 | 97 | if tree is not None: 98 | typer.echo(f"{pa_path.path}:") 99 | typer.echo(".") 100 | typer.echo(_format_tree(tree, pa_path.path)) 101 | else: 102 | sys.exit(1) 103 | 104 | 105 | @app.command() 106 | def upload( 107 | path: str = typer.Argument(..., help=("Full path of FILE where CONTENTS should be uploaded to.")), 108 | file: typer.FileBinaryRead = typer.Option( 109 | ..., 110 | "-c", 111 | "--contents", 112 | help="Path to exisitng file or stdin stream that should be uploaded to PATH." 113 | ), 114 | quiet: bool = typer.Option(False, "-q", "--quiet", help="Disable additional logging.") 115 | ): 116 | """ 117 | Upload CONTENTS to file at PATH. 118 | 119 | If PATH points to an existing file, it will be overwritten. 120 | """ 121 | pa_path = setup(path, quiet) 122 | success = pa_path.upload(file) 123 | sys.exit(0 if success else 1) 124 | 125 | 126 | @app.command() 127 | def delete( 128 | path: str = typer.Argument(..., help="Path to PythonAnywhere file or directory to be deleted."), 129 | quiet: bool = typer.Option(False, "-q", "--quiet", help="Disable additional logging.") 130 | ): 131 | """ 132 | Delete file or directory at PATH. 133 | 134 | If PATH points to a user owned directory all its contents will be 135 | deleted recursively. 136 | """ 137 | pa_path = setup(path, quiet) 138 | success = pa_path.delete() 139 | sys.exit(0 if success else 1) 140 | 141 | 142 | @app.command() 143 | def share( 144 | path: str = typer.Argument(..., help="Path to PythonAnywhere file."), 145 | check: bool = typer.Option(False, "-c", "--check", help="Check sharing status."), 146 | porcelain: bool = typer.Option(False, "-p", "--porcelain", help="Return sharing url in easy-to-parse format."), 147 | quiet: bool = typer.Option(False, "-q", "--quiet", help="Disable logging."), 148 | ): 149 | """Create a sharing link to a file at PATH or check its sharing status.""" 150 | pa_path = setup(path, quiet or porcelain) 151 | link = pa_path.get_sharing_url() if check else pa_path.share() 152 | 153 | if not link: 154 | sys.exit(1) 155 | if porcelain: 156 | typer.echo(link) 157 | 158 | 159 | @app.command() 160 | def unshare( 161 | path: str = typer.Argument(..., help="Path to PythonAnywhere file."), 162 | quiet: bool = typer.Option(False, "-q", "--quiet", help="Disable additional logging.") 163 | ): 164 | """Disable sharing link for a file at PATH.""" 165 | pa_path = setup(path, quiet) 166 | success = pa_path.unshare() 167 | sys.exit(0 if success else 1) 168 | -------------------------------------------------------------------------------- /pythonanywhere/files.py: -------------------------------------------------------------------------------- 1 | """User interface for interacting with PythonAnywhere files. 2 | Provides a class `PAPath` which should be used by helper scripts 3 | providing features for programmatic handling of user's files.""" 4 | 5 | import getpass 6 | import logging 7 | 8 | from snakesay import snakesay 9 | 10 | from pythonanywhere_core.files import Files 11 | 12 | logger = logging.getLogger("pythonanywhere") 13 | 14 | 15 | class PAPath: 16 | """Class providing interface for interacting with PythonAnywhere 17 | user files. 18 | 19 | Class should be instantiated with a path to an existing 20 | PythonAnywhere file or directory that user has access to or to an 21 | available destination path for a file that would be uploaded. 22 | 23 | To get PythonAnywhere url for given path use 24 | :property:`PAPath.url`, to get its contents use 25 | :property:`PAPath.contents` or :property:`PAPath.tree` for a list 26 | of regular paths, when given path is directory. 27 | 28 | To perform actions on path pointing to an existing PythonAnywhere 29 | file/directory, use following methods: 30 | - :method:`PAPath.delete` to delete file/directory 31 | - :method:`PAPath.upload` to overwrite file contents 32 | - :method:`PAPath.share` to start sharing a file 33 | - :method:`PAPath.unshare` to stop sharing a file 34 | - :method:`PAPath.get_sharing_url` to check if file is already 35 | shared and get its sharing url 36 | 37 | When path does not represent existing PythonAnywhere file, it can 38 | be created with :method:`PAPath.upload`.""" 39 | 40 | def __init__(self, path): 41 | self.path = self._standarize_path(path) 42 | self.api = Files() 43 | 44 | def __repr__(self): 45 | return self.url 46 | 47 | @staticmethod 48 | def _standarize_path(path): 49 | return path.replace("~", f"/home/{getpass.getuser()}") if path.startswith("~") else path 50 | 51 | @property 52 | def url(self): 53 | """Returns url to PythonAnywhere for `self.path`. Does not 54 | perform any checks (url might not point to an existing file).""" 55 | 56 | files_base = self.api.base_url.replace("/api/v0", "") 57 | return f"{files_base[:-1]}{self.path}" 58 | 59 | @property 60 | def contents(self): 61 | """When `self.path` points to a PythonAnywhere user 62 | directiory, returns a dictionary of its files and directories, 63 | where file/directory names are keys and values contain 64 | information about type and API endpoint. Otherwise (when 65 | `self.path` points to a file) contents of the file are 66 | returned as bytes. 67 | 68 | >>> PAPath('/home/username').contents 69 | >>> {'.bashrc': {'type': 'file', 70 | 'url': 'https://www.pythonanywhere.com/api/v0/user/username/files/path/home/username/.bashrc'}, 71 | '.local': {'type': 'directory', 72 | 'url': 'https://www.pythonanywhere.com/api/v0/user/username/files/path/home/username/.local'}, 73 | ... } 74 | 75 | >>> PAPath('/home/username/README.txt').contents 76 | >>> b"some README.txt contents..." 77 | """ 78 | 79 | try: 80 | content = self.api.path_get(self.path) 81 | return content if isinstance(content, dict) else content.decode("utf-8") 82 | except Exception as e: 83 | logger.warning(snakesay(str(e))) 84 | return None 85 | 86 | @property 87 | def tree(self): 88 | """Returns list of regular directories and files for 89 | `self.path`. 'Regular' means non dotfiles nor symlinks. 90 | Result is trimmed to 1000 items. 91 | 92 | >>> PAPath('/home/username').tree 93 | >>> ['/home/username/README.txt'] 94 | """ 95 | 96 | try: 97 | return self.api.tree_get(self.path) 98 | except Exception as e: 99 | logger.warning(snakesay(str(e))) 100 | return None 101 | 102 | def delete(self): 103 | """Returns `True` when `self.path` successfully deleted on 104 | PythonAnywhere, `False` otherwise.""" 105 | 106 | try: 107 | self.api.path_delete(self.path) 108 | logger.info(snakesay(f"{self.path} deleted!")) 109 | return True 110 | except Exception as e: 111 | logger.warning(snakesay(str(e))) 112 | return False 113 | 114 | def upload(self, content): 115 | """Returns `True` when provided `content` successfully 116 | uploaded to `self.path`. If `self.path` already existed on 117 | PythonAnywhere, it will be overwritten by the `content`. 118 | When upload is not successful, returns `False`.""" 119 | 120 | try: 121 | result = self.api.path_post(self.path, content) 122 | except Exception as e: 123 | logger.warning(snakesay(str(e))) 124 | return False 125 | 126 | msg = { 127 | 200: f"{self.path} successfully updated!", 128 | 201: f"Content successfully uploaded to {self.path}!" 129 | }[result] 130 | 131 | logger.info(snakesay(msg)) 132 | return True 133 | 134 | def get_sharing_url(self, quiet=False): 135 | """Returns PythonAnywhere sharing url for `self.path` if file 136 | is shared, empty string otherwise.""" 137 | 138 | url = self.api.sharing_get(self.path) 139 | if url: 140 | if not quiet: 141 | logger.info(snakesay(f"{self.path} is shared at {url}")) 142 | return url 143 | 144 | logger.info(snakesay(f"{self.path} has not been shared")) 145 | 146 | return "" 147 | 148 | def share(self): 149 | """Returns PythonAnywhere sharing link for `self.path` or an 150 | empty string when share not successful.""" 151 | 152 | try: 153 | msg, url = self.api.sharing_post(self.path) 154 | except Exception as e: 155 | logger.warning(snakesay(str(e))) 156 | return "" 157 | 158 | logger.info(snakesay(f"{self.path} {msg} at {url}")) 159 | return url 160 | 161 | def unshare(self): 162 | """Returns `True` when file unshared or has not been shared, 163 | `False` otherwise.""" 164 | 165 | already_shared = self.get_sharing_url(quiet=True) 166 | if already_shared: 167 | result = self.api.sharing_delete(self.path) 168 | if result == 204: 169 | logger.info(snakesay(f"{self.path} is no longer shared!")) 170 | return True 171 | logger.warning(snakesay(f"Could not unshare {self.path}... :(")) 172 | return False 173 | logger.info(snakesay(f"{self.path} is not being shared, no need to stop sharing...")) 174 | return True 175 | -------------------------------------------------------------------------------- /tests/test_scripts_commons.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import logging 3 | from unittest.mock import call 4 | 5 | import pytest 6 | 7 | from pythonanywhere.scripts_commons import ( 8 | ScriptSchema, 9 | get_logger, 10 | get_task_from_id, 11 | tabulate_formats, 12 | ) 13 | 14 | 15 | @pytest.mark.tasks 16 | class TestScriptSchema: 17 | def test_validates_boolean(self): 18 | schema = ScriptSchema({"--toggle": ScriptSchema.boolean}) 19 | 20 | for val in (True, False, None): 21 | result = schema.validate_user_input({"--toggle": val}) 22 | assert result == {"toggle": val} 23 | 24 | def test_exits_because_boolean_not_satisfied(self, mocker): 25 | mock_exit = mocker.patch("pythonanywhere.scripts_commons.sys.exit") 26 | mock_snake = mocker.patch("pythonanywhere.scripts_commons.snakesay") 27 | mock_warning = mocker.patch("pythonanywhere.scripts_commons.logger.warning") 28 | schema = ScriptSchema({"--toggle": ScriptSchema.boolean}) 29 | 30 | schema.validate_user_input({"--toggle": "not valid value"}) 31 | 32 | assert mock_exit.call_args == call(1) 33 | assert mock_warning.call_count == 1 34 | assert mock_snake.call_args == call( 35 | "Key '--toggle' error:\nOr(None, ) did not validate 'not valid value'\n" 36 | "'not valid value' should be instance of 'bool'" 37 | ) 38 | 39 | def test_validates_bour(self): 40 | schema = ScriptSchema({"--hour": ScriptSchema.hour}) 41 | 42 | for val in (None, 0, 12, 23): 43 | result = schema.validate_user_input({"--hour": val}) 44 | assert result == {"hour": val} 45 | 46 | def test_exits_because_hour_not_satisfied(self, mocker): 47 | mock_exit = mocker.patch("pythonanywhere.scripts_commons.sys.exit") 48 | mock_snake = mocker.patch("pythonanywhere.scripts_commons.snakesay") 49 | mock_warning = mocker.patch("pythonanywhere.scripts_commons.logger.warning") 50 | schema = ScriptSchema({"--hour": ScriptSchema.hour}) 51 | 52 | schema.validate_user_input({"--hour": 30}) 53 | 54 | assert mock_exit.call_args == call(1) 55 | assert mock_warning.call_count == 1 56 | assert mock_snake.call_args == call("--hour has to be in 0..23") 57 | 58 | def test_validates_minute(self): 59 | schema = ScriptSchema({"--minute": ScriptSchema.minute}) 60 | 61 | for val in (None, 1, 30, 59): 62 | result = schema.validate_user_input({"--minute": val}) 63 | assert result == {"minute": val} 64 | 65 | def test_exits_because_minute_not_satisfied(self, mocker): 66 | mock_exit = mocker.patch("pythonanywhere.scripts_commons.sys.exit") 67 | mock_snake = mocker.patch("pythonanywhere.scripts_commons.snakesay") 68 | mock_warning = mocker.patch("pythonanywhere.scripts_commons.logger.warning") 69 | schema = ScriptSchema({"--minute": ScriptSchema.minute}) 70 | 71 | schema.validate_user_input({"--minute": 60}) 72 | 73 | assert mock_exit.call_args == call(1) 74 | assert mock_warning.call_count == 1 75 | assert mock_snake.call_args == call("--minute has to be in 0..59") 76 | 77 | def test_validates_using_conversions(self): 78 | schema = ScriptSchema({"": ScriptSchema.id_required}) 79 | 80 | result = schema.validate_user_input({"": 42}, conversions={"id": "task_id"}) 81 | 82 | assert result == {"task_id": 42} 83 | 84 | def test_exits_because_id_not_satisfied(self, mocker): 85 | mock_exit = mocker.patch("pythonanywhere.scripts_commons.sys.exit") 86 | mock_snake = mocker.patch("pythonanywhere.scripts_commons.snakesay") 87 | mock_warning = mocker.patch("pythonanywhere.scripts_commons.logger.warning") 88 | schema = ScriptSchema({"": ScriptSchema.id_required}) 89 | 90 | schema.validate_user_input({"": None}) 91 | 92 | assert mock_exit.call_args == call(1) 93 | assert mock_warning.call_count == 1 94 | assert mock_snake.call_args == call(" has to be an integer") 95 | 96 | def test_validates_tabulate_format(self): 97 | schema = ScriptSchema({"--format": ScriptSchema.tabulate_format}) 98 | 99 | for val in tabulate_formats: 100 | result = schema.validate_user_input({"--format": val}) 101 | assert result == {"format": val} 102 | 103 | def test_exits_because_tabulate_format_not_satisfied(self, mocker): 104 | mock_exit = mocker.patch("pythonanywhere.scripts_commons.sys.exit") 105 | mock_snake = mocker.patch("pythonanywhere.scripts_commons.snakesay") 106 | mock_warning = mocker.patch("pythonanywhere.scripts_commons.logger.warning") 107 | schema = ScriptSchema({"--format": ScriptSchema.tabulate_format}) 108 | 109 | schema.validate_user_input({"--format": "non_existing_format"}) 110 | 111 | assert mock_exit.call_args == call(1) 112 | assert mock_warning.call_count == 1 113 | assert mock_snake.call_args == call( 114 | "--format should match one of: plain, simple, github, grid, fancy_grid, pipe, orgtbl, " 115 | "jira, presto, psql, rst, mediawiki, moinmoin, youtrack, html, latex, latex_raw, " 116 | "latex_booktabs, textile" 117 | ) 118 | 119 | 120 | @pytest.mark.tasks 121 | class TestScriptSchemaConvert: 122 | def test_replaces_default_strings(self): 123 | was = ("--option", "") 124 | should_be = ("option", "arg") 125 | 126 | for string, expected in zip(was, should_be): 127 | assert ScriptSchema({}).convert(string) == expected 128 | 129 | def test_returns_unchanged_string(self): 130 | assert ScriptSchema({}).convert("will_not_be_changed") == "will_not_be_changed" 131 | 132 | 133 | @pytest.mark.tasks 134 | class TestGetLogger: 135 | def test_returns_pa_logger(self, caplog): 136 | # get_logger should change logger to WARNING, i.e. level 30 137 | caplog.set_level(logging.INFO, logger="pythonanywhere") 138 | 139 | logger = get_logger() 140 | assert logger.name == "pythonanywhere" 141 | assert logger.level == 30 142 | 143 | def test_returns_pa_logger_info(self, caplog): 144 | # get_logger should change logger to INFO, i.e. level 20 145 | caplog.set_level(logging.WARNING, logger="pythonanywhere") 146 | 147 | logger = get_logger(set_info=True) 148 | 149 | assert logger.name == "pythonanywhere" 150 | assert logger.level == 20 151 | 152 | 153 | @pytest.mark.tasks 154 | class TestGetTaskFromId: 155 | def test_returns_task(self, mocker): 156 | user = getpass.getuser() 157 | specs = { 158 | "can_enable": False, 159 | "command": "echo foo", 160 | "enabled": True, 161 | "hour": 10, 162 | "interval": "daily", 163 | "logfile": f"/user/{user}/files/foo", 164 | "minute": 23, 165 | "printable_time": "10:23", 166 | "task_id": 42, 167 | "username": user, 168 | } 169 | mock_task = mocker.patch("pythonanywhere.scripts_commons.Task.from_id") 170 | for spec, value in specs.items(): 171 | setattr(mock_task.return_value, spec, value) 172 | 173 | task = get_task_from_id(42) 174 | 175 | for spec, value in specs.items(): 176 | assert getattr(task, spec) == value 177 | 178 | def test_catches_exception(self, mocker): 179 | mock_exit = mocker.patch("pythonanywhere.scripts_commons.sys.exit") 180 | mock_snakesay = mocker.patch("pythonanywhere.scripts_commons.snakesay") 181 | mock_warning = mocker.patch("pythonanywhere.scripts_commons.logger.warning") 182 | mock_task_from_id = mocker.patch("pythonanywhere.task.Task.from_id") 183 | mock_task_from_id.side_effect = Exception("exception") 184 | 185 | task = get_task_from_id(1) 186 | 187 | assert mock_snakesay.call_args == call("exception") 188 | assert mock_warning.call_count == 1 189 | assert mock_exit.call_args == call(1) 190 | -------------------------------------------------------------------------------- /tests/test_cli_django.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import time 4 | from platform import python_version 5 | from unittest.mock import call 6 | 7 | import pytest 8 | import requests 9 | from typer.testing import CliRunner 10 | 11 | from cli.django import app 12 | 13 | runner = CliRunner() 14 | 15 | 16 | @pytest.fixture 17 | def mock_django_project(mocker): 18 | return mocker.patch("cli.django.DjangoProject") 19 | 20 | 21 | @pytest.fixture 22 | def mock_update_wsgi_file(mocker): 23 | return mocker.patch("cli.django.DjangoProject.update_wsgi_file") 24 | 25 | 26 | @pytest.fixture 27 | def mock_call_api(mocker): 28 | return mocker.patch("pythonanywhere_core.webapp.call_api") 29 | 30 | 31 | @pytest.fixture 32 | def running_python_version(): 33 | return ".".join(python_version().split(".")[:2]) 34 | 35 | 36 | def test_main_subcommand_without_args_prints_help(): 37 | result = runner.invoke( 38 | app, 39 | [], 40 | ) 41 | assert result.exit_code == 2 42 | assert "Show this message and exit." in result.stdout 43 | 44 | 45 | def test_autoconfigure_calls_all_stuff_in_right_order(mock_django_project): 46 | result = runner.invoke( 47 | app, 48 | [ 49 | "autoconfigure", 50 | "repo.url", 51 | "-d", 52 | "www.domain.com", 53 | "-p", 54 | "python.version", 55 | "--nuke", 56 | ], 57 | ) 58 | mock_django_project.assert_called_once_with("www.domain.com", "python.version") 59 | assert mock_django_project.return_value.method_calls == [ 60 | call.sanity_checks(nuke=True), 61 | call.download_repo("repo.url", nuke=True), 62 | call.ensure_branch("None"), 63 | call.create_virtualenv(nuke=True), 64 | call.create_webapp(nuke=True), 65 | call.add_static_file_mappings(), 66 | call.find_django_files(), 67 | call.update_wsgi_file(), 68 | call.update_settings_file(), 69 | call.run_collectstatic(), 70 | call.run_migrate(), 71 | call.reload_webapp(), 72 | call.start_bash(), 73 | ] 74 | assert "Running sanity checks" in result.stdout 75 | assert ( 76 | f"All done! Your site is now live at https://www.domain.com" in result.stdout 77 | ) 78 | 79 | 80 | @pytest.mark.slowtest 81 | def test_autoconfigure_actually_works_against_example_repo( 82 | mocker, 83 | mock_call_api, 84 | mock_update_wsgi_file, 85 | fake_home, 86 | virtualenvs_folder, 87 | api_token, 88 | process_killer, 89 | running_python_version, 90 | ): 91 | git_ref = "non-nested-old" if running_python_version in ["3.8", "3.9"] else "master" 92 | expected_django_version = "4.2.16" if running_python_version in ["3.8", "3.9"] else "5.1.3" 93 | mocker.patch("cli.django.DjangoProject.start_bash") 94 | repo = "https://github.com/pythonanywhere/example-django-project.git" 95 | domain = "mydomain.com" 96 | 97 | runner.invoke( 98 | app, 99 | [ 100 | "autoconfigure", 101 | repo, 102 | "-d", 103 | domain, 104 | "-p", 105 | running_python_version, 106 | "--branch", 107 | git_ref, 108 | ], 109 | ) 110 | 111 | expected_virtualenv = virtualenvs_folder / domain 112 | expected_project_path = fake_home / domain 113 | django_project_name = "myproject" 114 | expected_settings_path = expected_project_path / django_project_name / "settings.py" 115 | 116 | django_version = ( 117 | subprocess.check_output( 118 | [ 119 | str(expected_virtualenv / "bin/python"), 120 | "-c" "import django; print(django.get_version())", 121 | ] 122 | ) 123 | .decode() 124 | .strip() 125 | ) 126 | assert django_version == expected_django_version 127 | 128 | with expected_settings_path.open() as f: 129 | lines = f.read().split("\n") 130 | assert "MEDIA_ROOT = Path(BASE_DIR / 'media')" in lines 131 | assert "ALLOWED_HOSTS = ['mydomain.com'] # type: List[str]" in lines 132 | 133 | assert "base.css" in os.listdir(str(fake_home / domain / "static/admin/css")) 134 | server = subprocess.Popen( 135 | [ 136 | str(expected_virtualenv / "bin/python"), 137 | str(expected_project_path / "manage.py"), 138 | "runserver", 139 | ] 140 | ) 141 | process_killer.append(server) 142 | time.sleep(2) 143 | response = requests.get("http://localhost:8000/", headers={"HOST": "mydomain.com"}) 144 | assert "Hello from an example django project" in response.text 145 | 146 | 147 | def test_start_calls_all_stuff_in_right_order(mock_django_project): 148 | result = runner.invoke( 149 | app, 150 | [ 151 | "start", 152 | "-d", 153 | "www.domain.com", 154 | "-j", 155 | "django.version", 156 | "-p", 157 | "python.version", 158 | "--nuke", 159 | ], 160 | ) 161 | 162 | assert mock_django_project.call_args == call("www.domain.com", "python.version") 163 | assert mock_django_project.return_value.method_calls == [ 164 | call.sanity_checks(nuke=True), 165 | call.create_virtualenv("django.version", nuke=True), 166 | call.run_startproject(nuke=True), 167 | call.find_django_files(), 168 | call.update_settings_file(), 169 | call.run_collectstatic(), 170 | call.create_webapp(nuke=True), 171 | call.add_static_file_mappings(), 172 | call.update_wsgi_file(), 173 | call.reload_webapp(), 174 | ] 175 | assert "Running sanity checks" in result.stdout 176 | assert ( 177 | f"All done! Your site is now live at https://www.domain.com" in result.stdout 178 | ) 179 | 180 | 181 | @pytest.mark.slowtest 182 | def test_start_actually_creates_django_project_in_virtualenv_with_hacked_settings_and_static_files( 183 | mock_call_api, 184 | mock_update_wsgi_file, 185 | fake_home, 186 | virtualenvs_folder, 187 | api_token, 188 | running_python_version, 189 | new_django_version, 190 | ): 191 | runner.invoke( 192 | app, 193 | [ 194 | "start", 195 | "-d", 196 | "mydomain.com", 197 | "-j", 198 | new_django_version, 199 | "-p", 200 | running_python_version, 201 | ], 202 | ) 203 | 204 | django_version = ( 205 | subprocess.check_output( 206 | [ 207 | str(virtualenvs_folder / "mydomain.com/bin/python"), 208 | "-c" "import django; print(django.get_version())", 209 | ] 210 | ) 211 | .decode() 212 | .strip() 213 | ) 214 | assert django_version == new_django_version 215 | 216 | with (fake_home / "mydomain.com/mysite/settings.py").open() as f: 217 | lines = f.read().split("\n") 218 | assert "MEDIA_ROOT = Path(BASE_DIR / 'media')" in lines 219 | assert "ALLOWED_HOSTS = ['mydomain.com']" in lines 220 | 221 | assert "base.css" in os.listdir(str(fake_home / "mydomain.com/static/admin/css")) 222 | 223 | 224 | @pytest.mark.slowtest 225 | def test_nuke_option_lets_you_run_twice( 226 | mock_call_api, 227 | mock_update_wsgi_file, 228 | fake_home, 229 | virtualenvs_folder, 230 | api_token, 231 | running_python_version, 232 | old_django_version, 233 | new_django_version, 234 | ): 235 | runner.invoke( 236 | app, 237 | [ 238 | "start", 239 | "-d", 240 | "mydomain.com", 241 | "-j", 242 | old_django_version, 243 | "-p", 244 | running_python_version, 245 | ], 246 | ) 247 | runner.invoke( 248 | app, 249 | [ 250 | "start", 251 | "-d", 252 | "mydomain.com", 253 | "-j", 254 | new_django_version, 255 | "-p", 256 | running_python_version, 257 | "--nuke", 258 | ], 259 | ) 260 | 261 | django_version = ( 262 | subprocess.check_output( 263 | [ 264 | str(virtualenvs_folder / "mydomain.com/bin/python"), 265 | "-c" "import django; print(django.get_version())", 266 | ] 267 | ) 268 | .decode() 269 | .strip() 270 | ) 271 | assert django_version == new_django_version 272 | -------------------------------------------------------------------------------- /cli/webapp.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import getpass 3 | from enum import Enum 4 | from pathlib import Path 5 | 6 | import typer 7 | from pythonanywhere_core.exceptions import MissingCNAMEException 8 | from pythonanywhere_core.webapp import Webapp 9 | from snakesay import snakesay 10 | from tabulate import tabulate 11 | 12 | from pythonanywhere.project import Project 13 | from pythonanywhere.utils import ensure_domain, format_log_deletion_message 14 | 15 | app = typer.Typer(no_args_is_help=True) 16 | 17 | 18 | @app.command(name="list") 19 | def list_(): 20 | """List all your webapps""" 21 | webapps = Webapp.list_webapps() 22 | if not webapps: 23 | typer.echo(snakesay("No webapps found.")) 24 | return 25 | 26 | for webapp in webapps: 27 | typer.echo(webapp['domain_name']) 28 | 29 | 30 | @app.command() 31 | def get( 32 | domain_name: str = typer.Option( 33 | "your-username.pythonanywhere.com", 34 | "-d", 35 | "--domain", 36 | help="Domain name, eg www.mydomain.com", 37 | ) 38 | ): 39 | """Get details for a specific webapp""" 40 | domain_name = ensure_domain(domain_name) 41 | webapp = Webapp(domain_name) 42 | webapp_info = webapp.get() 43 | 44 | table = [ 45 | ["Domain", webapp_info['domain_name']], 46 | ["Python version", webapp_info.get('python_version', 'unknown')], 47 | ["Source directory", webapp_info.get('source_directory', 'not set')], 48 | ["Virtualenv path", webapp_info.get('virtualenv_path', 'not set')], 49 | ["Enabled", webapp_info.get('enabled', 'unknown')] 50 | ] 51 | 52 | typer.echo(tabulate(table, tablefmt="simple")) 53 | 54 | 55 | @app.command() 56 | def create( 57 | domain_name: str = typer.Option( 58 | "your-username.pythonanywhere.com", 59 | "-d", 60 | "--domain", 61 | help="Domain name, eg www.mydomain.com", 62 | ), 63 | python_version: str = typer.Option( 64 | "3.8", 65 | "-p", 66 | "--python-version", 67 | help="Python version, eg '3.9'", 68 | ), 69 | nuke: bool = typer.Option( 70 | False, 71 | help="*Irrevocably* delete any existing web app config on this domain. Irrevocably.", 72 | ), 73 | ): 74 | """Create a new webapp with virtualenv and project setup""" 75 | domain = ensure_domain(domain_name) 76 | project = Project(domain, python_version) 77 | typer.echo(snakesay("Running sanity checks")) 78 | project.sanity_checks(nuke=nuke) 79 | project.virtualenv.create(nuke=nuke) 80 | project.create_webapp(nuke=nuke) 81 | project.add_static_file_mappings() 82 | typer.echo(snakesay(f"Reloading {domain_name} via API")) 83 | project.reload_webapp() 84 | 85 | typer.echo( 86 | snakesay( 87 | f"All done! Your site is now live at https://{domain}. " 88 | f"Your web app config screen is here: https://www.pythonanywhere.com/user/{getpass.getuser().lower()}" 89 | f"/webapps/{domain.replace('.', '_')}" 90 | ) 91 | ) 92 | 93 | 94 | @app.command() 95 | def reload( 96 | domain_name: str = typer.Option( 97 | "your-username.pythonanywhere.com", 98 | "-d", 99 | "--domain", 100 | help="Domain name, eg www.mydomain.com", 101 | ) 102 | ): 103 | """Reload a webapp to apply code or configuration changes""" 104 | domain_name = ensure_domain(domain_name) 105 | webapp = Webapp(domain_name) 106 | typer.echo(snakesay(f"Reloading {domain_name} via API")) 107 | try: 108 | webapp.reload() 109 | except MissingCNAMEException as e: 110 | typer.echo(snakesay(str(e))) 111 | typer.echo(snakesay(f"{domain_name} has been reloaded")) 112 | 113 | 114 | @app.command() 115 | def install_ssl( 116 | domain_name: str = typer.Argument( 117 | ..., 118 | help="Domain name, eg www.mydomain.com", 119 | ), 120 | certificate_file: Path = typer.Argument( 121 | ..., 122 | exists=True, 123 | file_okay=True, 124 | readable=True, 125 | resolve_path=True, 126 | help="The name of the file containing the combined certificate in PEM format (normally a number of blocks, " 127 | 'each one starting "BEGIN CERTIFICATE" and ending "END CERTIFICATE")', 128 | ), 129 | private_key_file: Path = typer.Argument( 130 | ..., 131 | exists=True, 132 | file_okay=True, 133 | readable=True, 134 | resolve_path=True, 135 | help="The name of the file containing the private key in PEM format (a file with one block, " 136 | 'starting with something like "BEGIN PRIVATE KEY" and ending with something like "END PRIVATE KEY")', 137 | ), 138 | suppress_reload: bool = typer.Option( 139 | False, 140 | help="The website will need to be reloaded in order to activate the new certificate/key combination " 141 | "-- this happens by default, use this option to suppress it.", 142 | ), 143 | ): 144 | """Install SSL certificate and private key for a webapp""" 145 | with open(certificate_file, "r") as f: 146 | certificate = f.read() 147 | 148 | with open(private_key_file, "r") as f: 149 | private_key = f.read() 150 | 151 | webapp = Webapp(domain_name) 152 | webapp.set_ssl(certificate, private_key) 153 | if not suppress_reload: 154 | try: 155 | webapp.reload() 156 | except MissingCNAMEException as e: 157 | typer.echo(snakesay(str(e))) 158 | 159 | ssl_details = webapp.get_ssl_info() 160 | typer.echo( 161 | snakesay( 162 | "That's all set up now :-)\n" 163 | f"Your new certificate for {domain_name} will expire\n" 164 | f"on {ssl_details['not_after'].date().isoformat()},\n" 165 | "so shortly before then you should renew it\n" 166 | "and install the new certificate." 167 | ) 168 | ) 169 | 170 | 171 | class LogType(str, Enum): 172 | access = "access" 173 | error = "error" 174 | server = "server" 175 | all = "all" 176 | 177 | 178 | def index_callback(value: str): 179 | if value == "all" or (value.isnumeric() and int(value) in range(10)): 180 | return value 181 | raise typer.BadParameter( 182 | "log_index has to be 0 for current log, 1-9 for one of archive logs or all for all of them" 183 | ) 184 | 185 | 186 | @app.command() 187 | def delete_logs( 188 | domain_name: str = typer.Option( 189 | "your-username.pythonanywhere.com", 190 | "-d", 191 | "--domain", 192 | help="Domain name, eg www.mydomain.com", 193 | ), 194 | log_type: LogType = typer.Option( 195 | LogType.all, 196 | "-t", 197 | "--log-type", 198 | ), 199 | log_index: str = typer.Option( 200 | "all", 201 | "-i", 202 | "--log-index", 203 | callback=index_callback, 204 | help="0 for current log, 1-9 for one of archive logs or all for all of them", 205 | ), 206 | ): 207 | """Delete webapp log files (access, error, server logs)""" 208 | domain = ensure_domain(domain_name) 209 | webapp = Webapp(domain) 210 | log_types = ["access", "error", "server"] 211 | logs = webapp.get_log_info() 212 | if log_type == "all" and log_index == "all": 213 | for key in log_types: 214 | for log in logs[key]: 215 | typer.echo(snakesay(format_log_deletion_message(domain, key, log))) 216 | webapp.delete_log(key, log) 217 | elif log_type == "all": 218 | for key in log_types: 219 | typer.echo(snakesay(format_log_deletion_message(domain, key, int(log_index)))) 220 | webapp.delete_log(key, int(log_index)) 221 | elif log_index == "all": 222 | for i in logs[log_type]: 223 | typer.echo(snakesay(format_log_deletion_message(domain, log_type.value, i))) 224 | webapp.delete_log(log_type, int(i)) 225 | else: 226 | typer.echo(snakesay(format_log_deletion_message(domain, log_type.value, int(log_index)))) 227 | webapp.delete_log(log_type, int(log_index)) 228 | typer.echo(snakesay("All done!")) 229 | 230 | 231 | @app.command() 232 | def delete( 233 | domain_name: str = typer.Option( 234 | "your-username.pythonanywhere.com", 235 | "-d", 236 | "--domain", 237 | help="Domain name, eg www.mydomain.com", 238 | ) 239 | ): 240 | """Delete a webapp""" 241 | domain_name = ensure_domain(domain_name) 242 | webapp = Webapp(domain_name) 243 | typer.echo(snakesay(f"Deleting {domain_name} via API")) 244 | webapp.delete() 245 | typer.echo(snakesay(f"{domain_name} has been deleted")) 246 | -------------------------------------------------------------------------------- /pythonanywhere/task.py: -------------------------------------------------------------------------------- 1 | """User interface for PythonAnywhere scheduled tasks. Provides two 2 | classes: `Task` and `TaskList` which should be used by helper scripts 3 | providing features for programmatic handling of scheduled task.""" 4 | 5 | import logging 6 | 7 | from snakesay import snakesay 8 | 9 | from pythonanywhere_core.schedule import Schedule 10 | 11 | logger = logging.getLogger(name=__name__) 12 | 13 | 14 | class Task: 15 | """Class representing PythonAnywhere scheduled task. 16 | 17 | Bare instance of the `Task` is just a 'blueprint' for a scheduled 18 | task. This means the proper way to create an object representing 19 | existing existing task or a task ready to be created a `Task` instance 20 | should be created using classmethod constructors: `Task.from_id`, 21 | `Task.to_be_created` or Task.from_api_specs`. 22 | 23 | To create new task use :classmethod:`Task.to_be_created` and call 24 | :method:`Task.create_schedule` on it. 25 | 26 | To get an object representing existing task its id is needed. Having a 27 | valid id call :classmethod:`Task.from_id` and then execute other 28 | actions on the task: 29 | - to delete the task use :method:`Task.delete_schedule`, 30 | - to update the task use :method:`Task.update_schedule`. 31 | 32 | :classmethod:`Task.from_api_specs` is intended to to be called with 33 | specs returned by API and should not be used with arbitrary specs 34 | defined by user. 35 | 36 | `Task` class is API agnostic meaning all API calls are made using the 37 | `pythonanywhere.api.schedule.Schedule` interface via `Task.schedule` 38 | attribute.""" 39 | 40 | def __init__(self): 41 | self.command = None 42 | self.hour = None 43 | self.minute = None 44 | self.interval = None 45 | self.enabled = None 46 | self.task_id = None 47 | self.can_enable = None 48 | self.expiry = None 49 | self.extend_url = None 50 | self.logfile = None 51 | self.printable_time = None 52 | self.url = None 53 | self.user = None 54 | self.schedule = Schedule() 55 | 56 | def __repr__(self): 57 | enabled = "enabled" if self.enabled else "disabled" 58 | status = ( 59 | f"{enabled} at {self.printable_time}" 60 | if self.printable_time 61 | else "ready to be created" 62 | ) 63 | num = f" <{self.task_id}>:" if self.task_id else "" 64 | 65 | return f"{self.interval.title()} task{num} '{self.command}' {status}" 66 | 67 | @classmethod 68 | def from_id(cls, task_id): 69 | """Creates representation of existing scheduled task by id. 70 | 71 | :param task_id: existing task id as integer 72 | :returns: `Task` instance with actual specs.""" 73 | 74 | task = cls() 75 | specs = task.schedule.get_specs(task_id) 76 | task.update_specs(specs) 77 | return task 78 | 79 | @classmethod 80 | def to_be_created(cls, *, command, minute, hour=None, disabled=False): 81 | """Creates object ready to be created via API. 82 | 83 | To create the task call :method:`Task.create_schedule` on it. 84 | :param command: command executed by the task 85 | :param minute: minute on which task will be executed (required) 86 | :param hour: hour on which daily task will be executed 87 | (required by daily tasks) 88 | :param disabled: set to True to create disabled task (default 89 | is True meaning task will be created as enabled) 90 | :returns: `Task` instance ready to be created""" 91 | 92 | if hour is not None and not (0 <= hour <= 23): 93 | raise ValueError("Hour has to be in 0..23") 94 | if not (0 <= minute <= 59): 95 | raise ValueError("Minute has to be in 0..59") 96 | 97 | task = cls() 98 | task.command = command 99 | task.hour = hour 100 | task.minute = minute 101 | task.interval = "daily" if hour is not None else "hourly" 102 | task.enabled = not disabled 103 | return task 104 | 105 | @classmethod 106 | def from_api_specs(cls, specs): 107 | """Create object representing scheduled task with specs returned by API. 108 | 109 | *Note* don't use this method in scripts. To create a new task use 110 | `Task.to_be_created` constructor. 111 | 112 | :param specs: spec dictionary returned by API. 113 | :returns: `Task` instance with actual specs.""" 114 | 115 | task = cls() 116 | task.update_specs(specs) 117 | return task 118 | 119 | def update_specs(self, specs): 120 | """Sets `Task` instance's attributes using specs returned by API. 121 | 122 | *Note*: don't use this method in scripts. 123 | 124 | :param specs: spec dictionary returned by API.""" 125 | 126 | for attr, value in specs.items(): 127 | if attr == "id": 128 | attr = "task_id" 129 | setattr(self, attr, value) 130 | 131 | def create_schedule(self): 132 | """Creates new scheduled task. 133 | 134 | *Note* use this method on `Task.to_be_created` instance.""" 135 | 136 | params = { 137 | "command": self.command, 138 | "enabled": self.enabled, 139 | "interval": self.interval, 140 | "minute": self.minute, 141 | } 142 | if self.hour is not None: 143 | params["hour"] = self.hour 144 | 145 | self.update_specs(self.schedule.create(params)) 146 | 147 | mode = "will" if self.enabled else "may be enabled to" 148 | msg = ( 149 | "Task '{command}' successfully created with id {task_id} " 150 | "and {mode} be run {interval} at {printable_time}" 151 | ).format( 152 | command=self.command, 153 | task_id=self.task_id, 154 | mode=mode, 155 | interval=self.interval, 156 | printable_time=self.printable_time, 157 | ) 158 | logger.info(snakesay(msg)) 159 | 160 | def delete_schedule(self): 161 | """Deletes existing task. 162 | 163 | *Note*: use this method on `Task.from_id` instance.""" 164 | 165 | if self.schedule.delete(self.task_id): 166 | logger.info(snakesay(f"Task {self.task_id} deleted!")) 167 | 168 | def update_schedule(self, params, *, porcelain=False): 169 | """Updates existing task using `params`. 170 | 171 | *Note*: use this method on `Task.from_id` instance. 172 | 173 | `params` should be one at least one of: command, enabled, interval, 174 | hour, minute. `interval` takes precedence over `hour` meaning that 175 | `hour` param will be ignored if `interval` is set to 'hourly'. 176 | 177 | :param params: dictionary of specs to update 178 | :param porcelain: when True don't use `snakesay` in stdout messages 179 | (defaults to False)""" 180 | 181 | specs = { 182 | "command": self.command, 183 | "enabled": self.enabled, 184 | "interval": self.interval, 185 | "hour": self.hour, 186 | "minute": self.minute, 187 | } 188 | 189 | specs.update(params) 190 | 191 | if ( 192 | (specs["interval"] != "daily") 193 | or (params.get("interval") == "daily" and self.hour) 194 | or (params.get("hour") == self.hour) 195 | ): 196 | specs.pop("hour") 197 | 198 | if params.get("minute") == self.minute: 199 | specs.pop("minute") 200 | 201 | new_specs = self.schedule.update(self.task_id, specs) 202 | 203 | diff = { 204 | key: (getattr(self, key), new_specs[key]) 205 | for key in specs 206 | if getattr(self, key) != new_specs[key] 207 | } 208 | 209 | def make_spec_str(key, old_spec, new_spec): 210 | return f"<{key}> from '{old_spec}' to '{new_spec}'" 211 | 212 | updated = [make_spec_str(key, val[0], val[1]) for key, val in diff.items()] 213 | 214 | def make_msg(join_with): 215 | fill = " " if join_with == ", " else join_with 216 | intro = f"Task {self.task_id} updated:{fill}" 217 | return f"{intro}{join_with.join(updated)}" 218 | 219 | if updated: 220 | if porcelain: 221 | logger.info(make_msg(join_with="\n")) 222 | else: 223 | logger.info(snakesay(make_msg(join_with=", "))) 224 | self.update_specs(new_specs) 225 | else: 226 | logger.warning(snakesay("Nothing to update!")) 227 | 228 | 229 | class TaskList: 230 | """Creates user's tasks representation using `Task` class and specs 231 | returned by API. 232 | 233 | Tasks are stored in `TaskList.tasks` variable.""" 234 | 235 | def __init__(self): 236 | self.tasks = [Task.from_api_specs(specs) for specs in Schedule().get_list()] 237 | -------------------------------------------------------------------------------- /tests/test_task.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | from unittest.mock import call 3 | 4 | import pytest 5 | 6 | from pythonanywhere.task import Task, TaskList 7 | 8 | 9 | @pytest.fixture 10 | def task_specs(): 11 | username = getpass.getuser() 12 | return { 13 | "can_enable": False, 14 | "command": "echo foo", 15 | "enabled": True, 16 | "expiry": None, 17 | "extend_url": f"/user/{username}/schedule/task/42/extend", 18 | "hour": 16, 19 | "task_id": 42, 20 | "interval": "daily", 21 | "logfile": "/user/{username}/files/var/log/tasklog-126708-daily-at-1600-echo_foo.log", 22 | "minute": 0, 23 | "printable_time": "16:00", 24 | "url": f"/api/v0/user/{username}/schedule/42", 25 | "user": username, 26 | } 27 | 28 | 29 | @pytest.fixture 30 | def example_task(task_specs): 31 | task = Task() 32 | for spec, value in task_specs.items(): 33 | setattr(task, spec, value) 34 | return task 35 | 36 | 37 | @pytest.mark.tasks 38 | class TestTaskToBeCreated: 39 | def test_instantiates_new_daily_enabled(self): 40 | task = Task.to_be_created(command="myscript.py", hour=8, minute=10, disabled=False) 41 | assert task.command == "myscript.py" 42 | assert task.hour == 8 43 | assert task.minute == 10 44 | assert task.interval == "daily" 45 | assert task.enabled is True 46 | assert task.__repr__() == "Daily task 'myscript.py' ready to be created" 47 | 48 | def test_instantiates_new_hourly_disabled(self): 49 | task = Task.to_be_created(command="myscript.py", hour=None, minute=10, disabled=True) 50 | assert task.command == "myscript.py" 51 | assert task.hour is None 52 | assert task.minute == 10 53 | assert task.interval == "hourly" 54 | assert task.enabled is False 55 | assert task.__repr__() == "Hourly task 'myscript.py' ready to be created" 56 | 57 | def test_raises_when_to_be_created_gets_wrong_hour(self): 58 | with pytest.raises(ValueError) as e: 59 | Task.to_be_created(command="echo foo", hour=25, minute=1) 60 | assert str(e.value) == "Hour has to be in 0..23" 61 | 62 | def test_raises_when_to_be_created_gets_wrong_minute(self): 63 | with pytest.raises(ValueError) as e: 64 | Task.to_be_created(command="echo foo", hour=12, minute=78) 65 | assert str(e.value) == "Minute has to be in 0..59" 66 | 67 | 68 | @pytest.mark.tasks 69 | class TestTaskFromId: 70 | def test_updates_specs(self, task_specs, mocker): 71 | mock_get_specs = mocker.patch("pythonanywhere.task.Schedule.get_specs") 72 | mock_get_specs.return_value = task_specs 73 | 74 | task = Task.from_id(task_id=42) 75 | 76 | for spec, expected_value in task_specs.items(): 77 | assert getattr(task, spec) == expected_value 78 | assert task.__repr__() == "Daily task <42>: 'echo foo' enabled at 16:00" 79 | 80 | 81 | @pytest.mark.tasks 82 | class TestTaskCreateSchedule: 83 | def test_creates_daily_task(self, mocker, task_specs): 84 | mock_create = mocker.patch("pythonanywhere.task.Schedule.create") 85 | mock_create.return_value = task_specs 86 | mock_update_specs = mocker.patch("pythonanywhere.task.Task.update_specs") 87 | task = Task.to_be_created(command="echo foo", hour=16, minute=0, disabled=False) 88 | 89 | task.create_schedule() 90 | 91 | assert mock_update_specs.call_args == call(task_specs) 92 | assert mock_create.call_count == 1 93 | assert mock_create.call_args == call( 94 | {"command": "echo foo", "hour": 16, "minute": 0, "enabled": True, "interval": "daily"} 95 | ) 96 | def test_creates_daily_midnight_task(self, mocker, task_specs): 97 | mock_create = mocker.patch("pythonanywhere.task.Schedule.create") 98 | mock_create.return_value = task_specs 99 | mock_update_specs = mocker.patch("pythonanywhere.task.Task.update_specs") 100 | task = Task.to_be_created(command="echo foo", hour=0, minute=0, disabled=False) 101 | 102 | task.create_schedule() 103 | 104 | assert mock_update_specs.call_args == call(task_specs) 105 | assert mock_create.call_count == 1 106 | assert mock_create.call_args == call( 107 | {"command": "echo foo", "hour": 0, "minute": 0, "enabled": True, "interval": "daily"} 108 | ) 109 | 110 | 111 | @pytest.mark.tasks 112 | class TestTaskDeleteSchedule: 113 | def test_calls_schedule_delete(self, example_task, mocker): 114 | mock_delete = mocker.patch("pythonanywhere.task.Schedule.delete") 115 | mock_delete.return_value = True 116 | mock_snake = mocker.patch("pythonanywhere.task.snakesay") 117 | mock_logger = mocker.patch("pythonanywhere.task.logger.info") 118 | 119 | example_task.delete_schedule() 120 | 121 | assert mock_delete.call_args == call(42) 122 | assert mock_snake.call_args == call("Task 42 deleted!") 123 | assert mock_logger.call_args == call(mock_snake.return_value) 124 | 125 | def test_raises_when_schedule_delete_fails(self, mocker): 126 | mock_delete = mocker.patch("pythonanywhere.task.Schedule.delete") 127 | mock_delete.side_effect = Exception("error msg") 128 | 129 | with pytest.raises(Exception) as e: 130 | Task().delete_schedule() 131 | 132 | assert str(e.value) == "error msg" 133 | assert mock_delete.call_count == 1 134 | 135 | 136 | @pytest.mark.tasks 137 | class TestTaskUpdateSchedule: 138 | def test_updates_specs_and_prints_porcelain(self, mocker, example_task, task_specs): 139 | mock_schedule_update = mocker.patch("pythonanywhere.task.Schedule.update") 140 | mock_info = mocker.patch("pythonanywhere.task.logger.info") 141 | mock_update_specs = mocker.patch("pythonanywhere.task.Task.update_specs") 142 | params = {"enabled": False} 143 | task_specs.update(params) 144 | mock_schedule_update.return_value = task_specs 145 | 146 | example_task.update_schedule(params, porcelain=True) 147 | 148 | assert mock_schedule_update.call_args == call( 149 | 42, 150 | { 151 | "hour": 16, 152 | "minute": 0, 153 | "enabled": False, 154 | "interval": "daily", 155 | "command": "echo foo", 156 | }, 157 | ) 158 | assert mock_info.call_args == call("Task 42 updated:\n from 'True' to 'False'") 159 | assert mock_update_specs.call_args == call(task_specs) 160 | 161 | def test_updates_specs_and_snakesays(self, mocker, example_task, task_specs): 162 | mock_schedule_update = mocker.patch("pythonanywhere.task.Schedule.update") 163 | mock_info = mocker.patch("pythonanywhere.task.logger.info") 164 | mock_snake = mocker.patch("pythonanywhere.task.snakesay") 165 | mock_update_specs = mocker.patch("pythonanywhere.task.Task.update_specs") 166 | params = {"enabled": False} 167 | task_specs.update(params) 168 | mock_schedule_update.return_value = task_specs 169 | 170 | example_task.update_schedule(params, porcelain=False) 171 | 172 | assert mock_info.call_args == call(mock_snake.return_value) 173 | assert mock_snake.call_args == call("Task 42 updated: from 'True' to 'False'") 174 | assert mock_update_specs.call_args == call(task_specs) 175 | 176 | def test_changes_daily_to_hourly(self, example_task, task_specs, mocker): 177 | mock_schedule_update = mocker.patch("pythonanywhere.task.Schedule.update") 178 | mock_update_specs = mocker.patch("pythonanywhere.task.Task.update_specs") 179 | params = {"interval": "hourly"} 180 | task_specs.update({**params, "hour": None}) 181 | mock_schedule_update.return_value = task_specs 182 | 183 | example_task.update_schedule(params, porcelain=False) 184 | 185 | assert mock_update_specs.call_args == call(task_specs) 186 | 187 | def test_warns_when_nothing_to_update(self, mocker, example_task, task_specs): 188 | mock_schedule_update = mocker.patch("pythonanywhere.task.Schedule.update") 189 | mock_snake = mocker.patch("pythonanywhere.task.snakesay") 190 | mock_warning = mocker.patch("pythonanywhere.task.logger.warning") 191 | mock_update_specs = mocker.patch("pythonanywhere.task.Task.update_specs") 192 | mock_schedule_update.return_value = task_specs 193 | params = {"enabled": True, "minute": 0} 194 | 195 | example_task.update_schedule(params) 196 | 197 | assert mock_snake.call_args == call("Nothing to update!") 198 | assert mock_warning.call_args == call(mock_snake.return_value) 199 | assert mock_update_specs.call_count == 0 200 | assert mock_schedule_update.call_args == call( 201 | 42, {"hour": 16, "enabled": True, "interval": "daily", "command": "echo foo"}, 202 | ) 203 | 204 | 205 | @pytest.mark.tasks 206 | class TestTaskList: 207 | def test_instatiates_task_list_calling_proper_methods(self, task_specs, mocker): 208 | mock_get_list = mocker.patch("pythonanywhere.task.Schedule.get_list") 209 | mock_get_list.return_value = [task_specs] 210 | mock_from_specs = mocker.patch("pythonanywhere.task.Task.from_api_specs") 211 | 212 | TaskList() 213 | 214 | assert mock_from_specs.call_args == call(task_specs) 215 | assert mock_from_specs.call_count == len(mock_get_list.return_value) 216 | assert mock_get_list.call_count == 1 217 | -------------------------------------------------------------------------------- /tests/test_cli_webapp.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | import tempfile 3 | from datetime import datetime 4 | from unittest.mock import call 5 | 6 | import pytest 7 | from dateutil.tz import tzutc 8 | from typer.testing import CliRunner 9 | 10 | from cli.webapp import app 11 | 12 | runner = CliRunner() 13 | 14 | 15 | @pytest.fixture 16 | def mock_webapp(mocker): 17 | mock_webapp = mocker.patch("cli.webapp.Webapp") 18 | mock_webapp.return_value.get_log_info.return_value = { 19 | "access": [0, 1, 2], 20 | "error": [0, 1, 2], 21 | "server": [0, 1, 2], 22 | } 23 | return mock_webapp 24 | 25 | 26 | @pytest.fixture 27 | def domain_name(): 28 | return "foo.bar.baz" 29 | 30 | 31 | @pytest.fixture(name="file_with_content") 32 | def fixture_file_with_content(): 33 | def file_with_content(content): 34 | filename = tempfile.NamedTemporaryFile(mode="w", encoding="utf8").name 35 | with open(filename, "w") as f: 36 | f.write(content) 37 | return filename 38 | 39 | return file_with_content 40 | 41 | 42 | def test_main_subcommand_without_args_prints_help(): 43 | result = runner.invoke( 44 | app, 45 | [], 46 | ) 47 | assert result.exit_code == 2 48 | assert "Show this message and exit." in result.stdout 49 | 50 | 51 | def test_list_webapps(mocker): 52 | mock_webapp_class = mocker.patch("cli.webapp.Webapp") 53 | mock_webapp_class.list_webapps.return_value = [ 54 | {"domain_name": "example1.com", "python_version": "python38"}, 55 | {"domain_name": "example2.pythonanywhere.com", "python_version": "python311"}, 56 | ] 57 | 58 | result = runner.invoke(app, ["list"]) 59 | 60 | assert result.exit_code == 0 61 | mock_webapp_class.list_webapps.assert_called_once() 62 | assert "example1.com" in result.stdout 63 | assert "example2.pythonanywhere.com" in result.stdout 64 | assert "python38" not in result.stdout 65 | 66 | 67 | def test_get_webapp(mock_webapp, domain_name): 68 | mock_webapp.return_value.get.return_value = { 69 | "domain_name": domain_name, 70 | "python_version": "python38", 71 | "source_directory": "/home/user/mysite/", 72 | "virtualenv_path": "/home/user/.virtualenvs/mysite/", 73 | "enabled": True, 74 | } 75 | 76 | result = runner.invoke(app, ["get", "-d", domain_name]) 77 | 78 | assert result.exit_code == 0 79 | mock_webapp.assert_called_once_with(domain_name) 80 | mock_webapp.return_value.get.assert_called_once() 81 | assert domain_name in result.stdout 82 | assert "python38" in result.stdout 83 | assert "/home/user/mysite/" in result.stdout 84 | assert "Domain" in result.stdout 85 | assert "Python version" in result.stdout 86 | 87 | 88 | def test_create_calls_all_stuff_in_right_order(mocker): 89 | mock_project = mocker.patch("cli.webapp.Project") 90 | 91 | result = runner.invoke( 92 | app, 93 | [ 94 | "create", 95 | "-d", 96 | "www.domain.com", 97 | "-p", 98 | "python.version", 99 | "--nuke", 100 | ], 101 | ) 102 | 103 | assert mock_project.call_args == call("www.domain.com", "python.version") 104 | assert mock_project.return_value.method_calls == [ 105 | call.sanity_checks(nuke=True), 106 | call.virtualenv.create(nuke=True), 107 | call.create_webapp(nuke=True), 108 | call.add_static_file_mappings(), 109 | call.reload_webapp(), 110 | ] 111 | assert "Running sanity checks" in result.stdout 112 | assert "All done! Your site is now live at https://www.domain.com" in result.stdout 113 | assert ( 114 | f"https://www.pythonanywhere.com/user/{getpass.getuser().lower()}/webapps/www_domain_com" 115 | in result.stdout 116 | ) 117 | 118 | 119 | def test_reload(mock_webapp, domain_name): 120 | result = runner.invoke(app, ["reload", "-d", domain_name]) 121 | 122 | assert f"{domain_name} has been reloaded" in result.stdout 123 | mock_webapp.assert_called_once_with(domain_name) 124 | assert mock_webapp.return_value.method_calls == [call.reload()] 125 | 126 | 127 | def test_reload_handles_missing_cname_exception(mocker, mock_webapp, domain_name): 128 | from pythonanywhere_core.exceptions import MissingCNAMEException 129 | mock_webapp.return_value.reload.side_effect = MissingCNAMEException() 130 | 131 | result = runner.invoke(app, ["reload", "-d", domain_name]) 132 | 133 | assert "Could not find a CNAME for your website" in result.stdout 134 | assert f"{domain_name} has been reloaded" in result.stdout 135 | 136 | 137 | def test_install_ssl_with_default_reload(mock_webapp, domain_name, file_with_content): 138 | mock_webapp.return_value.get_ssl_info.return_value = { 139 | "not_after": datetime(2018, 8, 24, 17, 16, 23, tzinfo=tzutc()) 140 | } 141 | certificate = "certificate" 142 | certificate_file = file_with_content(certificate) 143 | private_key = "private_key" 144 | private_key_file = file_with_content(private_key) 145 | 146 | result = runner.invoke( 147 | app, 148 | ["install-ssl", domain_name, certificate_file, private_key_file], 149 | ) 150 | 151 | mock_webapp.assert_called_once_with(domain_name) 152 | mock_webapp.return_value.set_ssl.assert_called_once_with(certificate, private_key) 153 | mock_webapp.return_value.reload.assert_called_once() 154 | assert f"for {domain_name}" in result.stdout 155 | assert "2018-08-24," in result.stdout 156 | 157 | 158 | def test_install_ssl_with_reload_suppressed( 159 | mock_webapp, domain_name, file_with_content 160 | ): 161 | certificate = "certificate" 162 | certificate_file = file_with_content(certificate) 163 | private_key = "private_key" 164 | private_key_file = file_with_content(private_key) 165 | 166 | runner.invoke( 167 | app, 168 | [ 169 | "install-ssl", 170 | domain_name, 171 | certificate_file, 172 | private_key_file, 173 | "--suppress-reload", 174 | ], 175 | ) 176 | 177 | mock_webapp.assert_called_once_with(domain_name) 178 | mock_webapp.return_value.set_ssl.assert_called_once_with(certificate, private_key) 179 | mock_webapp.return_value.reload.assert_not_called() 180 | 181 | 182 | def test_delete_all_logs(mock_webapp, domain_name): 183 | result = runner.invoke( 184 | app, 185 | [ 186 | "delete-logs", 187 | "-d", 188 | domain_name, 189 | ], 190 | ) 191 | 192 | mock_webapp.assert_called_once_with(domain_name) 193 | assert mock_webapp.return_value.delete_log.call_args_list == [ 194 | call("access", 0), 195 | call("access", 1), 196 | call("access", 2), 197 | call("error", 0), 198 | call("error", 1), 199 | call("error", 2), 200 | call("server", 0), 201 | call("server", 1), 202 | call("server", 2), 203 | ] 204 | assert "All done!" in result.stdout 205 | 206 | 207 | def test_delete_all_server_logs(mock_webapp, domain_name): 208 | result = runner.invoke( 209 | app, 210 | [ 211 | "delete-logs", 212 | "-d", 213 | domain_name, 214 | "-t", 215 | "server", 216 | ], 217 | ) 218 | 219 | mock_webapp.assert_called_once_with(domain_name) 220 | assert mock_webapp.return_value.delete_log.call_args_list == [ 221 | call("server", 0), 222 | call("server", 1), 223 | call("server", 2), 224 | ] 225 | assert "All done!" in result.stdout 226 | 227 | 228 | def test_delete_one_server_logs(mock_webapp, domain_name): 229 | result = runner.invoke( 230 | app, ["delete-logs", "-d", "foo.bar.baz", "-t", "server", "-i", "2"] 231 | ) 232 | 233 | mock_webapp.assert_called_once_with(domain_name) 234 | mock_webapp.return_value.delete_log.assert_called_once_with("server", 2) 235 | assert "All done!" in result.stdout 236 | 237 | 238 | def test_delete_all_current_logs(mock_webapp, domain_name): 239 | result = runner.invoke(app, ["delete-logs", "-d", "foo.bar.baz", "-i", "0"]) 240 | 241 | mock_webapp.assert_called_once_with(domain_name) 242 | assert mock_webapp.return_value.delete_log.call_args_list == [ 243 | call("access", 0), 244 | call("error", 0), 245 | call("server", 0), 246 | ] 247 | assert "All done!" in result.stdout 248 | 249 | 250 | def test_delete_all_logs_shows_per_file_messages(mock_webapp, domain_name): 251 | result = runner.invoke( 252 | app, 253 | [ 254 | "delete-logs", 255 | "-d", 256 | domain_name, 257 | ], 258 | ) 259 | 260 | assert "Deleting current access log file" in result.stdout 261 | assert "Deleting old (archive number 1) access log file" in result.stdout 262 | assert "Deleting old (archive number 2) error log file" in result.stdout 263 | assert "foo.bar.baz" in result.stdout 264 | assert "All done!" in result.stdout 265 | 266 | 267 | def test_delete_one_log_shows_message(mock_webapp, domain_name): 268 | result = runner.invoke( 269 | app, ["delete-logs", "-d", domain_name, "-t", "server", "-i", "2"] 270 | ) 271 | 272 | assert "Deleting old (archive number 2) server log file" in result.stdout 273 | assert "foo.bar.baz" in result.stdout 274 | assert "All done!" in result.stdout 275 | 276 | 277 | 278 | def test_validates_log_number(mock_webapp): 279 | result = runner.invoke( 280 | app, ["delete-logs", "-d", "foo.bar.baz", "-t", "server", "-i", "10"] 281 | ) 282 | assert "Invalid value" in result.stderr 283 | assert "log_index has to be 0 for current" in result.stderr 284 | 285 | 286 | def test_delete_webapp(mock_webapp, domain_name): 287 | result = runner.invoke(app, ["delete", "-d", domain_name]) 288 | 289 | assert result.exit_code == 0 290 | mock_webapp.assert_called_once_with(domain_name) 291 | mock_webapp.return_value.delete.assert_called_once() 292 | assert f"{domain_name} has been deleted" in result.stdout 293 | -------------------------------------------------------------------------------- /cli/schedule.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sys 3 | from datetime import datetime 4 | from typing import List 5 | 6 | import typer 7 | from snakesay import snakesay 8 | from tabulate import tabulate 9 | 10 | from pythonanywhere.scripts_commons import get_logger, get_task_from_id, tabulate_formats 11 | from pythonanywhere.task import Task, TaskList 12 | 13 | app = typer.Typer(no_args_is_help=True) 14 | 15 | 16 | @app.command() 17 | def set( 18 | command: str = typer.Option( 19 | ..., "-c", "--command", help="Task's command to be scheduled" 20 | ), 21 | hour: int = typer.Option( 22 | None, 23 | "-h", 24 | "--hour", 25 | min=0, 26 | max=23, 27 | help="Sets the task to be performed daily at HOUR", 28 | ), 29 | minute: int = typer.Option( 30 | ..., 31 | "-m", 32 | "--minute", 33 | min=0, 34 | max=59, 35 | help="Minute on which the task will be executed", 36 | ), 37 | disabled: bool = typer.Option( 38 | False, "-d", "--disabled", help="Creates disabled task (otherwise enabled)" 39 | ), 40 | ): 41 | """Create a scheduled task. 42 | 43 | Two categories of tasks are available: daily and hourly. 44 | Both kinds require a command to run and scheduled time. In order to create a 45 | daily task provide hour and minute; to create hourly task provide only minute. 46 | If task is intended to be enabled later add --disabled flag. 47 | 48 | Example: 49 | Create a daily task to be run at 13:15: 50 | 51 | pa schedule set --command "echo foo" --hour 13 --minute 15 52 | 53 | Create an inactive hourly task to be run 27 minutes past every hour: 54 | 55 | pa schedule set --command "echo bar" --minute 27 --disabled 56 | 57 | Note: 58 | Once task is created its behavior may be altered later on with 59 | `pa schedule update` or deleted with `pa schedule delete` 60 | commands.""" 61 | 62 | logger = get_logger(set_info=True) 63 | 64 | task = Task.to_be_created( 65 | command=command, hour=hour, minute=minute, disabled=disabled 66 | ) 67 | try: 68 | task.create_schedule() 69 | except Exception as e: 70 | logger.warning(snakesay(str(e))) 71 | 72 | 73 | delete_app = typer.Typer() 74 | app.add_typer( 75 | delete_app, name="delete", help="Delete scheduled task(s) by id or nuke'em all." 76 | ) 77 | 78 | 79 | @delete_app.command("all", help="Delete all scheduled tasks.") 80 | def delete_all_tasks( 81 | force: bool = typer.Option( 82 | False, "-f", "--force", help="Turns off user confirmation before deleting tasks" 83 | ), 84 | ): 85 | get_logger(set_info=True) 86 | 87 | if not force: 88 | user_response = typer.confirm( 89 | "This will irrevocably delete all your tasks, proceed?" 90 | ) 91 | if not user_response: 92 | return None 93 | 94 | for task in TaskList().tasks: 95 | task.delete_schedule() 96 | 97 | 98 | @delete_app.command( 99 | "id", 100 | help="""\b 101 | Delete one or more scheduled tasks by id. 102 | ID_NUMBERS may be acquired with `pa schedule list` 103 | """, 104 | ) 105 | def delete_task_by_id(id_numbers: List[int] = typer.Argument(...)): 106 | get_logger(set_info=True) 107 | 108 | for task_id in id_numbers: 109 | task = get_task_from_id(task_id, no_exit=True) 110 | task.delete_schedule() 111 | 112 | 113 | @app.command() 114 | def get( 115 | task_id: int = typer.Argument(..., metavar="id"), 116 | command: bool = typer.Option( 117 | False, "-c", "--command", help="Prints task's command" 118 | ), 119 | enabled: bool = typer.Option( 120 | False, "-e", "--enabled", help="Prints task's enabled status (True or False)" 121 | ), 122 | expiry: bool = typer.Option( 123 | False, "-x", "--expiry", help="Prints task's expiry date" 124 | ), 125 | minute: bool = typer.Option( 126 | False, "-m", "--minute", help="Prints task's scheduled minute" 127 | ), 128 | hour: bool = typer.Option( 129 | False, "-o", "--hour", help="Prints task's scheduled hour (if daily)" 130 | ), 131 | interval: bool = typer.Option( 132 | False, "-i", "--interval", help="Prints task's frequency (daily or hourly)" 133 | ), 134 | logfile: bool = typer.Option( 135 | False, "-l", "--logfile", help="Prints task's current log file path" 136 | ), 137 | printable_time: bool = typer.Option( 138 | False, "-p", "--printable-time", help="Prints task's scheduled time" 139 | ), 140 | no_spec: bool = typer.Option( 141 | False, "-n", "--no-spec", help="Prints only values without spec names" 142 | ), 143 | snake: bool = typer.Option( 144 | False, "-s", "--snakesay", help="Turns on snakesay... because why not" 145 | ) 146 | ): 147 | """Get scheduled task's specs. 148 | 149 | Available specs are: command, enabled, interval, hour, minute, printable-time, 150 | logfile, expiry. If no option specified, script will output all mentioned specs. 151 | 152 | Note that logfile query provides path for current (last) logfile. There may be 153 | several logfiles for each task. 154 | If task has been updated (e.g. by `pa_update_scheduled_task.py` script) logfile 155 | name has been changed too, but the file will not be created until first execution 156 | of the task. Thus getting logfile path via API call does not necessarily mean the 157 | file exists on the server yet. 158 | 159 | Note: 160 | Task ID may be found using pa schedule list command. 161 | 162 | Example: 163 | Get all specs for task with id 42: 164 | 165 | pa schedule get 42 166 | 167 | Get only logfile name for task with id 42: 168 | 169 | pa schedule get 42 --logfile --no-spec""" 170 | 171 | kwargs = {k: v for k, v in locals().items() if k != "task_id"} 172 | logger = get_logger(set_info=True) 173 | 174 | task = get_task_from_id(task_id) 175 | 176 | print_snake = kwargs.pop("snake") 177 | print_only_values = kwargs.pop("no_spec") 178 | 179 | specs = ( 180 | {spec: getattr(task, spec) for spec in kwargs if kwargs[spec]} 181 | if any([val for val in kwargs.values()]) 182 | else {spec: getattr(task, spec) for spec in kwargs} 183 | ) 184 | 185 | if specs.get("logfile"): 186 | specs.update({"logfile": task.logfile.replace(f"/user/{task.user}/files", "")}) 187 | 188 | intro = f"Task {task_id} specs: " 189 | if print_only_values: 190 | specs = "\n".join([str(val) for val in specs.values()]) 191 | logger.info(specs) 192 | elif print_snake: 193 | specs = [f"<{spec}>: {value}" for spec, value in specs.items()] 194 | specs.sort() 195 | logger.info(snakesay(intro + ", ".join(specs))) 196 | else: 197 | table = [[spec, val] for spec, val in specs.items()] 198 | table.sort(key=lambda x: x[0]) 199 | logger.info(intro) 200 | logger.info(tabulate(table, tablefmt="simple")) 201 | 202 | 203 | def tablefmt_callback(value: str): 204 | if value not in tabulate_formats: 205 | raise typer.BadParameter(f"Table format has to be one of: {', '.join(tabulate_formats)}") 206 | return value 207 | 208 | 209 | @app.command("list") 210 | def list_( 211 | tablefmt: str = typer.Option( 212 | "simple", "-f", "--format", help="Table format", callback=tablefmt_callback 213 | ) 214 | ): 215 | """Get list of user's scheduled tasks as a table with columns: 216 | id, interval, at (hour:minute/minute past), status (enabled/disabled), command. 217 | 218 | Note: 219 | This script provides an overview of all tasks. Once a task id is 220 | known and some specific data is required it's more convenient to get 221 | it using `pa schedule get` command instead of parsing the table. 222 | """ 223 | 224 | logger = get_logger(set_info=True) 225 | 226 | headers = "id", "interval", "at", "status", "command" 227 | attrs = "task_id", "interval", "printable_time", "enabled", "command" 228 | 229 | def stringify_values(task, attr): 230 | value = getattr(task, attr) 231 | if attr == "enabled": 232 | value = "enabled" if value else "disabled" 233 | return value 234 | 235 | table = [[stringify_values(task, attr) for attr in attrs] for task in TaskList().tasks] 236 | msg = tabulate(table, headers, tablefmt=tablefmt) if table else snakesay("No scheduled tasks") 237 | logger.info(msg) 238 | 239 | 240 | @app.command() 241 | def update( 242 | task_id: int = typer.Argument(..., metavar="id"), 243 | command: str = typer.Option( 244 | None, 245 | "-c", 246 | "--command", 247 | help="Changes command to COMMAND (multiword commands should be quoted)" 248 | ), 249 | hour: int = typer.Option( 250 | None, 251 | "-o", 252 | "--hour", 253 | min=0, 254 | max=23, 255 | help="Changes hour to HOUR (in 24h format)" 256 | ), 257 | minute: int = typer.Option( 258 | None, 259 | "-m", 260 | "--minute", 261 | min=0, 262 | max=59, 263 | help="Changes minute to MINUTE" 264 | ), 265 | disable: bool = typer.Option(False, "-d", "--disable", help="Disables task"), 266 | enable: bool = typer.Option(False, "-e", "--enable", help="Enables task"), 267 | toggle_enabled: bool = typer.Option( 268 | False, "-t", "--toggle-enabled", help="Toggles enable/disable state" 269 | ), 270 | daily: bool = typer.Option( 271 | False, 272 | "-a", 273 | "--daily", 274 | help=( 275 | "Switches interval to daily " 276 | "(when --hour is not provided, sets it automatically to current hour)" 277 | ) 278 | ), 279 | hourly: bool = typer.Option( 280 | False, 281 | "-u", 282 | "--hourly", 283 | help="Switches interval to hourly (takes precedence over --hour, i.e. sets hour to None)" 284 | ), 285 | quiet: bool = typer.Option(False, "-q", "--quiet", help="Turns off messages"), 286 | porcelain: bool = typer.Option( 287 | False, "-p", "--porcelain", help="Prints message in easy-to-parse format" 288 | ), 289 | ): 290 | """Update a scheduled task. 291 | 292 | Note that logfile name will change after updating the task but it won't be 293 | created until first execution of the task. 294 | To change interval from hourly to daily use --daily flag and provide --hour. 295 | When --daily flag is not accompanied with --hour, new hour for the task 296 | will be automatically set to current hour. 297 | When changing interval from daily to hourly --hour flag is ignored. 298 | 299 | Example: 300 | Change command for a scheduled task 42: 301 | 302 | pa schedule update 42 --command "echo new command" 303 | 304 | Change interval of the task 42 from hourly to daily to be run at 10 am: 305 | 306 | pa schedule update 42 --hour 10 307 | 308 | Change interval of the task 42 from daily to hourly and set new minute: 309 | 310 | pa schedule update 42 --minute 13 --hourly""" 311 | 312 | kwargs = {k: v for k, v in locals().items() if k != "task_id"} 313 | logger = get_logger() 314 | 315 | porcelain = kwargs.pop("porcelain") 316 | if not kwargs.pop("quiet"): 317 | logger.setLevel(logging.INFO) 318 | 319 | if not any(kwargs.values()): 320 | msg = "Nothing to update!" 321 | logger.warning(msg if porcelain else snakesay(msg)) 322 | sys.exit(1) 323 | 324 | if kwargs.pop("hourly"): 325 | kwargs["interval"] = "hourly" 326 | if kwargs.pop("daily"): 327 | kwargs["hour"] = kwargs["hour"] if kwargs["hour"] else datetime.now().hour 328 | kwargs["interval"] = "daily" 329 | 330 | task = get_task_from_id(task_id) 331 | 332 | enable_opt = [k for k in ["toggle_enabled", "disable", "enable"] if kwargs.pop(k)] 333 | params = {k: v for k, v in kwargs.items() if v} 334 | if enable_opt: 335 | lookup = {"toggle_enabled": not task.enabled, "disable": False, "enable": True} 336 | params.update({"enabled": lookup[enable_opt[0]]}) 337 | 338 | try: 339 | task.update_schedule(params, porcelain=porcelain) 340 | except Exception as e: 341 | logger.warning(snakesay(str(e))) 342 | -------------------------------------------------------------------------------- /tests/test_cli_path.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | from tempfile import NamedTemporaryFile 3 | from textwrap import dedent 4 | 5 | import pytest 6 | from typer.testing import CliRunner 7 | 8 | from cli.path import app 9 | 10 | runner = CliRunner() 11 | 12 | 13 | @pytest.fixture 14 | def home_dir(): 15 | return f"/home/{getpass.getuser()}" 16 | 17 | 18 | @pytest.fixture 19 | def mock_path(mocker): 20 | return mocker.patch("cli.path.PAPath", autospec=True) 21 | 22 | 23 | @pytest.fixture 24 | def mock_homedir_path(mock_path): 25 | contents = { 26 | '.bashrc': {'type': 'file', 'url': 'bashrc_file_url'}, 27 | 'A_file': {'type': 'file', 'url': 'A_file_url'}, 28 | 'a_dir': {'type': 'directory', 'url': 'dir_one_url'}, 29 | 'a_file': {'type': 'file', 'url': 'a_file_url'}, 30 | 'b_file': {'type': 'file', 'url': 'b_file_url'}, 31 | 'dir_two': {'type': 'directory', 'url': 'dir_two_url'}, 32 | } 33 | 34 | mock_path.return_value.contents = contents 35 | return mock_path 36 | 37 | 38 | @pytest.fixture 39 | def mock_file_path(mock_path): 40 | mock_path.return_value.contents = "file contents" 41 | return mock_path 42 | 43 | 44 | def test_main_subcommand_without_args_prints_help(): 45 | result = runner.invoke( 46 | app, 47 | [], 48 | ) 49 | assert result.exit_code == 2 50 | assert "Show this message and exit." in result.stdout 51 | 52 | 53 | class TestGet: 54 | def test_exits_early_when_no_contents_for_given_path(self, mock_path): 55 | mock_path.return_value.contents = None 56 | 57 | result = runner.invoke(app, ["get", "~/nonexistent.file"]) 58 | 59 | assert result.exit_code == 1 60 | 61 | def test_prints_file_contents_and_exits_when_path_is_file(self, mock_file_path, home_dir): 62 | result = runner.invoke(app, ["get", "~/some-file"]) 63 | 64 | mock_file_path.assert_called_once_with("~/some-file") 65 | assert "file contents\n" == result.stdout 66 | 67 | def test_prints_api_contents_and_exits_when_raw_option_set(self, mock_homedir_path): 68 | result = runner.invoke(app, ["get", "~", "--raw"]) 69 | 70 | assert '".bashrc": {"type": "file", "url": "bashrc_file_url"}' in result.stdout 71 | 72 | def test_lists_only_directories_when_dirs_option_set(self, mock_homedir_path, home_dir): 73 | mock_homedir_path.return_value.path = home_dir 74 | 75 | result = runner.invoke(app, ["get", "~", "--dirs"]) 76 | 77 | assert result.stdout.startswith(home_dir) 78 | for item, value in mock_homedir_path.return_value.contents.items(): 79 | if value["type"] == "file": 80 | assert item not in result.stdout 81 | elif value["type"] == "directory": 82 | assert item in result.stdout 83 | 84 | def test_lists_only_files_when_files_option_set(self, mock_homedir_path, home_dir): 85 | mock_homedir_path.return_value.path = home_dir 86 | 87 | result = runner.invoke(app, ["get", "~", "--files"]) 88 | 89 | assert result.stdout.startswith(home_dir) 90 | for item, value in mock_homedir_path.return_value.contents.items(): 91 | if value["type"] == "file": 92 | assert item in result.stdout 93 | elif value["type"] == "directory": 94 | assert item not in result.stdout 95 | 96 | def test_reverses_directory_content_list_when_reverse_option_set(self, mock_homedir_path, home_dir): 97 | mock_homedir_path.return_value.path = home_dir 98 | 99 | result = runner.invoke(app, ["get", "~", "--reverse"]) 100 | 101 | expected = dedent( 102 | f"""\ 103 | {home_dir}: 104 | D dir_two 105 | F b_file 106 | F a_file 107 | D a_dir 108 | F A_file 109 | F .bashrc 110 | """ 111 | ) 112 | 113 | assert expected == result.stdout 114 | 115 | def test_sorts_directory_content_list_by_type_when_type_option_set(self, mock_homedir_path, home_dir): 116 | mock_homedir_path.return_value.path = home_dir 117 | 118 | result = runner.invoke(app, ["get", "~", "--type"]) 119 | 120 | expected = dedent( 121 | f"""\ 122 | {home_dir}: 123 | D a_dir 124 | D dir_two 125 | F .bashrc 126 | F A_file 127 | F a_file 128 | F b_file 129 | """ 130 | ) 131 | 132 | assert expected == result.stdout 133 | 134 | def test_ignores_options_when_path_is_file(self, mock_file_path): 135 | result = runner.invoke(app, ["get", "~/some-file", "--type", "--reverse"]) 136 | 137 | assert "file contents\n" == result.stdout 138 | 139 | 140 | class TestTree: 141 | def test_prints_formatted_tree_when_successfull_api_call(self, mock_path, home_dir): 142 | mock_path.return_value.path = home_dir 143 | mock_path.return_value.tree = [ 144 | f"{home_dir}/README.txt", 145 | f"{home_dir}/dir_one/", 146 | f"{home_dir}/dir_one/bar.txt", 147 | f"{home_dir}/dir_one/nested_one/", 148 | f"{home_dir}/dir_one/nested_one/foo.txt", 149 | f"{home_dir}/dir_one/nested_two/", 150 | f"{home_dir}/empty/", 151 | f"{home_dir}/dir_two/", 152 | f"{home_dir}/dir_two/quux", 153 | f"{home_dir}/dir_two/baz/", 154 | f"{home_dir}/dir_three/", 155 | f"{home_dir}/dir_three/last.txt", 156 | ] 157 | 158 | result = runner.invoke(app, ["tree", "~"]) 159 | 160 | expected = dedent(f"""\ 161 | {home_dir}: 162 | . 163 | ├── README.txt 164 | ├── dir_one/ 165 | │ ├── bar.txt 166 | │ ├── nested_one/ 167 | │ │ └── foo.txt 168 | │ └── nested_two/ 169 | ├── empty/ 170 | ├── dir_two/ 171 | │ ├── quux 172 | │ └── baz/ 173 | └── dir_three/ 174 | └── last.txt 175 | """) 176 | 177 | assert result.stdout == expected 178 | 179 | def test_does_not_print_tree_when_path_is_incorrect(self, mock_path): 180 | mock_path.return_value.tree = None 181 | 182 | result = runner.invoke(app, ["tree", "/wrong/path"]) 183 | 184 | assert result.stdout == "" 185 | assert result.exit_code == 1 186 | 187 | def test_prints_tree_for_empty_directory(self, mock_path, home_dir): 188 | mock_path.return_value.path = f"{home_dir}/empty_dir" 189 | mock_path.return_value.tree = [] 190 | 191 | result = runner.invoke(app, ["tree", "~/empty_dir"]) 192 | 193 | expected = dedent(f"""\ 194 | {home_dir}/empty_dir: 195 | . 196 | 197 | """) 198 | assert result.stdout == expected 199 | 200 | 201 | class TestUpload: 202 | file = NamedTemporaryFile() 203 | 204 | def test_creates_pa_path_with_provided_path(self, mock_path, home_dir): 205 | runner.invoke(app, ["upload", "~/hello.txt", "-c", self.file.name]) 206 | mock_path.assert_called_once_with("~/hello.txt") 207 | 208 | def test_exits_with_success_when_successful_upload(self, mock_path): 209 | mock_path.return_value.upload.return_value = True 210 | 211 | result = runner.invoke(app, ["upload", "~/hello.txt", "-c", self.file.name]) 212 | 213 | assert mock_path.return_value.upload.called 214 | assert result.exit_code == 0 215 | 216 | def test_exits_with_error_when_unsuccessful_upload(self, mock_path): 217 | mock_path.return_value.upload.return_value = False 218 | 219 | result = runner.invoke(app, ["upload", "~/hello.txt", "-c", self.file.name]) 220 | 221 | assert mock_path.return_value.upload.called 222 | assert result.exit_code == 1 223 | 224 | 225 | class TestDelete: 226 | def test_creates_pa_path_with_provided_path(self, mock_path, home_dir): 227 | runner.invoke(app, ["delete", "~/hello.txt"]) 228 | mock_path.assert_called_once_with("~/hello.txt") 229 | 230 | def test_exits_with_success_when_successful_delete(self, mock_path): 231 | mock_path.return_value.delete.return_value = True 232 | 233 | result = runner.invoke(app, ["delete", "~/hello.txt"]) 234 | 235 | assert mock_path.return_value.delete.called 236 | assert result.exit_code == 0 237 | 238 | def test_exits_with_error_when_unsuccessful_delete(self, mock_path): 239 | mock_path.return_value.delete.return_value = False 240 | 241 | result = runner.invoke(app, ["delete", "~/hello.txt"]) 242 | 243 | assert mock_path.return_value.delete.called 244 | assert result.exit_code == 1 245 | 246 | 247 | class TestShare: 248 | def test_creates_pa_path_with_provided_path(self, mock_path, home_dir): 249 | runner.invoke(app, ["share", "~/hello.txt"]) 250 | mock_path.assert_called_once_with("~/hello.txt") 251 | 252 | def test_exits_with_success_when_successful_share(self, mocker, mock_path): 253 | result = runner.invoke(app, ["share", "~/hello.txt"]) 254 | 255 | assert mock_path.return_value.share.called 256 | assert not mock_path.return_value.get_sharing_url.called 257 | assert result.exit_code == 0 258 | 259 | def test_exits_with_success_and_prints_sharing_url_when_successful_share_and_porcelain_flag(self, mock_path): 260 | mock_path.return_value.share.return_value = "link" 261 | 262 | result = runner.invoke(app, ["share", "--porcelain", "~/hello.txt"]) 263 | 264 | assert "link" in result.stdout 265 | assert mock_path.return_value.share.called 266 | assert not mock_path.return_value.get_sharing_url.called 267 | assert result.exit_code == 0 268 | 269 | def test_exits_with_error_when_unsuccessful_share(self, mock_path): 270 | mock_path.return_value.share.return_value = "" 271 | 272 | result = runner.invoke(app, ["share", "~/hello.txt"]) 273 | 274 | assert mock_path.return_value.share.called 275 | assert not mock_path.return_value.get_sharing_url.called 276 | assert result.stdout == "" 277 | assert result.exit_code == 1 278 | 279 | def test_exits_with_success_when_path_already_shared(self, mock_path): 280 | result = runner.invoke(app, ["share", "--check", "~/hello.txt"]) 281 | 282 | assert mock_path.return_value.get_sharing_url.called 283 | assert not mock_path.return_value.share.called 284 | assert result.exit_code == 0 285 | 286 | def test_exits_with_success_and_prints_sharing_url_when_path_already_shared_and_porcelain_flag(self, mock_path): 287 | mock_path.return_value.get_sharing_url.return_value = "link" 288 | 289 | result = runner.invoke(app, ["share", "--check", "--porcelain", "~/hello.txt"]) 290 | 291 | assert mock_path.return_value.get_sharing_url.called 292 | assert not mock_path.return_value.share.called 293 | assert "link" in result.stdout 294 | assert result.exit_code == 0 295 | 296 | def test_exits_with_error_when_path_was_not_shared(self, mock_path): 297 | mock_path.return_value.get_sharing_url.return_value = "" 298 | 299 | result = runner.invoke(app, ["share", "--check", "~/hello.txt"]) 300 | 301 | assert mock_path.return_value.get_sharing_url.called 302 | assert not mock_path.return_value.share.called 303 | assert result.stdout == "" 304 | assert result.exit_code == 1 305 | 306 | 307 | class TestUnshare: 308 | def test_creates_pa_path_with_provided_path(self, mock_path, home_dir): 309 | runner.invoke(app, ["unshare", "~/hello.txt"]) 310 | mock_path.assert_called_once_with("~/hello.txt") 311 | 312 | def test_exits_with_success_when_successful_unshare_or_file_not_shared(self, mock_path): 313 | mock_path.return_value.unshare.return_value = True 314 | 315 | result = runner.invoke(app, ["unshare", "~/hello.txt"]) 316 | 317 | assert mock_path.return_value.unshare.called 318 | assert result.exit_code == 0 319 | 320 | def test_exits_with_error_when_unsuccessful_unshare(self, mock_path): 321 | mock_path.return_value.unshare.return_value = False 322 | 323 | result = runner.invoke(app, ["unshare", "~/hello.txt"]) 324 | 325 | assert mock_path.return_value.unshare.called 326 | assert result.exit_code == 1 327 | --------------------------------------------------------------------------------