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.
@@ -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,9 @@
1
+ """JSON formatter"""
2
+
3
+ import json
4
+ import click
5
+
6
+
7
+ def format_json(data):
8
+ """Format data as JSON"""
9
+ click.echo(json.dumps(data, indent=2))
@@ -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)
@@ -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]")