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"