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( "Deleting current {type} log file for {domain} via API". format(type=log_type, domain=self.domain))) if index == 1: url = get_api_endpoint().format( username=getpass.getuser(), flavor="files") + "path/var/log/{domain}.{type}.log.1/".format( domain=self.domain, type=log_type) elif index > 1: url = get_api_endpoint().format( username=getpass.getuser(), flavor="files" ) + "path/var/log/{domain}.{type}.log.{index}.gz/".format( domain=self.domain, type=log_type, index=index) else: url = get_api_endpoint().format( username=getpass.getuser(), flavor="files") + "path/var/log/{domain}.{type}.log/".format( domain=self.domain, type=log_type) response = call_api(url, "delete") if not response.ok: raise Exception( "DELETE log file via API failed, got {response}:{response_text}" .format(response=response, response_text=response.text))
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_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_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 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( "POST to create webapp via API failed, got {response}:{response_text}" .format(response=response, response_text=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 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( "GET log files info via API failed, got {response}:{response_text}" .format(response=response, response_text=response.text)) file_list = response.json() log_types = ["access", "error", "server"] logs = {"access": [], "error": [], "server": []} log_prefix = "/var/log/{domain}.".format(domain=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
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 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")
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 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_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_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)
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 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)
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)
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 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_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_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)
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)
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) )
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
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_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_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_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()
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 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)
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)}")