menda-cli 0.2.2__tar.gz → 0.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. {menda_cli-0.2.2 → menda_cli-0.2.3}/CHANGELOG.md +6 -0
  2. {menda_cli-0.2.2 → menda_cli-0.2.3}/PKG-INFO +1 -1
  3. {menda_cli-0.2.2 → menda_cli-0.2.3}/pyproject.toml +1 -1
  4. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/auth/api.py +6 -0
  5. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/auth/oauth2/client.py +33 -5
  6. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/auth/task.py +9 -2
  7. menda_cli-0.2.3/tests/auth/oauth2/test_client.py +82 -0
  8. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/cli/auth/test_login.py +4 -0
  9. {menda_cli-0.2.2 → menda_cli-0.2.3}/uv.lock +1 -1
  10. {menda_cli-0.2.2 → menda_cli-0.2.3}/.cruft.json +0 -0
  11. {menda_cli-0.2.2 → menda_cli-0.2.3}/.github/workflows/ci.yml +0 -0
  12. {menda_cli-0.2.2 → menda_cli-0.2.3}/.github/workflows/lint-pr.yml +0 -0
  13. {menda_cli-0.2.2 → menda_cli-0.2.3}/.github/workflows/prepare-release.yml +0 -0
  14. {menda_cli-0.2.2 → menda_cli-0.2.3}/.github/workflows/release.yml +0 -0
  15. {menda_cli-0.2.2 → menda_cli-0.2.3}/.gitignore +0 -0
  16. {menda_cli-0.2.2 → menda_cli-0.2.3}/README.md +0 -0
  17. {menda_cli-0.2.2 → menda_cli-0.2.3}/cliff.toml +0 -0
  18. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/__init__.py +0 -0
  19. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/auth/__init__.py +0 -0
  20. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/auth/env.py +0 -0
  21. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/auth/exceptions.py +0 -0
  22. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/auth/http.py +0 -0
  23. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/auth/inquirer.py +0 -0
  24. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/auth/mendacfg.py +0 -0
  25. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/auth/models.py +0 -0
  26. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/auth/oauth2/__init__.py +0 -0
  27. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/auth/oauth2/token.py +0 -0
  28. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/auth/pkce.py +0 -0
  29. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/__init__.py +0 -0
  30. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/auth/__init__.py +0 -0
  31. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/auth/commands.py +0 -0
  32. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/console.py +0 -0
  33. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/cookiecutters/databricks-default/cookiecutter.json +0 -0
  34. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/cookiecutters/databricks-default/{{cookiecutter.project_name}}/.gitignore +0 -0
  35. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/cookiecutters/databricks-default/{{cookiecutter.project_name}}/README.md +0 -0
  36. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/cookiecutters/databricks-default/{{cookiecutter.project_name}}/menda.yaml +0 -0
  37. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/cookiecutters/databricks-default/{{cookiecutter.project_name}}/pyproject.toml +0 -0
  38. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/cookiecutters/databricks-default/{{cookiecutter.project_name}}/src/{{cookiecutter.project_slug}}/__init__.py +0 -0
  39. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/cookiecutters/databricks-default/{{cookiecutter.project_name}}/src/{{cookiecutter.project_slug}}/dp/.gitkeep +0 -0
  40. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/cookiecutters/databricks-default/{{cookiecutter.project_name}}/src/{{cookiecutter.project_slug}}/orchestration/.gitkeep +0 -0
  41. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/discovery.py +0 -0
  42. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/exceptions.py +0 -0
  43. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/init/__init__.py +0 -0
  44. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/init/commands.py +0 -0
  45. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/init/prompts.py +0 -0
  46. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/init/templates.py +0 -0
  47. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/main.py +0 -0
  48. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/cli/params.py +0 -0
  49. {menda_cli-0.2.2 → menda_cli-0.2.3}/src/menda/logging.py +0 -0
  50. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/auth/__init__.py +0 -0
  51. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/auth/test_env.py +0 -0
  52. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/auth/test_exceptions.py +0 -0
  53. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/auth/test_mendacfg.py +0 -0
  54. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/auth/test_models.py +0 -0
  55. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/auth/test_pkce.py +0 -0
  56. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/auth/test_task.py +0 -0
  57. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/auth/test_token.py +0 -0
  58. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/cli/.gitkeep +0 -0
  59. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/cli/auth/test_configure.py +0 -0
  60. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/cli/auth/test_whoami.py +0 -0
  61. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/cli/init/__init__.py +0 -0
  62. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/cli/init/test_commands.py +0 -0
  63. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/cli/init/test_templates.py +0 -0
  64. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/cli/test_console.py +0 -0
  65. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/cli/test_discovery.py +0 -0
  66. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/cli/test_exceptions.py +0 -0
  67. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/cli/test_ipython.py +0 -0
  68. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/cli/test_main.py +0 -0
  69. {menda_cli-0.2.2 → menda_cli-0.2.3}/tests/cli/test_params.py +0 -0
@@ -2,6 +2,12 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.2.3] - 2026-04-14
6
+
7
+ ### Features
8
+
9
+ - Implement fetch_tenant_config and update device authorization flow (#16) ([9c31568](https://github.com/mendaml/menda-cli/commit/9c315689d07f24996928f7ab641e98de1c598b26))
10
+
5
11
  ## [0.2.2] - 2026-04-06
6
12
 
7
13
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: menda-cli
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: A CLI for building, deploying and managing menda data products.
5
5
  Author-email: jpm2617 <jose.moreno@mendaml.com>
6
6
  Requires-Python: >=3.11
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "menda-cli"
3
- version = "0.2.2"
3
+ version = "0.2.3"
4
4
  description = "A CLI for building, deploying and managing menda data products."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -6,6 +6,7 @@ AUTHORIZATION_PATH = "/auth/device/authorize"
6
6
  TOKEN_PATH = "/auth/device/token" # noqa: S105
7
7
  WHOAMI_PATH = "/auth/whoami"
8
8
  OAUTH2_TOKEN_PATH = "/auth/oauth2/token" # noqa: S105
9
+ CONFIG_PATH = "/system/config"
9
10
 
10
11
 
11
12
  class AuthRoutes:
@@ -82,6 +83,11 @@ class AuthRoutes:
82
83
  payload["code_verifier"] = code_verifier
83
84
  return session.post(url=OAUTH2_TOKEN_PATH, params=payload)
84
85
 
86
+ @classmethod
87
+ def config(cls, session: requests.Session, hostname: str) -> requests.Response:
88
+ """Get tenant configuration by hostname."""
89
+ return session.get(CONFIG_PATH, params={"hostname": hostname})
90
+
85
91
  @classmethod
86
92
  def whoami(cls, session: requests.Session, token: str) -> requests.Response:
87
93
  """Get current user info."""
@@ -13,13 +13,40 @@ from menda.auth.api import AuthRoutes
13
13
  from menda.auth.exceptions import AuthenticationError
14
14
 
15
15
  logger = structlog.get_logger()
16
-
17
- CLIENT_ID = "device"
18
16
  SCOPE = ["openid"]
19
17
  GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
20
18
  CALLBACK_PATH = "/auth/device/callback"
21
19
 
22
20
 
21
+ def fetch_tenant_config(session: requests.Session, host: str) -> str:
22
+ """Fetch app_client_id for the tenant from the config endpoint."""
23
+ from urllib.parse import urlparse
24
+
25
+ hostname = urlparse(host).hostname
26
+ if not hostname:
27
+ raise AuthenticationError(
28
+ detail=f"Invalid host URL: {host}"
29
+ )
30
+ response = AuthRoutes.config(session=session, hostname=hostname)
31
+ try:
32
+ body = response.json()
33
+ except Exception as err:
34
+ raise AuthenticationError(
35
+ code=response.status_code,
36
+ detail=f"Failed to resolve tenant config for '{hostname}': server returned non-JSON response (status {response.status_code})",
37
+ ) from err
38
+ if response.status_code == 200:
39
+ app_client_id = body.get("app_client_id")
40
+ if not app_client_id:
41
+ raise AuthenticationError(
42
+ code=response.status_code,
43
+ detail=f"Tenant config for '{hostname}' is missing app_client_id",
44
+ )
45
+ return app_client_id
46
+ detail = body.get("detail", "Failed to resolve tenant config")
47
+ raise AuthenticationError(code=response.status_code, detail=detail)
48
+
49
+
23
50
  def poll_for_token(interval: int, expires_in: int, api: typing.Callable[[], requests.Response]) -> requests.Response:
24
51
  """Poll an API endpoint at intervals until success or timeout."""
25
52
  deadline = time.time() + expires_in
@@ -42,10 +69,10 @@ def is_device_code_in_valid_state(state: str) -> bool:
42
69
  return state in ["authorized", "pending"]
43
70
 
44
71
 
45
- def authorize_device(session: requests.Session, code_challenge: str) -> models.DeviceAuthResponseModel:
72
+ def authorize_device(session: requests.Session, code_challenge: str, client_id: str) -> models.DeviceAuthResponseModel:
46
73
  """Request device authorization."""
47
74
  response = AuthRoutes.authorize_device(
48
- session=session, client_id=CLIENT_ID, scope=SCOPE, code_challenge=code_challenge, code_challenge_method="S256"
75
+ session=session, client_id=client_id, scope=SCOPE, code_challenge=code_challenge, code_challenge_method="S256"
49
76
  )
50
77
  if response and response.status_code == 200:
51
78
  return models.DeviceAuthResponseModel(**response.json())
@@ -63,6 +90,7 @@ def login_with_device(
63
90
  device_model: models.DeviceAuthResponseModel,
64
91
  host: str,
65
92
  code_verifier: str,
93
+ client_id: str,
66
94
  ) -> models.CredentialsModel:
67
95
  """Complete device flow login."""
68
96
  response = poll_for_token(
@@ -70,7 +98,7 @@ def login_with_device(
70
98
  expires_in=device_model.expires_in,
71
99
  api=lambda: AuthRoutes.token(
72
100
  session=session,
73
- client_id=CLIENT_ID,
101
+ client_id=client_id,
74
102
  redirect_uri=urljoin(host, CALLBACK_PATH),
75
103
  device_code=device_model.device_code,
76
104
  grant_type=GRANT_TYPE,
@@ -145,13 +145,20 @@ def login(console: Console, profile_id: str | None) -> None:
145
145
  session = requests_session(base_url=profile_data.host)
146
146
  code_verifier, code_challenge = pkce.generate_pkce_pair(48)
147
147
 
148
- device_model = client.authorize_device(session=session, code_challenge=code_challenge)
148
+ # Resolve real app_client_id before device auth
149
+ client_id = client.fetch_tenant_config(session=session, host=profile_data.host)
150
+
151
+ device_model = client.authorize_device(session=session, code_challenge=code_challenge, client_id=client_id)
149
152
  verification_url = urljoin(profile_data.host, device_model.verification_uri_complete)
150
153
  console.info(f"Opening browser for authentication: {verification_url}")
151
154
  client.open_browser(verification_url)
152
155
 
153
156
  credentials = client.login_with_device(
154
- session=session, device_model=device_model, host=profile_data.host, code_verifier=code_verifier
157
+ session=session,
158
+ device_model=device_model,
159
+ host=profile_data.host,
160
+ code_verifier=code_verifier,
161
+ client_id=client_id,
155
162
  )
156
163
 
157
164
  token.cache_token(credentials=credentials, profile_id=profile_id)
@@ -0,0 +1,82 @@
1
+ """Tests for fetch_tenant_config."""
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+
7
+ from menda.auth.exceptions import AuthenticationError
8
+ from menda.auth.oauth2.client import fetch_tenant_config
9
+
10
+
11
+ def _mock_response(status_code: int, json_data: dict | None = None, json_raises: bool = False) -> MagicMock:
12
+ response = MagicMock()
13
+ response.status_code = status_code
14
+ if json_raises:
15
+ response.json.side_effect = ValueError("No JSON")
16
+ else:
17
+ response.json.return_value = json_data
18
+ return response
19
+
20
+
21
+ class TestFetchTenantConfig:
22
+ @patch("menda.auth.oauth2.client.AuthRoutes.config")
23
+ def test_returns_app_client_id_on_success(self, mock_config: MagicMock) -> None:
24
+ session = MagicMock()
25
+ mock_config.return_value = _mock_response(200, {"app_client_id": "abc123", "userpool_id": "us-east-1_xyz"})
26
+
27
+ result = fetch_tenant_config(session, "https://acme.cloud.mendaml.com")
28
+
29
+ assert result == "abc123"
30
+ mock_config.assert_called_once_with(session=session, hostname="acme.cloud.mendaml.com")
31
+
32
+ @patch("menda.auth.oauth2.client.AuthRoutes.config")
33
+ def test_raises_on_empty_app_client_id(self, mock_config: MagicMock) -> None:
34
+ session = MagicMock()
35
+ mock_config.return_value = _mock_response(200, {"app_client_id": "", "userpool_id": "us-east-1_xyz"})
36
+
37
+ with pytest.raises(AuthenticationError, match="missing app_client_id"):
38
+ fetch_tenant_config(session, "https://acme.cloud.mendaml.com")
39
+
40
+ @patch("menda.auth.oauth2.client.AuthRoutes.config")
41
+ def test_raises_on_missing_app_client_id(self, mock_config: MagicMock) -> None:
42
+ session = MagicMock()
43
+ mock_config.return_value = _mock_response(200, {"userpool_id": "us-east-1_xyz"})
44
+
45
+ with pytest.raises(AuthenticationError, match="missing app_client_id"):
46
+ fetch_tenant_config(session, "https://acme.cloud.mendaml.com")
47
+
48
+ @patch("menda.auth.oauth2.client.AuthRoutes.config")
49
+ def test_raises_on_non_json_response(self, mock_config: MagicMock) -> None:
50
+ session = MagicMock()
51
+ mock_config.return_value = _mock_response(200, json_raises=True)
52
+
53
+ with pytest.raises(AuthenticationError, match="non-JSON response"):
54
+ fetch_tenant_config(session, "http://localhost:3000")
55
+
56
+ @patch("menda.auth.oauth2.client.AuthRoutes.config")
57
+ def test_raises_on_404(self, mock_config: MagicMock) -> None:
58
+ session = MagicMock()
59
+ mock_config.return_value = _mock_response(
60
+ 404, {"detail": "No tenant configuration found for hostname: unknown"}
61
+ )
62
+
63
+ with pytest.raises(AuthenticationError, match="No tenant configuration found"):
64
+ fetch_tenant_config(session, "https://unknown.mendaml.com")
65
+
66
+ @patch("menda.auth.oauth2.client.AuthRoutes.config")
67
+ def test_raises_on_server_error(self, mock_config: MagicMock) -> None:
68
+ session = MagicMock()
69
+ mock_config.return_value = _mock_response(500, {"detail": "Internal Server Error"})
70
+
71
+ with pytest.raises(AuthenticationError) as exc_info:
72
+ fetch_tenant_config(session, "https://acme.cloud.mendaml.com")
73
+ assert exc_info.value.code == 500
74
+
75
+ @patch("menda.auth.oauth2.client.AuthRoutes.config")
76
+ def test_parses_hostname_from_url(self, mock_config: MagicMock) -> None:
77
+ session = MagicMock()
78
+ mock_config.return_value = _mock_response(200, {"app_client_id": "abc123"})
79
+
80
+ fetch_tenant_config(session, "https://acme.cloud.mendaml.com:8443/some/path")
81
+
82
+ mock_config.assert_called_once_with(session=session, hostname="acme.cloud.mendaml.com")
@@ -43,6 +43,7 @@ class TestLoginCommand:
43
43
 
44
44
  self.env_patcher.stop()
45
45
 
46
+ @mock.patch("menda.auth.oauth2.client.fetch_tenant_config", return_value="test-app-client-id")
46
47
  @mock.patch("menda.auth.oauth2.client.authorize_device")
47
48
  @mock.patch("menda.auth.oauth2.client.open_browser")
48
49
  @mock.patch("menda.auth.oauth2.client.login_with_device")
@@ -55,6 +56,7 @@ class TestLoginCommand:
55
56
  mock_login_with_device: mock.Mock,
56
57
  mock_open_browser: mock.Mock,
57
58
  mock_authorize_device: mock.Mock,
59
+ mock_fetch_tenant_config: mock.Mock,
58
60
  setup_and_teardown: pytest.FixtureRequest,
59
61
  ) -> None:
60
62
  """Test successful login flow."""
@@ -93,6 +95,7 @@ class TestLoginCommand:
93
95
  mock_cache_token.assert_called_once_with(credentials=credentials, profile_id="test_profile")
94
96
  logger.info("All mocks verified successfully")
95
97
 
98
+ @mock.patch("menda.auth.oauth2.client.fetch_tenant_config", return_value="test-app-client-id")
96
99
  @mock.patch("menda.auth.oauth2.client.authorize_device")
97
100
  @mock.patch("menda.auth.oauth2.client.open_browser")
98
101
  @mock.patch("menda.auth.oauth2.client.login_with_device")
@@ -103,6 +106,7 @@ class TestLoginCommand:
103
106
  mock_login_with_device: mock.Mock,
104
107
  mock_open_browser: mock.Mock,
105
108
  mock_authorize_device: mock.Mock,
109
+ mock_fetch_tenant_config: mock.Mock,
106
110
  setup_and_teardown: pytest.FixtureRequest,
107
111
  ) -> None:
108
112
  """Test login flow with authentication error."""
@@ -415,7 +415,7 @@ wheels = [
415
415
 
416
416
  [[package]]
417
417
  name = "menda-cli"
418
- version = "0.2.2"
418
+ version = "0.2.3"
419
419
  source = { editable = "." }
420
420
  dependencies = [
421
421
  { name = "click" },
File without changes
File without changes
File without changes
File without changes
File without changes