Skip to content

Commit 7886001

Browse files
committed
🐙 octavia-cli: implement init command
1 parent e05dfd1 commit 7886001

File tree

10 files changed

+354
-33
lines changed

10 files changed

+354
-33
lines changed

octavia-cli/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ We welcome community contributions!
3838

3939
| Date | Milestone |
4040
|------------|-------------------------------------|
41+
| 2022-01-25 | Implement `octavia init` + some context checks|
4142
| 2022-01-19 | Implement `octavia list workspace sources`, `octavia list workspace destinations`, `octavia list workspace connections`|
4243
| 2022-01-17 | Implement `octavia list connectors source` and `octavia list connectors destinations`|
4344
| 2022-01-17 | Generate an API Python client from our Open API spec |
+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#
2+
# Copyright (c) 2021 Airbyte, Inc., all rights reserved.
3+
#
4+
5+
import os
6+
7+
import airbyte_api_client
8+
import click
9+
from airbyte_api_client.api import health_api, workspace_api
10+
from airbyte_api_client.model.workspace_id_request_body import WorkspaceIdRequestBody
11+
from urllib3.exceptions import MaxRetryError
12+
13+
from .init.commands import DIRECTORIES_TO_CREATE as REQUIRED_PROJECT_DIRECTORIES
14+
15+
16+
class UnhealthyApiError(click.ClickException):
17+
pass
18+
19+
20+
class UnreachableAirbyteInstanceError(click.ClickException):
21+
pass
22+
23+
24+
class WorkspaceIdError(click.ClickException):
25+
pass
26+
27+
28+
def check_api_health(api_client: airbyte_api_client.ApiClient) -> None:
29+
"""Check if the Airbyte API is network reachable and healthy.
30+
31+
Args:
32+
api_client (airbyte_api_client.ApiClient): Airbyte API client.
33+
34+
Raises:
35+
click.ClickException: Raised if the Airbyte api server is unavailable according to the API response.
36+
click.ClickException: Raised if the Airbyte URL is not reachable.
37+
"""
38+
api_instance = health_api.HealthApi(api_client)
39+
try:
40+
api_response = api_instance.get_health_check()
41+
if not api_response.available:
42+
raise UnhealthyApiError("Your Airbyte instance is not ready to receive requests.")
43+
except (airbyte_api_client.ApiException, MaxRetryError):
44+
raise UnreachableAirbyteInstanceError(
45+
"Could not reach your Airbyte instance, make sure the instance is up and running an network reachable."
46+
)
47+
48+
49+
def check_workspace_exists(api_client: airbyte_api_client.ApiClient, workspace_id: str) -> None:
50+
"""Check if the provided workspace id corresponds to an existing workspace on the Airbyte instance.
51+
52+
Args:
53+
api_client (airbyte_api_client.ApiClient): Airbyte API client.
54+
workspace_id (str): Id of the workspace whose existence we are trying to verify.
55+
56+
Raises:
57+
click.ClickException: Raised if the workspace does not exist on the Airbyte instance.
58+
"""
59+
api_instance = workspace_api.WorkspaceApi(api_client)
60+
try:
61+
api_instance.get_workspace(WorkspaceIdRequestBody(workspace_id=workspace_id), _check_return_type=False)
62+
except airbyte_api_client.ApiException:
63+
raise WorkspaceIdError("The workspace you are trying to use does not exist in your Airbyte instance")
64+
65+
66+
def check_is_initialized(project_directory: str = ".") -> bool:
67+
"""Check if required project directories exist to consider the project as initialized.
68+
69+
Args:
70+
project_directory (str, optional): Where the project should be initialized. Defaults to ".".
71+
72+
Returns:
73+
bool: [description]
74+
"""
75+
sub_directories = [f.name for f in os.scandir(project_directory) if f.is_dir()]
76+
return set(REQUIRED_PROJECT_DIRECTORIES).issubset(sub_directories)

octavia-cli/octavia_cli/entrypoint.py

+35-18
Original file line numberDiff line numberDiff line change
@@ -8,41 +8,58 @@
88
import click
99
from airbyte_api_client.api import workspace_api
1010

11+
from .check_context import check_api_health, check_is_initialized, check_workspace_exists
12+
from .init import commands as init_commands
1113
from .list import commands as list_commands
1214

13-
AVAILABLE_COMMANDS: List[click.Command] = [list_commands._list]
15+
AVAILABLE_COMMANDS: List[click.Command] = [list_commands._list, init_commands.init]
1416

1517

1618
@click.group()
1719
@click.option("--airbyte-url", envvar="AIRBYTE_URL", default="http://localhost:8000", help="The URL of your Airbyte instance.")
20+
@click.option(
21+
"--workspace-id",
22+
envvar="AIRBYTE_WORKSPACE_ID",
23+
default=None,
24+
help="The id of the workspace on which you want octavia-cli to work. Defaults to the first one found on your Airbyte instance.",
25+
)
1826
@click.pass_context
19-
def octavia(ctx: click.Context, airbyte_url: str) -> None:
27+
def octavia(ctx: click.Context, airbyte_url: str, workspace_id: str) -> None:
2028
ctx.ensure_object(dict)
29+
ctx.obj["API_CLIENT"] = get_api_client(airbyte_url)
30+
ctx.obj["WORKSPACE_ID"] = get_workspace_id(ctx.obj["API_CLIENT"], workspace_id)
31+
ctx.obj["PROJECT_IS_INITIALIZED"] = check_is_initialized()
32+
click.echo(
33+
click.style(
34+
f"🐙 - Octavia is targetting your Airbyte instance running at {airbyte_url} on workspace {ctx.obj['WORKSPACE_ID']}.", fg="green"
35+
)
36+
)
37+
if not ctx.obj["PROJECT_IS_INITIALIZED"]:
38+
click.echo(click.style("🐙 - Project is not yet initialized.", fg="red", bold=True))
39+
40+
41+
def get_api_client(airbyte_url):
2142
client_configuration = airbyte_api_client.Configuration(host=f"{airbyte_url}/api")
2243
api_client = airbyte_api_client.ApiClient(client_configuration)
23-
# TODO alafanechere workspace check might deserve its own function
24-
api_instance = workspace_api.WorkspaceApi(api_client)
25-
# open-api-generator consider non-required field as not nullable
26-
# This will break validation of WorkspaceRead object for firstCompletedSync and feedbackDone fields
27-
# This is why we bypass _check_return_type
28-
api_response = api_instance.list_workspaces(_check_return_type=False)
29-
# TODO alafanechere prompt user to chose a workspace if multiple workspaces exist
30-
workspace_id = api_response.workspaces[0]["workspaceId"]
31-
click.echo(f"🐙 - Octavia is targetting your Airbyte instance running at {airbyte_url} on workspace {workspace_id}")
32-
ctx.obj["API_CLIENT"] = api_client
33-
ctx.obj["WORKSPACE_ID"] = workspace_id
44+
check_api_health(api_client)
45+
return api_client
46+
47+
48+
def get_workspace_id(api_client, user_defined_workspace_id):
49+
if user_defined_workspace_id:
50+
check_workspace_exists(api_client, user_defined_workspace_id)
51+
return user_defined_workspace_id
52+
else:
53+
api_instance = workspace_api.WorkspaceApi(api_client)
54+
api_response = api_instance.list_workspaces(_check_return_type=False)
55+
return api_response.workspaces[0]["workspaceId"]
3456

3557

3658
def add_commands_to_octavia():
3759
for command in AVAILABLE_COMMANDS:
3860
octavia.add_command(command)
3961

4062

41-
@octavia.command(help="Scaffolds a local project directories.")
42-
def init():
43-
raise click.ClickException("The init command is not yet implemented.")
44-
45-
4663
@octavia.command(name="import", help="Import an existing resources from the Airbyte instance.")
4764
def _import() -> None:
4865
raise click.ClickException("The import command is not yet implemented.")
+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#
2+
# Copyright (c) 2021 Airbyte, Inc., all rights reserved.
3+
#
+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#
2+
# Copyright (c) 2021 Airbyte, Inc., all rights reserved.
3+
#
4+
5+
import os
6+
from typing import Iterable, Tuple
7+
8+
import click
9+
10+
DIRECTORIES_TO_CREATE = {"connections", "destinations", "sources"}
11+
12+
13+
def create_directories(directories_to_create: Iterable[str]) -> Tuple[Iterable[str], Iterable[str]]:
14+
created_directories = []
15+
not_created_directories = []
16+
for directory in directories_to_create:
17+
try:
18+
os.mkdir(directory)
19+
created_directories.append(directory)
20+
except FileExistsError:
21+
not_created_directories.append(directory)
22+
return created_directories, not_created_directories
23+
24+
25+
@click.command(help="Initialize required directories for the project.")
26+
def init():
27+
click.echo("🔨 - Initializing the project.")
28+
created_directories, not_created_directories = create_directories(DIRECTORIES_TO_CREATE)
29+
if created_directories:
30+
message = f"✅ - Created the following directories: {', '.join(created_directories)}."
31+
click.echo(click.style(message, fg="green"))
32+
if not_created_directories:
33+
message = f"❓ - Already existing directories: {', '.join(not_created_directories) }."
34+
click.echo(click.style(message, fg="yellow", bold=True))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
#
2+
# Copyright (c) 2021 Airbyte, Inc., all rights reserved.
3+
#
4+
5+
import os
6+
import shutil
7+
import tempfile
8+
from pathlib import Path
9+
10+
import airbyte_api_client
11+
import pytest
12+
from airbyte_api_client.model.workspace_id_request_body import WorkspaceIdRequestBody
13+
from octavia_cli import check_context
14+
from urllib3.exceptions import MaxRetryError
15+
16+
17+
@pytest.fixture
18+
def mock_api_client(mocker):
19+
return mocker.Mock()
20+
21+
22+
def test_api_check_health_available(mock_api_client, mocker):
23+
mocker.patch.object(check_context, "health_api")
24+
mock_api_response = mocker.Mock(available=True)
25+
check_context.health_api.HealthApi.return_value.get_health_check.return_value = mock_api_response
26+
27+
assert check_context.check_api_health(mock_api_client) is None
28+
check_context.health_api.HealthApi.assert_called_with(mock_api_client)
29+
api_instance = check_context.health_api.HealthApi.return_value
30+
api_instance.get_health_check.assert_called()
31+
32+
33+
def test_api_check_health_unavailable(mock_api_client, mocker):
34+
mocker.patch.object(check_context, "health_api")
35+
mock_api_response = mocker.Mock(available=False)
36+
check_context.health_api.HealthApi.return_value.get_health_check.return_value = mock_api_response
37+
with pytest.raises(check_context.UnhealthyApiError):
38+
check_context.check_api_health(mock_api_client)
39+
40+
41+
def test_api_check_health_unreachable_api_exception(mock_api_client, mocker):
42+
mocker.patch.object(check_context, "health_api")
43+
check_context.health_api.HealthApi.return_value.get_health_check.side_effect = airbyte_api_client.ApiException()
44+
with pytest.raises(check_context.UnreachableAirbyteInstanceError):
45+
check_context.check_api_health(mock_api_client)
46+
47+
48+
def test_api_check_health_unreachable_max_retry_error(mock_api_client, mocker):
49+
mocker.patch.object(check_context, "health_api")
50+
check_context.health_api.HealthApi.return_value.get_health_check.side_effect = MaxRetryError("foo", "bar")
51+
with pytest.raises(check_context.UnreachableAirbyteInstanceError):
52+
check_context.check_api_health(mock_api_client)
53+
54+
55+
def test_check_workspace_exists(mock_api_client, mocker):
56+
mocker.patch.object(check_context, "workspace_api")
57+
mock_api_instance = mocker.Mock()
58+
check_context.workspace_api.WorkspaceApi.return_value = mock_api_instance
59+
assert check_context.check_workspace_exists(mock_api_client, "foo") is None
60+
check_context.workspace_api.WorkspaceApi.assert_called_with(mock_api_client)
61+
mock_api_instance.get_workspace.assert_called_with(WorkspaceIdRequestBody("foo"), _check_return_type=False)
62+
63+
64+
def test_check_workspace_exists_error(mock_api_client, mocker):
65+
mocker.patch.object(check_context, "workspace_api")
66+
check_context.workspace_api.WorkspaceApi.return_value.get_workspace.side_effect = airbyte_api_client.ApiException()
67+
with pytest.raises(check_context.WorkspaceIdError):
68+
check_context.check_workspace_exists(mock_api_client, "foo")
69+
70+
71+
@pytest.fixture
72+
def project_directories():
73+
dirpath = tempfile.mkdtemp()
74+
yield str(Path(dirpath).parent.absolute()), [os.path.basename(dirpath)]
75+
shutil.rmtree(dirpath)
76+
77+
78+
def test_check_is_initialized(mocker, project_directories):
79+
project_directory, sub_directories = project_directories
80+
mocker.patch.object(check_context, "REQUIRED_PROJECT_DIRECTORIES", sub_directories)
81+
assert check_context.check_is_initialized(project_directory)
82+
83+
84+
def test_check_not_initialized():
85+
assert not check_context.check_is_initialized(".")

octavia-cli/unit_tests/test_entrypoint.py

+58-15
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,6 @@
22
# Copyright (c) 2021 Airbyte, Inc., all rights reserved.
33
#
44

5-
from unittest import mock
6-
75
import click
86
import pytest
97
from click.testing import CliRunner
@@ -17,25 +15,70 @@ def dumb(ctx):
1715

1816

1917
def test_octavia(mocker):
20-
mocker.patch.object(entrypoint, "workspace_api")
21-
mocker.patch.object(entrypoint, "airbyte_api_client")
22-
18+
mocker.patch.object(entrypoint, "click")
19+
mocker.patch.object(entrypoint, "get_api_client")
20+
mocker.patch.object(entrypoint, "get_workspace_id", mocker.Mock(return_value="api-defined-workspace-id"))
21+
mocker.patch.object(entrypoint, "check_is_initialized", mocker.Mock(return_value=True))
2322
context_object = {}
24-
mock_api_instance = entrypoint.workspace_api.WorkspaceApi.return_value
25-
mock_api_instance.list_workspaces.return_value = mock.MagicMock(workspaces=[{"workspaceId": "expected_workspace_id"}])
23+
entrypoint.octavia.add_command(dumb)
24+
runner = CliRunner()
25+
result = runner.invoke(entrypoint.octavia, ["--airbyte-url", "test-airbyte-url", "dumb"], obj=context_object)
26+
entrypoint.get_api_client.assert_called()
27+
entrypoint.get_workspace_id.assert_called_with(entrypoint.get_api_client.return_value, None)
28+
expected_message = "🐙 - Octavia is targetting your Airbyte instance running at test-airbyte-url on workspace api-defined-workspace-id."
29+
entrypoint.click.style.assert_called_with(expected_message, fg="green")
30+
entrypoint.click.echo.assert_called_with(entrypoint.click.style.return_value)
31+
assert context_object == {
32+
"API_CLIENT": entrypoint.get_api_client.return_value,
33+
"WORKSPACE_ID": entrypoint.get_workspace_id.return_value,
34+
"PROJECT_IS_INITIALIZED": entrypoint.check_is_initialized.return_value,
35+
}
36+
assert result.exit_code == 0
2637

38+
39+
def test_octavia_not_initialized(mocker):
40+
mocker.patch.object(entrypoint, "click")
41+
mocker.patch.object(entrypoint, "get_api_client")
42+
mocker.patch.object(entrypoint, "get_workspace_id", mocker.Mock(return_value="api-defined-workspace-id"))
43+
mocker.patch.object(entrypoint, "check_is_initialized", mocker.Mock(return_value=False))
44+
context_object = {}
2745
entrypoint.octavia.add_command(dumb)
2846
runner = CliRunner()
2947
result = runner.invoke(entrypoint.octavia, ["--airbyte-url", "test-airbyte-url", "dumb"], obj=context_object)
30-
entrypoint.airbyte_api_client.Configuration.assert_called_with(host="test-airbyte-url/api")
31-
entrypoint.airbyte_api_client.ApiClient.assert_called_with(entrypoint.airbyte_api_client.Configuration.return_value)
32-
entrypoint.workspace_api.WorkspaceApi.assert_called_with(entrypoint.airbyte_api_client.ApiClient.return_value)
33-
mock_api_instance.list_workspaces.assert_called_once()
34-
assert context_object["API_CLIENT"] == entrypoint.airbyte_api_client.ApiClient.return_value
35-
assert context_object["WORKSPACE_ID"] == "expected_workspace_id"
48+
entrypoint.click.style.assert_called_with("🐙 - Project is not yet initialized.", fg="red", bold=True)
49+
entrypoint.click.echo.assert_called_with(entrypoint.click.style.return_value)
3650
assert result.exit_code == 0
3751

3852

53+
def test_get_api_client(mocker):
54+
mocker.patch.object(entrypoint, "airbyte_api_client")
55+
mocker.patch.object(entrypoint, "check_api_health")
56+
api_client = entrypoint.get_api_client("test-url")
57+
entrypoint.airbyte_api_client.Configuration.assert_called_with(host="test-url/api")
58+
entrypoint.airbyte_api_client.ApiClient.assert_called_with(entrypoint.airbyte_api_client.Configuration.return_value)
59+
entrypoint.check_api_health.assert_called_with(entrypoint.airbyte_api_client.ApiClient.return_value)
60+
assert api_client == entrypoint.airbyte_api_client.ApiClient.return_value
61+
62+
63+
def test_get_workspace_id_user_defined(mocker):
64+
mock_api_client = mocker.Mock()
65+
mocker.patch.object(entrypoint, "check_workspace_exists")
66+
mocker.patch.object(entrypoint, "workspace_api")
67+
assert entrypoint.get_workspace_id(mock_api_client, "user-defined-workspace-id") == "user-defined-workspace-id"
68+
entrypoint.check_workspace_exists.assert_called_with(mock_api_client, "user-defined-workspace-id")
69+
70+
71+
def test_get_workspace_id_api_defined(mocker):
72+
mock_api_client = mocker.Mock()
73+
mocker.patch.object(entrypoint, "check_workspace_exists")
74+
mocker.patch.object(entrypoint, "workspace_api")
75+
mock_api_instance = entrypoint.workspace_api.WorkspaceApi.return_value
76+
mock_api_instance.list_workspaces.return_value = mocker.Mock(workspaces=[{"workspaceId": "api-defined-workspace-id"}])
77+
assert entrypoint.get_workspace_id(mock_api_client, None) == "api-defined-workspace-id"
78+
entrypoint.workspace_api.WorkspaceApi.assert_called_with(mock_api_client)
79+
mock_api_instance.list_workspaces.assert_called_with(_check_return_type=False)
80+
81+
3982
def test_commands_in_octavia_group():
4083
octavia_commands = entrypoint.octavia.commands.values()
4184
for command in entrypoint.AVAILABLE_COMMANDS:
@@ -44,7 +87,7 @@ def test_commands_in_octavia_group():
4487

4588
@pytest.mark.parametrize(
4689
"command",
47-
[entrypoint.init, entrypoint.apply, entrypoint.create, entrypoint.delete, entrypoint._import],
90+
[entrypoint.apply, entrypoint.create, entrypoint.delete, entrypoint._import],
4891
)
4992
def test_not_implemented_commands(command):
5093
runner = CliRunner()
@@ -54,4 +97,4 @@ def test_not_implemented_commands(command):
5497

5598

5699
def test_available_commands():
57-
assert entrypoint.AVAILABLE_COMMANDS == [entrypoint.list_commands._list]
100+
assert entrypoint.AVAILABLE_COMMANDS == [entrypoint.list_commands._list, entrypoint.init_commands.init]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#
2+
# Copyright (c) 2021 Airbyte, Inc., all rights reserved.
3+
#

0 commit comments

Comments
 (0)