diff --git a/02_quant_analysis.py b/02_quant_analysis.py
index 055bca7..19ba3e5 100644
--- a/02_quant_analysis.py
+++ b/02_quant_analysis.py
@@ -42,7 +42,7 @@ def _(JPMCSurvey, QSF_FILE, RESULTS_FILE):
return S, data_all
-@app.cell
+@app.cell(hide_code=True)
def _(Path, RESULTS_FILE, data_all, mo):
mo.md(f"""
# Load Data
@@ -68,12 +68,6 @@ def _(check_progress, data_all, duration_validation, mo):
return
-@app.cell
-def _(data_all, duration_validation):
- duration_validation(data_all)
- return
-
-
@app.cell(hide_code=True)
def _(mo):
mo.md(r"""
@@ -120,8 +114,9 @@ def _(S, mo):
return (filter_form,)
-@app.cell
+@app.cell(hide_code=True)
def _(S, data_all, filter_form, mo):
+ mo.stop(filter_form.value is None, mo.md("**Please submit filter above to proceed**"))
_d = S.filter_data(data_all, age=filter_form.value['age'], gender=filter_form.value['gender'], income=filter_form.value['income'], ethnicity=filter_form.value['ethnicity'], consumer=filter_form.value['consumer'])
# Stop execution and prevent other cells from running if no data is selected
@@ -162,7 +157,7 @@ def _(S, char_rank, mo):
### 1. Which character personality is ranked best?
- {mo.ui.plotly(S.plot_top3_ranking_distribution(char_rank, x_label='Character Personality', width=1000))}
+ {mo.ui.altair_chart(S.plot_top3_ranking_distribution(char_rank, x_label='Character Personality'))}
""")
return
@@ -173,7 +168,7 @@ def _(S, char_rank, mo):
### 2. Which character personality is ranked 1st the most?
- {mo.ui.plotly(S.plot_most_ranked_1(char_rank, title="Most Popular Character
(Number of Times Ranked 1st)", x_label='Character Personality', width=1000))}
+ {mo.ui.altair_chart(S.plot_most_ranked_1(char_rank, title="Most Popular Character
(Number of Times Ranked 1st)", x_label='Character Personality', width=1000))}
""")
return
@@ -186,7 +181,7 @@ def _(S, calculate_weighted_ranking_scores, char_rank, mo):
### 3. Which character personality most popular based on weighted scores?
- {mo.ui.plotly(S.plot_weighted_ranking_score(char_rank_weighted, title="Most Popular Character - Weighted Popularity Score
(1st=3pts, 2nd=2pts, 3rd=1pt)", x_label='Voice', width=1000))}
+ {mo.ui.altair_chart(S.plot_weighted_ranking_score(char_rank_weighted, title="Most Popular Character - Weighted Popularity Score
(1st=3pts, 2nd=2pts, 3rd=1pt)", x_label='Voice', width=1000))}
""")
return
@@ -211,7 +206,7 @@ def _(S, mo, v_18_8_3):
mo.md(f"""
### Which 8 voices are chosen the most out of 18?
- {mo.ui.plotly(S.plot_voice_selection_counts(v_18_8_3, height=500, width=1000))}
+ {mo.ui.altair_chart(S.plot_voice_selection_counts(v_18_8_3, height=500, width=1000))}
""")
return
@@ -223,7 +218,7 @@ def _(S, mo, v_18_8_3):
How many times does each voice end up in the top 3? ( this is based on the survey question where participants need to choose 3 out of the earlier selected 8 voices. So how often each of the 18 stimuli ended up in participants’ Top 3, after they first selected 8 out of 18.
- {mo.ui.plotly(S.plot_top3_selection_counts(v_18_8_3, height=500, width=1000))}
+ {mo.ui.altair_chart(S.plot_top3_selection_counts(v_18_8_3, height=500, width=1000))}
""")
return
@@ -242,7 +237,7 @@ def _(S, mo, top3_voices):
(not best 3 out of 8 question)
- {mo.ui.plotly(S.plot_ranking_distribution(top3_voices, x_label='Voice', width=1000))}
+ {mo.ui.altair_chart(S.plot_ranking_distribution(top3_voices, x_label='Voice', width=1000))}
""")
return
@@ -254,7 +249,7 @@ def _(S, mo, top3_voices_weighted):
- E.g. 1 point for place 3. 2 points for place 2 and 3 points for place 1. The voice with most points is ranked best.
Distribution of the rankings for each voice:
- {mo.ui.plotly(S.plot_weighted_ranking_score(top3_voices_weighted, title="Most Popular Voice - Weighted Popularity Score
(1st = 3pts, 2nd = 2pts, 3rd = 1pt)", height=500, width=1000))}
+ {mo.ui.altair_chart(S.plot_weighted_ranking_score(top3_voices_weighted, title="Most Popular Voice - Weighted Popularity Score
(1st = 3pts, 2nd = 2pts, 3rd = 1pt)", height=500, width=1000))}
""")
return
@@ -266,7 +261,7 @@ def _(S, mo, top3_voices):
(not always the voice with most points)
- {mo.ui.plotly(S.plot_most_ranked_1(top3_voices, title="Most Popular Voice
(Number of Times Ranked 1st)", x_label='Voice', width=1000))}
+ {mo.ui.altair_chart(S.plot_most_ranked_1(top3_voices, title="Most Popular Voice
(Number of Times Ranked 1st)", x_label='Voice', width=1000))}
""")
return
@@ -307,7 +302,7 @@ def _(S, mo, pl, ss_long):
content += f"""
### {i+1}) {trait.replace(":", " ↔ ")}
- {mo.ui.plotly(S.plot_speaking_style_trait_scores(trait_d, title=trait.replace(":", " ↔ "), height=550))}
+ {mo.ui.altair_chart(S.plot_speaking_style_trait_scores(trait_d, title=trait.replace(":", " ↔ "), height=550))}
"""
mo.md(content)
@@ -334,7 +329,7 @@ def _(S, mo, vscales):
mo.md(f"""
### How does each voice score on a scale from 1-10?
- {mo.ui.plotly(S.plot_average_scores_with_counts(vscales, x_label='Voice', width=1000))}
+ {mo.ui.altair_chart(S.plot_average_scores_with_counts(vscales, x_label='Voice', width=1000))}
""")
return
@@ -404,7 +399,7 @@ def _(S, SPEAKING_STYLES, joined_df, mo):
_content += f"""
#### Speaking Style **{style}**:
- {mo.ui.plotly(fig)}
+ {mo.ui.altair_chart(fig)}
"""
mo.md(_content)
@@ -469,7 +464,7 @@ def _(S, SPEAKING_STYLES, df_style, mo, top3_voices, utils):
#### Speaking Style **{_style}**:
- {mo.ui.plotly(_fig)}
+ {mo.ui.altair_chart(_fig)}
"""
diff --git a/plots.py b/plots.py
index c4da523..07c7269 100644
--- a/plots.py
+++ b/plots.py
@@ -1,9 +1,10 @@
-"""Plotting functions for Voice Branding analysis."""
+"""Plotting functions for Voice Branding analysis using Altair."""
import re
from pathlib import Path
-import plotly.graph_objects as go
+import altair as alt
+import pandas as pd
import polars as pl
from theme import ColorPalette
@@ -75,8 +76,115 @@ class JPMCPlotsMixin:
return "_".join(parts)
- def _save_plot(self, fig: go.Figure, title: str) -> None:
- """Save plot to PNG file if fig_save_dir is set."""
+ def _get_filter_description(self) -> str:
+ """Generate a human-readable description of active filters."""
+ parts = []
+
+ # Mapping of attribute name to (display_name, value, options_attr)
+ filters = [
+ ('Age', getattr(self, 'filter_age', None), 'options_age'),
+ ('Gender', getattr(self, 'filter_gender', None), 'options_gender'),
+ ('Consumer', getattr(self, 'filter_consumer', None), 'options_consumer'),
+ ('Ethnicity', getattr(self, 'filter_ethnicity', None), 'options_ethnicity'),
+ ('Income', getattr(self, 'filter_income', None), 'options_income'),
+ ]
+
+ for display_name, value, options_attr in filters:
+ if value is None:
+ continue
+
+ # Ensure value is a list for uniform handling
+ if not isinstance(value, list):
+ value = [value]
+
+ if len(value) == 0:
+ continue
+
+ # Check if all options are selected (equivalent to no filter)
+ master_list = getattr(self, options_attr, None)
+ if master_list and set(value) == set(master_list):
+ continue
+
+ # Use original values for display (full list)
+ clean_values = [str(v) for v in value]
+ val_str = ", ".join(clean_values)
+ # Use UPPERCASE for category name to distinguish from values
+ parts.append(f"{display_name.upper()}: {val_str}")
+
+ if not parts:
+ return ""
+
+ # Join with clear separator - double space for visual break
+ return "Filters: " + " — ".join(parts)
+
+ def _add_filter_footnote(self, chart: alt.Chart) -> alt.Chart:
+ """Add a footnote with active filters to the chart.
+
+ Uses chart subtitle for filter text to avoid layout issues with vconcat.
+ Returns the modified chart (or original if no filters).
+ """
+ filter_text = self._get_filter_description()
+
+ # Skip if no filters active - return original chart
+ if not filter_text:
+ return chart
+
+ # Wrap text into multiple lines at ~100 chars, but don't break mid-word
+ max_line_length = 100
+ words = filter_text.split()
+ lines = []
+ current_line = ""
+
+ for word in words:
+ test_line = f"{current_line} {word}".strip() if current_line else word
+ if len(test_line) <= max_line_length:
+ current_line = test_line
+ else:
+ if current_line:
+ lines.append(current_line)
+ current_line = word
+ if current_line:
+ lines.append(current_line)
+
+ # Get existing title from chart spec
+ chart_spec = chart.to_dict()
+ existing_title = chart_spec.get('title', '')
+
+ # Handle different title formats (string vs dict)
+ if isinstance(existing_title, str):
+ title_config = {
+ 'text': existing_title,
+ 'subtitle': lines,
+ 'subtitleColor': 'gray',
+ 'subtitleFontSize': 10,
+ 'anchor': 'start',
+ }
+ elif isinstance(existing_title, dict):
+ title_config = existing_title.copy()
+ title_config['subtitle'] = lines
+ title_config['subtitleColor'] = 'gray'
+ title_config['subtitleFontSize'] = 10
+ title_config['anchor'] = 'start'
+ else:
+ # No existing title, just add filters as subtitle
+ title_config = {
+ 'text': '',
+ 'subtitle': lines,
+ 'subtitleColor': 'gray',
+ 'subtitleFontSize': 10,
+ 'anchor': 'start',
+ }
+
+ return chart.properties(title=title_config)
+
+ def _save_plot(self, chart: alt.Chart, title: str) -> alt.Chart:
+ """Save chart to PNG file if fig_save_dir is set.
+
+ Returns the (potentially modified) chart with filter footnote added.
+ """
+ # Add filter footnote - returns combined chart if filters active
+ chart = self._add_filter_footnote(chart)
+
if hasattr(self, 'fig_save_dir') and self.fig_save_dir:
path = Path(self.fig_save_dir)
@@ -88,7 +196,11 @@ class JPMCPlotsMixin:
path.mkdir(parents=True, exist_ok=True)
filename = f"{self._sanitize_filename(title)}.png"
- fig.write_image(path / filename, width=fig.layout.width, height=fig.layout.height)
+
+ # Save using vl-convert backend
+ chart.save(str(path / filename), format='png', scale_factor=2.0)
+
+ return chart
def _ensure_dataframe(self, data: pl.LazyFrame | pl.DataFrame | None) -> pl.DataFrame:
"""Ensure data is an eager DataFrame, collecting if necessary."""
@@ -100,198 +212,156 @@ class JPMCPlotsMixin:
return df.collect()
return df
+ def _clean_voice_label(self, col_name: str) -> str:
+ """Extract and clean voice name from column name for display.
+
+ Handles patterns like:
+ - 'Voice_Scale__The_Coach' -> 'The Coach'
+ - 'Character_Ranking_The_Coach' -> 'The Coach'
+ - 'Top_3_Voices_ranking__Familiar_Friend' -> 'Familiar Friend'
+ """
+ # First split by __ if present
+ label = col_name.split('__')[-1] if '__' in col_name else col_name
+ # Remove common prefixes
+ label = label.replace('Character_Ranking_', '')
+ label = label.replace('Top_3_Voices_ranking_', '')
+ # Replace underscores with spaces
+ label = label.replace('_', ' ').strip()
+ return label
+
def plot_average_scores_with_counts(
self,
data: pl.LazyFrame | pl.DataFrame | None = None,
- title: str = "General Impression (1-10)
Per Voice with Number of Participants Who Rated It",
+ title: str = "General Impression (1-10)\nPer Voice with Number of Participants Who Rated It",
x_label: str = "Stimuli",
y_label: str = "Average General Impression Rating (1-10)",
color: str = ColorPalette.PRIMARY,
height: int | None = None,
- width: int | None = None,
- ) -> go.Figure:
- """
- Create a bar plot showing average scores and count of non-null values for each column.
- """
+ width: int | str | None = None,
+ ) -> alt.Chart:
+ """Create a bar plot showing average scores and count of non-null values for each column."""
df = self._ensure_dataframe(data)
- # Exclude _recordId column
+ # Calculate stats for each column (exclude _recordId)
stats = []
for col in [c for c in df.columns if c != '_recordId']:
avg_score = df[col].mean()
non_null_count = df[col].drop_nulls().len()
+ label = self._clean_voice_label(col)
stats.append({
- 'column': col,
+ 'voice': label,
'average': avg_score,
'count': non_null_count
})
- # Sort by average score in descending order
- stats_df = pl.DataFrame(stats).sort('average', descending=True)
+ # Convert to pandas for Altair (sort by average descending)
+ stats_df = pl.DataFrame(stats).sort('average', descending=True).to_pandas()
- # Extract voice identifiers from column names (e.g., "V14" from "Voice_Scale_1_10__V14")
- labels = [col.split('__')[-1] if '__' in col else col for col in stats_df['column']]
-
- # Create the plot
- fig = go.Figure()
-
- fig.add_trace(go.Bar(
- x=labels,
- y=stats_df['average'],
- text=stats_df['count'],
- textposition='inside',
- textfont=dict(size=10, color='black'),
- marker_color=color,
- hovertemplate='%{x}
Average: %{y:.2f}
Count: %{text}'
- ))
-
- fig.update_layout(
- title=title,
- xaxis_title=x_label,
- yaxis_title=y_label,
- height=height if height else getattr(self, 'plot_height', 500),
- width=width if width else getattr(self, 'plot_width', 1000),
- plot_bgcolor=ColorPalette.BACKGROUND,
- xaxis=dict(
- showgrid=True,
- gridcolor=ColorPalette.GRID,
- tickangle=-45
- ),
- yaxis=dict(
- range=[0, 10],
- showgrid=True,
- gridcolor=ColorPalette.GRID
- ),
- font=dict(size=11)
+ # Base bar chart
+ bars = alt.Chart(stats_df).mark_bar(color=color).encode(
+ x=alt.X('voice:N', title=x_label, sort='-y'),
+ y=alt.Y('average:Q', title=y_label, scale=alt.Scale(domain=[0, 10])),
+ tooltip=[
+ alt.Tooltip('voice:N', title='Voice'),
+ alt.Tooltip('average:Q', title='Average', format='.2f'),
+ alt.Tooltip('count:Q', title='Count')
+ ]
)
- self._save_plot(fig, title)
- return fig
+ # Text overlay for counts
+ text = alt.Chart(stats_df).mark_text(
+ dy=-5,
+ color='black',
+ fontSize=10
+ ).encode(
+ x=alt.X('voice:N', sort='-y'),
+ y=alt.Y('average:Q'),
+ text=alt.Text('count:Q')
+ )
+
+ # Combine layers
+ chart = (bars + text).properties(
+ title=title,
+ width=width if width else 'container',
+ height=height or getattr(self, 'plot_height', 400)
+ )
+
+ chart = self._save_plot(chart, title)
+ return chart
def plot_top3_ranking_distribution(
self,
data: pl.LazyFrame | pl.DataFrame | None = None,
- title: str = "Top 3 Rankings Distribution
Count of 1st, 2nd, and 3rd Place Votes per Voice",
+ title: str = "Top 3 Rankings Distribution\nCount of 1st, 2nd, and 3rd Place Votes per Voice",
x_label: str = "Voices",
y_label: str = "Number of Mentions in Top 3",
height: int | None = None,
- width: int | None = None,
- ) -> go.Figure:
- """
- Create a stacked bar chart showing how often each voice was ranked 1st, 2nd, or 3rd.
- """
+ width: int | str | None = None,
+ ) -> alt.Chart:
+ """Create a stacked bar chart showing how often each voice was ranked 1st, 2nd, or 3rd."""
df = self._ensure_dataframe(data)
- # Exclude _recordId column
+ # Calculate stats per column
stats = []
for col in [c for c in df.columns if c != '_recordId']:
- # Count occurrences of each rank (1, 2, 3)
- # We ensure we're just counting the specific integer values
rank1 = df.filter(pl.col(col) == 1).height
rank2 = df.filter(pl.col(col) == 2).height
rank3 = df.filter(pl.col(col) == 3).height
total = rank1 + rank2 + rank3
- # Only include if it received at least one vote (optional, but keeps chart clean)
if total > 0:
- stats.append({
- 'column': col,
- 'Rank 1': rank1,
- 'Rank 2': rank2,
- 'Rank 3': rank3,
- 'Total': total
- })
+ label = self._clean_voice_label(col)
+ # Add 3 rows (one per rank)
+ stats.append({'voice': label, 'rank': 'Rank 1 (1st Choice)', 'count': rank1, 'total': total})
+ stats.append({'voice': label, 'rank': 'Rank 2 (2nd Choice)', 'count': rank2, 'total': total})
+ stats.append({'voice': label, 'rank': 'Rank 3 (3rd Choice)', 'count': rank3, 'total': total})
- # Sort by Total count descending (Most popular overall)
- # Tie-break with Rank 1 count
- stats_df = pl.DataFrame(stats).sort(['Total', 'Rank 1'], descending=[True, True])
-
- # Extract voice identifiers from column names
- labels = [col.split('__')[-1] if '__' in col else col for col in stats_df['column']]
-
- fig = go.Figure()
-
- # Add traces for Rank 1, 2, and 3.
- # Stack order: Rank 1 at bottom (Base) -> Rank 2 -> Rank 3
- # This makes it easy to compare the "First Choice" volume across bars.
-
- fig.add_trace(go.Bar(
- name='Rank 1 (1st Choice)',
- x=labels,
- y=stats_df['Rank 1'],
- marker_color=ColorPalette.RANK_1,
- hovertemplate='%{x}
Rank 1: %{y}'
- ))
-
- fig.add_trace(go.Bar(
- name='Rank 2 (2nd Choice)',
- x=labels,
- y=stats_df['Rank 2'],
- marker_color=ColorPalette.RANK_2,
- hovertemplate='%{x}
Rank 2: %{y}'
- ))
-
- fig.add_trace(go.Bar(
- name='Rank 3 (3rd Choice)',
- x=labels,
- y=stats_df['Rank 3'],
- marker_color=ColorPalette.RANK_3,
- hovertemplate='%{x}
Rank 3: %{y}'
- ))
-
- fig.update_layout(
- barmode='stack',
+ # Convert to long format, sort by total
+ stats_df = pl.DataFrame(stats).to_pandas()
+
+ # Interactive legend selection - click to filter
+ selection = alt.selection_point(fields=['rank'], bind='legend')
+
+ # Create stacked bar chart with interactive legend
+ chart = alt.Chart(stats_df).mark_bar().encode(
+ x=alt.X('voice:N', title=x_label, sort=alt.EncodingSortField(field='total', op='sum', order='descending')),
+ y=alt.Y('count:Q', title=y_label, stack='zero'),
+ color=alt.Color('rank:N',
+ scale=alt.Scale(domain=['Rank 1 (1st Choice)', 'Rank 2 (2nd Choice)', 'Rank 3 (3rd Choice)'],
+ range=[ColorPalette.RANK_1, ColorPalette.RANK_2, ColorPalette.RANK_3]),
+ legend=alt.Legend(orient='top', direction='horizontal', title=None)),
+ order=alt.Order('rank:N', sort='ascending'),
+ opacity=alt.condition(selection, alt.value(1), alt.value(0.2)),
+ tooltip=[
+ alt.Tooltip('voice:N', title='Voice'),
+ alt.Tooltip('rank:N', title='Rank'),
+ alt.Tooltip('count:Q', title='Count')
+ ]
+ ).add_params(selection).properties(
title=title,
- xaxis_title=x_label,
- yaxis_title=y_label,
- height=height if height else getattr(self, 'plot_height', 500),
- width=width if width else getattr(self, 'plot_width', 1000),
- plot_bgcolor=ColorPalette.BACKGROUND,
- xaxis=dict(
- showgrid=True,
- gridcolor=ColorPalette.GRID,
- tickangle=-45
- ),
- yaxis=dict(
- showgrid=True,
- gridcolor=ColorPalette.GRID
- ),
- legend=dict(
- orientation="h",
- yanchor="bottom",
- y=1.02,
- xanchor="right",
- x=1,
- traceorder="normal"
- ),
- font=dict(size=11)
+ width=width if width else 'container',
+ height=height or getattr(self, 'plot_height', 400)
)
- self._save_plot(fig, title)
- return fig
+ chart = self._save_plot(chart, title)
+ return chart
def plot_ranking_distribution(
self,
data: pl.LazyFrame | pl.DataFrame | None = None,
- title: str = "Rankings Distribution
(1st to 4th Place)",
+ title: str = "Rankings Distribution\n(1st to 4th Place)",
x_label: str = "Item",
y_label: str = "Number of Votes",
height: int | None = None,
- width: int | None = None,
- ) -> go.Figure:
- """
- Create a stacked bar chart showing the distribution of rankings (1st to 4th) for characters or voices.
- Sorted by the number of Rank 1 votes.
- """
+ width: int | str | None = None,
+ ) -> alt.Chart:
+ """Create a stacked bar chart showing the distribution of rankings (1st to 4th)."""
df = self._ensure_dataframe(data)
stats = []
- # Identify ranking columns (assume all columns except _recordId)
ranking_cols = [c for c in df.columns if c != '_recordId']
for col in ranking_cols:
- # Count occurrences of each rank (1, 2, 3, 4)
- # Using height/len to count rows in the filtered frame
r1 = df.filter(pl.col(col) == 1).height
r2 = df.filter(pl.col(col) == 2).height
r3 = df.filter(pl.col(col) == 3).height
@@ -299,248 +369,154 @@ class JPMCPlotsMixin:
total = r1 + r2 + r3 + r4
if total > 0:
- stats.append({
- 'column': col,
- 'Rank 1': r1,
- 'Rank 2': r2,
- 'Rank 3': r3,
- 'Rank 4': r4
- })
+ label = self._clean_voice_label(col)
+ stats.append({'item': label, 'rank': 'Rank 1 (Best)', 'count': r1, 'rank1': r1})
+ stats.append({'item': label, 'rank': 'Rank 2', 'count': r2, 'rank1': r1})
+ stats.append({'item': label, 'rank': 'Rank 3', 'count': r3, 'rank1': r1})
+ stats.append({'item': label, 'rank': 'Rank 4 (Worst)', 'count': r4, 'rank1': r1})
if not stats:
- return go.Figure()
+ return alt.Chart(pd.DataFrame({'text': ['No data']})).mark_text().encode(text='text:N')
- # Sort by Rank 1 (Most "Best" votes) descending to show the winner first
- # Secondary sort by Rank 2
- stats_df = pl.DataFrame(stats).sort(['Rank 1', 'Rank 2'], descending=[True, True])
+ stats_df = pl.DataFrame(stats).to_pandas()
- # Clean up labels: Remove prefix and underscores
- # e.g. "Character_Ranking_The_Coach" -> "The Coach"
- labels = [
- col.replace('Character_Ranking_', '').replace('Top_3_Voices_ranking__', '').replace('_', ' ').strip()
- for col in stats_df['column']
- ]
+ # Interactive legend selection - click to filter
+ selection = alt.selection_point(fields=['rank'], bind='legend')
- fig = go.Figure()
-
- # Rank 1 (Best)
- fig.add_trace(go.Bar(
- name='Rank 1 (Best)',
- x=labels,
- y=stats_df['Rank 1'],
- marker_color=ColorPalette.RANK_1,
- hovertemplate='%{x}
Rank 1: %{y}'
- ))
-
- # Rank 2
- fig.add_trace(go.Bar(
- name='Rank 2',
- x=labels,
- y=stats_df['Rank 2'],
- marker_color=ColorPalette.RANK_2,
- hovertemplate='%{x}
Rank 2: %{y}'
- ))
-
- # Rank 3
- fig.add_trace(go.Bar(
- name='Rank 3',
- x=labels,
- y=stats_df['Rank 3'],
- marker_color=ColorPalette.RANK_3,
- hovertemplate='%{x}
Rank 3: %{y}'
- ))
-
- # Rank 4 (Worst)
- # Using a neutral grey as a fallback for the lowest rank to keep focus on top ranks
- fig.add_trace(go.Bar(
- name='Rank 4 (Worst)',
- x=labels,
- y=stats_df['Rank 4'],
- marker_color=ColorPalette.RANK_4,
- hovertemplate='%{x}
Rank 4: %{y}'
- ))
-
- fig.update_layout(
- barmode='stack',
+ chart = alt.Chart(stats_df).mark_bar().encode(
+ x=alt.X('item:N', title=x_label, sort=alt.EncodingSortField(field='rank1', order='descending')),
+ y=alt.Y('count:Q', title=y_label, stack='zero'),
+ color=alt.Color('rank:N',
+ scale=alt.Scale(domain=['Rank 1 (Best)', 'Rank 2', 'Rank 3', 'Rank 4 (Worst)'],
+ range=[ColorPalette.RANK_1, ColorPalette.RANK_2, ColorPalette.RANK_3, ColorPalette.RANK_4]),
+ legend=alt.Legend(orient='top', direction='horizontal', title=None)),
+ order=alt.Order('rank:N', sort='ascending'),
+ opacity=alt.condition(selection, alt.value(1), alt.value(0.2)),
+ tooltip=[
+ alt.Tooltip('item:N', title='Item'),
+ alt.Tooltip('rank:N', title='Rank'),
+ alt.Tooltip('count:Q', title='Count')
+ ]
+ ).add_params(selection).properties(
title=title,
- xaxis_title=x_label,
- yaxis_title=y_label,
- height=height if height else getattr(self, 'plot_height', 500),
- width=width if width else getattr(self, 'plot_width', 1000),
- plot_bgcolor=ColorPalette.BACKGROUND,
- xaxis=dict(
- showgrid=True,
- gridcolor=ColorPalette.GRID,
- tickangle=-45
- ),
- yaxis=dict(
- showgrid=True,
- gridcolor=ColorPalette.GRID
- ),
- legend=dict(
- orientation="h",
- yanchor="bottom",
- y=1.02,
- xanchor="right",
- x=1,
- traceorder="normal"
- ),
- font=dict(size=11)
+ width=width if width else 'container',
+ height=height or getattr(self, 'plot_height', 400)
)
- self._save_plot(fig, title)
- return fig
+ chart = self._save_plot(chart, title)
+ return chart
def plot_most_ranked_1(
self,
data: pl.LazyFrame | pl.DataFrame | None = None,
- title: str = "Most Popular Choice
(Number of Times Ranked 1st)",
+ title: str = "Most Popular Choice\n(Number of Times Ranked 1st)",
x_label: str = "Item",
y_label: str = "Count of 1st Place Rankings",
height: int | None = None,
- width: int | None = None,
- ) -> go.Figure:
- """
- Create a bar chart showing which item (character/voice) was ranked #1 the most.
- Top 3 items are highlighted.
- """
+ width: int | str | None = None,
+ ) -> alt.Chart:
+ """Create a bar chart showing which item was ranked #1 the most. Top 3 highlighted."""
df = self._ensure_dataframe(data)
stats = []
- # Identify ranking columns (assume all columns except _recordId)
ranking_cols = [c for c in df.columns if c != '_recordId']
for col in ranking_cols:
- # Count occurrences of rank 1
count_rank_1 = df.filter(pl.col(col) == 1).height
-
- stats.append({
- 'column': col,
- 'count': count_rank_1
- })
+ label = self._clean_voice_label(col)
+ stats.append({'item': label, 'count': count_rank_1})
- # Sort by count descending
+ # Convert and sort
stats_df = pl.DataFrame(stats).sort('count', descending=True)
-
- # Clean up labels
- labels = [
- col.replace('Character_Ranking_', '').replace('Top_3_Voices_ranking__', '').replace('_', ' ').strip()
- for col in stats_df['column']
- ]
-
- # Assign colors: Top 3 get PRIMARY (Blue), others get NEUTRAL (Grey)
- colors = [
- ColorPalette.PRIMARY if i < 3 else ColorPalette.NEUTRAL
- for i in range(len(stats_df))
- ]
-
- fig = go.Figure()
- fig.add_trace(go.Bar(
- x=labels,
- y=stats_df['count'],
- text=stats_df['count'],
- textposition='inside',
- textfont=dict(size=10, color='white'),
- marker_color=colors,
- hovertemplate='%{x}
1st Place Votes: %{y}'
- ))
+ # Add rank column for coloring (1-3 vs 4+)
+ stats_df = stats_df.with_row_index('rank_index')
+ stats_df = stats_df.with_columns(
+ pl.when(pl.col('rank_index') < 3)
+ .then(pl.lit('Top 3'))
+ .otherwise(pl.lit('Other'))
+ .alias('category')
+ ).to_pandas()
- fig.update_layout(
+ # Bar chart with conditional color
+ chart = alt.Chart(stats_df).mark_bar().encode(
+ x=alt.X('item:N', title=x_label, sort='-y'),
+ y=alt.Y('count:Q', title=y_label),
+ color=alt.Color('category:N',
+ scale=alt.Scale(domain=['Top 3', 'Other'],
+ range=[ColorPalette.PRIMARY, ColorPalette.NEUTRAL]),
+ legend=None),
+ tooltip=[
+ alt.Tooltip('item:N', title='Item'),
+ alt.Tooltip('count:Q', title='1st Place Votes')
+ ]
+ ).properties(
title=title,
- xaxis_title=x_label,
- yaxis_title=y_label,
- height=height if height else getattr(self, 'plot_height', 500),
- width=width if width else getattr(self, 'plot_width', 1000),
- plot_bgcolor=ColorPalette.BACKGROUND,
- xaxis=dict(
- showgrid=True,
- gridcolor=ColorPalette.GRID,
- tickangle=-45
- ),
- yaxis=dict(
- showgrid=True,
- gridcolor=ColorPalette.GRID
- ),
- font=dict(size=11)
+ width=width if width else 'container',
+ height=height or getattr(self, 'plot_height', 400)
)
- self._save_plot(fig, title)
- return fig
+ chart = self._save_plot(chart, title)
+ return chart
def plot_weighted_ranking_score(
self,
data: pl.LazyFrame | pl.DataFrame | None = None,
- title: str = "Weighted Popularity Score
(1st=3pts, 2nd=2pts, 3rd=1pt)",
+ title: str = "Weighted Popularity Score\n(1st=3pts, 2nd=2pts, 3rd=1pt)",
x_label: str = "Character Personality",
y_label: str = "Total Weighted Score",
color: str = ColorPalette.PRIMARY,
height: int | None = None,
- width: int | None = None,
- ) -> go.Figure:
- """
- Create a bar chart showing the weighted ranking score for each character.
- """
- weighted_df = self._ensure_dataframe(data)
+ width: int | str | None = None,
+ ) -> alt.Chart:
+ """Create a bar chart showing the weighted ranking score for each character."""
+ weighted_df = self._ensure_dataframe(data).to_pandas()
- fig = go.Figure()
-
- fig.add_trace(go.Bar(
- x=weighted_df['Character'],
- y=weighted_df['Weighted Score'],
- text=weighted_df['Weighted Score'],
- textposition='inside',
- textfont=dict(size=11, color='white'),
- marker_color=color,
- hovertemplate='%{x}
Score: %{y}'
- ))
-
- fig.update_layout(
- title=title,
- xaxis_title=x_label,
- yaxis_title=y_label,
- height=height if height else getattr(self, 'plot_height', 500),
- width=width if width else getattr(self, 'plot_width', 1000),
- plot_bgcolor=ColorPalette.BACKGROUND,
- xaxis=dict(
- showgrid=True,
- gridcolor=ColorPalette.GRID,
- tickangle=-45
- ),
- yaxis=dict(
- showgrid=True,
- gridcolor=ColorPalette.GRID
- ),
- font=dict(size=11)
+ # Bar chart
+ bars = alt.Chart(weighted_df).mark_bar(color=color).encode(
+ x=alt.X('Character:N', title=x_label),
+ y=alt.Y('Weighted Score:Q', title=y_label),
+ tooltip=[
+ alt.Tooltip('Character:N'),
+ alt.Tooltip('Weighted Score:Q', title='Score')
+ ]
)
- self._save_plot(fig, title)
- return fig
+ # Text overlay
+ text = bars.mark_text(
+ dy=-5,
+ color='white',
+ fontSize=11
+ ).encode(
+ text='Weighted Score:Q'
+ )
+
+ chart = (bars + text).properties(
+ title=title,
+ width=width if width else 'container',
+ height=height or getattr(self, 'plot_height', 400)
+ )
+
+ chart = self._save_plot(chart, title)
+ return chart
def plot_voice_selection_counts(
self,
data: pl.LazyFrame | pl.DataFrame | None = None,
target_column: str = "8_Combined",
- title: str = "Most Frequently Chosen Voices
(Top 8 Highlighted)",
+ title: str = "Most Frequently Chosen Voices\n(Top 8 Highlighted)",
x_label: str = "Voice",
y_label: str = "Number of Times Chosen",
height: int | None = None,
- width: int | None = None,
- ) -> go.Figure:
- """
- Create a bar plot showing the frequency of voice selections.
- """
+ width: int | str | None = None,
+ ) -> alt.Chart:
+ """Create a bar plot showing the frequency of voice selections."""
df = self._ensure_dataframe(data)
if target_column not in df.columns:
- return go.Figure()
+ return alt.Chart(pd.DataFrame({'text': [f"Column '{target_column}' not found"]})).mark_text().encode(text='text:N')
- # Process the data:
- # 1. Select the relevant column and remove nulls
- # 2. Split the comma-separated string into a list
- # 3. Explode the list so each voice gets its own row
- # 4. Strip whitespace ensuring "Voice 1" and " Voice 1" match
- # 5. Count occurrences
+ # Process data: split, explode, count
stats_df = (
df.select(pl.col(target_column))
.drop_nulls()
@@ -551,67 +527,52 @@ class JPMCPlotsMixin:
.group_by(target_column)
.agg(pl.len().alias("count"))
.sort("count", descending=True)
+ .with_row_index('rank_index')
+ .with_columns(
+ pl.when(pl.col('rank_index') < 8)
+ .then(pl.lit('Top 8'))
+ .otherwise(pl.lit('Other'))
+ .alias('category')
+ )
+ .to_pandas()
)
- # Define colors: Top 8 get PRIMARY, rest get NEUTRAL
- colors = [
- ColorPalette.PRIMARY if i < 8 else ColorPalette.NEUTRAL
- for i in range(len(stats_df))
- ]
-
- fig = go.Figure()
-
- fig.add_trace(go.Bar(
- x=stats_df[target_column],
- y=stats_df['count'],
- text=stats_df['count'],
- textposition='outside',
- marker_color=colors,
- hovertemplate='%{x}
Selections: %{y}'
- ))
-
- fig.update_layout(
+ chart = alt.Chart(stats_df).mark_bar().encode(
+ x=alt.X(f'{target_column}:N', title=x_label, sort='-y'),
+ y=alt.Y('count:Q', title=y_label),
+ color=alt.Color('category:N',
+ scale=alt.Scale(domain=['Top 8', 'Other'],
+ range=[ColorPalette.PRIMARY, ColorPalette.NEUTRAL]),
+ legend=None),
+ tooltip=[
+ alt.Tooltip(f'{target_column}:N', title='Voice'),
+ alt.Tooltip('count:Q', title='Selections')
+ ]
+ ).properties(
title=title,
- xaxis_title=x_label,
- yaxis_title=y_label,
- height=height if height else getattr(self, 'plot_height', 500),
- width=width if width else getattr(self, 'plot_width', 1000),
- plot_bgcolor=ColorPalette.BACKGROUND,
- xaxis=dict(
- showgrid=True,
- gridcolor=ColorPalette.GRID,
- tickangle=-45
- ),
- yaxis=dict(
- showgrid=True,
- gridcolor=ColorPalette.GRID
- ),
- font=dict(size=11),
+ width=width if width else 'container',
+ height=height or getattr(self, 'plot_height', 400)
)
- self._save_plot(fig, title)
- return fig
+ chart = self._save_plot(chart, title)
+ return chart
def plot_top3_selection_counts(
self,
data: pl.LazyFrame | pl.DataFrame | None = None,
target_column: str = "3_Ranked",
- title: str = "Most Frequently Chosen Top 3 Voices
(Top 3 Highlighted)",
+ title: str = "Most Frequently Chosen Top 3 Voices\n(Top 3 Highlighted)",
x_label: str = "Voice",
y_label: str = "Count of Mentions in Top 3",
height: int | None = None,
- width: int | None = None,
- ) -> go.Figure:
- """
- Question: Which 3 voices are chosen the most out of 18?
- """
+ width: int | str | None = None,
+ ) -> alt.Chart:
+ """Question: Which 3 voices are chosen the most out of 18?"""
df = self._ensure_dataframe(data)
if target_column not in df.columns:
- return go.Figure()
+ return alt.Chart(pd.DataFrame({'text': [f"Column '{target_column}' not found"]})).mark_text().encode(text='text:N')
- # Process the data:
- # Same logic as plot_voice_selection_counts: explode comma-separated string
stats_df = (
df.select(pl.col(target_column))
.drop_nulls()
@@ -622,46 +583,35 @@ class JPMCPlotsMixin:
.group_by(target_column)
.agg(pl.len().alias("count"))
.sort("count", descending=True)
+ .with_row_index('rank_index')
+ .with_columns(
+ pl.when(pl.col('rank_index') < 3)
+ .then(pl.lit('Top 3'))
+ .otherwise(pl.lit('Other'))
+ .alias('category')
+ )
+ .to_pandas()
)
- # Define colors: Top 3 get PRIMARY, rest get NEUTRAL
- colors = [
- ColorPalette.PRIMARY if i < 3 else ColorPalette.NEUTRAL
- for i in range(len(stats_df))
- ]
-
- fig = go.Figure()
-
- fig.add_trace(go.Bar(
- x=stats_df[target_column],
- y=stats_df['count'],
- text=stats_df['count'],
- textposition='outside',
- marker_color=colors,
- hovertemplate='%{x}
In Top 3: %{y} times'
- ))
-
- fig.update_layout(
+ chart = alt.Chart(stats_df).mark_bar().encode(
+ x=alt.X(f'{target_column}:N', title=x_label, sort='-y'),
+ y=alt.Y('count:Q', title=y_label),
+ color=alt.Color('category:N',
+ scale=alt.Scale(domain=['Top 3', 'Other'],
+ range=[ColorPalette.PRIMARY, ColorPalette.NEUTRAL]),
+ legend=None),
+ tooltip=[
+ alt.Tooltip(f'{target_column}:N', title='Voice'),
+ alt.Tooltip('count:Q', title='In Top 3')
+ ]
+ ).properties(
title=title,
- xaxis_title=x_label,
- yaxis_title=y_label,
- height=height if height else getattr(self, 'plot_height', 500),
- width=width if width else getattr(self, 'plot_width', 1000),
- plot_bgcolor=ColorPalette.BACKGROUND,
- xaxis=dict(
- showgrid=True,
- gridcolor=ColorPalette.GRID,
- tickangle=-45
- ),
- yaxis=dict(
- showgrid=True,
- gridcolor=ColorPalette.GRID
- ),
- font=dict(size=11),
+ width=width if width else 'container',
+ height=height or getattr(self, 'plot_height', 400)
)
- self._save_plot(fig, title)
- return fig
+ chart = self._save_plot(chart, title)
+ return chart
def plot_speaking_style_trait_scores(
self,
@@ -671,19 +621,17 @@ class JPMCPlotsMixin:
right_anchor: str = None,
title: str = "Speaking Style Trait Analysis",
height: int | None = None,
- width: int | None = None,
- ) -> go.Figure:
- """
- Plot scores for a single speaking style trait across multiple voices.
- """
+ width: int | str | None = None,
+ ) -> alt.Chart:
+ """Plot scores for a single speaking style trait across multiple voices."""
df = self._ensure_dataframe(data)
if df.is_empty():
- return go.Figure()
+ return alt.Chart(pd.DataFrame({'text': ['No data']})).mark_text().encode(text='text:N')
required_cols = ["Voice", "score"]
if not all(col in df.columns for col in required_cols):
- return go.Figure()
+ return alt.Chart(pd.DataFrame({'text': ['Missing required columns']})).mark_text().encode(text='text:N')
# Calculate stats: Mean, Count
stats = (
@@ -693,93 +641,61 @@ class JPMCPlotsMixin:
pl.col("score").mean().alias("mean_score"),
pl.col("score").count().alias("count")
])
- .sort("mean_score", descending=False) # Ascending for display bottom-to-top
+ .sort("mean_score", descending=False) # Ascending for bottom-to-top display
+ .to_pandas()
)
- # Attempt to extract anchors from DF if not provided
+ # Extract anchors from data if not provided
if (left_anchor is None or right_anchor is None) and "Left_Anchor" in df.columns:
head = df.filter(pl.col("Left_Anchor").is_not_null()).head(1)
if not head.is_empty():
- if left_anchor is None: left_anchor = head["Left_Anchor"][0]
- if right_anchor is None: right_anchor = head["Right_Anchor"][0]
+ if left_anchor is None:
+ left_anchor = head["Left_Anchor"][0]
+ if right_anchor is None:
+ right_anchor = head["Right_Anchor"][0]
if trait_description is None:
if left_anchor and right_anchor:
trait_description = f"{left_anchor.split('|')[0]} vs. {right_anchor.split('|')[0]}"
+ elif "Description" in df.columns:
+ head = df.filter(pl.col("Description").is_not_null()).head(1)
+ trait_description = head["Description"][0] if not head.is_empty() else ""
else:
- # Try getting from Description column
- if "Description" in df.columns:
- head = df.filter(pl.col("Description").is_not_null()).head(1)
- if not head.is_empty():
- trait_description = head["Description"][0]
- else:
- trait_description = ""
- else:
- trait_description = ""
+ trait_description = ""
- fig = go.Figure()
-
- fig.add_trace(go.Bar(
- y=stats["Voice"], # Y is Voice
- x=stats["mean_score"], # X is Score
- orientation='h',
- text=stats["count"],
- textposition='inside',
- textangle=0,
- textfont=dict(size=16, color='white'),
- texttemplate='%{text}', # Count on bar
- marker_color=ColorPalette.PRIMARY,
- hovertemplate='%{y}
Average: %{x:.2f}
Count: %{text}'
- ))
-
- # Add annotations for anchors
- annotations = []
-
- # Place anchors at the bottom
- if left_anchor:
- annotations.append(dict(
- xref='x', yref='paper',
- x=1, y=-0.2, # Below axis
- xanchor='left', yanchor='top',
- text=f"1: {left_anchor.split('|')[0]}",
- showarrow=False,
- font=dict(size=10, color='gray')
- ))
- if right_anchor:
- annotations.append(dict(
- xref='x', yref='paper',
- x=5, y=-0.2, # Below axis
- xanchor='right', yanchor='top',
- text=f"5: {right_anchor.split('|')[0]}",
- showarrow=False,
- font=dict(size=10, color='gray')
- ))
-
- fig.update_layout(
- title=dict(
- text=f"{title}
{trait_description}
(Numbers on bars indicate respondent count)",
- y=0.92
- ),
- xaxis_title="Average Score (1-5)",
- yaxis_title="Voice",
- height=height if height else getattr(self, 'plot_height', 500),
- width=width if width else getattr(self, 'plot_width', 1000),
- plot_bgcolor=ColorPalette.BACKGROUND,
- xaxis=dict(
- range=[1, 5],
- showgrid=True,
- gridcolor=ColorPalette.GRID,
- zeroline=False
- ),
- yaxis=dict(
- showgrid=False
- ),
- margin=dict(b=120),
- annotations=annotations,
- font=dict(size=11)
+ # Horizontal bar chart
+ bars = alt.Chart(stats).mark_bar(color=ColorPalette.PRIMARY).encode(
+ x=alt.X('mean_score:Q', title='Average Score (1-5)', scale=alt.Scale(domain=[1, 5])),
+ y=alt.Y('Voice:N', title='Voice', sort='-x'),
+ tooltip=[
+ alt.Tooltip('Voice:N'),
+ alt.Tooltip('mean_score:Q', title='Average', format='.2f'),
+ alt.Tooltip('count:Q', title='Count')
+ ]
)
- self._save_plot(fig, title)
- return fig
+
+ # Count text inside bars
+ text = bars.mark_text(
+ align='center',
+ baseline='middle',
+ color='white',
+ fontSize=16
+ ).encode(
+ text='count:Q'
+ )
+
+ # Combine
+ chart = (bars + text).properties(
+ title={
+ "text": title,
+ "subtitle": [trait_description, "(Numbers on bars indicate respondent count)"]
+ },
+ width=width if width else 'container',
+ height=height or getattr(self, 'plot_height', 400)
+ )
+
+ chart = self._save_plot(chart, title)
+ return chart
def plot_speaking_style_correlation(
self,
@@ -787,10 +703,10 @@ class JPMCPlotsMixin:
style_traits: list[str],
data: pl.LazyFrame | pl.DataFrame | None = None,
title: str | None = None,
- ) -> go.Figure:
- """
- Plots the correlation between Speaking Style Trait Scores (1-5) and Voice Scale (1-10) using a Bar Chart.
- """
+ width: int | str | None = None,
+ height: int | None = None,
+ ) -> alt.Chart:
+ """Plots correlation between Speaking Style Trait Scores (1-5) and Voice Scale (1-10)."""
df = self._ensure_dataframe(data)
if title is None:
@@ -798,80 +714,47 @@ class JPMCPlotsMixin:
trait_correlations = []
- # 1. Calculate Correlations
+ # Calculate correlations
for i, trait in enumerate(style_traits):
- # Match against Right_Anchor which contains the positive trait description
- # Use exact match for reliability
- subset = df.filter(
- pl.col("Right_Anchor") == trait
- )
-
- # Drop Nulls for correlation calculation
+ subset = df.filter(pl.col("Right_Anchor") == trait)
valid_data = subset.select(["score", "Voice_Scale_Score"]).drop_nulls()
if valid_data.height > 1:
- # Calculate Pearson Correlation
corr_val = valid_data.select(pl.corr("score", "Voice_Scale_Score")).item()
-
- # Trait Label for Plot
+ # Wrap trait text at '|' for display
+ trait_display = trait.replace('|', '\n')
trait_correlations.append({
- "trait_full": trait,
- "trait_short": f"Trait {i+1}",
+ "trait_display": trait_display,
+ "trait_index": f"Trait {i+1}",
"correlation": corr_val if corr_val is not None else 0.0
})
- # 2. Build Plot Data
if not trait_correlations:
- # Return empty fig with title
- fig = go.Figure()
- fig.update_layout(title=f"No data for {style_color} Style")
- return fig
+ return alt.Chart(pd.DataFrame({'text': [f"No data for {style_color} Style"]})).mark_text().encode(text='text:N')
- plot_df = pl.DataFrame(trait_correlations)
-
- # Determine colors based on correlation sign
- colors = []
- for val in plot_df["correlation"]:
- if val >= 0:
- colors.append("green") # Positive
- else:
- colors.append("red") # Negative
-
- fig = go.Figure()
-
- fig.add_trace(go.Bar(
- x=[f"Trait {i+1}" for i in range(len(plot_df))], # Simple Labels on Axis
- y=plot_df["correlation"],
- text=[f"{val:.2f}" for val in plot_df["correlation"]],
- textposition='outside', # Or auto
- marker_color=colors,
- hovertemplate="%{customdata}
Correlation: %{y:.2f}",
- customdata=plot_df["trait_full"] # Full text on hover
- ))
-
- # Wrap text at the "|" separator for cleaner line breaks
- def wrap_text_at_pipe(text):
- parts = [p.strip() for p in text.split("|")]
- return "
".join(parts)
-
- x_labels = [wrap_text_at_pipe(t) for t in plot_df["trait_full"]]
-
- # Update trace to use full labels
- fig.data[0].x = x_labels
-
- fig.update_layout(
+ plot_df = pl.DataFrame(trait_correlations).to_pandas()
+
+ # Conditional color based on sign
+ chart = alt.Chart(plot_df).mark_bar().encode(
+ x=alt.X('trait_display:N', title=None, axis=alt.Axis(labelAngle=0)),
+ y=alt.Y('correlation:Q', title='Correlation', scale=alt.Scale(domain=[-1, 1])),
+ color=alt.condition(
+ alt.datum.correlation >= 0,
+ alt.value('green'),
+ alt.value('red')
+ ),
+ tooltip=[
+ alt.Tooltip('trait_display:N', title='Trait'),
+ alt.Tooltip('correlation:Q', format='.2f')
+ ]
+ ).properties(
title=title,
- yaxis_title="Correlation",
- yaxis=dict(range=[-1, 1], zeroline=True, zerolinecolor="black"),
- xaxis=dict(tickangle=0), # Keep flat if possible
- height=400, # Use fixed default from original
- width=1000,
- template="plotly_white",
- showlegend=False
+ width=width if width else 'container',
+ height=height or 350
)
-
- self._save_plot(fig, title)
- return fig
+
+ chart = self._save_plot(chart, title)
+ return chart
def plot_speaking_style_ranking_correlation(
self,
@@ -879,10 +762,10 @@ class JPMCPlotsMixin:
style_traits: list[str],
data: pl.LazyFrame | pl.DataFrame | None = None,
title: str | None = None,
- ) -> go.Figure:
- """
- Plots the correlation between Speaking Style Trait Scores (1-5) and Voice Ranking Points (0-3).
- """
+ width: int | str | None = None,
+ height: int | None = None,
+ ) -> alt.Chart:
+ """Plots correlation between Speaking Style Trait Scores (1-5) and Voice Ranking Points (0-3)."""
df = self._ensure_dataframe(data)
if title is None:
@@ -890,72 +773,41 @@ class JPMCPlotsMixin:
trait_correlations = []
- # 1. Calculate Correlations
for i, trait in enumerate(style_traits):
- # Match against Right_Anchor which contains the positive trait description
subset = df.filter(pl.col("Right_Anchor") == trait)
-
- # Drop Nulls for correlation calculation
valid_data = subset.select(["score", "Ranking_Points"]).drop_nulls()
if valid_data.height > 1:
- # Calculate Pearson Correlation
corr_val = valid_data.select(pl.corr("score", "Ranking_Points")).item()
-
+ trait_display = trait.replace('|', '\n')
trait_correlations.append({
- "trait_full": trait,
- "trait_short": f"Trait {i+1}",
+ "trait_display": trait_display,
+ "trait_index": f"Trait {i+1}",
"correlation": corr_val if corr_val is not None else 0.0
})
- # 2. Build Plot Data
if not trait_correlations:
- fig = go.Figure()
- fig.update_layout(title=f"No data for {style_color} Style")
- return fig
+ return alt.Chart(pd.DataFrame({'text': [f"No data for {style_color} Style"]})).mark_text().encode(text='text:N')
- plot_df = pl.DataFrame(trait_correlations)
-
- # Determine colors based on correlation sign
- colors = []
- for val in plot_df["correlation"]:
- if val >= 0:
- colors.append("green")
- else:
- colors.append("red")
-
- fig = go.Figure()
-
- fig.add_trace(go.Bar(
- x=[f"Trait {i+1}" for i in range(len(plot_df))],
- y=plot_df["correlation"],
- text=[f"{val:.2f}" for val in plot_df["correlation"]],
- textposition='outside',
- marker_color=colors,
- hovertemplate="%{customdata}
Correlation: %{y:.2f}",
- customdata=plot_df["trait_full"]
- ))
-
- # Wrap text at the "|" separator for cleaner line breaks
- def wrap_text_at_pipe(text):
- parts = [p.strip() for p in text.split("|")]
- return "
".join(parts)
-
- x_labels = [wrap_text_at_pipe(t) for t in plot_df["trait_full"]]
-
- # Update trace to use full labels
- fig.data[0].x = x_labels
-
- fig.update_layout(
+ plot_df = pl.DataFrame(trait_correlations).to_pandas()
+
+ chart = alt.Chart(plot_df).mark_bar().encode(
+ x=alt.X('trait_display:N', title=None, axis=alt.Axis(labelAngle=0)),
+ y=alt.Y('correlation:Q', title='Correlation', scale=alt.Scale(domain=[-1, 1])),
+ color=alt.condition(
+ alt.datum.correlation >= 0,
+ alt.value('green'),
+ alt.value('red')
+ ),
+ tooltip=[
+ alt.Tooltip('trait_display:N', title='Trait'),
+ alt.Tooltip('correlation:Q', format='.2f')
+ ]
+ ).properties(
title=title,
- yaxis_title="Correlation",
- yaxis=dict(range=[-1, 1], zeroline=True, zerolinecolor="black"),
- xaxis=dict(tickangle=0),
- height=400,
- width=1000,
- template="plotly_white",
- showlegend=False
+ width=width if width else 'container',
+ height=height or 350
)
-
- self._save_plot(fig, title)
- return fig
+
+ chart = self._save_plot(chart, title)
+ return chart
diff --git a/pyproject.toml b/pyproject.toml
index 6c70c31..5fb45e7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,6 @@ readme = "README.md"
requires-python = ">=3.12"
dependencies = [
"altair>=6.0.0",
- "kaleido>=1.2.0",
"marimo>=0.18.0",
"matplotlib>=3.10.8",
"modin[dask]>=0.37.1",
@@ -15,12 +14,13 @@ dependencies = [
"openai>=2.9.0",
"openpyxl>=3.1.5",
"pandas>=2.3.3",
- "plotly>=6.5.1",
"polars>=1.37.1",
+ "pyarrow>=23.0.0",
"pysqlite3>=0.6.0",
"pyzmq>=27.1.0",
"requests>=2.32.5",
"taguette>=1.5.1",
+ "vl-convert-python>=1.9.0.post1",
"wordcloud>=1.9.5",
]
diff --git a/theme.py b/theme.py
index fac1325..aa15845 100644
--- a/theme.py
+++ b/theme.py
@@ -23,3 +23,58 @@ class ColorPalette:
TEXT = "black"
GRID = "lightgray"
BACKGROUND = "white"
+
+
+def jpmc_altair_theme():
+ """JPMC brand theme for Altair charts."""
+ return {
+ 'config': {
+ 'view': {
+ 'continuousWidth': 1000,
+ 'continuousHeight': 500,
+ 'strokeWidth': 0
+ },
+ 'background': ColorPalette.BACKGROUND,
+ 'axis': {
+ 'grid': True,
+ 'gridColor': ColorPalette.GRID,
+ 'labelFontSize': 11,
+ 'titleFontSize': 12,
+ 'labelColor': ColorPalette.TEXT,
+ 'titleColor': ColorPalette.TEXT,
+ 'labelLimit': 200 # Allow longer labels before truncation
+ },
+ 'axisX': {
+ 'labelAngle': -45,
+ 'labelLimit': 200 # Allow longer x-axis labels
+ },
+ 'axisY': {
+ 'labelAngle': 0
+ },
+ 'legend': {
+ 'orient': 'top',
+ 'direction': 'horizontal',
+ 'titleFontSize': 11,
+ 'labelFontSize': 11
+ },
+ 'title': {
+ 'fontSize': 14,
+ 'color': ColorPalette.TEXT,
+ 'anchor': 'start',
+ 'subtitleFontSize': 10,
+ 'subtitleColor': 'gray'
+ },
+ 'bar': {
+ 'color': ColorPalette.PRIMARY
+ }
+ }
+ }
+
+
+# Register Altair theme
+try:
+ import altair as alt
+ alt.themes.register('jpmc', jpmc_altair_theme)
+ alt.themes.enable('jpmc')
+except ImportError:
+ pass # Altair not installed
diff --git a/uv.lock b/uv.lock
index ada9950..0883f2b 100644
--- a/uv.lock
+++ b/uv.lock
@@ -220,19 +220,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
-[[package]]
-name = "choreographer"
-version = "1.2.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "logistro" },
- { name = "simplejson" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/74/47/64a035c6f764450ea9f902cbeba14c8c70316c2641125510066d8f912bfa/choreographer-1.2.1.tar.gz", hash = "sha256:022afd72b1e9b0bcb950420b134e70055a294c791b6f36cfb47d89745b701b5f", size = 43399, upload-time = "2025-11-09T23:04:44.749Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/9f/d73dfb85d7a5b1a56a99adc50f2074029468168c970ff5daeade4ad819e4/choreographer-1.2.1-py3-none-any.whl", hash = "sha256:9af5385effa3c204dbc337abf7ac74fd8908ced326a15645dc31dde75718c77e", size = 49338, upload-time = "2025-11-09T23:04:43.154Z" },
-]
-
[[package]]
name = "click"
version = "8.3.1"
@@ -578,15 +565,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" },
]
-[[package]]
-name = "iniconfig"
-version = "2.3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
-]
-
[[package]]
name = "itsdangerous"
version = "2.2.0"
@@ -715,22 +693,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
]
-[[package]]
-name = "kaleido"
-version = "1.2.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "choreographer" },
- { name = "logistro" },
- { name = "orjson" },
- { name = "packaging" },
- { name = "pytest-timeout" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/38/ad/76eec859b71eda803a88ea50ed3f270281254656bb23d19eb0a39aa706a0/kaleido-1.2.0.tar.gz", hash = "sha256:fa621a14423e8effa2895a2526be00af0cf21655be1b74b7e382c171d12e71ef", size = 64160, upload-time = "2025-11-04T21:24:23.833Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4b/97/f6de8d4af54d6401d6581a686cce3e3e2371a79ba459a449104e026c08bc/kaleido-1.2.0-py3-none-any.whl", hash = "sha256:c27ed82b51df6b923d0e656feac221343a0dbcd2fb9bc7e6b1db97f61e9a1513", size = 68997, upload-time = "2025-11-04T21:24:21.704Z" },
-]
-
[[package]]
name = "kiwisolver"
version = "1.4.9"
@@ -812,15 +774,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3", size = 4398, upload-time = "2022-04-20T22:04:42.23Z" },
]
-[[package]]
-name = "logistro"
-version = "2.0.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/08/90/bfd7a6fab22bdfafe48ed3c4831713cb77b4779d18ade5e248d5dbc0ca22/logistro-2.0.1.tar.gz", hash = "sha256:8446affc82bab2577eb02bfcbcae196ae03129287557287b6a070f70c1985047", size = 8398, upload-time = "2025-11-01T02:41:18.81Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/54/20/6aa79ba3570bddd1bf7e951c6123f806751e58e8cce736bad77b2cf348d7/logistro-2.0.1-py3-none-any.whl", hash = "sha256:06ffa127b9fb4ac8b1972ae6b2a9d7fde57598bf5939cd708f43ec5bba2d31eb", size = 8555, upload-time = "2025-11-01T02:41:17.587Z" },
-]
-
[[package]]
name = "loro"
version = "1.10.3"
@@ -1287,59 +1240,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/60/69/68b0b15515c74b31e93eaf40ea4a8297097386ccbe7e2fd4c7b0dd5aacd3/opentelemetry_api-1.10.0-py3-none-any.whl", hash = "sha256:ee56d74d8576807d86e0791bcfa44ec2c9abeec3f5ea080de09fd5fe7c442655", size = 47996, upload-time = "2022-03-11T00:12:18.859Z" },
]
-[[package]]
-name = "orjson"
-version = "3.11.5"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/04/b8/333fdb27840f3bf04022d21b654a35f58e15407183aeb16f3b41aa053446/orjson-3.11.5.tar.gz", hash = "sha256:82393ab47b4fe44ffd0a7659fa9cfaacc717eb617c93cde83795f14af5c2e9d5", size = 5972347, upload-time = "2025-12-06T15:55:39.458Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ef/a4/8052a029029b096a78955eadd68ab594ce2197e24ec50e6b6d2ab3f4e33b/orjson-3.11.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:334e5b4bff9ad101237c2d799d9fd45737752929753bf4faf4b207335a416b7d", size = 245347, upload-time = "2025-12-06T15:54:22.061Z" },
- { url = "https://files.pythonhosted.org/packages/64/67/574a7732bd9d9d79ac620c8790b4cfe0717a3d5a6eb2b539e6e8995e24a0/orjson-3.11.5-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:ff770589960a86eae279f5d8aa536196ebda8273a2a07db2a54e82b93bc86626", size = 129435, upload-time = "2025-12-06T15:54:23.615Z" },
- { url = "https://files.pythonhosted.org/packages/52/8d/544e77d7a29d90cf4d9eecd0ae801c688e7f3d1adfa2ebae5e1e94d38ab9/orjson-3.11.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ed24250e55efbcb0b35bed7caaec8cedf858ab2f9f2201f17b8938c618c8ca6f", size = 132074, upload-time = "2025-12-06T15:54:24.694Z" },
- { url = "https://files.pythonhosted.org/packages/6e/57/b9f5b5b6fbff9c26f77e785baf56ae8460ef74acdb3eae4931c25b8f5ba9/orjson-3.11.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a66d7769e98a08a12a139049aac2f0ca3adae989817f8c43337455fbc7669b85", size = 130520, upload-time = "2025-12-06T15:54:26.185Z" },
- { url = "https://files.pythonhosted.org/packages/f6/6d/d34970bf9eb33f9ec7c979a262cad86076814859e54eb9a059a52f6dc13d/orjson-3.11.5-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86cfc555bfd5794d24c6a1903e558b50644e5e68e6471d66502ce5cb5fdef3f9", size = 136209, upload-time = "2025-12-06T15:54:27.264Z" },
- { url = "https://files.pythonhosted.org/packages/e7/39/bc373b63cc0e117a105ea12e57280f83ae52fdee426890d57412432d63b3/orjson-3.11.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a230065027bc2a025e944f9d4714976a81e7ecfa940923283bca7bbc1f10f626", size = 139837, upload-time = "2025-12-06T15:54:28.75Z" },
- { url = "https://files.pythonhosted.org/packages/cb/aa/7c4818c8d7d324da220f4f1af55c343956003aa4d1ce1857bdc1d396ba69/orjson-3.11.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b29d36b60e606df01959c4b982729c8845c69d1963f88686608be9ced96dbfaa", size = 137307, upload-time = "2025-12-06T15:54:29.856Z" },
- { url = "https://files.pythonhosted.org/packages/46/bf/0993b5a056759ba65145effe3a79dd5a939d4a070eaa5da2ee3180fbb13f/orjson-3.11.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74099c6b230d4261fdc3169d50efc09abf38ace1a42ea2f9994b1d79153d477", size = 139020, upload-time = "2025-12-06T15:54:31.024Z" },
- { url = "https://files.pythonhosted.org/packages/65/e8/83a6c95db3039e504eda60fc388f9faedbb4f6472f5aba7084e06552d9aa/orjson-3.11.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e697d06ad57dd0c7a737771d470eedc18e68dfdefcdd3b7de7f33dfda5b6212e", size = 141099, upload-time = "2025-12-06T15:54:32.196Z" },
- { url = "https://files.pythonhosted.org/packages/b9/b4/24fdc024abfce31c2f6812973b0a693688037ece5dc64b7a60c1ce69e2f2/orjson-3.11.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e08ca8a6c851e95aaecc32bc44a5aa75d0ad26af8cdac7c77e4ed93acf3d5b69", size = 413540, upload-time = "2025-12-06T15:54:33.361Z" },
- { url = "https://files.pythonhosted.org/packages/d9/37/01c0ec95d55ed0c11e4cae3e10427e479bba40c77312b63e1f9665e0737d/orjson-3.11.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e8b5f96c05fce7d0218df3fdfeb962d6b8cfff7e3e20264306b46dd8b217c0f3", size = 151530, upload-time = "2025-12-06T15:54:34.6Z" },
- { url = "https://files.pythonhosted.org/packages/f9/d4/f9ebc57182705bb4bbe63f5bbe14af43722a2533135e1d2fb7affa0c355d/orjson-3.11.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ddbfdb5099b3e6ba6d6ea818f61997bb66de14b411357d24c4612cf1ebad08ca", size = 141863, upload-time = "2025-12-06T15:54:35.801Z" },
- { url = "https://files.pythonhosted.org/packages/0d/04/02102b8d19fdcb009d72d622bb5781e8f3fae1646bf3e18c53d1bc8115b5/orjson-3.11.5-cp312-cp312-win32.whl", hash = "sha256:9172578c4eb09dbfcf1657d43198de59b6cef4054de385365060ed50c458ac98", size = 135255, upload-time = "2025-12-06T15:54:37.209Z" },
- { url = "https://files.pythonhosted.org/packages/d4/fb/f05646c43d5450492cb387de5549f6de90a71001682c17882d9f66476af5/orjson-3.11.5-cp312-cp312-win_amd64.whl", hash = "sha256:2b91126e7b470ff2e75746f6f6ee32b9ab67b7a93c8ba1d15d3a0caaf16ec875", size = 133252, upload-time = "2025-12-06T15:54:38.401Z" },
- { url = "https://files.pythonhosted.org/packages/dc/a6/7b8c0b26ba18c793533ac1cd145e131e46fcf43952aa94c109b5b913c1f0/orjson-3.11.5-cp312-cp312-win_arm64.whl", hash = "sha256:acbc5fac7e06777555b0722b8ad5f574739e99ffe99467ed63da98f97f9ca0fe", size = 126777, upload-time = "2025-12-06T15:54:39.515Z" },
- { url = "https://files.pythonhosted.org/packages/10/43/61a77040ce59f1569edf38f0b9faadc90c8cf7e9bec2e0df51d0132c6bb7/orjson-3.11.5-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3b01799262081a4c47c035dd77c1301d40f568f77cc7ec1bb7db5d63b0a01629", size = 245271, upload-time = "2025-12-06T15:54:40.878Z" },
- { url = "https://files.pythonhosted.org/packages/55/f9/0f79be617388227866d50edd2fd320cb8fb94dc1501184bb1620981a0aba/orjson-3.11.5-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:61de247948108484779f57a9f406e4c84d636fa5a59e411e6352484985e8a7c3", size = 129422, upload-time = "2025-12-06T15:54:42.403Z" },
- { url = "https://files.pythonhosted.org/packages/77/42/f1bf1549b432d4a78bfa95735b79b5dac75b65b5bb815bba86ad406ead0a/orjson-3.11.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:894aea2e63d4f24a7f04a1908307c738d0dce992e9249e744b8f4e8dd9197f39", size = 132060, upload-time = "2025-12-06T15:54:43.531Z" },
- { url = "https://files.pythonhosted.org/packages/25/49/825aa6b929f1a6ed244c78acd7b22c1481fd7e5fda047dc8bf4c1a807eb6/orjson-3.11.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ddc21521598dbe369d83d4d40338e23d4101dad21dae0e79fa20465dbace019f", size = 130391, upload-time = "2025-12-06T15:54:45.059Z" },
- { url = "https://files.pythonhosted.org/packages/42/ec/de55391858b49e16e1aa8f0bbbb7e5997b7345d8e984a2dec3746d13065b/orjson-3.11.5-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cce16ae2f5fb2c53c3eafdd1706cb7b6530a67cc1c17abe8ec747f5cd7c0c51", size = 135964, upload-time = "2025-12-06T15:54:46.576Z" },
- { url = "https://files.pythonhosted.org/packages/1c/40/820bc63121d2d28818556a2d0a09384a9f0262407cf9fa305e091a8048df/orjson-3.11.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e46c762d9f0e1cfb4ccc8515de7f349abbc95b59cb5a2bd68df5973fdef913f8", size = 139817, upload-time = "2025-12-06T15:54:48.084Z" },
- { url = "https://files.pythonhosted.org/packages/09/c7/3a445ca9a84a0d59d26365fd8898ff52bdfcdcb825bcc6519830371d2364/orjson-3.11.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7345c759276b798ccd6d77a87136029e71e66a8bbf2d2755cbdde1d82e78706", size = 137336, upload-time = "2025-12-06T15:54:49.426Z" },
- { url = "https://files.pythonhosted.org/packages/9a/b3/dc0d3771f2e5d1f13368f56b339c6782f955c6a20b50465a91acb79fe961/orjson-3.11.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75bc2e59e6a2ac1dd28901d07115abdebc4563b5b07dd612bf64260a201b1c7f", size = 138993, upload-time = "2025-12-06T15:54:50.939Z" },
- { url = "https://files.pythonhosted.org/packages/d1/a2/65267e959de6abe23444659b6e19c888f242bf7725ff927e2292776f6b89/orjson-3.11.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:54aae9b654554c3b4edd61896b978568c6daa16af96fa4681c9b5babd469f863", size = 141070, upload-time = "2025-12-06T15:54:52.414Z" },
- { url = "https://files.pythonhosted.org/packages/63/c9/da44a321b288727a322c6ab17e1754195708786a04f4f9d2220a5076a649/orjson-3.11.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4bdd8d164a871c4ec773f9de0f6fe8769c2d6727879c37a9666ba4183b7f8228", size = 413505, upload-time = "2025-12-06T15:54:53.67Z" },
- { url = "https://files.pythonhosted.org/packages/7f/17/68dc14fa7000eefb3d4d6d7326a190c99bb65e319f02747ef3ebf2452f12/orjson-3.11.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a261fef929bcf98a60713bf5e95ad067cea16ae345d9a35034e73c3990e927d2", size = 151342, upload-time = "2025-12-06T15:54:55.113Z" },
- { url = "https://files.pythonhosted.org/packages/c4/c5/ccee774b67225bed630a57478529fc026eda33d94fe4c0eac8fe58d4aa52/orjson-3.11.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c028a394c766693c5c9909dec76b24f37e6a1b91999e8d0c0d5feecbe93c3e05", size = 141823, upload-time = "2025-12-06T15:54:56.331Z" },
- { url = "https://files.pythonhosted.org/packages/67/80/5d00e4155d0cd7390ae2087130637671da713959bb558db9bac5e6f6b042/orjson-3.11.5-cp313-cp313-win32.whl", hash = "sha256:2cc79aaad1dfabe1bd2d50ee09814a1253164b3da4c00a78c458d82d04b3bdef", size = 135236, upload-time = "2025-12-06T15:54:57.507Z" },
- { url = "https://files.pythonhosted.org/packages/95/fe/792cc06a84808dbdc20ac6eab6811c53091b42f8e51ecebf14b540e9cfe4/orjson-3.11.5-cp313-cp313-win_amd64.whl", hash = "sha256:ff7877d376add4e16b274e35a3f58b7f37b362abf4aa31863dadacdd20e3a583", size = 133167, upload-time = "2025-12-06T15:54:58.71Z" },
- { url = "https://files.pythonhosted.org/packages/46/2c/d158bd8b50e3b1cfdcf406a7e463f6ffe3f0d167b99634717acdaf5e299f/orjson-3.11.5-cp313-cp313-win_arm64.whl", hash = "sha256:59ac72ea775c88b163ba8d21b0177628bd015c5dd060647bbab6e22da3aad287", size = 126712, upload-time = "2025-12-06T15:54:59.892Z" },
- { url = "https://files.pythonhosted.org/packages/c2/60/77d7b839e317ead7bb225d55bb50f7ea75f47afc489c81199befc5435b50/orjson-3.11.5-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e446a8ea0a4c366ceafc7d97067bfd55292969143b57e3c846d87fc701e797a0", size = 245252, upload-time = "2025-12-06T15:55:01.127Z" },
- { url = "https://files.pythonhosted.org/packages/f1/aa/d4639163b400f8044cef0fb9aa51b0337be0da3a27187a20d1166e742370/orjson-3.11.5-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:53deb5addae9c22bbe3739298f5f2196afa881ea75944e7720681c7080909a81", size = 129419, upload-time = "2025-12-06T15:55:02.723Z" },
- { url = "https://files.pythonhosted.org/packages/30/94/9eabf94f2e11c671111139edf5ec410d2f21e6feee717804f7e8872d883f/orjson-3.11.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82cd00d49d6063d2b8791da5d4f9d20539c5951f965e45ccf4e96d33505ce68f", size = 132050, upload-time = "2025-12-06T15:55:03.918Z" },
- { url = "https://files.pythonhosted.org/packages/3d/c8/ca10f5c5322f341ea9a9f1097e140be17a88f88d1cfdd29df522970d9744/orjson-3.11.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3fd15f9fc8c203aeceff4fda211157fad114dde66e92e24097b3647a08f4ee9e", size = 130370, upload-time = "2025-12-06T15:55:05.173Z" },
- { url = "https://files.pythonhosted.org/packages/25/d4/e96824476d361ee2edd5c6290ceb8d7edf88d81148a6ce172fc00278ca7f/orjson-3.11.5-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9df95000fbe6777bf9820ae82ab7578e8662051bb5f83d71a28992f539d2cda7", size = 136012, upload-time = "2025-12-06T15:55:06.402Z" },
- { url = "https://files.pythonhosted.org/packages/85/8e/9bc3423308c425c588903f2d103cfcfe2539e07a25d6522900645a6f257f/orjson-3.11.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:92a8d676748fca47ade5bc3da7430ed7767afe51b2f8100e3cd65e151c0eaceb", size = 139809, upload-time = "2025-12-06T15:55:07.656Z" },
- { url = "https://files.pythonhosted.org/packages/e9/3c/b404e94e0b02a232b957c54643ce68d0268dacb67ac33ffdee24008c8b27/orjson-3.11.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aa0f513be38b40234c77975e68805506cad5d57b3dfd8fe3baa7f4f4051e15b4", size = 137332, upload-time = "2025-12-06T15:55:08.961Z" },
- { url = "https://files.pythonhosted.org/packages/51/30/cc2d69d5ce0ad9b84811cdf4a0cd5362ac27205a921da524ff42f26d65e0/orjson-3.11.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa1863e75b92891f553b7922ce4ee10ed06db061e104f2b7815de80cdcb135ad", size = 138983, upload-time = "2025-12-06T15:55:10.595Z" },
- { url = "https://files.pythonhosted.org/packages/0e/87/de3223944a3e297d4707d2fe3b1ffb71437550e165eaf0ca8bbe43ccbcb1/orjson-3.11.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4be86b58e9ea262617b8ca6251a2f0d63cc132a6da4b5fcc8e0a4128782c829", size = 141069, upload-time = "2025-12-06T15:55:11.832Z" },
- { url = "https://files.pythonhosted.org/packages/65/30/81d5087ae74be33bcae3ff2d80f5ccaa4a8fedc6d39bf65a427a95b8977f/orjson-3.11.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b923c1c13fa02084eb38c9c065afd860a5cff58026813319a06949c3af5732ac", size = 413491, upload-time = "2025-12-06T15:55:13.314Z" },
- { url = "https://files.pythonhosted.org/packages/d0/6f/f6058c21e2fc1efaf918986dbc2da5cd38044f1a2d4b7b91ad17c4acf786/orjson-3.11.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1b6bd351202b2cd987f35a13b5e16471cf4d952b42a73c391cc537974c43ef6d", size = 151375, upload-time = "2025-12-06T15:55:14.715Z" },
- { url = "https://files.pythonhosted.org/packages/54/92/c6921f17d45e110892899a7a563a925b2273d929959ce2ad89e2525b885b/orjson-3.11.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bb150d529637d541e6af06bbe3d02f5498d628b7f98267ff87647584293ab439", size = 141850, upload-time = "2025-12-06T15:55:15.94Z" },
- { url = "https://files.pythonhosted.org/packages/88/86/cdecb0140a05e1a477b81f24739da93b25070ee01ce7f7242f44a6437594/orjson-3.11.5-cp314-cp314-win32.whl", hash = "sha256:9cc1e55c884921434a84a0c3dd2699eb9f92e7b441d7f53f3941079ec6ce7499", size = 135278, upload-time = "2025-12-06T15:55:17.202Z" },
- { url = "https://files.pythonhosted.org/packages/e4/97/b638d69b1e947d24f6109216997e38922d54dcdcdb1b11c18d7efd2d3c59/orjson-3.11.5-cp314-cp314-win_amd64.whl", hash = "sha256:a4f3cb2d874e03bc7767c8f88adaa1a9a05cecea3712649c3b58589ec7317310", size = 133170, upload-time = "2025-12-06T15:55:18.468Z" },
- { url = "https://files.pythonhosted.org/packages/8f/dd/f4fff4a6fe601b4f8f3ba3aa6da8ac33d17d124491a3b804c662a70e1636/orjson-3.11.5-cp314-cp314-win_arm64.whl", hash = "sha256:38b22f476c351f9a1c43e5b07d8b5a02eb24a6ab8e75f700f7d479d4568346a5", size = 126713, upload-time = "2025-12-06T15:55:19.738Z" },
-]
-
[[package]]
name = "packaging"
version = "25.0"
@@ -1424,7 +1324,6 @@ version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "altair" },
- { name = "kaleido" },
{ name = "marimo" },
{ name = "matplotlib" },
{ name = "modin", extra = ["dask"] },
@@ -1433,19 +1332,19 @@ dependencies = [
{ name = "openai" },
{ name = "openpyxl" },
{ name = "pandas" },
- { name = "plotly" },
{ name = "polars" },
+ { name = "pyarrow" },
{ name = "pysqlite3" },
{ name = "pyzmq" },
{ name = "requests" },
{ name = "taguette" },
+ { name = "vl-convert-python" },
{ name = "wordcloud" },
]
[package.metadata]
requires-dist = [
{ name = "altair", specifier = ">=6.0.0" },
- { name = "kaleido", specifier = ">=1.2.0" },
{ name = "marimo", specifier = ">=0.18.0" },
{ name = "matplotlib", specifier = ">=3.10.8" },
{ name = "modin", extras = ["dask"], specifier = ">=0.37.1" },
@@ -1454,12 +1353,13 @@ requires-dist = [
{ name = "openai", specifier = ">=2.9.0" },
{ name = "openpyxl", specifier = ">=3.1.5" },
{ name = "pandas", specifier = ">=2.3.3" },
- { name = "plotly", specifier = ">=6.5.1" },
{ name = "polars", specifier = ">=1.37.1" },
+ { name = "pyarrow", specifier = ">=23.0.0" },
{ name = "pysqlite3", specifier = ">=0.6.0" },
{ name = "pyzmq", specifier = ">=27.1.0" },
{ name = "requests", specifier = ">=2.32.5" },
{ name = "taguette", specifier = ">=1.5.1" },
+ { name = "vl-convert-python", specifier = ">=1.9.0.post1" },
{ name = "wordcloud", specifier = ">=1.9.5" },
]
@@ -1532,28 +1432,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
]
-[[package]]
-name = "plotly"
-version = "6.5.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "narwhals" },
- { name = "packaging" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/d6/ff/a4938b75e95114451efdb34db6b41930253e67efc8dc737bd592ef2e419d/plotly-6.5.1.tar.gz", hash = "sha256:b0478c8d5ada0c8756bce15315bcbfec7d3ab8d24614e34af9aff7bfcfea9281", size = 7014606, upload-time = "2026-01-07T20:11:41.644Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e9/8e/24e0bb90b2d75af84820693260c5534e9ed351afdda67ed6f393a141a0e2/plotly-6.5.1-py3-none-any.whl", hash = "sha256:5adad4f58c360612b6c5ce11a308cdbc4fd38ceb1d40594a614f0062e227abe1", size = 9894981, upload-time = "2026-01-07T20:11:38.124Z" },
-]
-
-[[package]]
-name = "pluggy"
-version = "1.6.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
-]
-
[[package]]
name = "polars"
version = "1.37.1"
@@ -1632,6 +1510,49 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/73/2ce007f4198c80fcf2cb24c169884f833fe93fbc03d55d302627b094ee91/psutil-7.2.1-cp37-abi3-win_arm64.whl", hash = "sha256:0d67c1822c355aa6f7314d92018fb4268a76668a536f133599b91edd48759442", size = 133836, upload-time = "2025-12-29T08:26:43.086Z" },
]
+[[package]]
+name = "pyarrow"
+version = "23.0.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/01/33/ffd9c3eb087fa41dd79c3cf20c4c0ae3cdb877c4f8e1107a446006344924/pyarrow-23.0.0.tar.gz", hash = "sha256:180e3150e7edfcd182d3d9afba72f7cf19839a497cc76555a8dce998a8f67615", size = 1167185, upload-time = "2026-01-18T16:19:42.218Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/3d/bd/c861d020831ee57609b73ea721a617985ece817684dc82415b0bc3e03ac3/pyarrow-23.0.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5961a9f646c232697c24f54d3419e69b4261ba8a8b66b0ac54a1851faffcbab8", size = 34189116, upload-time = "2026-01-18T16:15:28.054Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/23/7725ad6cdcbaf6346221391e7b3eecd113684c805b0a95f32014e6fa0736/pyarrow-23.0.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:632b3e7c3d232f41d64e1a4a043fb82d44f8a349f339a1188c6a0dd9d2d47d8a", size = 35803831, upload-time = "2026-01-18T16:15:33.798Z" },
+ { url = "https://files.pythonhosted.org/packages/57/06/684a421543455cdc2944d6a0c2cc3425b028a4c6b90e34b35580c4899743/pyarrow-23.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:76242c846db1411f1d6c2cc3823be6b86b40567ee24493344f8226ba34a81333", size = 44436452, upload-time = "2026-01-18T16:15:41.598Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/6f/8f9eb40c2328d66e8b097777ddcf38494115ff9f1b5bc9754ba46991191e/pyarrow-23.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b73519f8b52ae28127000986bf228fda781e81d3095cd2d3ece76eb5cf760e1b", size = 47557396, upload-time = "2026-01-18T16:15:51.252Z" },
+ { url = "https://files.pythonhosted.org/packages/10/6e/f08075f1472e5159553501fde2cc7bc6700944bdabe49a03f8a035ee6ccd/pyarrow-23.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:068701f6823449b1b6469120f399a1239766b117d211c5d2519d4ed5861f75de", size = 48147129, upload-time = "2026-01-18T16:16:00.299Z" },
+ { url = "https://files.pythonhosted.org/packages/7d/82/d5a680cd507deed62d141cc7f07f7944a6766fc51019f7f118e4d8ad0fb8/pyarrow-23.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1801ba947015d10e23bca9dd6ef5d0e9064a81569a89b6e9a63b59224fd060df", size = 50596642, upload-time = "2026-01-18T16:16:08.502Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/26/4f29c61b3dce9fa7780303b86895ec6a0917c9af927101daaaf118fbe462/pyarrow-23.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:52265266201ec25b6839bf6bd4ea918ca6d50f31d13e1cf200b4261cd11dc25c", size = 27660628, upload-time = "2026-01-18T16:16:15.28Z" },
+ { url = "https://files.pythonhosted.org/packages/66/34/564db447d083ec7ff93e0a883a597d2f214e552823bfc178a2d0b1f2c257/pyarrow-23.0.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:ad96a597547af7827342ffb3c503c8316e5043bb09b47a84885ce39394c96e00", size = 34184630, upload-time = "2026-01-18T16:16:22.141Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/3a/3999daebcb5e6119690c92a621c4d78eef2ffba7a0a1b56386d2875fcd77/pyarrow-23.0.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:b9edf990df77c2901e79608f08c13fbde60202334a4fcadb15c1f57bf7afee43", size = 35796820, upload-time = "2026-01-18T16:16:29.441Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/ee/39195233056c6a8d0976d7d1ac1cd4fe21fb0ec534eca76bc23ef3f60e11/pyarrow-23.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:36d1b5bc6ddcaff0083ceec7e2561ed61a51f49cce8be079ee8ed406acb6fdef", size = 44438735, upload-time = "2026-01-18T16:16:38.79Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/41/6a7328ee493527e7afc0c88d105ecca69a3580e29f2faaeac29308369fd7/pyarrow-23.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4292b889cd224f403304ddda8b63a36e60f92911f89927ec8d98021845ea21be", size = 47557263, upload-time = "2026-01-18T16:16:46.248Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/ee/34e95b21ee84db494eae60083ddb4383477b31fb1fd19fd866d794881696/pyarrow-23.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dfd9e133e60eaa847fd80530a1b89a052f09f695d0b9c34c235ea6b2e0924cf7", size = 48153529, upload-time = "2026-01-18T16:16:53.412Z" },
+ { url = "https://files.pythonhosted.org/packages/52/88/8a8d83cea30f4563efa1b7bf51d241331ee5cd1b185a7e063f5634eca415/pyarrow-23.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832141cc09fac6aab1cd3719951d23301396968de87080c57c9a7634e0ecd068", size = 50598851, upload-time = "2026-01-18T16:17:01.133Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/4c/2929c4be88723ba025e7b3453047dc67e491c9422965c141d24bab6b5962/pyarrow-23.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:7a7d067c9a88faca655c71bcc30ee2782038d59c802d57950826a07f60d83c4c", size = 27577747, upload-time = "2026-01-18T16:18:02.413Z" },
+ { url = "https://files.pythonhosted.org/packages/64/52/564a61b0b82d72bd68ec3aef1adda1e3eba776f89134b9ebcb5af4b13cb6/pyarrow-23.0.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:ce9486e0535a843cf85d990e2ec5820a47918235183a5c7b8b97ed7e92c2d47d", size = 34446038, upload-time = "2026-01-18T16:17:07.861Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/c9/232d4f9855fd1de0067c8a7808a363230d223c83aeee75e0fe6eab851ba9/pyarrow-23.0.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:075c29aeaa685fd1182992a9ed2499c66f084ee54eea47da3eb76e125e06064c", size = 35921142, upload-time = "2026-01-18T16:17:15.401Z" },
+ { url = "https://files.pythonhosted.org/packages/96/f2/60af606a3748367b906bb82d41f0032e059f075444445d47e32a7ff1df62/pyarrow-23.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:799965a5379589510d888be3094c2296efd186a17ca1cef5b77703d4d5121f53", size = 44490374, upload-time = "2026-01-18T16:17:23.93Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/2d/7731543050a678ea3a413955a2d5d80d2a642f270aa57a3cb7d5a86e3f46/pyarrow-23.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ef7cac8fe6fccd8b9e7617bfac785b0371a7fe26af59463074e4882747145d40", size = 47527896, upload-time = "2026-01-18T16:17:33.393Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/90/f3342553b7ac9879413aed46500f1637296f3c8222107523a43a1c08b42a/pyarrow-23.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15a414f710dc927132dd67c361f78c194447479555af57317066ee5116b90e9e", size = 48210401, upload-time = "2026-01-18T16:17:42.012Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/da/9862ade205ecc46c172b6ce5038a74b5151c7401e36255f15975a45878b2/pyarrow-23.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e0d2e6915eca7d786be6a77bf227fbc06d825a75b5b5fe9bcbef121dec32685", size = 50579677, upload-time = "2026-01-18T16:17:50.241Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4c/f11f371f5d4740a5dafc2e11c76bcf42d03dfdb2d68696da97de420b6963/pyarrow-23.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:4b317ea6e800b5704e5e5929acb6e2dc13e9276b708ea97a39eb8b345aa2658b", size = 27631889, upload-time = "2026-01-18T16:17:56.55Z" },
+ { url = "https://files.pythonhosted.org/packages/97/bb/15aec78bcf43a0c004067bd33eb5352836a29a49db8581fc56f2b6ca88b7/pyarrow-23.0.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:20b187ed9550d233a872074159f765f52f9d92973191cd4b93f293a19efbe377", size = 34213265, upload-time = "2026-01-18T16:18:07.904Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/6c/deb2c594bbba41c37c5d9aa82f510376998352aa69dfcb886cb4b18ad80f/pyarrow-23.0.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:18ec84e839b493c3886b9b5e06861962ab4adfaeb79b81c76afbd8d84c7d5fda", size = 35819211, upload-time = "2026-01-18T16:18:13.94Z" },
+ { url = "https://files.pythonhosted.org/packages/e0/e5/ee82af693cb7b5b2b74f6524cdfede0e6ace779d7720ebca24d68b57c36b/pyarrow-23.0.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:e438dd3f33894e34fd02b26bd12a32d30d006f5852315f611aa4add6c7fab4bc", size = 44502313, upload-time = "2026-01-18T16:18:20.367Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/86/95c61ad82236495f3c31987e85135926ba3ec7f3819296b70a68d8066b49/pyarrow-23.0.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:a244279f240c81f135631be91146d7fa0e9e840e1dfed2aba8483eba25cd98e6", size = 47585886, upload-time = "2026-01-18T16:18:27.544Z" },
+ { url = "https://files.pythonhosted.org/packages/bb/6e/a72d901f305201802f016d015de1e05def7706fff68a1dedefef5dc7eff7/pyarrow-23.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c4692e83e42438dba512a570c6eaa42be2f8b6c0f492aea27dec54bdc495103a", size = 48207055, upload-time = "2026-01-18T16:18:35.425Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/e5/5de029c537630ca18828db45c30e2a78da03675a70ac6c3528203c416fe3/pyarrow-23.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae7f30f898dfe44ea69654a35c93e8da4cef6606dc4c72394068fd95f8e9f54a", size = 50619812, upload-time = "2026-01-18T16:18:43.553Z" },
+ { url = "https://files.pythonhosted.org/packages/59/8d/2af846cd2412e67a087f5bda4a8e23dfd4ebd570f777db2e8686615dafc1/pyarrow-23.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:5b86bb649e4112fb0614294b7d0a175c7513738876b89655605ebb87c804f861", size = 28263851, upload-time = "2026-01-18T16:19:38.567Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/7f/caab863e587041156f6786c52e64151b7386742c8c27140f637176e9230e/pyarrow-23.0.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ebc017d765d71d80a3f8584ca0566b53e40464586585ac64176115baa0ada7d3", size = 34463240, upload-time = "2026-01-18T16:18:49.755Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/fa/3a5b8c86c958e83622b40865e11af0857c48ec763c11d472c87cd518283d/pyarrow-23.0.0-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:0800cc58a6d17d159df823f87ad66cefebf105b982493d4bad03ee7fab84b993", size = 35935712, upload-time = "2026-01-18T16:18:55.626Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/08/17a62078fc1a53decb34a9aa79cf9009efc74d63d2422e5ade9fed2f99e3/pyarrow-23.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:3a7c68c722da9bb5b0f8c10e3eae71d9825a4b429b40b32709df5d1fa55beb3d", size = 44503523, upload-time = "2026-01-18T16:19:03.958Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/70/84d45c74341e798aae0323d33b7c39194e23b1abc439ceaf60a68a7a969a/pyarrow-23.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:bd5556c24622df90551063ea41f559b714aa63ca953db884cfb958559087a14e", size = 47542490, upload-time = "2026-01-18T16:19:11.208Z" },
+ { url = "https://files.pythonhosted.org/packages/61/d9/d1274b0e6f19e235de17441e53224f4716574b2ca837022d55702f24d71d/pyarrow-23.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:54810f6e6afc4ffee7c2e0051b61722fbea9a4961b46192dcfae8ea12fa09059", size = 48233605, upload-time = "2026-01-18T16:19:19.544Z" },
+ { url = "https://files.pythonhosted.org/packages/39/07/e4e2d568cb57543d84482f61e510732820cddb0f47c4bb7df629abfed852/pyarrow-23.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:14de7d48052cf4b0ed174533eafa3cfe0711b8076ad70bede32cf59f744f0d7c", size = 50603979, upload-time = "2026-01-18T16:19:26.717Z" },
+ { url = "https://files.pythonhosted.org/packages/72/9c/47693463894b610f8439b2e970b82ef81e9599c757bf2049365e40ff963c/pyarrow-23.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:427deac1f535830a744a4f04a6ac183a64fcac4341b3f618e693c41b7b98d2b0", size = 28338905, upload-time = "2026-01-18T16:19:32.93Z" },
+]
+
[[package]]
name = "pycparser"
version = "2.23"
@@ -1781,34 +1702,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/76/103fc35bec8934c672b5193eadc66384472fd071c269cd95bc4b16595d17/pysqlite3-0.6.0-cp314-cp314-win_amd64.whl", hash = "sha256:81226436fae52d3bec4ce578104c5bcf4c22d3fcf29d4a590fcbde56b90472ef", size = 903944, upload-time = "2026-01-05T18:29:30.309Z" },
]
-[[package]]
-name = "pytest"
-version = "9.0.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "iniconfig" },
- { name = "packaging" },
- { name = "pluggy" },
- { name = "pygments" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
-]
-
-[[package]]
-name = "pytest-timeout"
-version = "2.4.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pytest" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" },
-]
-
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -2060,41 +1953,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
]
-[[package]]
-name = "simplejson"
-version = "3.20.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/41/f4/a1ac5ed32f7ed9a088d62a59d410d4c204b3b3815722e2ccfb491fa8251b/simplejson-3.20.2.tar.gz", hash = "sha256:5fe7a6ce14d1c300d80d08695b7f7e633de6cd72c80644021874d985b3393649", size = 85784, upload-time = "2025-09-26T16:29:36.64Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/9d/9e/1a91e7614db0416885eab4136d49b7303de20528860ffdd798ce04d054db/simplejson-3.20.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:4376d5acae0d1e91e78baeba4ee3cf22fbf6509d81539d01b94e0951d28ec2b6", size = 93523, upload-time = "2025-09-26T16:28:00.356Z" },
- { url = "https://files.pythonhosted.org/packages/5e/2b/d2413f5218fc25608739e3d63fe321dfa85c5f097aa6648dbe72513a5f12/simplejson-3.20.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f8fe6de652fcddae6dec8f281cc1e77e4e8f3575249e1800090aab48f73b4259", size = 75844, upload-time = "2025-09-26T16:28:01.756Z" },
- { url = "https://files.pythonhosted.org/packages/ad/f1/efd09efcc1e26629e120fef59be059ce7841cc6e1f949a4db94f1ae8a918/simplejson-3.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25ca2663d99328d51e5a138f22018e54c9162438d831e26cfc3458688616eca8", size = 75655, upload-time = "2025-09-26T16:28:03.037Z" },
- { url = "https://files.pythonhosted.org/packages/97/ec/5c6db08e42f380f005d03944be1af1a6bd501cc641175429a1cbe7fb23b9/simplejson-3.20.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12a6b2816b6cab6c3fd273d43b1948bc9acf708272074c8858f579c394f4cbc9", size = 150335, upload-time = "2025-09-26T16:28:05.027Z" },
- { url = "https://files.pythonhosted.org/packages/81/f5/808a907485876a9242ec67054da7cbebefe0ee1522ef1c0be3bfc90f96f6/simplejson-3.20.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac20dc3fcdfc7b8415bfc3d7d51beccd8695c3f4acb7f74e3a3b538e76672868", size = 158519, upload-time = "2025-09-26T16:28:06.5Z" },
- { url = "https://files.pythonhosted.org/packages/66/af/b8a158246834645ea890c36136584b0cc1c0e4b83a73b11ebd9c2a12877c/simplejson-3.20.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db0804d04564e70862ef807f3e1ace2cc212ef0e22deb1b3d6f80c45e5882c6b", size = 148571, upload-time = "2025-09-26T16:28:07.715Z" },
- { url = "https://files.pythonhosted.org/packages/20/05/ed9b2571bbf38f1a2425391f18e3ac11cb1e91482c22d644a1640dea9da7/simplejson-3.20.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:979ce23ea663895ae39106946ef3d78527822d918a136dbc77b9e2b7f006237e", size = 152367, upload-time = "2025-09-26T16:28:08.921Z" },
- { url = "https://files.pythonhosted.org/packages/81/2c/bad68b05dd43e93f77994b920505634d31ed239418eb6a88997d06599983/simplejson-3.20.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a2ba921b047bb029805726800819675249ef25d2f65fd0edb90639c5b1c3033c", size = 150205, upload-time = "2025-09-26T16:28:10.086Z" },
- { url = "https://files.pythonhosted.org/packages/69/46/90c7fc878061adafcf298ce60cecdee17a027486e9dce507e87396d68255/simplejson-3.20.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:12d3d4dc33770069b780cc8f5abef909fe4a3f071f18f55f6d896a370fd0f970", size = 151823, upload-time = "2025-09-26T16:28:11.329Z" },
- { url = "https://files.pythonhosted.org/packages/ab/27/b85b03349f825ae0f5d4f780cdde0bbccd4f06c3d8433f6a3882df887481/simplejson-3.20.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:aff032a59a201b3683a34be1169e71ddda683d9c3b43b261599c12055349251e", size = 158997, upload-time = "2025-09-26T16:28:12.917Z" },
- { url = "https://files.pythonhosted.org/packages/71/ad/d7f3c331fb930638420ac6d236db68e9f4c28dab9c03164c3cd0e7967e15/simplejson-3.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:30e590e133b06773f0dc9c3f82e567463df40598b660b5adf53eb1c488202544", size = 154367, upload-time = "2025-09-26T16:28:14.393Z" },
- { url = "https://files.pythonhosted.org/packages/f0/46/5c67324addd40fa2966f6e886cacbbe0407c03a500db94fb8bb40333fcdf/simplejson-3.20.2-cp312-cp312-win32.whl", hash = "sha256:8d7be7c99939cc58e7c5bcf6bb52a842a58e6c65e1e9cdd2a94b697b24cddb54", size = 74285, upload-time = "2025-09-26T16:28:15.931Z" },
- { url = "https://files.pythonhosted.org/packages/fa/c9/5cc2189f4acd3a6e30ffa9775bf09b354302dbebab713ca914d7134d0f29/simplejson-3.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:2c0b4a67e75b945489052af6590e7dca0ed473ead5d0f3aad61fa584afe814ab", size = 75969, upload-time = "2025-09-26T16:28:17.017Z" },
- { url = "https://files.pythonhosted.org/packages/5e/9e/f326d43f6bf47f4e7704a4426c36e044c6bedfd24e072fb8e27589a373a5/simplejson-3.20.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90d311ba8fcd733a3677e0be21804827226a57144130ba01c3c6a325e887dd86", size = 93530, upload-time = "2025-09-26T16:28:18.07Z" },
- { url = "https://files.pythonhosted.org/packages/35/28/5a4b8f3483fbfb68f3f460bc002cef3a5735ef30950e7c4adce9c8da15c7/simplejson-3.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:feed6806f614bdf7f5cb6d0123cb0c1c5f40407ef103aa935cffaa694e2e0c74", size = 75846, upload-time = "2025-09-26T16:28:19.12Z" },
- { url = "https://files.pythonhosted.org/packages/7a/4d/30dfef83b9ac48afae1cf1ab19c2867e27b8d22b5d9f8ca7ce5a0a157d8c/simplejson-3.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6b1d8d7c3e1a205c49e1aee6ba907dcb8ccea83651e6c3e2cb2062f1e52b0726", size = 75661, upload-time = "2025-09-26T16:28:20.219Z" },
- { url = "https://files.pythonhosted.org/packages/09/1d/171009bd35c7099d72ef6afd4bb13527bab469965c968a17d69a203d62a6/simplejson-3.20.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:552f55745044a24c3cb7ec67e54234be56d5d6d0e054f2e4cf4fb3e297429be5", size = 150579, upload-time = "2025-09-26T16:28:21.337Z" },
- { url = "https://files.pythonhosted.org/packages/61/ae/229bbcf90a702adc6bfa476e9f0a37e21d8c58e1059043038797cbe75b8c/simplejson-3.20.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2da97ac65165d66b0570c9e545786f0ac7b5de5854d3711a16cacbcaa8c472d", size = 158797, upload-time = "2025-09-26T16:28:22.53Z" },
- { url = "https://files.pythonhosted.org/packages/90/c5/fefc0ac6b86b9108e302e0af1cf57518f46da0baedd60a12170791d56959/simplejson-3.20.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f59a12966daa356bf68927fca5a67bebac0033cd18b96de9c2d426cd11756cd0", size = 148851, upload-time = "2025-09-26T16:28:23.733Z" },
- { url = "https://files.pythonhosted.org/packages/43/f1/b392952200f3393bb06fbc4dd975fc63a6843261705839355560b7264eb2/simplejson-3.20.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133ae2098a8e162c71da97cdab1f383afdd91373b7ff5fe65169b04167da976b", size = 152598, upload-time = "2025-09-26T16:28:24.962Z" },
- { url = "https://files.pythonhosted.org/packages/f4/b4/d6b7279e52a3e9c0fa8c032ce6164e593e8d9cf390698ee981ed0864291b/simplejson-3.20.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7977640af7b7d5e6a852d26622057d428706a550f7f5083e7c4dd010a84d941f", size = 150498, upload-time = "2025-09-26T16:28:26.114Z" },
- { url = "https://files.pythonhosted.org/packages/62/22/ec2490dd859224326d10c2fac1353e8ad5c84121be4837a6dd6638ba4345/simplejson-3.20.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b530ad6d55e71fa9e93e1109cf8182f427a6355848a4ffa09f69cc44e1512522", size = 152129, upload-time = "2025-09-26T16:28:27.552Z" },
- { url = "https://files.pythonhosted.org/packages/33/ce/b60214d013e93dd9e5a705dcb2b88b6c72bada442a97f79828332217f3eb/simplejson-3.20.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bd96a7d981bf64f0e42345584768da4435c05b24fd3c364663f5fbc8fabf82e3", size = 159359, upload-time = "2025-09-26T16:28:28.667Z" },
- { url = "https://files.pythonhosted.org/packages/99/21/603709455827cdf5b9d83abe726343f542491ca8dc6a2528eb08de0cf034/simplejson-3.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f28ee755fadb426ba2e464d6fcf25d3f152a05eb6b38e0b4f790352f5540c769", size = 154717, upload-time = "2025-09-26T16:28:30.288Z" },
- { url = "https://files.pythonhosted.org/packages/3c/f9/dc7f7a4bac16cf7eb55a4df03ad93190e11826d2a8950052949d3dfc11e2/simplejson-3.20.2-cp313-cp313-win32.whl", hash = "sha256:472785b52e48e3eed9b78b95e26a256f59bb1ee38339be3075dad799e2e1e661", size = 74289, upload-time = "2025-09-26T16:28:31.809Z" },
- { url = "https://files.pythonhosted.org/packages/87/10/d42ad61230436735c68af1120622b28a782877146a83d714da7b6a2a1c4e/simplejson-3.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:a1a85013eb33e4820286139540accbe2c98d2da894b2dcefd280209db508e608", size = 75972, upload-time = "2025-09-26T16:28:32.883Z" },
- { url = "https://files.pythonhosted.org/packages/05/5b/83e1ff87eb60ca706972f7e02e15c0b33396e7bdbd080069a5d1b53cf0d8/simplejson-3.20.2-py3-none-any.whl", hash = "sha256:3b6bb7fb96efd673eac2e4235200bfffdc2353ad12c54117e1e4e2fc485ac017", size = 57309, upload-time = "2025-09-26T16:29:35.312Z" },
-]
-
[[package]]
name = "six"
version = "1.17.0"
@@ -2309,6 +2167,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" },
]
+[[package]]
+name = "vl-convert-python"
+version = "1.9.0.post1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/93/89/36722344d1758ec2106f4e8eca980f173cfe8f8d0358c1b77cc5d2e035a4/vl_convert_python-1.9.0.post1.tar.gz", hash = "sha256:a5b06b3128037519001166f5341ec7831e19fbd7f3a5f78f73d557ac2d5859ef", size = 4663469, upload-time = "2026-01-21T00:09:55.61Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/9f/59/e5862245972ff467d38b0eb5ad28154685e23ecabb47e14f2b6962da7b56/vl_convert_python-1.9.0.post1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:43e9515f65bbcd317d1ef328787fd7bf0344c2fde9292eb7a0e64d5d3d29fccb", size = 30512930, upload-time = "2026-01-21T00:09:43.198Z" },
+ { url = "https://files.pythonhosted.org/packages/62/e6/e7d0b538c2f0daaf120901dc113bd5d5d1fa51a9532fa5ffd90234e8c69e/vl_convert_python-1.9.0.post1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:b0e7a3245f32addec7e7abeb1badf72b1513ed71ba1dba7aca853901217b3f4e", size = 29738742, upload-time = "2026-01-21T00:09:46.016Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/e2/5645a1bc174c53ff8cd305ed76a4a76ba36e155302db20b42b7e78daeef8/vl_convert_python-1.9.0.post1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6ecfe4b7e2ea9e8c30fd6d6eaea3ef85475be1ad249407d9796dce4ecdb5b32", size = 33366278, upload-time = "2026-01-21T00:09:48.42Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/18/88e02899b72fa8273ffb32bde12b0e5776ee0fd9fb29559a49c48ec4c5fa/vl_convert_python-1.9.0.post1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c1558fa0055e88c465bd3d71760cde9fa2c94a95f776a0ef9178252fd820b1f", size = 33520215, upload-time = "2026-01-21T00:09:50.992Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/db/6e8616587035bf0745d0f10b1791c7e945180ac5d6b28677d2f2b3ca693c/vl_convert_python-1.9.0.post1-cp37-abi3-win_amd64.whl", hash = "sha256:7e263269ac0d304640ca842b44dfe430ed863accd9edecff42e279bfc48ce940", size = 32051516, upload-time = "2026-01-21T00:09:53.47Z" },
+]
+
[[package]]
name = "webencodings"
version = "0.5.1"