From 79956802eb8f248460c493d7daef33830cf4bded Mon Sep 17 00:00:00 2001 From: Markus Holtermann Date: Mon, 12 Aug 2019 15:55:54 +1000 Subject: [PATCH 1/8] Remove asyncio --- croud/api.py | 152 +++++++++++++++++ croud/clusters/commands.py | 19 +-- croud/consumers/commands.py | 15 +- croud/login.py | 52 +++--- croud/logout.py | 15 +- croud/me.py | 7 +- croud/monitoring/grafana/commands.py | 10 +- croud/organizations/auditlogs/commands.py | 5 +- croud/organizations/commands.py | 15 +- croud/organizations/users/commands.py | 14 +- croud/products/commands.py | 9 +- croud/projects/commands.py | 15 +- croud/projects/users/commands.py | 14 +- croud/rest.py | 109 ------------ croud/server.py | 101 +++++++----- croud/session.py | 116 ------------- croud/users/commands.py | 4 +- croud/users/roles/commands.py | 5 +- setup.py | 2 +- test-requirements.txt | 2 - tests/commands/test_clusters.py | 63 ++++--- tests/commands/test_consumers.py | 45 +++-- tests/commands/test_login.py | 16 +- tests/commands/test_me.py | 15 +- tests/commands/test_monitoring.py | 15 +- tests/commands/test_organizations.py | 119 +++++++------- tests/commands/test_products.py | 15 +- tests/commands/test_projects.py | 57 ++++--- tests/commands/test_users.py | 37 ++--- tests/conftest.py | 51 +++++- tests/test_api.py | 131 +++++++++++++++ tests/test_rest.py | 135 --------------- tests/test_server.py | 68 ++++---- tests/test_session.py | 72 -------- tests/util/__init__.py | 9 +- tests/util/fake_cloud.py | 191 ++++++++++++++++++++++ tests/util/fake_server.py | 138 ---------------- 37 files changed, 883 insertions(+), 975 deletions(-) create mode 100644 croud/api.py delete mode 100644 croud/rest.py delete mode 100644 croud/session.py create mode 100644 tests/test_api.py delete mode 100644 tests/test_rest.py delete mode 100644 tests/test_session.py create mode 100644 tests/util/fake_cloud.py delete mode 100644 tests/util/fake_server.py diff --git a/croud/api.py b/croud/api.py new file mode 100644 index 00000000..fe580749 --- /dev/null +++ b/croud/api.py @@ -0,0 +1,152 @@ +# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor +# license agreements. See the NOTICE file distributed with this work for +# additional information regarding copyright ownership. Crate licenses +# this file to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may +# obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# However, if you have executed another commercial license agreement +# with Crate these terms will supersede the license and you may use the +# software solely pursuant to the terms of the relevant commercial agreement. + +import enum +from argparse import Namespace +from typing import Dict, Optional, Tuple + +import requests +from yarl import URL + +from croud.config import Configuration +from croud.printer import print_error + +ResponsePair = Tuple[Optional[Dict], Optional[Dict]] + +CLOUD_LOCAL_URL = "http://localhost:8000" +CLOUD_DEV_DOMAIN = "cratedb-dev.cloud" +CLOUD_PROD_DOMAIN = "cratedb.cloud" + + +class RequestMethod(enum.Enum): + DELETE = "delete" + GET = "get" + PATCH = "patch" + POST = "post" + PUT = "put" + + +class Client: + def __init__(self, *, env: str, region: str, sudo: bool = False): + self.env = env or Configuration.get_env() + self.region = region or Configuration.get_setting("region") + self.sudo = sudo + + self.base_url = URL(cloud_url(self.env, self.region)) + + self._token = Configuration.get_token(self.env) + self.session = requests.Session() + if get_verify_ssl() is False: + self.session.verify = False + self.session.cookies["session"] = self._token + if self.sudo: + self.session.headers["X-Auth-Sudo"] = "1" + + @staticmethod + def from_args(args: Namespace) -> "Client": + return Client(env=args.env, region=args.region, sudo=args.sudo) + + def request( + self, + method: RequestMethod, + endpoint: str, + *, + params: dict = None, + body: dict = None, + ): + kwargs: dict = {"allow_redirects": False} + if params is not None: + kwargs["params"] = params + if body is not None: + kwargs["json"] = body + + try: + response = self.session.request( + method.value, str(self.base_url.with_path(endpoint)), **kwargs + ) + except requests.RequestException as e: + message = ( + f"Failed to perform command on {e.request.url}. " + f"Original error was: '{e}' " + f"Does the environment exist in the region you specified?" + ) + return None, {"message": message, "success": False} + + if response.is_redirect: # login redirect + print_error("Unauthorized. Use `croud login` to login to CrateDB Cloud.") + exit(1) + + # Refresh a previously provided token because it has timed out + response_token = response.cookies.get("session") + if response_token and response_token != self._token: + self._token = response_token + Configuration.set_token(response_token, self.env) + + return self.decode_response(response) + + def delete( + self, endpoint: str, *, params: dict = None, body: dict = None + ) -> ResponsePair: + return self.request(RequestMethod.DELETE, endpoint, params=params, body=body) + + def get(self, endpoint: str, *, params: dict = None) -> ResponsePair: + return self.request(RequestMethod.GET, endpoint, params=params) + + def patch( + self, endpoint: str, *, params: dict = None, body: dict = None + ) -> ResponsePair: + return self.request(RequestMethod.PATCH, endpoint, params=params, body=body) + + def post( + self, endpoint: str, *, params: dict = None, body: dict = None + ) -> ResponsePair: + return self.request(RequestMethod.POST, endpoint, params=params, body=body) + + def put( + self, endpoint: str, *, params: dict = None, body: dict = None + ) -> ResponsePair: + return self.request(RequestMethod.PUT, endpoint, params=params, body=body) + + def decode_response(self, resp: requests.Response) -> ResponsePair: + if resp.status_code == 204: + # response is empty + return None, None + + try: + # API always returns JSON, unless there's an unhandled server error + body = resp.json() + except ValueError: + body = {"message": "Invalid response type.", "success": False} + + if resp.status_code >= 400: + return None, body + else: + return body, None + + +def cloud_url(env: str, region: str = "bregenz.a1") -> str: + if env == "local": + return CLOUD_LOCAL_URL + + host = CLOUD_DEV_DOMAIN if env == "dev" else CLOUD_PROD_DOMAIN + return f"https://{region}.{host}" + + +def get_verify_ssl() -> bool: + return True diff --git a/croud/clusters/commands.py b/croud/clusters/commands.py index 75f7040b..e37c93d4 100644 --- a/croud/clusters/commands.py +++ b/croud/clusters/commands.py @@ -19,10 +19,9 @@ from argparse import Namespace +from croud.api import Client from croud.config import get_output_format from croud.printer import print_response -from croud.rest import Client -from croud.session import RequestMethod from croud.util import require_confirmation @@ -36,7 +35,7 @@ def clusters_list(args: Namespace) -> None: params["project_id"] = args.project_id client = Client.from_args(args) - data, errors = client.send(RequestMethod.GET, "/api/v2/clusters/", params=params) + data, errors = client.get("/api/v2/clusters/", params=params) print_response( data=data, errors=errors, @@ -72,7 +71,7 @@ def clusters_deploy(args: Namespace) -> None: if args.unit: body["product_unit"] = args.unit client = Client.from_args(args) - data, errors = client.send(RequestMethod.POST, "/api/v2/clusters/", body=body) + data, errors = client.post("/api/v2/clusters/", body=body) print_response( data=data, errors=errors, @@ -91,9 +90,7 @@ def clusters_scale(args: Namespace) -> None: body = {"product_unit": args.unit} client = Client.from_args(args) - data, errors = client.send( - RequestMethod.PUT, f"/api/v2/clusters/{args.cluster_id}/scale/", body=body - ) + data, errors = client.put(f"/api/v2/clusters/{args.cluster_id}/scale/", body=body) print_response( data=data, errors=errors, @@ -112,9 +109,7 @@ def clusters_upgrade(args: Namespace) -> None: body = {"crate_version": args.version} client = Client.from_args(args) - data, errors = client.send( - RequestMethod.PUT, f"/api/v2/clusters/{args.cluster_id}/upgrade/", body=body - ) + data, errors = client.put(f"/api/v2/clusters/{args.cluster_id}/upgrade/", body=body) print_response( data=data, errors=errors, @@ -132,9 +127,7 @@ def clusters_upgrade(args: Namespace) -> None: ) def clusters_delete(args: Namespace) -> None: client = Client.from_args(args) - data, errors = client.send( - RequestMethod.DELETE, f"/api/v2/clusters/{args.cluster_id}/" - ) + data, errors = client.delete(f"/api/v2/clusters/{args.cluster_id}/") print_response( data=data, errors=errors, diff --git a/croud/consumers/commands.py b/croud/consumers/commands.py index a0f68db4..2f8e645a 100644 --- a/croud/consumers/commands.py +++ b/croud/consumers/commands.py @@ -19,10 +19,9 @@ from argparse import Namespace +from croud.api import Client from croud.config import get_output_format from croud.printer import print_response -from croud.rest import Client -from croud.session import RequestMethod from croud.util import require_confirmation @@ -44,7 +43,7 @@ def consumers_deploy(args: Namespace) -> None: "table_schema": args.consumer_schema, } client = Client.from_args(args) - data, errors = client.send(RequestMethod.POST, "/api/v2/consumers/", body=body) + data, errors = client.post("/api/v2/consumers/", body=body) print_response( data=data, errors=errors, @@ -76,7 +75,7 @@ def consumers_list(args: Namespace) -> None: params["project_id"] = args.project_id client = Client.from_args(args) - data, errors = client.send(RequestMethod.GET, "/api/v2/consumers/", params=params) + data, errors = client.get("/api/v2/consumers/", params=params) print_response( data=data, errors=errors, @@ -114,9 +113,7 @@ def consumers_edit(args: Namespace) -> None: if config: body["config"] = config client = Client.from_args(args) - data, errors = client.send( - RequestMethod.PATCH, f"/api/v2/consumers/{args.consumer_id}/", body=body - ) + data, errors = client.patch(f"/api/v2/consumers/{args.consumer_id}/", body=body) print_response( data=data, errors=errors, keys=["id"], output_fmt=get_output_format(args) ) @@ -128,9 +125,7 @@ def consumers_edit(args: Namespace) -> None: ) def consumers_delete(args: Namespace) -> None: client = Client.from_args(args) - data, errors = client.send( - RequestMethod.DELETE, f"/api/v2/consumers/{args.consumer_id}/" - ) + data, errors = client.delete(f"/api/v2/consumers/{args.consumer_id}/") print_response( data=data, errors=errors, diff --git a/croud/login.py b/croud/login.py index b3358ddf..b5d51f8e 100644 --- a/croud/login.py +++ b/croud/login.py @@ -17,16 +17,14 @@ # with Crate these terms will supersede the license and you may use the # software solely pursuant to the terms of the relevant commercial agreement. -import asyncio from argparse import Namespace from functools import partial from typing import Optional +from croud.api import Client, cloud_url from croud.config import Configuration -from croud.printer import print_error, print_info -from croud.rest import Client, RequestMethod +from croud.printer import print_error, print_info, print_warning from croud.server import Server -from croud.session import cloud_url from croud.util import can_launch_browser, open_page_in_browser LOGIN_PATH = "/oauth2/login?cli=true" @@ -36,7 +34,7 @@ def get_org_id() -> Optional[str]: client = Client( env=Configuration.get_env(), region=Configuration.get_setting("region") ) - data, error = client.send(RequestMethod.GET, "/api/v2/users/me/") + data, error = client.get("/api/v2/users/me/") if data and not error: return data.get("organization_id") return None @@ -47,40 +45,30 @@ def login(args: Namespace) -> None: Performs an OAuth2 Login to CrateDB Cloud """ - if can_launch_browser(): - env = args.env or Configuration.get_env() - - loop = asyncio.get_event_loop() - server = Server(loop) - server.create_web_app(partial(Configuration.set_token, env=env)) - loop.run_until_complete(server.start()) - - open_page_in_browser(_login_url(env)) - print_info("A browser tab has been launched for you to login.") - - try: - loop.run_forever() - except KeyboardInterrupt: - loop.run_until_complete(server.stop()) - exit(1) - finally: - loop.run_until_complete(server.stop()) - - Configuration.set_context(env.lower()) + if not can_launch_browser(): + print_error("Login only works with a valid browser installed.") + exit(1) + env = args.env or Configuration.get_env() + server_thread = Server(partial(Configuration.set_token, env=env)) + Configuration.set_context(env.lower()) + server_thread.start() + open_page_in_browser(_login_url(env)) + print_info("A browser tab has been launched for you to login.") + try: + # Wait for the user to login. They'll be redirected to the `SetTokenHandler` + # which will set the token in the configuration. + server_thread.wait() + except (KeyboardInterrupt, SystemExit): + print_warning("Login cancelled.") + else: organization_id = get_org_id() if organization_id: Configuration.set_organization_id(organization_id, env) else: Configuration.set_organization_id("", env) - loop.close() - - else: - print_error("Login only works with a valid browser installed.") - exit(1) - - print_info("Login successful.") + print_info("Login successful.") def _login_url(env: str) -> str: diff --git a/croud/logout.py b/croud/logout.py index 25f518d2..ea78c134 100644 --- a/croud/logout.py +++ b/croud/logout.py @@ -17,32 +17,25 @@ # with Crate these terms will supersede the license and you may use the # software solely pursuant to the terms of the relevant commercial agreement. -import asyncio from argparse import Namespace +from croud.api import Client, cloud_url from croud.config import Configuration from croud.printer import print_info -from croud.session import HttpSession, cloud_url LOGOUT_PATH = "/oauth2/logout" def logout(args: Namespace) -> None: - loop = asyncio.get_event_loop() - env = args.env or Configuration.get_env() - token = Configuration.get_token(env) + client = Client.from_args(args) + env = client.env + client.get(_logout_url(env)) - loop.run_until_complete(make_request(env, token)) Configuration.set_token("", env) Configuration.set_organization_id("", env) print_info("You have been logged out.") -async def make_request(env: str, token: str) -> None: - async with HttpSession(env, token) as session: - await session.logout(_logout_url(env)) - - def _logout_url(env: str) -> str: return cloud_url(env) + LOGOUT_PATH diff --git a/croud/me.py b/croud/me.py index bb5a1da5..818f55f9 100644 --- a/croud/me.py +++ b/croud/me.py @@ -19,10 +19,9 @@ from argparse import Namespace +from croud.api import Client from croud.config import get_output_format from croud.printer import print_response -from croud.rest import Client -from croud.session import RequestMethod def me(args: Namespace) -> None: @@ -31,7 +30,7 @@ def me(args: Namespace) -> None: """ client = Client.from_args(args) - data, errors = client.send(RequestMethod.GET, "/api/v2/users/me/") + data, errors = client.get("/api/v2/users/me/") print_response( data=data, errors=errors, @@ -50,7 +49,7 @@ def me_edit(args: Namespace) -> None: body["email"] = args.email client = Client.from_args(args) - data, errors = client.send(RequestMethod.PATCH, "/api/v2/users/me/", body=body) + data, errors = client.patch("/api/v2/users/me/", body=body) print_response( data=data, errors=errors, diff --git a/croud/monitoring/grafana/commands.py b/croud/monitoring/grafana/commands.py index 1dcee167..5e204c10 100644 --- a/croud/monitoring/grafana/commands.py +++ b/croud/monitoring/grafana/commands.py @@ -19,18 +19,16 @@ from argparse import Namespace +from croud.api import Client from croud.config import get_output_format from croud.printer import print_response -from croud.rest import Client -from croud.session import RequestMethod def set_grafana(enable: bool, args: Namespace) -> None: - method = RequestMethod.POST if enable else RequestMethod.DELETE - client = Client.from_args(args) - data, errors = client.send( - method, "/api/v2/monitoring/grafana/", body={"project_id": args.project_id} + method = client.post if enable else client.delete + data, errors = method( + "/api/v2/monitoring/grafana/", body={"project_id": args.project_id} ) state = "enabled" if enable else "disabled" print_response( diff --git a/croud/organizations/auditlogs/commands.py b/croud/organizations/auditlogs/commands.py index ab7fefd5..965cc224 100644 --- a/croud/organizations/auditlogs/commands.py +++ b/croud/organizations/auditlogs/commands.py @@ -21,10 +21,9 @@ from argparse import Namespace from typing import Dict, List +from croud.api import Client from croud.config import get_output_format from croud.printer import print_error, print_response -from croud.rest import Client -from croud.session import RequestMethod from croud.util import org_id_config_fallback # Hat tip to Django for ISO8601 deserialization functions @@ -69,7 +68,7 @@ def auditlogs_list(args: Namespace) -> None: while True: if cursor: params["last"] = cursor - page, errors = client.send(RequestMethod.GET, url, params=params) + page, errors = client.get(url, params=params) if errors or not page: break else: diff --git a/croud/organizations/commands.py b/croud/organizations/commands.py index 9db9bd75..d7158378 100644 --- a/croud/organizations/commands.py +++ b/croud/organizations/commands.py @@ -19,10 +19,9 @@ from argparse import Namespace +from croud.api import Client from croud.config import Configuration, get_output_format from croud.printer import print_error, print_response -from croud.rest import Client -from croud.session import RequestMethod from croud.util import org_id_config_fallback, require_confirmation @@ -37,7 +36,7 @@ def organizations_create(args: Namespace) -> None: else: body = {"name": args.name} - data, errors = client.send(RequestMethod.POST, "/api/v2/organizations/", body=body) + data, errors = client.post("/api/v2/organizations/", body=body) print_response( data=data, errors=errors, @@ -63,9 +62,7 @@ def organizations_edit(args: Namespace) -> None: print_error("No input arguments found.") exit(1) - data, errors = client.send( - RequestMethod.PUT, f"/api/v2/organizations/{args.org_id}/", body=body - ) + data, errors = client.put(f"/api/v2/organizations/{args.org_id}/", body=body) print_response( data=data, errors=errors, @@ -81,7 +78,7 @@ def organizations_list(args: Namespace) -> None: """ client = Client.from_args(args) - data, errors = client.send(RequestMethod.GET, "/api/v2/organizations/") + data, errors = client.get("/api/v2/organizations/") print_response( data=data, errors=errors, @@ -101,9 +98,7 @@ def organizations_delete(args: Namespace) -> None: """ client = Client.from_args(args) - data, errors = client.send( - RequestMethod.DELETE, f"/api/v2/organizations/{args.org_id}/" - ) + data, errors = client.delete(f"/api/v2/organizations/{args.org_id}/") print_response( data=data, errors=errors, diff --git a/croud/organizations/users/commands.py b/croud/organizations/users/commands.py index f10a9909..5ac99a08 100644 --- a/croud/organizations/users/commands.py +++ b/croud/organizations/users/commands.py @@ -22,10 +22,9 @@ from argparse import Namespace +from croud.api import Client from croud.config import get_output_format from croud.printer import print_response -from croud.rest import Client -from croud.session import RequestMethod from croud.util import org_id_config_fallback @@ -33,8 +32,7 @@ def org_users_add(args: Namespace): client = Client.from_args(args) - data, errors = client.send( - RequestMethod.POST, + data, errors = client.post( f"/api/v2/organizations/{args.org_id}/users/", body={"user": args.user, "role_fqn": args.role}, ) @@ -63,9 +61,7 @@ def org_users_list(args: Namespace) -> None: """ client = Client.from_args(args) - data, errors = client.send( - RequestMethod.GET, f"/api/v2/organizations/{args.org_id}/users/" - ) + data, errors = client.get(f"/api/v2/organizations/{args.org_id}/users/") print_response( data=data, errors=errors, @@ -79,8 +75,8 @@ def org_users_list(args: Namespace) -> None: def org_users_remove(args: Namespace): client = Client.from_args(args) - data, errors = client.send( - RequestMethod.DELETE, f"/api/v2/organizations/{args.org_id}/users/{args.user}/" + data, errors = client.delete( + f"/api/v2/organizations/{args.org_id}/users/{args.user}/" ) print_response( data=data, diff --git a/croud/products/commands.py b/croud/products/commands.py index 4866cf80..393323f1 100644 --- a/croud/products/commands.py +++ b/croud/products/commands.py @@ -19,10 +19,9 @@ from argparse import Namespace +from croud.api import Client from croud.config import get_output_format from croud.printer import print_response -from croud.rest import Client -from croud.session import RequestMethod def products_list(args: Namespace) -> None: @@ -32,10 +31,10 @@ def products_list(args: Namespace) -> None: client = Client.from_args(args) url = "/api/v2/products/" + params = {} if args.kind: - data, errors = client.send(RequestMethod.GET, url, params={"kind": args.kind}) - else: - data, errors = client.send(RequestMethod.GET, url) + params["kind"] = args.kind + data, errors = client.get(url, params=params) print_response( data=data, errors=errors, diff --git a/croud/projects/commands.py b/croud/projects/commands.py index 1f3204a9..f6134400 100644 --- a/croud/projects/commands.py +++ b/croud/projects/commands.py @@ -19,10 +19,9 @@ from argparse import Namespace +from croud.api import Client from croud.config import get_output_format from croud.printer import print_response -from croud.rest import Client -from croud.session import RequestMethod from croud.util import org_id_config_fallback, require_confirmation @@ -33,10 +32,8 @@ def project_create(args: Namespace) -> None: """ client = Client.from_args(args) - data, errors = client.send( - RequestMethod.POST, - "/api/v2/projects/", - body={"name": args.name, "organization_id": args.org_id}, + data, errors = client.post( + "/api/v2/projects/", body={"name": args.name, "organization_id": args.org_id} ) print_response( data=data, @@ -56,9 +53,7 @@ def project_delete(args: Namespace) -> None: Deletes a project in the organization the user belongs to. """ client = Client.from_args(args) - data, errors = client.send( - RequestMethod.DELETE, f"/api/v2/projects/{args.project_id}/" - ) + data, errors = client.delete(f"/api/v2/projects/{args.project_id}/") print_response( data=data, errors=errors, @@ -73,7 +68,7 @@ def projects_list(args: Namespace) -> None: """ client = Client.from_args(args) - data, errors = client.send(RequestMethod.GET, "/api/v2/projects/") + data, errors = client.get("/api/v2/projects/") print_response( data=data, errors=errors, diff --git a/croud/projects/users/commands.py b/croud/projects/users/commands.py index 5376f1ef..f247698e 100644 --- a/croud/projects/users/commands.py +++ b/croud/projects/users/commands.py @@ -19,10 +19,9 @@ from argparse import Namespace +from croud.api import Client from croud.config import get_output_format from croud.printer import print_response -from croud.rest import Client -from croud.session import RequestMethod def project_users_add(args: Namespace) -> None: @@ -32,8 +31,7 @@ def project_users_add(args: Namespace) -> None: client = Client.from_args(args) - data, errors = client.send( - RequestMethod.POST, + data, errors = client.post( f"/api/v2/projects/{args.project_id}/users/", body={"user": args.user, "role_fqn": args.role}, ) @@ -61,9 +59,7 @@ def project_users_list(args: Namespace) -> None: """ client = Client.from_args(args) - data, errors = client.send( - RequestMethod.GET, f"/api/v2/projects/{args.project_id}/users/" - ) + data, errors = client.get(f"/api/v2/projects/{args.project_id}/users/") print_response( data=data, errors=errors, @@ -79,8 +75,8 @@ def project_users_remove(args: Namespace) -> None: """ client = Client.from_args(args) - data, errors = client.send( - RequestMethod.DELETE, f"/api/v2/projects/{args.project_id}/users/{args.user}/" + data, errors = client.delete( + f"/api/v2/projects/{args.project_id}/users/{args.user}/" ) print_response( data=data, diff --git a/croud/rest.py b/croud/rest.py deleted file mode 100644 index c72e63bd..00000000 --- a/croud/rest.py +++ /dev/null @@ -1,109 +0,0 @@ -# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor -# license agreements. See the NOTICE file distributed with this work for -# additional information regarding copyright ownership. Crate licenses -# this file to you under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. You may -# obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -# -# However, if you have executed another commercial license agreement -# with Crate these terms will supersede the license and you may use the -# software solely pursuant to the terms of the relevant commercial agreement. - -import asyncio -import uuid -from argparse import Namespace -from functools import partial -from typing import Optional - -from aiohttp import ClientResponse, ContentTypeError, TCPConnector # type: ignore -from aiohttp.client_exceptions import ClientConnectorError - -from croud.config import Configuration -from croud.session import HttpSession, RequestMethod - - -class Client: - _env: str - _token: str - _region: str - _sudo: bool - - def __init__( - self, - env: str, - region: str, - loop=None, - sudo: bool = False, - conn: Optional[TCPConnector] = None, - ): - self._env = env or Configuration.get_env() - self._token = Configuration.get_token(self._env) - self._region = region or Configuration.get_setting("region") - self._sudo = sudo - self.loop = loop or asyncio.get_event_loop() - self.conn = conn - - @staticmethod - def from_args(args: Namespace) -> "Client": - return Client(env=args.env, region=args.region, sudo=args.sudo) - - def send( - self, - method: RequestMethod, - endpoint: str, - *, - body: dict = None, - params: dict = None, - ): - return self.loop.run_until_complete(self.fetch(method, endpoint, body, params)) - - async def fetch( - self, - method: RequestMethod, - endpoint: str, - body: dict = None, - params: dict = None, - ): - async with HttpSession( - self._env, - self._token, - self._region, - on_new_token=partial(Configuration.set_token, env=self._env), - headers={"X-Auth-Sudo": str(uuid.uuid4())} if self._sudo is True else {}, - conn=self.conn, - ) as session: - try: - resp = await session.fetch(method, endpoint, body, params) - except ClientConnectorError as e: - message = ( - f"Failed to perform command on {e.host}. " - f"Original error was: '{e}' " - f"Does the environment exist in the region you specified?" - ) - return None, {"message": message, "success": False} - else: - return await self._decode_response(resp) - - async def _decode_response(self, resp: ClientResponse): - if resp.status == 204: - # response is empty - return None, None - - try: - body = await resp.json() - # API always returns JSON, unless there's an unhandled server error - except ContentTypeError: - body = {"message": "Invalid response type.", "success": False} - - if resp.status >= 400: - return None, body - else: - return body, None diff --git a/croud/server.py b/croud/server.py index 12992c73..ad9651b7 100644 --- a/croud/server.py +++ b/croud/server.py @@ -17,51 +17,78 @@ # with Crate these terms will supersede the license and you may use the # software solely pursuant to the terms of the relevant commercial agreement. -import asyncio +from http.server import BaseHTTPRequestHandler, HTTPServer +from threading import Thread from typing import Callable +from urllib import parse -from aiohttp import web +HOST = "localhost" +PORT = 8400 -class Server: - LOGIN_MSG: str = """ - You have successfully logged into CrateDB Cloud! -

This window can be closed.

""" +class SetTokenHTTPServer(HTTPServer): + def __init__(self, on_token: Callable[[str], None], *args, **kwargs): + self.on_token = on_token + super().__init__(*args, **kwargs) - PORT: int = 8400 - def __init__(self, loop: asyncio.AbstractEventLoop): - self.loop = loop +class SetTokenHandler(BaseHTTPRequestHandler): + SUCCESS_MSG = ( + b"You have successfully logged into CrateDB Cloud!" + b"

This window can be closed.

" + ) + MISSING_TOKEN_MSG = ( + b"Login to CrateDB Cloud failed!" + b"

Authentication token missing in URL.

" + ) + DUPLICATE_TOKEN_MSG = ( + b"Login to CrateDB Cloud failed!" + b"

More than one authentication token present in URL.

" + ) - def create_web_app(self, on_token: Callable[[str], None]) -> web.Application: - app = web.Application() - app.add_routes([web.get("/", self.handle_session)]) - app.on_response_prepare.append(self.after_request) # type: ignore - self.runner = web.AppRunner(app) - self.on_token = on_token - return app + def do_GET(self): + query_string = parse.urlparse(self.path).query + query = parse.parse_qs(query_string) + if "token" not in query: + code = 400 + msg = SetTokenHandler.MISSING_TOKEN_MSG + elif len(query["token"]) != 1: + code = 400 + msg = SetTokenHandler.DUPLICATE_TOKEN_MSG + else: + token = query["token"][0] + self.server.on_token(token) + code = 200 + msg = SetTokenHandler.SUCCESS_MSG + self.send_response(code) + self.send_header("Content-Type", "text/html") + self.send_header("Content-Length", str(len(msg))) + self.end_headers() + self.wfile.write(msg) + # Set the termination condition for the server's `serve_forever`. + # We cannot use `self.server.shutdown()` here because we're in the same + # thread as the one who'd be waiting for the shutdown, thus causing a + # deadlock. + self.server._BaseServer__shutdown_request = True - async def handle_session(self, req: web.Request) -> web.Response: - """Token handler that receives the session token from query param""" - try: - self.on_token(req.rel_url.query["token"]) - except KeyError as ex: - return web.Response(status=500, text=f"No query param {ex!s} in request") - return web.Response(status=200, text=Server.LOGIN_MSG, content_type="text/html") + def log_request(self, *args, **kwargs): + # Just don't log anything ... + pass + + +class Server: + def __init__(self, on_token, random_port: bool = False): + port = 0 if random_port else PORT + self._server = SetTokenHTTPServer(on_token, (HOST, port), SetTokenHandler) + self._thread = Thread(target=self._server.serve_forever, daemon=True) - async def after_request(self, req: web.Request, resp: web.Response) -> web.Response: - """middleware callback right after a request got handled""" - # stop the event loop right after successful login response - # so that the Server can be terminated gracefully afterwards - self.loop.stop() - return resp + def start(self) -> "Server": + self._thread.start() + return self - async def start(self) -> None: - """Start a local HTTP server""" - await self.runner.setup() - site = web.TCPSite(self.runner, "localhost", Server.PORT) - await site.start() + def wait(self, timeout=None): + self._thread.join(timeout=timeout) - async def stop(self) -> None: - """Shutdown the server gracefully""" - await self.runner.cleanup() + @property + def port(self): + return self._server.socket.getsockname()[1] diff --git a/croud/session.py b/croud/session.py deleted file mode 100644 index e993793f..00000000 --- a/croud/session.py +++ /dev/null @@ -1,116 +0,0 @@ -# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor -# license agreements. See the NOTICE file distributed with this work for -# additional information regarding copyright ownership. Crate licenses -# this file to you under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. You may -# obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -# -# However, if you have executed another commercial license agreement -# with Crate these terms will supersede the license and you may use the -# software solely pursuant to the terms of the relevant commercial agreement. - -import ssl -from enum import Enum -from types import TracebackType -from typing import Callable, Dict, Optional, Type - -import certifi -from aiohttp import ClientResponse, ClientSession, TCPConnector # type: ignore -from yarl import URL - -from croud.printer import print_error - -CLOUD_LOCAL_URL = "http://localhost:8000" -CLOUD_DEV_DOMAIN = "cratedb-dev.cloud" -CLOUD_PROD_DOMAIN = "cratedb.cloud" - - -class RequestMethod(Enum): - GET = "get" - POST = "post" - DELETE = "delete" - PATCH = "patch" - PUT = "put" - - -class HttpSession: - def __init__( - self, - env: str, - token: str, - region: str = "bregenz.a1", - url: Optional[str] = None, - conn: Optional[TCPConnector] = None, - headers: Dict[str, str] = {}, - on_new_token: Callable[[str], None] = None, - ) -> None: - self.env = env - self.token = token - self.on_new_token = on_new_token - - if not url: - url = cloud_url(env, region) - - self.url = url - if conn is None: - conn = self._get_conn() - - self.client = ClientSession( - cookies={"session": self.token}, connector=conn, headers=headers - ) - - def _get_conn(self) -> TCPConnector: - ssl_context = ssl.create_default_context(cafile=certifi.where()) - return TCPConnector(ssl=ssl_context) - - async def fetch( - self, - method: RequestMethod, - endpoint: str, - body: dict = None, - params: dict = None, - ) -> ClientResponse: - url = URL(self.url).with_path(endpoint) - resp = await getattr(self.client, method.value)( - url, json=body, allow_redirects=False, params=params - ) - if resp.status == 302: # login redirect - print_error("Unauthorized. Use `croud login` to login to CrateDB Cloud.") - exit(1) - return resp - - async def logout(self, url: str): - await self.client.get(url) - - async def __aenter__(self) -> "HttpSession": - return self - - async def close(self) -> None: - new_token = self.client.cookie_jar.filter_cookies(self.url).get("session") - if new_token and self.token != new_token.value and self.on_new_token: - self.on_new_token(new_token.value) - await self.client.close() - - async def __aexit__( - self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], - ) -> None: - await self.close() - - -def cloud_url(env: str, region: str = "bregenz.a1") -> str: - if env == "local": - return CLOUD_LOCAL_URL - - host = CLOUD_DEV_DOMAIN if env == "dev" else CLOUD_PROD_DOMAIN - return f"https://{region}.{host}" diff --git a/croud/users/commands.py b/croud/users/commands.py index fe9ca082..21f77962 100644 --- a/croud/users/commands.py +++ b/croud/users/commands.py @@ -19,9 +19,9 @@ from argparse import Namespace +from croud.api import Client from croud.config import get_output_format from croud.printer import print_response, print_warning -from croud.rest import Client, RequestMethod def transform_roles_list(key): @@ -42,7 +42,7 @@ def users_list(args: Namespace) -> None: ) no_roles = {"no-roles": "1"} if (args.no_roles or args.no_org) else None - data, errors = client.send(RequestMethod.GET, f"/api/v2/users/", params=no_roles) + data, errors = client.get("/api/v2/users/", params=no_roles) print_response( data=data, errors=errors, diff --git a/croud/users/roles/commands.py b/croud/users/roles/commands.py index 32571867..45437867 100644 --- a/croud/users/roles/commands.py +++ b/croud/users/roles/commands.py @@ -19,10 +19,9 @@ from argparse import Namespace +from croud.api import Client from croud.config import get_output_format from croud.printer import print_response -from croud.rest import Client -from croud.session import RequestMethod def roles_list(args: Namespace) -> None: @@ -31,7 +30,7 @@ def roles_list(args: Namespace) -> None: """ client = Client.from_args(args) - data, errors = client.send(RequestMethod.GET, "/api/v2/roles/") + data, errors = client.get("/api/v2/roles/") print_response( data=data, errors=errors, diff --git a/setup.py b/setup.py index d4a651f6..c8776f79 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ entry_points={"console_scripts": ["croud = croud.__main__:main"]}, packages=find_packages(), install_requires=[ - "aiohttp==3.4.4", + "requests==2.22.0", "colorama==0.4.1", "appdirs==1.4.3", "pyyaml==5.1.2", diff --git a/test-requirements.txt b/test-requirements.txt index 91a1afbb..e62973b8 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -4,8 +4,6 @@ pytest-mock==0.10.1 pytest-isort==0.2.1 pytest-black==0.3.5 pytest-mypy==0.3.2 -pytest-aiohttp==0.3.0 -pytest-asyncio==0.10.0 pytest-random-order==1.0.4 mypy==0.670 black==19.3b0 diff --git a/tests/commands/test_clusters.py b/tests/commands/test_clusters.py index 52d9b8ef..b0db8374 100644 --- a/tests/commands/test_clusters.py +++ b/tests/commands/test_clusters.py @@ -19,26 +19,25 @@ from unittest import mock +from croud.api import Client, RequestMethod from croud.config import Configuration -from croud.rest import Client -from croud.session import RequestMethod from tests.util import assert_rest, call_command, gen_uuid @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_clusers_list(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_clusers_list(mock_request, mock_load_config): call_command("croud", "clusters", "list") - assert_rest(mock_send, RequestMethod.GET, "/api/v2/clusters/", params={}) + assert_rest(mock_request, RequestMethod.GET, "/api/v2/clusters/", params={}) @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_clusers_list_with_project_id(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_clusers_list_with_project_id(mock_request, mock_load_config): project_id = gen_uuid() call_command("croud", "clusters", "list", "--project-id", project_id) assert_rest( - mock_send, + mock_request, RequestMethod.GET, "/api/v2/clusters/", params={"project_id": project_id}, @@ -46,8 +45,8 @@ def test_clusers_list_with_project_id(mock_send, mock_load_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_clusers_deploy(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_clusers_deploy(mock_request, mock_load_config): project_id = gen_uuid() call_command( "croud", @@ -71,7 +70,7 @@ def test_clusers_deploy(mock_send, mock_load_config): "s3cr3t!", ) assert_rest( - mock_send, + mock_request, RequestMethod.POST, "/api/v2/clusters/", body={ @@ -89,8 +88,8 @@ def test_clusers_deploy(mock_send, mock_load_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_clusers_deploy_no_unit(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_clusers_deploy_no_unit(mock_request, mock_load_config): project_id = gen_uuid() call_command( "croud", @@ -112,7 +111,7 @@ def test_clusers_deploy_no_unit(mock_send, mock_load_config): "s3cr3t!", ) assert_rest( - mock_send, + mock_request, RequestMethod.POST, "/api/v2/clusters/", body={ @@ -129,8 +128,8 @@ def test_clusers_deploy_no_unit(mock_send, mock_load_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_clusers_deploy_nightly(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_clusers_deploy_nightly(mock_request, mock_load_config): project_id = gen_uuid() call_command( "croud", @@ -156,7 +155,7 @@ def test_clusers_deploy_nightly(mock_send, mock_load_config): "nightly", ) assert_rest( - mock_send, + mock_request, RequestMethod.POST, "/api/v2/clusters/", body={ @@ -174,15 +173,15 @@ def test_clusers_deploy_nightly(mock_send, mock_load_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_clusers_scale(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_clusers_scale(mock_request, mock_load_config): unit = 1 cluster_id = gen_uuid() call_command( "croud", "clusters", "scale", "--cluster-id", cluster_id, "--unit", "1" ) assert_rest( - mock_send, + mock_request, RequestMethod.PUT, f"/api/v2/clusters/{cluster_id}/scale/", body={"product_unit": unit}, @@ -190,15 +189,15 @@ def test_clusers_scale(mock_send, mock_load_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_clusers_upgrade(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_clusers_upgrade(mock_request, mock_load_config): version = "3.2.6" cluster_id = gen_uuid() call_command( "croud", "clusters", "upgrade", "--cluster-id", cluster_id, "--version", version ) assert_rest( - mock_send, + mock_request, RequestMethod.PUT, f"/api/v2/clusters/{cluster_id}/upgrade/", body={"crate_version": version}, @@ -206,12 +205,12 @@ def test_clusers_upgrade(mock_send, mock_load_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=(None, {})) -def test_clusters_delete(mock_send, mock_load_config, capsys): +@mock.patch.object(Client, "request", return_value=(None, {})) +def test_clusters_delete(mock_request, mock_load_config, capsys): cluster_id = gen_uuid() with mock.patch("builtins.input", side_effect=["yes"]) as mock_input: call_command("croud", "clusters", "delete", "--cluster-id", cluster_id) - assert_rest(mock_send, RequestMethod.DELETE, f"/api/v2/clusters/{cluster_id}/") + assert_rest(mock_request, RequestMethod.DELETE, f"/api/v2/clusters/{cluster_id}/") mock_input.assert_called_once_with( "Are you sure you want to delete the cluster? [yN] " ) @@ -222,12 +221,12 @@ def test_clusters_delete(mock_send, mock_load_config, capsys): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=(None, {})) -def test_clusters_delete_flag(mock_send, mock_load_config, capsys): +@mock.patch.object(Client, "request", return_value=(None, {})) +def test_clusters_delete_flag(mock_request, mock_load_config, capsys): cluster_id = gen_uuid() with mock.patch("builtins.input", side_effect=["y"]) as mock_input: call_command("croud", "clusters", "delete", "--cluster-id", cluster_id, "-y") - assert_rest(mock_send, RequestMethod.DELETE, f"/api/v2/clusters/{cluster_id}/") + assert_rest(mock_request, RequestMethod.DELETE, f"/api/v2/clusters/{cluster_id}/") mock_input.assert_not_called() _, err_output = capsys.readouterr() @@ -236,12 +235,12 @@ def test_clusters_delete_flag(mock_send, mock_load_config, capsys): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=(None, {})) -def test_clusters_delete_aborted(mock_send, mock_load_config, capsys): +@mock.patch.object(Client, "request", return_value=(None, {})) +def test_clusters_delete_aborted(mock_request, mock_load_config, capsys): cluster_id = gen_uuid() with mock.patch("builtins.input", side_effect=["Nooooo"]) as mock_input: call_command("croud", "clusters", "delete", "--cluster-id", cluster_id) - mock_send.assert_not_called() + mock_request.assert_not_called() mock_input.assert_called_once_with( "Are you sure you want to delete the cluster? [yN] " ) diff --git a/tests/commands/test_consumers.py b/tests/commands/test_consumers.py index 02439808..c15542f1 100644 --- a/tests/commands/test_consumers.py +++ b/tests/commands/test_consumers.py @@ -19,9 +19,8 @@ from unittest import mock +from croud.api import Client, RequestMethod from croud.config import Configuration -from croud.rest import Client -from croud.session import RequestMethod from tests.util import assert_rest, call_command, gen_uuid eventhub_dsn = "Endpoint=sb://myhub.servicebus.windows.net/;SharedAccessKeyName=...;SharedAccessKey=...;EntityPath=..." # noqa @@ -29,8 +28,8 @@ @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=["data", "errors"]) -def test_consumers_deploy(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=["data", "errors"]) +def test_consumers_deploy(mock_request, mock_load_config): project_id = gen_uuid() cluster_id = gen_uuid() @@ -65,7 +64,7 @@ def test_consumers_deploy(mock_send, mock_load_config): ) assert_rest( - mock_send, + mock_request, RequestMethod.POST, "/api/v2/consumers/", body={ @@ -88,15 +87,15 @@ def test_consumers_deploy(mock_send, mock_load_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_consumers_list(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_consumers_list(mock_request, mock_load_config): call_command("croud", "consumers", "list") - assert_rest(mock_send, RequestMethod.GET, "/api/v2/consumers/", params={}) + assert_rest(mock_request, RequestMethod.GET, "/api/v2/consumers/", params={}) @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_consumers_list_with_params(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_consumers_list_with_params(mock_request, mock_load_config): project_id = gen_uuid() cluster_id = gen_uuid() call_command( @@ -111,7 +110,7 @@ def test_consumers_list_with_params(mock_send, mock_load_config): "eventhub-consumer", ) assert_rest( - mock_send, + mock_request, RequestMethod.GET, "/api/v2/consumers/", params={ @@ -123,8 +122,8 @@ def test_consumers_list_with_params(mock_send, mock_load_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_consumers_edit(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_consumers_edit(mock_request, mock_load_config): consumer_id = gen_uuid() cluster_id = gen_uuid() @@ -151,7 +150,7 @@ def test_consumers_edit(mock_send, mock_load_config): ) assert_rest( - mock_send, + mock_request, RequestMethod.PATCH, f"/api/v2/consumers/{consumer_id}/", body={ @@ -169,12 +168,12 @@ def test_consumers_edit(mock_send, mock_load_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=(None, {})) -def test_consumers_delete(mock_send, mock_load_config, capsys): +@mock.patch.object(Client, "request", return_value=(None, {})) +def test_consumers_delete(mock_request, mock_load_config, capsys): consumer_id = gen_uuid() with mock.patch("builtins.input", side_effect=["YES"]) as mock_input: call_command("croud", "consumers", "delete", "--consumer-id", consumer_id) - assert_rest(mock_send, RequestMethod.DELETE, f"/api/v2/consumers/{consumer_id}/") + assert_rest(mock_request, RequestMethod.DELETE, f"/api/v2/consumers/{consumer_id}/") mock_input.assert_called_once_with( "Are you sure you want to delete the consumer? [yN] " ) @@ -185,12 +184,12 @@ def test_consumers_delete(mock_send, mock_load_config, capsys): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=(None, {})) -def test_consumers_delete_flag(mock_send, mock_load_config, capsys): +@mock.patch.object(Client, "request", return_value=(None, {})) +def test_consumers_delete_flag(mock_request, mock_load_config, capsys): consumer_id = gen_uuid() with mock.patch("builtins.input", side_effect=["y"]) as mock_input: call_command("croud", "consumers", "delete", "--consumer-id", consumer_id, "-y") - assert_rest(mock_send, RequestMethod.DELETE, f"/api/v2/consumers/{consumer_id}/") + assert_rest(mock_request, RequestMethod.DELETE, f"/api/v2/consumers/{consumer_id}/") mock_input.assert_not_called() _, err_output = capsys.readouterr() @@ -199,12 +198,12 @@ def test_consumers_delete_flag(mock_send, mock_load_config, capsys): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=(None, {})) -def test_consumers_delete_aborted(mock_send, mock_load_config, capsys): +@mock.patch.object(Client, "request", return_value=(None, {})) +def test_consumers_delete_aborted(mock_request, mock_load_config, capsys): consumer_id = gen_uuid() with mock.patch("builtins.input", side_effect=["Nooooo"]) as mock_input: call_command("croud", "consumers", "delete", "--consumer-id", consumer_id) - mock_send.assert_not_called() + mock_request.assert_not_called() mock_input.assert_called_once_with( "Are you sure you want to delete the consumer? [yN] " ) diff --git a/tests/commands/test_login.py b/tests/commands/test_login.py index 3d2d7164..56f6bfab 100644 --- a/tests/commands/test_login.py +++ b/tests/commands/test_login.py @@ -27,9 +27,8 @@ from tests.util import MockConfig, call_command -@mock.patch.object(Server, "stop") +@mock.patch.object(Server, "wait") @mock.patch.object(Server, "start") -@mock.patch("croud.login.asyncio.get_event_loop") @mock.patch("croud.login.can_launch_browser", return_value=True) @mock.patch("croud.login.open_page_in_browser") @mock.patch("croud.login.print_info") @@ -37,9 +36,8 @@ def test_login( mock_print_info, mock_open_page_in_browser, mock_can_launch_browser, - mock_loop, mock_start, - mock_stop, + mock_wait, ): cfg = MockConfig(Configuration.DEFAULT_CONFIG) @@ -58,9 +56,8 @@ def test_login( assert config["auth"]["contexts"]["dev"]["organization_id"] == "my-org-id" -@mock.patch.object(Server, "stop") +@mock.patch.object(Server, "wait") @mock.patch.object(Server, "start") -@mock.patch("croud.login.asyncio.get_event_loop") @mock.patch("croud.login.can_launch_browser", return_value=True) @mock.patch("croud.login.open_page_in_browser") @mock.patch("croud.login.print_info") @@ -68,9 +65,8 @@ def test_login_local( mock_print_info, mock_open_page_in_browser, mock_can_launch_browser, - mock_loop, mock_start, - mock_stop, + mock_wait, ): """ Test for a bug that caused that upon login to local env the local token @@ -92,7 +88,7 @@ def test_login_local( with mock.patch("croud.config.load_config", side_effect=cfg.read_config): with mock.patch("croud.config.write_config", side_effect=cfg.write_config): - with mock.patch("croud.rest.Client.send", return_value=[None, None]): + with mock.patch("croud.api.Client.request", return_value=[None, None]): call_command("croud", "login", "--env", "local") new_cfg = cfg.read_config() @@ -143,7 +139,7 @@ def test_get_org_id(org_id_param): with mock.patch("croud.config.load_config", side_effect=cfg.read_config): with mock.patch("croud.config.write_config", side_effect=cfg.write_config): with mock.patch( - "croud.rest.Client.send", + "croud.api.Client.request", return_value=[{"organization_id": org_id_param}, None], ): org_id = get_org_id() diff --git a/tests/commands/test_me.py b/tests/commands/test_me.py index 6e222d35..0ef23cda 100644 --- a/tests/commands/test_me.py +++ b/tests/commands/test_me.py @@ -19,25 +19,24 @@ from unittest import mock +from croud.api import Client, RequestMethod from croud.config import Configuration -from croud.rest import Client -from croud.session import RequestMethod from tests.util import assert_rest, call_command @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_me(mock_send, mock_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_me(mock_request, mock_config): call_command("croud", "me") - assert_rest(mock_send, RequestMethod.GET, "/api/v2/users/me/") + assert_rest(mock_request, RequestMethod.GET, "/api/v2/users/me/") @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_me_edit_email(mock_send, mock_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_me_edit_email(mock_request, mock_config): call_command("croud", "me", "edit", "--email", "user@crate.io") assert_rest( - mock_send, + mock_request, RequestMethod.PATCH, "/api/v2/users/me/", body={"email": "user@crate.io"}, diff --git a/tests/commands/test_monitoring.py b/tests/commands/test_monitoring.py index b2adb0d8..1fdf5ff8 100644 --- a/tests/commands/test_monitoring.py +++ b/tests/commands/test_monitoring.py @@ -19,19 +19,18 @@ from unittest import mock +from croud.api import Client, RequestMethod from croud.config import Configuration -from croud.rest import Client -from croud.session import RequestMethod from tests.util import assert_rest, call_command, gen_uuid @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_monitoring_grafana_enable(mock_send, mock_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_monitoring_grafana_enable(mock_request, mock_config): project_id = gen_uuid() call_command("croud", "monitoring", "grafana", "enable", "--project-id", project_id) assert_rest( - mock_send, + mock_request, RequestMethod.POST, "/api/v2/monitoring/grafana/", body={"project_id": project_id}, @@ -39,14 +38,14 @@ def test_monitoring_grafana_enable(mock_send, mock_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_monitoring_grafana_disable(mock_send, mock_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_monitoring_grafana_disable(mock_request, mock_config): project_id = gen_uuid() call_command( "croud", "monitoring", "grafana", "disable", "--project-id", project_id ) assert_rest( - mock_send, + mock_request, RequestMethod.DELETE, "/api/v2/monitoring/grafana/", body={"project_id": project_id}, diff --git a/tests/commands/test_organizations.py b/tests/commands/test_organizations.py index 78b0bce2..ca7dcce6 100644 --- a/tests/commands/test_organizations.py +++ b/tests/commands/test_organizations.py @@ -21,12 +21,11 @@ import pytest +from croud.api import Client, RequestMethod from croud.config import Configuration from croud.organizations.users.commands import ( role_fqn_transform as organization_role_fqn_transform, ) -from croud.rest import Client -from croud.session import RequestMethod from tests.util import assert_rest, call_command, gen_uuid config_org_id = gen_uuid() @@ -45,11 +44,11 @@ @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_organizations_create(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_organizations_create(mock_request, mock_load_config): call_command("croud", "organizations", "create", "--name", "test-org") assert_rest( - mock_send, + mock_request, RequestMethod.POST, "/api/v2/organizations/", body={"name": "test-org"}, @@ -57,13 +56,13 @@ def test_organizations_create(mock_send, mock_load_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_organizations_create_plan_type(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_organizations_create_plan_type(mock_request, mock_load_config): call_command( "croud", "organizations", "create", "--name", "test-org", "--plan-type", "3" ) assert_rest( - mock_send, + mock_request, RequestMethod.POST, "/api/v2/organizations/", body={"name": "test-org", "plan_type": 3}, @@ -71,8 +70,8 @@ def test_organizations_create_plan_type(mock_send, mock_load_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_organizations_edit(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_organizations_edit(mock_request, mock_load_config): org_id = gen_uuid() call_command( "croud", @@ -86,7 +85,7 @@ def test_organizations_edit(mock_send, mock_load_config): org_id, ) assert_rest( - mock_send, + mock_request, RequestMethod.PUT, f"/api/v2/organizations/{org_id}/", body={"name": "new-org-name", "plan_type": 3}, @@ -94,14 +93,14 @@ def test_organizations_edit(mock_send, mock_load_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_organizations_edit_name(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_organizations_edit_name(mock_request, mock_load_config): org_id = gen_uuid() call_command( "croud", "organizations", "edit", "--name", "new-org-name", "--org-id", org_id ) assert_rest( - mock_send, + mock_request, RequestMethod.PUT, f"/api/v2/organizations/{org_id}/", body={"name": "new-org-name"}, @@ -109,14 +108,14 @@ def test_organizations_edit_name(mock_send, mock_load_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_organizations_edit_plan_type(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_organizations_edit_plan_type(mock_request, mock_load_config): org_id = gen_uuid() call_command( "croud", "organizations", "edit", "--plan-type", "3", "--org-id", org_id ) assert_rest( - mock_send, + mock_request, RequestMethod.PUT, f"/api/v2/organizations/{org_id}/", body={"plan_type": 3}, @@ -124,8 +123,8 @@ def test_organizations_edit_plan_type(mock_send, mock_load_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=(None, {})) -def test_organizations_edit_no_arguments(mock_send, mock_load_config, capsys): +@mock.patch.object(Client, "request", return_value=(None, {})) +def test_organizations_edit_no_arguments(mock_request, mock_load_config, capsys): org_id = gen_uuid() with pytest.raises(SystemExit): call_command("croud", "organizations", "edit", "--org-id", org_id) @@ -135,19 +134,19 @@ def test_organizations_edit_no_arguments(mock_send, mock_load_config, capsys): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_organizations_list(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_organizations_list(mock_request, mock_load_config): call_command("croud", "organizations", "list") - assert_rest(mock_send, RequestMethod.GET, "/api/v2/organizations/") + assert_rest(mock_request, RequestMethod.GET, "/api/v2/organizations/") @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=(None, {})) -def test_organizations_delete(mock_send, mock_load_config, capsys): +@mock.patch.object(Client, "request", return_value=(None, {})) +def test_organizations_delete(mock_request, mock_load_config, capsys): org_id = gen_uuid() with mock.patch("builtins.input", side_effect=["Y"]) as mock_input: call_command("croud", "organizations", "delete", "--org-id", org_id) - assert_rest(mock_send, RequestMethod.DELETE, f"/api/v2/organizations/{org_id}/") + assert_rest(mock_request, RequestMethod.DELETE, f"/api/v2/organizations/{org_id}/") mock_input.assert_called_once_with( "Are you sure you want to delete the organization? [yN] " ) @@ -158,14 +157,14 @@ def test_organizations_delete(mock_send, mock_load_config, capsys): @mock.patch("croud.config.Configuration.set_organization_id") @mock.patch("croud.config.load_config", return_value=FALLBACK_ORG_ID_CONFIG) -@mock.patch.object(Client, "send", return_value=(None, {"errors": "Message"})) +@mock.patch.object(Client, "request", return_value=(None, {"errors": "Message"})) def test_organizations_delete_failure_org_id_not_deleted_from_config( - mock_send, mock_load_config, mock_set_config, capsys + mock_request, mock_load_config, mock_set_config, capsys ): with mock.patch("builtins.input", side_effect=["Y"]): call_command("croud", "organizations", "delete") assert_rest( - mock_send, + mock_request, RequestMethod.DELETE, f"/api/v2/organizations/{mock_load_config.return_value['auth']['contexts']['local']['organization_id']}/", # noqa ) @@ -178,14 +177,14 @@ def test_organizations_delete_failure_org_id_not_deleted_from_config( @mock.patch("croud.config.Configuration.set_organization_id") @mock.patch("croud.config.load_config", return_value=FALLBACK_ORG_ID_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) +@mock.patch.object(Client, "request", return_value=({}, None)) def test_organizations_delete_org_id_from_local_config( - mock_send, mock_load_config, mock_set_config, capsys + mock_request, mock_load_config, mock_set_config, capsys ): with mock.patch("builtins.input", side_effect=["Y"]): call_command("croud", "organizations", "delete") assert_rest( - mock_send, + mock_request, RequestMethod.DELETE, f"/api/v2/organizations/{mock_load_config.return_value['auth']['contexts']['local']['organization_id']}/", # noqa ) @@ -196,12 +195,12 @@ def test_organizations_delete_org_id_from_local_config( @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=(None, {})) -def test_organizations_delete_flag(mock_send, mock_load_config, capsys): +@mock.patch.object(Client, "request", return_value=(None, {})) +def test_organizations_delete_flag(mock_request, mock_load_config, capsys): org_id = gen_uuid() with mock.patch("builtins.input") as mock_input: call_command("croud", "organizations", "delete", "--org-id", org_id, "-y") - assert_rest(mock_send, RequestMethod.DELETE, f"/api/v2/organizations/{org_id}/") + assert_rest(mock_request, RequestMethod.DELETE, f"/api/v2/organizations/{org_id}/") mock_input.assert_not_called() _, err_output = capsys.readouterr() @@ -210,12 +209,12 @@ def test_organizations_delete_flag(mock_send, mock_load_config, capsys): @pytest.mark.parametrize("input", ["", "N", "No", "cancel"]) @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=(None, {})) -def test_organizations_delete_aborted(mock_send, mock_load_config, capsys, input): +@mock.patch.object(Client, "request", return_value=(None, {})) +def test_organizations_delete_aborted(mock_request, mock_load_config, capsys, input): org_id = gen_uuid() with mock.patch("builtins.input", side_effect=[input]) as mock_input: call_command("croud", "organizations", "delete", "--org-id", org_id) - mock_send.assert_not_called() + mock_request.assert_not_called() mock_input.assert_called_once_with( "Are you sure you want to delete the organization? [yN] " ) @@ -225,12 +224,14 @@ def test_organizations_delete_aborted(mock_send, mock_load_config, capsys, input @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=(None, {})) -def test_organizations_delete_aborted_with_input(mock_send, mock_load_config, capsys): +@mock.patch.object(Client, "request", return_value=(None, {})) +def test_organizations_delete_aborted_with_input( + mock_request, mock_load_config, capsys +): org_id = gen_uuid() with mock.patch("builtins.input", side_effect=["N"]) as mock_input: call_command("croud", "organizations", "delete", "--org-id", org_id) - mock_send.assert_not_called() + mock_request.assert_not_called() mock_input.assert_called_once_with( "Are you sure you want to delete the organization? [yN] " ) @@ -240,13 +241,13 @@ def test_organizations_delete_aborted_with_input(mock_send, mock_load_config, ca @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_organizations_auditlogs_list(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_organizations_auditlogs_list(mock_request, mock_load_config): org_id = gen_uuid() call_command("croud", "organizations", "auditlogs", "list", "--org-id", org_id) assert_rest( - mock_send, + mock_request, RequestMethod.GET, f"/api/v2/organizations/{org_id}/auditlogs/", params={}, @@ -254,8 +255,8 @@ def test_organizations_auditlogs_list(mock_send, mock_load_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_organizations_auditlogs_list_filtered(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_organizations_auditlogs_list_filtered(mock_request, mock_load_config): org_id = gen_uuid() call_command( @@ -273,7 +274,7 @@ def test_organizations_auditlogs_list_filtered(mock_send, mock_load_config): "2019-11-12T12:34:56", ) assert_rest( - mock_send, + mock_request, RequestMethod.GET, f"/api/v2/organizations/{org_id}/auditlogs/", params={ @@ -289,9 +290,11 @@ def test_organizations_auditlogs_list_filtered(mock_send, mock_load_config): [(True, "User added to organization."), (False, "Role altered for user.")], ) @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({"added": True}, None)) -def test_organizations_users_add(mock_send, mock_load_config, added, message, capsys): - mock_send.return_value = ({"added": added}, None) +@mock.patch.object(Client, "request") +def test_organizations_users_add( + mock_request, mock_load_config, added, message, capsys +): + mock_request.return_value = ({"added": added}, None) org_id = gen_uuid() user = "test@crate.io" @@ -310,7 +313,7 @@ def test_organizations_users_add(mock_send, mock_load_config, added, message, ca role_fqn, ) assert_rest( - mock_send, + mock_request, RequestMethod.POST, f"/api/v2/organizations/{org_id}/users/", body={"user": user, "role_fqn": role_fqn}, @@ -322,17 +325,19 @@ def test_organizations_users_add(mock_send, mock_load_config, added, message, ca @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_organizations_users_list(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_organizations_users_list(mock_request, mock_load_config): org_id = gen_uuid() call_command("croud", "organizations", "users", "list", "--org-id", org_id) - assert_rest(mock_send, RequestMethod.GET, f"/api/v2/organizations/{org_id}/users/") + assert_rest( + mock_request, RequestMethod.GET, f"/api/v2/organizations/{org_id}/users/" + ) @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_organizations_users_remove(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_organizations_users_remove(mock_request, mock_load_config): org_id = gen_uuid() user = "test@crate.io" @@ -340,7 +345,9 @@ def test_organizations_users_remove(mock_send, mock_load_config): "croud", "organizations", "users", "remove", "--user", user, "--org-id", org_id ) assert_rest( - mock_send, RequestMethod.DELETE, f"/api/v2/organizations/{org_id}/users/{user}/" + mock_request, + RequestMethod.DELETE, + f"/api/v2/organizations/{org_id}/users/{user}/", ) diff --git a/tests/commands/test_products.py b/tests/commands/test_products.py index 42c13353..151b19a4 100644 --- a/tests/commands/test_products.py +++ b/tests/commands/test_products.py @@ -19,23 +19,22 @@ from unittest import mock +from croud.api import Client, RequestMethod from croud.config import Configuration -from croud.rest import Client -from croud.session import RequestMethod from tests.util import assert_rest, call_command @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_products_list(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_products_list(mock_request, mock_load_config): call_command("croud", "products", "list") - assert_rest(mock_send, RequestMethod.GET, "/api/v2/products/") + assert_rest(mock_request, RequestMethod.GET, "/api/v2/products/", params={}) @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_products_list_kind(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_products_list_kind(mock_request, mock_load_config): call_command("croud", "products", "list", "--kind", "cluster") assert_rest( - mock_send, RequestMethod.GET, "/api/v2/products/", params={"kind": "cluster"} + mock_request, RequestMethod.GET, "/api/v2/products/", params={"kind": "cluster"} ) diff --git a/tests/commands/test_projects.py b/tests/commands/test_projects.py index 245f7d27..ac17dfe6 100644 --- a/tests/commands/test_projects.py +++ b/tests/commands/test_projects.py @@ -21,18 +21,17 @@ import pytest +from croud.api import Client, RequestMethod from croud.config import Configuration from croud.projects.users.commands import ( role_fqn_transform as project_role_fqn_transform, ) -from croud.rest import Client -from croud.session import RequestMethod from tests.util import assert_rest, call_command, gen_uuid @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_projects_create(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_projects_create(mock_request, mock_load_config): call_command( "croud", "projects", @@ -43,7 +42,7 @@ def test_projects_create(mock_send, mock_load_config): "organization-id", ) assert_rest( - mock_send, + mock_request, RequestMethod.POST, "/api/v2/projects/", body={"name": "new-project", "organization_id": "organization-id"}, @@ -51,12 +50,12 @@ def test_projects_create(mock_send, mock_load_config): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=(None, {})) -def test_projects_delete(mock_send, mock_load_config, capsys): +@mock.patch.object(Client, "request", return_value=(None, {})) +def test_projects_delete(mock_request, mock_load_config, capsys): project_id = gen_uuid() with mock.patch("builtins.input", side_effect=["yes"]) as mock_input: call_command("croud", "projects", "delete", "--project-id", project_id) - assert_rest(mock_send, RequestMethod.DELETE, f"/api/v2/projects/{project_id}/") + assert_rest(mock_request, RequestMethod.DELETE, f"/api/v2/projects/{project_id}/") mock_input.assert_called_once_with( "Are you sure you want to delete the project? [yN] " ) @@ -67,12 +66,12 @@ def test_projects_delete(mock_send, mock_load_config, capsys): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=(None, {})) -def test_projects_delete_flag(mock_send, mock_load_config, capsys): +@mock.patch.object(Client, "request", return_value=(None, {})) +def test_projects_delete_flag(mock_request, mock_load_config, capsys): project_id = gen_uuid() with mock.patch("builtins.input", side_effect=["y"]) as mock_input: call_command("croud", "projects", "delete", "--project-id", project_id, "-y") - assert_rest(mock_send, RequestMethod.DELETE, f"/api/v2/projects/{project_id}/") + assert_rest(mock_request, RequestMethod.DELETE, f"/api/v2/projects/{project_id}/") mock_input.assert_not_called() _, err_output = capsys.readouterr() @@ -81,12 +80,12 @@ def test_projects_delete_flag(mock_send, mock_load_config, capsys): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=(None, {})) -def test_projects_delete_aborted(mock_send, mock_load_config, capsys): +@mock.patch.object(Client, "request", return_value=(None, {})) +def test_projects_delete_aborted(mock_request, mock_load_config, capsys): project_id = gen_uuid() with mock.patch("builtins.input", side_effect=["Nooooo"]) as mock_input: call_command("croud", "projects", "delete", "--project-id", project_id) - mock_send.assert_not_called() + mock_request.assert_not_called() mock_input.assert_called_once_with( "Are you sure you want to delete the project? [yN] " ) @@ -96,10 +95,10 @@ def test_projects_delete_aborted(mock_send, mock_load_config, capsys): @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_projects_list(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_projects_list(mock_request, mock_load_config): call_command("croud", "projects", "list") - assert_rest(mock_send, RequestMethod.GET, "/api/v2/projects/") + assert_rest(mock_request, RequestMethod.GET, "/api/v2/projects/") @pytest.mark.parametrize( @@ -107,9 +106,9 @@ def test_projects_list(mock_send, mock_load_config): [(True, "User added to project."), (False, "Role altered for user.")], ) @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send") -def test_projects_users_add(mock_send, mock_load_config, added, message, capsys): - mock_send.return_value = ({"added": added}, None) +@mock.patch.object(Client, "request") +def test_projects_users_add(mock_request, mock_load_config, added, message, capsys): + mock_request.return_value = ({"added": added}, None) project_id = gen_uuid() @@ -130,7 +129,7 @@ def test_projects_users_add(mock_send, mock_load_config, added, message, capsys) role_fqn, ) assert_rest( - mock_send, + mock_request, RequestMethod.POST, f"/api/v2/projects/{project_id}/users/", body={"user": user, "role_fqn": role_fqn}, @@ -142,17 +141,19 @@ def test_projects_users_add(mock_send, mock_load_config, added, message, capsys) @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_projects_users_list(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_projects_users_list(mock_request, mock_load_config): project_id = gen_uuid() call_command("croud", "projects", "users", "list", "--project-id", project_id) - assert_rest(mock_send, RequestMethod.GET, f"/api/v2/projects/{project_id}/users/") + assert_rest( + mock_request, RequestMethod.GET, f"/api/v2/projects/{project_id}/users/" + ) @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_projects_users_remove(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_projects_users_remove(mock_request, mock_load_config): project_id = gen_uuid() # uid or email would be possible for the backend @@ -169,7 +170,9 @@ def test_projects_users_remove(mock_send, mock_load_config): user, ) assert_rest( - mock_send, RequestMethod.DELETE, f"/api/v2/projects/{project_id}/users/{user}/" + mock_request, + RequestMethod.DELETE, + f"/api/v2/projects/{project_id}/users/{user}/", ) diff --git a/tests/commands/test_users.py b/tests/commands/test_users.py index cee4f970..2d3dae32 100644 --- a/tests/commands/test_users.py +++ b/tests/commands/test_users.py @@ -19,61 +19,60 @@ from unittest import mock +from croud.api import Client, RequestMethod from croud.config import Configuration -from croud.rest import Client -from croud.session import RequestMethod from croud.users.commands import transform_roles_list from tests.util import assert_rest, call_command @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_users_list(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_users_list(mock_request, mock_load_config): call_command("croud", "users", "list") - assert_rest(mock_send, RequestMethod.GET, "/api/v2/users/", params=None) + assert_rest(mock_request, RequestMethod.GET, "/api/v2/users/", params=None) @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_users_list_no_org(mock_send, mock_load_config, capsys): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_users_list_no_org(mock_request, mock_load_config, capsys): call_command("croud", "users", "list", "--no-org") assert_rest( - mock_send, RequestMethod.GET, "/api/v2/users/", params={"no-roles": "1"} + mock_request, RequestMethod.GET, "/api/v2/users/", params={"no-roles": "1"} ) _, err = capsys.readouterr() assert "The --no-org argument is deprecated. Please use --no-roles instead." in err @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_users_list_no_roles(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_users_list_no_roles(mock_request, mock_load_config): call_command("croud", "users", "list", "--no-roles") assert_rest( - mock_send, RequestMethod.GET, "/api/v2/users/", params={"no-roles": "1"} + mock_request, RequestMethod.GET, "/api/v2/users/", params={"no-roles": "1"} ) @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_users_list_no_org_no_roles(mock_send, mock_load_config, capsys): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_users_list_no_org_no_roles(mock_request, mock_load_config, capsys): call_command("croud", "users", "list", "--no-roles", "--no-org") assert_rest( - mock_send, RequestMethod.GET, "/api/v2/users/", params={"no-roles": "1"} + mock_request, RequestMethod.GET, "/api/v2/users/", params={"no-roles": "1"} ) _, err = capsys.readouterr() assert "The --no-org argument is deprecated. Please use --no-roles instead." in err @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_users_roles_list(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_users_roles_list(mock_request, mock_load_config): call_command("croud", "users", "roles", "list") - assert_rest(mock_send, RequestMethod.GET, "/api/v2/roles/") + assert_rest(mock_request, RequestMethod.GET, "/api/v2/roles/") @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) -@mock.patch.object(Client, "send", return_value=({}, None)) -def test_transform_roles_list(mock_send, mock_load_config): +@mock.patch.object(Client, "request", return_value=({}, None)) +def test_transform_roles_list(mock_request, mock_load_config): user = { "organization_roles": [ {"organization_id": "org-1", "role_fqn": "org_admin"}, diff --git a/tests/conftest.py b/tests/conftest.py index cdcc3382..baf70e0f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,50 @@ -import aiohttp +# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor +# license agreements. See the NOTICE file distributed with this work for +# additional information regarding copyright ownership. Crate licenses +# this file to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may +# obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# However, if you have executed another commercial license agreement +# with Crate these terms will supersede the license and you may use the +# software solely pursuant to the terms of the relevant commercial agreement. + +from unittest import mock + import pytest +import urllib3 + +from croud.api import Client -from tests.util.fake_server import FakeCrateDBCloud, FakeResolver +from .util.fake_cloud import FakeCrateDBCloud + + +@pytest.fixture(scope="session") +def fake_cratedb_cloud(): + cloud = FakeCrateDBCloud() + cloud.start() + yield cloud + cloud._server.shutdown() @pytest.fixture -async def fake_cloud_connector(event_loop): - async with FakeCrateDBCloud(loop=event_loop) as dns_info: - resolver = FakeResolver(dns_info, loop=event_loop) - yield aiohttp.TCPConnector(loop=event_loop, resolver=resolver, ssl=True) +def client(fake_cratedb_cloud): + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + + with mock.patch( + "croud.api.cloud_url", + return_value=f"https://127.0.0.1:{fake_cratedb_cloud.port}", + ): + with mock.patch("croud.api.get_verify_ssl", return_value=False): + with mock.patch( + "croud.api.Configuration.get_token", return_value="some-token" + ): + yield Client(env="local", region="bregenz.a1") diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 00000000..3028453a --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,131 @@ +# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor +# license agreements. See the NOTICE file distributed with this work for +# additional information regarding copyright ownership. Crate licenses +# this file to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may +# obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# However, if you have executed another commercial license agreement +# with Crate these terms will supersede the license and you may use the +# software solely pursuant to the terms of the relevant commercial agreement. + +import argparse +import re +from unittest import mock + +import pytest + +from croud.api import Client, cloud_url + + +def test_send_success_sets_data_with_key(client: Client): + resp_data, errors = client.get("/data/data-key") + assert resp_data == {"data": {"key": "value"}} + assert errors is None + + +def test_send_success_sets_data_without_key(client: Client): + resp_data, errors = client.get("/data/no-key") + assert resp_data == {"key": "value"} + assert errors is None + + +def test_send_error_sets_error(client: Client): + resp_data, errors = client.get("/errors/400") + assert errors == {"message": "Bad request.", "errors": {"key": "Error on 'key'"}} + + +def test_send_text_response(client: Client): + resp_data, errors = client.get("/text-response") + assert resp_data is None + assert errors == {"message": "Invalid response type.", "success": False} + + +def test_send_empty_response(client: Client): + resp_data, errors = client.get("/empty-response") + assert resp_data is None + assert errors is None + + +def test_send_redirect_response(client: Client, capsys): + with pytest.raises(SystemExit): + client.get("/redirect") + _, err = capsys.readouterr() + assert "Unauthorized. Use `croud login` to login to CrateDB Cloud." in err + + +def test_send_new_token_response(client: Client): + with mock.patch("croud.api.Configuration.set_token") as set_token_mock: + client.get("/new-token") + assert ("session", "new-token") in client.session.cookies.items() + assert client._token == "new-token" + set_token_mock.assert_called_once_with("new-token", "local") + + +# If ``sudo=True``, the X-Auth-Sudo header should be set with any value. This +# test checks that the header is set. +def test_send_sudo_header_set(client: Client): + client = Client(env="dev", region="bregenz.a1", sudo=True) + resp_data, errors = client.get("/test-x-sudo") + assert resp_data == {} + assert errors is None + + +# If ``sudo=False``, no X-Auth-Sudo header should be set. This test checks that +# the header is not set. +def test_send_sudo_header_not_set(client: Client): + client = Client(env="dev", region="bregenz.a1", sudo=False) + resp_data, errors = client.get("/test-x-sudo") + assert resp_data == {"message": "Header not set, as expected."} + assert errors is None + + +# This test makes sure that the client is instantiated with the correct arguments, +# and does not fail if the arguments are in a random positional order. +def test_client_initialization(client: Client): + args = argparse.Namespace( + output_fmt="json", sudo=True, region="bregenz.a1", env="dev" + ) + client = Client.from_args(args) + assert client.env == "dev" + assert client.region == "bregenz.a1" + assert client.sudo is True + + +@mock.patch("croud.api.Configuration.get_token", return_value="some-token") +@mock.patch("croud.api.get_verify_ssl", return_value=False) +@mock.patch("croud.api.cloud_url", return_value="https://invalid.cratedb.local") +def test_error_message_on_connection_error( + mock_cloud_url, mock_get_verify_ssl, mock_get_token +): + expected_message_re = re.compile( + r"^Failed to perform command on https://invalid\.cratedb\.local/me\. " + r"Original error was: 'HTTPSConnectionPool\(.*\)' " + r"Does the environment exist in the region you specified\?$" + ) + client = Client(env="dev", region="bregenz.a1") + resp_data, errors = client.get("/me") + assert resp_data is None + assert expected_message_re.match(errors["message"]) is not None + assert errors["success"] is False + + +@pytest.mark.parametrize( + "env,region,expected", + [ + ("dev", "bregenz.a1", "https://bregenz.a1.cratedb-dev.cloud"), + ("dev", "eastus.azure", "https://eastus.azure.cratedb-dev.cloud"), + ("prod", "eastus.azure", "https://eastus.azure.cratedb.cloud"), + ("prod", "westeurope.azure", "https://westeurope.azure.cratedb.cloud"), + ], +) +def test_cloud_url(env, region, expected): + assert cloud_url(env, region) == expected diff --git a/tests/test_rest.py b/tests/test_rest.py deleted file mode 100644 index d8e47d1d..00000000 --- a/tests/test_rest.py +++ /dev/null @@ -1,135 +0,0 @@ -# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor -# license agreements. See the NOTICE file distributed with this work for -# additional information regarding copyright ownership. Crate licenses -# this file to you under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. You may -# obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -# -# However, if you have executed another commercial license agreement -# with Crate these terms will supersede the license and you may use the -# software solely pursuant to the terms of the relevant commercial agreement. - -import argparse -import asyncio -from unittest import mock - -import pytest - -from croud.config import Configuration -from croud.rest import Client -from croud.session import RequestMethod - - -@pytest.fixture -def client(fake_cloud_connector, event_loop): - with mock.patch.object(Configuration, "get_token", return_value="eyJraWQiOiIx"): - yield Client( - env="dev", region="bregenz.a1", conn=fake_cloud_connector, loop=event_loop - ) - - -@mock.patch("croud.session.cloud_url", return_value="https://cratedb.local") -def test_send_success_sets_data_with_key(mock_cloud_url, client): - resp_data, errors = client.send(RequestMethod.GET, "/data/data-key") - assert resp_data["data"] == {"key": "value"} - assert errors is None - - -@mock.patch("croud.session.cloud_url", return_value="https://cratedb.local") -def test_send_success_sets_data_without_key(mock_cloud_url, client): - resp_data, errors = client.send(RequestMethod.GET, "/data/no-key") - assert resp_data == {"key": "value"} - assert errors is None - - -@mock.patch("croud.session.cloud_url", return_value="https://cratedb.local") -def test_send_error_sets_error(mock_cloud_url, client): - resp_data, errors = client.send(RequestMethod.GET, "/errors/400") - assert errors == {"message": "Bad request.", "errors": {"key": "Error on 'key'"}} - - -@mock.patch("croud.session.cloud_url", return_value="https://cratedb.local") -def test_send_text_response(mock_cloud_url, client): - resp_data, errors = client.send(RequestMethod.GET, "/text-response") - assert resp_data is None - assert errors == {"message": "Invalid response type.", "success": False} - - -@mock.patch("croud.session.cloud_url", return_value="https://cratedb.local") -def test_send_empty_response(mock_cloud_url, client): - resp_data, errors = client.send(RequestMethod.GET, "/empty-response") - assert resp_data is None - assert errors is None - - -# If ``sudo=True``, the X-Auth-Sudo header should be set with any value. This -# test checks that the header is set. -@mock.patch.object(Configuration, "get_token", return_value="eyJraWQiOiIx") -@mock.patch("croud.session.cloud_url", return_value="https://cratedb.local") -def test_send_sudo_header_set( - mock_cloud_url, mock_token, fake_cloud_connector, event_loop -): - client = Client( - env="dev", - region="bregenz.a1", - sudo=True, - conn=fake_cloud_connector, - loop=event_loop, - ) - resp_data, errors = client.send(RequestMethod.GET, f"/test-x-sudo") - assert resp_data == {} - assert errors is None - - -# If ``sudo=False``, no X-Auth-Sudo header should be set. This test checks that -# the header is not set. -@mock.patch.object(Configuration, "get_token", return_value="eyJraWQiOiIx") -@mock.patch("croud.session.cloud_url", return_value="https://cratedb.local") -def test_send_sudo_header_not_set( - mock_cloud_url, mock_token, fake_cloud_connector, event_loop -): - client = Client( - env="dev", - region="bregenz.a1", - sudo=False, - conn=fake_cloud_connector, - loop=event_loop, - ) - resp_data, errors = client.send(RequestMethod.GET, f"/test-x-sudo") - assert resp_data == {"message": "Header not set, as expected."} - assert errors is None - - -# This test makes sure that the client is instantiated with the correct arguments, -# and does not fail if the arguments are in a random positional order. -@mock.patch.object(Configuration, "get_token", return_value="eyJraWQiOiIx") -@mock.patch("croud.session.cloud_url", return_value="https://cratedb.local") -def test_client_initialization(mock_cloud_url, mock_token, event_loop): - args = argparse.Namespace( - output_fmt="json", sudo=True, region="bregenz.a1", env="dev" - ) - client = Client.from_args(args) - assert client._env == "dev" - assert client._region == "bregenz.a1" - assert client._sudo is True - assert isinstance(client.loop, asyncio.AbstractEventLoop) - - -@mock.patch("croud.session.cloud_url", return_value="https://invalid.cratedb.local") -def test_error_message_on_connection_error(mock_cloud_url, client): - expected_message = ( - "Failed to perform command on invalid.cratedb.local. " - "Original error was: 'Cannot connect to host invalid.cratedb.local:443 ssl:None [Name or service not known]' " # noqa - "Does the environment exist in the region you specified?" - ) - resp_data, errors = client.send(RequestMethod.GET, "/me") - assert resp_data is None - assert errors == {"message": expected_message, "success": False} diff --git a/tests/test_server.py b/tests/test_server.py index d1a00d5c..e53ac165 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -18,46 +18,38 @@ # software solely pursuant to the terms of the relevant commercial agreement. -from unittest import mock - import pytest -from aiohttp.test_utils import TestClient, TestServer +import requests -from croud.config import Configuration from croud.server import Server -@pytest.mark.asyncio -@mock.patch.object(Configuration, "set_token") -async def test_token_handler_and_login(mock_write_token, event_loop): - server = Server(event_loop) - app = server.create_web_app(on_token=lambda x: None) - client = TestClient(TestServer(app), loop=event_loop) - await client.start_server() - - with mock.patch.object(event_loop, "stop"): # We exit the server after the request - resp = await client.get("?token=123") - assert resp.status == 200 - text = await resp.text() - - assert "You have successfully logged into CrateDB Cloud!" in text - - await client.close() - - -@pytest.mark.asyncio -@mock.patch.object(Configuration, "set_token") -async def test_token_missing_for_login(mock_write_token, event_loop): - server = Server(event_loop) - app = server.create_web_app(on_token=lambda x: None) - client = TestClient(TestServer(app), loop=event_loop) - await client.start_server() - - with mock.patch.object(event_loop, "stop"): # We exit the server after the request - resp = await client.get("/") - assert resp.status == 500 - text = await resp.text() - - assert "No query param 'token' in request" in text - - await client.close() +@pytest.mark.parametrize( + "qs,status_code,message,token_value", + [ + ("?token=foo", 200, "You have successfully logged into CrateDB Cloud!", "foo"), + ("", 400, "Authentication token missing in URL.", None), + ( + "?token=foo&token=bar", + 400, + "ore than one authentication token present in URL.", + None, + ), + ], +) +def test_token_handler_and_login(qs, status_code, message, token_value): + token_store = {} + + def on_token(token): + token_store["token"] = token + + server = Server(on_token, random_port=True).start() + + response = requests.get(f"http://localhost:{server.port}/{qs}") + + if token_value is not None: + assert token_store["token"] == token_value + else: + assert "token" not in token_store + assert response.status_code == status_code + assert message in response.text diff --git a/tests/test_session.py b/tests/test_session.py deleted file mode 100644 index 4452c3a0..00000000 --- a/tests/test_session.py +++ /dev/null @@ -1,72 +0,0 @@ -# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor -# license agreements. See the NOTICE file distributed with this work for -# additional information regarding copyright ownership. Crate licenses -# this file to you under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. You may -# obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -# -# However, if you have executed another commercial license agreement -# with Crate these terms will supersede the license and you may use the -# software solely pursuant to the terms of the relevant commercial agreement. - -from http.cookies import Morsel -from unittest import mock - -import pytest - -from croud.config import Configuration -from croud.session import HttpSession, RequestMethod, cloud_url - - -@pytest.mark.asyncio -async def test_request_unauthorized(fake_cloud_connector): - with pytest.raises(SystemExit) as cm: - async with HttpSession( - "dev", "", url="https://cratedb.local", conn=fake_cloud_connector - ) as session: - with mock.patch("croud.session.print_error") as mock_print_error: - await session.fetch(RequestMethod.GET, "/data/data-key") - - assert cm.value.code == 1 - mock_print_error.assert_called_once_with( - "Unauthorized. Use `croud login` to login to CrateDB Cloud." - ) - - -@mock.patch.object(Configuration, "get_env", return_value="dev") -def test_correct_url(mock_env): - region = "bregenz.a1" - - url = cloud_url("prod", region) - assert url == f"https://{region}.cratedb.cloud" - - url = cloud_url("dev", region) - assert url == f"https://{region}.cratedb-dev.cloud" - - url = cloud_url("local", region) - assert url == "http://localhost:8000" - - region = "westeurope.azure" - url = cloud_url("prod", region) - assert url == f"https://{region}.cratedb.cloud" - - -@pytest.mark.asyncio -async def test_on_new_token(): - update_config = mock.Mock() - async with HttpSession( - "dev", "old_token", "eastus.azure", on_new_token=update_config - ) as session: - session_cookie = Morsel() - session_cookie.set("session", "new_token", None) - session.client.cookie_jar.update_cookies({"session": session_cookie}) - - update_config.assert_called_once_with("new_token") diff --git a/tests/util/__init__.py b/tests/util/__init__.py index 636b029f..06027df7 100644 --- a/tests/util/__init__.py +++ b/tests/util/__init__.py @@ -26,6 +26,7 @@ from typing import Dict from croud.__main__ import get_parser +from croud.api import RequestMethod normalize = partial(re.sub, r"\s+", "") @@ -70,15 +71,17 @@ def call_command(*argv): parser.print_help() -def assert_rest(mock_send, method, endpoint, *, body=UNDEFINED, params=UNDEFINED): +def assert_rest( + mock_request, method: RequestMethod, endpoint, *, params=None, body=None +): args = [method, endpoint] kwargs = {} - if body is not UNDEFINED: + if method != RequestMethod.GET and body is not UNDEFINED: kwargs["body"] = body if params is not UNDEFINED: kwargs["params"] = params - mock_send.assert_called_once_with(*args, **kwargs) + mock_request.assert_called_once_with(*args, **kwargs) def gen_uuid(): diff --git a/tests/util/fake_cloud.py b/tests/util/fake_cloud.py new file mode 100644 index 00000000..4d755889 --- /dev/null +++ b/tests/util/fake_cloud.py @@ -0,0 +1,191 @@ +# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor +# license agreements. See the NOTICE file distributed with this work for +# additional information regarding copyright ownership. Crate licenses +# this file to you under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. You may +# obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# +# However, if you have executed another commercial license agreement +# with Crate these terms will supersede the license and you may use the +# software solely pursuant to the terms of the relevant commercial agreement. + +import json +import pathlib +import ssl +from http.server import BaseHTTPRequestHandler, HTTPServer +from threading import Thread +from typing import Callable, Dict +from urllib import parse + + +class Response: + __slots__ = ("bytes", "status", "headers") + + def __init__(self, *, text=None, json_data=None, status=200, headers=None): + self.status = status + self.headers = headers or {} + if json_data is not None: + self.headers.setdefault("Content-Type", "application/json") + text = json.dumps(json_data) + else: + text = text or "" + self.headers.setdefault("Content-Type", "text/html") + + self.bytes = text.encode() + self.headers["Content-Length"] = len(self.bytes) + + +class FakeCrateDBCloudServer(HTTPServer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Load certificates and sign key used to simulate ssl/tls + here = pathlib.Path(__file__) + self.ssl_cert = here.parent / "server.crt" + ssl_key = here.parent / "server.key" + self.socket = ssl.wrap_socket( + self.socket, + keyfile=str(ssl_key), + certfile=str(self.ssl_cert), + server_side=True, + ) + + +class FakeCrateDBCloudRequestHandler(BaseHTTPRequestHandler): + def __init__(self, *args, **kwargs): + self.routes: Dict[str, Callable[[], Response]] = { + "/data/data-key": self.data_data_key, + "/data/no-key": self.data_no_key, + "/errors/400": self.error_400, + "/text-response": self.text_response, + "/empty-response": self.empty_response, + "/redirect": self.redirect, + "/new-token": self.new_token, + "/test-x-sudo": self.assert_x_sudo, + } + super().__init__(*args, **kwargs) + + def do_GET(self): + parsed = parse.urlparse(self.path) + self.request_path = parsed.path + self.query = parse.parse_qs(parsed.query) + + body_length = int(self.headers.get("Content-Length", 0)) + if body_length > 0: + self.body = self.rfile.read(body_length) + else: + self.body = None + + self.cookies = dict( + cookie.split("=", 1) + for cookie in self.headers.get_all("cookie", []) + if cookie + ) + + handler = self.routes.get(self.request_path, self.default_response) + + response = handler() + self.send_response(response.status) + for header, value in response.headers.items(): + self.send_header(header, value) + self.end_headers() + self.wfile.write(response.bytes) + + do_DELETE = do_GET + do_HEAD = do_GET + do_PATCH = do_GET + do_POST = do_GET + do_PUT = do_GET + + def default_response(self) -> Response: + return Response( + json_data={ + "body": self.body, + "headers": dict(self.headers), # type: ignore + "method": self.command, + "path": self.request_path, + "query": self.query, + }, + status=404, + ) + + def data_data_key(self) -> Response: + if self.is_authorized: + return Response(json_data={"data": {"key": "value"}}, status=200) + return Response(status=302, headers={"Location": "/"}) + + def data_no_key(self) -> Response: + if self.is_authorized: + return Response(json_data={"key": "value"}) + return Response(status=302, headers={"Location": "/"}) + + def error_400(self) -> Response: + if self.is_authorized: + return Response( + json_data={ + "message": "Bad request.", + "errors": {"key": "Error on 'key'"}, + }, + status=400, + ) + + return Response(status=302, headers={"Location": "/"}) + + def text_response(self) -> Response: + if self.is_authorized: + return Response(text="Non JSON response.", status=500) + return Response(status=302, headers={"Location": "/"}) + + def empty_response(self) -> Response: + if self.is_authorized: + return Response(status=204) + return Response(status=302, headers={"Location": "/"}) + + def redirect(self) -> Response: + return Response(status=301, headers={"Location": "/"}) + + def new_token(self) -> Response: + return Response( + status=204, headers={"Set-Cookie": "session=new-token; Domain=127.0.0.1"} + ) + + def assert_x_sudo(self) -> Response: + if self.headers.get("X-Auth-Sudo") is not None: + return Response(json_data={}) + else: + return Response(json_data={"message": "Header not set, as expected."}) + + @property + def is_authorized(self) -> bool: + if "session" in self.cookies: + if self.cookies["session"]: + return True + return False + + def log_message(self, *args, **kwargs): + # Don't log anything during tests. + pass + + +class FakeCrateDBCloud: + def __init__(self): + self._server = FakeCrateDBCloudServer( + ("127.0.0.1", 0), FakeCrateDBCloudRequestHandler + ) + self._thread = Thread(target=self._server.serve_forever, daemon=True) + + def start(self) -> "FakeCrateDBCloud": + self._thread.start() + return self + + @property + def port(self): + return self._server.socket.getsockname()[1] diff --git a/tests/util/fake_server.py b/tests/util/fake_server.py deleted file mode 100644 index 5f385499..00000000 --- a/tests/util/fake_server.py +++ /dev/null @@ -1,138 +0,0 @@ -# Licensed to CRATE Technology GmbH ("Crate") under one or more contributor -# license agreements. See the NOTICE file distributed with this work for -# additional information regarding copyright ownership. Crate licenses -# this file to you under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. You may -# obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT -# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the -# License for the specific language governing permissions and limitations -# under the License. -# -# However, if you have executed another commercial license agreement -# with Crate these terms will supersede the license and you may use the -# software solely pursuant to the terms of the relevant commercial agreement. - -import asyncio -import pathlib -import socket -import ssl -from typing import Any, Dict, Iterable - -from aiohttp import web -from aiohttp.resolver import DefaultResolver -from aiohttp.test_utils import unused_port - - -class FakeResolver: - _LOCAL_HOST = {0: "127.0.0.1", socket.AF_INET: "127.0.0.1", socket.AF_INET6: "::1"} - - def __init__(self, fakes, loop: asyncio.AbstractEventLoop): - """fakes -- dns -> port dict""" - self._fakes = fakes - self._resolver = DefaultResolver(loop=loop) - - async def resolve( - self, host: str, port: int = 0, family: socket.AddressFamily = socket.AF_INET - ) -> Iterable[Dict[str, Any]]: - fake_port = self._fakes.get(host) - if fake_port is not None: - return [ - { - "hostname": host, - "host": self._LOCAL_HOST[family], - "port": fake_port, - "family": family, - "proto": 0, - "flags": socket.AI_NUMERICHOST, - } - ] - else: - return await self._resolver.resolve(host, port, family) - - -class FakeCrateDBCloud: - def __init__(self, loop: asyncio.AbstractEventLoop): - self.loop = loop - self.app = web.Application() - # thi will allow us to register multiple endpoints/handlers to test - self.app.router.add_routes( - [ - web.get("/data/data-key", self.data_data_key), - web.get("/data/no-key", self.data_no_key), - web.get("/errors/400", self.error_400), - web.get("/text-response", self.text_response), - web.get("/empty-response", self.empty_response), - web.get("/test-x-sudo", self.assert_x_sudo), - ] - ) - - here = pathlib.Path(__file__) - # Load certificates and sign key used to simulate ssl/tls - ssl_cert = here.parent / "server.crt" - ssl_key = here.parent / "server.key" - self.ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) - self.ssl_context.load_cert_chain(str(ssl_cert), str(ssl_key)) - - async def __aenter__(self): - return await self.start() - - async def __aexit__(self, exc_type, exc, tb): - await self.stop() - - async def start(self) -> Dict[str, int]: - port = unused_port() - self.runner = web.AppRunner(self.app) - await self.runner.setup() - site = web.TCPSite(self.runner, "localhost", port, ssl_context=self.ssl_context) - await site.start() - return {"cratedb.local": port} - - async def stop(self) -> None: - await self.runner.cleanup() - - async def data_data_key(self, request: web.Request) -> web.Response: - if self._is_authorized(request): - return web.json_response({"data": {"key": "value"}}) - return web.Response(status=302) - - async def data_no_key(self, request: web.Request) -> web.Response: - if self._is_authorized(request): - return web.json_response({"key": "value"}) - return web.Response(status=302) - - async def error_400(self, request: web.Request) -> web.Response: - if self._is_authorized(request): - return web.json_response( - {"message": "Bad request.", "errors": {"key": "Error on 'key'"}}, - status=400, - ) - return web.Response(status=302) - - async def text_response(self, request: web.Request) -> web.Response: - if self._is_authorized(request): - return web.Response(body="Non JSON response.", status=500) - return web.Response(status=302) - - async def empty_response(self, request: web.Request) -> web.Response: - if self._is_authorized(request): - return web.Response(body="", status=204) - return web.Response(status=302) - - def _is_authorized(self, request: web.Request) -> bool: - if "session" in request.cookies: - if request.cookies["session"]: - return True - return False - - async def assert_x_sudo(self, request: web.Request): - if request.headers.get("X-Auth-Sudo") is not None: - return web.json_response({}, status=200) - else: - return web.json_response( - {"message": "Header not set, as expected."}, status=200 - ) From 325fc1bb293b9f2efe259145a02555f441990123 Mon Sep 17 00:00:00 2001 From: Markus Holtermann Date: Fri, 20 Dec 2019 09:49:39 +0100 Subject: [PATCH 2/8] fixup! Remove asyncio --- croud/api.py | 4 ++-- croud/login.py | 4 ++-- croud/logout.py | 4 ++-- tests/conftest.py | 2 +- tests/test_api.py | 12 +++++++----- 5 files changed, 14 insertions(+), 12 deletions(-) diff --git a/croud/api.py b/croud/api.py index fe580749..d854cedb 100644 --- a/croud/api.py +++ b/croud/api.py @@ -48,7 +48,7 @@ def __init__(self, *, env: str, region: str, sudo: bool = False): self.region = region or Configuration.get_setting("region") self.sudo = sudo - self.base_url = URL(cloud_url(self.env, self.region)) + self.base_url = URL(construct_api_base_url(self.env, self.region)) self._token = Configuration.get_token(self.env) self.session = requests.Session() @@ -140,7 +140,7 @@ def decode_response(self, resp: requests.Response) -> ResponsePair: return body, None -def cloud_url(env: str, region: str = "bregenz.a1") -> str: +def construct_api_base_url(env: str, region: str = "bregenz.a1") -> str: if env == "local": return CLOUD_LOCAL_URL diff --git a/croud/login.py b/croud/login.py index b5d51f8e..923f7b3f 100644 --- a/croud/login.py +++ b/croud/login.py @@ -21,7 +21,7 @@ from functools import partial from typing import Optional -from croud.api import Client, cloud_url +from croud.api import Client, construct_api_base_url from croud.config import Configuration from croud.printer import print_error, print_info, print_warning from croud.server import Server @@ -72,4 +72,4 @@ def login(args: Namespace) -> None: def _login_url(env: str) -> str: - return cloud_url(env.lower()) + LOGIN_PATH + return construct_api_base_url(env.lower()) + LOGIN_PATH diff --git a/croud/logout.py b/croud/logout.py index ea78c134..31abebaf 100644 --- a/croud/logout.py +++ b/croud/logout.py @@ -19,7 +19,7 @@ from argparse import Namespace -from croud.api import Client, cloud_url +from croud.api import Client, construct_api_base_url from croud.config import Configuration from croud.printer import print_info @@ -38,4 +38,4 @@ def logout(args: Namespace) -> None: def _logout_url(env: str) -> str: - return cloud_url(env) + LOGOUT_PATH + return construct_api_base_url(env) + LOGOUT_PATH diff --git a/tests/conftest.py b/tests/conftest.py index baf70e0f..2e957dd3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -40,7 +40,7 @@ def client(fake_cratedb_cloud): urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) with mock.patch( - "croud.api.cloud_url", + "croud.api.construct_api_base_url", return_value=f"https://127.0.0.1:{fake_cratedb_cloud.port}", ): with mock.patch("croud.api.get_verify_ssl", return_value=False): diff --git a/tests/test_api.py b/tests/test_api.py index 3028453a..f9e64ca4 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -23,7 +23,7 @@ import pytest -from croud.api import Client, cloud_url +from croud.api import Client, construct_api_base_url def test_send_success_sets_data_with_key(client: Client): @@ -102,9 +102,11 @@ def test_client_initialization(client: Client): @mock.patch("croud.api.Configuration.get_token", return_value="some-token") @mock.patch("croud.api.get_verify_ssl", return_value=False) -@mock.patch("croud.api.cloud_url", return_value="https://invalid.cratedb.local") +@mock.patch( + "croud.api.construct_api_base_url", return_value="https://invalid.cratedb.local" +) def test_error_message_on_connection_error( - mock_cloud_url, mock_get_verify_ssl, mock_get_token + mock_construct_api_base_url, mock_get_verify_ssl, mock_get_token ): expected_message_re = re.compile( r"^Failed to perform command on https://invalid\.cratedb\.local/me\. " @@ -127,5 +129,5 @@ def test_error_message_on_connection_error( ("prod", "westeurope.azure", "https://westeurope.azure.cratedb.cloud"), ], ) -def test_cloud_url(env, region, expected): - assert cloud_url(env, region) == expected +def test_construct_api_base_url(env, region, expected): + assert construct_api_base_url(env, region) == expected From d05d85fad83b51cdf74ee5bb21ae7f4d842229d2 Mon Sep 17 00:00:00 2001 From: Markus Holtermann Date: Fri, 20 Dec 2019 09:59:05 +0100 Subject: [PATCH 3/8] fixup! fixup! Remove asyncio --- croud/api.py | 14 ++++++++------ tests/conftest.py | 7 ++----- tests/test_api.py | 11 ++++------- 3 files changed, 14 insertions(+), 18 deletions(-) diff --git a/croud/api.py b/croud/api.py index d854cedb..b92512cc 100644 --- a/croud/api.py +++ b/croud/api.py @@ -43,7 +43,13 @@ class RequestMethod(enum.Enum): class Client: - def __init__(self, *, env: str, region: str, sudo: bool = False): + def __init__( + self, *, env: str, region: str, sudo: bool = False, _verify_ssl: bool = True + ): + """ + :param bool _verify_ssl: A private variable that must only be used during tests! + """ + self.env = env or Configuration.get_env() self.region = region or Configuration.get_setting("region") self.sudo = sudo @@ -52,7 +58,7 @@ def __init__(self, *, env: str, region: str, sudo: bool = False): self._token = Configuration.get_token(self.env) self.session = requests.Session() - if get_verify_ssl() is False: + if _verify_ssl is False: self.session.verify = False self.session.cookies["session"] = self._token if self.sudo: @@ -146,7 +152,3 @@ def construct_api_base_url(env: str, region: str = "bregenz.a1") -> str: host = CLOUD_DEV_DOMAIN if env == "dev" else CLOUD_PROD_DOMAIN return f"https://{region}.{host}" - - -def get_verify_ssl() -> bool: - return True diff --git a/tests/conftest.py b/tests/conftest.py index 2e957dd3..9543483c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -43,8 +43,5 @@ def client(fake_cratedb_cloud): "croud.api.construct_api_base_url", return_value=f"https://127.0.0.1:{fake_cratedb_cloud.port}", ): - with mock.patch("croud.api.get_verify_ssl", return_value=False): - with mock.patch( - "croud.api.Configuration.get_token", return_value="some-token" - ): - yield Client(env="local", region="bregenz.a1") + with mock.patch("croud.api.Configuration.get_token", return_value="some-token"): + yield Client(env="local", region="bregenz.a1", _verify_ssl=False) diff --git a/tests/test_api.py b/tests/test_api.py index f9e64ca4..20743e76 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -73,7 +73,7 @@ def test_send_new_token_response(client: Client): # If ``sudo=True``, the X-Auth-Sudo header should be set with any value. This # test checks that the header is set. def test_send_sudo_header_set(client: Client): - client = Client(env="dev", region="bregenz.a1", sudo=True) + client = Client(env="dev", region="bregenz.a1", sudo=True, _verify_ssl=False) resp_data, errors = client.get("/test-x-sudo") assert resp_data == {} assert errors is None @@ -82,7 +82,7 @@ def test_send_sudo_header_set(client: Client): # If ``sudo=False``, no X-Auth-Sudo header should be set. This test checks that # the header is not set. def test_send_sudo_header_not_set(client: Client): - client = Client(env="dev", region="bregenz.a1", sudo=False) + client = Client(env="dev", region="bregenz.a1", sudo=False, _verify_ssl=False) resp_data, errors = client.get("/test-x-sudo") assert resp_data == {"message": "Header not set, as expected."} assert errors is None @@ -101,19 +101,16 @@ def test_client_initialization(client: Client): @mock.patch("croud.api.Configuration.get_token", return_value="some-token") -@mock.patch("croud.api.get_verify_ssl", return_value=False) @mock.patch( "croud.api.construct_api_base_url", return_value="https://invalid.cratedb.local" ) -def test_error_message_on_connection_error( - mock_construct_api_base_url, mock_get_verify_ssl, mock_get_token -): +def test_error_message_on_connection_error(mock_construct_api_base_url, mock_get_token): expected_message_re = re.compile( r"^Failed to perform command on https://invalid\.cratedb\.local/me\. " r"Original error was: 'HTTPSConnectionPool\(.*\)' " r"Does the environment exist in the region you specified\?$" ) - client = Client(env="dev", region="bregenz.a1") + client = Client(env="dev", region="bregenz.a1", _verify_ssl=False) resp_data, errors = client.get("/me") assert resp_data is None assert expected_message_re.match(errors["message"]) is not None From 0602798c03910ef7719ca5c9274b2c2b9af7aae2 Mon Sep 17 00:00:00 2001 From: Markus Holtermann Date: Fri, 20 Dec 2019 10:01:06 +0100 Subject: [PATCH 4/8] fixup! fixup! fixup! Remove asyncio --- croud/products/commands.py | 5 +---- tests/commands/test_products.py | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/croud/products/commands.py b/croud/products/commands.py index 393323f1..42451903 100644 --- a/croud/products/commands.py +++ b/croud/products/commands.py @@ -31,10 +31,7 @@ def products_list(args: Namespace) -> None: client = Client.from_args(args) url = "/api/v2/products/" - params = {} - if args.kind: - params["kind"] = args.kind - data, errors = client.get(url, params=params) + data, errors = client.get(url, params={"kind": args.kind} if args.kind else None) print_response( data=data, errors=errors, diff --git a/tests/commands/test_products.py b/tests/commands/test_products.py index 151b19a4..96158732 100644 --- a/tests/commands/test_products.py +++ b/tests/commands/test_products.py @@ -28,7 +28,7 @@ @mock.patch.object(Client, "request", return_value=({}, None)) def test_products_list(mock_request, mock_load_config): call_command("croud", "products", "list") - assert_rest(mock_request, RequestMethod.GET, "/api/v2/products/", params={}) + assert_rest(mock_request, RequestMethod.GET, "/api/v2/products/") @mock.patch("croud.config.load_config", return_value=Configuration.DEFAULT_CONFIG) From 7ac5869cffe15667d94ea4353c9219fe916a4a77 Mon Sep 17 00:00:00 2001 From: Markus Holtermann Date: Fri, 20 Dec 2019 10:08:04 +0100 Subject: [PATCH 5/8] fixup! fixup! fixup! fixup! Remove asyncio --- croud/login.py | 5 ++--- croud/server.py | 4 ++-- tests/commands/test_login.py | 16 ++++++++-------- tests/conftest.py | 4 ++-- tests/test_server.py | 2 +- tests/util/fake_cloud.py | 5 ++++- 6 files changed, 19 insertions(+), 17 deletions(-) diff --git a/croud/login.py b/croud/login.py index 923f7b3f..178cff9b 100644 --- a/croud/login.py +++ b/croud/login.py @@ -50,15 +50,14 @@ def login(args: Namespace) -> None: exit(1) env = args.env or Configuration.get_env() - server_thread = Server(partial(Configuration.set_token, env=env)) Configuration.set_context(env.lower()) - server_thread.start() + server = Server(partial(Configuration.set_token, env=env)).start_in_background() open_page_in_browser(_login_url(env)) print_info("A browser tab has been launched for you to login.") try: # Wait for the user to login. They'll be redirected to the `SetTokenHandler` # which will set the token in the configuration. - server_thread.wait() + server.wait_for_shutdown() except (KeyboardInterrupt, SystemExit): print_warning("Login cancelled.") else: diff --git a/croud/server.py b/croud/server.py index ad9651b7..4df2b538 100644 --- a/croud/server.py +++ b/croud/server.py @@ -82,11 +82,11 @@ def __init__(self, on_token, random_port: bool = False): self._server = SetTokenHTTPServer(on_token, (HOST, port), SetTokenHandler) self._thread = Thread(target=self._server.serve_forever, daemon=True) - def start(self) -> "Server": + def start_in_background(self) -> "Server": self._thread.start() return self - def wait(self, timeout=None): + def wait_for_shutdown(self, timeout=None): self._thread.join(timeout=timeout) @property diff --git a/tests/commands/test_login.py b/tests/commands/test_login.py index 56f6bfab..15a4f822 100644 --- a/tests/commands/test_login.py +++ b/tests/commands/test_login.py @@ -27,8 +27,8 @@ from tests.util import MockConfig, call_command -@mock.patch.object(Server, "wait") -@mock.patch.object(Server, "start") +@mock.patch.object(Server, "wait_for_shutdown") +@mock.patch.object(Server, "start_in_background") @mock.patch("croud.login.can_launch_browser", return_value=True) @mock.patch("croud.login.open_page_in_browser") @mock.patch("croud.login.print_info") @@ -36,8 +36,8 @@ def test_login( mock_print_info, mock_open_page_in_browser, mock_can_launch_browser, - mock_start, - mock_wait, + mock_start_in_background, + mock_wait_for_shutdown, ): cfg = MockConfig(Configuration.DEFAULT_CONFIG) @@ -56,8 +56,8 @@ def test_login( assert config["auth"]["contexts"]["dev"]["organization_id"] == "my-org-id" -@mock.patch.object(Server, "wait") -@mock.patch.object(Server, "start") +@mock.patch.object(Server, "wait_for_shutdown") +@mock.patch.object(Server, "start_in_background") @mock.patch("croud.login.can_launch_browser", return_value=True) @mock.patch("croud.login.open_page_in_browser") @mock.patch("croud.login.print_info") @@ -65,8 +65,8 @@ def test_login_local( mock_print_info, mock_open_page_in_browser, mock_can_launch_browser, - mock_start, - mock_wait, + mock_start_in_background, + mock_wait_for_shutdown, ): """ Test for a bug that caused that upon login to local env the local token diff --git a/tests/conftest.py b/tests/conftest.py index 9543483c..64c9b4c3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,9 +30,9 @@ @pytest.fixture(scope="session") def fake_cratedb_cloud(): cloud = FakeCrateDBCloud() - cloud.start() + cloud.start_in_background() yield cloud - cloud._server.shutdown() + cloud.wait_for_shutdown() @pytest.fixture diff --git a/tests/test_server.py b/tests/test_server.py index e53ac165..45ae7ce2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -43,7 +43,7 @@ def test_token_handler_and_login(qs, status_code, message, token_value): def on_token(token): token_store["token"] = token - server = Server(on_token, random_port=True).start() + server = Server(on_token, random_port=True).start_in_background() response = requests.get(f"http://localhost:{server.port}/{qs}") diff --git a/tests/util/fake_cloud.py b/tests/util/fake_cloud.py index 4d755889..9b4796c6 100644 --- a/tests/util/fake_cloud.py +++ b/tests/util/fake_cloud.py @@ -182,10 +182,13 @@ def __init__(self): ) self._thread = Thread(target=self._server.serve_forever, daemon=True) - def start(self) -> "FakeCrateDBCloud": + def start_in_background(self) -> "FakeCrateDBCloud": self._thread.start() return self + def wait_for_shutdown(self): + self._server.shutdown() + @property def port(self): return self._server.socket.getsockname()[1] From 9c08a183fb877ad8af00725575de242b6f9a33c5 Mon Sep 17 00:00:00 2001 From: Markus Holtermann Date: Fri, 20 Dec 2019 10:12:56 +0100 Subject: [PATCH 6/8] fixup! fixup! fixup! fixup! fixup! Remove asyncio --- tests/conftest.py | 6 ++---- tests/util/fake_cloud.py | 6 ++++++ 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 64c9b4c3..ba1fb274 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,10 +29,8 @@ @pytest.fixture(scope="session") def fake_cratedb_cloud(): - cloud = FakeCrateDBCloud() - cloud.start_in_background() - yield cloud - cloud.wait_for_shutdown() + with FakeCrateDBCloud() as cloud: + yield cloud @pytest.fixture diff --git a/tests/util/fake_cloud.py b/tests/util/fake_cloud.py index 9b4796c6..5e02ec47 100644 --- a/tests/util/fake_cloud.py +++ b/tests/util/fake_cloud.py @@ -192,3 +192,9 @@ def wait_for_shutdown(self): @property def port(self): return self._server.socket.getsockname()[1] + + def __enter__(self): + return self.start_in_background() + + def __exit__(self, exc_type, exc_value, traceback): + self.wait_for_shutdown() From 19b8f57b5b06bc8333710310ef0f21909c05e960 Mon Sep 17 00:00:00 2001 From: Markus Holtermann Date: Wed, 18 Dec 2019 23:20:51 +0100 Subject: [PATCH 7/8] Add support for Python 3.8 --- .travis.yml | 3 +++ CHANGES.rst | 2 ++ setup.py | 1 + tox.ini | 2 +- 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 8ee47486..7c65e4f2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -34,6 +34,9 @@ matrix: - <<: *code name: "Python 3.7" python: 3.7 + - <<: *code + name: "Python 3.8" + python: 3.8 - <<: *docs name: "Python 3.7" diff --git a/CHANGES.rst b/CHANGES.rst index 71b26799..7c3f08af 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,8 @@ Changes for croud Unreleased ========== +- Added support for Python 3.8 + 0.20.0 - 2019/11/28 =================== diff --git a/setup.py b/setup.py index c8776f79..bcb4eb56 100644 --- a/setup.py +++ b/setup.py @@ -61,5 +61,6 @@ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", ], ) diff --git a/tox.ini b/tox.ini index 50629e2b..bcebd945 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py36,py37 +envlist = py36,py37,py38 [testenv] deps = -r{toxinidir}/test-requirements.txt From ea92e23b49e4c8fed02ecd2e5c686960149e2658 Mon Sep 17 00:00:00 2001 From: Markus Holtermann Date: Wed, 18 Dec 2019 23:24:29 +0100 Subject: [PATCH 8/8] Updated dependencies --- setup.py | 13 +++++++------ test-requirements.txt | 11 ++++------- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/setup.py b/setup.py index bcb4eb56..74647a65 100644 --- a/setup.py +++ b/setup.py @@ -42,15 +42,16 @@ entry_points={"console_scripts": ["croud = croud.__main__:main"]}, packages=find_packages(), install_requires=[ - "requests==2.22.0", - "colorama==0.4.1", "appdirs==1.4.3", - "pyyaml==5.1.2", "certifi", - "tabulate==0.8.2", - "schema==0.6.8", + "colorama==0.4.3", + "pyyaml==5.2", + "requests==2.22.0", + "schema==0.7.1", + "tabulate==0.8.6", + "yarl==1.4.2", ], - extras_require={"testing": ["tox==3.6.1"], "development": ["black==19.3b0"]}, + extras_require={"testing": ["tox==3.14.2"], "development": ["black==19.3b0"]}, python_requires=">=3.6", classifiers=[ "Development Status :: 4 - Beta", diff --git a/test-requirements.txt b/test-requirements.txt index e62973b8..4f4197d4 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,9 +1,6 @@ -pytest>=3,<4 +pytest-black==0.3.7 pytest-flake8==1.0.4 -pytest-mock==0.10.1 -pytest-isort==0.2.1 -pytest-black==0.3.5 -pytest-mypy==0.3.2 +pytest-isort==0.3.1 +pytest-mypy==0.4.2 pytest-random-order==1.0.4 -mypy==0.670 -black==19.3b0 +pytest>=5,<6