ga4-cli 0.1.0__py3-none-any.whl
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.
- ga4_cli-0.1.0.dist-info/METADATA +253 -0
- ga4_cli-0.1.0.dist-info/RECORD +32 -0
- ga4_cli-0.1.0.dist-info/WHEEL +5 -0
- ga4_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ga4_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- ga4_cli-0.1.0.dist-info/top_level.txt +2 -0
- ga_cli/__init__.py +3 -0
- ga_cli/auth.py +59 -0
- ga_cli/cli.py +35 -0
- ga_cli/commands/__init__.py +0 -0
- ga_cli/commands/accounts.py +81 -0
- ga_cli/commands/config.py +65 -0
- ga_cli/commands/datastreams.py +146 -0
- ga_cli/commands/properties.py +151 -0
- ga_cli/config.py +95 -0
- ga_cli/decorators.py +90 -0
- ga_cli/errors.py +35 -0
- ga_cli/formatters/__init__.py +0 -0
- ga_cli/formatters/json.py +9 -0
- ga_cli/formatters/table.py +23 -0
- ga_cli/logging_config.py +44 -0
- ga_cli/output.py +59 -0
- ga_cli/retry.py +40 -0
- ga_cli/validators.py +52 -0
- tests/__init__.py +0 -0
- tests/conftest.py +89 -0
- tests/test_accounts.py +49 -0
- tests/test_auth.py +74 -0
- tests/test_cli.py +52 -0
- tests/test_config.py +140 -0
- tests/test_errors.py +73 -0
- tests/test_validators.py +146 -0
ga_cli/retry.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Retry logic with exponential backoff for API calls"""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import functools
|
|
5
|
+
from google.api_core import exceptions
|
|
6
|
+
from ga_cli.logging_config import logger
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def retry_on_transient_error(max_retries=3, backoff_factor=2):
|
|
10
|
+
"""Decorator for retrying on transient errors
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
max_retries: Maximum number of retry attempts
|
|
14
|
+
backoff_factor: Multiplier for exponential backoff delay
|
|
15
|
+
"""
|
|
16
|
+
def decorator(func):
|
|
17
|
+
@functools.wraps(func)
|
|
18
|
+
def wrapper(*args, **kwargs):
|
|
19
|
+
retries = 0
|
|
20
|
+
while retries < max_retries:
|
|
21
|
+
try:
|
|
22
|
+
return func(*args, **kwargs)
|
|
23
|
+
except (exceptions.ServiceUnavailable,
|
|
24
|
+
exceptions.DeadlineExceeded,
|
|
25
|
+
exceptions.InternalServerError) as e:
|
|
26
|
+
retries += 1
|
|
27
|
+
if retries >= max_retries:
|
|
28
|
+
logger.error(f"Max retries ({max_retries}) exceeded for {func.__name__}")
|
|
29
|
+
raise
|
|
30
|
+
|
|
31
|
+
wait_time = backoff_factor ** retries
|
|
32
|
+
logger.warning(
|
|
33
|
+
f"Transient error in {func.__name__}, "
|
|
34
|
+
f"retrying in {wait_time}s (attempt {retries}/{max_retries}): {str(e)}"
|
|
35
|
+
)
|
|
36
|
+
time.sleep(wait_time)
|
|
37
|
+
|
|
38
|
+
return func(*args, **kwargs)
|
|
39
|
+
return wrapper
|
|
40
|
+
return decorator
|
ga_cli/validators.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Input validation helpers for CLI commands"""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def validate_account_id(ctx, param, value):
|
|
8
|
+
"""Validate account ID format"""
|
|
9
|
+
if value and not value.isdigit():
|
|
10
|
+
raise click.BadParameter("Account ID must be numeric")
|
|
11
|
+
return value
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_property_id(ctx, param, value):
|
|
15
|
+
"""Validate property ID format"""
|
|
16
|
+
if value and not value.isdigit():
|
|
17
|
+
raise click.BadParameter("Property ID must be numeric")
|
|
18
|
+
return value
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def validate_url(ctx, param, value):
|
|
22
|
+
"""Validate URL format"""
|
|
23
|
+
if value:
|
|
24
|
+
pattern = re.compile(r'^https?://.+')
|
|
25
|
+
if not pattern.match(value):
|
|
26
|
+
raise click.BadParameter("URL must start with http:// or https://")
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_timezone(ctx, param, value):
|
|
31
|
+
"""Validate timezone"""
|
|
32
|
+
if value:
|
|
33
|
+
try:
|
|
34
|
+
import pytz
|
|
35
|
+
pytz.timezone(value)
|
|
36
|
+
except pytz.exceptions.UnknownTimeZoneError:
|
|
37
|
+
raise click.BadParameter(f"Invalid timezone: {value}")
|
|
38
|
+
return value
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_currency(ctx, param, value):
|
|
42
|
+
"""Validate currency code"""
|
|
43
|
+
if value and not re.match(r'^[A-Z]{3}$', value):
|
|
44
|
+
raise click.BadParameter("Currency must be 3-letter code (e.g., USD)")
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def validate_stream_id(ctx, param, value):
|
|
49
|
+
"""Validate stream ID format"""
|
|
50
|
+
if value and not value.isdigit():
|
|
51
|
+
raise click.BadParameter("Stream ID must be numeric")
|
|
52
|
+
return value
|
tests/__init__.py
ADDED
|
File without changes
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Pytest fixtures and configuration"""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import Mock, MagicMock
|
|
5
|
+
from click.testing import CliRunner
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.fixture
|
|
9
|
+
def cli_runner():
|
|
10
|
+
"""Provide a Click CLI test runner"""
|
|
11
|
+
return CliRunner()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def mock_client():
|
|
16
|
+
"""Mock Analytics Admin API client"""
|
|
17
|
+
client = Mock()
|
|
18
|
+
return client
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def mock_account():
|
|
23
|
+
"""Mock account object"""
|
|
24
|
+
account = Mock()
|
|
25
|
+
account.name = "accounts/123456"
|
|
26
|
+
account.display_name = "Test Account"
|
|
27
|
+
account.region_code = "US"
|
|
28
|
+
account.create_time = MagicMock()
|
|
29
|
+
account.create_time.__str__ = Mock(return_value="2023-01-01 00:00:00.000000")
|
|
30
|
+
account.update_time = MagicMock()
|
|
31
|
+
account.update_time.__str__ = Mock(return_value="2023-01-02 00:00:00.000000")
|
|
32
|
+
return account
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.fixture
|
|
36
|
+
def mock_property():
|
|
37
|
+
"""Mock property object"""
|
|
38
|
+
property = Mock()
|
|
39
|
+
property.name = "properties/987654"
|
|
40
|
+
property.display_name = "Test Property"
|
|
41
|
+
property.time_zone = "America/Los_Angeles"
|
|
42
|
+
property.currency_code = "USD"
|
|
43
|
+
property.property_type = Mock()
|
|
44
|
+
property.property_type.name = "PROPERTY_TYPE_ORDINARY"
|
|
45
|
+
property.industry_category = Mock()
|
|
46
|
+
property.industry_category.name = "TECHNOLOGY"
|
|
47
|
+
property.create_time = MagicMock()
|
|
48
|
+
property.create_time.__str__ = Mock(return_value="2023-01-01 00:00:00.000000")
|
|
49
|
+
return property
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.fixture
|
|
53
|
+
def mock_datastream():
|
|
54
|
+
"""Mock data stream object"""
|
|
55
|
+
stream = Mock()
|
|
56
|
+
stream.name = "properties/987654/dataStreams/123"
|
|
57
|
+
stream.display_name = "Test Stream"
|
|
58
|
+
stream.type_ = Mock()
|
|
59
|
+
stream.type_.name = "WEB_DATA_STREAM"
|
|
60
|
+
stream.create_time = MagicMock()
|
|
61
|
+
stream.create_time.__str__ = Mock(return_value="2023-01-01 00:00:00.000000")
|
|
62
|
+
|
|
63
|
+
# Mock web stream data
|
|
64
|
+
stream.web_stream_data = Mock()
|
|
65
|
+
stream.web_stream_data.measurement_id = "G-XXXXXXXXXX"
|
|
66
|
+
stream.web_stream_data.default_uri = "https://example.com"
|
|
67
|
+
stream.web_stream_data.firebase_app_id = "1:123456:web:abc"
|
|
68
|
+
|
|
69
|
+
stream.android_app_stream_data = None
|
|
70
|
+
stream.ios_app_stream_data = None
|
|
71
|
+
|
|
72
|
+
return stream
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.fixture
|
|
76
|
+
def mock_credentials_path(tmp_path):
|
|
77
|
+
"""Create a temporary credentials file"""
|
|
78
|
+
creds_file = tmp_path / "credentials.json"
|
|
79
|
+
creds_file.write_text('{"type": "service_account"}')
|
|
80
|
+
return str(creds_file)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.fixture
|
|
84
|
+
def mock_config_manager(mocker, mock_credentials_path):
|
|
85
|
+
"""Mock ConfigManager"""
|
|
86
|
+
manager = Mock()
|
|
87
|
+
manager.get_credentials_path.return_value = mock_credentials_path
|
|
88
|
+
manager.get.return_value = mock_credentials_path
|
|
89
|
+
return manager
|
tests/test_accounts.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Tests for account commands"""
|
|
2
|
+
|
|
3
|
+
from click.testing import CliRunner
|
|
4
|
+
from unittest.mock import Mock, patch, MagicMock
|
|
5
|
+
from ga_cli.cli import cli
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@patch('ga_cli.decorators.AuthManager')
|
|
9
|
+
def test_accounts_list(mock_auth):
|
|
10
|
+
"""Test accounts list command"""
|
|
11
|
+
runner = CliRunner()
|
|
12
|
+
|
|
13
|
+
mock_account = Mock()
|
|
14
|
+
mock_account.name = "accounts/123456"
|
|
15
|
+
mock_account.display_name = "Test Account"
|
|
16
|
+
mock_account.region_code = "US"
|
|
17
|
+
mock_account.create_time = MagicMock()
|
|
18
|
+
mock_account.create_time.__str__ = Mock(return_value="2023-01-01 00:00:00.000000")
|
|
19
|
+
|
|
20
|
+
mock_client = Mock()
|
|
21
|
+
mock_client.list_accounts.return_value = [mock_account]
|
|
22
|
+
mock_auth.return_value.get_client.return_value = mock_client
|
|
23
|
+
|
|
24
|
+
result = runner.invoke(cli, ['accounts', 'list'])
|
|
25
|
+
|
|
26
|
+
assert 'Test Account' in result.output or result.exit_code == 1
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@patch('ga_cli.decorators.AuthManager')
|
|
30
|
+
def test_accounts_get(mock_auth):
|
|
31
|
+
"""Test accounts get command"""
|
|
32
|
+
runner = CliRunner()
|
|
33
|
+
|
|
34
|
+
mock_account = Mock()
|
|
35
|
+
mock_account.name = "accounts/123456"
|
|
36
|
+
mock_account.display_name = "Test Account"
|
|
37
|
+
mock_account.region_code = "US"
|
|
38
|
+
mock_account.create_time = MagicMock()
|
|
39
|
+
mock_account.create_time.__str__ = Mock(return_value="2023-01-01 00:00:00.000000")
|
|
40
|
+
mock_account.update_time = MagicMock()
|
|
41
|
+
mock_account.update_time.__str__ = Mock(return_value="2023-01-02 00:00:00.000000")
|
|
42
|
+
|
|
43
|
+
mock_client = Mock()
|
|
44
|
+
mock_client.get_account.return_value = mock_account
|
|
45
|
+
mock_auth.return_value.get_client.return_value = mock_client
|
|
46
|
+
|
|
47
|
+
result = runner.invoke(cli, ['accounts', 'get', '123456'])
|
|
48
|
+
|
|
49
|
+
assert 'Test Account' in result.output or result.exit_code == 1
|
tests/test_auth.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Tests for authentication module"""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import Mock, patch, MagicMock
|
|
5
|
+
from ga_cli.auth import AuthManager
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestAuthManager:
|
|
9
|
+
"""Test AuthManager class"""
|
|
10
|
+
|
|
11
|
+
def test_init_with_credentials_path(self):
|
|
12
|
+
"""Test initialization with credentials path"""
|
|
13
|
+
auth = AuthManager(credentials_path="/path/to/creds.json")
|
|
14
|
+
assert auth.credentials_path == "/path/to/creds.json"
|
|
15
|
+
|
|
16
|
+
def test_init_without_credentials_uses_env(self, monkeypatch):
|
|
17
|
+
"""Test initialization falls back to environment variable"""
|
|
18
|
+
monkeypatch.setenv("GOOGLE_APPLICATION_CREDENTIALS", "/env/creds.json")
|
|
19
|
+
auth = AuthManager()
|
|
20
|
+
assert auth.credentials_path == "/env/creds.json"
|
|
21
|
+
|
|
22
|
+
@patch('ga_cli.auth.service_account')
|
|
23
|
+
@patch('ga_cli.auth.AnalyticsAdminServiceClient')
|
|
24
|
+
def test_get_client_with_credentials(self, mock_client_class, mock_service_account):
|
|
25
|
+
"""Test get_client creates client with credentials"""
|
|
26
|
+
mock_credentials = Mock()
|
|
27
|
+
mock_service_account.Credentials.from_service_account_file.return_value = mock_credentials
|
|
28
|
+
|
|
29
|
+
auth = AuthManager(credentials_path="/path/to/creds.json")
|
|
30
|
+
client = auth.get_client()
|
|
31
|
+
|
|
32
|
+
mock_service_account.Credentials.from_service_account_file.assert_called_once_with(
|
|
33
|
+
"/path/to/creds.json"
|
|
34
|
+
)
|
|
35
|
+
mock_client_class.assert_called_once()
|
|
36
|
+
assert client is not None
|
|
37
|
+
|
|
38
|
+
@patch('ga_cli.auth.AnalyticsAdminServiceClient')
|
|
39
|
+
def test_get_client_caches_instance(self, mock_client_class):
|
|
40
|
+
"""Test get_client returns cached client instance"""
|
|
41
|
+
auth = AuthManager()
|
|
42
|
+
client1 = auth.get_client()
|
|
43
|
+
client2 = auth.get_client()
|
|
44
|
+
|
|
45
|
+
# Should only create client once
|
|
46
|
+
assert mock_client_class.call_count == 1
|
|
47
|
+
assert client1 is client2
|
|
48
|
+
|
|
49
|
+
@patch('ga_cli.auth.service_account')
|
|
50
|
+
@patch('ga_cli.auth.AnalyticsAdminServiceClient')
|
|
51
|
+
def test_context_manager(self, mock_client_class, mock_service_account):
|
|
52
|
+
"""Test AuthManager as context manager"""
|
|
53
|
+
mock_credentials = Mock()
|
|
54
|
+
mock_service_account.Credentials.from_service_account_file.return_value = mock_credentials
|
|
55
|
+
|
|
56
|
+
auth = AuthManager(credentials_path="/path/to/creds.json")
|
|
57
|
+
|
|
58
|
+
with auth as client:
|
|
59
|
+
assert client is not None
|
|
60
|
+
|
|
61
|
+
# After exit, client should be cleared
|
|
62
|
+
assert auth._client is None
|
|
63
|
+
|
|
64
|
+
@patch('ga_cli.auth.service_account')
|
|
65
|
+
@patch('ga_cli.auth.AnalyticsAdminServiceClient')
|
|
66
|
+
def test_get_client_with_timeout(self, mock_client_class, mock_service_account):
|
|
67
|
+
"""Test get_client accepts timeout parameter"""
|
|
68
|
+
mock_credentials = Mock()
|
|
69
|
+
mock_service_account.Credentials.from_service_account_file.return_value = mock_credentials
|
|
70
|
+
|
|
71
|
+
auth = AuthManager(credentials_path="/path/to/creds.json")
|
|
72
|
+
client = auth.get_client(timeout=60)
|
|
73
|
+
|
|
74
|
+
assert client is not None
|
tests/test_cli.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Tests for CLI entry point"""
|
|
2
|
+
|
|
3
|
+
from click.testing import CliRunner
|
|
4
|
+
from ga_cli.cli import cli
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_cli_help():
|
|
8
|
+
"""Test that CLI help works"""
|
|
9
|
+
runner = CliRunner()
|
|
10
|
+
result = runner.invoke(cli, ['--help'])
|
|
11
|
+
assert result.exit_code == 0
|
|
12
|
+
assert 'Google Analytics CLI' in result.output
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_cli_version():
|
|
16
|
+
"""Test that version flag works"""
|
|
17
|
+
runner = CliRunner()
|
|
18
|
+
result = runner.invoke(cli, ['--version'])
|
|
19
|
+
assert result.exit_code == 0
|
|
20
|
+
assert '0.1.0' in result.output
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_accounts_command_exists():
|
|
24
|
+
"""Test that accounts command exists"""
|
|
25
|
+
runner = CliRunner()
|
|
26
|
+
result = runner.invoke(cli, ['accounts', '--help'])
|
|
27
|
+
assert result.exit_code == 0
|
|
28
|
+
assert 'Manage Google Analytics accounts' in result.output
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_properties_command_exists():
|
|
32
|
+
"""Test that properties command exists"""
|
|
33
|
+
runner = CliRunner()
|
|
34
|
+
result = runner.invoke(cli, ['properties', '--help'])
|
|
35
|
+
assert result.exit_code == 0
|
|
36
|
+
assert 'Manage Google Analytics properties' in result.output
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_datastreams_command_exists():
|
|
40
|
+
"""Test that datastreams command exists"""
|
|
41
|
+
runner = CliRunner()
|
|
42
|
+
result = runner.invoke(cli, ['datastreams', '--help'])
|
|
43
|
+
assert result.exit_code == 0
|
|
44
|
+
assert 'Manage data streams' in result.output
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_config_command_exists():
|
|
48
|
+
"""Test that config command exists"""
|
|
49
|
+
runner = CliRunner()
|
|
50
|
+
result = runner.invoke(cli, ['config', '--help'])
|
|
51
|
+
assert result.exit_code == 0
|
|
52
|
+
assert 'Manage CLI configuration' in result.output
|
tests/test_config.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Tests for configuration module"""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from unittest.mock import Mock, patch
|
|
8
|
+
import tempfile
|
|
9
|
+
import click
|
|
10
|
+
from ga_cli.config import ConfigManager
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestConfigManager:
|
|
14
|
+
"""Test ConfigManager class"""
|
|
15
|
+
|
|
16
|
+
def test_init_creates_paths(self):
|
|
17
|
+
"""Test initialization creates config paths"""
|
|
18
|
+
manager = ConfigManager()
|
|
19
|
+
assert manager.config_dir == Path.home() / '.ga-cli'
|
|
20
|
+
assert manager.config_file == manager.config_dir / 'config.ini'
|
|
21
|
+
|
|
22
|
+
def test_ensure_config_dir_creates_directory(self):
|
|
23
|
+
"""Test ensure_config_dir creates directory"""
|
|
24
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
25
|
+
manager = ConfigManager()
|
|
26
|
+
manager.config_dir = Path(tmpdir) / '.ga-cli'
|
|
27
|
+
manager.config_file = manager.config_dir / 'config.ini'
|
|
28
|
+
|
|
29
|
+
manager.ensure_config_dir()
|
|
30
|
+
|
|
31
|
+
assert manager.config_dir.exists()
|
|
32
|
+
# Check directory permissions are restrictive (700)
|
|
33
|
+
assert oct(os.stat(manager.config_dir).st_mode)[-3:] == '700'
|
|
34
|
+
|
|
35
|
+
def test_save_creates_secure_file(self):
|
|
36
|
+
"""Test save creates config file with secure permissions"""
|
|
37
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
38
|
+
manager = ConfigManager()
|
|
39
|
+
manager.config_dir = Path(tmpdir)
|
|
40
|
+
manager.config_file = manager.config_dir / 'config.ini'
|
|
41
|
+
|
|
42
|
+
manager.config['test'] = {'key': 'value'}
|
|
43
|
+
manager.save()
|
|
44
|
+
|
|
45
|
+
assert manager.config_file.exists()
|
|
46
|
+
# Check file permissions are restrictive (600)
|
|
47
|
+
assert oct(os.stat(manager.config_file).st_mode)[-3:] == '600'
|
|
48
|
+
|
|
49
|
+
def test_load_reads_existing_config(self):
|
|
50
|
+
"""Test load reads existing configuration"""
|
|
51
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
52
|
+
manager = ConfigManager()
|
|
53
|
+
manager.config_dir = Path(tmpdir)
|
|
54
|
+
manager.config_file = manager.config_dir / 'config.ini'
|
|
55
|
+
|
|
56
|
+
# Create config file
|
|
57
|
+
manager.config['section'] = {'key': 'value'}
|
|
58
|
+
manager.save()
|
|
59
|
+
|
|
60
|
+
# Load in new instance
|
|
61
|
+
manager2 = ConfigManager()
|
|
62
|
+
manager2.config_dir = Path(tmpdir)
|
|
63
|
+
manager2.config_file = manager2.config_dir / 'config.ini'
|
|
64
|
+
manager2.load()
|
|
65
|
+
|
|
66
|
+
assert manager2.config.get('section', 'key') == 'value'
|
|
67
|
+
|
|
68
|
+
def test_get_credentials_path(self):
|
|
69
|
+
"""Test get_credentials_path retrieves stored path"""
|
|
70
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
71
|
+
# Create a test credentials file
|
|
72
|
+
creds_file = Path(tmpdir) / 'creds.json'
|
|
73
|
+
creds_file.write_text('{}')
|
|
74
|
+
|
|
75
|
+
manager = ConfigManager()
|
|
76
|
+
manager.config_dir = Path(tmpdir)
|
|
77
|
+
manager.config_file = manager.config_dir / 'config.ini'
|
|
78
|
+
|
|
79
|
+
manager.config['auth'] = {'credentials_path': str(creds_file)}
|
|
80
|
+
manager.save()
|
|
81
|
+
|
|
82
|
+
path = manager.get_credentials_path()
|
|
83
|
+
assert path == str(creds_file)
|
|
84
|
+
|
|
85
|
+
def test_get_credentials_path_returns_none_when_missing(self):
|
|
86
|
+
"""Test get_credentials_path returns None when not configured"""
|
|
87
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
88
|
+
manager = ConfigManager()
|
|
89
|
+
manager.config_dir = Path(tmpdir)
|
|
90
|
+
manager.config_file = manager.config_dir / 'config.ini'
|
|
91
|
+
|
|
92
|
+
path = manager.get_credentials_path()
|
|
93
|
+
assert path is None
|
|
94
|
+
|
|
95
|
+
def test_set_credentials_path_validates_file(self):
|
|
96
|
+
"""Test set_credentials_path validates file exists"""
|
|
97
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
98
|
+
creds_file = Path(tmpdir) / 'creds.json'
|
|
99
|
+
creds_file.write_text('{}')
|
|
100
|
+
|
|
101
|
+
manager = ConfigManager()
|
|
102
|
+
manager.config_dir = Path(tmpdir)
|
|
103
|
+
manager.config_file = manager.config_dir / 'config.ini'
|
|
104
|
+
|
|
105
|
+
manager.set_credentials_path(str(creds_file))
|
|
106
|
+
|
|
107
|
+
assert manager.config.get('auth', 'credentials_path') == str(creds_file)
|
|
108
|
+
|
|
109
|
+
def test_set_credentials_path_rejects_missing_file(self):
|
|
110
|
+
"""Test set_credentials_path rejects non-existent file"""
|
|
111
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
112
|
+
manager = ConfigManager()
|
|
113
|
+
manager.config_dir = Path(tmpdir)
|
|
114
|
+
manager.config_file = manager.config_dir / 'config.ini'
|
|
115
|
+
|
|
116
|
+
with pytest.raises(click.ClickException, match="Invalid or insecure credentials file"):
|
|
117
|
+
manager.set_credentials_path('/nonexistent/creds.json')
|
|
118
|
+
|
|
119
|
+
def test_get_method(self):
|
|
120
|
+
"""Test generic get method"""
|
|
121
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
122
|
+
manager = ConfigManager()
|
|
123
|
+
manager.config_dir = Path(tmpdir)
|
|
124
|
+
manager.config_file = manager.config_dir / 'config.ini'
|
|
125
|
+
|
|
126
|
+
manager.config['section'] = {'key': 'value'}
|
|
127
|
+
manager.save()
|
|
128
|
+
|
|
129
|
+
value = manager.get('section', 'key')
|
|
130
|
+
assert value == 'value'
|
|
131
|
+
|
|
132
|
+
def test_get_method_with_fallback(self):
|
|
133
|
+
"""Test get method returns fallback for missing key"""
|
|
134
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
135
|
+
manager = ConfigManager()
|
|
136
|
+
manager.config_dir = Path(tmpdir)
|
|
137
|
+
manager.config_file = manager.config_dir / 'config.ini'
|
|
138
|
+
|
|
139
|
+
value = manager.get('missing', 'key', fallback='default')
|
|
140
|
+
assert value == 'default'
|
tests/test_errors.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Tests for error handling module"""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from unittest.mock import Mock
|
|
5
|
+
from google.api_core import exceptions
|
|
6
|
+
from ga_cli.errors import get_friendly_error, ERROR_MESSAGES
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestGetFriendlyError:
|
|
10
|
+
"""Test get_friendly_error function"""
|
|
11
|
+
|
|
12
|
+
def test_not_found_exception(self):
|
|
13
|
+
"""Test friendly message for NotFound exception"""
|
|
14
|
+
exc = exceptions.NotFound("Resource not found")
|
|
15
|
+
message = get_friendly_error(exc)
|
|
16
|
+
assert message == ERROR_MESSAGES['not_found']
|
|
17
|
+
assert "not found" in message.lower()
|
|
18
|
+
|
|
19
|
+
def test_permission_denied_exception(self):
|
|
20
|
+
"""Test friendly message for PermissionDenied exception"""
|
|
21
|
+
exc = exceptions.PermissionDenied("Permission denied")
|
|
22
|
+
message = get_friendly_error(exc)
|
|
23
|
+
assert message == ERROR_MESSAGES['permission_denied']
|
|
24
|
+
assert "permission" in message.lower()
|
|
25
|
+
|
|
26
|
+
def test_invalid_argument_exception(self):
|
|
27
|
+
"""Test friendly message for InvalidArgument exception"""
|
|
28
|
+
exc = exceptions.InvalidArgument("Invalid argument")
|
|
29
|
+
message = get_friendly_error(exc)
|
|
30
|
+
assert message == ERROR_MESSAGES['invalid_argument']
|
|
31
|
+
assert "invalid" in message.lower()
|
|
32
|
+
|
|
33
|
+
def test_unauthenticated_exception(self):
|
|
34
|
+
"""Test friendly message for Unauthenticated exception"""
|
|
35
|
+
exc = exceptions.Unauthenticated("Authentication failed")
|
|
36
|
+
message = get_friendly_error(exc)
|
|
37
|
+
assert message == ERROR_MESSAGES['unauthenticated']
|
|
38
|
+
assert "authentication" in message.lower()
|
|
39
|
+
|
|
40
|
+
def test_resource_exhausted_exception(self):
|
|
41
|
+
"""Test friendly message for ResourceExhausted exception"""
|
|
42
|
+
exc = exceptions.ResourceExhausted("Rate limit exceeded")
|
|
43
|
+
message = get_friendly_error(exc)
|
|
44
|
+
assert message == ERROR_MESSAGES['resource_exhausted']
|
|
45
|
+
assert "rate limit" in message.lower()
|
|
46
|
+
|
|
47
|
+
def test_internal_server_error_exception(self):
|
|
48
|
+
"""Test friendly message for InternalServerError exception"""
|
|
49
|
+
exc = exceptions.InternalServerError("Internal server error")
|
|
50
|
+
message = get_friendly_error(exc)
|
|
51
|
+
assert message == ERROR_MESSAGES['internal']
|
|
52
|
+
assert "internal" in message.lower() or "server" in message.lower()
|
|
53
|
+
|
|
54
|
+
def test_service_unavailable_exception(self):
|
|
55
|
+
"""Test friendly message for ServiceUnavailable exception"""
|
|
56
|
+
exc = exceptions.ServiceUnavailable("Service unavailable")
|
|
57
|
+
message = get_friendly_error(exc)
|
|
58
|
+
assert message == ERROR_MESSAGES['unavailable']
|
|
59
|
+
assert "unavailable" in message.lower()
|
|
60
|
+
|
|
61
|
+
def test_deadline_exceeded_exception(self):
|
|
62
|
+
"""Test friendly message for DeadlineExceeded exception"""
|
|
63
|
+
exc = exceptions.DeadlineExceeded("Deadline exceeded")
|
|
64
|
+
message = get_friendly_error(exc)
|
|
65
|
+
assert message == ERROR_MESSAGES['deadline_exceeded']
|
|
66
|
+
assert "timeout" in message.lower() or "deadline" in message.lower()
|
|
67
|
+
|
|
68
|
+
def test_unknown_exception(self):
|
|
69
|
+
"""Test handling of unknown exception types"""
|
|
70
|
+
exc = Exception("Unknown error")
|
|
71
|
+
message = get_friendly_error(exc)
|
|
72
|
+
assert "An error occurred" in message
|
|
73
|
+
assert "Unknown error" in message
|