colacloud-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.
@@ -0,0 +1,3 @@
1
+ """COLA Cloud CLI - Command-line interface for the COLA Cloud API."""
2
+
3
+ __version__ = "0.1.0"
colacloud_cli/api.py ADDED
@@ -0,0 +1,337 @@
1
+ """COLA Cloud API client wrapper."""
2
+
3
+ from typing import Any, Optional
4
+
5
+ import httpx
6
+
7
+ from colacloud_cli import __version__
8
+ from colacloud_cli.config import get_config
9
+
10
+
11
+ class APIError(Exception):
12
+ """Exception raised for API errors."""
13
+
14
+ def __init__(
15
+ self,
16
+ message: str,
17
+ status_code: Optional[int] = None,
18
+ error_code: Optional[str] = None,
19
+ details: Optional[dict] = None,
20
+ ):
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.status_code = status_code
24
+ self.error_code = error_code
25
+ self.details = details or {}
26
+
27
+ def __str__(self) -> str:
28
+ parts = [self.message]
29
+ if self.status_code:
30
+ parts.insert(0, f"[{self.status_code}]")
31
+ if self.error_code:
32
+ parts.insert(0, f"({self.error_code})")
33
+ return " ".join(parts)
34
+
35
+
36
+ class AuthenticationError(APIError):
37
+ """Exception raised for authentication errors."""
38
+
39
+ pass
40
+
41
+
42
+ class RateLimitError(APIError):
43
+ """Exception raised when rate limit is exceeded."""
44
+
45
+ def __init__(
46
+ self,
47
+ message: str,
48
+ retry_after: Optional[int] = None,
49
+ **kwargs,
50
+ ):
51
+ super().__init__(message, **kwargs)
52
+ self.retry_after = retry_after
53
+
54
+
55
+ class ColaCloudClient:
56
+ """HTTP client for the COLA Cloud API."""
57
+
58
+ def __init__(
59
+ self,
60
+ api_key: Optional[str] = None,
61
+ base_url: Optional[str] = None,
62
+ timeout: float = 30.0,
63
+ ):
64
+ """Initialize the API client.
65
+
66
+ Args:
67
+ api_key: API key for authentication. If not provided,
68
+ will be read from config.
69
+ base_url: API base URL. If not provided, will use default.
70
+ timeout: Request timeout in seconds.
71
+ """
72
+ config = get_config()
73
+ self.api_key = api_key or config.get_api_key()
74
+ self.base_url = (base_url or config.get_api_base_url()).rstrip("/")
75
+ self.timeout = timeout
76
+
77
+ self._client = httpx.Client(
78
+ base_url=self.base_url,
79
+ timeout=timeout,
80
+ headers=self._get_headers(),
81
+ )
82
+
83
+ def _get_headers(self) -> dict[str, str]:
84
+ """Get request headers including authentication."""
85
+ headers = {
86
+ "Accept": "application/json",
87
+ "User-Agent": f"colacloud-cli/{__version__}",
88
+ }
89
+ if self.api_key:
90
+ headers["X-API-Key"] = self.api_key
91
+ return headers
92
+
93
+ def _handle_response(self, response: httpx.Response) -> dict[str, Any]:
94
+ """Handle API response and raise appropriate exceptions.
95
+
96
+ Args:
97
+ response: HTTP response object.
98
+
99
+ Returns:
100
+ Parsed JSON response data.
101
+
102
+ Raises:
103
+ AuthenticationError: If authentication fails.
104
+ RateLimitError: If rate limit is exceeded.
105
+ APIError: For other API errors.
106
+ """
107
+ # Try to parse JSON response
108
+ try:
109
+ data = response.json()
110
+ except (ValueError, httpx.DecodingError):
111
+ data = {}
112
+
113
+ # Handle successful responses
114
+ if response.is_success:
115
+ return data
116
+
117
+ # Extract error information
118
+ error_info = data.get("error", {})
119
+ error_code = error_info.get("code", "unknown_error")
120
+ error_message = error_info.get("message", response.reason_phrase or "Unknown error")
121
+ error_details = error_info.get("details", {})
122
+
123
+ # Handle specific error types
124
+ if response.status_code == 401:
125
+ raise AuthenticationError(
126
+ error_message,
127
+ status_code=response.status_code,
128
+ error_code=error_code,
129
+ details=error_details,
130
+ )
131
+
132
+ if response.status_code == 429:
133
+ retry_after = None
134
+ if "Retry-After" in response.headers:
135
+ try:
136
+ retry_after = int(response.headers["Retry-After"])
137
+ except ValueError:
138
+ pass
139
+
140
+ raise RateLimitError(
141
+ error_message,
142
+ status_code=response.status_code,
143
+ error_code=error_code,
144
+ details=error_details,
145
+ retry_after=retry_after,
146
+ )
147
+
148
+ raise APIError(
149
+ error_message,
150
+ status_code=response.status_code,
151
+ error_code=error_code,
152
+ details=error_details,
153
+ )
154
+
155
+ def _require_api_key(self) -> None:
156
+ """Ensure API key is configured.
157
+
158
+ Raises:
159
+ AuthenticationError: If API key is not configured.
160
+ """
161
+ if not self.api_key:
162
+ raise AuthenticationError(
163
+ "API key not configured. Run 'cola config set-key' to set your API key, "
164
+ "or set the COLACLOUD_API_KEY environment variable."
165
+ )
166
+
167
+ def close(self) -> None:
168
+ """Close the HTTP client."""
169
+ self._client.close()
170
+
171
+ def __enter__(self) -> "ColaCloudClient":
172
+ return self
173
+
174
+ def __exit__(self, *args) -> None:
175
+ self.close()
176
+
177
+ # COLA endpoints
178
+
179
+ def list_colas(
180
+ self,
181
+ query: Optional[str] = None,
182
+ product_type: Optional[str] = None,
183
+ origin: Optional[str] = None,
184
+ brand_name: Optional[str] = None,
185
+ approval_date_from: Optional[str] = None,
186
+ approval_date_to: Optional[str] = None,
187
+ abv_min: Optional[float] = None,
188
+ abv_max: Optional[float] = None,
189
+ page: int = 1,
190
+ per_page: int = 20,
191
+ ) -> dict[str, Any]:
192
+ """Search and filter COLAs.
193
+
194
+ Args:
195
+ query: Full-text search query.
196
+ product_type: Filter by product type (malt beverage, wine, distilled spirits).
197
+ origin: Filter by country/state.
198
+ brand_name: Filter by brand name (partial match).
199
+ approval_date_from: Filter by minimum approval date (YYYY-MM-DD).
200
+ approval_date_to: Filter by maximum approval date (YYYY-MM-DD).
201
+ abv_min: Filter by minimum ABV.
202
+ abv_max: Filter by maximum ABV.
203
+ page: Page number.
204
+ per_page: Results per page (max 100).
205
+
206
+ Returns:
207
+ API response with data and pagination info.
208
+ """
209
+ self._require_api_key()
210
+
211
+ params = {"page": page, "per_page": per_page}
212
+
213
+ if query:
214
+ params["q"] = query
215
+ if product_type:
216
+ params["product_type"] = product_type
217
+ if origin:
218
+ params["origin"] = origin
219
+ if brand_name:
220
+ params["brand_name"] = brand_name
221
+ if approval_date_from:
222
+ params["approval_date_from"] = approval_date_from
223
+ if approval_date_to:
224
+ params["approval_date_to"] = approval_date_to
225
+ if abv_min is not None:
226
+ params["abv_min"] = abv_min
227
+ if abv_max is not None:
228
+ params["abv_max"] = abv_max
229
+
230
+ response = self._client.get("/colas", params=params)
231
+ return self._handle_response(response)
232
+
233
+ def get_cola(self, ttb_id: str) -> dict[str, Any]:
234
+ """Get a single COLA by TTB ID.
235
+
236
+ Args:
237
+ ttb_id: The TTB ID of the COLA.
238
+
239
+ Returns:
240
+ API response with COLA details.
241
+ """
242
+ self._require_api_key()
243
+
244
+ response = self._client.get(f"/colas/{ttb_id}")
245
+ return self._handle_response(response)
246
+
247
+ # Permittee endpoints
248
+
249
+ def list_permittees(
250
+ self,
251
+ query: Optional[str] = None,
252
+ state: Optional[str] = None,
253
+ is_active: Optional[bool] = None,
254
+ page: int = 1,
255
+ per_page: int = 20,
256
+ ) -> dict[str, Any]:
257
+ """Search permittees.
258
+
259
+ Args:
260
+ query: Search by company name (partial match).
261
+ state: Filter by state.
262
+ is_active: Filter by active status.
263
+ page: Page number.
264
+ per_page: Results per page (max 100).
265
+
266
+ Returns:
267
+ API response with data and pagination info.
268
+ """
269
+ self._require_api_key()
270
+
271
+ params = {"page": page, "per_page": per_page}
272
+
273
+ if query:
274
+ params["q"] = query
275
+ if state:
276
+ params["state"] = state.upper()
277
+ if is_active is not None:
278
+ params["is_active"] = "true" if is_active else "false"
279
+
280
+ response = self._client.get("/permittees", params=params)
281
+ return self._handle_response(response)
282
+
283
+ def get_permittee(self, permit_number: str) -> dict[str, Any]:
284
+ """Get a single permittee by permit number.
285
+
286
+ Args:
287
+ permit_number: The permit number.
288
+
289
+ Returns:
290
+ API response with permittee details.
291
+ """
292
+ self._require_api_key()
293
+
294
+ response = self._client.get(f"/permittees/{permit_number}")
295
+ return self._handle_response(response)
296
+
297
+ # Barcode endpoint
298
+
299
+ def lookup_barcode(self, barcode_value: str) -> dict[str, Any]:
300
+ """Look up COLAs by barcode.
301
+
302
+ Args:
303
+ barcode_value: The barcode value (UPC, EAN, etc.).
304
+
305
+ Returns:
306
+ API response with barcode info and associated COLAs.
307
+ """
308
+ self._require_api_key()
309
+
310
+ response = self._client.get(f"/barcode/{barcode_value}")
311
+ return self._handle_response(response)
312
+
313
+ # Usage endpoint
314
+
315
+ def get_usage(self) -> dict[str, Any]:
316
+ """Get current API usage statistics.
317
+
318
+ Returns:
319
+ API response with usage data.
320
+ """
321
+ self._require_api_key()
322
+
323
+ response = self._client.get("/usage")
324
+ return self._handle_response(response)
325
+
326
+
327
+ # Convenience function to get a client instance
328
+ def get_client(**kwargs) -> ColaCloudClient:
329
+ """Get a ColaCloudClient instance.
330
+
331
+ Args:
332
+ **kwargs: Arguments to pass to ColaCloudClient constructor.
333
+
334
+ Returns:
335
+ ColaCloudClient instance.
336
+ """
337
+ return ColaCloudClient(**kwargs)
@@ -0,0 +1 @@
1
+ """CLI command modules."""
@@ -0,0 +1,48 @@
1
+ """Barcode lookup command for COLA Cloud CLI."""
2
+
3
+ import json
4
+
5
+ import click
6
+
7
+ from colacloud_cli.api import APIError, get_client
8
+ from colacloud_cli.commands.utils import console, handle_api_error
9
+ from colacloud_cli.formatters import format_barcode_result
10
+
11
+
12
+ @click.command(name="barcode")
13
+ @click.argument("value")
14
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
15
+ def barcode_command(value: str, as_json: bool):
16
+ """Look up COLAs by barcode (UPC, EAN, etc.).
17
+
18
+ VALUE is the barcode number from the product label.
19
+
20
+ This command searches the COLA database for products that have been
21
+ registered with the specified barcode.
22
+
23
+ Examples:
24
+
25
+ \b
26
+ # Look up a UPC barcode
27
+ cola barcode 012345678901
28
+
29
+ \b
30
+ # Look up an EAN barcode
31
+ cola barcode 5000281025155
32
+
33
+ \b
34
+ # Output as JSON
35
+ cola barcode 012345678901 --json
36
+ """
37
+ try:
38
+ with get_client() as client:
39
+ result = client.lookup_barcode(value)
40
+
41
+ if as_json:
42
+ click.echo(json.dumps(result, indent=2))
43
+ else:
44
+ data = result.get("data", {})
45
+ format_barcode_result(data, console)
46
+
47
+ except APIError as e:
48
+ handle_api_error(e)
@@ -0,0 +1,179 @@
1
+ """COLA commands for COLA Cloud CLI."""
2
+
3
+ import json
4
+
5
+ import click
6
+
7
+ from colacloud_cli.api import APIError, get_client
8
+ from colacloud_cli.commands.utils import console, handle_api_error
9
+ from colacloud_cli.formatters import (
10
+ format_cola_detail,
11
+ format_cola_table,
12
+ format_pagination,
13
+ )
14
+
15
+
16
+ @click.group(name="colas")
17
+ def colas_group():
18
+ """Search and retrieve COLA records."""
19
+ pass
20
+
21
+
22
+ @colas_group.command(name="list")
23
+ @click.option("-q", "--query", help="Full-text search query.")
24
+ @click.option(
25
+ "--product-type",
26
+ type=click.Choice(["malt beverage", "wine", "distilled spirits"], case_sensitive=False),
27
+ help="Filter by product type.",
28
+ )
29
+ @click.option("--origin", help="Filter by origin (country/state).")
30
+ @click.option("--brand", "brand_name", help="Filter by brand name (partial match).")
31
+ @click.option("--date-from", "approval_date_from", help="Filter by minimum approval date (YYYY-MM-DD).")
32
+ @click.option("--date-to", "approval_date_to", help="Filter by maximum approval date (YYYY-MM-DD).")
33
+ @click.option("--abv-min", type=float, help="Filter by minimum ABV.")
34
+ @click.option("--abv-max", type=float, help="Filter by maximum ABV.")
35
+ @click.option("--limit", "per_page", default=20, type=int, help="Results per page (max 100).")
36
+ @click.option("--page", default=1, type=int, help="Page number.")
37
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
38
+ def list_colas(
39
+ query: str | None,
40
+ product_type: str | None,
41
+ origin: str | None,
42
+ brand_name: str | None,
43
+ approval_date_from: str | None,
44
+ approval_date_to: str | None,
45
+ abv_min: float | None,
46
+ abv_max: float | None,
47
+ per_page: int,
48
+ page: int,
49
+ as_json: bool,
50
+ ):
51
+ """List and search COLA records.
52
+
53
+ Examples:
54
+
55
+ \b
56
+ # Search for bourbon
57
+ cola colas list -q "bourbon"
58
+
59
+ \b
60
+ # List wines from California
61
+ cola colas list --product-type wine --origin california
62
+
63
+ \b
64
+ # Find high-ABV spirits
65
+ cola colas list --product-type "distilled spirits" --abv-min 50
66
+
67
+ \b
68
+ # Search by brand
69
+ cola colas list --brand "buffalo trace"
70
+ """
71
+ try:
72
+ with get_client() as client:
73
+ result = client.list_colas(
74
+ query=query,
75
+ product_type=product_type,
76
+ origin=origin,
77
+ brand_name=brand_name,
78
+ approval_date_from=approval_date_from,
79
+ approval_date_to=approval_date_to,
80
+ abv_min=abv_min,
81
+ abv_max=abv_max,
82
+ page=page,
83
+ per_page=min(per_page, 100),
84
+ )
85
+
86
+ if as_json:
87
+ click.echo(json.dumps(result, indent=2))
88
+ else:
89
+ colas = result.get("data", [])
90
+ pagination = result.get("pagination", {})
91
+
92
+ if not colas:
93
+ console.print("[yellow]No COLAs found matching your criteria.[/]")
94
+ return
95
+
96
+ table = format_cola_table(colas, console)
97
+ console.print(table)
98
+ format_pagination(pagination, console)
99
+
100
+ except APIError as e:
101
+ handle_api_error(e)
102
+
103
+
104
+ @colas_group.command(name="get")
105
+ @click.argument("ttb_id")
106
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
107
+ def get_cola(ttb_id: str, as_json: bool):
108
+ """Get detailed information about a specific COLA.
109
+
110
+ TTB_ID is the unique identifier for the COLA (e.g., 24001234).
111
+
112
+ Examples:
113
+
114
+ \b
115
+ # Get COLA details
116
+ cola colas get 24001234
117
+
118
+ \b
119
+ # Output as JSON
120
+ cola colas get 24001234 --json
121
+ """
122
+ try:
123
+ with get_client() as client:
124
+ result = client.get_cola(ttb_id)
125
+
126
+ if as_json:
127
+ click.echo(json.dumps(result, indent=2))
128
+ else:
129
+ cola = result.get("data", {})
130
+ format_cola_detail(cola, console)
131
+
132
+ except APIError as e:
133
+ handle_api_error(e)
134
+
135
+
136
+ @colas_group.command(name="search")
137
+ @click.argument("query")
138
+ @click.option("--limit", "per_page", default=20, type=int, help="Results per page (max 100).")
139
+ @click.option("--page", default=1, type=int, help="Page number.")
140
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
141
+ def search_colas(query: str, per_page: int, page: int, as_json: bool):
142
+ """Quick search for COLAs.
143
+
144
+ This is a shortcut for 'cola colas list -q <query>'.
145
+
146
+ Examples:
147
+
148
+ \b
149
+ # Search for whiskey
150
+ cola colas search "whiskey"
151
+
152
+ \b
153
+ # Search for a specific brand
154
+ cola colas search "buffalo trace"
155
+ """
156
+ try:
157
+ with get_client() as client:
158
+ result = client.list_colas(
159
+ query=query,
160
+ page=page,
161
+ per_page=min(per_page, 100),
162
+ )
163
+
164
+ if as_json:
165
+ click.echo(json.dumps(result, indent=2))
166
+ else:
167
+ colas = result.get("data", [])
168
+ pagination = result.get("pagination", {})
169
+
170
+ if not colas:
171
+ console.print(f"[yellow]No COLAs found for '{query}'.[/]")
172
+ return
173
+
174
+ table = format_cola_table(colas, console)
175
+ console.print(table)
176
+ format_pagination(pagination, console)
177
+
178
+ except APIError as e:
179
+ handle_api_error(e)
@@ -0,0 +1,80 @@
1
+ """Configuration commands for COLA Cloud CLI."""
2
+
3
+ import json
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from colacloud_cli.config import get_config
9
+ from colacloud_cli.formatters import format_config
10
+
11
+ console = Console()
12
+
13
+
14
+ @click.group(name="config")
15
+ def config_group():
16
+ """Manage CLI configuration."""
17
+ pass
18
+
19
+
20
+ @config_group.command(name="set-key")
21
+ @click.option(
22
+ "--key",
23
+ "-k",
24
+ help="API key to set. If not provided, will prompt for input.",
25
+ )
26
+ def set_key(key: str | None):
27
+ """Set your COLA Cloud API key.
28
+
29
+ The API key will be saved to ~/.colacloud/config.json with restricted
30
+ permissions (readable only by you).
31
+
32
+ You can also set the COLACLOUD_API_KEY environment variable instead.
33
+ """
34
+ config = get_config()
35
+
36
+ if key is None:
37
+ # Prompt for API key with hidden input
38
+ key = click.prompt(
39
+ "Enter your API key",
40
+ hide_input=True,
41
+ confirmation_prompt=True,
42
+ )
43
+
44
+ if not key or not key.strip():
45
+ console.print("[red]Error:[/] API key cannot be empty.")
46
+ raise SystemExit(1)
47
+
48
+ key = key.strip()
49
+ config.set_api_key(key)
50
+
51
+ console.print("[green]Success![/] API key saved to ~/.colacloud/config.json")
52
+ console.print("[dim]The config file has been set to mode 600 (owner read/write only).[/]")
53
+
54
+
55
+ @config_group.command(name="show")
56
+ @click.option("--json", "as_json", is_flag=True, help="Output as JSON.")
57
+ def show(as_json: bool):
58
+ """Show current configuration.
59
+
60
+ API key will be partially masked for security.
61
+ """
62
+ config = get_config()
63
+ config_data = config.to_dict()
64
+
65
+ if as_json:
66
+ click.echo(json.dumps(config_data, indent=2))
67
+ else:
68
+ format_config(config_data, console)
69
+
70
+
71
+ @config_group.command(name="clear")
72
+ @click.confirmation_option(prompt="Are you sure you want to clear your configuration?")
73
+ def clear():
74
+ """Clear all saved configuration.
75
+
76
+ This will remove your saved API key and any other settings.
77
+ """
78
+ config = get_config()
79
+ config.clear()
80
+ console.print("[green]Configuration cleared.[/]")