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.
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