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 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 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 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 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 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 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 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 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 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 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 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 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)}")
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 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 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( "{base_url}{task_id}/".format(base_url=self.base_url, task_id=task_id), "DELETE") if result.status_code == 204: return True if not result.ok: raise Exception( "DELETE via API on task {task_id} failed, got {result}: " "{result_text}".format(task_id=task_id, result=result, result_text=result.text))
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 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 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}" )
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
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()