asa-api-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.
asa_api_cli/utils.py ADDED
@@ -0,0 +1,503 @@
1
+ """Shared utilities for CLI commands."""
2
+
3
+ import json
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from datetime import date
7
+ from enum import Enum
8
+ from pathlib import Path
9
+ from typing import Any, TypeVar
10
+
11
+ import typer
12
+ from asa_api_client import AppleSearchAdsClient
13
+ from asa_api_client.exceptions import AppleSearchAdsError, ConfigurationError
14
+ from rich.console import Console
15
+ from rich.panel import Panel
16
+ from rich.progress import (
17
+ BarColumn,
18
+ Progress,
19
+ SpinnerColumn,
20
+ TaskProgressColumn,
21
+ TextColumn,
22
+ TimeElapsedColumn,
23
+ )
24
+ from rich.syntax import Syntax
25
+ from rich.table import Table
26
+ from rich.text import Text
27
+ from rich.theme import Theme
28
+
29
+ # Custom theme for consistent styling
30
+ ASA_THEME = Theme(
31
+ {
32
+ "info": "cyan",
33
+ "success": "green",
34
+ "warning": "yellow",
35
+ "error": "red bold",
36
+ "highlight": "magenta",
37
+ "muted": "dim",
38
+ "header": "bold cyan",
39
+ "value": "white",
40
+ "label": "blue",
41
+ }
42
+ )
43
+
44
+ console = Console(theme=ASA_THEME)
45
+ error_console = Console(stderr=True, theme=ASA_THEME)
46
+
47
+ T = TypeVar("T")
48
+
49
+
50
+ def enum_value(v: Enum | str | bool) -> str:
51
+ """Get the value from an enum, string, or bool.
52
+
53
+ Since API models use `EnumType | str | bool` for forward compatibility,
54
+ this helper safely extracts the string value.
55
+ """
56
+ if isinstance(v, Enum):
57
+ return str(v.value)
58
+ elif isinstance(v, bool):
59
+ return "OPT_IN" if v else "OPT_OUT"
60
+ return str(v)
61
+
62
+
63
+ class OutputFormat(str, Enum):
64
+ """Output format options."""
65
+
66
+ TABLE = "table"
67
+ JSON = "json"
68
+ CSV = "csv"
69
+
70
+
71
+ def get_client() -> AppleSearchAdsClient:
72
+ """Get an authenticated client from environment variables.
73
+
74
+ Returns:
75
+ An authenticated AppleSearchAdsClient.
76
+
77
+ Raises:
78
+ typer.Exit: If configuration is invalid.
79
+ """
80
+ try:
81
+ return AppleSearchAdsClient.from_env()
82
+ except ConfigurationError as e:
83
+ print_error("Configuration Error", e.message)
84
+ error_console.print()
85
+ print_info_panel(
86
+ "Required Environment Variables",
87
+ "• ASA_CLIENT_ID\n• ASA_TEAM_ID\n• ASA_KEY_ID\n• ASA_ORG_ID\n• ASA_PRIVATE_KEY_PATH (or ASA_PRIVATE_KEY)",
88
+ )
89
+ raise typer.Exit(1) from None
90
+
91
+
92
+ def handle_api_error(e: AppleSearchAdsError) -> None:
93
+ """Handle and display an API error.
94
+
95
+ Args:
96
+ e: The exception to handle.
97
+ """
98
+ from asa_api_client.exceptions import ValidationError
99
+
100
+ details_parts = []
101
+
102
+ # Show request info if available (for debugging)
103
+ if e.response_body and "_request" in e.response_body:
104
+ details_parts.append(f"Request: {e.response_body['_request']}")
105
+
106
+ if e.status_code:
107
+ details_parts.append(f"Status code: {e.status_code}")
108
+
109
+ # Show field errors for validation errors
110
+ if isinstance(e, ValidationError) and e.field_errors:
111
+ for field, errors in e.field_errors.items():
112
+ for err in errors:
113
+ details_parts.append(f" {field}: {err}")
114
+
115
+ # Show response body if available (for debugging)
116
+ if e.response_body:
117
+ import json
118
+
119
+ # Remove internal _request field before displaying
120
+ body_copy = {k: v for k, v in e.response_body.items() if not k.startswith("_")}
121
+ body_str = json.dumps(body_copy, indent=2)
122
+ if len(body_str) < 500: # Only show if not too long
123
+ details_parts.append(f"Response: {body_str}")
124
+
125
+ details = "\n".join(details_parts) if details_parts else None
126
+ print_error("API Error", e.message, details=details)
127
+
128
+
129
+ # ============================================================================
130
+ # Styled Output Functions
131
+ # ============================================================================
132
+
133
+
134
+ def print_success(message: str, details: str | None = None) -> None:
135
+ """Print a success message with a checkmark.
136
+
137
+ Args:
138
+ message: The success message.
139
+ details: Optional additional details.
140
+ """
141
+ text = Text()
142
+ text.append("✓ ", style="success")
143
+ text.append(message)
144
+ if details:
145
+ text.append(f"\n {details}", style="muted")
146
+ console.print(text)
147
+
148
+
149
+ def print_error(title: str, message: str, details: str | None = None) -> None:
150
+ """Print an error message in a styled panel.
151
+
152
+ Args:
153
+ title: Error title.
154
+ message: Error message.
155
+ details: Optional additional details.
156
+ """
157
+ content = Text(message)
158
+ if details:
159
+ content.append(f"\n{details}", style="muted")
160
+
161
+ panel = Panel(
162
+ content,
163
+ title=f"[error]✗ {title}[/error]",
164
+ border_style="red",
165
+ padding=(0, 1),
166
+ )
167
+ error_console.print(panel)
168
+
169
+
170
+ def print_warning(message: str) -> None:
171
+ """Print a warning message.
172
+
173
+ Args:
174
+ message: The warning message.
175
+ """
176
+ text = Text()
177
+ text.append("⚠ ", style="warning")
178
+ text.append(message, style="warning")
179
+ console.print(text)
180
+
181
+
182
+ def print_info(message: str) -> None:
183
+ """Print an info message.
184
+
185
+ Args:
186
+ message: The info message.
187
+ """
188
+ text = Text()
189
+ text.append("ℹ ", style="info")
190
+ text.append(message)
191
+ console.print(text)
192
+
193
+
194
+ def print_info_panel(title: str, content: str) -> None:
195
+ """Print information in a styled panel.
196
+
197
+ Args:
198
+ title: Panel title.
199
+ content: Panel content.
200
+ """
201
+ panel = Panel(
202
+ content,
203
+ title=f"[info]{title}[/info]",
204
+ border_style="cyan",
205
+ padding=(0, 1),
206
+ )
207
+ console.print(panel)
208
+
209
+
210
+ def print_result_panel(title: str, data: dict[str, Any]) -> None:
211
+ """Print a result in a styled panel with key-value pairs.
212
+
213
+ Args:
214
+ title: Panel title.
215
+ data: Dictionary of key-value pairs to display.
216
+ """
217
+ lines = []
218
+ for key, value in data.items():
219
+ lines.append(f"[label]{key}:[/label] [value]{value}[/value]")
220
+
221
+ panel = Panel(
222
+ "\n".join(lines),
223
+ title=f"[success]{title}[/success]",
224
+ border_style="green",
225
+ padding=(0, 1),
226
+ )
227
+ console.print(panel)
228
+
229
+
230
+ # ============================================================================
231
+ # Table Output
232
+ # ============================================================================
233
+
234
+
235
+ def print_table(
236
+ data: list[dict[str, Any]],
237
+ columns: list[str],
238
+ title: str | None = None,
239
+ column_labels: dict[str, str] | None = None,
240
+ ) -> None:
241
+ """Print data as a rich table.
242
+
243
+ Args:
244
+ data: List of dictionaries to display.
245
+ columns: Column names to include.
246
+ title: Optional table title.
247
+ column_labels: Optional mapping of column names to display labels.
248
+ """
249
+ table = Table(
250
+ title=title,
251
+ show_header=True,
252
+ header_style="header",
253
+ border_style="muted",
254
+ row_styles=["", "dim"],
255
+ )
256
+
257
+ labels = column_labels or {}
258
+ for col in columns:
259
+ label = labels.get(col, col.replace("_", " ").title())
260
+ table.add_column(label)
261
+
262
+ for row in data:
263
+ table.add_row(*[str(row.get(col, "")) for col in columns])
264
+
265
+ console.print(table)
266
+
267
+
268
+ # ============================================================================
269
+ # JSON Output with Syntax Highlighting
270
+ # ============================================================================
271
+
272
+
273
+ def print_json(data: Any, title: str | None = None) -> None:
274
+ """Print data as syntax-highlighted JSON.
275
+
276
+ Args:
277
+ data: Data to print as JSON.
278
+ title: Optional title to display above the JSON.
279
+ """
280
+ if hasattr(data, "model_dump"):
281
+ data = data.model_dump(by_alias=True, exclude_none=True)
282
+ elif isinstance(data, list) and data and hasattr(data[0], "model_dump"):
283
+ data = [item.model_dump(by_alias=True, exclude_none=True) for item in data]
284
+
285
+ json_str = json.dumps(data, indent=2, default=str)
286
+
287
+ syntax = Syntax(
288
+ json_str,
289
+ "json",
290
+ theme="monokai",
291
+ line_numbers=False,
292
+ word_wrap=True,
293
+ )
294
+
295
+ if title:
296
+ panel = Panel(syntax, title=f"[info]{title}[/info]", border_style="cyan")
297
+ console.print(panel)
298
+ else:
299
+ console.print(syntax)
300
+
301
+
302
+ def print_csv(data: list[dict[str, Any]], columns: list[str]) -> None:
303
+ """Print data as CSV.
304
+
305
+ Args:
306
+ data: List of dictionaries to print.
307
+ columns: Column names to include.
308
+ """
309
+ import csv
310
+ import sys
311
+
312
+ writer = csv.DictWriter(sys.stdout, fieldnames=columns, extrasaction="ignore")
313
+ writer.writeheader()
314
+ writer.writerows(data)
315
+
316
+
317
+ def output_data(
318
+ data: list[dict[str, Any]],
319
+ columns: list[str],
320
+ format: OutputFormat,
321
+ title: str | None = None,
322
+ column_labels: dict[str, str] | None = None,
323
+ ) -> None:
324
+ """Output data in the specified format.
325
+
326
+ Args:
327
+ data: Data to output.
328
+ columns: Column names for table/CSV output.
329
+ format: Output format.
330
+ title: Optional title for table output.
331
+ column_labels: Optional mapping of column names to display labels.
332
+ """
333
+ if format == OutputFormat.JSON:
334
+ print_json(data, title)
335
+ elif format == OutputFormat.CSV:
336
+ print_csv(data, columns)
337
+ else:
338
+ print_table(data, columns, title, column_labels)
339
+
340
+
341
+ # ============================================================================
342
+ # Progress Indicators
343
+ # ============================================================================
344
+
345
+
346
+ @contextmanager
347
+ def spinner(message: str) -> Iterator[None]:
348
+ """Show a spinner while executing a block.
349
+
350
+ Args:
351
+ message: Message to display with the spinner.
352
+
353
+ Yields:
354
+ None
355
+
356
+ Example:
357
+ with spinner("Loading campaigns..."):
358
+ campaigns = client.campaigns.list()
359
+ """
360
+ with console.status(f"[info]{message}[/info]", spinner="dots"):
361
+ yield
362
+
363
+
364
+ def create_progress() -> Progress:
365
+ """Create a progress bar for iteration.
366
+
367
+ Returns:
368
+ A configured Progress instance.
369
+
370
+ Example:
371
+ with create_progress() as progress:
372
+ task = progress.add_task("Loading...", total=100)
373
+ for item in items:
374
+ progress.advance(task)
375
+ """
376
+ return Progress(
377
+ SpinnerColumn(),
378
+ TextColumn("[progress.description]{task.description}"),
379
+ BarColumn(),
380
+ TaskProgressColumn(),
381
+ TimeElapsedColumn(),
382
+ console=console,
383
+ )
384
+
385
+
386
+ def iterate_with_progress(
387
+ items: Iterator[T],
388
+ total: int | None = None,
389
+ description: str = "Processing...",
390
+ ) -> Iterator[T]:
391
+ """Iterate over items with a progress bar.
392
+
393
+ Args:
394
+ items: Iterator to wrap.
395
+ total: Total number of items (if known).
396
+ description: Progress bar description.
397
+
398
+ Yields:
399
+ Items from the iterator.
400
+
401
+ Example:
402
+ for campaign in iterate_with_progress(client.campaigns.iter_all(), total=100):
403
+ process(campaign)
404
+ """
405
+ with create_progress() as progress:
406
+ task = progress.add_task(description, total=total)
407
+ for item in items:
408
+ yield item
409
+ progress.advance(task)
410
+
411
+
412
+ # ============================================================================
413
+ # Utility Functions
414
+ # ============================================================================
415
+
416
+
417
+ def parse_date(value: str) -> date:
418
+ """Parse a date string in YYYY-MM-DD format.
419
+
420
+ Args:
421
+ value: Date string to parse.
422
+
423
+ Returns:
424
+ Parsed date object.
425
+
426
+ Raises:
427
+ typer.BadParameter: If the date format is invalid.
428
+ """
429
+ try:
430
+ return date.fromisoformat(value)
431
+ except ValueError:
432
+ raise typer.BadParameter(f"Invalid date format: '{value}'. Use YYYY-MM-DD format.") from None
433
+
434
+
435
+ def save_to_file(content: str, path: Path) -> None:
436
+ """Save content to a file.
437
+
438
+ Args:
439
+ content: Content to save.
440
+ path: File path.
441
+ """
442
+ path.write_text(content)
443
+ print_success(f"Saved to {path}")
444
+
445
+
446
+ def format_number(value: int | float | None) -> str:
447
+ """Format a number with thousands separators.
448
+
449
+ Args:
450
+ value: Number to format.
451
+
452
+ Returns:
453
+ Formatted string or "-" if None.
454
+ """
455
+ if value is None:
456
+ return "-"
457
+ if isinstance(value, float):
458
+ return f"{value:,.2f}"
459
+ return f"{value:,}"
460
+
461
+
462
+ def format_money(amount: str | None, currency: str | None = None) -> str:
463
+ """Format a monetary value.
464
+
465
+ Args:
466
+ amount: The amount string.
467
+ currency: Optional currency code.
468
+
469
+ Returns:
470
+ Formatted money string.
471
+ """
472
+ if amount is None:
473
+ return "-"
474
+ if currency:
475
+ return f"{amount} {currency}"
476
+ return amount
477
+
478
+
479
+ def format_percent(value: float | None) -> str:
480
+ """Format a value as a percentage.
481
+
482
+ Args:
483
+ value: Value between 0 and 1.
484
+
485
+ Returns:
486
+ Formatted percentage string.
487
+ """
488
+ if value is None:
489
+ return "-"
490
+ return f"{value:.2%}"
491
+
492
+
493
+ def confirm_action(message: str, default: bool = False) -> bool:
494
+ """Ask for confirmation with styled prompt.
495
+
496
+ Args:
497
+ message: Confirmation message.
498
+ default: Default value if user just presses Enter.
499
+
500
+ Returns:
501
+ True if confirmed, False otherwise.
502
+ """
503
+ return typer.confirm(message, default=default)
@@ -0,0 +1,196 @@
1
+ Metadata-Version: 2.4
2
+ Name: asa-api-cli
3
+ Version: 0.1.0
4
+ Summary: Command-line interface for Apple Search Ads campaign management
5
+ Project-URL: Homepage, https://github.com/SamPetherbridge/asa-api-cli
6
+ Project-URL: Repository, https://github.com/SamPetherbridge/asa-api-cli
7
+ Project-URL: Issues, https://github.com/SamPetherbridge/asa-api-cli/issues
8
+ Author-email: Sam Petherbridge <hello@peth.me>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: advertising,apple,campaign-management,cli,search-ads
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.13
21
+ Requires-Dist: asa-api-client>=0.1.0
22
+ Requires-Dist: rich>=13.9.0
23
+ Requires-Dist: typer>=0.13.0
24
+ Provides-Extra: dev
25
+ Requires-Dist: mypy>=1.13.0; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
27
+ Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
28
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
29
+ Requires-Dist: ruff>=0.8.0; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # ASA API CLI
33
+
34
+ [![PyPI version](https://img.shields.io/pypi/v/asa-api-cli.svg)](https://pypi.org/project/asa-api-cli/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/asa-api-cli.svg)](https://pypi.org/project/asa-api-cli/)
36
+ [![License](https://img.shields.io/github/license/SamPetherbridge/asa-api-cli.svg)](https://github.com/SamPetherbridge/asa-api-cli/blob/main/LICENSE)
37
+ [![CI](https://github.com/SamPetherbridge/asa-api-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/SamPetherbridge/asa-api-cli/actions/workflows/ci.yml)
38
+
39
+ A command-line interface for managing Apple Search Ads campaigns.
40
+
41
+ Built on top of [asa-api-client](https://github.com/SamPetherbridge/asa-api-client).
42
+
43
+ ## Installation
44
+
45
+ Using [uv](https://docs.astral.sh/uv/) (recommended):
46
+
47
+ ```bash
48
+ uv tool install asa-api-cli
49
+ ```
50
+
51
+ Using pip:
52
+
53
+ ```bash
54
+ pip install asa-api-cli
55
+ ```
56
+
57
+ Using pipx:
58
+
59
+ ```bash
60
+ pipx install asa-api-cli
61
+ ```
62
+
63
+ ## Setup
64
+
65
+ Set up your Apple Search Ads API credentials:
66
+
67
+ ```bash
68
+ export ASA_CLIENT_ID="SEARCHADS.your-client-id"
69
+ export ASA_TEAM_ID="SEARCHADS.your-team-id"
70
+ export ASA_KEY_ID="your-key-id"
71
+ export ASA_ORG_ID="123456"
72
+ export ASA_PRIVATE_KEY_PATH="/path/to/private-key.pem"
73
+ ```
74
+
75
+ Or use a `.env` file in your working directory.
76
+
77
+ Test your credentials:
78
+
79
+ ```bash
80
+ asa auth test
81
+ ```
82
+
83
+ ## Commands
84
+
85
+ ### Campaigns
86
+
87
+ ```bash
88
+ # List all enabled campaigns
89
+ asa campaigns list
90
+
91
+ # List all campaigns (including paused)
92
+ asa campaigns list --all
93
+
94
+ # List with 7-day spend data
95
+ asa campaigns list --with-spend
96
+
97
+ # Get a specific campaign
98
+ asa campaigns get 123456789
99
+
100
+ # Output as JSON
101
+ asa campaigns list --json
102
+ ```
103
+
104
+ ### Ad Groups
105
+
106
+ ```bash
107
+ # List ad groups in a campaign
108
+ asa ad-groups list 123456789
109
+ ```
110
+
111
+ ### Keywords
112
+
113
+ ```bash
114
+ # List keywords in an ad group
115
+ asa keywords list 123456789 987654321
116
+ ```
117
+
118
+ ### Reports
119
+
120
+ ```bash
121
+ # Campaign performance report
122
+ asa reports campaigns --start 2024-01-01 --end 2024-01-31
123
+
124
+ # Keyword report
125
+ asa reports keywords 123456789 --start 2024-01-01 --end 2024-01-31
126
+
127
+ # Export to CSV
128
+ asa reports campaigns --start 2024-01-01 --end 2024-01-31 --output report.csv
129
+ ```
130
+
131
+ ### Brand Campaigns
132
+
133
+ Create brand protection campaigns for your app:
134
+
135
+ ```bash
136
+ # Interactive mode (recommended)
137
+ asa brand
138
+
139
+ # With arguments
140
+ asa brand "My App Name" -v "My App" -c US -c GB
141
+
142
+ # All standard countries
143
+ asa brand "My App Name" --country all
144
+
145
+ # Preview without creating
146
+ asa brand "My App Name" -c US --dry-run
147
+ ```
148
+
149
+ ### Campaign Expansion
150
+
151
+ Expand existing campaigns to new markets:
152
+
153
+ ```bash
154
+ # Interactive mode
155
+ asa optimize expand
156
+
157
+ # With filters
158
+ asa optimize expand --type Generic --match EM
159
+
160
+ # Preview
161
+ asa optimize expand --dry-run
162
+ ```
163
+
164
+ ### Bid Optimization
165
+
166
+ Check and fix bid discrepancies:
167
+
168
+ ```bash
169
+ # Check bids
170
+ asa optimize bid-check
171
+
172
+ # Auto-fix discrepancies
173
+ asa optimize bid-check --auto-fix
174
+
175
+ # With threshold
176
+ asa optimize bid-check --threshold 0.05
177
+ ```
178
+
179
+ ## Shell Completion
180
+
181
+ Install shell completion for your shell:
182
+
183
+ ```bash
184
+ # Bash
185
+ asa --install-completion bash
186
+
187
+ # Zsh
188
+ asa --install-completion zsh
189
+
190
+ # Fish
191
+ asa --install-completion fish
192
+ ```
193
+
194
+ ## License
195
+
196
+ MIT License - Copyright (c) 2025 Peth Pty Ltd
@@ -0,0 +1,15 @@
1
+ asa_api_cli/__init__.py,sha256=vBki45z9Mf1zMH14tLj5W6OBcttjxeHWVBM2DyWOLOE,311
2
+ asa_api_cli/ad_groups.py,sha256=frVqei3uyq_eSgBgp7KORPm5xrxUHJAqIh3_P4ds_pI,8226
3
+ asa_api_cli/auth.py,sha256=mmMugQWnFGvHnjjdyTk5_bCUmF7D9EZbgDpm0FIwAKw,6382
4
+ asa_api_cli/brand.py,sha256=rJglbYAQ17H0Nu7SkfykRTaXO0IlBVuroH-flqEIQuM,26467
5
+ asa_api_cli/campaigns.py,sha256=lvqxqMMxcvR2W8TDFNotZzub5P1c3239euIs0kKftek,11555
6
+ asa_api_cli/keywords.py,sha256=-a0kUF96ynrVF04Gxd44p8gHWFU6d8Z-OfAZs6V_3BQ,16324
7
+ asa_api_cli/main.py,sha256=t3JXLjN1YfApB6Vsai8RoOS_4YoDu149VxuL4dIuApo,2231
8
+ asa_api_cli/optimize.py,sha256=_FldTB8vzUCedxzxChxLn_4MK3ykFYsx7i9uPe1IAzY,36743
9
+ asa_api_cli/reports.py,sha256=pB656hOVeyja7PJKWdCW3H6Yju1bChJhdarVuJi0xPo,13911
10
+ asa_api_cli/utils.py,sha256=DredVaT9T8Xjjfmno7JIpybZE3Epgbs2itKjFwYIvnw,13166
11
+ asa_api_cli-0.1.0.dist-info/METADATA,sha256=r-9DWQT6ZkgT50NvBDJK0hgCOefnI-38cCoz-x_22E4,4353
12
+ asa_api_cli-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
13
+ asa_api_cli-0.1.0.dist-info/entry_points.txt,sha256=1NAsJc-p7DGzsOv467Os0iuyCPxelaOdJi1CGREtDz4,40
14
+ asa_api_cli-0.1.0.dist-info/licenses/LICENSE,sha256=KB9suX2BucB1i3r7FT6wxU5ELrvgB95zgAHfzFFUAw4,1069
15
+ asa_api_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any