ga4-cli 0.1.1__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.1.dist-info/METADATA +308 -0
- ga4_cli-0.1.1.dist-info/RECORD +32 -0
- ga4_cli-0.1.1.dist-info/WHEEL +5 -0
- ga4_cli-0.1.1.dist-info/entry_points.txt +2 -0
- ga4_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
- ga4_cli-0.1.1.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
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Data stream management commands"""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from google.analytics.admin_v1alpha.types import DataStream
|
|
5
|
+
from ga_cli.decorators import with_client
|
|
6
|
+
from ga_cli.formatters.table import format_table
|
|
7
|
+
from ga_cli.formatters.json import format_json
|
|
8
|
+
from ga_cli.validators import validate_property_id, validate_stream_id, validate_url
|
|
9
|
+
from ga_cli.logging_config import logger
|
|
10
|
+
from ga_cli.retry import retry_on_transient_error
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def datastreams():
|
|
15
|
+
"""Manage data streams"""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@datastreams.command()
|
|
20
|
+
@click.argument('property_id', callback=validate_property_id)
|
|
21
|
+
@click.option('--format', type=click.Choice(['table', 'json']), default='table')
|
|
22
|
+
@click.pass_context
|
|
23
|
+
@with_client
|
|
24
|
+
def list(ctx, property_id, format):
|
|
25
|
+
"""List data streams for a property"""
|
|
26
|
+
client = ctx.obj['client']
|
|
27
|
+
logger.info(f"Listing data streams for property: {property_id}")
|
|
28
|
+
|
|
29
|
+
streams_data = []
|
|
30
|
+
for stream in _list_datastreams_with_retry(client, property_id):
|
|
31
|
+
stream_info = {
|
|
32
|
+
'id': stream.name.split('/')[-1] if '/' in stream.name else stream.name,
|
|
33
|
+
'name': stream.display_name or 'N/A',
|
|
34
|
+
'type': stream.type_.name if stream.type_ else 'N/A',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# Add stream-type specific data with null checks
|
|
38
|
+
if stream.web_stream_data:
|
|
39
|
+
stream_info['measurement_id'] = stream.web_stream_data.measurement_id or 'N/A'
|
|
40
|
+
stream_info['url'] = stream.web_stream_data.default_uri or 'N/A'
|
|
41
|
+
elif stream.android_app_stream_data:
|
|
42
|
+
stream_info['package_name'] = stream.android_app_stream_data.package_name or 'N/A'
|
|
43
|
+
elif stream.ios_app_stream_data:
|
|
44
|
+
stream_info['bundle_id'] = stream.ios_app_stream_data.bundle_id or 'N/A'
|
|
45
|
+
|
|
46
|
+
streams_data.append(stream_info)
|
|
47
|
+
|
|
48
|
+
logger.info(f"Found {len(streams_data)} data streams")
|
|
49
|
+
|
|
50
|
+
if format == 'json':
|
|
51
|
+
format_json(streams_data)
|
|
52
|
+
else:
|
|
53
|
+
format_table(streams_data, title=f"Data Streams for Property {property_id}")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@datastreams.command()
|
|
57
|
+
@click.argument('property_id', callback=validate_property_id)
|
|
58
|
+
@click.argument('stream_id', callback=validate_stream_id)
|
|
59
|
+
@click.option('--format', type=click.Choice(['table', 'json']), default='table')
|
|
60
|
+
@click.pass_context
|
|
61
|
+
@with_client
|
|
62
|
+
def get(ctx, property_id, stream_id, format):
|
|
63
|
+
"""Get data stream details including measurement ID"""
|
|
64
|
+
client = ctx.obj['client']
|
|
65
|
+
logger.info(f"Getting data stream {stream_id} for property: {property_id}")
|
|
66
|
+
|
|
67
|
+
stream = _get_datastream_with_retry(client, property_id, stream_id)
|
|
68
|
+
|
|
69
|
+
stream_data = {
|
|
70
|
+
'id': stream.name.split('/')[-1] if '/' in stream.name else stream.name,
|
|
71
|
+
'name': stream.display_name or 'N/A',
|
|
72
|
+
'type': stream.type_.name if stream.type_ else 'N/A',
|
|
73
|
+
'create_time': str(stream.create_time).split('.')[0] if stream.create_time else 'N/A',
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# Add web stream specific data with null checks
|
|
77
|
+
if stream.web_stream_data:
|
|
78
|
+
stream_data['measurement_id'] = stream.web_stream_data.measurement_id or 'N/A'
|
|
79
|
+
stream_data['url'] = stream.web_stream_data.default_uri or 'N/A'
|
|
80
|
+
stream_data['firebase_app_id'] = stream.web_stream_data.firebase_app_id or 'N/A'
|
|
81
|
+
|
|
82
|
+
logger.info(f"Retrieved data stream: {stream.display_name}")
|
|
83
|
+
|
|
84
|
+
if format == 'json':
|
|
85
|
+
format_json(stream_data)
|
|
86
|
+
else:
|
|
87
|
+
format_table([stream_data], title=f"Data Stream: {stream.display_name}")
|
|
88
|
+
|
|
89
|
+
if stream.web_stream_data and stream.web_stream_data.measurement_id:
|
|
90
|
+
click.echo(f"\nMeasurement ID: {stream.web_stream_data.measurement_id}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@datastreams.command()
|
|
94
|
+
@click.argument('property_id', callback=validate_property_id)
|
|
95
|
+
@click.option('--name', required=True, help='Display name for the data stream')
|
|
96
|
+
@click.option('--url', required=True, callback=validate_url, help='Website URL')
|
|
97
|
+
@click.pass_context
|
|
98
|
+
@with_client
|
|
99
|
+
def create(ctx, property_id, name, url):
|
|
100
|
+
"""Create a new web data stream"""
|
|
101
|
+
client = ctx.obj['client']
|
|
102
|
+
logger.info(f"Creating data stream '{name}' for property: {property_id}")
|
|
103
|
+
|
|
104
|
+
stream = _create_datastream_with_retry(client, property_id, name, url)
|
|
105
|
+
|
|
106
|
+
stream_id = stream.name.split('/')[-1] if '/' in stream.name else stream.name
|
|
107
|
+
logger.info(f"Created data stream: {stream_id}")
|
|
108
|
+
|
|
109
|
+
click.echo(f"Created data stream: {stream.display_name}")
|
|
110
|
+
click.echo(f" Stream ID: {stream_id}")
|
|
111
|
+
if stream.web_stream_data:
|
|
112
|
+
if stream.web_stream_data.measurement_id:
|
|
113
|
+
click.echo(f" Measurement ID: {stream.web_stream_data.measurement_id}")
|
|
114
|
+
if stream.web_stream_data.default_uri:
|
|
115
|
+
click.echo(f" URL: {stream.web_stream_data.default_uri}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@retry_on_transient_error()
|
|
119
|
+
def _list_datastreams_with_retry(client, property_id):
|
|
120
|
+
"""List data streams with retry logic"""
|
|
121
|
+
return list(client.list_data_streams(parent=f"properties/{property_id}"))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@retry_on_transient_error()
|
|
125
|
+
def _get_datastream_with_retry(client, property_id, stream_id):
|
|
126
|
+
"""Get data stream with retry logic"""
|
|
127
|
+
return client.get_data_stream(name=f"properties/{property_id}/dataStreams/{stream_id}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@retry_on_transient_error()
|
|
131
|
+
def _create_datastream_with_retry(client, property_id, name, url):
|
|
132
|
+
"""Create data stream with retry logic"""
|
|
133
|
+
data_stream = DataStream(
|
|
134
|
+
display_name=name,
|
|
135
|
+
type_=DataStream.DataStreamType.WEB_DATA_STREAM,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Safely initialize web_stream_data if not exists
|
|
139
|
+
if data_stream.web_stream_data is None:
|
|
140
|
+
data_stream.web_stream_data = DataStream.WebStreamData()
|
|
141
|
+
data_stream.web_stream_data.default_uri = url
|
|
142
|
+
|
|
143
|
+
return client.create_data_stream(
|
|
144
|
+
parent=f"properties/{property_id}",
|
|
145
|
+
data_stream=data_stream
|
|
146
|
+
)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Property management commands"""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from google.analytics.admin_v1alpha.types import Property
|
|
5
|
+
from ga_cli.decorators import with_client
|
|
6
|
+
from ga_cli.formatters.table import format_table
|
|
7
|
+
from ga_cli.formatters.json import format_json
|
|
8
|
+
from ga_cli.validators import validate_account_id, validate_property_id, validate_timezone, validate_currency
|
|
9
|
+
from ga_cli.logging_config import logger
|
|
10
|
+
from ga_cli.retry import retry_on_transient_error
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@click.group()
|
|
14
|
+
def properties():
|
|
15
|
+
"""Manage Google Analytics properties"""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@properties.command()
|
|
20
|
+
@click.argument('account_id', callback=validate_account_id)
|
|
21
|
+
@click.option('--format', type=click.Choice(['table', 'json']), default='table')
|
|
22
|
+
@click.pass_context
|
|
23
|
+
@with_client
|
|
24
|
+
def list(ctx, account_id, format):
|
|
25
|
+
"""List properties for an account"""
|
|
26
|
+
client = ctx.obj['client']
|
|
27
|
+
logger.info(f"Listing properties for account: {account_id}")
|
|
28
|
+
|
|
29
|
+
properties_data = []
|
|
30
|
+
request = {"filter": f"ancestor:accounts/{account_id}"}
|
|
31
|
+
for property in _list_properties_with_retry(client, request):
|
|
32
|
+
properties_data.append({
|
|
33
|
+
'id': property.name.split('/')[-1] if '/' in property.name else property.name,
|
|
34
|
+
'name': property.display_name or 'N/A',
|
|
35
|
+
'type': property.property_type.name if property.property_type else 'N/A',
|
|
36
|
+
'timezone': property.time_zone or 'N/A',
|
|
37
|
+
'currency': property.currency_code or 'N/A',
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
logger.info(f"Found {len(properties_data)} properties")
|
|
41
|
+
|
|
42
|
+
if format == 'json':
|
|
43
|
+
format_json(properties_data)
|
|
44
|
+
else:
|
|
45
|
+
format_table(properties_data, title=f"Properties for Account {account_id}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@properties.command()
|
|
49
|
+
@click.argument('property_id', callback=validate_property_id)
|
|
50
|
+
@click.option('--format', type=click.Choice(['table', 'json']), default='table')
|
|
51
|
+
@click.pass_context
|
|
52
|
+
@with_client
|
|
53
|
+
def get(ctx, property_id, format):
|
|
54
|
+
"""Get property details"""
|
|
55
|
+
client = ctx.obj['client']
|
|
56
|
+
logger.info(f"Getting property details for: {property_id}")
|
|
57
|
+
|
|
58
|
+
property = _get_property_with_retry(client, property_id)
|
|
59
|
+
|
|
60
|
+
property_data = {
|
|
61
|
+
'id': property.name.split('/')[-1] if '/' in property.name else property.name,
|
|
62
|
+
'name': property.display_name or 'N/A',
|
|
63
|
+
'type': property.property_type.name if property.property_type else 'N/A',
|
|
64
|
+
'timezone': property.time_zone or 'N/A',
|
|
65
|
+
'currency': property.currency_code or 'N/A',
|
|
66
|
+
'industry': property.industry_category.name if property.industry_category else 'N/A',
|
|
67
|
+
'create_time': str(property.create_time).split('.')[0] if property.create_time else 'N/A',
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
logger.info(f"Retrieved property: {property.display_name}")
|
|
71
|
+
|
|
72
|
+
if format == 'json':
|
|
73
|
+
format_json(property_data)
|
|
74
|
+
else:
|
|
75
|
+
format_table([property_data], title=f"Property: {property.display_name}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@properties.command()
|
|
79
|
+
@click.argument('account_id', callback=validate_account_id)
|
|
80
|
+
@click.option('--name', required=True, help='Display name for the property')
|
|
81
|
+
@click.option('--timezone', default='America/Los_Angeles', callback=validate_timezone,
|
|
82
|
+
help='Property timezone')
|
|
83
|
+
@click.option('--currency', default='USD', callback=validate_currency,
|
|
84
|
+
help='Property currency code (e.g., USD, EUR)')
|
|
85
|
+
@click.option('--industry', default='OTHER', help='Industry category')
|
|
86
|
+
@click.pass_context
|
|
87
|
+
@with_client
|
|
88
|
+
def create(ctx, account_id, name, timezone, currency, industry):
|
|
89
|
+
"""Create a new GA4 property"""
|
|
90
|
+
client = ctx.obj['client']
|
|
91
|
+
logger.info(f"Creating property '{name}' for account: {account_id}")
|
|
92
|
+
|
|
93
|
+
property = _create_property_with_retry(
|
|
94
|
+
client, account_id, name, timezone, currency, industry
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
property_id = property.name.split('/')[-1] if '/' in property.name else property.name
|
|
98
|
+
logger.info(f"Created property: {property_id}")
|
|
99
|
+
|
|
100
|
+
click.echo(f"Created property: {property.display_name}")
|
|
101
|
+
click.echo(f" Property ID: {property_id}")
|
|
102
|
+
click.echo(f" Timezone: {property.time_zone}")
|
|
103
|
+
click.echo(f" Currency: {property.currency_code}")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@properties.command()
|
|
107
|
+
@click.argument('property_id', callback=validate_property_id)
|
|
108
|
+
@click.confirmation_option(prompt='Are you sure you want to delete this property?')
|
|
109
|
+
@click.pass_context
|
|
110
|
+
@with_client
|
|
111
|
+
def delete(ctx, property_id):
|
|
112
|
+
"""Delete a property"""
|
|
113
|
+
client = ctx.obj['client']
|
|
114
|
+
logger.info(f"Deleting property: {property_id}")
|
|
115
|
+
|
|
116
|
+
_delete_property_with_retry(client, property_id)
|
|
117
|
+
|
|
118
|
+
logger.info(f"Deleted property: {property_id}")
|
|
119
|
+
click.echo(f"Property {property_id} deleted successfully")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@retry_on_transient_error()
|
|
123
|
+
def _list_properties_with_retry(client, request):
|
|
124
|
+
"""List properties with retry logic"""
|
|
125
|
+
return list(client.list_properties(request=request))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@retry_on_transient_error()
|
|
129
|
+
def _get_property_with_retry(client, property_id):
|
|
130
|
+
"""Get property with retry logic"""
|
|
131
|
+
return client.get_property(name=f"properties/{property_id}")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@retry_on_transient_error()
|
|
135
|
+
def _create_property_with_retry(client, account_id, name, timezone, currency, industry):
|
|
136
|
+
"""Create property with retry logic"""
|
|
137
|
+
return client.create_property(
|
|
138
|
+
property=Property(
|
|
139
|
+
parent=f"accounts/{account_id}",
|
|
140
|
+
display_name=name,
|
|
141
|
+
time_zone=timezone,
|
|
142
|
+
currency_code=currency,
|
|
143
|
+
industry_category=industry,
|
|
144
|
+
)
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@retry_on_transient_error()
|
|
149
|
+
def _delete_property_with_retry(client, property_id):
|
|
150
|
+
"""Delete property with retry logic"""
|
|
151
|
+
return client.delete_property(name=f"properties/{property_id}")
|
ga_cli/config.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Configuration management for GA CLI"""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import stat
|
|
5
|
+
import configparser
|
|
6
|
+
import click
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigManager:
|
|
11
|
+
"""Manages CLI configuration"""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self.config_dir = Path.home() / '.ga-cli'
|
|
15
|
+
self.config_file = self.config_dir / 'config.ini'
|
|
16
|
+
self.config = configparser.ConfigParser()
|
|
17
|
+
|
|
18
|
+
def ensure_config_dir(self):
|
|
19
|
+
"""Create config directory if it doesn't exist"""
|
|
20
|
+
self.config_dir.mkdir(exist_ok=True)
|
|
21
|
+
# Set restrictive permissions on config directory
|
|
22
|
+
os.chmod(self.config_dir, stat.S_IRWXU) # 700
|
|
23
|
+
|
|
24
|
+
def load(self):
|
|
25
|
+
"""Load configuration from file"""
|
|
26
|
+
if self.config_file.exists():
|
|
27
|
+
self.config.read(self.config_file)
|
|
28
|
+
|
|
29
|
+
def save(self):
|
|
30
|
+
"""Save configuration to file"""
|
|
31
|
+
self.ensure_config_dir()
|
|
32
|
+
with open(self.config_file, 'w') as f:
|
|
33
|
+
self.config.write(f)
|
|
34
|
+
# Set restrictive permissions on config file
|
|
35
|
+
os.chmod(self.config_file, stat.S_IRUSR | stat.S_IWUSR) # 600
|
|
36
|
+
|
|
37
|
+
def get_credentials_path(self):
|
|
38
|
+
"""Get stored credentials path"""
|
|
39
|
+
self.load()
|
|
40
|
+
return self.config.get('auth', 'credentials_path', fallback=None)
|
|
41
|
+
|
|
42
|
+
def set_credentials_path(self, path):
|
|
43
|
+
"""Store credentials path"""
|
|
44
|
+
# Validate credentials file before storing
|
|
45
|
+
if not self._validate_credentials_file(path):
|
|
46
|
+
raise click.ClickException("Invalid or insecure credentials file")
|
|
47
|
+
|
|
48
|
+
if 'auth' not in self.config:
|
|
49
|
+
self.config['auth'] = {}
|
|
50
|
+
self.config['auth']['credentials_path'] = path
|
|
51
|
+
self.save()
|
|
52
|
+
|
|
53
|
+
def _validate_credentials_file(self, path: str) -> bool:
|
|
54
|
+
"""Validate credentials file exists and has secure permissions
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
path: Path to credentials file
|
|
58
|
+
|
|
59
|
+
Returns:
|
|
60
|
+
True if valid and secure, False otherwise
|
|
61
|
+
"""
|
|
62
|
+
if not os.path.exists(path):
|
|
63
|
+
click.echo(f"Error: Credentials file not found: {path}", err=True)
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
# Check not world-readable
|
|
67
|
+
st = os.stat(path)
|
|
68
|
+
if st.st_mode & (stat.S_IROTH | stat.S_IWOTH):
|
|
69
|
+
click.echo(
|
|
70
|
+
"Warning: Credentials file is world-readable. "
|
|
71
|
+
"Consider running: chmod 600 " + path,
|
|
72
|
+
err=True
|
|
73
|
+
)
|
|
74
|
+
# Don't fail, just warn
|
|
75
|
+
|
|
76
|
+
# Check ownership (Unix only)
|
|
77
|
+
if os.name != 'nt' and st.st_uid != os.getuid():
|
|
78
|
+
click.echo("Warning: Credentials file not owned by current user", err=True)
|
|
79
|
+
# Don't fail, just warn
|
|
80
|
+
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
def get(self, section: str, key: str, fallback=None):
|
|
84
|
+
"""Get a configuration value
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
section: Configuration section
|
|
88
|
+
key: Configuration key
|
|
89
|
+
fallback: Default value if not found
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Configuration value or fallback
|
|
93
|
+
"""
|
|
94
|
+
self.load()
|
|
95
|
+
return self.config.get(section, key, fallback=fallback)
|
ga_cli/decorators.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Common decorators for CLI commands"""
|
|
2
|
+
|
|
3
|
+
import functools
|
|
4
|
+
import click
|
|
5
|
+
from ga_cli.auth import AuthManager
|
|
6
|
+
from ga_cli.logging_config import logger
|
|
7
|
+
from ga_cli.errors import get_friendly_error
|
|
8
|
+
from google.api_core import exceptions
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def with_client(func):
|
|
12
|
+
"""Decorator to provide authenticated API client
|
|
13
|
+
|
|
14
|
+
This decorator:
|
|
15
|
+
- Retrieves credentials from context or config
|
|
16
|
+
- Creates an authenticated API client
|
|
17
|
+
- Handles common API exceptions with user-friendly messages
|
|
18
|
+
- Adds logging for all operations
|
|
19
|
+
- Injects client into context for use by command
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
@accounts.command()
|
|
23
|
+
@click.pass_context
|
|
24
|
+
@with_client
|
|
25
|
+
def list(ctx):
|
|
26
|
+
client = ctx.obj['client']
|
|
27
|
+
# ... rest of logic
|
|
28
|
+
"""
|
|
29
|
+
@functools.wraps(func)
|
|
30
|
+
def wrapper(ctx, *args, **kwargs):
|
|
31
|
+
try:
|
|
32
|
+
# Get credentials path
|
|
33
|
+
credentials_path = ctx.obj.get('credentials')
|
|
34
|
+
if not credentials_path:
|
|
35
|
+
from ga_cli.config import ConfigManager
|
|
36
|
+
config_manager = ConfigManager()
|
|
37
|
+
credentials_path = config_manager.get('credentials', 'path')
|
|
38
|
+
|
|
39
|
+
if not credentials_path:
|
|
40
|
+
logger.error("No credentials configured")
|
|
41
|
+
raise click.ClickException(
|
|
42
|
+
"No credentials configured. Run 'ga-cli config init' first."
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
logger.debug(f"Using credentials: {credentials_path}")
|
|
46
|
+
|
|
47
|
+
# Create authenticated client
|
|
48
|
+
auth = AuthManager(credentials_path)
|
|
49
|
+
client = auth.get_client()
|
|
50
|
+
|
|
51
|
+
# Add client to context
|
|
52
|
+
ctx.obj['client'] = client
|
|
53
|
+
|
|
54
|
+
# Call the actual command
|
|
55
|
+
logger.info(f"Executing command: {func.__name__}")
|
|
56
|
+
return func(ctx, *args, **kwargs)
|
|
57
|
+
|
|
58
|
+
except exceptions.NotFound as e:
|
|
59
|
+
logger.error(f"Resource not found: {str(e)}")
|
|
60
|
+
click.echo(get_friendly_error(e), err=True)
|
|
61
|
+
raise click.Abort()
|
|
62
|
+
except exceptions.PermissionDenied as e:
|
|
63
|
+
logger.error(f"Permission denied: {str(e)}")
|
|
64
|
+
click.echo(get_friendly_error(e), err=True)
|
|
65
|
+
raise click.Abort()
|
|
66
|
+
except exceptions.Unauthenticated as e:
|
|
67
|
+
logger.error(f"Authentication failed: {str(e)}")
|
|
68
|
+
click.echo(get_friendly_error(e), err=True)
|
|
69
|
+
raise click.Abort()
|
|
70
|
+
except exceptions.InvalidArgument as e:
|
|
71
|
+
logger.error(f"Invalid argument: {str(e)}")
|
|
72
|
+
click.echo(get_friendly_error(e), err=True)
|
|
73
|
+
raise click.Abort()
|
|
74
|
+
except exceptions.ResourceExhausted as e:
|
|
75
|
+
logger.error(f"Rate limit exceeded: {str(e)}")
|
|
76
|
+
click.echo(get_friendly_error(e), err=True)
|
|
77
|
+
raise click.Abort()
|
|
78
|
+
except exceptions.GoogleAPIError as e:
|
|
79
|
+
logger.error(f"Google API error: {str(e)}")
|
|
80
|
+
click.echo(get_friendly_error(e), err=True)
|
|
81
|
+
raise click.Abort()
|
|
82
|
+
except click.ClickException:
|
|
83
|
+
# Re-raise Click exceptions as-is
|
|
84
|
+
raise
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.exception("Unexpected error")
|
|
87
|
+
click.echo(f"Error: {str(e)}", err=True)
|
|
88
|
+
raise click.Abort()
|
|
89
|
+
|
|
90
|
+
return wrapper
|
ga_cli/errors.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""User-friendly error messages for ga-cli"""
|
|
2
|
+
|
|
3
|
+
from google.api_core import exceptions
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
ERROR_MESSAGES = {
|
|
7
|
+
'not_found': 'Resource not found. Please check the ID and try again.',
|
|
8
|
+
'permission_denied': 'Permission denied. Ensure your service account has the required permissions.',
|
|
9
|
+
'invalid_argument': 'Invalid argument provided. Please check your input.',
|
|
10
|
+
'unauthenticated': 'Authentication failed. Run "ga-cli config init" to configure credentials.',
|
|
11
|
+
'resource_exhausted': 'Rate limit exceeded. Please wait a moment and try again.',
|
|
12
|
+
'internal': 'Internal server error. Please try again later.',
|
|
13
|
+
'unavailable': 'Service temporarily unavailable. Please try again later.',
|
|
14
|
+
'deadline_exceeded': 'Request timeout. Please try again.',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_friendly_error(exception):
|
|
19
|
+
"""Map API exceptions to user-friendly messages"""
|
|
20
|
+
error_map = {
|
|
21
|
+
exceptions.NotFound: ERROR_MESSAGES['not_found'],
|
|
22
|
+
exceptions.PermissionDenied: ERROR_MESSAGES['permission_denied'],
|
|
23
|
+
exceptions.InvalidArgument: ERROR_MESSAGES['invalid_argument'],
|
|
24
|
+
exceptions.Unauthenticated: ERROR_MESSAGES['unauthenticated'],
|
|
25
|
+
exceptions.ResourceExhausted: ERROR_MESSAGES['resource_exhausted'],
|
|
26
|
+
exceptions.InternalServerError: ERROR_MESSAGES['internal'],
|
|
27
|
+
exceptions.ServiceUnavailable: ERROR_MESSAGES['unavailable'],
|
|
28
|
+
exceptions.DeadlineExceeded: ERROR_MESSAGES['deadline_exceeded'],
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
for exc_type, message in error_map.items():
|
|
32
|
+
if isinstance(exception, exc_type):
|
|
33
|
+
return message
|
|
34
|
+
|
|
35
|
+
return f"An error occurred: {str(exception)}"
|
|
File without changes
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Table formatter using Rich"""
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def format_table(data, title=None):
|
|
8
|
+
"""Format list of dicts as a Rich table"""
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
if not data:
|
|
12
|
+
console.print("[yellow]No results found[/yellow]")
|
|
13
|
+
return
|
|
14
|
+
|
|
15
|
+
table = Table(show_header=True, header_style="bold magenta", title=title)
|
|
16
|
+
|
|
17
|
+
for key in data[0].keys():
|
|
18
|
+
table.add_column(key.replace('_', ' ').title())
|
|
19
|
+
|
|
20
|
+
for item in data:
|
|
21
|
+
table.add_row(*[str(v) for v in item.values()])
|
|
22
|
+
|
|
23
|
+
console.print(table)
|
ga_cli/logging_config.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Logging configuration for ga-cli"""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def setup_logging():
|
|
8
|
+
"""Configure logging for ga-cli"""
|
|
9
|
+
log_dir = Path.home() / '.ga-cli'
|
|
10
|
+
log_dir.mkdir(exist_ok=True)
|
|
11
|
+
|
|
12
|
+
log_file = log_dir / 'ga-cli.log'
|
|
13
|
+
|
|
14
|
+
# Create logger
|
|
15
|
+
logger = logging.getLogger('ga_cli')
|
|
16
|
+
logger.setLevel(logging.INFO)
|
|
17
|
+
|
|
18
|
+
# Prevent duplicate handlers
|
|
19
|
+
if logger.handlers:
|
|
20
|
+
return logger
|
|
21
|
+
|
|
22
|
+
# File handler
|
|
23
|
+
fh = logging.FileHandler(log_file)
|
|
24
|
+
fh.setLevel(logging.DEBUG)
|
|
25
|
+
|
|
26
|
+
# Console handler (only WARNING and above)
|
|
27
|
+
ch = logging.StreamHandler()
|
|
28
|
+
ch.setLevel(logging.WARNING)
|
|
29
|
+
|
|
30
|
+
# Formatter
|
|
31
|
+
formatter = logging.Formatter(
|
|
32
|
+
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
33
|
+
)
|
|
34
|
+
fh.setFormatter(formatter)
|
|
35
|
+
ch.setFormatter(formatter)
|
|
36
|
+
|
|
37
|
+
logger.addHandler(fh)
|
|
38
|
+
logger.addHandler(ch)
|
|
39
|
+
|
|
40
|
+
return logger
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Get logger singleton
|
|
44
|
+
logger = setup_logging()
|
ga_cli/output.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Output helper functions for consistent formatting"""
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import json
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def success_message(message: str, format_type: str = 'text'):
|
|
11
|
+
"""Display success message
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
message: Success message to display
|
|
15
|
+
format_type: Output format ('text' or 'json')
|
|
16
|
+
"""
|
|
17
|
+
if format_type == 'json':
|
|
18
|
+
click.echo(json.dumps({"success": True, "message": message}))
|
|
19
|
+
else:
|
|
20
|
+
console.print(f"[green]{message}[/green]")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def error_message(message: str, format_type: str = 'text'):
|
|
24
|
+
"""Display error message
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
message: Error message to display
|
|
28
|
+
format_type: Output format ('text' or 'json')
|
|
29
|
+
"""
|
|
30
|
+
if format_type == 'json':
|
|
31
|
+
click.echo(json.dumps({"success": False, "error": message}))
|
|
32
|
+
else:
|
|
33
|
+
console.print(f"[red]{message}[/red]", err=True)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def info_message(message: str, format_type: str = 'text'):
|
|
37
|
+
"""Display info message
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
message: Info message to display
|
|
41
|
+
format_type: Output format ('text' or 'json')
|
|
42
|
+
"""
|
|
43
|
+
if format_type == 'json':
|
|
44
|
+
click.echo(json.dumps({"info": message}))
|
|
45
|
+
else:
|
|
46
|
+
console.print(f"[blue]{message}[/blue]")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def warning_message(message: str, format_type: str = 'text'):
|
|
50
|
+
"""Display warning message
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
message: Warning message to display
|
|
54
|
+
format_type: Output format ('text' or 'json')
|
|
55
|
+
"""
|
|
56
|
+
if format_type == 'json':
|
|
57
|
+
click.echo(json.dumps({"warning": message}))
|
|
58
|
+
else:
|
|
59
|
+
console.print(f"[yellow]{message}[/yellow]")
|