diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4259262..fa9ed53 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,26 +1,21 @@ name: Tests -on: [push] +on: [push, pull_request] jobs: build: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '3.6', '3.7', '3.8', '3.9', '3.10' ] + python-version: [ "3.8", "3.9", "3.10", "3.11", "3.12", "3.13" ] name: Python ${{ matrix.python-version }} steps: - - uses: actions/checkout@v2 - - - name: Setup timezone - uses: zcong1993/setup-timezone@master - with: - timezone: UTC + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5 with: python-version: ${{ matrix.python-version }} @@ -33,8 +28,8 @@ jobs: - name: Test with pytest run: | - pytest + pytest -vvv - name: Check coverage run: | - pytest --cov=cli --cov=pythonanywhere --cov=scripts --cov-fail-under=65 \ No newline at end of file + pytest --cov=cli --cov=pythonanywhere --cov=scripts --cov-fail-under=65 diff --git a/README.md b/README.md index eba75c8..85ea224 100644 --- a/README.md +++ b/README.md @@ -14,12 +14,12 @@ from your own machine (see [usage](#Usage) below). ### On PythonAnywhere In a PythonAnywhere Bash console, run: - pip3.9 install --user pythonanywhere + pip3.10 install --user pythonanywhere -If there is no `python3.9` on your PythonAnywhere account, +If there is no `python3.10` on your PythonAnywhere account, you should upgrade your account to the newest system image. See [here](https://help.pythonanywhere.com/pages/ChangingSystemImage) how to do that. -`pa` works with python 3.6, 3.7 and 3.8, but we recommend using the latest system image. +`pa` works with python 3.8, 3.9, and 3.10 but we recommend using the latest system image. ### On your own machine Install the `pythonanywhere` package from [PyPI](https://pypi.org/project/pythonanywhere/). @@ -61,7 +61,7 @@ Some legacy [scripts](https://github.com/pythonanywhere/helper_scripts/blob/mast Pull requests are welcome! You'll find tests in the [tests](https://github.com/pythonanywhere/helper_scripts/blob/master/tests) folder... # prep your dev environment - mkvirtualenv --python=python3.6 helper_scripts + mkvirtualenv --python=python3.10 helper_scripts pip install -r requirements.txt pip install -e . diff --git a/cli/django.py b/cli/django.py index 0a59f79..ac36b7b 100644 --- a/cli/django.py +++ b/cli/django.py @@ -1,17 +1,23 @@ #!/usr/bin/python3 import typer +from snakesay import snakesay from pythonanywhere.django_project import DjangoProject -from pythonanywhere.snakesay import snakesay from pythonanywhere.utils import ensure_domain -app = typer.Typer() +app = typer.Typer(no_args_is_help=True) @app.command() def autoconfigure( repo_url: str = typer.Argument(..., help="url of remote git repository of your django project"), + branch: str = typer.Option( + "None", + "-b", + "--branch", + help="Branch name in case of multiple branches", + ), domain_name: str = typer.Option( "your-username.pythonanywhere.com", "-d", @@ -19,10 +25,10 @@ def autoconfigure( help="Domain name, eg www.mydomain.com", ), python_version: str = typer.Option( - "3.6", + "3.8", "-p", "--python-version", - help="Python version, eg '3.8'", + help="Python version, eg '3.9'", ), nuke: bool = typer.Option( False, @@ -43,6 +49,7 @@ def autoconfigure( project = DjangoProject(domain, python_version) project.sanity_checks(nuke=nuke) project.download_repo(repo_url, nuke=nuke), + project.ensure_branch(branch), project.create_virtualenv(nuke=nuke) project.create_webapp(nuke=nuke) project.add_static_file_mappings() diff --git a/cli/pa b/cli/pa deleted file mode 100755 index fa293aa..0000000 --- a/cli/pa +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/python3 - -import typer - -from cli import django -from cli import schedule -from cli import webapp -from cli import path - -help = """This is a new experimental PythonAnywhere cli client. - -It was build with typer & click under the hood. -""" - -app = typer.Typer(help=help) -app.add_typer(django.app, name="django", help="Makes Django Girls tutorial projects deployment easy") -app.add_typer(schedule.app, name="schedule", help="Manage scheduled tasks") -app.add_typer(webapp.app, name="webapp", help="Everything for web apps") -app.add_typer(path.app, name="path", help="Perform some operations on files") - - -if __name__ == "__main__": - app(prog_name="pa") diff --git a/cli/pa.py b/cli/pa.py new file mode 100755 index 0000000..84ba543 --- /dev/null +++ b/cli/pa.py @@ -0,0 +1,51 @@ +#!/usr/bin/python3 + +import typer + +from cli import django +from cli import path +from cli import schedule +from cli import students +from cli import webapp +from cli import website + +help = """This is a new experimental PythonAnywhere cli client. + +It was build with typer & click under the hood. +""" + +app = typer.Typer(help=help, no_args_is_help=True, context_settings={"help_option_names": ["--help", "-h"]}) +app.add_typer( + django.app, + name="django", + help="Makes Django Girls tutorial projects deployment easy" +) +app.add_typer( + path.app, + name="path", + help="Perform some operations on files" +) +app.add_typer( + schedule.app, + name="schedule", + help="Manage scheduled tasks" +) +app.add_typer( + students.app, + name="students", + help="Perform some operations on students" +) +app.add_typer( + webapp.app, + name="webapp", + help="Everything for web apps: use this if you're not using our experimental features" +) +app.add_typer( + website.app, + name="website", + help="EXPERIMENTAL: create and manage ASGI websites" +) + + +if __name__ == "__main__": + app(prog_name="pa") diff --git a/cli/path.py b/cli/path.py index 1845e08..f928576 100644 --- a/cli/path.py +++ b/cli/path.py @@ -1,15 +1,15 @@ +import json import re import sys from collections import namedtuple -from pprint import pprint import typer from pythonanywhere.files import PAPath from pythonanywhere.scripts_commons import get_logger -app = typer.Typer() +app = typer.Typer(no_args_is_help=True) def setup(path: str, quiet: bool) -> PAPath: @@ -44,7 +44,7 @@ def get( sys.exit(1) if raw or isinstance(contents, str): - {dict: pprint, str: print}[type(contents)](contents) + {dict: lambda x: print(json.dumps(x)), str: print}[type(contents)](contents) sys.exit() NameToType = namedtuple("NameToType", ["name", "type"]) diff --git a/cli/schedule.py b/cli/schedule.py index ebf938b..c8a0428 100644 --- a/cli/schedule.py +++ b/cli/schedule.py @@ -4,13 +4,13 @@ from typing import List import typer +from snakesay import snakesay from tabulate import tabulate from pythonanywhere.scripts_commons import get_logger, get_task_from_id, tabulate_formats -from pythonanywhere.snakesay import snakesay from pythonanywhere.task import Task, TaskList -app = typer.Typer() +app = typer.Typer(no_args_is_help=True) @app.command() diff --git a/cli/students.py b/cli/students.py new file mode 100644 index 0000000..8794dbc --- /dev/null +++ b/cli/students.py @@ -0,0 +1,96 @@ +import sys + +import typer + +from pythonanywhere.scripts_commons import get_logger +from pythonanywhere.students import Students + +app = typer.Typer(no_args_is_help=True) + + +def setup(quiet: bool) -> Students: + logger = get_logger(set_info=True) + if quiet: + logger.disabled = True + return Students() + + +@app.command() +def get( + numbered: bool = typer.Option( + False, "-n", "--numbered", help="Add ordering numbers." + ), + quiet: bool = typer.Option( + False, "-q", "--quiet", help="Disable additional logging." + ), + raw: bool = typer.Option( + False, "-a", "--raw", help="Print list of usernames from the API response." + ), + sort: bool = typer.Option(False, "-s", "--sort", help="Sort alphabetically"), + sort_reverse: bool = typer.Option( + False, "-r", "--reverse", help="Sort alphabetically in reverse order" + ), +): + """ + Get list of student usernames. + """ + + api = setup(quiet) + students = api.get() + + if students is None or students == []: + sys.exit(1) + + if raw: + typer.echo(students) + sys.exit() + + if sort or sort_reverse: + students.sort(reverse=sort_reverse) + + for number, student in enumerate(students, start=1): + line = f"{number:>3}. {student}" if numbered else student + typer.echo(line) + + +@app.command() +def delete( + student: str = typer.Argument(..., help="Username of a student to be removed."), + quiet: bool = typer.Option( + False, "-q", "--quiet", help="Disable additional logging." + ), +): + """ + Remove a student from the students list. + """ + + api = setup(quiet) + result = 0 if api.delete(student) else 1 + sys.exit(result) + + +@app.command() +def holidays( + quiet: bool = typer.Option( + False, "-q", "--quiet", help="Disable additional logging." + ), +): + """ + School's out for summer! School's out forever! (removes all students) + """ + + api = setup(quiet) + students = api.get() + + if not students: + sys.exit(1) + + result = 0 if all(api.delete(s) for s in students) else 1 + if not quiet: + typer.echo( + [ + f"Removed all {len(students)} students!", + f"Something went wrong, try again", + ][result] + ) + sys.exit(result) diff --git a/cli/webapp.py b/cli/webapp.py index b75ac60..a35d01f 100644 --- a/cli/webapp.py +++ b/cli/webapp.py @@ -4,13 +4,13 @@ from pathlib import Path import typer +from pythonanywhere_core.webapp import Webapp +from snakesay import snakesay -from pythonanywhere.api.webapp import Webapp from pythonanywhere.project import Project -from pythonanywhere.snakesay import snakesay from pythonanywhere.utils import ensure_domain -app = typer.Typer() +app = typer.Typer(no_args_is_help=True) @app.command() @@ -22,10 +22,10 @@ def create( help="Domain name, eg www.mydomain.com", ), python_version: str = typer.Option( - "3.6", + "3.8", "-p", "--python-version", - help="Python version, eg '3.8'", + help="Python version, eg '3.9'", ), nuke: bool = typer.Option( False, diff --git a/cli/website.py b/cli/website.py new file mode 100644 index 0000000..3e9fd72 --- /dev/null +++ b/cli/website.py @@ -0,0 +1,140 @@ +#!/usr/bin/python3 + +from typing_extensions import Annotated + +import typer +from snakesay import snakesay +from tabulate import tabulate + +from pythonanywhere_core.website import Website +from pythonanywhere_core.exceptions import PythonAnywhereApiException, DomainAlreadyExistsException + + +app = typer.Typer(no_args_is_help=True) + + +@app.command() +def create( + domain_name: Annotated[ + str, + typer.Option( + "-d", + "--domain", + help="Domain name, eg. yourusername.pythonanywhere.com or www.mydomain.com", + ) + ], + command: Annotated[ + str, + typer.Option( + "-c", + "--command", + help="The command to start up your server", + ) + ], +): + """Create an ASGI website""" + try: + Website().create(domain_name=domain_name, command=command) + except DomainAlreadyExistsException: + typer.echo(f"You already have a website for {domain_name}.") + raise typer.Exit(code=1) + except PythonAnywhereApiException as e: + typer.echo(str(e)) + raise typer.Exit(code=1) + + typer.echo( + snakesay( + f"All done! Your site is now live at {domain_name}. " + ) + ) + + +@app.command() +def get( + domain_name: str = typer.Option( + None, + "-d", + "--domain", + help="Get details for domain name, eg. yourusername.pythonanywhere.com or www.mydomain.com", + ) +): + """If no domain name is specified, list all domains. Otherwise get details for specified domain""" + website = Website() + if domain_name is not None: + website_info = website.get(domain_name=domain_name) + tabular_data = [ + ["domain name", website_info["domain_name"]], + ["cname", website_info["webapp"]["domains"][0].get("cname")], + ["enabled", website_info["enabled"]], + ["command", website_info["webapp"]["command"]], + ] + if "logfiles" in website_info: + tabular_data.extend( + [ + ["access log", website_info["logfiles"]["access"]], + ["error log", website_info["logfiles"]["error"]], + ["server log", website_info["logfiles"]["server"]], + ] + ) + tabular_data = [[k, v] for k, v in tabular_data if v is not None] + + table = tabulate(tabular_data, tablefmt="simple") + else: + websites = website.list() + table = tabulate( + [ + [website_info["domain_name"], website_info["enabled"]] + for website_info in websites + ], + headers=["domain name", "enabled"], + tablefmt="simple" + ) + typer.echo(table) + + +@app.command() +def reload( + domain_name: Annotated[ + str, + typer.Option( + "-d", + "--domain", + help="Domain name, eg. yourusername.pythonanywhere.com or www.mydomain.com", + ) + ], +): + """Reload the website at the given domain""" + Website().reload(domain_name=domain_name) + typer.echo(snakesay(f"Website {domain_name} has been reloaded!")) + + +@app.command() +def delete( + domain_name: Annotated[ + str, + typer.Option( + "-d", + "--domain", + help="Domain name, eg. yourusername.pythonanywhere.com or www.mydomain.com", + ) + ], +): + """Delete the website at the given domain""" + Website().delete(domain_name=domain_name) + typer.echo(snakesay(f"Website {domain_name} has been deleted!")) + + +@app.command() +def create_autorenew_cert( + domain_name: Annotated[ + str, + typer.Option( + "-d", + "--domain", + help="Domain name, eg. yourusername.pythonanywhere.com or www.mydomain.com", + ) + ], +): + """Create and apply an auto-renewing Let's Encrypt certificate for the given domain""" + Website().auto_ssl(domain_name=domain_name) + typer.echo(snakesay(f"Applied auto-renewing SSL certificate for {domain_name}!")) diff --git a/pytest.ini b/pytest.ini index e890eac..0441627 100644 --- a/pytest.ini +++ b/pytest.ini @@ -5,3 +5,4 @@ markers = slowtest: marks tests as one of slowest (deselect with '-m "not slowtest"') tasks: marks test as one of related to tasks files: marks test as one of related to files + students: marks test as one of related to students diff --git a/pythonanywhere/__init__.py b/pythonanywhere/__init__.py index a2fecb4..32e2f39 100644 --- a/pythonanywhere/__init__.py +++ b/pythonanywhere/__init__.py @@ -1 +1 @@ -__version__ = "0.9.2" +__version__ = "0.15.5" diff --git a/pythonanywhere/api/__init__.py b/pythonanywhere/api/__init__.py deleted file mode 100644 index bf2644f..0000000 --- a/pythonanywhere/api/__init__.py +++ /dev/null @@ -1,16 +0,0 @@ -import warnings - -from pythonanywhere.api.base import AuthenticationError, PYTHON_VERSIONS, call_api, get_api_endpoint -from pythonanywhere.api.webapp import Webapp - -## TODO PEP 562 __getattr__ should be used here to handle deprecation warnings nicely when we drop python 3.6. -## See https://www.python.org/dev/peps/pep-0562/#id8 - -warnings.warn( - """ - Importing from pythonanywhere.api is deprecated in favor of - pythonanywhere.api.base for call_api and get_api_endpoint - and pythonanywhere.api.webapp for Webapp - """, - DeprecationWarning -) diff --git a/pythonanywhere/api/base.py b/pythonanywhere/api/base.py deleted file mode 100644 index dd0505c..0000000 --- a/pythonanywhere/api/base.py +++ /dev/null @@ -1,55 +0,0 @@ -import os - -import requests - -PYTHON_VERSIONS = { - "3.6": "python36", - "3.7": "python37", - "3.8": "python38", - "3.9": "python39", - "3.10": "python310", -} - - -class AuthenticationError(Exception): - pass - - -class NoTokenError(Exception): - pass - - -def get_api_endpoint(): - hostname = os.environ.get( - "PYTHONANYWHERE_SITE", - "www." + os.environ.get( - "PYTHONANYWHERE_DOMAIN", - "pythonanywhere.com" - ) - ) - return f"https://{hostname}/api/v0/user/{{username}}/{{flavor}}/" - - -def call_api(url, method, **kwargs): - token = os.environ.get("API_TOKEN") - if token is None: - raise NoTokenError( - "Oops, you don't seem to have an API token. " - "Please go to the 'Account' page on PythonAnywhere, then to the 'API Token' " - "tab. Click the 'Create a new API token' button to create the token, then " - "start a new console and try running this script again." - ) - insecure = os.environ.get("PYTHONANYWHERE_INSECURE_API") == "true" - response = requests.request( - method=method, - url=url, - headers={"Authorization": f"Token {token}"}, - verify=not insecure, - **kwargs - ) - if response.status_code == 401: - print(response, response.text) - raise AuthenticationError( - f"Authentication error {response.status_code} calling API: {response.text}" - ) - return response diff --git a/pythonanywhere/api/base.pyi b/pythonanywhere/api/base.pyi deleted file mode 100644 index 52bc530..0000000 --- a/pythonanywhere/api/base.pyi +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Dict - -from requests import Response - -PYTHON_VERSIONS: Dict[str, str] = ... - -class AuthenticationError(Exception): ... -class NoTokenError(Exception): ... - -def get_api_endpoint() -> str: ... -def call_api(url: str, method: str, **kwargs) -> Response: ... - - diff --git a/pythonanywhere/api/files_api.py b/pythonanywhere/api/files_api.py deleted file mode 100644 index 320e27a..0000000 --- a/pythonanywhere/api/files_api.py +++ /dev/null @@ -1,171 +0,0 @@ -"""Interface speaking with PythonAnywhere API providing methods for -files. *Don't use* `Files` :class: in helper scripts, use -`pythonanywhere.files.Path` class instead.""" - -import getpass -from urllib.parse import urljoin - -from pythonanywhere.api.base import call_api, get_api_endpoint - - -class Files: - """ Interface for PythonAnywhere files API. - - Uses `pythonanywhere.api.base` :method: `get_api_endpoint` to - create url, which is stored in a class variable `Files.base_url`, - then calls `call_api` with appropriate arguments to execute files - action. - - Covers: - - GET, POST and DELETE for files path endpoint - - POST, GET and DELETE for files sharing endpoint - - GET for tree endpoint - - "path" methods: - - use :method: `Files.path_get` to get contents of file or - directory from `path` - - use :method: `Files.path_post` to upload or update file at given - `dest_path` using contents from `source` - - use :method: `Files.path_delete` to delete file/directory on on - given `path` - - "sharing" methods: - - use :method: `Files.sharing_post` to enable sharing a file from - `path` (if not shared before) and get a link to it - - use :method: `Files.sharing_get` to get sharing url for `path` - - use :method: `Files.sharing_delete` to disable sharing for - `path` - - "tree" method: - - use :method: `Files.tree_get` to get list of regular files and - subdirectories of a directory at `path` (limited to 1000 results) - """ - - base_url = get_api_endpoint().format(username=getpass.getuser(), flavor="files") - path_endpoint = urljoin(base_url, "path") - sharing_endpoint = urljoin(base_url, "sharing/") - tree_endpoint = urljoin(base_url, "tree/") - - def _error_msg(self, result): - """TODO: error responses should be unified at the API side """ - - if "application/json" in result.headers.get("content-type", ""): - jsn = result.json() - msg = jsn.get("detail") or jsn.get("message") or jsn.get("error", "") - return f": {msg}" - return "" - - def path_get(self, path): - """Returns dictionary of directory contents when `path` is an - absolute path to of an existing directory or file contents if - `path` is an absolute path to an existing file -- both - available to the PythonAnywhere user. Raises when `path` is - invalid or unavailable.""" - - url = f"{self.path_endpoint}{path}" - - result = call_api(url, "GET") - - if result.status_code == 200: - if "application/json" in result.headers.get("content-type", ""): - return result.json() - return result.content - - raise Exception( - f"GET to fetch contents of {url} failed, got {result}{self._error_msg(result)}" - ) - - def path_post(self, dest_path, content): - """Uploads contents of `content` to `dest_path` which should be - a valid absolute path of a file available to a PythonAnywhere - user. If `dest_path` contains directories which don't exist - yet, they will be created. - - Returns 200 if existing file on PythonAnywhere has been - updated with `source` contents, or 201 if file from - `dest_path` has been created with those contents.""" - - url = f"{self.path_endpoint}{dest_path}" - - result = call_api(url, "POST", files={"content": content}) - - if result.ok: - return result.status_code - - raise Exception( - f"POST to upload contents to {url} failed, got {result}{self._error_msg(result)}" - ) - - def path_delete(self, path): - """Deletes the file at specified `path` (if file is a - directory it will be deleted as well). - - Returns 204 on sucess, raises otherwise.""" - - url = f"{self.path_endpoint}{path}" - - result = call_api(url, "DELETE") - - if result.status_code == 204: - return result.status_code - - raise Exception( - f"DELETE on {url} failed, got {result}{self._error_msg(result)}" - ) - - def sharing_post(self, path): - """Starts sharing a file at `path`. - - Returns a tuple with a status code and sharing link on - success, raises otherwise. Status code is 201 on success, 200 - if file has been already shared.""" - - url = self.sharing_endpoint - - result = call_api(url, "POST", json={"path": path}) - - if result.ok: - return result.status_code, result.json()["url"] - - raise Exception( - f"POST to {url} to share '{path}' failed, got {result}{self._error_msg(result)}" - ) - - def sharing_get(self, path): - """Checks sharing status for a `path`. - - Returns url with sharing link if file is shared or an empty - string otherwise.""" - - url = f"{self.sharing_endpoint}?path={path}" - - result = call_api(url, "GET") - - return result.json()["url"] if result.ok else "" - - def sharing_delete(self, path): - """Stops sharing file at `path`. - - Returns 204 on successful unshare.""" - - url = f"{self.sharing_endpoint}?path={path}" - - result = call_api(url, "DELETE") - - return result.status_code - - def tree_get(self, path): - """Returns list of absolute paths of regular files and - subdirectories of a directory at `path`. Result is limited to - 1000 items. - - Raises if `path` does not point to an existing directory.""" - - url = f"{self.tree_endpoint}?path={path}" - - result = call_api(url, "GET") - - if result.ok: - return result.json() - - raise Exception(f"GET to {url} failed, got {result}{self._error_msg(result)}") diff --git a/pythonanywhere/api/files_api.pyi b/pythonanywhere/api/files_api.pyi deleted file mode 100644 index fb29414..0000000 --- a/pythonanywhere/api/files_api.pyi +++ /dev/null @@ -1,17 +0,0 @@ -from requests.models import Response -from typing import Tuple, Union - -class Files: - base_url: str = ... - path_endpoint: str = ... - sharing_endpoint: str = ... - tree_endpoint: str = ... - - def _error_msg(self, result: Response) -> str: ... - def path_get(self, path: str) -> Union[dict, bytes]: ... - def path_post(self, dest_path: str, source: bytes) -> int: ... - def path_delete(self, path: str) -> int: ... - def sharing_post(self, path: str) -> Tuple[int, str]: ... - def sharing_get(self, path: str) -> str: ... - def sharing_delete(self, path: str) -> int: ... - def tree_get(self, path: str) -> dict: ... diff --git a/pythonanywhere/api/schedule.py b/pythonanywhere/api/schedule.py deleted file mode 100644 index 3cbd7ab..0000000 --- a/pythonanywhere/api/schedule.py +++ /dev/null @@ -1,109 +0,0 @@ -""" Interface speaking with PythonAnywhere API providing methods for scheduled tasks. -*Don't use* `Schedule` :class: in helper scripts, use `task.Task` instead.""" - -import getpass - -from pythonanywhere.api.base import call_api, get_api_endpoint - - -class Schedule: - """Interface for PythonAnywhere scheduled tasks API. - - Uses `pythonanywhere.api` :method: `get_api_endpoint` to create url, - which is stored in a class variable `Schedule.base_url`, then calls - `call_api` with appropriate arguments to execute scheduled tasks tasks - actions. Covers 'GET' and 'POST' methods for tasks list, as well as - 'GET', 'PATCH' and 'DELETE' methods for task with id. - - Use :method: `Schedule.get_list` to get all tasks list. - Use :method: `Schedule.create` to create new task. - Use :method: `Schedule.get_specs` to get existing task specs. - Use :method: `Schedule.delete` to delete existing task. - Use :method: `Schedule.update` to update existing task.""" - - base_url = get_api_endpoint().format(username=getpass.getuser(), flavor="schedule") - - def get_list(self): - """Gets list of existing scheduled tasks. - - :returns: list of existing scheduled tasks specs""" - - return call_api(self.base_url, "GET").json() - - def create(self, params): - """Creates new scheduled task using `params`. - - Params should be: command, enabled (True or False), interval (daily or - hourly), hour (24h format) and minute. - - :param params: dictionary with required scheduled task specs - :returns: dictionary with created task specs""" - - result = call_api(self.base_url, "POST", json=params) - - if result.status_code == 201: - return result.json() - - if not result.ok: - raise Exception( - f"POST to set new task via API failed, got {result}: {result.text}" - ) - - def get_specs(self, task_id): - """Get task specs by id. - - :param task_id: existing task id - :returns: dictionary of existing task specs""" - - result = call_api( - f"{self.base_url}{task_id}/", "GET" - ) - if result.status_code == 200: - return result.json() - else: - raise Exception( - "Could not get task with id {task_id}. Got result {result}: {content}".format( - task_id=task_id, result=result, content=result.text - ) - ) - - def delete(self, task_id): - """Deletes scheduled task by id. - - :param task_id: scheduled task to be deleted id number - :returns: True when API response is 204""" - - result = call_api( - f"{self.base_url}{task_id}/", "DELETE" - ) - - if result.status_code == 204: - return True - - if not result.ok: - raise Exception( - f"DELETE via API on task {task_id} failed, got {result}: {result.text}" - ) - - def update(self, task_id, params): - """Updates existing task using id and params. - - Params should at least one of: command, enabled, interval, hour, - minute. To update hourly task don't use 'hour' param. On the other - hand when changing task's interval from 'hourly' to 'daily' hour is - required. - - :param task_id: existing task id - :param params: dictionary of specs to update""" - - result = call_api( - f"{self.base_url}{task_id}/", - "PATCH", - json=params, - ) - if result.status_code == 200: - return result.json() - else: - raise Exception( - f"Could not update task {task_id}. Got {result}: {result.text}" - ) diff --git a/pythonanywhere/api/schedule.pyi b/pythonanywhere/api/schedule.pyi deleted file mode 100644 index 99fcb9d..0000000 --- a/pythonanywhere/api/schedule.pyi +++ /dev/null @@ -1,11 +0,0 @@ -from typing import List, Optional - -from typing_extensions import Literal - -class Schedule: - base_url: str = ... - def get_list(self) -> List[dict]: ... - def create(self, params: dict) -> Optional[dict]: ... - def get_specs(self, task_id: int) -> dict: ... - def delete(self, task_id: int) -> Literal[True]: ... - def update(self, task_id: int, params: dict) -> dict: ... diff --git a/pythonanywhere/api/webapp.py b/pythonanywhere/api/webapp.py deleted file mode 100644 index bbe7589..0000000 --- a/pythonanywhere/api/webapp.py +++ /dev/null @@ -1,185 +0,0 @@ -import getpass -import os -from pathlib import Path -from textwrap import dedent - -from dateutil.parser import parse - -from pythonanywhere.api.base import PYTHON_VERSIONS, call_api, get_api_endpoint -from pythonanywhere.exceptions import SanityException -from pythonanywhere.snakesay import snakesay - - -class Webapp: - def __init__(self, domain): - self.domain = domain - - def __eq__(self, other): - return self.domain == other.domain - - def sanity_checks(self, nuke): - print(snakesay("Running API sanity checks")) - token = os.environ.get("API_TOKEN") - if not token: - raise SanityException( - dedent( - """ - Could not find your API token. - You may need to create it on the Accounts page? - You will also need to close this console and open a new one once you've done that. - """ - ) - ) - - if nuke: - return - - url = get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") + self.domain + "/" - response = call_api(url, "get") - if response.status_code == 200: - raise SanityException( - "You already have a webapp for {domain}.\n\nUse the --nuke option if you want to replace it.".format( - domain=self.domain - ) - ) - - def create(self, python_version, virtualenv_path, project_path, nuke): - print(snakesay("Creating web app via API")) - if nuke: - webapp_url = get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") + self.domain + "/" - call_api(webapp_url, "delete") - post_url = get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") - patch_url = post_url + self.domain + "/" - response = call_api( - post_url, "post", data={"domain_name": self.domain, "python_version": PYTHON_VERSIONS[python_version]} - ) - if not response.ok or response.json().get("status") == "ERROR": - raise Exception( - f"POST to create webapp via API failed, got {response}:{response.text}" - ) - response = call_api( - patch_url, "patch", data={"virtualenv_path": virtualenv_path, "source_directory": project_path} - ) - if not response.ok: - raise Exception( - "PATCH to set virtualenv path and source directory via API failed," - "got {response}:{response_text}".format(response=response, response_text=response.text) - ) - - def add_default_static_files_mappings(self, project_path): - print(snakesay("Adding static files mappings for /static/ and /media/")) - - url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") + self.domain + "/static_files/" - ) - call_api(url, "post", json=dict(url="/static/", path=str(Path(project_path) / "static"))) - call_api(url, "post", json=dict(url="/media/", path=str(Path(project_path) / "media"))) - - def reload(self): - print(snakesay(f"Reloading {self.domain} via API")) - url = get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") + self.domain + "/reload/" - response = call_api(url, "post") - if not response.ok: - if response.status_code == 409 and response.json()["error"] == "cname_error": - print( - snakesay( - dedent(""" - Could not find a CNAME for your website. If you're using an A record, - CloudFlare, or some other way of pointing your domain at PythonAnywhere - then that should not be a problem. If you're not, you should double-check - your DNS setup. - """) - ) - ) - return - raise Exception( - f"POST to reload webapp via API failed, got {response}:{response.text}" - ) - - def set_ssl(self, certificate, private_key): - print(snakesay(f"Setting up SSL for {self.domain} via API")) - url = get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") + self.domain + "/ssl/" - response = call_api(url, "post", json={"cert": certificate, "private_key": private_key}) - if not response.ok: - raise Exception( - dedent( - """ - POST to set SSL details via API failed, got {response}:{response_text} - If you just created an API token, you need to set the API_TOKEN environment variable or start a - new console. Also you need to have setup a `{domain}` PythonAnywhere webapp for this to work. - """ - ).format(response=response, response_text=response.text, domain=self.domain) - ) - - def get_ssl_info(self): - url = get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") + self.domain + "/ssl/" - response = call_api(url, "get") - if not response.ok: - raise Exception( - f"GET SSL details via API failed, got {response}:{response.text}" - ) - - result = response.json() - result["not_after"] = parse(result["not_after"]) - return result - - def delete_log(self, log_type, index=0): - if index: - print( - snakesay( - "Deleting old (archive number {index}) {type} log file for {domain} via API".format( - index=index, type=log_type, domain=self.domain - ) - ) - ) - else: - print( - snakesay( - f"Deleting current {log_type} log file for {self.domain} via API" - ) - ) - - if index == 1: - url = get_api_endpoint().format( - username=getpass.getuser(), flavor="files" - ) + f"path/var/log/{self.domain}.{log_type}.log.1/" - elif index > 1: - url = get_api_endpoint().format( - username=getpass.getuser(), flavor="files" - ) + f"path/var/log/{self.domain}.{log_type}.log.{index}.gz/" - else: - url = get_api_endpoint().format( - username=getpass.getuser(), flavor="files" - ) + f"path/var/log/{self.domain}.{log_type}.log/" - response = call_api(url, "delete") - if not response.ok: - raise Exception( - f"DELETE log file via API failed, got {response}:{response.text}" - ) - - def get_log_info(self): - url = get_api_endpoint().format(username=getpass.getuser(), flavor="files") + "tree/?path=/var/log/" - response = call_api(url, "get") - if not response.ok: - raise Exception( - f"GET log files info via API failed, got {response}:{response.text}" - ) - file_list = response.json() - log_types = ["access", "error", "server"] - logs = {"access": [], "error": [], "server": []} - log_prefix = f"/var/log/{self.domain}." - for file_name in file_list: - if type(file_name) == str and file_name.startswith(log_prefix): - log = file_name[len(log_prefix):].split(".") - if log[0] in log_types: - log_type = log[0] - if log[-1] == "log": - log_index = 0 - elif log[-1] == "1": - log_index = 1 - elif log[-1] == "gz": - log_index = int(log[-2]) - else: - continue - logs[log_type].append(log_index) - return logs diff --git a/pythonanywhere/api/webapp.pyi b/pythonanywhere/api/webapp.pyi deleted file mode 100644 index 4c34af2..0000000 --- a/pythonanywhere/api/webapp.pyi +++ /dev/null @@ -1,15 +0,0 @@ -from pathlib import Path -from typing import Dict, List, Any - -class Webapp: - domain: str = ... - def __init__(self, domain: str) -> None: ... - def __eq__(self, other: Webapp) -> bool: ... - def sanity_checks(self, nuke: bool) -> None: ... - def create(self, python_version: str, virtualenv_path: Path, project_path: Path, nuke: bool) -> None: ... - def add_default_static_files_mappings(self, project_path: Path) -> None: ... - def reload(self) -> None: ... - def set_ssl(self, certificate: str, private_key: str) -> None: ... - def get_ssl_info(self) -> Dict[str, Any]: ... - def delete_log(self, log_type: str, index: int = 0) -> None: ... - def get_log_info(self) -> Dict[str, List[int]]: ... \ No newline at end of file diff --git a/pythonanywhere/django_project.py b/pythonanywhere/django_project.py index 39619c3..02cbb97 100644 --- a/pythonanywhere/django_project.py +++ b/pythonanywhere/django_project.py @@ -4,13 +4,16 @@ import subprocess from packaging import version +from snakesay import snakesay from pythonanywhere.exceptions import SanityException -from pythonanywhere.snakesay import snakesay from .project import Project class DjangoProject(Project): + def django_version_newer_or_equal_than(self, this_version): + return version.parse(self.virtualenv.get_version("django")) >= version.parse(this_version) + def download_repo(self, repo, nuke): if nuke and self.project_path.exists(): shutil.rmtree(str(self.project_path)) @@ -51,24 +54,25 @@ def create_virtualenv(self, django_version=None, nuke=False): packages = f'django=={django_version}' self.virtualenv.pip_install(packages) - def detect_requirements(self): requirements_txt = self.project_path / 'requirements.txt' if requirements_txt.exists(): return f'-r {requirements_txt.resolve()}' return 'django' - - def run_startproject(self, nuke): print(snakesay('Starting Django project')) if nuke and self.project_path.exists(): shutil.rmtree(str(self.project_path)) self.project_path.mkdir() + + new_django = self.django_version_newer_or_equal_than("4.0") + django_admin_executable = "django-admin" if new_django else "django-admin.py" + subprocess.check_call([ - str(Path(self.virtualenv.path) / 'bin/django-admin.py'), - 'startproject', - 'mysite', + str(Path(self.virtualenv.path) / "bin" / django_admin_executable), + "startproject", + "mysite", str(self.project_path), ]) @@ -94,7 +98,7 @@ def update_settings_file(self): f'ALLOWED_HOSTS = [{self.domain!r}]' ) - new_django = version.parse(self.virtualenv.get_version("django")) >= version.parse("3.1") + new_django = self.django_version_newer_or_equal_than("3.1") if re.search(r'^MEDIA_ROOT\s*=', settings, flags=re.MULTILINE) is None: new_settings += "\nMEDIA_URL = '/media/'" diff --git a/pythonanywhere/files.py b/pythonanywhere/files.py index 7362c0a..b09a1cc 100644 --- a/pythonanywhere/files.py +++ b/pythonanywhere/files.py @@ -4,10 +4,10 @@ import getpass import logging -from urllib.parse import urljoin -from pythonanywhere.api.files_api import Files -from pythonanywhere.snakesay import snakesay +from snakesay import snakesay + +from pythonanywhere_core.files import Files logger = logging.getLogger("pythonanywhere") @@ -44,9 +44,6 @@ def __init__(self, path): def __repr__(self): return self.url - def _make_sharing_url(self, path): - return urljoin(self.api.base_url.split("api")[0], path) - @staticmethod def _standarize_path(path): return path.replace("~", f"/home/{getpass.getuser()}") if path.startswith("~") else path @@ -140,10 +137,9 @@ def get_sharing_url(self, quiet=False): url = self.api.sharing_get(self.path) if url: - sharing_url = self._make_sharing_url(url) if not quiet: - logger.info(snakesay(f"{self.path} is shared at {sharing_url}")) - return sharing_url + logger.info(snakesay(f"{self.path} is shared at {url}")) + return url logger.info(snakesay(f"{self.path} has not been shared")) @@ -154,15 +150,13 @@ def share(self): empty string when share not successful.""" try: - code, url = self.api.sharing_post(self.path) + msg, url = self.api.sharing_post(self.path) except Exception as e: logger.warning(snakesay(str(e))) return "" - msg = {200: "was already", 201: "successfully"}[code] - sharing_url = self._make_sharing_url(url) - logger.info(snakesay(f"{self.path} {msg} shared at {sharing_url}")) - return sharing_url + logger.info(snakesay(f"{self.path} {msg} at {url}")) + return url def unshare(self): """Returns `True` when file unshared or has not been shared, diff --git a/pythonanywhere/files.pyi b/pythonanywhere/files.pyi index c5811ec..d08eefa 100644 --- a/pythonanywhere/files.pyi +++ b/pythonanywhere/files.pyi @@ -2,7 +2,7 @@ from typing import Optional, Union from typer import FileBinaryRead -from pythonanywhere.api.files_api import Files +from pythonanywhere_core.files import Files class PAPath: diff --git a/pythonanywhere/project.py b/pythonanywhere/project.py index ada416b..ec682ae 100644 --- a/pythonanywhere/project.py +++ b/pythonanywhere/project.py @@ -1,11 +1,12 @@ from pathlib import Path import uuid -from pythonanywhere.api.webapp import Webapp +from pythonanywhere_core.webapp import Webapp +from snakesay import snakesay + from pythonanywhere.exceptions import SanityException from pythonanywhere.virtualenvs import Virtualenv from pythonanywhere.launch_bash_in_virtualenv import launch_bash_in_virtualenv -from pythonanywhere.snakesay import snakesay class Project: diff --git a/pythonanywhere/project.pyi b/pythonanywhere/project.pyi index 550e2dd..1f5db4a 100644 --- a/pythonanywhere/project.pyi +++ b/pythonanywhere/project.pyi @@ -1,6 +1,7 @@ from pathlib import Path -from pythonanywhere.api.webapp import Webapp +from pythonanywhere_core.webapp import Webapp + from pythonanywhere.virtualenvs import Virtualenv class Project: diff --git a/pythonanywhere/scripts_commons.py b/pythonanywhere/scripts_commons.py index 809b3d3..9b3c512 100644 --- a/pythonanywhere/scripts_commons.py +++ b/pythonanywhere/scripts_commons.py @@ -4,8 +4,8 @@ import sys from schema import And, Or, Schema, SchemaError, Use +from snakesay import snakesay -from pythonanywhere.snakesay import snakesay from pythonanywhere.task import Task logger = logging.getLogger(__name__) diff --git a/pythonanywhere/snakesay.py b/pythonanywhere/snakesay.py deleted file mode 100755 index eab09b4..0000000 --- a/pythonanywhere/snakesay.py +++ /dev/null @@ -1,42 +0,0 @@ -#!/usr/bin/env python3.6 -import sys -import textwrap - -MESSAGE = '\n'.join([ - '', - '{bubble}', - ' \\', - ' ~<:>>>>>>>>>', - -]) - - -def snakesay(*things): - bubble = '\n'.join(speech_bubble_lines(' '.join(things))) - return MESSAGE.format(bubble=bubble) - - -def speech_bubble_lines(speech): - lines, width = rewrap(speech) - if len(lines) <= 1: - text = ''.join(lines) - yield f'< {text} >' - - else: - yield ' ' + '_' * width - yield '/ ' + (' ' * width) + ' \\' - for line in lines: - yield f'| {line} |' - yield '\\ ' + (' ' * width) + ' /' - yield ' ' + '-' * width - - -def rewrap(speech): - lines = textwrap.wrap(speech) - width = max(len(l) for l in lines) if lines else 0 - return [line.ljust(width) for line in lines], width - - - -if __name__ == '__main__': - print(snakesay(*sys.argv[1:])) diff --git a/pythonanywhere/snakesay.pyi b/pythonanywhere/snakesay.pyi deleted file mode 100755 index ea7dd5b..0000000 --- a/pythonanywhere/snakesay.pyi +++ /dev/null @@ -1,7 +0,0 @@ -from typing import Type, Tuple, List, Iterator - -MESSAGE = Type[str] - -def snakesay(*things) -> str: ... -def speech_bubble_lines(speech: str) -> Iterator[str]: ... -def rewrap(speech: str) -> Tuple[List[str], int]: ... diff --git a/pythonanywhere/students.py b/pythonanywhere/students.py new file mode 100644 index 0000000..3d5c881 --- /dev/null +++ b/pythonanywhere/students.py @@ -0,0 +1,60 @@ +"""User interface for Pythonanywhere students API. + +Provides a class `Students` which should be used by helper scripts +providing features for programmatic listing and removing of the user's +students. +""" + +import logging + +from snakesay import snakesay + +from pythonanywhere_core.students import StudentsAPI + +logger = logging.getLogger("pythonanywhere") + + +class Students: + """Class providing interface for PythonAnywhere students API. + + To perform actions on students related with user's account, use + following methods: + - :method:`Students.get` to get a list of students + - :method:`Students.delete` to remove a student with a given username + """ + + def __init__(self): + self.api = StudentsAPI() + + def get(self): + """ + Returns list of usernames when user has students, otherwise an + empty list. + """ + + try: + result = self.api.get() + student_usernames = [student["username"] for student in result["students"]] + count = len(student_usernames) + if count: + msg = f"You have {count} student{'s' if count > 1 else ''}!" + else: + msg = "Currently you don't have any students." + logger.info(snakesay(msg)) + return student_usernames + except Exception as e: + logger.warning(snakesay(str(e))) + + def delete(self, username): + """ + Returns `True` when user with `username` successfully removed from + user's students list, `False` otherwise. + """ + + try: + self.api.delete(username) + logger.info(snakesay(f"{username!r} removed from the list of students!")) + return True + except Exception as e: + logger.warning(snakesay(str(e))) + return False diff --git a/pythonanywhere/students.pyi b/pythonanywhere/students.pyi new file mode 100644 index 0000000..f2ca598 --- /dev/null +++ b/pythonanywhere/students.pyi @@ -0,0 +1,8 @@ +from typing import Optional +from pythonanywhere.api.students_api import Students_Api + +class Students: + api: StudentsAPI = ... + def __init__(self) -> None: ... + def get(self) -> Optional[list]: ... + def delete(self, username: str) -> bool: ... diff --git a/pythonanywhere/task.py b/pythonanywhere/task.py index 7273c0f..6d91355 100644 --- a/pythonanywhere/task.py +++ b/pythonanywhere/task.py @@ -4,8 +4,9 @@ import logging -from pythonanywhere.api.schedule import Schedule -from pythonanywhere.snakesay import snakesay +from snakesay import snakesay + +from pythonanywhere_core.schedule import Schedule logger = logging.getLogger(name=__name__) @@ -97,7 +98,7 @@ def to_be_created(cls, *, command, minute, hour=None, disabled=False): task.command = command task.hour = hour task.minute = minute - task.interval = "daily" if hour else "hourly" + task.interval = "daily" if hour is not None else "hourly" task.enabled = not disabled return task @@ -138,7 +139,7 @@ def create_schedule(self): "interval": self.interval, "minute": self.minute, } - if self.hour: + if self.hour is not None: params["hour"] = self.hour self.update_specs(self.schedule.create(params)) diff --git a/pythonanywhere/virtualenvs.py b/pythonanywhere/virtualenvs.py index 289adf9..c4d51cb 100644 --- a/pythonanywhere/virtualenvs.py +++ b/pythonanywhere/virtualenvs.py @@ -2,7 +2,7 @@ import subprocess from pathlib import Path -from pythonanywhere.snakesay import snakesay +from snakesay import snakesay class Virtualenv: diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..789fa52 --- /dev/null +++ b/renovate.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "github>anaconda/renovate-config" + ], + "ignoreDeps": ["schema"] +} diff --git a/requirements.txt b/requirements.txt index cea5aec..5737cd8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,16 +1,20 @@ -python-dateutil==2.8.1 -click==8.0.3 +python-dateutil==2.9.0.post0 +click==8.1.7 docopt==0.6.2 +importlib-metadata==8.5.0 packaging -psutil==5.7.0 -pytest==6.2.5 -pytest-cov==3.0.0 -pytest-mock==3.6.1 -pytest-mypy==0.6.2 -requests==2.26.0 -responses==0.16.0 +psutil==6.1.0 +pytest==8.3.3 +pytest-cov==5.0.0 +pytest-mock==3.14.0 +pytest-mypy==0.10.3 +pythonanywhere_core==0.2.4 +requests==2.32.3 +responses==0.25.3 schema==0.7.2 -tabulate==0.8.9 -typer==0.4.0 -urllib3==1.26.7 -virtualenvwrapper==4.8.4 +snakesay==0.10.3 +tabulate==0.9.0 +typer==0.13.1 +urllib3==2.2.3 +virtualenvwrapper==6.1.1 +zipp>=3.19.1 # not directly required, pinned by Snyk to avoid a vulnerability diff --git a/scripts/pa_autoconfigure_django.py b/scripts/pa_autoconfigure_django.py index 5c41ecf..e211290 100755 --- a/scripts/pa_autoconfigure_django.py +++ b/scripts/pa_autoconfigure_django.py @@ -1,4 +1,4 @@ -#!/usr/bin/python3.6 +#!/usr/bin/python3.8 """Autoconfigure a Django project from on a github URL. - downloads the repo @@ -13,14 +13,14 @@ Options: --branch= Branch name in case of multiple branches [default: None] --domain= Domain name, eg www.mydomain.com [default: your-username.pythonanywhere.com] - --python= Python version, eg "3.8" [default: 3.6] + --python= Python version, eg "3.9" [default: 3.8] --nuke *Irrevocably* delete any existing web app config on this domain. Irrevocably. """ from docopt import docopt +from snakesay import snakesay from pythonanywhere.django_project import DjangoProject -from pythonanywhere.snakesay import snakesay from pythonanywhere.utils import ensure_domain diff --git a/scripts/pa_autoconfigure_django.pyi b/scripts/pa_autoconfigure_django.pyi index 605868c..9ce886c 100644 --- a/scripts/pa_autoconfigure_django.pyi +++ b/scripts/pa_autoconfigure_django.pyi @@ -1 +1 @@ -def main(repo_url: str, domain: str, python_version: str, nuke: bool) -> None: ... +def main(repo_url: str, branch: str, domain: str, python_version: str, nuke: bool) -> None: ... diff --git a/scripts/pa_create_scheduled_task.py b/scripts/pa_create_scheduled_task.py index d3dd937..9fd2e3d 100755 --- a/scripts/pa_create_scheduled_task.py +++ b/scripts/pa_create_scheduled_task.py @@ -1,4 +1,4 @@ -#!/usr/bin/python3.6 +#!/usr/bin/python3.8 """Create a scheduled task. Two categories of tasks are available: daily and hourly. diff --git a/scripts/pa_create_webapp_with_virtualenv.py b/scripts/pa_create_webapp_with_virtualenv.py index 99dd899..b31dca9 100755 --- a/scripts/pa_create_webapp_with_virtualenv.py +++ b/scripts/pa_create_webapp_with_virtualenv.py @@ -1,4 +1,4 @@ -#!/usr/bin/python3.6 +#!/usr/bin/python3.8 """Create a web app with a virtualenv - creates a simple hello world web app @@ -11,7 +11,7 @@ Options: --domain= Domain name, eg www.mydomain.com [default: your-username.pythonanywhere.com] - --python= Python version, eg "3.8" [default: 3.6] + --python= Python version, eg "3.9" [default: 3.8] --nuke *Irrevocably* delete any existing web app config on this domain. Irrevocably. """ @@ -19,8 +19,9 @@ import getpass from textwrap import dedent +from snakesay import snakesay + from pythonanywhere.project import Project -from pythonanywhere.snakesay import snakesay from pythonanywhere.utils import ensure_domain diff --git a/scripts/pa_delete_scheduled_task.py b/scripts/pa_delete_scheduled_task.py index 84122b7..80efb04 100755 --- a/scripts/pa_delete_scheduled_task.py +++ b/scripts/pa_delete_scheduled_task.py @@ -1,4 +1,4 @@ -#!/usr/bin/python3.6 +#!/usr/bin/python3.8 """Delete scheduled task(s) by id or nuke'em all. Usage: diff --git a/scripts/pa_delete_webapp_logs.py b/scripts/pa_delete_webapp_logs.py index a828912..e9cfcc3 100755 --- a/scripts/pa_delete_webapp_logs.py +++ b/scripts/pa_delete_webapp_logs.py @@ -1,4 +1,4 @@ -#!/usr/bin/python3.6 +#!/usr/bin/python3.8 """Deletes webapp logs. - gets list of logs via api @@ -14,9 +14,9 @@ """ from docopt import docopt +from pythonanywhere_core.webapp import Webapp +from snakesay import snakesay -from pythonanywhere.api.webapp import Webapp -from pythonanywhere.snakesay import snakesay from pythonanywhere.utils import ensure_domain diff --git a/scripts/pa_get_scheduled_task_specs.py b/scripts/pa_get_scheduled_task_specs.py index 39d20ea..a38adc3 100755 --- a/scripts/pa_get_scheduled_task_specs.py +++ b/scripts/pa_get_scheduled_task_specs.py @@ -1,4 +1,4 @@ -#!/usr/bin/python3.6 +#!/usr/bin/python3.8 """Get current scheduled task's specs file by task id. Available specs are: command, enabled, interval, hour, minute, printable-time, @@ -43,10 +43,10 @@ pa_get_scheduled_task_specs 42 --logfile --no-spec""" from docopt import docopt +from snakesay import snakesay from tabulate import tabulate from pythonanywhere.scripts_commons import ScriptSchema, get_logger, get_task_from_id -from pythonanywhere.snakesay import snakesay def main(*, task_id, **kwargs): diff --git a/scripts/pa_get_scheduled_tasks_list.py b/scripts/pa_get_scheduled_tasks_list.py index e5236c7..571233d 100755 --- a/scripts/pa_get_scheduled_tasks_list.py +++ b/scripts/pa_get_scheduled_tasks_list.py @@ -1,4 +1,4 @@ -#!/usr/bin/python3.6 +#!/usr/bin/python3.8 """Get list of user's scheduled tasks as a table with columns: id, interval, at (hour:minute/minute past), status (enabled/disabled), command. @@ -17,10 +17,10 @@ the table.""" from docopt import docopt +from snakesay import snakesay from tabulate import tabulate from pythonanywhere.scripts_commons import ScriptSchema, get_logger -from pythonanywhere.snakesay import snakesay from pythonanywhere.task import TaskList diff --git a/scripts/pa_install_webapp_letsencrypt_ssl.py b/scripts/pa_install_webapp_letsencrypt_ssl.py index 66038cb..50609d3 100755 --- a/scripts/pa_install_webapp_letsencrypt_ssl.py +++ b/scripts/pa_install_webapp_letsencrypt_ssl.py @@ -1,4 +1,4 @@ -#!/usr/bin/python3.6 +#!/usr/bin/python3.8 """Set the HTTPS certificate and private key for a website, assuming that these have been generated by the dehydrated script that gets them from Let's Encrypt, and that they're in the standard place. This script should normally only be run on PythonAnywhere. @@ -17,8 +17,8 @@ import os import sys -from pythonanywhere.api.webapp import Webapp -from pythonanywhere.snakesay import snakesay +from pythonanywhere_core.webapp import Webapp +from snakesay import snakesay def main(domain_name, suppress_reload): diff --git a/scripts/pa_install_webapp_ssl.py b/scripts/pa_install_webapp_ssl.py index ca42eba..36e4407 100755 --- a/scripts/pa_install_webapp_ssl.py +++ b/scripts/pa_install_webapp_ssl.py @@ -1,4 +1,4 @@ -#!/usr/bin/python3.6 +#!/usr/bin/python3.8 """Set the HTTPS certificate and private key for a website to the contents of two files, and reload the site. Usage: @@ -15,12 +15,12 @@ -- this happens by default, use this option to suppress it. """ -from docopt import docopt import os import sys -from pythonanywhere.api.webapp import Webapp -from pythonanywhere.snakesay import snakesay +from docopt import docopt +from pythonanywhere_core.webapp import Webapp +from snakesay import snakesay def main(domain_name, certificate_file, private_key_file, suppress_reload): diff --git a/scripts/pa_reload_webapp.py b/scripts/pa_reload_webapp.py index 0119aa0..5f59c7f 100755 --- a/scripts/pa_reload_webapp.py +++ b/scripts/pa_reload_webapp.py @@ -1,4 +1,4 @@ -#!/usr/bin/python3.6 +#!/usr/bin/python3.8 """Reloads the given site Usage: @@ -10,8 +10,8 @@ from docopt import docopt -from pythonanywhere.api.webapp import Webapp -from pythonanywhere.snakesay import snakesay +from pythonanywhere_core.webapp import Webapp +from snakesay import snakesay def main(domain_name): diff --git a/scripts/pa_start_django_webapp_with_virtualenv.py b/scripts/pa_start_django_webapp_with_virtualenv.py index c857a72..1981cc5 100755 --- a/scripts/pa_start_django_webapp_with_virtualenv.py +++ b/scripts/pa_start_django_webapp_with_virtualenv.py @@ -1,6 +1,6 @@ -#!/usr/bin/python3.6 +#!/usr/bin/python3.8 """Create a new Django webapp with a virtualenv. Defaults to -your free domain, the latest version of Django and Python 3.6 +your free domain, the latest version of Django and Python 3.8 Usage: pa_start_django_webapp_with_virtualenv.py [--domain= --django= --python=] [--nuke] @@ -8,13 +8,13 @@ Options: --domain= Domain name, eg www.mydomain.com [default: your-username.pythonanywhere.com] --django= Django version, eg "1.8.4" [default: latest] - --python= Python version, eg "2.7" [default: 3.6] + --python= Python version, eg "2.7" [default: 3.8] --nuke *Irrevocably* delete any existing web app config on this domain. Irrevocably. """ from docopt import docopt +from snakesay import snakesay -from pythonanywhere.snakesay import snakesay from pythonanywhere.django_project import DjangoProject from pythonanywhere.utils import ensure_domain diff --git a/scripts/pa_update_scheduled_task.py b/scripts/pa_update_scheduled_task.py index efc2910..0e17af4 100755 --- a/scripts/pa_update_scheduled_task.py +++ b/scripts/pa_update_scheduled_task.py @@ -1,4 +1,4 @@ -#!/usr/bin/python3.6 +#!/usr/bin/python3.8 """Update a scheduled task using id and proper specs. Note that logfile name will change after updating the task but it won't be @@ -48,9 +48,9 @@ from datetime import datetime from docopt import docopt +from snakesay import snakesay from pythonanywhere.scripts_commons import ScriptSchema, get_logger, get_task_from_id -from pythonanywhere.snakesay import snakesay def main(*, task_id, **kwargs): diff --git a/setup.py b/setup.py index 11b974a..ef57b92 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ setup( name="pythonanywhere", - version="0.9.11", + version="0.15.5", description="PythonAnywhere helper tools for users", long_description=long_description, long_description_content_type="text/markdown", @@ -22,31 +22,39 @@ "Intended Audience :: Developers", "Topic :: Software Development :: Libraries", "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.6", ], keywords="pythonanywhere api cloud web hosting", - packages=["cli", "pythonanywhere", "pythonanywhere.api"], + packages=[ + "cli", + "pythonanywhere", + ], install_requires=[ "docopt", "packaging", "python-dateutil", + "pythonanywhere_core==0.2.4", "requests", "schema", + "snakesay", "tabulate", "typer", ], extras_require={}, - python_requires=">=3.6", + python_requires=">=3.8", package_data={}, data_files=[], - entry_points={}, + entry_points={ + "console_scripts": [ + "pa=cli.pa:app", + ] + }, scripts=[ - "cli/pa", - "pythonanywhere/snakesay.py", "scripts/pa_autoconfigure_django.py", "scripts/pa_create_scheduled_task.py", "scripts/pa_create_webapp_with_virtualenv.py", diff --git a/tests/conftest.py b/tests/conftest.py index 5653dda..37794ef 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import tempfile from getpass import getuser from pathlib import Path +from platform import python_version from unittest.mock import Mock, patch import psutil @@ -39,7 +40,7 @@ def fake_home(local_pip_cache): os.environ["HOME"] = str(tempdir) yield tempdir os.environ["HOME"] = old_home - shutil.rmtree(str(tempdir)) + shutil.rmtree(str(tempdir), ignore_errors=True) new_stuff = set(Path(old_home).iterdir()) - old_home_contents if new_stuff: @@ -62,7 +63,7 @@ def virtualenvs_folder(): os.environ["WORKON_HOME"] = old_workon else: del os.environ["WORKON_HOME"] - shutil.rmtree(str(tempdir)) + shutil.rmtree(str(tempdir), ignore_errors=True) if actual_virtualenvs.is_dir(): new_envs = set(actual_virtualenvs.iterdir()) - set(old_virtualenvs) @@ -120,3 +121,21 @@ def process_killer(): for child in psutil.Process(p.pid).children(): child.kill() p.kill() + +@pytest.fixture +def running_python_version(): + return ".".join(python_version().split(".")[:2]) + +@pytest.fixture +def new_django_version(running_python_version): + if running_python_version in ["3.10", "3.11", "3.12", "3.13"]: + return "5.1.3" + else: + return "4.2.16" + +@pytest.fixture +def old_django_version(running_python_version): + if running_python_version in ["3.10", "3.11", "3.12", "3.13"]: + return "5.1.2" + else: + return "4.2.15" diff --git a/tests/test_api_base.py b/tests/test_api_base.py deleted file mode 100644 index 92750bb..0000000 --- a/tests/test_api_base.py +++ /dev/null @@ -1,45 +0,0 @@ -from unittest.mock import patch - -import pytest -import responses - -from pythonanywhere.api.base import AuthenticationError, call_api, get_api_endpoint - - -class TestGetAPIEndpoint: - - def test_defaults_to_pythonanywhere_dot_com_if_no_environment_variables(self): - assert get_api_endpoint() == "https://www.pythonanywhere.com/api/v0/user/{username}/{flavor}/" - - def test_gets_domain_from_pythonanywhere_site_and_ignores_pythonanywhere_domain_if_both_set(self, monkeypatch): - monkeypatch.setenv("PYTHONANYWHERE_SITE", "www.foo.com") - monkeypatch.setenv("PYTHONANYWHERE_DOMAIN", "wibble.com") - assert get_api_endpoint() == "https://www.foo.com/api/v0/user/{username}/{flavor}/" - - def test_gets_domain_from_pythonanywhere_domain_and_adds_on_www_if_set_but_no_pythonanywhere_site( - self, monkeypatch - ): - monkeypatch.setenv("PYTHONANYWHERE_DOMAIN", "foo.com") - assert get_api_endpoint() == "https://www.foo.com/api/v0/user/{username}/{flavor}/" - - -class TestCallAPI: - def test_raises_on_401(self, api_token, api_responses): - url = "https://foo.com/" - api_responses.add(responses.POST, url, status=401, body="nope") - with pytest.raises(AuthenticationError) as e: - call_api(url, "post") - assert str(e.value) == "Authentication error 401 calling API: nope" - - def test_passes_verify_from_environment(self, api_token, monkeypatch): - monkeypatch.setenv("PYTHONANYWHERE_INSECURE_API", "true") - with patch("pythonanywhere.api.base.requests") as mock_requests: - call_api("url", "post", foo="bar") - args, kwargs = mock_requests.request.call_args - assert kwargs["verify"] is False - - def test_verify_is_true_if_env_not_set(self, api_token): - with patch("pythonanywhere.api.base.requests") as mock_requests: - call_api("url", "post", foo="bar") - args, kwargs = mock_requests.request.call_args - assert kwargs["verify"] is True diff --git a/tests/test_api_files.py b/tests/test_api_files.py deleted file mode 100644 index 718d4b1..0000000 --- a/tests/test_api_files.py +++ /dev/null @@ -1,358 +0,0 @@ -import getpass -import json -import tempfile -from unittest.mock import patch -from urllib.parse import urljoin - -import pytest -import responses - -from pythonanywhere.api.base import get_api_endpoint -from pythonanywhere.api.files_api import Files - - -class TestFiles: - username = getpass.getuser() - base_url = get_api_endpoint().format(username=username, flavor="files") - home_dir_path = f"/home/{username}" - default_home_dir_files = { - ".bashrc": {"type": "file", "url": f"{base_url}path{home_dir_path}/.bashrc"}, - ".gitconfig": {"type": "file", "url": f"{base_url}path{home_dir_path}/.gitconfig"}, - ".local": {"type": "directory", "url": f"{base_url}path{home_dir_path}/.local"}, - ".profile": {"type": "file", "url": f"{base_url}path{home_dir_path}/.profile"}, - "README.txt": {"type": "file", "url": f"{base_url}path{home_dir_path}/README.txt"}, - } - readme_contents = ( - b"# vim: set ft=rst:\n\nSee https://help.pythonanywhere.com/ " - b'(or click the "Help" link at the top\nright) ' - b"for help on how to use PythonAnywhere, including tips on copying and\n" - b"pasting from consoles, and writing your own web applications.\n" - ) - - -@pytest.mark.files -class TestFilesPathGet(TestFiles): - def test_returns_contents_of_directory_when_path_to_dir_provided( - self, api_token, api_responses, - ): - dir_url = urljoin(self.base_url, f"path{self.home_dir_path}") - api_responses.add( - responses.GET, - url=dir_url, - status=200, - body=json.dumps(self.default_home_dir_files), - headers={"Content-Type": "application/json"}, - ) - - assert Files().path_get(self.home_dir_path) == self.default_home_dir_files - - def test_returns_file_contents_when_file_path_provided(self, api_token, api_responses): - filepath = urljoin(self.home_dir_path, "README.txt") - file_url = urljoin(self.base_url, f"path{filepath}") - body = self.readme_contents - api_responses.add( - responses.GET, - url=file_url, - status=200, - body=body, - headers={"Content-Type": "application/octet-stream; charset=utf-8"}, - ) - - assert Files().path_get(filepath) == body - - def test_raises_because_wrong_path_provided(self, api_token, api_responses): - wrong_path = "/foo" - wrong_url = urljoin(self.base_url, f"path{wrong_path}") - body = bytes(f'{{"detail": "No such file or directory: {wrong_path}"}}', "utf") - api_responses.add( - responses.GET, - url=wrong_url, - status=404, - body=body, - headers={"Content-Type": "application/json"}, - ) - - with pytest.raises(Exception) as e: - Files().path_get(wrong_path) - - expected_error_msg = ( - f"GET to fetch contents of {wrong_url} failed, got : " - f"No such file or directory: {wrong_path}" - ) - assert str(e.value) == expected_error_msg - - -@pytest.mark.files -class TestFilesPathPost(TestFiles): - def test_returns_200_when_file_updated(self, api_token, api_responses): - existing_file_path = f"{self.home_dir_path}/README.txt" - existing_file_url = self.default_home_dir_files["README.txt"]["url"] - api_responses.add( - responses.POST, - url=existing_file_url, - status=200, - ) - content = "content".encode() - - result = Files().path_post(existing_file_path, content) - - assert result == 200 - - def test_returns_201_when_file_uploaded(self, api_token, api_responses): - new_file_path = f"{self.home_dir_path}/new.txt" - new_file_url = f"{self.base_url}path{self.home_dir_path}/new.txt" - api_responses.add( - responses.POST, - url=new_file_url, - status=201, - ) - content = "content".encode() - - result = Files().path_post(new_file_path, content) - - assert result == 201 - - def test_raises_when_wrong_path(self, api_token, api_responses): - invalid_path = "foo" - url_with_invalid_path = urljoin(self.base_url, f"path{invalid_path}") - api_responses.add( - responses.POST, - url=url_with_invalid_path, - status=404, - ) - content = "content".encode() - - with pytest.raises(Exception) as e: - Files().path_post(invalid_path, content) - - expected_error_msg = ( - f"POST to upload contents to {url_with_invalid_path} failed, got " - ) - assert str(e.value) == expected_error_msg - - def test_raises_when_no_contents_provided(self, api_token, api_responses): - valid_path = f"{self.home_dir_path}/README.txt" - valid_url = urljoin(self.base_url, f"path{valid_path}") - body = bytes('{"detail": "You must provide a file with the name \'content\'."}', "utf") - api_responses.add( - responses.POST, - url=valid_url, - status=400, - body=body, - headers={"Content-Type": "application/json"}, - ) - - with pytest.raises(Exception) as e: - Files().path_post(valid_path, None) - - expected_error_msg = ( - f"POST to upload contents to {valid_url} failed, got : " - "You must provide a file with the name 'content'." - ) - assert str(e.value) == expected_error_msg - - -@pytest.mark.files -class TestFilesPathDelete(TestFiles): - def test_returns_204_on_successful_file_deletion(self, api_token, api_responses): - valid_path = f"{self.home_dir_path}/README.txt" - valid_url = urljoin(self.base_url, f"path{valid_path}") - api_responses.add( - responses.DELETE, - url=valid_url, - status=204, - ) - - result = Files().path_delete(valid_path) - - assert result == 204 - - def test_raises_when_permission_denied(self, api_token, api_responses): - home_dir_url = urljoin(self.base_url, f"path{self.home_dir_path}") - body = bytes( - '{"message":"You do not have permission to delete this","code":"forbidden"}', - "utf" - ) - api_responses.add( - responses.DELETE, - url=home_dir_url, - status=403, - body=body, - headers={"Content-Type": "application/json"}, - ) - - with pytest.raises(Exception) as e: - Files().path_delete(self.home_dir_path) - - expected_error_msg = ( - f"DELETE on {home_dir_url} failed, got : " - "You do not have permission to delete this" - ) - assert str(e.value) == expected_error_msg - - def test_raises_when_wrong_path_provided(self, api_token, api_responses): - invalid_path = "/home/some_other_user/" - invalid_url = urljoin(self.base_url, f"path{invalid_path}") - body = bytes('{"message":"File does not exist","code":"not_found"}', "utf") - api_responses.add( - responses.DELETE, - url=invalid_url, - status=404, - body=body, - headers={"Content-Type": "application/json"}, - ) - - with pytest.raises(Exception) as e: - Files().path_delete(invalid_path) - - expected_error_msg = ( - f"DELETE on {invalid_url} failed, got : " - "File does not exist" - ) - assert str(e.value) == expected_error_msg - - -@pytest.mark.files -class TestFilesSharingPost(TestFiles): - def test_returns_url_when_path_successfully_shared_or_has_been_shared_before( - self, api_token, api_responses - ): - valid_path = f"{self.home_dir_path}/README.txt" - shared_url = f"/user/{self.username}/shares/asdf1234/" - partial_response = dict( - method=responses.POST, - url=urljoin(self.base_url, "sharing/"), - body=bytes(f'{{"url": "{shared_url}"}}', "utf"), - headers={"Content-Type": "application/json"}, - ) - api_responses.add(**partial_response, status=201) - api_responses.add(**partial_response, status=200) - - files = Files() - first_share = files.sharing_post(valid_path) - - assert first_share[0] == 201 - assert first_share[1] == shared_url - - second_share = files.sharing_post(valid_path) - - assert second_share[0] == 200 - assert second_share[1] == shared_url - - @pytest.mark.skip(reason="not implemented in the api yet") - def test_raises_exception_when_path_not_provided(self, api_token, api_responses): - url = urljoin(self.base_url, "sharing/") - api_responses.add( - responses.POST, - url=url, - status=400, - body=bytes('{"error": "required field (path) not found"}', "utf"), - headers={"Content-Type": "application/json"}, - ) - - with pytest.raises(Exception) as e: - Files().sharing_post("") - - expected_error_msg = ( - f"POST to {url} to share '' failed, got : " - "provided path is not valid" # or similar - ) - assert str(e.value) == expected_error_msg - - -@pytest.mark.files -class TestFilesSharingGet(TestFiles): - def test_returns_sharing_url_when_path_is_shared(self, api_token, api_responses): - valid_path = f"{self.home_dir_path}/README.txt" - sharing_url = urljoin(self.base_url, f"sharing/") - get_url = urljoin(self.base_url, f"sharing/?path={valid_path}") - shared_url = f"/user/{self.username}/shares/asdf1234/" - partial_response = dict( - body=bytes(f'{{"url": "{shared_url}"}}', "utf"), - headers={"Content-Type": "application/json"}, - ) - api_responses.add(**partial_response, method=responses.POST, url=sharing_url, status=201) - api_responses.add(**partial_response, method=responses.GET, url=get_url, status=200) - files = Files() - files.sharing_post(valid_path) - - assert files.sharing_get(valid_path) == shared_url - - def test_returns_empty_string_when_path_not_shared(self, api_token, api_responses): - valid_path = f"{self.home_dir_path}/README.txt" - url = urljoin(self.base_url, f"sharing/?path={valid_path}") - api_responses.add(method=responses.GET, url=url, status=404) - - assert Files().sharing_get(valid_path) == "" - - -@pytest.mark.files -class TestFilesSharingDelete(TestFiles): - def test_returns_204_on_sucessful_unshare(self, api_token, api_responses): - valid_path = f"{self.home_dir_path}/README.txt" - url = urljoin(self.base_url, f"sharing/?path={valid_path}") - shared_url = f"/user/{self.username}/shares/asdf1234/" - api_responses.add(method=responses.DELETE, url=url, status=204) - - assert Files().sharing_delete(valid_path) == 204 - - -@pytest.mark.files -class TestFilesTreeGet(TestFiles): - def test_returns_list_of_the_regular_files_and_subdirectories_of_a_directory( - self, api_token, api_responses - ): - url = urljoin(self.base_url, f"tree/?path={self.home_dir_path}") - self.default_home_dir_files["foo"] = { - "type": "directory", "url": f"{self.base_url}path{self.home_dir_path}/foo" - }, - tree = f'["{self.home_dir_path}/README.txt", "{self.home_dir_path}/foo/"]' - api_responses.add( - responses.GET, - url=url, - status=200, - body=bytes(tree, "utf"), - headers={"Content-Type": "application/json"}, - ) - - result = Files().tree_get(self.home_dir_path) - - assert result == [f"{self.home_dir_path}/{file}" for file in ["README.txt", "foo/"]] - - def test_raises_when_path_not_pointing_to_directory(self, api_token, api_responses): - invalid_path = "/hmoe/oof" - url = urljoin(self.base_url, f"tree/?path={invalid_path}") - api_responses.add( - responses.GET, - url=url, - status=400, - body=bytes(f'{{"detail": "{invalid_path} is not a directory"}}', "utf"), - headers={"Content-Type": "application/json"}, - ) - - with pytest.raises(Exception) as e: - Files().tree_get(invalid_path) - - expected_error_msg = ( - f"GET to {url} failed, got : {invalid_path} is not a directory" - ) - assert str(e.value) == expected_error_msg - - def test_raises_when_path_does_not_exist(self, api_token, api_responses): - invalid_path = "/hmoe/oof" - url = urljoin(self.base_url, f"tree/?path={invalid_path}") - api_responses.add( - responses.GET, - url=url, - status=400, - body=bytes(f'{{"detail": "{invalid_path} does not exist"}}', "utf"), - headers={"Content-Type": "application/json"}, - ) - - with pytest.raises(Exception) as e: - Files().tree_get(invalid_path) - - expected_error_msg = ( - f"GET to {url} failed, got : {invalid_path} does not exist" - ) - assert str(e.value) == expected_error_msg diff --git a/tests/test_api_schedule.py b/tests/test_api_schedule.py deleted file mode 100644 index 86cc2e6..0000000 --- a/tests/test_api_schedule.py +++ /dev/null @@ -1,167 +0,0 @@ -import getpass -import json - -import pytest -import responses - -from pythonanywhere.api.base import get_api_endpoint -from pythonanywhere.api.schedule import Schedule - - -@pytest.fixture -def task_base_url(): - return get_api_endpoint().format(username=getpass.getuser(), flavor="schedule") - - -@pytest.fixture -def task_specs(): - username = getpass.getuser() - return { - "can_enable": False, - "command": "echo foo", - "enabled": True, - "expiry": None, - "extend_url": f"/user/{username}/schedule/task/123/extend", - "hour": 16, - "id": 123, - "interval": "daily", - "logfile": "/user/{username}/files/var/log/tasklog-126708-daily-at-1600-echo_foo.log", - "minute": 0, - "printable_time": "16:00", - "url": f"/api/v0/user/{username}/schedule/123", - "user": username, - } - - -@pytest.fixture -def hourly_task_params(): - return { - "command": "echo foo", - "enabled": True, - "interval": "hourly", - "minute": 0, - } - - -@pytest.fixture -def daily_task_params(hourly_task_params): - return hourly_task_params.update({"interval": "daily", "hour": 16}) - - -@pytest.mark.tasks -class TestScheduleCreate: - def test_creates_daily_task( - self, api_token, api_responses, task_specs, daily_task_params, task_base_url - ): - api_responses.add( - responses.POST, url=task_base_url, status=201, body=json.dumps(task_specs) - ) - - assert Schedule().create(daily_task_params) == task_specs - - def test_creates_hourly_task( - self, api_token, api_responses, task_specs, hourly_task_params, task_base_url - ): - hourly_specs = {"hour": None, "interval": "hourly", "printable_time": "00 minutes past"} - task_specs.update(hourly_specs) - api_responses.add( - responses.POST, url=task_base_url, status=201, body=json.dumps(task_specs) - ) - - assert Schedule().create(hourly_task_params) == task_specs - - def test_raises_because_missing_params(self, api_token, api_responses, task_base_url): - body = ( - '{"interval":["This field is required."],"command":["This field is required."],' - '"minute":["This field is required."]}' - ) - api_responses.add(responses.POST, url=task_base_url, status=400, body=body) - - with pytest.raises(Exception) as e: - Schedule().create({}) - - expected_error_msg = "POST to set new task via API failed, got : " + body - assert str(e.value) == expected_error_msg - - -@pytest.mark.tasks -class TestScheduleDelete: - def test_deletes_task(self, api_token, api_responses, task_base_url): - url = task_base_url + "42/" - api_responses.add(responses.DELETE, url=url, status=204) - - result = Schedule().delete(42) - - post = api_responses.calls[0] - assert post.request.url == url - assert post.request.body is None - assert result is True - - def test_raises_because_attempt_to_delete_nonexisting_task( - self, api_token, api_responses, task_base_url - ): - body = '{"detail": "Not fount."}' - api_responses.add( - responses.DELETE, url=task_base_url + "42/", status=404, body=body, - ) - - with pytest.raises(Exception) as e: - Schedule().delete(42) - - assert str(e.value) == "DELETE via API on task 42 failed, got : " + body - - -@pytest.mark.tasks -class TestScheduleGetSpecs: - def test_returns_spec_dict(self, api_token, api_responses, task_base_url, task_specs): - api_responses.add( - responses.GET, url=task_base_url + "123/", status=200, body=json.dumps(task_specs) - ) - - assert Schedule().get_specs(123) == task_specs - - def test_raises_because_attempt_to_get_nonexisting_task( - self, api_token, api_responses, task_base_url - ): - body = '{"detail":"Not found."}' - api_responses.add(responses.GET, url=task_base_url + "42/", status=404, body=body) - - with pytest.raises(Exception) as e: - Schedule().get_specs(42) - - expected_error_msg = "Could not get task with id 42. Got result : " + body - assert str(e.value) == expected_error_msg - - -@pytest.mark.tasks -class TestScheduleGetList: - def test_returns_tasks_list(self, api_token, api_responses, task_base_url): - fake_specs = [{"fake": "specs"}, {"and": "more"}] - api_responses.add( - responses.GET, url=task_base_url, status=200, body=json.dumps(fake_specs), - ) - - assert Schedule().get_list() == fake_specs - - -@pytest.mark.tasks -class TestScheduleUpdate: - def test_updates_daily_task( - self, api_token, api_responses, task_specs, daily_task_params, task_base_url - ): - api_responses.add( - responses.PATCH, url=task_base_url + "123/", status=200, body=json.dumps(task_specs), - ) - - assert Schedule().update(123, daily_task_params) == task_specs - - def test_raises_when_wrong_params( - self, api_token, api_responses, task_specs, daily_task_params, task_base_url - ): - body = '{"non_field_errors":["Hourly tasks must not have an hour."]}' - api_responses.add(responses.PATCH, url=task_base_url + "1/", status=400, body=body) - - with pytest.raises(Exception) as e: - Schedule().update(1, {"hour": 23}) - - assert str(e.value) == "Could not update task 1. Got : " + body diff --git a/tests/test_api_webapp.py b/tests/test_api_webapp.py deleted file mode 100644 index 6cd5b3f..0000000 --- a/tests/test_api_webapp.py +++ /dev/null @@ -1,528 +0,0 @@ -import getpass -import json -from datetime import datetime -from urllib.parse import urlencode - -from dateutil.tz import tzutc -import pytest -import responses -from pythonanywhere.api.base import PYTHON_VERSIONS, get_api_endpoint -from pythonanywhere.api.webapp import Webapp -from pythonanywhere.exceptions import SanityException - - -class TestWebapp: - def test_init(self): - app = Webapp("www.my-domain.com") - assert app.domain == "www.my-domain.com" - - def test_compare_equal(self): - assert Webapp("www.my-domain.com") == Webapp("www.my-domain.com") - - def test_compare_not_equal(self): - assert Webapp("www.my-domain.com") != Webapp("www.other-domain.com") - - -class TestWebappSanityChecks: - domain = "www.domain.com" - expected_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") - + domain - + "/" - ) - - def test_does_not_complain_if_api_token_exists(self, api_token, api_responses): - webapp = Webapp(self.domain) - api_responses.add(responses.GET, self.expected_url, status=404) - webapp.sanity_checks(nuke=False) # should not raise - - def test_raises_if_no_api_token_exists(self, api_responses, no_api_token): - webapp = Webapp(self.domain) - with pytest.raises(SanityException) as e: - webapp.sanity_checks(nuke=False) - assert "Could not find your API token" in str(e.value) - - def test_raises_if_webapp_already_exists(self, api_token, api_responses): - webapp = Webapp(self.domain) - api_responses.add( - responses.GET, - self.expected_url, - status=200, - body=json.dumps({"id": 1, "domain_name": self.domain}), - ) - - with pytest.raises(SanityException) as e: - webapp.sanity_checks(nuke=False) - - assert "You already have a webapp for " + self.domain in str(e.value) - assert "nuke" in str(e.value) - - def test_does_not_raise_if_no_webapp(self, api_token, api_responses): - webapp = Webapp(self.domain) - api_responses.add(responses.GET, self.expected_url, status=404) - webapp.sanity_checks(nuke=False) # should not raise - - def test_nuke_option_overrides_all_but_token_check( - self, api_token, api_responses, fake_home, virtualenvs_folder - ): - webapp = Webapp(self.domain) - (fake_home / self.domain).mkdir() - (virtualenvs_folder / self.domain).mkdir() - - webapp.sanity_checks(nuke=True) # should not raise - - -class TestCreateWebapp: - def test_does_post_to_create_webapp(self, api_responses, api_token): - expected_post_url = get_api_endpoint().format( - username=getpass.getuser(), flavor="webapps" - ) - expected_patch_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") - + "mydomain.com/" - ) - api_responses.add( - responses.POST, - expected_post_url, - status=201, - body=json.dumps({"status": "OK"}), - ) - api_responses.add(responses.PATCH, expected_patch_url, status=200) - - Webapp("mydomain.com").create( - "3.7", "/virtualenv/path", "/project/path", nuke=False - ) - - post = api_responses.calls[0] - assert post.request.url == expected_post_url - assert post.request.body == urlencode( - {"domain_name": "mydomain.com", "python_version": PYTHON_VERSIONS["3.7"]} - ) - assert post.request.headers["Authorization"] == f"Token {api_token}" - - def test_does_patch_to_update_virtualenv_path_and_source_directory( - self, api_responses, api_token - ): - expected_post_url = get_api_endpoint().format( - username=getpass.getuser(), flavor="webapps" - ) - expected_patch_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") - + "mydomain.com/" - ) - api_responses.add( - responses.POST, - expected_post_url, - status=201, - body=json.dumps({"status": "OK"}), - ) - api_responses.add(responses.PATCH, expected_patch_url, status=200) - - Webapp("mydomain.com").create( - "3.7", "/virtualenv/path", "/project/path", nuke=False - ) - - patch = api_responses.calls[1] - assert patch.request.url == expected_patch_url - assert patch.request.body == urlencode( - {"virtualenv_path": "/virtualenv/path", "source_directory": "/project/path"} - ) - assert patch.request.headers["Authorization"] == f"Token {api_token}" - - def test_raises_if_post_does_not_20x(self, api_responses, api_token): - expected_post_url = get_api_endpoint().format( - username=getpass.getuser(), flavor="webapps" - ) - api_responses.add( - responses.POST, expected_post_url, status=500, body="an error" - ) - - with pytest.raises(Exception) as e: - Webapp("mydomain.com").create( - "3.7", "/virtualenv/path", "/project/path", nuke=False - ) - - assert "POST to create webapp via API failed" in str(e.value) - assert "an error" in str(e.value) - - def test_raises_if_post_returns_a_200_with_status_error( - self, api_responses, api_token - ): - expected_post_url = get_api_endpoint().format( - username=getpass.getuser(), flavor="webapps" - ) - api_responses.add( - responses.POST, - expected_post_url, - status=200, - body=json.dumps( - { - "status": "ERROR", - "error_type": "bad", - "error_message": "bad things happened", - } - ), - ) - - with pytest.raises(Exception) as e: - Webapp("mydomain.com").create( - "3.7", "/virtualenv/path", "/project/path", nuke=False - ) - - assert "POST to create webapp via API failed" in str(e.value) - assert "bad things happened" in str(e.value) - - def test_raises_if_patch_does_not_20x(self, api_responses, api_token): - expected_post_url = get_api_endpoint().format( - username=getpass.getuser(), flavor="webapps" - ) - expected_patch_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") - + "mydomain.com/" - ) - api_responses.add( - responses.POST, - expected_post_url, - status=201, - body=json.dumps({"status": "OK"}), - ) - api_responses.add( - responses.PATCH, - expected_patch_url, - status=400, - json={"message": "an error"}, - ) - - with pytest.raises(Exception) as e: - Webapp("mydomain.com").create( - "3.7", "/virtualenv/path", "/project/path", nuke=False - ) - - assert ( - "PATCH to set virtualenv path and source directory via API failed" - in str(e.value) - ) - assert "an error" in str(e.value) - - def test_does_delete_first_for_nuke_call(self, api_responses, api_token): - post_url = get_api_endpoint().format( - username=getpass.getuser(), flavor="webapps" - ) - webapp_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") - + "mydomain.com/" - ) - api_responses.add(responses.DELETE, webapp_url, status=200) - api_responses.add( - responses.POST, post_url, status=201, body=json.dumps({"status": "OK"}) - ) - api_responses.add(responses.PATCH, webapp_url, status=200) - - Webapp("mydomain.com").create( - "3.7", "/virtualenv/path", "/project/path", nuke=True - ) - - delete = api_responses.calls[0] - assert delete.request.method == "DELETE" - assert delete.request.url == webapp_url - assert delete.request.headers["Authorization"] == f"Token {api_token}" - - def test_ignores_404_from_delete_call_when_nuking(self, api_responses, api_token): - post_url = get_api_endpoint().format( - username=getpass.getuser(), flavor="webapps" - ) - webapp_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") - + "mydomain.com/" - ) - api_responses.add(responses.DELETE, webapp_url, status=404) - api_responses.add( - responses.POST, post_url, status=201, body=json.dumps({"status": "OK"}) - ) - api_responses.add(responses.PATCH, webapp_url, status=200) - - Webapp("mydomain.com").create( - "3.7", "/virtualenv/path", "/project/path", nuke=True - ) - - -class TestAddDefaultStaticFilesMapping: - def test_does_two_posts_to_static_files_endpoint(self, api_token, api_responses): - expected_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") - + "mydomain.com/static_files/" - ) - api_responses.add(responses.POST, expected_url, status=201) - api_responses.add(responses.POST, expected_url, status=201) - - Webapp("mydomain.com").add_default_static_files_mappings("/project/path") - - post1 = api_responses.calls[0] - assert post1.request.url == expected_url - assert post1.request.headers["content-type"] == "application/json" - assert post1.request.headers["Authorization"] == f"Token {api_token}" - assert json.loads(post1.request.body.decode("utf8")) == { - "url": "/static/", - "path": "/project/path/static", - } - post2 = api_responses.calls[1] - assert post2.request.url == expected_url - assert post2.request.headers["content-type"] == "application/json" - assert post2.request.headers["Authorization"] == f"Token {api_token}" - assert json.loads(post2.request.body.decode("utf8")) == { - "url": "/media/", - "path": "/project/path/media", - } - - -class TestReloadWebapp: - def test_does_post_to_reload_url(self, api_responses, api_token): - expected_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") - + "mydomain.com/reload/" - ) - api_responses.add(responses.POST, expected_url, status=200) - - Webapp("mydomain.com").reload() - - post = api_responses.calls[0] - assert post.request.url == expected_url - assert post.request.body is None - assert post.request.headers["Authorization"] == f"Token {api_token}" - - def test_raises_if_post_does_not_20x_that_is_not_a_cname_error( - self, api_responses, api_token - ): - expected_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") - + "mydomain.com/reload/" - ) - api_responses.add(responses.POST, expected_url, status=404, body="nope") - - with pytest.raises(Exception) as e: - Webapp("mydomain.com").reload() - - assert "POST to reload webapp via API failed" in str(e.value) - assert "nope" in str(e.value) - - def test_does_not_raise_if_post_responds_with_a_cname_error( - self, api_responses, api_token - ): - expected_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") - + "mydomain.com/reload/" - ) - api_responses.add( - responses.POST, - expected_url, - status=409, - json={"status": "error", "error": "cname_error"}, - ) - - ## Should not raise - Webapp("mydomain.com").reload() - - -class TestSetWebappSSL: - def test_does_post_to_ssl_url(self, api_responses, api_token): - expected_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") - + "mydomain.com/ssl/" - ) - api_responses.add(responses.POST, expected_url, status=200) - certificate = "certificate data" - private_key = "private key data" - - Webapp("mydomain.com").set_ssl(certificate, private_key) - - post = api_responses.calls[0] - assert post.request.url == expected_url - assert json.loads(post.request.body.decode("utf8")) == { - "private_key": "private key data", - "cert": "certificate data", - } - assert post.request.headers["Authorization"] == f"Token {api_token}" - - def test_raises_if_post_does_not_20x(self, api_responses, api_token): - expected_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") - + "mydomain.com/ssl/" - ) - api_responses.add(responses.POST, expected_url, status=404, body="nope") - - with pytest.raises(Exception) as e: - Webapp("mydomain.com").set_ssl("foo", "bar") - - assert "POST to set SSL details via API failed" in str(e.value) - assert "nope" in str(e.value) - - -class TestGetWebappSSLInfo: - def test_returns_json_from_server_having_parsed_expiry_with_z_for_utc_and_no_separators( - self, api_responses, api_token - ): - expected_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") - + "mydomain.com/ssl/" - ) - api_responses.add( - responses.GET, - expected_url, - status=200, - body=json.dumps( - { - "not_after": "20180824T171623Z", - "issuer_name": "PythonAnywhere test CA", - "subject_name": "www.mycoolsite.com", - "subject_alternate_names": ["www.mycoolsite.com", "mycoolsite.com"], - } - ), - ) - - assert Webapp("mydomain.com").get_ssl_info() == { - "not_after": datetime(2018, 8, 24, 17, 16, 23, tzinfo=tzutc()), - "issuer_name": "PythonAnywhere test CA", - "subject_name": "www.mycoolsite.com", - "subject_alternate_names": ["www.mycoolsite.com", "mycoolsite.com"], - } - - get = api_responses.calls[0] - assert get.request.method == "GET" - assert get.request.url == expected_url - assert get.request.headers["Authorization"] == f"Token {api_token}" - - def test_returns_json_from_server_having_parsed_expiry_with_timezone_offset_and_separators( - self, api_responses, api_token - ): - expected_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") - + "mydomain.com/ssl/" - ) - api_responses.add( - responses.GET, - expected_url, - status=200, - body=json.dumps( - { - "not_after": "2018-08-24T17:16:23+00:00", - "issuer_name": "PythonAnywhere test CA", - "subject_name": "www.mycoolsite.com", - "subject_alternate_names": ["www.mycoolsite.com", "mycoolsite.com"], - } - ), - ) - - assert Webapp("mydomain.com").get_ssl_info() == { - "not_after": datetime(2018, 8, 24, 17, 16, 23, tzinfo=tzutc()), - "issuer_name": "PythonAnywhere test CA", - "subject_name": "www.mycoolsite.com", - "subject_alternate_names": ["www.mycoolsite.com", "mycoolsite.com"], - } - - get = api_responses.calls[0] - assert get.request.method == "GET" - assert get.request.url == expected_url - assert get.request.headers["Authorization"] == f"Token {api_token}" - - def test_raises_if_get_does_not_return_200(self, api_responses, api_token): - expected_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="webapps") - + "mydomain.com/ssl/" - ) - api_responses.add(responses.GET, expected_url, status=404, body="nope") - - with pytest.raises(Exception) as e: - Webapp("mydomain.com").get_ssl_info() - - assert "GET SSL details via API failed, got" in str(e.value) - assert "nope" in str(e.value) - - -class TestDeleteWebappLog: - def test_delete_current_access_log(self, api_responses, api_token): - expected_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="files") - + "path/var/log/mydomain.com.access.log/" - ) - api_responses.add(responses.DELETE, expected_url, status=200) - - Webapp("mydomain.com").delete_log(log_type="access") - - post = api_responses.calls[0] - assert post.request.url == expected_url - assert post.request.body is None - assert post.request.headers["Authorization"] == f"Token {api_token}" - - def test_delete_old_access_log(self, api_responses, api_token): - expected_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="files") - + "path/var/log/mydomain.com.access.log.1/" - ) - api_responses.add(responses.DELETE, expected_url, status=200) - - Webapp("mydomain.com").delete_log(log_type="access", index=1) - - post = api_responses.calls[0] - assert post.request.url == expected_url - assert post.request.body is None - assert post.request.headers["Authorization"] == f"Token {api_token}" - - def test_raises_if_delete_does_not_20x(self, api_responses, api_token): - expected_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="files") - + "path/var/log/mydomain.com.access.log/" - ) - api_responses.add(responses.DELETE, expected_url, status=404, body="nope") - - with pytest.raises(Exception) as e: - Webapp("mydomain.com").delete_log(log_type="access") - - assert "DELETE log file via API failed" in str(e.value) - assert "nope" in str(e.value) - - -class TestGetWebappLogs: - def test_get_list_of_logs(self, api_responses, api_token): - expected_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="files") - + "tree/?path=/var/log/" - ) - api_responses.add( - responses.GET, - expected_url, - status=200, - body=json.dumps( - [ - "/var/log/blah", - "/var/log/mydomain.com.access.log", - "/var/log/mydomain.com.access.log.1", - "/var/log/mydomain.com.access.log.2.gz", - "/var/log/mydomain.com.error.log", - "/var/log/mydomain.com.error.log.1", - "/var/log/mydomain.com.error.log.2.gz", - "/var/log/mydomain.com.server.log", - "/var/log/mydomain.com.server.log.1", - "/var/log/mydomain.com.server.log.2.gz", - ] - ), - ) - - logs = Webapp("mydomain.com").get_log_info() - - post = api_responses.calls[0] - assert post.request.url == expected_url - assert post.request.headers["Authorization"] == f"Token {api_token}" - assert logs == {"access": [0, 1, 2], "error": [0, 1, 2], "server": [0, 1, 2]} - - def test_raises_if_get_does_not_20x(self, api_responses, api_token): - expected_url = ( - get_api_endpoint().format(username=getpass.getuser(), flavor="files") - + "tree/?path=/var/log/" - ) - api_responses.add(responses.GET, expected_url, status=404, body="nope") - - with pytest.raises(Exception) as e: - Webapp("mydomain.com").get_log_info() - - assert "GET log files info via API failed" in str(e.value) - assert "nope" in str(e.value) diff --git a/tests/test_cli_django.py b/tests/test_cli_django.py index 3189ffa..9d7a4a3 100644 --- a/tests/test_cli_django.py +++ b/tests/test_cli_django.py @@ -25,7 +25,7 @@ def mock_update_wsgi_file(mocker): @pytest.fixture def mock_call_api(mocker): - return mocker.patch("pythonanywhere.api.webapp.call_api") + return mocker.patch("pythonanywhere_core.webapp.call_api") @pytest.fixture @@ -33,6 +33,15 @@ def running_python_version(): return ".".join(python_version().split(".")[:2]) +def test_main_subcommand_without_args_prints_help(): + result = runner.invoke( + app, + [], + ) + assert result.exit_code == 0 + assert "Show this message and exit." in result.stdout + + def test_autoconfigure_calls_all_stuff_in_right_order(mock_django_project): result = runner.invoke( app, @@ -50,6 +59,7 @@ def test_autoconfigure_calls_all_stuff_in_right_order(mock_django_project): assert mock_django_project.return_value.method_calls == [ call.sanity_checks(nuke=True), call.download_repo("repo.url", nuke=True), + call.ensure_branch("None"), call.create_virtualenv(nuke=True), call.create_webapp(nuke=True), call.add_static_file_mappings(), @@ -77,6 +87,8 @@ def test_autoconfigure_actually_works_against_example_repo( process_killer, running_python_version, ): + git_ref = "non-nested-old" if running_python_version in ["3.8", "3.9"] else "master" + expected_django_version = "4.2.16" if running_python_version in ["3.8", "3.9"] else "5.1.3" mocker.patch("cli.django.DjangoProject.start_bash") repo = "https://github.com/pythonanywhere/example-django-project.git" domain = "mydomain.com" @@ -90,10 +102,11 @@ def test_autoconfigure_actually_works_against_example_repo( domain, "-p", running_python_version, + "--branch", + git_ref, ], ) - expected_django_version = "3.0.6" expected_virtualenv = virtualenvs_folder / domain expected_project_path = fake_home / domain django_project_name = "myproject" @@ -113,7 +126,7 @@ def test_autoconfigure_actually_works_against_example_repo( with expected_settings_path.open() as f: lines = f.read().split("\n") - assert "MEDIA_ROOT = os.path.join(BASE_DIR, 'media')" in lines + assert "MEDIA_ROOT = Path(BASE_DIR / 'media')" in lines assert "ALLOWED_HOSTS = ['mydomain.com'] # type: List[str]" in lines assert "base.css" in os.listdir(str(fake_home / domain / "static/admin/css")) @@ -171,6 +184,7 @@ def test_start_actually_creates_django_project_in_virtualenv_with_hacked_setting virtualenvs_folder, api_token, running_python_version, + new_django_version, ): runner.invoke( app, @@ -179,7 +193,7 @@ def test_start_actually_creates_django_project_in_virtualenv_with_hacked_setting "-d", "mydomain.com", "-j", - "2.2.12", + new_django_version, "-p", running_python_version, ], @@ -195,11 +209,11 @@ def test_start_actually_creates_django_project_in_virtualenv_with_hacked_setting .decode() .strip() ) - assert django_version == "2.2.12" + assert django_version == new_django_version with (fake_home / "mydomain.com/mysite/settings.py").open() as f: lines = f.read().split("\n") - assert "MEDIA_ROOT = os.path.join(BASE_DIR, 'media')" in lines + assert "MEDIA_ROOT = Path(BASE_DIR / 'media')" in lines assert "ALLOWED_HOSTS = ['mydomain.com']" in lines assert "base.css" in os.listdir(str(fake_home / "mydomain.com/static/admin/css")) @@ -213,10 +227,9 @@ def test_nuke_option_lets_you_run_twice( virtualenvs_folder, api_token, running_python_version, + old_django_version, + new_django_version, ): - old_django_version = "2.2.12" - new_django_version = "3.0.6" - runner.invoke( app, [ diff --git a/tests/test_cli_pa.py b/tests/test_cli_pa.py new file mode 100644 index 0000000..a722972 --- /dev/null +++ b/tests/test_cli_pa.py @@ -0,0 +1,24 @@ +import typer.core + +from typer.testing import CliRunner + +from cli.pa import app + +typer.core.rich = None # Workaround to disable rich output to make testing on github actions easier +# TODO: remove this workaround +runner = CliRunner() + + +def test_main_command_without_args_prints_help(): + result = runner.invoke( + app, + [], + ) + assert result.exit_code == 0 + assert "This is a new experimental PythonAnywhere cli client." in result.stdout + assert "Makes Django Girls tutorial projects deployment easy" in result.stdout + assert "Perform some operations on files" in result.stdout + assert "Manage scheduled tasks" in result.stdout + assert "Perform some operations on students" in result.stdout + assert "Everything for web apps: use this if you're not using" in result.stdout + assert "EXPERIMENTAL: create and manage ASGI websites" in result.stdout diff --git a/tests/test_cli_path.py b/tests/test_cli_path.py index 8da9457..499123f 100644 --- a/tests/test_cli_path.py +++ b/tests/test_cli_path.py @@ -41,6 +41,15 @@ def mock_file_path(mock_path): return mock_path +def test_main_subcommand_without_args_prints_help(): + result = runner.invoke( + app, + [], + ) + assert result.exit_code == 0 + assert "Show this message and exit." in result.stdout + + class TestGet: def test_exits_early_when_no_contents_for_given_path(self, mock_path): mock_path.return_value.contents = None @@ -58,7 +67,7 @@ def test_prints_file_contents_and_exits_when_path_is_file(self, mock_file_path, def test_prints_api_contents_and_exits_when_raw_option_set(self, mock_homedir_path): result = runner.invoke(app, ["get", "~", "--raw"]) - assert "'.bashrc': {'type': 'file', 'url': 'bashrc_file_url'}" in result.stdout + assert '".bashrc": {"type": "file", "url": "bashrc_file_url"}' in result.stdout def test_lists_only_directories_when_dirs_option_set(self, mock_homedir_path, home_dir): mock_homedir_path.return_value.path = home_dir diff --git a/tests/test_cli_schedule.py b/tests/test_cli_schedule.py index eabc484..60818b2 100644 --- a/tests/test_cli_schedule.py +++ b/tests/test_cli_schedule.py @@ -40,6 +40,15 @@ def mock_confirm(mocker): return mocker.patch("cli.schedule.typer.confirm") +def test_main_subcommand_without_args_prints_help(): + result = runner.invoke( + app, + [], + ) + assert result.exit_code == 0 + assert "Show this message and exit." in result.stdout + + class TestSet: def test_calls_all_stuff_in_right_order(self, mocker): mock_logger = mocker.patch("cli.schedule.get_logger") diff --git a/tests/test_cli_students.py b/tests/test_cli_students.py new file mode 100644 index 0000000..e23c3e8 --- /dev/null +++ b/tests/test_cli_students.py @@ -0,0 +1,145 @@ +import pytest +from typer.testing import CliRunner +from unittest.mock import call + +from cli.students import app + +runner = CliRunner() + + +@pytest.fixture +def mock_students(mocker): + return mocker.patch("cli.students.Students", autospec=True) + + +@pytest.fixture +def mock_students_get(mock_students): + return mock_students.return_value.get + + +@pytest.fixture +def mock_students_delete(mock_students): + return mock_students.return_value.delete + + +def test_main_subcommand_without_args_prints_help(): + result = runner.invoke( + app, + [], + ) + assert result.exit_code == 0 + assert "Show this message and exit." in result.stdout + + +@pytest.mark.students +class TestGet: + def test_exits_early_with_error_when_api_does_not_return_expected_list( + self, mock_students_get + ): + mock_students_get.return_value = None + + result = runner.invoke(app, ["get"]) + + assert result.exit_code == 1 + + def test_exits_early_with_error_when_api_returns_empty_list(self, mock_students_get): + mock_students_get.return_value = [] + + result = runner.invoke(app, ["get"]) + + assert result.exit_code == 1 + + def test_prints_list_of_students_when_students_found(self, mock_students_get): + students_found = ["one", "two", "three"] + mock_students_get.return_value = students_found + + result = runner.invoke(app, ["get"]) + + assert result.exit_code == 0 + assert "\n".join(students_found) in result.stdout + + def test_prints_numbered_list_of_students_when_students_found_and_numbered_flag_used( + self, mock_students_get + ): + students_found = ["one", "two", "three"] + mock_students_get.return_value = students_found + + result = runner.invoke(app, ["get", "--numbered"]) + + assert result.exit_code == 0 + assert "1. one" in result.stdout + assert "2. two" in result.stdout + assert "3. three" in result.stdout + + def test_prints_repr_of_list_returned_by_the_api_when_raw_flag_used(self, mock_students_get): + mock_students_get.return_value = ["one", "two", "three"] + + result = runner.invoke(app, ["get", "--raw"]) + + assert result.exit_code == 0 + assert "['one', 'two', 'three']" in result.stdout + + def test_prints_sorted_list_of_students_returned_by_the_api_when_sort_flag_used( + self, mock_students_get + ): + mock_students_get.return_value = ["one", "two", "three"] + + result = runner.invoke(app, ["get", "--sort"]) + + assert result.exit_code == 0 + assert "one\nthree\ntwo" in result.stdout + + def test_prints_reversed_sorted_list_of_students_returned_by_the_api_when_sort_reverse_flag_used( + self, mock_students_get + ): + mock_students_get.return_value = ["one", "two", "three"] + + result = runner.invoke(app, ["get", "--reverse"]) + + assert result.exit_code == 0 + assert "two\nthree\none" in result.stdout + + +@pytest.mark.students +class TestDelete: + def test_exits_with_success_when_provided_student_removed(self, mock_students_delete): + mock_students_delete.return_value = True + + result = runner.invoke(app, ["delete", "thisStudent"]) + + assert result.exit_code == 0 + assert mock_students_delete.call_args_list == [call("thisStudent")] + + + def test_exits_with_error_when_no_student_removed(self, mock_students_delete): + mock_students_delete.return_value = False + + result = runner.invoke(app, ["delete", "thisStudent"]) + + assert result.exit_code == 1 + + +@pytest.mark.students +class TestHolidays: + def test_exits_with_success_when_all_students_removed( + self, mock_students_get, mock_students_delete + ): + students = ["one", "two", "three"] + mock_students_get.return_value = students + mock_students_delete.side_effect = [True for _ in students] + + result = runner.invoke(app, ["holidays"]) + + assert result.exit_code == 0 + assert mock_students_delete.call_args_list == [call(s) for s in students] + assert "Removed all 3 students" in result.stdout + + def test_exits_with_error_when_none_student_removed( + self, mock_students_get, mock_students_delete + ): + mock_students_get.return_value = [] + + result = runner.invoke(app, ["holidays"]) + + assert result.exit_code == 1 + assert not mock_students_delete.called diff --git a/tests/test_cli_webapp.py b/tests/test_cli_webapp.py index 3a6c651..b7565f8 100644 --- a/tests/test_cli_webapp.py +++ b/tests/test_cli_webapp.py @@ -39,6 +39,15 @@ def file_with_content(content): return file_with_content +def test_main_subcommand_without_args_prints_help(): + result = runner.invoke( + app, + [], + ) + assert result.exit_code == 0 + assert "Show this message and exit." in result.stdout + + def test_create_calls_all_stuff_in_right_order(mocker): mock_project = mocker.patch("cli.webapp.Project") diff --git a/tests/test_cli_website.py b/tests/test_cli_website.py new file mode 100644 index 0000000..13acb17 --- /dev/null +++ b/tests/test_cli_website.py @@ -0,0 +1,419 @@ +import getpass +from unittest.mock import call + +import pytest +from typer.testing import CliRunner + +from cli.website import app +from pythonanywhere_core.exceptions import PythonAnywhereApiException, DomainAlreadyExistsException + + +runner = CliRunner() + + +@pytest.fixture +def domain_name(): + return "foo.bar.com" + + +@pytest.fixture +def command(): + return "/usr/local/bin/uvicorn --uds $DOMAIN_SOCKET main:app" + + +@pytest.fixture +def mock_echo(mocker): + return mocker.patch("cli.website.typer.echo") + + +@pytest.fixture +def mock_tabulate(mocker): + return mocker.patch("cli.website.tabulate") + + +@pytest.fixture +def mock_website(mocker): + return mocker.patch("cli.website.Website") + + +@pytest.fixture +def website_info(domain_name, command): + return { + "domain_name": domain_name, + "enabled": True, + "id": 42, + "logfiles": { + "access": f"/var/log/{domain_name}.access.log", + "error": f"/var/log/{domain_name}.error.log", + "server": f"/var/log/{domain_name}.server.log", + }, + "user": getpass.getuser(), + "webapp": { + "command": command, + "domains": [ + { + "domain_name": domain_name, + "enabled": True + } + ], + "id": 42 + } + } + + +def test_main_subcommand_without_args_prints_help(): + result = runner.invoke( + app, + [], + ) + assert result.exit_code == 0 + assert "Show this message and exit." in result.stdout + + +def test_create_without_domain_barfs(): + result = runner.invoke( + app, + [ + "create", + "-c", + "some kind of server", + ], + ) + assert result.exit_code != 0 + assert "Missing option" in result.stdout + + +def test_create_without_command_barfs(): + result = runner.invoke( + app, + [ + "create", + "-d", + "www.something.com", + ], + ) + assert result.exit_code != 0 + assert "Missing option" in result.stdout + + +def test_create_with_domain_and_command_creates_it(mock_website): + result = runner.invoke( + app, + [ + "create", + "-d", + "www.something.com", + "-c", + "some kind of server", + ], + ) + assert result.exit_code == 0 + mock_website.return_value.create.assert_called_once_with( + domain_name="www.something.com", + command="some kind of server" + ) + assert "All done!" in result.stdout + + +def test_create_with_existing_domain(mock_website): + mock_website.return_value.create.side_effect = DomainAlreadyExistsException + domain_name = "www.something.com" + result = runner.invoke( + app, + [ + "create", + "-d", + domain_name, + "-c", + "some kind of server", + ], + ) + assert result.exit_code != 0 + mock_website.return_value.create.assert_called_once_with( + domain_name="www.something.com", + command="some kind of server" + ) + assert "You already have a website for www.something.com." in result.stdout + + +def test_create_with_existing_domain(mock_website): + mock_website.return_value.create.side_effect = PythonAnywhereApiException("Something terrible has happened.") + domain_name = "www.something.com" + result = runner.invoke( + app, + [ + "create", + "-d", + domain_name, + "-c", + "some kind of server", + ], + ) + assert result.exit_code != 0 + mock_website.return_value.create.assert_called_once_with( + domain_name="www.something.com", + command="some kind of server" + ) + assert "Something terrible has happened." in result.stdout + + +def test_get_with_no_domain_lists_websites(mock_echo, mock_tabulate, mock_website, website_info): + second_website_info = {"domain_name": "blah.com", "enabled": False} + mock_website.return_value.list.return_value = [website_info, second_website_info] + + result = runner.invoke( + app, + [ + "get", + ], + ) + + assert result.exit_code == 0 + mock_website.return_value.list.assert_called_once() + assert mock_tabulate.call_args == call( + [ + [website_info["domain_name"], website_info["enabled"]], + [second_website_info["domain_name"], second_website_info["enabled"]], + ], + headers=["domain name", "enabled"], + tablefmt="simple", + ) + mock_echo.assert_called_once_with(mock_tabulate.return_value) + + +def test_get_with_domain_gives_details_for_domain( + mock_echo, mock_tabulate, mock_website, website_info, domain_name +): + mock_website.return_value.get.return_value = website_info + + result = runner.invoke( + app, + [ + "get", + "-d", + domain_name + ], + ) + + assert result.exit_code == 0 + mock_website.return_value.get.assert_called_once_with(domain_name=domain_name) + assert mock_tabulate.call_args == call( + [ + ["domain name", website_info["domain_name"]], + ["enabled", website_info["enabled"]], + ["command", website_info["webapp"]["command"]], + ["access log", website_info["logfiles"]["access"]], + ["error log", website_info["logfiles"]["error"]], + ["server log", website_info["logfiles"]["server"]], + ], + tablefmt="simple", + ) + mock_echo.assert_called_once_with(mock_tabulate.return_value) + + +def test_get_with_domain_gives_details_for_domain_even_without_logfiles( + domain_name, command, mock_echo, mock_tabulate, mock_website +): + website_info = { + "domain_name": domain_name, + "enabled": True, + "id": 42, + "user": getpass.getuser(), + "webapp": { + "command": command, + "domains": [ + { + "domain_name": domain_name, + "enabled": True + } + ], + "id": 42 + } + } + mock_website.return_value.get.return_value = website_info + + result = runner.invoke( + app, + [ + "get", + "-d", + domain_name + ], + ) + + assert result.exit_code == 0 + mock_website.return_value.get.assert_called_once_with(domain_name=domain_name) + assert mock_tabulate.call_args == call( + [ + ["domain name", website_info["domain_name"]], + ["enabled", website_info["enabled"]], + ["command", website_info["webapp"]["command"]], + ], + tablefmt="simple", + ) + mock_echo.assert_called_once_with(mock_tabulate.return_value) + + +def test_get_includes_cname_if_cname_is_present_in_domain( + domain_name, command, mock_echo, mock_tabulate, mock_website +): + website_info = { + "domain_name": domain_name, + "enabled": True, + "id": 42, + "user": getpass.getuser(), + "webapp": { + "command": command, + "domains": [ + { + "domain_name": domain_name, + "enabled": True, + "cname": "the-cname" + } + ], + "id": 42 + } + } + mock_website.return_value.get.return_value = website_info + + result = runner.invoke( + app, + [ + "get", + "-d", + domain_name + ], + ) + + assert result.exit_code == 0 + mock_website.return_value.get.assert_called_once_with(domain_name=domain_name) + assert mock_tabulate.call_args == call( + [ + ["domain name", website_info["domain_name"]], + ["cname", website_info["webapp"]["domains"][0]["cname"]], + ["enabled", website_info["enabled"]], + ["command", website_info["webapp"]["command"]], + ], + tablefmt="simple", + ) + mock_echo.assert_called_once_with(mock_tabulate.return_value) + + +def test_get_does_not_include_cname_if_cname_is_not_present_in_domain( + domain_name, command, mock_echo, mock_tabulate, mock_website +): + website_info = { + "domain_name": domain_name, + "enabled": True, + "id": 42, + "user": getpass.getuser(), + "webapp": { + "command": command, + "domains": [ + { + "domain_name": domain_name, + "enabled": True, + } + ], + "id": 42 + } + } + mock_website.return_value.get.return_value = website_info + + result = runner.invoke( + app, + [ + "get", + "-d", + domain_name + ], + ) + + assert result.exit_code == 0 + mock_website.return_value.get.assert_called_once_with(domain_name=domain_name) + assert mock_tabulate.call_args == call( + [ + ["domain name", website_info["domain_name"]], + ["enabled", website_info["enabled"]], + ["command", website_info["webapp"]["command"]], + ], + tablefmt="simple", + ) + mock_echo.assert_called_once_with(mock_tabulate.return_value) + + +def test_reload_with_no_domain_barfs(): + result = runner.invoke( + app, + [ + "reload", + ], + ) + assert result.exit_code != 0 + assert "Missing option" in result.stdout + + +def test_reload_with_domain_reloads(mocker, mock_echo, mock_website): + mock_snakesay = mocker.patch("cli.website.snakesay") + + result = runner.invoke( + app, + [ + "reload", + "-d", + "www.domain.com", + ], + ) + + assert result.exit_code == 0 + mock_website.return_value.reload.assert_called_once_with(domain_name="www.domain.com") + mock_snakesay.assert_called_once_with(f"Website www.domain.com has been reloaded!") + mock_echo.assert_called_once_with(mock_snakesay.return_value) + + +def test_delete_with_no_domain_barfs(): + result = runner.invoke( + app, + [ + "delete", + ], + ) + assert result.exit_code != 0 + assert "Missing option" in result.stdout + + +def test_delete_with_domain_deletes_it(mocker, mock_echo, mock_website): + mock_snakesay = mocker.patch("cli.website.snakesay") + + result = runner.invoke( + app, + [ + "delete", + "-d", + "www.domain.com", + ], + ) + + assert result.exit_code == 0 + mock_website.return_value.delete.assert_called_once_with(domain_name="www.domain.com") + mock_snakesay.assert_called_once_with(f"Website www.domain.com has been deleted!") + mock_echo.assert_called_once_with(mock_snakesay.return_value) + + +def test_create_le_autorenew_cert(mocker, mock_echo, mock_website): + mock_snakesay = mocker.patch("cli.website.snakesay") + + result = runner.invoke( + app, + [ + "create-autorenew-cert", + "-d", + "www.domain.com", + ], + ) + + assert result.exit_code == 0 + mock_website.return_value.auto_ssl.assert_called_once_with(domain_name="www.domain.com") + mock_snakesay.assert_called_once_with(f"Applied auto-renewing SSL certificate for www.domain.com!") + mock_echo.assert_called_once_with(mock_snakesay.return_value) + diff --git a/tests/test_django_project.py b/tests/test_django_project.py index 258504b..d2c7469 100644 --- a/tests/test_django_project.py +++ b/tests/test_django_project.py @@ -14,6 +14,34 @@ from pythonanywhere.exceptions import SanityException +@pytest.fixture +def project_with_mock_virtualenv(virtualenvs_folder): + project = DjangoProject("mydomain.com", "python.version") + project.virtualenv.create = Mock() + project.virtualenv.pip_install = Mock() + yield project + + +class TestDjangoVersionNewerThan: + def test_returns_true_if_django_version_is_newer_than_provided_version( + self, project_with_mock_virtualenv + ): + project_with_mock_virtualenv.virtualenv.get_version = Mock(return_value="9.9.9") + assert project_with_mock_virtualenv.django_version_newer_or_equal_than("1.1.1") is True + + def test_returns_true_when_django_version_is_the_same_as_provided_version( + self, project_with_mock_virtualenv + ): + project_with_mock_virtualenv.virtualenv.get_version = Mock(return_value="9.9.9") + assert project_with_mock_virtualenv.django_version_newer_or_equal_than("9.9.9") is True + + def test_returns_false_when_django_version_is_older_than_provided_version( + self, project_with_mock_virtualenv + ): + project_with_mock_virtualenv.virtualenv.get_version = Mock(return_value="9.9.9") + assert project_with_mock_virtualenv.django_version_newer_or_equal_than("10.1.1") is False + + class TestDownloadRepo: @pytest.mark.slowtest def test_actually_downloads_repo(self, fake_home, virtualenvs_folder): @@ -146,14 +174,6 @@ def test_if_requirements_txt_exists(self, fake_home, virtualenvs_folder): assert project.detect_requirements() == f"-r {requirements_txt.resolve()}" -@pytest.fixture -def project_with_mock_virtualenv(virtualenvs_folder): - project = DjangoProject("mydomain.com", "python.version") - project.virtualenv.create = Mock() - project.virtualenv.pip_install = Mock() - yield project - - class TestCreateVirtualenv: def test_calls_virtualenv_create(self, project_with_mock_virtualenv): project_with_mock_virtualenv.create_virtualenv("django.version", nuke="nuke option") @@ -178,11 +198,13 @@ def test_uses_detect_if_django_version_not_specified(self, project_with_mock_vir class TestRunStartproject: def test_creates_folder(self, mock_subprocess, fake_home, virtualenvs_folder): project = DjangoProject("mydomain.com", "python.version") + project.virtualenv.get_version = Mock(return_value="1.0") project.run_startproject(nuke=False) assert (fake_home / "mydomain.com").is_dir() - def test_calls_startproject(self, mock_subprocess, fake_home, virtualenvs_folder): + def test_calls_startproject_for_older_django(self, mock_subprocess, fake_home, virtualenvs_folder): project = DjangoProject("mydomain.com", "python.version") + project.virtualenv.get_version = Mock(return_value="3.9") project.run_startproject(nuke=False) assert mock_subprocess.check_call.call_args == call( [ @@ -193,11 +215,26 @@ def test_calls_startproject(self, mock_subprocess, fake_home, virtualenvs_folder ] ) + + def test_calls_startproject_for_newer_django(self, mock_subprocess, fake_home, virtualenvs_folder): + project = DjangoProject("mydomain.com", "python.version") + project.virtualenv.get_version = Mock(return_value="4.0") + project.run_startproject(nuke=False) + assert mock_subprocess.check_call.call_args == call( + [ + str(Path(project.virtualenv.path / "bin/django-admin")), + "startproject", + "mysite", + str(fake_home / "mydomain.com"), + ] + ) + def test_nuke_option_deletes_directory_first(self, mock_subprocess, fake_home, virtualenvs_folder): project = DjangoProject("mydomain.com", "python.version") (fake_home / project.domain).mkdir() old_file = fake_home / project.domain / "old_file.py" old_file.write_text("old stuff") + project.virtualenv.get_version = Mock(return_value="1.0") project.run_startproject(nuke=True) @@ -205,23 +242,26 @@ def test_nuke_option_deletes_directory_first(self, mock_subprocess, fake_home, v def test_nuke_option_handles_directory_not_existing(self, mock_subprocess, fake_home, virtualenvs_folder): project = DjangoProject("mydomain.com", "python.version") + project.virtualenv.get_version = Mock(return_value="1.0") project.run_startproject(nuke=True) # should not raise @pytest.fixture -def non_nested_submodule(): +def non_nested_submodule(running_python_version): + git_ref = "non-nested-old" if running_python_version in ["3.8", "3.9"] else "master" subprocess.check_call(["git", "submodule", "update", "--init", "--recursive"]) submodule_path = Path(__file__).parents[1] / "submodules" / "example-django-project" - subprocess.check_call(["git", "checkout", "master"], cwd=str(submodule_path)) + subprocess.check_call(["git", "checkout", git_ref], cwd=str(submodule_path)) yield submodule_path subprocess.check_call(["git", "submodule", "update", "--init", "--recursive"]) @pytest.fixture -def more_nested_submodule(): +def more_nested_submodule(running_python_version): + git_ref = "more-nested-old" if running_python_version in ["3.8", "3.9"] else "morenested" subprocess.check_call(["git", "submodule", "update", "--init", "--recursive"]) submodule_path = Path(__file__).parents[1] / "submodules" / "example-django-project" - subprocess.check_call(["git", "checkout", "morenested"], cwd=str(submodule_path)) + subprocess.check_call(["git", "checkout", git_ref], cwd=str(submodule_path)) yield submodule_path subprocess.check_call(["git", "submodule", "update", "--init", "--recursive"]) @@ -286,7 +326,7 @@ class TestUpdateSettingsFile: def test_adds_STATIC_and_MEDIA_config_to_settings_with_old_django(self, virtualenvs_folder): project = DjangoProject("mydomain.com", "python.version") project.settings_path = Path(tempfile.NamedTemporaryFile().name) - project.virtualenv.get_version = Mock(return_value="1.0") + project.virtualenv.get_version = Mock(return_value="3.0") with project.settings_path.open("w") as f: f.write( @@ -338,7 +378,7 @@ def test_adds_STATIC_and_MEDIA_config_to_settings_with_new_django(self, virtuale def test_adds_domain_to_ALLOWED_HOSTS(self, virtualenvs_folder): project = DjangoProject("mydomain.com", "python.version") project.settings_path = Path(tempfile.NamedTemporaryFile().name) - project.virtualenv.get_version = Mock(return_value="1.0") + project.virtualenv.get_version = Mock(return_value="3.0") with project.settings_path.open("w") as f: f.write( @@ -361,7 +401,7 @@ def test_adds_domain_to_ALLOWED_HOSTS(self, virtualenvs_folder): def test_only_adds_MEDIA_URL_if_its_not_already_there(self, virtualenvs_folder): project = DjangoProject("mydomain.com", "python.version") project.settings_path = Path(tempfile.NamedTemporaryFile().name) - project.virtualenv.get_version = Mock(return_value="1.0") + project.virtualenv.get_version = Mock(return_value="3.0") with project.settings_path.open("w") as f: f.write( @@ -386,7 +426,7 @@ def test_only_adds_MEDIA_URL_if_its_not_already_there(self, virtualenvs_folder): def test_only_adds_STATIC_ROOT_if_its_not_already_there(self, virtualenvs_folder): project = DjangoProject("mydomain.com", "python.version") project.settings_path = Path(tempfile.NamedTemporaryFile().name) - project.virtualenv.get_version = Mock(return_value="1.0") + project.virtualenv.get_version = Mock(return_value="3.0") with project.settings_path.open("w") as f: f.write( @@ -411,7 +451,7 @@ def test_only_adds_STATIC_ROOT_if_its_not_already_there(self, virtualenvs_folder def test_only_adds_MEDIA_ROOT_if_its_not_already_there(self, virtualenvs_folder): project = DjangoProject("mydomain.com", "python.version") project.settings_path = Path(tempfile.NamedTemporaryFile().name) - project.virtualenv.get_version = Mock(return_value="1.0") + project.virtualenv.get_version = Mock(return_value="3.0") with project.settings_path.open("w") as f: f.write( @@ -471,12 +511,11 @@ def test_updates_wsgi_file_from_template(self, virtualenvs_folder): @pytest.mark.slowtest def test_actually_produces_wsgi_file_that_can_import_project_non_nested( - self, fake_home, non_nested_submodule, virtualenvs_folder + self, fake_home, non_nested_submodule, virtualenvs_folder, running_python_version ): - running_python_version = ".".join(python_version().split(".")[:2]) project = DjangoProject("mydomain.com", running_python_version) shutil.copytree(str(non_nested_submodule), str(project.project_path)) - if running_python_version in ["3.7", "3.8", "3.9", "3.10"]: + if running_python_version in ["3.8", "3.9", "3.10", "3.11"]: project.create_virtualenv(django_version="latest") else: project.create_virtualenv() @@ -491,15 +530,14 @@ def test_actually_produces_wsgi_file_that_can_import_project_non_nested( @pytest.mark.slowtest def test_actually_produces_wsgi_file_that_can_import_nested_project( - self, fake_home, more_nested_submodule, virtualenvs_folder + self, fake_home, more_nested_submodule, virtualenvs_folder, running_python_version ): - running_python_version = ".".join(python_version().split(".")[:2]) project = DjangoProject("mydomain.com", running_python_version) shutil.copytree(str(more_nested_submodule), str(project.project_path)) - if running_python_version in ["3.7", "3.8", "3.9", "3.10"]: - project.create_virtualenv(django_version="latest") - else: + if running_python_version in ["3.8", "3.9"]: project.create_virtualenv() + else: + project.create_virtualenv(django_version="latest") project.find_django_files() project.wsgi_file_path = Path(tempfile.NamedTemporaryFile().name) diff --git a/tests/test_files.py b/tests/test_files.py index 33b5776..dacfd4a 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -2,10 +2,29 @@ from unittest.mock import call import pytest +from pythonanywhere_core.base import get_api_endpoint -from pythonanywhere.api.files_api import Files +from pythonanywhere_core.files import Files from pythonanywhere.files import PAPath -from tests.test_api_files import TestFiles + + +class TestFiles: + username = getuser() + base_url = get_api_endpoint(username=username, flavor="files") + home_dir_path = f"/home/{username}" + default_home_dir_files = { + ".bashrc": {"type": "file", "url": f"{base_url}path{home_dir_path}/.bashrc"}, + ".gitconfig": {"type": "file", "url": f"{base_url}path{home_dir_path}/.gitconfig"}, + ".local": {"type": "directory", "url": f"{base_url}path{home_dir_path}/.local"}, + ".profile": {"type": "file", "url": f"{base_url}path{home_dir_path}/.profile"}, + "README.txt": {"type": "file", "url": f"{base_url}path{home_dir_path}/README.txt"}, + } + readme_contents = ( + b"# vim: set ft=rst:\n\nSee https://help.pythonanywhere.com/ " + b'(or click the "Help" link at the top\nright) ' + b"for help on how to use PythonAnywhere, including tips on copying and\n" + b"pasting from consoles, and writing your own web applications.\n" + ) @pytest.mark.files @@ -30,14 +49,6 @@ def test_repr_returns_url_property_value(self, mocker): assert PAPath("path").__repr__() == mock_url - def test_make_sharing_url_contains_pa_site_address(self, mocker): - mock_urljoin = mocker.patch("pythonanywhere.files.urljoin") - pa_path = PAPath("path") - - pa_path._make_sharing_url("rest") - - assert mock_urljoin.call_args == call(pa_path.api.base_url.split("api")[0], "rest") - def test_sanitizes_path(self): pa_path = PAPath("~") @@ -48,7 +59,7 @@ def test_sanitizes_path(self): class TestPAPathContents(TestFiles): def test_returns_file_contents_as_string_if_path_points_to_a_file(self, mocker): path = f"{self.home_dir_path}README.txt" - mock_path_get = mocker.patch("pythonanywhere.api.files_api.Files.path_get") + mock_path_get = mocker.patch("pythonanywhere_core.files.Files.path_get") mock_path_get.return_value = self.readme_contents result = PAPath(path).contents @@ -57,7 +68,7 @@ def test_returns_file_contents_as_string_if_path_points_to_a_file(self, mocker): assert result == self.readme_contents.decode() def test_returns_directory_contents_if_path_points_to_a_directory(self, mocker): - mock_path_get = mocker.patch("pythonanywhere.api.files_api.Files.path_get") + mock_path_get = mocker.patch("pythonanywhere_core.files.Files.path_get") mock_path_get.return_value = self.default_home_dir_files result = PAPath(self.home_dir_path).contents @@ -65,7 +76,7 @@ def test_returns_directory_contents_if_path_points_to_a_directory(self, mocker): assert result == self.default_home_dir_files def test_warns_when_path_unavailable(self, mocker): - mock_path_get = mocker.patch("pythonanywhere.api.files_api.Files.path_get") + mock_path_get = mocker.patch("pythonanywhere_core.files.Files.path_get") mock_path_get.side_effect = Exception("error msg") mock_snake = mocker.patch("pythonanywhere.files.snakesay") mock_warning = mocker.patch("pythonanywhere.files.logger.warning") @@ -80,7 +91,7 @@ def test_warns_when_path_unavailable(self, mocker): @pytest.mark.files class TestPAPathTree(): def test_returns_list_of_regular_dirs_and_files(self, mocker): - mock_tree_get = mocker.patch("pythonanywhere.api.files_api.Files.tree_get") + mock_tree_get = mocker.patch("pythonanywhere_core.files.Files.tree_get") path = "/home/user" tree = [f"{path}/README.txt"] mock_tree_get.return_value = tree @@ -91,7 +102,7 @@ def test_returns_list_of_regular_dirs_and_files(self, mocker): assert mock_tree_get.call_args == call(path) def test_warns_if_fetching_of_tree_unsuccessful(self, mocker): - mock_tree_get = mocker.patch("pythonanywhere.api.files_api.Files.tree_get") + mock_tree_get = mocker.patch("pythonanywhere_core.files.Files.tree_get") mock_tree_get.side_effect = Exception("failed") mock_snake = mocker.patch("pythonanywhere.files.snakesay") mock_warning = mocker.patch("pythonanywhere.files.logger.warning") @@ -108,7 +119,7 @@ def test_warns_if_fetching_of_tree_unsuccessful(self, mocker): @pytest.mark.files class TestPAPathDelete(): def test_informes_about_successful_file_deletion(self, mocker): - mock_delete = mocker.patch("pythonanywhere.api.files_api.Files.path_delete") + mock_delete = mocker.patch("pythonanywhere_core.files.Files.path_delete") mock_delete.return_value.status_code = 204 mock_snake = mocker.patch("pythonanywhere.files.snakesay") mock_info = mocker.patch("pythonanywhere.files.logger.info") @@ -121,7 +132,7 @@ def test_informes_about_successful_file_deletion(self, mocker): assert mock_info.call_args == call(mock_snake.return_value) def test_warns_about_failed_deletion(self, mocker): - mock_delete = mocker.patch("pythonanywhere.api.files_api.Files.path_delete") + mock_delete = mocker.patch("pythonanywhere_core.files.Files.path_delete") mock_delete.side_effect = Exception("error msg") mock_snake = mocker.patch("pythonanywhere.files.snakesay") mock_warning = mocker.patch("pythonanywhere.files.logger.warning") @@ -136,7 +147,7 @@ def test_warns_about_failed_deletion(self, mocker): @pytest.mark.files class TestPAPathUpload(): def test_informs_about_successful_upload_of_a_file(self, mocker): - mock_post = mocker.patch("pythonanywhere.api.files_api.Files.path_post") + mock_post = mocker.patch("pythonanywhere_core.files.Files.path_post") mock_post.return_value = 201 mock_snake = mocker.patch("pythonanywhere.files.snakesay") mock_info = mocker.patch("pythonanywhere.files.logger.info") @@ -151,7 +162,7 @@ def test_informs_about_successful_upload_of_a_file(self, mocker): assert result is True def test_informs_about_successful_update_of_existing_file_with_provided_stream(self, mocker): - mock_post = mocker.patch("pythonanywhere.api.files_api.Files.path_post") + mock_post = mocker.patch("pythonanywhere_core.files.Files.path_post") mock_post.return_value = 200 mock_snake = mocker.patch("pythonanywhere.files.snakesay") mock_info = mocker.patch("pythonanywhere.files.logger.info") @@ -166,7 +177,7 @@ def test_informs_about_successful_update_of_existing_file_with_provided_stream(s assert result is True def test_warns_when_file_has_not_been_uploaded(self, mocker): - mock_post = mocker.patch("pythonanywhere.api.files_api.Files.path_post") + mock_post = mocker.patch("pythonanywhere_core.files.Files.path_post") mock_post.side_effect = Exception("sth went wrong") mock_warning = mocker.patch("pythonanywhere.files.logger.warning") mock_snake = mocker.patch("pythonanywhere.files.snakesay") @@ -184,9 +195,8 @@ def test_warns_when_file_has_not_been_uploaded(self, mocker): @pytest.mark.files class TestPAPathShare(): def test_returns_full_url_for_shared_file(self, mocker): - mock_sharing_get = mocker.patch("pythonanywhere.api.files_api.Files.sharing_get") + mock_sharing_get = mocker.patch("pythonanywhere_core.files.Files.sharing_get") mock_sharing_get.return_value = "url" - mock_make_url = mocker.patch("pythonanywhere.files.PAPath._make_sharing_url") mock_snake = mocker.patch("pythonanywhere.files.snakesay") mock_info = mocker.patch("pythonanywhere.files.logger.info") query_path = "/pa/path/to/a/file" @@ -194,12 +204,12 @@ def test_returns_full_url_for_shared_file(self, mocker): result = PAPath(query_path).get_sharing_url() assert mock_sharing_get.call_args == call(query_path) - assert mock_snake.call_args == call(f"{query_path} is shared at {mock_make_url.return_value}") + assert mock_snake.call_args == call(f"{query_path} is shared at {mock_sharing_get.return_value}") assert mock_info.call_args == call(mock_snake.return_value) assert result.endswith("url") def test_returns_empty_string_when_file_not_shared(self, mocker): - mock_sharing_get = mocker.patch("pythonanywhere.api.files_api.Files.sharing_get") + mock_sharing_get = mocker.patch("pythonanywhere_core.files.Files.sharing_get") mock_sharing_get.return_value = "" mock_snake = mocker.patch("pythonanywhere.files.snakesay") mock_info = mocker.patch("pythonanywhere.files.logger.info") @@ -213,9 +223,8 @@ def test_returns_empty_string_when_file_not_shared(self, mocker): assert result == "" def test_path_already_shared(self, mocker): - mock_sharing_post = mocker.patch("pythonanywhere.api.files_api.Files.sharing_post") - mock_sharing_post.return_value = (200, "url") - mock_make_url = mocker.patch("pythonanywhere.files.PAPath._make_sharing_url") + mock_sharing_post = mocker.patch("pythonanywhere_core.files.Files.sharing_post") + mock_sharing_post.return_value = ("was already shared", "url") mock_snake = mocker.patch("pythonanywhere.files.snakesay") mock_info = mocker.patch("pythonanywhere.files.logger.info") path_to_share = "/pa/path/to/a/file" @@ -223,15 +232,12 @@ def test_path_already_shared(self, mocker): result = PAPath(path_to_share).share() assert mock_sharing_post.call_args == call(path_to_share) - assert mock_snake.call_args == call(f"{path_to_share} was already shared at {mock_make_url.return_value}") + assert mock_snake.call_args == call(f"{path_to_share} was already shared at {mock_sharing_post.return_value[1]}") assert mock_info.call_args == call(mock_snake.return_value) - assert mock_make_url.call_args == call("url") - assert result == mock_make_url.return_value def test_path_successfully_shared(self, mocker): - mock_sharing_post = mocker.patch("pythonanywhere.api.files_api.Files.sharing_post") - mock_sharing_post.return_value = (201, "url") - mock_make_url = mocker.patch("pythonanywhere.files.PAPath._make_sharing_url") + mock_sharing_post = mocker.patch("pythonanywhere_core.files.Files.sharing_post") + mock_sharing_post.return_value = ("successfully shared", "url") mock_snake = mocker.patch("pythonanywhere.files.snakesay") mock_info = mocker.patch("pythonanywhere.files.logger.info") path_to_share = "/pa/path/to/a/file" @@ -239,13 +245,11 @@ def test_path_successfully_shared(self, mocker): result = PAPath(path_to_share).share() assert mock_sharing_post.call_args == call(path_to_share) - assert mock_snake.call_args == call(f"{path_to_share} successfully shared at {mock_make_url.return_value}") + assert mock_snake.call_args == call(f"{path_to_share} successfully shared at {mock_sharing_post.return_value[1]}") assert mock_info.call_args == call(mock_snake.return_value) - assert mock_make_url.call_args == call("url") - assert result == mock_make_url.return_value def test_warns_if_share_fails(self, mocker): - mock_sharing_post = mocker.patch("pythonanywhere.api.files_api.Files.sharing_post") + mock_sharing_post = mocker.patch("pythonanywhere_core.files.Files.sharing_post") mock_sharing_post.side_effect = Exception("failed") mock_snake = mocker.patch("pythonanywhere.files.snakesay") mock_warning = mocker.patch("pythonanywhere.files.logger.warning") @@ -259,9 +263,9 @@ def test_warns_if_share_fails(self, mocker): assert result == "" def test_path_is_not_shared_so_cannot_be_unshared(self, mocker): - mock_sharing_get = mocker.patch("pythonanywhere.api.files_api.Files.sharing_get") + mock_sharing_get = mocker.patch("pythonanywhere_core.files.Files.sharing_get") mock_sharing_get.return_value = "" - mock_sharing_delete = mocker.patch("pythonanywhere.api.files_api.Files.sharing_delete") + mock_sharing_delete = mocker.patch("pythonanywhere_core.files.Files.sharing_delete") mock_snake = mocker.patch("pythonanywhere.files.snakesay") mock_info = mocker.patch("pythonanywhere.files.logger.info") path_to_unshare = "/pa/path/to/a/file" @@ -277,9 +281,9 @@ def test_path_is_not_shared_so_cannot_be_unshared(self, mocker): assert result is True def test_path_successfully_unshared(self, mocker): - mock_sharing_get = mocker.patch("pythonanywhere.api.files_api.Files.sharing_get") + mock_sharing_get = mocker.patch("pythonanywhere_core.files.Files.sharing_get") mock_sharing_get.return_value = "url" - mock_sharing_delete = mocker.patch("pythonanywhere.api.files_api.Files.sharing_delete") + mock_sharing_delete = mocker.patch("pythonanywhere_core.files.Files.sharing_delete") mock_sharing_delete.return_value = 204 mock_snake = mocker.patch("pythonanywhere.files.snakesay") mock_info = mocker.patch("pythonanywhere.files.logger.info") @@ -294,9 +298,9 @@ def test_path_successfully_unshared(self, mocker): assert result is True def test_warns_if_unshare_not_successful(self, mocker): - mock_sharing_get = mocker.patch("pythonanywhere.api.files_api.Files.sharing_get") + mock_sharing_get = mocker.patch("pythonanywhere_core.files.Files.sharing_get") mock_sharing_get.return_value = "url" - mock_sharing_delete = mocker.patch("pythonanywhere.api.files_api.Files.sharing_delete") + mock_sharing_delete = mocker.patch("pythonanywhere_core.files.Files.sharing_delete") mock_sharing_delete.return_value = 999 mock_snake = mocker.patch("pythonanywhere.files.snakesay") mock_warning = mocker.patch("pythonanywhere.files.logger.warning") diff --git a/tests/test_pa_autoconfigure_django.py b/tests/test_pa_autoconfigure_django.py index 7cf21ad..1e4e90c 100644 --- a/tests/test_pa_autoconfigure_django.py +++ b/tests/test_pa_autoconfigure_django.py @@ -1,4 +1,3 @@ -from platform import python_version from unittest.mock import call, patch import os import pytest @@ -7,6 +6,7 @@ import time from scripts.pa_autoconfigure_django import main +from tests.conftest import new_django_version class TestMain: @@ -33,17 +33,22 @@ def test_calls_all_stuff_in_right_order(self): @pytest.mark.slowtest def test_actually_works_against_example_repo( - self, fake_home, virtualenvs_folder, api_token, process_killer + self, fake_home, virtualenvs_folder, api_token, process_killer, running_python_version, new_django_version ): - running_python_version = ".".join(python_version().split(".")[:2]) + git_ref = "non-nested-old" if running_python_version in ["3.8", "3.9"] else "master" repo = 'https://github.com/pythonanywhere/example-django-project.git' domain = 'mydomain.com' with patch('scripts.pa_autoconfigure_django.DjangoProject.update_wsgi_file'): with patch('scripts.pa_autoconfigure_django.DjangoProject.start_bash'): - with patch('pythonanywhere.api.webapp.call_api'): - main(repo, "None", domain, running_python_version, nuke=False) + with patch('pythonanywhere_core.webapp.call_api'): + main( + repo_url=repo, + branch=git_ref, + domain=domain, + python_version=running_python_version, + nuke=False + ) - expected_django_version = '3.0.6' expected_virtualenv = virtualenvs_folder / domain expected_project_path = fake_home / domain django_project_name = 'myproject' @@ -54,11 +59,11 @@ def test_actually_works_against_example_repo( '-c' 'import django; print(django.get_version())' ]).decode().strip() - assert django_version == expected_django_version + assert django_version == new_django_version with expected_settings_path.open() as f: lines = f.read().split('\n') - assert "MEDIA_ROOT = os.path.join(BASE_DIR, 'media')" in lines + assert "MEDIA_ROOT = Path(BASE_DIR / 'media')" in lines assert "ALLOWED_HOSTS = ['mydomain.com'] # type: List[str]" in lines assert 'base.css' in os.listdir(str(fake_home / domain / 'static/admin/css')) diff --git a/tests/test_pa_start_django_webapp_with_virtualenv.py b/tests/test_pa_start_django_webapp_with_virtualenv.py index 55c8ef2..c56c5d1 100644 --- a/tests/test_pa_start_django_webapp_with_virtualenv.py +++ b/tests/test_pa_start_django_webapp_with_virtualenv.py @@ -1,78 +1,78 @@ import os import subprocess -from platform import python_version -from unittest.mock import call, patch +from unittest.mock import call, patch, sentinel import pytest from scripts.pa_start_django_webapp_with_virtualenv import main -class TestMain: - def test_calls_all_stuff_in_right_order(self): - with patch("scripts.pa_start_django_webapp_with_virtualenv.DjangoProject") as mock_DjangoProject: - main("www.domain.com", "django.version", "python.version", nuke="nuke option") - assert mock_DjangoProject.call_args == call("www.domain.com", "python.version") - assert mock_DjangoProject.return_value.method_calls == [ - call.sanity_checks(nuke="nuke option"), - call.create_virtualenv("django.version", nuke="nuke option"), - call.run_startproject(nuke="nuke option"), - call.find_django_files(), - call.update_settings_file(), - call.run_collectstatic(), - call.create_webapp(nuke="nuke option"), - call.add_static_file_mappings(), - call.update_wsgi_file(), - call.webapp.reload(), - ] +def test_calls_all_stuff_in_right_order(mocker): + mock_DjangoProject = mocker.patch( + "scripts.pa_start_django_webapp_with_virtualenv.DjangoProject" + ) - @pytest.mark.slowtest - def test_actually_creates_django_project_in_virtualenv_with_hacked_settings_and_static_files( - self, fake_home, virtualenvs_folder, api_token - ): - running_python_version = ".".join(python_version().split(".")[:2]) - with patch("scripts.pa_start_django_webapp_with_virtualenv.DjangoProject.update_wsgi_file"): - with patch("pythonanywhere.api.webapp.call_api"): - main("mydomain.com", "2.2.12", running_python_version, nuke=False) + main( + sentinel.domain, sentinel.django_version, sentinel.python_version, nuke=sentinel.nuke + ) + assert mock_DjangoProject.call_args == call(sentinel.domain, sentinel.python_version) + assert mock_DjangoProject.return_value.method_calls == [ + call.sanity_checks(nuke=sentinel.nuke), + call.create_virtualenv(sentinel.django_version, nuke=sentinel.nuke), + call.run_startproject(nuke=sentinel.nuke), + call.find_django_files(), + call.update_settings_file(), + call.run_collectstatic(), + call.create_webapp(nuke=sentinel.nuke), + call.add_static_file_mappings(), + call.update_wsgi_file(), + call.webapp.reload(), + ] - django_version = ( - subprocess.check_output( - [ - str(virtualenvs_folder / "mydomain.com/bin/python"), - "-c" "import django; print(django.get_version())", - ] - ) - .decode() - .strip() - ) - assert django_version == "2.2.12" +@pytest.mark.slowtest +def test_actually_creates_django_project_in_virtualenv_with_hacked_settings_and_static_files( + fake_home, virtualenvs_folder, api_token, running_python_version, new_django_version +): + with patch("scripts.pa_start_django_webapp_with_virtualenv.DjangoProject.update_wsgi_file"): + with patch("pythonanywhere_core.webapp.call_api"): + main("mydomain.com", new_django_version, running_python_version, nuke=False) - with (fake_home / "mydomain.com/mysite/settings.py").open() as f: - lines = f.read().split("\n") - assert "MEDIA_ROOT = os.path.join(BASE_DIR, 'media')" in lines - assert "ALLOWED_HOSTS = ['mydomain.com']" in lines + output_django_version = ( + subprocess.check_output( + [ + str(virtualenvs_folder / "mydomain.com/bin/python"), + "-c" "import django; print(django.get_version())", + ] + ) + .decode() + .strip() + ) + assert output_django_version == new_django_version - assert "base.css" in os.listdir(str(fake_home / "mydomain.com/static/admin/css")) + with (fake_home / "mydomain.com/mysite/settings.py").open() as f: + lines = f.read().split("\n") + assert "MEDIA_ROOT = Path(BASE_DIR / 'media')" in lines + assert "ALLOWED_HOSTS = ['mydomain.com']" in lines - @pytest.mark.slowtest - def test_nuke_option_lets_you_run_twice(self, fake_home, virtualenvs_folder, api_token): + assert "base.css" in os.listdir(str(fake_home / "mydomain.com/static/admin/css")) - with patch("scripts.pa_start_django_webapp_with_virtualenv.DjangoProject.update_wsgi_file"): - with patch("pythonanywhere.api.webapp.call_api"): - running_python_version = ".".join(python_version().split(".")[:2]) - old_django_version = "2.2.12" - new_django_version = "3.0.6" +@pytest.mark.slowtest +def test_nuke_option_lets_you_run_twice( + fake_home, virtualenvs_folder, api_token, running_python_version, new_django_version, old_django_version +): - main("mydomain.com", old_django_version, running_python_version, nuke=False) - main("mydomain.com", new_django_version, running_python_version, nuke=True) + with patch("scripts.pa_start_django_webapp_with_virtualenv.DjangoProject.update_wsgi_file"): + with patch("pythonanywhere_core.webapp.call_api"): + main("mydomain.com", old_django_version, running_python_version, nuke=False) + main("mydomain.com", new_django_version, running_python_version, nuke=True) - django_version = ( - subprocess.check_output( - [ - str(virtualenvs_folder / "mydomain.com/bin/python"), - "-c" "import django; print(django.get_version())", - ] - ) - .decode() - .strip() + django_version = ( + subprocess.check_output( + [ + str(virtualenvs_folder / "mydomain.com/bin/python"), + "-c" "import django; print(django.get_version())", + ] ) - assert django_version == new_django_version + .decode() + .strip() + ) + assert django_version == new_django_version diff --git a/tests/test_project.py b/tests/test_project.py index 7120a32..94b63cb 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -2,9 +2,10 @@ import pytest from pathlib import Path +from pythonanywhere_core.webapp import Webapp + from pythonanywhere.project import Project from pythonanywhere.exceptions import SanityException -from pythonanywhere.api.webapp import Webapp from pythonanywhere.virtualenvs import Virtualenv diff --git a/tests/test_snakesay.py b/tests/test_snakesay.py deleted file mode 100644 index d9a070f..0000000 --- a/tests/test_snakesay.py +++ /dev/null @@ -1,36 +0,0 @@ -from pythonanywhere.snakesay import snakesay, MESSAGE - - -def test_nothing(): - assert "~<:>>>>>>>>>" in snakesay() - assert snakesay() == MESSAGE.format(bubble='< >') - - -def test_one_line(): - assert '< hi there >' in snakesay('hi there') - - -def test_two_lines(): - two_lines = 'a' * 81 - message = snakesay(two_lines) - print(message) - assert '/ ' in message - assert '| aaaaa' in message - assert 'aaaaa |' in message - assert '\\ ' in message - - -def test_three_lines(): - three_lines = 'a' * 80 * 2 + 'a' - long_message = snakesay(three_lines) - print(long_message) - assert '/ ' in long_message - - assert '| aaaaaa' in long_message - assert 'aaaaaa |' in long_message - - assert '\\ ' in long_message - - -def test_multiple_arguments(): - assert '< hi there >' in snakesay('hi', 'there') diff --git a/tests/test_students.py b/tests/test_students.py new file mode 100644 index 0000000..024921b --- /dev/null +++ b/tests/test_students.py @@ -0,0 +1,84 @@ +import pytest +from unittest.mock import call + +from pythonanywhere_core.students import StudentsAPI +from pythonanywhere.students import Students + + +@pytest.mark.students +class TestStudentsInit: + def test_instantiates_correctly(self): + students = Students() + assert isinstance(students.api, StudentsAPI) + + +@pytest.mark.students +class TestStudentsGet: + def test_returns_list_of_usernames_when_found_in_api_response(self, mocker): + mock_students_api_get = mocker.patch("pythonanywhere.students.StudentsAPI.get") + student_usernames = ["student1", "student2"] + mock_students_api_get.return_value = { + "students": [{"username": s} for s in student_usernames] + } + + result = Students().get() + + assert mock_students_api_get.called + assert result == student_usernames + + def test_returns_empty_list_when_no_usernames_found_in_api_response(self, mocker): + mock_students_api_get = mocker.patch("pythonanywhere.students.StudentsAPI.get") + mock_students_api_get.return_value = {"students": []} + + result = Students().get() + + assert mock_students_api_get.called + assert result == [] + + @pytest.mark.parametrize( + "api_response,expected_wording", + [ + ({"students": [{"username": "one"}, {"username": "two"}]}, "You have 2 students!"), + ({"students": [{"username": "one"}]}, "You have 1 student!"), + ] + ) + def test_uses_correct_grammar_in_log_messages(self, mocker, api_response, expected_wording): + mock_students_api_get = mocker.patch("pythonanywhere.students.StudentsAPI.get") + mock_students_api_get.return_value = api_response + mock_snake = mocker.patch("pythonanywhere.students.snakesay") + mock_info = mocker.patch("pythonanywhere.students.logger.info") + + Students().get() + + assert mock_snake.call_args == call(expected_wording) + assert mock_info.call_args == call(mock_snake.return_value) + + +@pytest.mark.students +class TestStudentsDelete: + def test_returns_true_and_informs_when_student_removed(self, mocker): + mock_students_api_delete = mocker.patch("pythonanywhere.students.StudentsAPI.delete") + mock_students_api_delete.return_value = True + mock_snake = mocker.patch("pythonanywhere.students.snakesay") + mock_info = mocker.patch("pythonanywhere.students.logger.info") + student = "badstudent" + + result = Students().delete(student) + + assert mock_snake.call_args == call(f"{student!r} removed from the list of students!") + assert mock_info.call_args == call(mock_snake.return_value) + assert result is True + + def test_returns_false_and_warns_when_student_not_removed(self, mocker): + mock_students_api_delete = mocker.patch("pythonanywhere.students.StudentsAPI.delete") + mock_students_api_delete.side_effect = Exception("error msg") + mock_students_api_delete.return_value = False + mock_snake = mocker.patch("pythonanywhere.students.snakesay") + mock_warning = mocker.patch("pythonanywhere.students.logger.warning") + student = "badstudent" + + result = Students().delete(student) + + assert mock_snake.call_args == call("error msg") + assert mock_warning.call_args == call(mock_snake.return_value) + assert result is False diff --git a/tests/test_task.py b/tests/test_task.py index 80b245a..c7f1a8d 100644 --- a/tests/test_task.py +++ b/tests/test_task.py @@ -68,7 +68,7 @@ def test_raises_when_to_be_created_gets_wrong_minute(self): @pytest.mark.tasks class TestTaskFromId: def test_updates_specs(self, task_specs, mocker): - mock_get_specs = mocker.patch("pythonanywhere.api.schedule.Schedule.get_specs") + mock_get_specs = mocker.patch("pythonanywhere.task.Schedule.get_specs") mock_get_specs.return_value = task_specs task = Task.from_id(task_id=42) @@ -81,7 +81,7 @@ def test_updates_specs(self, task_specs, mocker): @pytest.mark.tasks class TestTaskCreateSchedule: def test_creates_daily_task(self, mocker, task_specs): - mock_create = mocker.patch("pythonanywhere.api.schedule.Schedule.create") + mock_create = mocker.patch("pythonanywhere.task.Schedule.create") mock_create.return_value = task_specs mock_update_specs = mocker.patch("pythonanywhere.task.Task.update_specs") task = Task.to_be_created(command="echo foo", hour=16, minute=0, disabled=False) @@ -93,12 +93,25 @@ def test_creates_daily_task(self, mocker, task_specs): assert mock_create.call_args == call( {"command": "echo foo", "hour": 16, "minute": 0, "enabled": True, "interval": "daily"} ) + def test_creates_daily_midnight_task(self, mocker, task_specs): + mock_create = mocker.patch("pythonanywhere.task.Schedule.create") + mock_create.return_value = task_specs + mock_update_specs = mocker.patch("pythonanywhere.task.Task.update_specs") + task = Task.to_be_created(command="echo foo", hour=0, minute=0, disabled=False) + + task.create_schedule() + + assert mock_update_specs.call_args == call(task_specs) + assert mock_create.call_count == 1 + assert mock_create.call_args == call( + {"command": "echo foo", "hour": 0, "minute": 0, "enabled": True, "interval": "daily"} + ) @pytest.mark.tasks class TestTaskDeleteSchedule: def test_calls_schedule_delete(self, example_task, mocker): - mock_delete = mocker.patch("pythonanywhere.api.schedule.Schedule.delete") + mock_delete = mocker.patch("pythonanywhere.task.Schedule.delete") mock_delete.return_value = True mock_snake = mocker.patch("pythonanywhere.task.snakesay") mock_logger = mocker.patch("pythonanywhere.task.logger.info") @@ -110,7 +123,7 @@ def test_calls_schedule_delete(self, example_task, mocker): assert mock_logger.call_args == call(mock_snake.return_value) def test_raises_when_schedule_delete_fails(self, mocker): - mock_delete = mocker.patch("pythonanywhere.api.schedule.Schedule.delete") + mock_delete = mocker.patch("pythonanywhere.task.Schedule.delete") mock_delete.side_effect = Exception("error msg") with pytest.raises(Exception) as e: @@ -123,7 +136,7 @@ def test_raises_when_schedule_delete_fails(self, mocker): @pytest.mark.tasks class TestTaskUpdateSchedule: def test_updates_specs_and_prints_porcelain(self, mocker, example_task, task_specs): - mock_schedule_update = mocker.patch("pythonanywhere.api.schedule.Schedule.update") + mock_schedule_update = mocker.patch("pythonanywhere.task.Schedule.update") mock_info = mocker.patch("pythonanywhere.task.logger.info") mock_update_specs = mocker.patch("pythonanywhere.task.Task.update_specs") params = {"enabled": False} @@ -146,7 +159,7 @@ def test_updates_specs_and_prints_porcelain(self, mocker, example_task, task_spe assert mock_update_specs.call_args == call(task_specs) def test_updates_specs_and_snakesays(self, mocker, example_task, task_specs): - mock_schedule_update = mocker.patch("pythonanywhere.api.schedule.Schedule.update") + mock_schedule_update = mocker.patch("pythonanywhere.task.Schedule.update") mock_info = mocker.patch("pythonanywhere.task.logger.info") mock_snake = mocker.patch("pythonanywhere.task.snakesay") mock_update_specs = mocker.patch("pythonanywhere.task.Task.update_specs") @@ -161,7 +174,7 @@ def test_updates_specs_and_snakesays(self, mocker, example_task, task_specs): assert mock_update_specs.call_args == call(task_specs) def test_changes_daily_to_hourly(self, example_task, task_specs, mocker): - mock_schedule_update = mocker.patch("pythonanywhere.api.schedule.Schedule.update") + mock_schedule_update = mocker.patch("pythonanywhere.task.Schedule.update") mock_update_specs = mocker.patch("pythonanywhere.task.Task.update_specs") params = {"interval": "hourly"} task_specs.update({**params, "hour": None}) @@ -172,7 +185,7 @@ def test_changes_daily_to_hourly(self, example_task, task_specs, mocker): assert mock_update_specs.call_args == call(task_specs) def test_warns_when_nothing_to_update(self, mocker, example_task, task_specs): - mock_schedule_update = mocker.patch("pythonanywhere.api.schedule.Schedule.update") + mock_schedule_update = mocker.patch("pythonanywhere.task.Schedule.update") mock_snake = mocker.patch("pythonanywhere.task.snakesay") mock_warning = mocker.patch("pythonanywhere.task.logger.warning") mock_update_specs = mocker.patch("pythonanywhere.task.Task.update_specs") @@ -192,7 +205,7 @@ def test_warns_when_nothing_to_update(self, mocker, example_task, task_specs): @pytest.mark.tasks class TestTaskList: def test_instatiates_task_list_calling_proper_methods(self, task_specs, mocker): - mock_get_list = mocker.patch("pythonanywhere.api.schedule.Schedule.get_list") + mock_get_list = mocker.patch("pythonanywhere.task.Schedule.get_list") mock_get_list.return_value = [task_specs] mock_from_specs = mocker.patch("pythonanywhere.task.Task.from_api_specs") diff --git a/tests/test_virtualenvs.py b/tests/test_virtualenvs.py index 2eb860c..5ce69a6 100644 --- a/tests/test_virtualenvs.py +++ b/tests/test_virtualenvs.py @@ -12,7 +12,7 @@ def test_path(self, virtualenvs_folder): assert v.path == Path(virtualenvs_folder) / "domain.com" def test_create_uses_bash_and_sources_virtualenvwrapper(self, mock_subprocess, virtualenvs_folder): - v = Virtualenv("domain.com", "3.6") + v = Virtualenv("domain.com", "3.8") v.create(nuke=False) args, kwargs = mock_subprocess.check_call.call_args command_list = args[0] @@ -20,15 +20,15 @@ def test_create_uses_bash_and_sources_virtualenvwrapper(self, mock_subprocess, v assert command_list[2].startswith("source virtualenvwrapper.sh && mkvirtualenv") def test_create_calls_mkvirtualenv_with_python_version_and_domain(self, mock_subprocess, virtualenvs_folder): - v = Virtualenv("domain.com", "3.6") + v = Virtualenv("domain.com", "3.8") v.create(nuke=False) args, kwargs = mock_subprocess.check_call.call_args command_list = args[0] bash_command = command_list[2] - assert "mkvirtualenv --python=python3.6 domain.com" in bash_command + assert "mkvirtualenv --python=python3.8 domain.com" in bash_command def test_nuke_option_deletes_virtualenv(self, mock_subprocess, virtualenvs_folder): - v = Virtualenv("domain.com", "3.6") + v = Virtualenv("domain.com", "3.8") v.create(nuke=True) args, kwargs = mock_subprocess.check_call.call_args command_list = args[0] @@ -36,7 +36,7 @@ def test_nuke_option_deletes_virtualenv(self, mock_subprocess, virtualenvs_folde assert command_list[2].startswith("source virtualenvwrapper.sh && rmvirtualenv domain.com") def test_install_pip_installs_each_package(self, mock_subprocess, virtualenvs_folder): - v = Virtualenv("domain.com", "3.6") + v = Virtualenv("domain.com", "3.8") v.create(nuke=False) v.pip_install("package1 package2==1.1.2") args, kwargs = mock_subprocess.check_call.call_args_list[-1] @@ -45,8 +45,7 @@ def test_install_pip_installs_each_package(self, mock_subprocess, virtualenvs_fo assert command_list == [pip_path, "install", "package1", "package2==1.1.2"] @pytest.mark.slowtest - def test_actually_installing_a_real_package(self, fake_home, virtualenvs_folder): - running_python_version = ".".join(python_version().split(".")[:2]) + def test_actually_installing_a_real_package(self, fake_home, virtualenvs_folder, running_python_version): v = Virtualenv("www.adomain.com", running_python_version) v.create(nuke=False) v.pip_install("aafigure") @@ -54,10 +53,9 @@ def test_actually_installing_a_real_package(self, fake_home, virtualenvs_folder) subprocess.check_call([str(v.path / "bin/python"), "-c" "import aafigure"]) @pytest.mark.slowtest - def test_gets_version(self, fake_home, virtualenvs_folder): - running_python_version = ".".join(python_version().split(".")[:2]) + def test_gets_version(self, fake_home, virtualenvs_folder, running_python_version): v = Virtualenv("www.adomain.com", running_python_version) v.create(nuke=False) v.pip_install("aafigure==0.6") - assert v.get_version("aafigure") == "0.6" \ No newline at end of file + assert v.get_version("aafigure") == "0.6"