parqv 0.1.0__py3-none-any.whl → 0.2.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.
@@ -1,4 +1,3 @@
1
- import json
2
1
  import logging
3
2
  from typing import Dict, Any, Optional, List, Union
4
3
 
@@ -12,176 +11,236 @@ log = logging.getLogger(__name__)
12
11
 
13
12
 
14
13
  class ColumnListItem(ListItem):
14
+ """A ListItem that stores the column name."""
15
+
15
16
  def __init__(self, column_name: str) -> None:
16
- super().__init__(Label(column_name), name=column_name, id=f"col-item-{column_name.replace(' ', '_')}")
17
+ # Ensure IDs are CSS-safe (replace spaces, etc.)
18
+ safe_id_name = "".join(c if c.isalnum() else '_' for c in column_name)
19
+ super().__init__(Label(column_name), name=column_name, id=f"col-item-{safe_id_name}")
17
20
  self.column_name = column_name
18
21
 
19
22
 
20
23
  def format_stats_for_display(stats_data: Dict[str, Any]) -> List[Union[str, Text]]:
24
+ """Formats the statistics dictionary for display as lines of text."""
21
25
  if not stats_data:
22
26
  return [Text.from_markup("[red]No statistics data available.[/red]")]
23
27
 
24
28
  lines: List[Union[str, Text]] = []
25
29
  col_name = stats_data.get("column", "N/A")
26
30
  col_type = stats_data.get("type", "Unknown")
27
- nullable = stats_data.get("nullable", "Unknown")
28
-
31
+ nullable_val = stats_data.get("nullable")
32
+
33
+ if nullable_val is True:
34
+ nullable_str = "Nullable"
35
+ elif nullable_val is False:
36
+ nullable_str = "Required"
37
+ else:
38
+ nullable_str = "Unknown Nullability"
29
39
  lines.append(Text.assemble(("Column: ", "bold"), f"`{col_name}`"))
30
- lines.append(Text.assemble(("Type: ", "bold"), f"{col_type} ({'Nullable' if nullable else 'Required'})"))
40
+ lines.append(Text.assemble(("Type: ", "bold"), f"{col_type} ({nullable_str})"))
31
41
  lines.append("─" * (len(col_name) + len(col_type) + 20))
32
42
 
33
43
  calc_error = stats_data.get("error")
34
44
  if calc_error:
35
45
  lines.append(Text("Calculation Error:", style="bold red"))
36
- lines.append(f"```{calc_error}```")
46
+ lines.append(f"```\n{calc_error}\n```")
47
+ lines.append("")
48
+
49
+ message = stats_data.get("message")
50
+ if message:
51
+ lines.append(Text(f"Info: {message}", style="italic cyan"))
52
+ lines.append("")
37
53
 
38
54
  calculated = stats_data.get("calculated")
39
55
  if calculated:
40
56
  lines.append(Text("Calculated Statistics:", style="bold"))
41
57
  keys_to_display = [
42
58
  "Total Count", "Valid Count", "Null Count", "Null Percentage",
43
- "Min", "Max", "Mean", "StdDev", "Distinct Count", "Value Counts"
59
+ "Min", "Max", "Mean", "StdDev", "Variance",
60
+ "Distinct Count", "Min Length", "Max Length", "Avg Length",
61
+ "Value Counts"
44
62
  ]
63
+ found_stats = False
45
64
  for key in keys_to_display:
46
65
  if key in calculated:
66
+ found_stats = True
47
67
  value = calculated[key]
48
- if isinstance(value, dict):
68
+ if key == "Value Counts" and isinstance(value, dict):
49
69
  lines.append(f" - {key}:")
50
70
  for sub_key, sub_val in value.items():
51
- lines.append(f" - {sub_key}: {sub_val:,}")
71
+ sub_val_str = f"{sub_val:,}" if isinstance(sub_val, (int, float)) else str(sub_val)
72
+ lines.append(f" - {sub_key}: {sub_val_str}")
73
+ elif isinstance(value, (int, float)):
74
+ lines.append(f" - {key}: {value:,}")
52
75
  else:
53
76
  lines.append(f" - {key}: {value}")
54
- lines.append("")
55
-
56
- meta_stats = stats_data.get("basic_metadata_stats")
57
- if meta_stats:
58
- lines.append(Text("Stats from File Metadata (Per Row Group):", style="bold"))
59
- try:
60
- json_str = json.dumps(meta_stats, indent=2, default=str)
61
- lines.append(f"```json\n{json_str}\n```")
62
- except Exception as e:
63
- lines.append(f" (Error formatting metadata: {e})")
64
- lines.append("")
65
-
66
- meta_stats_error = stats_data.get("metadata_stats_error")
67
- if meta_stats_error:
68
- lines.append(Text(f"Metadata Stats Warning: {meta_stats_error}", style="yellow"))
69
-
70
- message = stats_data.get("message")
71
- if message and not calculated:
72
- lines.append(Text(message, style="italic"))
73
-
77
+ if not found_stats and not calc_error:
78
+ lines.append(Text(" (No specific stats calculated for this type)", style="dim"))
74
79
  return lines
75
80
 
76
81
 
77
82
  class SchemaView(VerticalScroll):
78
- DEFAULT_STATS_MESSAGE = "Select a column above to view statistics."
83
+ """Displays a list of columns and the statistics for the selected column."""
84
+ DEFAULT_STATS_MESSAGE = "Select a column from the list above to view its statistics."
79
85
  loading = var(False)
80
86
 
81
87
  def compose(self) -> ComposeResult:
88
+ """Create child widgets for the SchemaView."""
82
89
  yield ListView(id="column-list-view")
83
90
  yield LoadingIndicator(id="schema-loading-indicator")
84
- yield Container(id="schema-stats-content")
91
+ yield VerticalScroll(Container(id="schema-stats-content"), id="schema-stats-scroll")
85
92
 
86
93
  def on_mount(self) -> None:
87
- self.query_one("#schema-loading-indicator", LoadingIndicator).styles.display = "none"
94
+ """Called when the widget is mounted."""
95
+ self.query_one("#schema-loading-indicator", LoadingIndicator).display = False
96
+ self.query_one("#schema-stats-content", Container).display = False
88
97
  self.call_later(self.load_column_list)
89
- self.call_later(self._update_stats_display, [])
98
+ self.call_later(self._display_default_message)
99
+
100
+ def _display_default_message(self):
101
+ """Helper to display the initial message in the stats area."""
102
+ try:
103
+ stats_container = self.query_one("#schema-stats-content", Container)
104
+ stats_container.query("*").remove()
105
+ stats_container.mount(Static(self.DEFAULT_STATS_MESSAGE, classes="stats-line"))
106
+ stats_container.display = True
107
+ except Exception as e:
108
+ log.error(f"Failed to display default stats message: {e}")
90
109
 
91
110
  def load_column_list(self):
92
- list_view: Optional[ListView] = None
111
+ """Loads the list of columns from the data handler."""
112
+ list_view: Optional[ListView] = self.query_one("#column-list-view", ListView)
113
+ list_view.clear()
114
+
93
115
  try:
94
- list_views = self.query("#column-list-view")
95
- if not list_views:
96
- log.error("ListView widget (#column-list-view) not found!")
116
+ if not self.app.handler:
117
+ log.error("SchemaView: Data handler not available.")
118
+ list_view.append(ListItem(Label("[red]Data handler not available.[/red]")))
97
119
  return
98
- list_view = list_views.first()
99
- log.debug("ListView widget found.")
100
120
 
101
- list_view.clear()
121
+ schema_data: Optional[List[Dict[str, str]]] = self.app.handler.get_schema_data()
122
+ log.debug(f"SchemaView: Received schema data for list: {schema_data}")
102
123
 
103
- if self.app.handler and self.app.handler.schema:
104
- column_names: List[str] = self.app.handler.schema.names
105
- if column_names:
106
- for name in column_names:
107
- list_view.append(ColumnListItem(name))
108
- else:
109
- log.warning("Schema has no columns.")
110
- list_view.append(ListItem(Label("[yellow]Schema has no columns.[/yellow]")))
111
- elif not self.app.handler:
112
- log.error("Parquet handler not available.")
113
- list_view.append(ListItem(Label("[red]Parquet handler not available.[/red]")))
124
+ if schema_data is None:
125
+ log.error("SchemaView: Failed to retrieve schema data (handler returned None).")
126
+ list_view.append(ListItem(Label("[red]Could not load schema.[/red]")))
127
+ elif not schema_data:
128
+ log.warning("SchemaView: Schema has no columns.")
129
+ list_view.append(ListItem(Label("[yellow]Schema has no columns.[/yellow]")))
114
130
  else:
115
- log.error("Parquet schema not available.")
116
- list_view.append(ListItem(Label("[red]Parquet schema not available.[/red]")))
131
+ column_count = 0
132
+ for col_info in schema_data:
133
+ column_name = col_info.get("name")
134
+ if column_name:
135
+ list_view.append(ColumnListItem(column_name))
136
+ column_count += 1
137
+ else:
138
+ log.warning("SchemaView: Found column info without a 'name' key.")
139
+ log.info(f"SchemaView: Populated column list with {column_count} columns.")
117
140
 
118
141
  except Exception as e:
119
142
  log.exception("Error loading column list in SchemaView:")
120
- if list_view:
121
- list_view.clear()
122
- list_view.append(ListItem(Label(f"[red]Error loading schema view: {e}[/red]")))
143
+ list_view.clear()
144
+ list_view.append(ListItem(Label(f"[red]Error loading schema: {e}[/red]")))
123
145
 
124
146
  def watch_loading(self, loading: bool) -> None:
125
- loading_indicator = self.query_one("#schema-loading-indicator", LoadingIndicator)
126
- stats_content = self.query_one("#schema-stats-content", Container)
127
- loading_indicator.styles.display = "block" if loading else "none"
128
- stats_content.styles.display = "none" if loading else "block"
147
+ """React to changes in the loading state."""
148
+ try:
149
+ loading_indicator = self.query_one("#schema-loading-indicator", LoadingIndicator)
150
+ stats_scroll = self.query_one("#schema-stats-scroll", VerticalScroll)
151
+ loading_indicator.display = loading
152
+ stats_scroll.display = not loading
153
+ if loading:
154
+ stats_content = self.query_one("#schema-stats-content", Container)
155
+ stats_content.display = False
156
+ except Exception as e:
157
+ log.error(f"Error updating loading display: {e}")
129
158
 
130
159
  async def _update_stats_display(self, lines: List[Union[str, Text]]) -> None:
160
+ """Updates the statistics display area with formatted lines."""
131
161
  try:
132
- content_area = self.query_one("#schema-stats-content", Container)
133
- await content_area.query("*").remove()
162
+ stats_content_container = self.query_one("#schema-stats-content", Container)
163
+ stats_scroll_container = self.query_one("#schema-stats-scroll", VerticalScroll)
164
+ await stats_content_container.query("*").remove()
134
165
 
135
166
  if not lines:
136
- await content_area.mount(Static(self.DEFAULT_STATS_MESSAGE, classes="stats-line"))
137
- return
138
-
139
- new_widgets: List[Static] = []
140
- for line in lines:
141
- content: Union[str, Text] = line
142
- css_class = "stats-line"
143
- if isinstance(line, str) and line.startswith("```"):
144
- content = line.strip("` \n")
145
- css_class = "stats-code"
146
- elif isinstance(line, Text) and ("red" in str(line.style) or "yellow" in str(line.style)):
147
- css_class = "stats-error stats-line"
148
-
149
- new_widgets.append(Static(content, classes=css_class))
150
-
151
- if new_widgets:
152
- await content_area.mount_all(new_widgets)
167
+ await stats_content_container.mount(Static(self.DEFAULT_STATS_MESSAGE, classes="stats-line"))
168
+ else:
169
+ new_widgets: List[Static] = []
170
+ for line in lines:
171
+ content: Union[str, Text] = line
172
+ css_class = "stats-line"
173
+ if isinstance(line, str) and line.startswith("```"):
174
+ content = line.strip()
175
+ if content.startswith("```json"):
176
+ content = content[7:]
177
+ elif content.startswith("```"):
178
+ content = content[3:]
179
+ if content.endswith("```"):
180
+ content = content[:-3]
181
+ content = content.strip()
182
+ css_class = "stats-code"
183
+ elif isinstance(line, Text):
184
+ style_str = str(line.style).lower()
185
+ if "red" in style_str:
186
+ css_class = "stats-error stats-line"
187
+ elif "yellow" in style_str:
188
+ css_class = "stats-warning stats-line"
189
+ elif "italic" in style_str:
190
+ css_class = "stats-info stats-line"
191
+ elif "bold" in style_str:
192
+ css_class = "stats-header stats-line"
193
+ new_widgets.append(Static(content, classes=css_class))
194
+ if new_widgets:
195
+ await stats_content_container.mount_all(new_widgets)
196
+
197
+ stats_content_container.display = True
198
+ stats_scroll_container.display = True
199
+ stats_scroll_container.scroll_home(animate=False)
153
200
  except Exception as e:
154
201
  log.error(f"Error updating stats display: {e}", exc_info=True)
155
202
  try:
156
- await content_area.query("*").remove()
157
- await content_area.mount(Static(f"[red]Internal error displaying stats: {e}[/red]"))
203
+ await stats_content_container.query("*").remove()
204
+ await stats_content_container.mount(Static(f"[red]Internal error displaying stats: {e}[/red]"))
205
+ stats_content_container.display = True
206
+ stats_scroll_container.display = True
158
207
  except Exception:
159
208
  pass
160
209
 
161
210
  async def on_list_view_selected(self, event: ListView.Selected) -> None:
211
+ """Handle column selection in the ListView."""
162
212
  event.stop()
163
213
  selected_item = event.item
164
214
 
165
215
  if isinstance(selected_item, ColumnListItem):
166
216
  column_name = selected_item.column_name
167
- log.info(f"Column selected: {column_name}")
168
217
  self.loading = True
169
218
 
170
219
  stats_data: Dict[str, Any] = {}
171
- error_str: Optional[str] = None
220
+ error_markup: Optional[str] = None
221
+
172
222
  try:
173
223
  if self.app.handler:
174
224
  stats_data = self.app.handler.get_column_stats(column_name)
225
+ if stats_data.get("error"):
226
+ log.warning(f"Handler returned error for column '{column_name}': {stats_data['error']}")
227
+ error_markup = f"[red]Error getting stats: {stats_data['error']}[/]"
228
+ stats_data = {}
175
229
  else:
176
- error_str = "[red]Error: Parquet handler not available.[/]"
177
- log.error("Parquet handler not found on app.")
230
+ error_markup = "[red]Error: Data handler not available.[/]"
231
+ log.error("SchemaView: Data handler not found on app.")
178
232
  except Exception as e:
179
- log.exception(f"ERROR calculating stats for {column_name}")
180
- error_str = f"[red]Error loading stats for {column_name}:\n{type(e).__name__}: {e}[/]"
233
+ log.exception(f"Exception calculating stats for {column_name}")
234
+ error_markup = f"[red]Error loading stats for '{column_name}':\n{type(e).__name__}: {e}[/]"
235
+
236
+ if error_markup:
237
+ lines_to_render = [Text.from_markup(error_markup)]
238
+ else:
239
+ lines_to_render = format_stats_for_display(stats_data)
181
240
 
182
- lines_to_render = format_stats_for_display(stats_data) if not error_str else [Text.from_markup(error_str)]
183
241
  await self._update_stats_display(lines_to_render)
184
242
  self.loading = False
185
243
  else:
244
+ log.debug("Non-column item selected in ListView.")
186
245
  await self._update_stats_display([])
187
246
  self.loading = False
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: parqv
3
+ Version: 0.2.0
4
+ Summary: An interactive Python TUI for visualizing, exploring, and analyzing files directly in your terminal.
5
+ Author-email: Sangmin Yoon <sanspareilsmyn@gmail.com>
6
+ License-Expression: Apache-2.0
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: textual>=1.0.0
11
+ Requires-Dist: pyarrow>=16.0.0
12
+ Requires-Dist: pandas>=2.0.0
13
+ Requires-Dist: numpy>=1.20.0
14
+ Requires-Dist: duckdb>=1.2.0
15
+ Dynamic: license-file
16
+
17
+ # parqv
18
+
19
+ [![Python Version](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/)
20
+ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE)
21
+ [![PyPI version](https://badge.fury.io/py/parqv.svg)](https://badge.fury.io/py/parqv) <!-- TODO: Link after first PyPI release -->
22
+ [![Built with Textual](https://img.shields.io/badge/Built%20with-Textual-blueviolet.svg)](https://textual.textualize.io/)
23
+
24
+ ---
25
+
26
+ **Supported File Formats:** ✅ **Parquet** | ✅ **JSON** / **JSON Lines (ndjson)** | *(More planned!)*
27
+
28
+ ---
29
+
30
+ **`parqv` is a Python-based interactive TUI (Text User Interface) tool designed to explore, analyze, and understand various data file formats directly within your terminal.** Initially supporting Parquet and JSON, `parqv` aims to provide a unified, visual experience for quick data inspection without leaving your console.
31
+
32
+ ## 💻 Demo (Showing Parquet)
33
+
34
+ ![parqv.gif](assets/parqv.gif)
35
+ *(Demo shows Parquet features; UI adapts for other formats)*
36
+
37
+ ## 🤔 Why `parqv`?
38
+ 1. **Unified Interface:** Launch `parqv <your_data_file>` to access **metadata, schema, data preview, and column statistics** all within a single, navigable terminal window. No more juggling different commands for different file types.
39
+ 2. **Interactive Exploration:**
40
+ * **🖱️ Keyboard & Mouse Driven:** Navigate using familiar keys (arrows, `hjkl`, Tab) or even your mouse (thanks to `Textual`).
41
+ * **📜 Scrollable Views:** Easily scroll through large schemas, data tables, or column lists.
42
+ * **🌲 Clear Schema View:** Understand column names, data types, and nullability at a glance. (Complex nested structures visualization might vary by format).
43
+ * **📊 Dynamic Stats:** Select a column and instantly see its detailed statistics (counts, nulls, min/max, mean, distinct values, etc.).
44
+ 3. **Cross-Format Consistency:**
45
+ * **🎨 Rich Display:** Leverages `rich` and `Textual` for colorful, readable tables and text across supported formats.
46
+ * **📈 Quick Stats:** Get key statistical insights consistently, regardless of the underlying file type.
47
+ * **🔌 Extensible:** Designed with a handler interface to easily add support for more file formats in the future (like CSV, Arrow IPC, etc.).
48
+
49
+ ## ✨ Features (TUI Mode)
50
+ * **Multi-Format Support:** Currently supports **Parquet** (`.parquet`) and **JSON/JSON Lines** (`.json`, `.ndjson`). Run `parqv <your_file.{parquet,json,ndjson}>`.
51
+ * **Metadata Panel:** Displays key file information (path, format, size, total rows, column count, etc.). *Fields may vary slightly depending on the file format.*
52
+ * **Schema Explorer:**
53
+ * Interactive list view of columns.
54
+ * Clearly shows column names, data types, and nullability.
55
+ * **Data Table Viewer:**
56
+ * Scrollable table preview of the file's data.
57
+ * Attempts to preserve data types for better representation.
58
+ * **Column Statistics Viewer:**
59
+ * Select a column in the Schema tab to view detailed statistics.
60
+ * Shows counts (total, valid, null), percentages, and type-specific stats (min/max, mean, stddev, distinct counts, length stats, boolean value counts where applicable).
61
+ * **Row Group Inspector (Parquet Specific):**
62
+ * *This panel only appears when viewing Parquet files.*
63
+ * Lists row groups with stats (row count, compressed/uncompressed size).
64
+ * (Planned) Select a row group for more details.
65
+
66
+ ## 🚀 Getting Started
67
+
68
+ **1. Prerequisites:**
69
+ * **Python:** Version 3.10 or higher.
70
+ * **pip:** The Python package installer.
71
+
72
+ **2. Install `parqv`:**
73
+ * Open your terminal and run:
74
+ ```bash
75
+ pip install parqv
76
+ ```
77
+ *(This will also install dependencies like `textual`, `pyarrow`, `pandas`, and `duckdb`)*
78
+ * **Updating `parqv`:**
79
+ ```bash
80
+ pip install --upgrade parqv
81
+ ```
82
+
83
+ **3. Run `parqv`:**
84
+ * Point `parqv` to your data file:
85
+ ```bash
86
+ #parquet
87
+ parqv /path/to/your/data.parquet
88
+
89
+ # json
90
+ parqv /path/to/your/data.json
91
+ * The interactive TUI will launch. Use your keyboard (and mouse, if supported by your terminal) to navigate:
92
+ * **Arrow Keys / `j`,`k` (in lists):** Move selection up/down.
93
+ * **`Tab` / `Shift+Tab`:** Cycle focus between the main tab content and potentially other areas. (Focus handling might evolve).
94
+ * **`Enter` (in column list):** Select a column to view statistics.
95
+ * **View Switching:** Use `Ctrl+N` (Next Tab) and `Ctrl+P` (Previous Tab) or click on the tabs (Metadata, Schema, Data Preview).
96
+ * **Scrolling:** Use `PageUp` / `PageDown` / `Home` / `End` or arrow keys/mouse wheel within scrollable areas (like Schema stats or Data Preview).
97
+ * **`q` / `Ctrl+C`:** Quit `parqv`.
98
+ * *(Help Screen `?` is planned)*
99
+
100
+ ---
101
+
102
+ ## 📄 License
103
+
104
+ Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for the full license text.
@@ -0,0 +1,17 @@
1
+ parqv/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ parqv/app.py,sha256=QmGEBtHoASdNzDmSgLQ2BvpTnZOuxQj2l0QGhYfp48w,6476
3
+ parqv/parqv.css,sha256=C42ZXUwMX1ZXfGo0AmixbHxz0CWKzWBHZ_hkhq5aehg,2920
4
+ parqv/handlers/__init__.py,sha256=gplqWWpt0npYosDZfoX1Ek0sfvAD0YITMjlQEo-IYVc,343
5
+ parqv/handlers/base_handler.py,sha256=JjlI0QdUZmozaMhAZnhzzS8nC2J63QRDX3rZqzXOpW8,3662
6
+ parqv/handlers/json.py,sha256=KgujRGimqzE3kVGQFwfkCb2Yv3YWxB8w-b17q5X2Yj8,19794
7
+ parqv/handlers/parquet.py,sha256=kG46oeVONmi6cCVzf5MaF88PkmXP7jCEGIAlDkG6N2M,32221
8
+ parqv/views/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ parqv/views/data_view.py,sha256=T_LPbXdxm_KOmwtTQWAxlUeNTlSGfIgQIg853WDMAME,2355
10
+ parqv/views/metadata_view.py,sha256=GnzhPJolMDZU8AAwY0h_uToz1sdM54iJbM2GLkD3caI,1013
11
+ parqv/views/schema_view.py,sha256=3QUYlOxTk9DYKdipEmt79U6hSlNJa_j3a3SK9UNwWHk,11332
12
+ parqv-0.2.0.dist-info/licenses/LICENSE,sha256=Ewl2wCa8r6ncxHlpf-ZZXb77c82zdfxHuEeKzBbm6nM,11324
13
+ parqv-0.2.0.dist-info/METADATA,sha256=q1K9Vqntt70-ffbJ3eT2g8_TW4XABsFHCkdYbBF4nXM,5387
14
+ parqv-0.2.0.dist-info/WHEEL,sha256=pxyMxgL8-pra_rKaQ4drOZAegBVuX-G_4nRHjjgWbmo,91
15
+ parqv-0.2.0.dist-info/entry_points.txt,sha256=8Tm8rTiIB-tbVItoOA4M7seEmFnrtK25BMH9UKzqfXg,44
16
+ parqv-0.2.0.dist-info/top_level.txt,sha256=_t3_49ZluJbvl0QU_P3GNVuXxCffqiTp37dzZIa2GEw,6
17
+ parqv-0.2.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (78.1.0)
2
+ Generator: setuptools (79.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5