diff --git a/examples/grounded_mindmaps.py b/examples/grounded_mindmaps.py new file mode 100644 index 0000000..91c8e4e --- /dev/null +++ b/examples/grounded_mindmaps.py @@ -0,0 +1,191 @@ +import logging + +from dotenv import load_dotenv + +from bigdata_research_tools.mindmap.mindmap_generator import MindMapGenerator +from bigdata_research_tools.visuals.mindmap_visuals import plot_mindmap + +# Load environment variables for authentication +print(f"Environment variables loaded: {load_dotenv()}") + +# Configure logging +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + + +def test_one_shot_mindmap( + main_theme, + focus, + map_type, + instructions, + llm_base_config: str = "openai::gpt-4o-mini", +): + """Test one-shot mind map generation with base LLM.""" + logger.info("=" * 60) + logger.info("TEST 1: One-Shot Mind Map Generation with Base LLM") + logger.info("=" * 60) + mindmap_generator = MindMapGenerator( + llm_model_config_base=llm_base_config, + ) + _, mindmap = mindmap_generator.generate_one_shot( + instructions=instructions, + focus=focus, + main_theme=main_theme, + map_type=map_type, + allow_grounding=False, + ) + logger.info("Results: %s", mindmap["mindmap_text"]) + return mindmap["mindmap_df"], mindmap["mindmap_json"] + + +def test_refined_mindmap( + main_theme, + focus, + map_type, + instructions, + base_mindmap: str, + llm_base_config: str = "openai::o3-mini", +): + """Test refined mindmap generation with reasoning LLM sent in the base config.""" + logger.info("=" * 60) + logger.info("TEST 2: Refined MindMap Generation with Reasoning LLM in Base Config") + logger.info("=" * 60) + mindmap_generator = MindMapGenerator( + llm_model_config_base=llm_base_config, + ) + _, mindmap = mindmap_generator.generate_refined( + focus=focus, + main_theme=main_theme, + initial_mindmap=base_mindmap, + output_dir="./refined_mindmaps", + filename="refined_mindmap.json", + map_type=map_type, + instructions=instructions, + ) + logger.info("Results: %s", mindmap["mindmap_text"]) + + +def test_refined_mindmap2( + main_theme, + focus, + map_type, + instructions, + base_mindmap: str, + llm_base_config: str, + llm_reasoning_config: str = "openai::o3-mini", +): + """Test refined mindmap generation with reasoning LLM sent in the reasoning config.""" + logger.info("=" * 60) + logger.info( + "TEST 3: Refined MindMap Generation with Reasoning LLM in Reasoning Config" + ) + logger.info("=" * 60) + mindmap_generator = MindMapGenerator( + llm_model_config_base=llm_base_config, + llm_model_config_reasoning=llm_reasoning_config, + ) + _, mindmap = mindmap_generator.generate_refined( + focus=focus, + main_theme=main_theme, + initial_mindmap=base_mindmap, + date_range=("2025-10-01", "2025-10-31"), + output_dir="./refined_mindmaps", + filename="refined_mindmap.json", + map_type=map_type, + instructions=instructions, + ) + logger.info("Results: %s", mindmap["mindmap_text"]) + + +def test_dynamic_mindmap( + main_theme, + focus, + map_type, + instructions, + llm_base_config: str = "openai::gpt-4o-mini", + llm_reasoning_config: str = "openai::o3-mini", +): + """Test dynamic mindmap generation with two LLMs.""" + logger.info("=" * 60) + logger.info("TEST 4: Dynamic MindMap Generation with Two LLMs") + logger.info("=" * 60) + mindmap_generator = MindMapGenerator( + llm_model_config_base=llm_base_config, + llm_model_config_reasoning=llm_reasoning_config, + ) + _, mindmap = mindmap_generator.generate_dynamic( + instructions=instructions, + focus=focus, + main_theme=main_theme, + month_intervals=[ + ("2025-09-01", "2025-09-30"), + ("2025-10-01", "2025-10-31"), + ("2025-11-01", "2025-11-30"), + ], + month_names=[ + "September_2025", + "October_2025", + "November_2025", + ], + ) + logger.info("Results: %s", mindmap["base_mindmap"]["mindmap_json"]) + logger.info("Results: %s", mindmap["October_2025"]["mindmap_json"]) + logger.info("") + + +def main( + MAIN_THEME="Political Change in Japan.", + INSTRUCTIONS="Create a mindmap according to a given risk scenario. Map by risk type for any industry and assess short term impact only.", + FOCUS="Provide a detailed taxonomy of risks related to changes in the Japanese political landscape. Evaluate how the resignation of the Prime Minister and the pre-election of Sanae Takaichi will affect companies, their strategy and operations. Take into consideration their increased conservative stance on immigration, energy, and trade. Add any other risk areas that may arise from these political changes. The mind map should be as comprehensive as possible and cover all major risk areas.", + map_type="risk", +): + """Run all tests.""" + logger.info("Testing Grounded MindMap Generation") + logger.info("=" * 60) + + try: + df_mindmap, base_mindmap = test_one_shot_mindmap( + MAIN_THEME, + FOCUS, + map_type, + INSTRUCTIONS, + llm_base_config="openai::gpt-4o-mini", + ) + plot_mindmap(df_mindmap, MAIN_THEME) + test_refined_mindmap( + MAIN_THEME, + FOCUS, + map_type, + INSTRUCTIONS, + base_mindmap, + llm_base_config="openai::o3-mini", + ) + test_refined_mindmap2( + MAIN_THEME, + FOCUS, + map_type, + INSTRUCTIONS, + base_mindmap, + llm_base_config="openai::o3-mini", + ) + test_dynamic_mindmap( + MAIN_THEME, + FOCUS, + map_type, + INSTRUCTIONS, + llm_base_config="openai::gpt-4o-mini", + llm_reasoning_config="openai::o3-mini", + ) + + logger.info("=" * 60) + logger.info("All tests completed successfully") + + except Exception as e: + logger.error("Error during testing: %s", e) + raise + + +if __name__ == "__main__": + main() diff --git a/examples/risk_analyzer.py b/examples/risk_analyzer.py index 7edb2f3..c14e2f6 100644 --- a/examples/risk_analyzer.py +++ b/examples/risk_analyzer.py @@ -34,6 +34,7 @@ def risk_analyzer_example( control_entities=control_entities, focus=focus, # Optional focus to narrow the theme, llm_model_config=llm_model_config, + ground_mindmap=False, ) class PrintObserver(Observer): diff --git a/examples/thematic_screener.py b/examples/thematic_screener.py index dacc07d..67d34f6 100644 --- a/examples/thematic_screener.py +++ b/examples/thematic_screener.py @@ -30,6 +30,7 @@ def thematic_screener_example( end_date="2024-02-28", document_type=DocumentType.TRANSCRIPTS, fiscal_year=2024, + ground_mindmap=True, ) class PrintObserver(Observer): @@ -59,7 +60,7 @@ def update(self, message: OberserverNotification): x = thematic_screener_example( "Chip Manufacturers", export_path=str(output_path), - llm_model_config="openai::gpt-5-mini", + llm_model_config="openai::gpt-4o-mini", ) custom_config = { "company_column": "Company", diff --git a/pyproject.toml b/pyproject.toml index 209308f..3db1bf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "json-repair>=0.44.1", "tabulate>=0.9.0,<1.0.0", "plotly>=6.0.0,<7.0.0", + "matplotlib>=3.10.6,<4.0.0" ] [project.urls] diff --git a/src/bigdata_research_tools/llm/azure.py b/src/bigdata_research_tools/llm/azure.py index 7929568..37ac3b9 100644 --- a/src/bigdata_research_tools/llm/azure.py +++ b/src/bigdata_research_tools/llm/azure.py @@ -97,7 +97,7 @@ async def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> st async def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], + tools: list[dict], temperature: float = 0, **kwargs, ) -> dict[str, list[dict] | str]: @@ -234,7 +234,7 @@ def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> str: def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], + tools: list[dict], temperature: float = 0, **kwargs, ) -> dict[str, list[dict] | str]: diff --git a/src/bigdata_research_tools/llm/base.py b/src/bigdata_research_tools/llm/base.py index 8296733..d919ae1 100644 --- a/src/bigdata_research_tools/llm/base.py +++ b/src/bigdata_research_tools/llm/base.py @@ -59,6 +59,7 @@ def validate_reasoning_config(self): self.reasoning_effort = ( self.reasoning_effort if self.reasoning_effort is not None else "high" ) + self.max_completion_tokens = None if self.temperature is not None: warnings.warn( "The selected model does not support temperature settings. " @@ -112,7 +113,7 @@ async def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> st async def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], + tools: list[dict], temperature: float = 0, **kwargs, ) -> dict[str, list[dict] | str]: @@ -202,7 +203,7 @@ async def get_stream_response( async def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], + tools: list[dict], temperature: float = 0, **kwargs, ) -> dict[str, list[dict] | str]: @@ -244,8 +245,7 @@ def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> str: def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], - temperature: float = 0, + tools: list[dict], **kwargs, ) -> dict[str, list[dict] | str]: """ @@ -331,8 +331,7 @@ def get_stream_response( def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], - temperature: float = 0, + tools: list[dict], **kwargs, ) -> dict[str, list[dict] | str]: """ @@ -352,9 +351,7 @@ def get_tools_response( - arguments (list[dict]): List of arguments for each function - text (str): The text content of the message, if any. """ - return self.provider.get_tools_response( - chat_history, tools, temperature, **kwargs - ) + return self.provider.get_tools_response(chat_history, tools, **kwargs) class NotInitializedLLMProviderError(Exception): diff --git a/src/bigdata_research_tools/llm/bedrock.py b/src/bigdata_research_tools/llm/bedrock.py index e50456e..f099586 100644 --- a/src/bigdata_research_tools/llm/bedrock.py +++ b/src/bigdata_research_tools/llm/bedrock.py @@ -2,7 +2,6 @@ try: from boto3 import Session # ty: ignore[unresolved-import] - from botocore import BaseClient # type: ignore[unresolved-import] except ImportError: raise ImportError( "Missing optional dependency for LLM Bedrock provider, " @@ -34,7 +33,7 @@ def configure_bedrock_client(self) -> None: if not self._client: self._client = Session(**self.connection_config) - def _get_bedrock_client(self) -> BaseClient: + def _get_bedrock_client(self): if not self._client: raise NotInitializedLLMProviderError(self) return self._client.client("bedrock-runtime") @@ -113,7 +112,7 @@ async def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> st async def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], + tools: list[dict], temperature: float = 0, **kwargs, ) -> dict[str, list[dict] | str]: @@ -195,7 +194,7 @@ def configure_bedrock_client(self) -> None: if not self._client: self._client = Session(**self.connection_config) - def _get_bedrock_client(self) -> BaseClient: + def _get_bedrock_client(self): if not self._client: raise NotInitializedLLMProviderError(self) return self._client.client("bedrock-runtime") @@ -274,7 +273,7 @@ def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> str: def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], + tools: list[dict], temperature: float = 0, **kwargs, ) -> dict[str, list[dict] | str]: diff --git a/src/bigdata_research_tools/llm/openai.py b/src/bigdata_research_tools/llm/openai.py index 57152bd..54b7116 100644 --- a/src/bigdata_research_tools/llm/openai.py +++ b/src/bigdata_research_tools/llm/openai.py @@ -58,8 +58,7 @@ async def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> st async def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], - temperature: float = 0, + tools: list[dict], **kwargs, ) -> dict[str, list[dict] | str]: """ @@ -85,7 +84,6 @@ async def get_tools_response( messages=chat_history, model=self.model, tools=tools, - temperature=temperature, **kwargs, ) message = response.choices[0].message @@ -169,10 +167,9 @@ def get_response(self, chat_history: list[dict[str, str]], **kwargs) -> str: def get_tools_response( self, chat_history: list[dict[str, str]], - tools: list[dict[str, str]], - temperature: float = 0, + tools: list[dict], **kwargs, - ) -> dict[str, list[dict] | str]: + ) -> dict: """ Get the response from an LLM model from OpenAI with tools. Args: @@ -196,19 +193,26 @@ def get_tools_response( messages=chat_history, model=self.model, tools=tools, - temperature=temperature, **kwargs, ) message = response.choices[0].message output = { + "id": [], "func_names": [], "arguments": [], "text": message.content, + "tool_calls": {}, } + if function_calls := message.tool_calls if message.tool_calls else None: output = { + "id": [f.id for f in function_calls], "func_names": [f.function.name for f in function_calls], "arguments": [loads(f.function.arguments) for f in function_calls], + "tool_calls": response.model_dump() + .get("choices", [])[0] + .get("message", {}) + .get("tool_calls", []), } return output diff --git a/src/bigdata_research_tools/mindmap/__init__.py b/src/bigdata_research_tools/mindmap/__init__.py new file mode 100644 index 0000000..2e8809e --- /dev/null +++ b/src/bigdata_research_tools/mindmap/__init__.py @@ -0,0 +1,8 @@ +from bigdata_research_tools.mindmap.mindmap import ( + MindMap, + generate_risk_tree, + generate_theme_tree, +) +from bigdata_research_tools.mindmap.mindmap_generator import MindMapGenerator + +__all__ = ["MindMap", "MindMapGenerator", "generate_theme_tree", "generate_risk_tree"] diff --git a/src/bigdata_research_tools/tree.py b/src/bigdata_research_tools/mindmap/mindmap.py similarity index 83% rename from src/bigdata_research_tools/tree.py rename to src/bigdata_research_tools/mindmap/mindmap.py index 018451e..2dc2e65 100644 --- a/src/bigdata_research_tools/tree.py +++ b/src/bigdata_research_tools/mindmap/mindmap.py @@ -30,7 +30,7 @@ @dataclass -class SemanticTree: +class MindMap: """ A hierarchical tree structure where each node represents a semantically meaningful unit, or node, that guide the analyst's research process. @@ -44,36 +44,36 @@ class SemanticTree: summary (str): A brief explanation of the node's relevance. For the root node, this describes the overall relevance of the tree; for sub-nodes, it explains their connection to the parent node. - children (list[SemanticTree] | None): A list of child nodes representing sub-units. + children (list[MindMap] | None): A list of child nodes representing sub-units. keywords (list[str] | None): A list of keywords summarizing the current node. """ label: str node: int summary: str = "" - children: list["SemanticTree"] = field(default_factory=list) + children: list["MindMap"] = field(default_factory=list) keywords: list[str] = field(default_factory=list) def __str__(self) -> str: return self.as_string() @staticmethod - def from_dict(tree_dict: dict) -> "SemanticTree": + def from_dict(tree_dict: dict) -> "MindMap": """ - Create a SemanticTree object from a dictionary. + Create a MindMap object from a dictionary. Args: - tree_dict (dict): A dictionary representing the SemanticTree structure. + tree_dict (dict): A dictionary representing the MindMap structure. Returns: - SemanticTree: The SemanticTree object generated from the dictionary. + MindMap: The MindMap object generated from the dictionary. """ # Handle case sensitivity in keys tree_dict = dict_keys_to_lowercase(tree_dict) - tree = SemanticTree(**tree_dict) # ty: ignore[missing-argument] + tree = MindMap(**tree_dict) # ty: ignore[missing-argument] tree.children = [ - SemanticTree.from_dict(child) for child in tree_dict.get("children", []) + MindMap.from_dict(child) for child in tree_dict.get("children", []) ] return tree @@ -110,7 +110,7 @@ def get_label_summaries(self) -> dict[str, str]: Extract the label summaries from the tree. Returns: - dict[str, str]: Dictionary with all the labels of the SemanticTree as keys and their associated summaries as values. + dict[str, str]: Dictionary with all the labels of the MindMap as keys and their associated summaries as values. """ label_summary = {self.label: self.summary} for child in self.children: @@ -119,7 +119,7 @@ def get_label_summaries(self) -> dict[str, str]: def get_summaries(self) -> list[str]: """ - Extract the node summaries from a SemanticTree. + Extract the node summaries from a MindMap. Returns: list[str]: List of all 'summary' values in the tree, including its children. @@ -134,7 +134,7 @@ def get_terminal_label_summaries(self) -> dict[str, str]: Extract the items (labels, summaries) from terminal nodes of the tree. Returns: - dict[str, str]: Dictionary with the labels of the SemanticTree as keys and + dict[str, str]: Dictionary with the labels of the MindMap as keys and their associated summaries as values, only using terminal nodes. """ label_summary = {} @@ -192,12 +192,28 @@ def visualize(self, engine: str = "graphviz") -> None: self._visualize_graphviz() elif engine == "plotly": self._visualize_plotly() + elif engine == "matplotlib": + self._visualize_matplotlib() else: raise ValueError( f"Unsupported engine '{engine}'. " - f"Supported engines are 'graphviz' and 'plotly'." + f"Supported engines are 'graphviz', 'plotly', and 'matplotlib'." ) + def _visualize_matplotlib(self): + """ + Auxiliary function to visualize the tree using Matplotlib. + + Returns: + A Matplotlib Plot rendering the mindmap. + """ + import matplotlib + + matplotlib.use("Agg") # Use non-interactive backend + from bigdata_research_tools.visuals.mindmap_visuals import plot_mindmap + + plot_mindmap(self.to_dataframe(), main_theme=self.label) + def _visualize_graphviz(self) -> graphviz.Digraph: """ Auxiliary function to visualize the tree using Graphviz. @@ -214,7 +230,7 @@ def _visualize_graphviz(self) -> graphviz.Digraph: splines="curved", ) - def add_nodes(node: SemanticTree): + def add_nodes(node: MindMap): # Determine if the node is a terminal (leaf) node is_terminal = not node.children @@ -272,7 +288,7 @@ def _visualize_plotly(self) -> None: "please install `bigdata_research_tools[plotly]` to enable them." ) - def extract_labels(node: SemanticTree, parent_label=""): + def extract_labels(node: MindMap, parent_label=""): labels.append(node.label) parents.append(parent_label) for child in node.children: @@ -330,14 +346,49 @@ def save_json(self, filepath: str, **kwargs) -> None: with open(filepath, "w", encoding="utf-8") as f: json.dump(self._to_dict(), f, ensure_ascii=False, indent=2, **kwargs) + def to_rows(self, parent_label=None): + """ + Flatten tree to rows for DataFrame: each row is (Parent, Label, Node, Summary) + """ + rows = [] + rows.append( + { + "Parent": parent_label, + "Label": self.label, + "Node": self.node, + "Summary": self.summary, + } + ) + for child in self.children: + rows.extend(child.to_rows(parent_label=self.label)) + return rows + + def to_dataframe(self, leaves_only=False): + import pandas as pd + + rows = self.to_rows(parent_label=None) + # Exclude rows where Parent is None or Parent == self.label (root node) + filtered = [row for row in rows if row["Parent"] not in (None, self.label)] + if leaves_only: + # Only keep rows that are leaves (i.e., have no children) + filtered = [ + row + for row in filtered + if row["Label"] not in {r["Parent"] for r in filtered} + ] + return pd.DataFrame(filtered) + + def to_json(self): + return json.dumps(self._to_dict(), indent=2) + def generate_theme_tree( main_theme: str, focus: str = "", llm_model_config: LLMConfig | dict | str = "openai::gpt-4o-mini", -) -> SemanticTree: +) -> MindMap: """ - Generate a `SemanticTree` class from a main theme and focus. + Generate a `MindMap` class from a main theme and focus. Args: main_theme (str): The primary theme to analyze. @@ -354,7 +405,7 @@ def generate_theme_tree( - `seed` (int) Returns: - SemanticTree: The generated theme tree. + MindMap: The generated theme tree. """ if isinstance(llm_model_config, dict): llm_model_config = LLMConfig(**llm_model_config) @@ -380,7 +431,7 @@ def generate_theme_tree( tree_str = repair_json(tree_str) tree_dict = ast.literal_eval(tree_str) - return SemanticTree.from_dict(tree_dict) + return MindMap.from_dict(tree_dict) def dict_keys_to_lowercase(d: dict[str, Any]) -> dict[str, Any]: @@ -404,10 +455,10 @@ def dict_keys_to_lowercase(d: dict[str, Any]) -> dict[str, Any]: def stringify_label_summaries(label_summaries: dict[str, str]) -> list[str]: """ - Convert the label summaries of a SemanticTree into a list of strings. + Convert the label summaries of a MindMap into a list of strings. Args: - label_summaries (dict[str, str]): A dictionary of label summaries of SemanticTree. + label_summaries (dict[str, str]): A dictionary of label summaries of MindMap. Expected format: {label: summary}. Returns: List[str]: A list of strings, each one containing a label and its summary, i.e. @@ -420,9 +471,9 @@ def generate_risk_tree( main_theme: str, focus: str = "", llm_model_config: LLMConfig | dict | str = "openai::gpt-4o-mini", -) -> SemanticTree: +) -> MindMap: """ - Generate a `SemanticTree` class from a main theme and analyst focus. + Generate a `MindMap` class from a main theme and analyst focus. Args: main_theme (str): The primary theme to analyze. @@ -440,7 +491,7 @@ def generate_risk_tree( - `seed` (int) Returns: - SemanticTree: The generated theme tree. + MindMap: The generated theme tree. """ if isinstance(llm_model_config, dict): llm_model_config = LLMConfig(**llm_model_config) @@ -465,7 +516,7 @@ def generate_risk_tree( tree_dict = ast.literal_eval(tree_str) - return SemanticTree.from_dict(tree_dict) + return MindMap.from_dict(tree_dict) def get_default_tree_config(llm_model: str) -> LLMConfig: @@ -475,7 +526,6 @@ def get_default_tree_config(llm_model: str) -> LLMConfig: model=llm_model, reasoning_effort="high", seed=42, - max_completion_tokens=300, response_format={"type": "json_object"}, ) else: @@ -485,7 +535,6 @@ def get_default_tree_config(llm_model: str) -> LLMConfig: top_p=1, frequency_penalty=0, presence_penalty=0, - max_completion_tokens=300, seed=42, response_format={"type": "json_object"}, ) diff --git a/src/bigdata_research_tools/mindmap/mindmap_generator.py b/src/bigdata_research_tools/mindmap/mindmap_generator.py new file mode 100644 index 0000000..8490290 --- /dev/null +++ b/src/bigdata_research_tools/mindmap/mindmap_generator.py @@ -0,0 +1,837 @@ +import ast +import json +import os +import re +from concurrent.futures import ThreadPoolExecutor, as_completed +from logging import Logger, getLogger +from typing import Optional + +from bigdata_client.daterange import AbsoluteDateRange, RollingDateRange +from bigdata_client.models.advanced_search_query import QueryComponent +from bigdata_client.models.search import DocumentType, SortBy +from bigdata_client.query import ( + Any as BigdataAny, +) +from bigdata_client.query import Entity, Keyword, Similarity +from tqdm import tqdm + +from bigdata_research_tools.client import bigdata_connection +from bigdata_research_tools.llm import LLMEngine +from bigdata_research_tools.llm.base import LLMConfig +from bigdata_research_tools.mindmap.mindmap import MindMap, get_default_tree_config +from bigdata_research_tools.mindmap.mindmap_utils import ( + format_mindmap_to_dataframe, + load_results_from_file, + prompts_dict, + save_results_to_file, +) +from bigdata_research_tools.search.search import run_search + +logger: Logger = getLogger(__name__) + +bigdata_tool_description = [ + { + "type": "function", + "function": { + "name": "bigdata_search", + "description": "Run a semantic similarity search on news content using Bigdata API.", + "parameters": { + "type": "object", + "properties": { + "search_list": { + "type": "array", + "items": {"type": "string"}, + "description": "The list of strings containing various detailed sentences to search in News documents.", + }, + "entities_list": { + "type": "array", + "items": {"type": "string"}, + "description": "The list of entities (People, Places or Organizations) to focus the search on. They will be added as search context with an OR logic.", + }, + "keywords_list": { + "type": "array", + "items": {"type": "string"}, + "description": "The list of keywords (one or two words defining topics or concepts) to focus the search on. They will be added as search context with an OR logic.", + }, + }, + "required": ["search_list", "entities_list", "keywords_list"], + }, + }, + } +] + + +class MindMapGenerator: + """ + Core orchestrator for generating, refining, and dynamically evolving mind maps using LLMs and Bigdata search. + + Features: + - One-shot mind map generation (optionally grounded in search results) + - Refined mind map generation (LLM proposes searches to enhance an initial mind map) + - Dynamic mind map evolution over time intervals (each step refines previous map with new search context) + """ + + def __init__( + self, + llm_model_config_base: LLMConfig | dict | str = "openai::gpt-4o-mini", + llm_model_config_reasoning: Optional[LLMConfig | dict | str] = None, + ): + """ + Args: + llm_client: Handles LLM chat and tool-calling. + """ + self.bigdata_connection = bigdata_connection() + + llm_model_config_reasoning = ( + llm_model_config_reasoning + if llm_model_config_reasoning + else llm_model_config_base + ) + + if isinstance(llm_model_config_base, dict): + self.llm_model_config_base = LLMConfig(**llm_model_config_base) + elif isinstance(llm_model_config_base, str): + self.llm_model_config_base = get_default_tree_config(llm_model_config_base) + else: + self.llm_model_config_base = llm_model_config_base + + if isinstance(llm_model_config_reasoning, dict): + self.llm_model_config_reasoning = LLMConfig(**llm_model_config_reasoning) + elif isinstance(llm_model_config_reasoning, str): + self.llm_model_config_reasoning = get_default_tree_config( + llm_model_config_reasoning + ) + else: + self.llm_model_config_reasoning = llm_model_config_reasoning + + self.llm_base = LLMEngine( + model=self.llm_model_config_base.model, + **self.llm_model_config_base.connection_config, + ) + + self.llm_reasoning = LLMEngine( + model=self.llm_model_config_reasoning.model, + **self.llm_model_config_reasoning.connection_config, + ) + + def _parse_llm_to_themetree(self, mindmap_text: str) -> MindMap: + """ + Parse LLM output (expected to be a valid JSON object) into a MindMap. + Strictly enforce JSON/dict structure, required fields, and allowed keys. If parsing or validation fails, raises an error with details. + """ + + text = mindmap_text.strip() + # Remove code block markers and language tags (minimal cleaning) + text = re.sub(r"^```[a-zA-Z]*\s*", "", text) + text = re.sub(r"```$", "", text) + # Remove accidental language tags at the start (e.g., "json\n{") + text = re.sub(r"^[a-zA-Z]+\s*\n*{", "{", text) + # Remove any prefix before the first { or [ + text = re.sub(r"^[^({\[]*({|\[)", r"\1", text, flags=re.DOTALL) + # Try JSON, then ast.literal_eval + try: + tree_dict = json.loads(text) + except Exception: + try: + tree_dict = ast.literal_eval(text) + except Exception as e: + raise ValueError( + f"Failed to parse LLM output as JSON or Python dict.\nRaw output:\n{mindmap_text}\nCLEANED OUTPUT:\n{text}\nError: {e}" + ) + + # --- Strict validation of required fields and allowed keys --- + allowed_keys = {"label", "node", "summary", "children"} + + def validate_node(node, path="root"): + if not isinstance(node, dict): + raise ValueError(f"Node at {path} is not a dict: {node}") + # Check for illegal keys + illegal_keys = set(node.keys()) - allowed_keys + if illegal_keys: + raise ValueError( + f"Illegal key(s) {illegal_keys} at {path}. Node: {node}" + ) + # Check for required fields + for key in allowed_keys: + if key not in node or node[key] is None: + raise ValueError( + f"Missing or null required field '{key}' at {path}. Node: {node}" + ) + if not isinstance(node["children"], list): + raise ValueError( + f"'children' field at {path} is not a list. Node: {node}" + ) + for idx, child in enumerate(node["children"]): + validate_node(child, path=f"{path} -> children[{idx}]") + + # Lowercase keys for robustness + def dict_keys_to_lowercase(d): + if isinstance(d, dict): + return {k.lower(): dict_keys_to_lowercase(v) for k, v in d.items()} + elif isinstance(d, list): + return [dict_keys_to_lowercase(i) for i in d] + else: + return d + + tree_dict = dict_keys_to_lowercase(tree_dict) + try: + validate_node(tree_dict) + except Exception as e: + raise ValueError( + f"Mind map structure validation failed: {e}\nParsed dict:\n{json.dumps(tree_dict, indent=2)}" + ) + try: + theme_tree = MindMap.from_dict(tree_dict) + except Exception as e: + raise ValueError( + f"Failed to build ThemeTree from dict: {e}\nParsed dict:\n{json.dumps(tree_dict, indent=2)}" + ) + return theme_tree + + def _themetree_to_dataframe(self, theme_tree: MindMap): + """ + Convert a ThemeTree object to a pandas DataFrame. + """ + try: + df = theme_tree.to_dataframe() + except Exception as e: + raise ValueError( + f"Failed to convert ThemeTree to DataFrame: {e}\nThemeTree:\n{theme_tree}" + ) + return df + + def compose_base_message( + self, main_theme: str, focus: str, map_type: str, instructions: Optional[str] + ) -> list: + # Explicit, step-by-step prompt (robust, as in working repo, minus Keywords) + if instructions is None: + instructions = prompts_dict[map_type]["default_instructions"].format( + main_theme=main_theme, analyst_focus=focus + ) + + enforce_structure = prompts_dict[map_type]["enforce_structure_string"] + messages = [ + { + "role": "system", + "content": f"{instructions} {focus}\n{enforce_structure}", + }, + { + "role": "user", + "content": prompts_dict[map_type]["user_prompt_message"].format( + main_theme=main_theme + ), + }, + ] + + return messages + + def compose_tool_call_message( + self, + main_theme: str, + focus: str, + map_type: str, + instructions: Optional[str], + date_range: Optional[tuple[str, str]], + initial_mindmap: Optional[str], + ) -> list: + enforce_structure = prompts_dict[map_type]["enforce_structure_string"] + + if instructions is None: + instructions = prompts_dict[map_type]["default_instructions"].format( + main_theme=main_theme, analyst_focus=focus + ) + + tool_prompt = f"{instructions} {focus} You can use news search to find relevant information about the topic. \nUse the Bigdata API to search for news articles related to the topic and use them to inform your response." + + if initial_mindmap: + tool_prompt += f"\nStarting from the following mind map:\n{initial_mindmap}" + if date_range is not None: + tool_prompt += f"\nYour search will be conducted over the range: {date_range[0]} - {date_range[1]}" + + tool_prompt += f"\nReturn a list of searches you would like to perform to enhance it.\n{enforce_structure}" + + messages = [ + {"role": "system", "content": tool_prompt}, + { + "role": "user", + "content": prompts_dict[map_type]["user_prompt_message"].format( + main_theme=main_theme + ), + }, + ] + + return messages + + def send_tool_call( + self, messages: list, llm_client: LLMEngine, llm_kwargs: dict + ) -> tuple: + llm_kwargs.update( + { + "tool_choice": { + "type": "function", + "function": {"name": "bigdata_search"}, + } + } + ) + + response_dict = llm_client.get_tools_response( + messages, tools=bigdata_tool_description, **llm_kwargs + ) + + try: + if response_dict["tool_calls"] is not None: + tool_call_id = response_dict["id"][0] + arguments = response_dict["arguments"][0] + search_list = arguments.get("search_list", []) # ty: ignore[possibly-missing-attribute] + entities_list = arguments.get("entities_list", []) # ty: ignore[possibly-missing-attribute] + keywords_list = arguments.get("keywords_list", []) # ty: ignore[possibly-missing-attribute] + return ( + tool_call_id, + response_dict["tool_calls"], + search_list, + entities_list, + keywords_list, + ) + else: + print("No tool call found in the response.") + + return None, None, response_dict["text"], None, None + except Exception as e: + raise RuntimeError(f"Failed to parse OpenAI tool call response: {e}") + + def compose_final_message( + self, + main_theme: str, + focus: str, + map_type: str, + instructions: Optional[str], + date_range: Optional[tuple[str, str]], + tool_calls, + tool_call_id, + context, + ) -> list: + enforce_structure = prompts_dict[map_type]["enforce_structure_string"] + + if instructions is None: + instructions = prompts_dict[map_type]["default_instructions"].format( + main_theme=main_theme, analyst_focus=focus + ) + + final_prompt = f"{instructions} {focus}. \nIMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant.\n{enforce_structure}" + + if date_range is not None: + final_prompt += f"\nYour search will be conducted over the range: {date_range[0]} - {date_range[1]}" + + final_message = [ + { + "role": "system", + "content": final_prompt, + }, + { + "role": "user", + "content": prompts_dict[map_type]["user_prompt_message"].format( + main_theme=main_theme + ), + }, + {"role": "assistant", "content": None, "tool_calls": tool_calls}, + {"role": "tool", "tool_call_id": tool_call_id, "content": context}, + ] + + return final_message + + def compose_refinement_message( + self, + main_theme: str, + focus: str, + map_type: str, + instructions: Optional[str], + date_range: Optional[tuple[str, str]], + initial_mindmap: str, + context: str, + tool_calls, + tool_call_id: str | None, + ) -> list: + enforce_structure = prompts_dict[map_type]["enforce_structure_string"] + + if instructions is None: + instructions = prompts_dict[map_type]["default_instructions"].format( + main_theme=main_theme, analyst_focus=focus + ) + + refine_prompt = f"{instructions} {prompts_dict[map_type]['qualifier']}: {main_theme} {focus}.\nBased on these instructions, enhance the given mindmap with the information below. Only return the mindmap without extra text.\nIMPORTANT: Only create additional branches if the tool call results contain explicit information suggesting that new branches would be relevant.\n{enforce_structure}." + + if date_range is not None: + refine_prompt += f"\nYour search will be conducted over the range: {date_range[0]} - {date_range[1]}" + + refinement_messages = [ + {"role": "system", "content": refine_prompt}, + {"role": "user", "content": initial_mindmap}, + {"role": "assistant", "content": None, "tool_calls": tool_calls}, + {"role": "tool", "tool_call_id": tool_call_id, "content": context}, + ] + + return refinement_messages + + def generate_one_shot( + self, + main_theme: str, + focus: str, + allow_grounding: bool = False, + instructions: Optional[str] = None, + date_range: Optional[tuple[str, str]] = None, + map_type: str = "risk", + ) -> tuple[MindMap, dict]: + """ + Generate a mind map in one LLM call, optionally allowing the LLM to request grounding. + If allow_grounding is True, use the specified grounding_method ("tool_call" or "chat"). + Optionally log intermediate steps to disk. + """ + + messages = self.compose_base_message( + main_theme=main_theme, + focus=focus, + map_type=map_type, + instructions=instructions, + ) + + llm_kwargs = self.llm_model_config_base.get_llm_kwargs( + remove_max_tokens=True, remove_timeout=True + ) + if allow_grounding: + messages = self.compose_tool_call_message( + main_theme=main_theme, + focus=focus, + map_type=map_type, + instructions=instructions, + date_range=date_range, + initial_mindmap=None, + ) + tool_call_id, tool_calls, search_list, entities_list, keywords_list = ( + self.send_tool_call(messages, self.llm_base, llm_kwargs) + ) + + if search_list and isinstance(search_list, list): + context = self._run_and_collate_search( + search_list, entities_list, keywords_list, date_range=date_range + ) + + final_messages = self.compose_final_message( + main_theme=main_theme, + focus=focus, + map_type=map_type, + instructions=instructions, + date_range=date_range, + tool_calls=tool_calls, + tool_call_id=tool_call_id, + context=context, + ) + + mindmap_text = self.llm_base.get_response(final_messages) + + theme_tree = self._parse_llm_to_themetree(mindmap_text) + df = self._themetree_to_dataframe(theme_tree) + return theme_tree, { + "mindmap_text": mindmap_text, + "mindmap_df": df, + "mindmap_json": theme_tree.to_json(), ##where does this come from? + "grounded": True, + "search_queries": search_list, + "search_context": context, + } + else: + # decide if this fallback should be simplified + mindmap_text = search_list if isinstance(search_list, str) else "" + theme_tree = self._parse_llm_to_themetree( + mindmap_text + ) ## check if correct + df = format_mindmap_to_dataframe(mindmap_text) + return MindMap("", 0), { + "mindmap_text": mindmap_text, + "mindmap_df": df, + "mindmap_json": theme_tree.to_json(), + "grounded": False, + } + # Default: just generate mind map + mindmap_text = self.llm_base.get_response(messages) + + theme_tree = self._parse_llm_to_themetree(mindmap_text) + df = self._themetree_to_dataframe(theme_tree) + return theme_tree, { + "mindmap_text": mindmap_text, + "mindmap_tree": theme_tree, + "mindmap_json": theme_tree.to_json(), + "mindmap_df": df, + "grounded": False, + } + + def generate_refined( + self, + main_theme: str, + focus: str, + initial_mindmap: str, + output_dir: str = "./refined_mindmaps", + filename: str = "refined_mindmap.json", + map_type: str = "risk", + instructions: Optional[str] = None, + search_scope: Optional[DocumentType] = None, + sortby: Optional[SortBy] = None, + date_range: Optional[tuple[str, str]] = None, + chunk_limit: int = 20, + **llm_kwargs, + ) -> tuple[MindMap | None, dict]: + """ + Refine an initial mind map: LLM proposes searches, search is run, LLM refines mind map with search results. + Optionally log intermediate steps to disk. + """ + + messages = self.compose_tool_call_message( + main_theme=main_theme, + focus=focus, + map_type=map_type, + instructions=instructions, + date_range=date_range, + initial_mindmap=initial_mindmap, + ) + llm_kwargs = self.llm_model_config_reasoning.get_llm_kwargs( + remove_max_tokens=True, remove_timeout=True + ) + + tool_call_id, tool_calls, search_list, entities_list, keywords_list = ( + self.send_tool_call(messages, self.llm_reasoning, llm_kwargs=llm_kwargs) + ) + + if search_list and isinstance(search_list, list): + context = self._run_and_collate_search( + search_list, + entities_list, + keywords_list, + search_scope, + sortby, + date_range, + chunk_limit, + ) + + refinement_messages = self.compose_refinement_message( + main_theme=main_theme, + focus=focus, + map_type=map_type, + instructions=instructions, + date_range=date_range, + initial_mindmap=initial_mindmap, + tool_calls=tool_calls, + tool_call_id=tool_call_id, + context=context, + ) + mindmap_text = self.llm_reasoning.get_response(refinement_messages) + + theme_tree = self._parse_llm_to_themetree(mindmap_text) + df = self._themetree_to_dataframe(theme_tree) + result_dict = { + "mindmap_text": mindmap_text, + "mindmap_df": df, + "mindmap_json": theme_tree.to_json(), + "search_queries": search_list, + "search_context": context, + } + save_results_to_file(result_dict, output_dir, filename) + return theme_tree, result_dict + else: + mindmap_text = search_list if isinstance(search_list, str) else "" + df = format_mindmap_to_dataframe(mindmap_text) + result_dict = { + "mindmap_text": mindmap_text, + "mindmap_df": df, + "mindmap_json": "", + "search_queries": [], + "search_context": "", + } + save_results_to_file(result_dict, output_dir, filename) + return None, result_dict + + def generate_or_load_refined( + self, + main_theme: str, + focus: str, + map_type: str, + initial_mindmap: str, + instructions: Optional[str], + search_scope: Optional[DocumentType] = None, + sortby: Optional[SortBy] = None, + date_range: Optional[tuple[str, str]] = None, + chunk_limit: int = 20, + output_dir: str = "./bootstrapped_mindmaps", + filename: str = "refined_mindmap", + i: int = 0, + ) -> dict: + if f"{filename}_{i}.json" in os.listdir(output_dir): + result = load_results_from_file(output_dir, f"{filename}_{i}.json") + print(f"Loaded existing result for {filename}_{i}.json") + else: + try: + _, result = self.generate_refined( + instructions=instructions, + focus=focus, + main_theme=main_theme, + map_type=map_type, + initial_mindmap=initial_mindmap, + date_range=date_range, + search_scope=search_scope, + sortby=sortby, + chunk_limit=chunk_limit, + output_dir=output_dir, + filename=f"{filename}_{i}.json", + ) + # save_results_to_file(result, output_dir, ) + except Exception: + _, result = self.generate_refined( + instructions=instructions, + focus=focus, + main_theme=main_theme, + map_type=map_type, + initial_mindmap=initial_mindmap, + date_range=date_range, + output_dir=output_dir, + filename=f"{filename}_{i}.json", + ) + + return result + + def bootstrap_refined( + self, + main_theme: str, + focus: str, + map_type: str, + initial_mindmap: str, + instructions: Optional[str], + search_scope: Optional[DocumentType] = None, + sortby: Optional[SortBy] = None, + date_range: Optional[tuple[str, str]] = None, + chunk_limit: int = 20, + output_dir: str = "./bootstrapped_mindmaps", + filename: str = "refined_mindmap", + n_elements: int = 50, + max_workers: int = 10, + ) -> list[dict]: + """ + Generate multiple refined mindmaps in parallel using ThreadPoolExecutor. + + Generates n_elements mindmaps by calling generate_or_load_refined for each index. + Uses a thread pool to parallelize the generation process for better efficiency. + Each mindmap is saved with an index suffix to the output_dir. + + Returns a list of all generated mindmap results. + """ + # Create output directory if it doesn't exist + os.makedirs(output_dir, exist_ok=True) + + refined_results = [] + with ThreadPoolExecutor(max_workers=max_workers) as executor: + # Create a mapping of futures to their corresponding indices + future_to_index = {} + + # Submit all tasks and track which future corresponds to which index + for i in range(n_elements): + future = executor.submit( + self.generate_or_load_refined, + instructions=instructions, + focus=focus, + main_theme=main_theme, + map_type=map_type, + initial_mindmap=initial_mindmap, + search_scope=search_scope, + sortby=sortby, + date_range=date_range, + chunk_limit=chunk_limit, + output_dir=output_dir, + filename=filename, + i=i, + ) + future_to_index[future] = i + + # Process futures as they complete + for future in tqdm( + as_completed(future_to_index), + total=n_elements, + desc="Bootstrapping Refined Mindmaps...", + ): + i = future_to_index[future] + try: + # Store the result in the list + refined_results.append(future.result()) + except Exception as e: + print(f"Error in generating mindmap {i}: {e}") + + return refined_results + + def generate_dynamic( + self, + main_theme: str, + focus: str, + month_intervals: list[tuple[str, str]], + month_names: list[str], + instructions: Optional[str], + search_scope: Optional[DocumentType] = None, + sortby: Optional[SortBy] = None, + chunk_limit: int = 20, + map_type: str = "risk", + output_dir: str = "./dynamic_mindmaps", + **llm_kwargs, + ) -> tuple[dict[str, MindMap], dict]: + """ + Dynamic/iterative mind map generation over time intervals. + Returns a list of dicts, one per interval. + Each step: generate/refine mind map for the given interval, grounded in search results for that period. + """ + results = {} + mind_map_objs = {} + # Step 1: Generate initial mind map for t0 + one_shot_map, one_shot_dict = self.generate_one_shot( + main_theme=main_theme, + focus=focus, + allow_grounding=False, + instructions=instructions, + map_type=map_type, + **llm_kwargs, + ) + prev_mindmap = one_shot_dict["mindmap_json"] + mind_map_objs["base_mindmap"] = one_shot_map + results["base_mindmap"] = one_shot_dict + # Step 2: For each subsequent interval, refine using previous mind map and new search, including starting month + for i, (date_range, month_name) in enumerate( + zip(month_intervals, month_names), start=0 + ): + refined_map, refined = self.generate_refined( + main_theme=main_theme, + focus=focus, + initial_mindmap=prev_mindmap, + map_type=map_type, + output_dir=output_dir, + filename=f"{month_name}.json", + instructions=instructions, + search_scope=search_scope, + sortby=sortby, + date_range=date_range, + chunk_limit=chunk_limit, + **llm_kwargs, + ) + + results[month_name] = refined + mind_map_objs[month_name] = refined_map + prev_mindmap = refined["mindmap_json"] + return mind_map_objs, results + + def _run_and_collate_search( + self, + search_list: list[str], + entities_list: Optional[list[str]], + keywords_list: Optional[list[str]], + search_scope: Optional[DocumentType] = None, + sortby: Optional[SortBy] = None, + date_range: Optional[tuple[str, str]] = None, + chunk_limit: int = 20, + ) -> str: + """ + Run Bigdata search for each query and collate results for LLM context. + Uses sensible defaults for scope, sortby, and date_range. + If date_range is a list of one tuple (e.g. [('2025-01-01', '2025-01-31')]), unpacks it. + If date_range is a tuple/list of two strings, converts to AbsoluteDateRange. + """ + + # Set defaults if not provided + scope = search_scope if search_scope is not None else DocumentType.NEWS + sortby = sortby if sortby is not None else SortBy.RELEVANCE + + if date_range is None: + date_range_filter = RollingDateRange.LAST_THIRTY_DAYS + else: + date_range_filter = AbsoluteDateRange( + start=date_range[0], end=date_range[1] + ) + + if entities_list: + print(f"Entities List: {entities_list}") + entity_objs = [] + for entity_name in entities_list: + try: + suggestions = self.bigdata_connection.knowledge_graph.autosuggest( + entity_name, limit=1 + ) + if suggestions: # Check if list is not empty + entity = suggestions[0] + entity_objs.append(entity) + else: + print(f"Warning: No autosuggest results for '{entity_name}'") + except Exception as e: + print(f"Warning: Autosuggest failed for '{entity_name}': {e}") + continue + + confirmed_entities = [ + entity.id + for entity, orig_str in zip(entity_objs, entities_list) + if entity.name.lower() in orig_str.lower() + or orig_str.lower() in entity.name.lower() + ] + if confirmed_entities: + entities = BigdataAny([Entity(entity) for entity in confirmed_entities]) + else: + entities = None + print( + f"Searching with entities: {[entity.name for entity, orig_str in zip(entity_objs, entities_list) if entity.name in orig_str or orig_str in entity.name]}" + ) + else: + entities = None + if keywords_list: + print(f"Searching with keywords: {keywords_list}") + keywords = BigdataAny([Keyword(kw) for kw in keywords_list]) + else: + keywords = None + + print(f"Searching with sentences: {search_list}") + + queries: list[QueryComponent] = [ + Similarity(sentence) for sentence in search_list + ] + if entities: + queries = [query & entities for query in queries] + if keywords: + queries = [query & keywords for query in queries] + + all_results = run_search( + queries=queries, + date_ranges=date_range_filter, + sortby=sortby, + scope=scope, + limit=chunk_limit, + only_results=False, + rerank_threshold=None, + ) + + return self.collate_results(all_results) + + def collate_results(self, results: dict) -> str: + """ + Collate a list of (query, result) tuples into a single string for LLM context. + + Args: + results (list): List of (query, result) tuples. + + Returns: + str: Collated string for LLM context. + """ + doctexts = [] + for (text_query, date_range), result in results.items(): + dictitem = text_query.to_dict() + if dictitem["type"] == "similarity": + sentence = dictitem["value"] + else: + sentence = "" + docstr = f"###Query: {sentence}\n ### Results:\n" + for doc in result: + headline = getattr(doc, "headline", "No headline") + docstr += f"## {headline}\n\n##" + docstr += f"Date: {doc.timestamp.strftime('%Y-%m-%d')}\n\n" + if hasattr(doc, "chunks"): + for chunk in doc.chunks: + docstr += f"{chunk.text}\n" + doctexts.append(docstr) + return "\n".join(doctexts) diff --git a/src/bigdata_research_tools/mindmap/mindmap_utils.py b/src/bigdata_research_tools/mindmap/mindmap_utils.py new file mode 100644 index 0000000..0f8e25b --- /dev/null +++ b/src/bigdata_research_tools/mindmap/mindmap_utils.py @@ -0,0 +1,208 @@ +import json +import os +from io import StringIO + +import pandas as pd + +prompts_dict = { + "theme": { + "qualifier": "Main Theme", + "user_prompt_message": "Your given Theme is: {main_theme}", + "default_instructions": ( + "Forget all previous prompts." + "You are assisting a professional analyst tasked with creating a screener to measure the impact of the theme {main_theme} on companies." + "Your objective is to generate a comprehensive tree structure of distinct sub-themes that will guide the analyst's research process." + "Follow these steps strictly:" + "1. **Understand the Core Theme {main_theme}**:" + " - The theme {main_theme} is a central concept. All components are essential for a thorough understanding." + "2. **Create a Taxonomy of Sub-themes for {main_theme}**:" + " - Decompose the main theme {main_theme} into concise, focused, and self-contained sub-themes." + " - Each sub-theme should represent a singular, concise, informative, and clear aspect of the main theme." + " - Expand the sub-theme to be relevant for the {main_theme}: a single word is not informative enough." + " - Prioritize clarity and specificity in your sub-themes." + " - Avoid repetition and strive for diverse angles of exploration." + " - Provide a comprehensive list of potential sub-themes." + "3. **Iterate Based on the Analyst's Focus {analyst_focus}**:" + " - If no specific {analyst_focus} is provided, transition directly to formatting the JSON response." + "4. **Format Your Response as a JSON Object**:" + " - Each node in the JSON object must include:" + " - `node`: an integer representing the unique identifier for the node." + " - `label`: a string for the name of the sub-theme." + " - `summary`: a string to explain briefly in maximum 15 words why the sub-theme is related to the theme {main_theme}." + " - For the node referring to the first node {main_theme}, just define briefly in maximum 15 words the theme {main_theme}." + " - `children`: an array of child nodes." + ), + "enforce_structure_string": ( + """IMPORTANT: Your response MUST be a valid JSON object. Each node in the JSON object must include:\n" + "- `node`: an integer representing the unique identifier for the node.\n" + "- `label`: a string for the name of the sub-theme.\n" + "- `summary`: a string to explain briefly in maximum 15 words why the sub-theme is related to the theme.\n" + "- For the node referring to the main theme, just define briefly in maximum 15 words the theme.\n" + "- `children`: an array of child nodes.\n" + "Format the JSON object as a nested dictionary. Be careful when specifying keys and items.\n" + "Avoid overlapping labels. Break down joint concepts into unique parents so that each parent represents ONLY ONE concept. AVOID creating branch names such as 'Compliance and Regulatory Risk'. Keep risks separate and create a single branch for each risk, such as 'Compliance Risk' and 'Regulatory Risk', each with their own children.\n" + "Return ONLY the JSON object, with no extra text, explanation, or markdown.\n" + "You MUST use ONLY these field names: label, node, summary, children. Do NOT use underscores, spaces, or any other characters in field names. If you use any other field names, your answer will be rejected.\n" + "## Example Structure:\n" + "**Theme: Global Warming**\n\n" + "{\n" + " \"node\": 1,\n" + " \"label\": \"Global Warming\",\n" + " \"summary\": \"Global Warming is a serious risk\",\n" + " \"children\": [\n" + " {\"node\": 2, \"label\": \"Renewable Energy Adoption\", \"summary\": \"Renewable energy reduces greenhouse gas emissions and thereby global warming and climate change effects\", \"children\": [\n" + " {\"node\": 5, \"label\": \"Solar Energy\", \"summary\": \"Solar energy reduces greenhouse gas emissions\"},\n" + " {\"node\": 6, \"label\": \"Wind Energy\", \"summary\": \"Wind energy reduces greenhouse gas emissions\"},\n" + " {\"node\": 7, \"label\": \"Hydropower\", \"summary\": \"Hydropower reduces greenhouse gas emissions\"}\n" + " ]},\n" + " {\"node\": 3, \"label\": \"Carbon Emission Reduction\", \"summary\": \"Carbon emission reduction decreases greenhouse gases\", \"children\": [\n" + " {\"node\": 8, \"label\": \"Carbon Capture Technology\", \"summary\": \"Carbon capture technology reduces atmospheric CO2\"},\n" + " {\"node\": 9, \"label\": \"Emission Trading Systems\", \"summary\": \"Emission trading systems incentivize reductions in greenhouse gases\"}\n" + " ]}\n" + " ]\n" + "}\n" + """ + ), + }, + "risk": { + "qualifier": "Risk Scenario", + "user_prompt_message": "Your given Risk Scenario is: {main_theme}", + "default_instructions": ( + "Forget all previous prompts." + "You are assisting a professional risk analyst tasked with creating a taxonomy to classify the impact of the Risk Scenario '**{main_theme}**' on companies." + "Your objective is to generate a **comprehensive tree structure** that maps the **risk spillovers** stemming from the Risk Scenario '**{main_theme}**', and generates related sub-scenarios." + "Key Instructions:" + "1. **Understand the Risk Scenario: '{main_theme}'**:" + " - The Risk Scenario '**{main_theme}**' represents a central, multifaceted concept that may be harmful or beneficial to firms." + " - Your task is to identify how the Risk Scenario impacts firms through various **risk spillovers** and transmission channels." + " - Summarize the Risk Scenario '**{main_theme}**' in a **short list of essential keywords**." + " - The keyword list should be short (1-2 keywords). Avoid unnecessary, unmentioned, indirectly inferred, or redundant keywords." + "2. **Create a Tree Structure for Risk Spillovers and Sub-Scenarios**:" + " - Decompose the Risk Scenario into **distinct, focused, and self-contained risk spillovers**." + " - Each risk spillover must represent a **specific risk channel** through which firms are exposed to as a consequence of the Risk Scenario." + " - Label each **primary node** in the tree explicitly as a 'Risk' in the `Label` field. For example:" + " - Use 'Cost Risk' instead of 'Cost Impacts'." + " - Use 'Supply Chain Risk' instead of 'Supply Chain Disruptions'." + " - Risk spillovers must:" + " - Cover a wide range of potential impacts on firms' operations, business, performance, strategy, profits, and long-term success." + " - Explore both macroeconomic and microeconomic dimensions of the Risk Scenario '**{main_theme}**' and analyze their impact on firms when relevant." + " - Microeconomic effects, such as cost of inputs, directly affect firms' operations" + " - Macroeconomic effects may affect firms revenues directly (e.g. currency fluctuations) or indirectly (e.g. economic downturns triggering lower demand)." + " - Include **direct and indirect consequences** of the main scenario." + " - Represent **dimensions of risk** that firms must monitor or mitigate." + " - NOT overlap." + " - Independently identify the most relevant spillovers based on the Risk Scenario '**{main_theme}**', without limiting to predefined categories." + "3. **Generate Sub-Scenarios for Each Risk Spillover**:" + " - For each risk spillover, identify **specific sub-scenarios** that will arise as a consequence of the Risk Scenario '**{main_theme}**'." + " - All sub-scenarios must:" + " - Be **concise and descriptive sentences**, clearly stating how the sub-scenario is an event caused by the main scenario." + " - **Explicitly include ALL core concepts and keywords** from the main scenario, including specific geographical locations or temporal details, in every sentence in order to ensure clarity and relevance towards the main scenario." + " - Integrate the Risk Scenario in a natural way, avoiding repetitive or mechanical structures." + " - Not exceed 15 words." + " - Sub-scenarios MUST be mutually exclusive: they CANNOT overlap neither within nor across branches of the tree." + " - Do NOT combine multiple sub-scenarios in a single label." + " - Sub-Scenarios have to be consistent with the parent Risk Spillover (e.g. Market Access related sub-scenarios have to belong to the Market Access Risk node)." + " - Generate 3 OR MORE sub-scenarios for each risk spillover." + " - Generate a short label for each subscenario." + "4. **Iterate Based on the Analyst's Focus: '{analyst_focus}'**:" + " - After generating the initial tree structure, use the analyst's focus ('{analyst_focus}') to:" + " - Identify **missing branches** or underexplored areas of the tree." + " - Add new risk spillovers or sub-scenarios that align with the analyst's focus." + " - Ensure that sub-scenarios ALWAYS include ALL core components of the Risk Scenario and are formulated as natural sentences." + " - Ensure that sub-scenarios DO NOT overlap within and across risk spillovers." + " - Ensure that sub-scenarios belong to the correct Risk Spillover." + " - If the analyst focus is empty, skip this step." + " - If you don't understand the analyst focus ('{analyst_focus}'), ask an open-ended question to the analyst." + "5. **Review and Expand the Tree for Missing Risks**:" + " - After incorporating the analyst's focus, review the tree structure to ensure it includes a **broad range of risks** and sub-scenarios." + " - Add any missing risks or sub-scenarios to the tree." + ), + "enforce_structure_string": ( + """IMPORTANT: Your response MUST be a valid JSON object. Each node in the JSON object must include:\n" + " - `node`: an integer representing the unique identifier for the node.\n" + " - `label`: a string for the name of the sub-theme.\n" + " - `summary`: a string to explain briefly in maximum 15 words why the sub-theme is related to the main theme or risk.\n" + " - `children`: an array of child nodes.\n" + "Format the JSON object as a nested dictionary. Be careful when specifying keys and items.\n" + "Avoid overlapping labels. Break down joint concepts into unique parents so that each parent represents ONLY ONE concept. AVOID creating branch names such as 'Compliance and Regulatory Risk'. Keep risks separate and create a single branch for each risk, such as 'Compliance Risk' and 'Regulatory Risk', each with their own children.\n" + "Return ONLY the JSON object, with no extra text, explanation, or markdown.\n" + "You MUST use ONLY these field names: label, node, summary, children. Do NOT use underscores, spaces, or any other characters in field names. If you use any other field names, your answer will be rejected.\n" + "## Example Structure:\n" + "**Theme: Global Warming**\n\n" + "{\n" + " \"node\": 1,\n" + " \"label\": \"Global Warming\",\n" + " \"summary\": \"Global Warming is a serious risk\",\n" + " \"children\": [\n" + " {\"node\": 2, \"label\": \"Renewable Energy Adoption\", \"summary\": \"Renewable energy reduces greenhouse gas emissions and thereby global warming and climate change effects\", \"children\": [\n" + " {\"node\": 5, \"label\": \"Solar Energy\", \"summary\": \"Solar energy reduces greenhouse gas emissions\"},\n" + " {\"node\": 6, \"label\": \"Wind Energy\", \"summary\": \"Wind energy reduces greenhouse gas emissions\"},\n" + " {\"node\": 7, \"label\": \"Hydropower\", \"summary\": \"Hydropower reduces greenhouse gas emissions\"}\n" + " ]},\n" + " {\"node\": 3, \"label\": \"Carbon Emission Reduction\", \"summary\": \"Carbon emission reduction decreases greenhouse gases\", \"children\": [\n" + " {\"node\": 8, \"label\": \"Carbon Capture Technology\", \"summary\": \"Carbon capture technology reduces atmospheric CO2\"},\n" + " {\"node\": 9, \"label\": \"Emission Trading Systems\", \"summary\": \"Emission trading systems incentivize reductions in greenhouse gases\"}\n" + " ]}\n" + " ]\n" + "}\n" + """ + ), + }, +} + + +def format_mindmap_to_dataframe(mindmap_text): + """ + Parse a mind map in pipe-delimited table format into a cleaned pandas DataFrame. + Strips whitespace and removes unnamed columns. + + Args: + mindmap_text (str): The mind map content as a string in pipe-delimited format. + + Returns: + pd.DataFrame: A pandas DataFrame containing the cleaned data from the mind map. + + Raises: + ValueError: If the resulting DataFrame does not contain the required columns. + """ + try: + df = pd.read_csv( + StringIO(mindmap_text.strip()), sep="|", engine="python", skiprows=[1] + ) + df = df.loc[:, ~df.columns.str.contains("^Unnamed")] + except Exception: + try: + df = pd.read_csv( + StringIO(mindmap_text.strip()), + sep="|", + engine="python", + skiprows=[1], + on_bad_lines="skip", + ) + df = df.loc[:, ~df.columns.str.contains("^Unnamed")] + except Exception as e2: + raise ValueError(f"Failed to parse mindmap text to DataFrame: {e2}") + required_columns = {"Main Branches", "Sub-Branches", "Description"} + if not required_columns.issubset(set(df.columns)): + raise ValueError(f"Missing required columns in mindmap table: {df.columns}") + return df + + +def save_results_to_file(results, output_dir, filename): + """ + Save the results to a JSON file. + """ + os.makedirs(output_dir, exist_ok=True) + output_file = os.path.join(output_dir, filename) + + with open(output_file, "w") as f: + json.dump(results, f, default=str, indent=2) + + +def load_results_from_file(output_dir, filename): + """ + Load the results from a JSON file. + """ + input_file = os.path.join(output_dir, filename) + with open(input_file, "r") as f: + return json.load(f) diff --git a/src/bigdata_research_tools/portfolio/motivation.py b/src/bigdata_research_tools/portfolio/motivation.py index cd05b1f..4f9555e 100644 --- a/src/bigdata_research_tools/portfolio/motivation.py +++ b/src/bigdata_research_tools/portfolio/motivation.py @@ -44,7 +44,10 @@ def _get_default_model_config(self, model: str) -> LLMConfig: """Get default LLM model configuration.""" if any(rm in model for rm in REASONING_MODELS): return LLMConfig( - model=model, reasoning_effort="high", seed=42, max_completion_tokens=300 + model=model, + reasoning_effort="high", + seed=42, + max_completion_tokens=None, ) else: return LLMConfig( diff --git a/src/bigdata_research_tools/search/query_builder.py b/src/bigdata_research_tools/search/query_builder.py index 7482714..3952011 100644 --- a/src/bigdata_research_tools/search/query_builder.py +++ b/src/bigdata_research_tools/search/query_builder.py @@ -73,7 +73,7 @@ def build_similarity_queries(sentences: list[str]) -> list[QueryComponent]: def build_batched_query( - sentences: list[str], + sentences: list[str] | None, keywords: list[str] | None, entities: EntitiesToSearch | None, control_entities: EntitiesToSearch | None, diff --git a/src/bigdata_research_tools/search/search.py b/src/bigdata_research_tools/search/search.py index 9f4933a..941f354 100644 --- a/src/bigdata_research_tools/search/search.py +++ b/src/bigdata_research_tools/search/search.py @@ -13,7 +13,7 @@ import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime -from typing import Literal, overload +from typing import Literal, Sequence, cast, overload from bigdata_client import Bigdata from bigdata_client.daterange import AbsoluteDateRange, RollingDateRange @@ -30,17 +30,18 @@ send_trace, ) -NORMALIZED_DATE_RANGE = ( - list[tuple[datetime, datetime]] - | list[RollingDateRange] - | list[tuple[datetime, datetime] | RollingDateRange] -) +NORMALIZED_DATE_RANGE = Sequence[ + tuple[datetime, datetime] | AbsoluteDateRange | RollingDateRange +] -INPUT_DATE_RANGE = tuple[datetime, datetime] | RollingDateRange | NORMALIZED_DATE_RANGE +INPUT_DATE_RANGE = ( + tuple[datetime, datetime] + | RollingDateRange + | AbsoluteDateRange + | NORMALIZED_DATE_RANGE +) -SEARCH_QUERY_RESULTS_TYPE = dict[ - tuple[QueryComponent, AbsoluteDateRange | RollingDateRange], list[Document] -] +SEARCH_QUERY_RESULTS_TYPE = dict[tuple[QueryComponent, str], list[Document]] REQUESTS_PER_MINUTE_LIMIT = 300 MAX_WORKERS = 4 @@ -246,7 +247,15 @@ def concurrent_search( as_completed(futures), total=len(futures), desc="Querying Bigdata..." ): query, date_range = futures[future] + try: + if isinstance(date_range, AbsoluteDateRange): + date_range = f"{date_range.start_dt.isoformat()}_{date_range.end_dt.isoformat()}" + elif isinstance(date_range, tuple): + date_range = ( + f"{date_range[0].isoformat()}_{date_range[1].isoformat()}" + ) + results[(query, date_range)] = future.result() except Exception as e: raise e @@ -268,8 +277,14 @@ def get_quota_consumed(self) -> float: def normalize_date_range( date_ranges: INPUT_DATE_RANGE, ) -> NORMALIZED_DATE_RANGE: - if not isinstance(date_ranges, list): - date_ranges = [date_ranges] + if isinstance(date_ranges, (AbsoluteDateRange, RollingDateRange, tuple)): + return cast(NORMALIZED_DATE_RANGE, [date_ranges]) + if isinstance(date_ranges, Sequence): + if all( + isinstance(dr, (tuple, AbsoluteDateRange, RollingDateRange)) + for dr in date_ranges + ): + return list(date_ranges) return date_ranges @@ -340,8 +355,9 @@ def run_search( If `only_results` is False, returns a mapping of the tuple of search query and date range to the list of the corresponding search results. """ - date_ranges = normalize_date_range(date_ranges) - date_ranges.sort(key=lambda x: x[0]) + date_ranges = list(normalize_date_range(date_ranges)) + if isinstance(date_ranges[0], tuple) or isinstance(date_ranges[0], list): + date_ranges.sort(key=lambda x: x[0]) workflow_start = datetime.now() workflow_status = WorkflowStatus.UNKNOWN diff --git a/src/bigdata_research_tools/visuals/mindmap_visuals.py b/src/bigdata_research_tools/visuals/mindmap_visuals.py new file mode 100644 index 0000000..4b14227 --- /dev/null +++ b/src/bigdata_research_tools/visuals/mindmap_visuals.py @@ -0,0 +1,1137 @@ +""" +Mindmap Plotter - A graphviz-inspired tool for creating mindmap visualizations. + +Clean, structured layout with proper text fitting and spacing. + +Algorithm Overview: +------------------- +The tool uses a multi-stage layout algorithm to ensure all text fits within rectangles +and the entire mindmap fits within the chart area: + +1. **Layout Calculation Phase**: + - Calculates base font size based on total available area and number of elements + - Determines uniform dimensions for each node level (theme, main branches, sub-branches) + - Applies horizontal and vertical scaling if needed to fit within chart bounds + - All scaling happens BEFORE text fitting to ensure accurate measurements + +2. **Text Fitting Algorithm**: + - Uses binary search (30-40 iterations) to find optimal font size for each text element + - Text wrapping respects word boundaries only (no mid-word breaks) + - Separate fitting strategies for main branches (strict, 88% safety margin) and + sub-branches (prioritizes larger fonts, 90% safety margin, minimal wrapping) + - Measures actual rendered text dimensions on the target figure for accuracy + +3. **Iterative Refinement Loop**: + - Renders the plot and measures actual text dimensions + - Detects overflow (text exceeding rectangle bounds or rectangles exceeding chart area) + - Adjusts font sizes and dimensions iteratively (up to 5 iterations) + - Continues until no overflow is detected + +4. **Boundary Enforcement**: + - Accounts for rectangle border linewidth (extends outward from edges) + - Uses absolute bottom boundary to prevent bottom border cutoff + - Centers sub-branch blocks with their main branch while respecting boundaries + - Clips all elements to axes limits to prevent rendering outside chart area + +The algorithm guarantees no text overflow, no ellipsis, and proper spacing while +maintaining visual hierarchy and readability. +""" + +from typing import Any + +import matplotlib + +matplotlib.use("Agg") # Use non-interactive backend +import math +import os +from typing import Dict, Tuple + +import matplotlib.patches as patches +import matplotlib.pyplot as plt +import pandas as pd +from matplotlib import rcParams + +from bigdata_research_tools.mindmap.mindmap import MindMap + +##Note: In case the mindmap overflows from the chart area and edges, you can try to adjust the padding system by shrinking the 'available chart area'. For example, if padding does not work, you can try to have the algorithm think that the available area is 95% of the original available area. In this way, the algorithm will have less space to work with and will try to fit the text within the available area. + + +class MindmapPlotter: + """Main class for plotting mindmaps with graphviz-inspired layout.""" + + def __init__( + self, + mindmap: pd.DataFrame | MindMap, + main_theme: str, + title: str = "Mind Map", + color_scheme: str = "gold", + output_dir: str = "./outputs", + aspect_ratio: float = 8 / 9, + ): + """Initialize the mindmap plotter.""" + if isinstance(mindmap, pd.DataFrame): + self.df = mindmap.copy() + elif isinstance(mindmap, MindMap): + self.df = mindmap.to_dataframe() + + # Handle flexible column names: either (Parent, Label) or (Main Branches, Sub-Branches) + if "Main Branches" in self.df.columns and "Sub-Branches" in self.df.columns: + # Already in correct format + pass + elif "Parent" in self.df.columns and "Label" in self.df.columns: + # Rename to standard format + self.df = self.df.rename( + columns={"Parent": "Main Branches", "Label": "Sub-Branches"} + ) + else: + # Check what columns we have + has_main = "Main Branches" in self.df.columns + has_sub = "Sub-Branches" in self.df.columns + has_parent = "Parent" in self.df.columns + has_label = "Label" in self.df.columns + + raise ValueError( + f"DataFrame must have either (Parent, Label) or (Main Branches, Sub-Branches) columns. " + f"Found columns: {list(self.df.columns)}. " + f"Has Main Branches: {has_main}, Has Sub-Branches: {has_sub}, " + f"Has Parent: {has_parent}, Has Label: {has_label}" + ) + + # Assert required columns exist + assert "Main Branches" in self.df.columns, ( + "Missing 'Main Branches' column after processing" + ) + assert "Sub-Branches" in self.df.columns, ( + "Missing 'Sub-Branches' column after processing" + ) + + self.df["Main Branches"] = self.df["Main Branches"].astype(str).str.strip() + self.df["Sub-Branches"] = self.df["Sub-Branches"].astype(str).str.strip() + + self.main_theme = main_theme + self.title = title + self.color_scheme = color_scheme + self.output_dir = output_dir + self.aspect_ratio = aspect_ratio + + os.makedirs(output_dir, exist_ok=True) + + plt.rcParams["svg.fonttype"] = "none" + rcParams["font.family"] = "DejaVu Sans" + + self.colors = self._get_color_scheme(color_scheme) + + self.main_branches = self.df["Main Branches"].unique() + self.n_main = len(self.main_branches) + + self.sub_branches = { + main: self.df[self.df["Main Branches"] == main]["Sub-Branches"].tolist() + for main in self.main_branches + } + self.n_sub_total = sum(len(subs) for subs in self.sub_branches.values()) + + self.fig_width = 10.0 + self.fig_height = self.fig_width / aspect_ratio + self.title_height = 0 # No title - removed to maximize space + + # Add padding to prevent rectangles from going over chart edges + self.edge_padding = 0.25 # Padding on sides + self.top_padding = 0.1 # Minimal top padding + self.bottom_padding = 0.6 # Extra bottom padding to ensure borders don't get cut (increased further) + # Account for linewidth - borders extend outward by half linewidth on each side + self.max_linewidth = 2.5 # Maximum linewidth used (for main branches/theme) + self.sub_linewidth = 2.0 # Sub-branch linewidth + # Linewidth extends by half on each side, convert points to inches (72 points per inch) + # Be very conservative: use 3x linewidth as padding to ensure borders don't get cut + self.linewidth_padding = ( + self.max_linewidth / 72.0 + ) * 3.0 # Very conservative padding + self.sub_linewidth_padding = ( + self.sub_linewidth / 72.0 + ) * 3.0 # Sub-branch linewidth padding + # Use minimal top padding, extra bottom padding + # Reduce available_height to account for linewidth extension at bottom + self.available_height = ( + self.fig_height + - self.title_height + - self.top_padding + - (self.bottom_padding + self.sub_linewidth_padding) + ) + self.available_width = self.fig_width - 2 * ( + self.edge_padding + self.linewidth_padding + ) + # Calculate absolute bottom boundary - no rectangle should exceed this + # Use sub_linewidth_padding since sub-branches are at the bottom + self.absolute_bottom = ( + self.fig_height - self.bottom_padding - self.sub_linewidth_padding + ) + + # Increased padding for text inside boxes + self.pad_x = 0.7 # Increased from 0.5 + self.pad_y = 0.7 # Increased from 0.5 + + def _get_color_scheme(self, scheme: str) -> Dict[str, str]: + """Get color scheme dictionary.""" + schemes = { + "gold": { + "edge": "#eab720", + "link": "#eab720", + "background": "none", # Transparent + "text": "black", + }, + "light_blue": { + "edge": "#206EB5", + "link": "#206EB5", + "background": "none", # Transparent + "text": "black", + }, + "dark_blue": { + "edge": "#2C318C", + "link": "#2C318C", + "background": "none", # Transparent + "text": "black", + }, + } + return schemes.get(scheme, schemes["gold"]) + + def _measure_text(self, ax, text: str, fontsize: float) -> Tuple[float, float]: + """Measure text dimensions accurately.""" + if not text: + return self.pad_x, self.pad_y + + t = ax.text(0, 0, text, fontsize=fontsize, ha="left", va="bottom") + if not hasattr(ax.figure.canvas, "renderer"): + ax.figure.canvas.draw() + renderer = ax.figure.canvas.get_renderer() + bbox = t.get_window_extent(renderer=renderer) + t.remove() + + width = bbox.width / ax.figure.dpi + self.pad_x + height = bbox.height / ax.figure.dpi + self.pad_y + + return width, height + + def _wrap_text(self, text: str, max_chars: int) -> str: + """Wrap text at word boundaries.""" + if not text: + return text + + lines = [] + for para in text.split("\n"): + if not para: + lines.append("") + continue + + words = para.split() + if not words: + lines.append("") + continue + + line = words[0] + for word in words[1:]: + if len(line + " " + word) <= max_chars: + line += " " + word + else: + lines.append(line) + line = word + lines.append(line) + + return "\n".join(lines) + + def _fit_text_main( + self, + ax, + text: str, + max_width: float, + max_height: float, + initial_font: float, + min_font: float = 9, + ) -> Tuple[float, str, float, float]: + """ + Fit text for main branches - STRICT no overflow. + Returns: fontsize, wrapped_text, actual_width, actual_height + """ + if not text: + return min_font, "", self.pad_x, self.pad_y + + available_w = max(0.1, max_width - self.pad_x) + _available_h = max(0.1, max_height - self.pad_y) + + # Binary search for optimal font size + low_font = min_font + high_font = initial_font * 1.3 + best = None + + for _ in range(40): + test_font = (low_font + high_font) / 2.0 + + # Less aggressive wrapping - prioritize font size + chars_per_inch = test_font / 12.0 * 7 # More chars per inch + wrap_chars = max(20, int(available_w * chars_per_inch)) # Minimum 20 chars + + wrapped = self._wrap_text(text, wrap_chars) + w, h = self._measure_text(ax, wrapped, test_font) + + if w <= max_width * 0.88 and h <= max_height * 0.88: + best = (test_font, wrapped, w, h) + low_font = test_font + 0.2 + else: + high_font = test_font - 0.2 + + if high_font < low_font: + break + + if best: + return best + + # Fallback: ensure it fits + fontsize = min_font + chars_per_inch = fontsize / 12.0 * 7 + wrap_chars = max(20, int(available_w * chars_per_inch)) + wrapped = self._wrap_text(text, wrap_chars) + w, h = self._measure_text(ax, wrapped, fontsize) + + # Keep reducing until it fits - more aggressive + max_attempts = 60 + attempt = 0 + while ( + (w > max_width * 0.88 or h > max_height * 0.88) + and fontsize > min_font * 0.7 + and attempt < max_attempts + ): + attempt += 1 + scale = ( + min(max_width * 0.88 / max(0.01, w), max_height * 0.88 / max(0.01, h)) + * 0.97 + ) + fontsize = max(min_font * 0.7, fontsize * scale) + chars_per_inch = fontsize / 12.0 * 7 + wrap_chars = max(20, int(available_w * chars_per_inch)) + wrapped = self._wrap_text(text, wrap_chars) + w, h = self._measure_text(ax, wrapped, fontsize) + + # Final verification - ensure text actually fits + w, h = self._measure_text(ax, wrapped, fontsize) + if w > max_width * 0.88 or h > max_height * 0.88: + # Force one more reduction + scale = ( + min(max_width * 0.88 / max(0.01, w), max_height * 0.88 / max(0.01, h)) + * 0.98 + ) + fontsize = max(min_font * 0.7, fontsize * scale) + chars_per_inch = fontsize / 12.0 * 7 + wrap_chars = max(20, int((max_width - self.pad_x) * chars_per_inch)) + wrapped = self._wrap_text(text, wrap_chars) + w, h = self._measure_text(ax, wrapped, fontsize) + + # Final safety check - clip dimensions + w = min(w, max_width * 0.88) + h = min(h, max_height * 0.88) + + return fontsize, wrapped, w, h + + def _fit_text_sub( + self, + ax, + text: str, + max_width: float, + max_height: float, + initial_font: float, + min_font: float = 8, + ) -> Tuple[float, str, float, float]: + """ + Fit text for sub-branches - prioritize large font, minimal wrapping. + Returns: fontsize, wrapped_text, actual_width, actual_height + """ + if not text: + return min_font, "", self.pad_x, self.pad_y + + available_w = max(0.1, max_width - self.pad_x) + _available_h = max(0.1, max_height - self.pad_y) + + # Binary search prioritizing larger fonts + low_font = min_font + high_font = initial_font * 1.5 + best = None + + for _ in range(40): + test_font = (low_font + high_font) / 2.0 + + # Much less aggressive wrapping - wide lines preferred + chars_per_inch = test_font / 12.0 * 8 # Even more chars per inch + wrap_chars = max( + 25, int(available_w * chars_per_inch) + ) # Minimum 25 chars, prefer wide lines + + wrapped = self._wrap_text(text, wrap_chars) + w, h = self._measure_text(ax, wrapped, test_font) + + if w <= max_width * 0.90 and h <= max_height * 0.90: + best = (test_font, wrapped, w, h) + low_font = test_font + 0.3 # Try even larger + else: + high_font = test_font - 0.3 + + if high_font < low_font: + break + + if best: + return best + + # Fallback: use minimum font with wide wrapping + fontsize = min_font + chars_per_inch = fontsize / 12.0 * 8 + wrap_chars = max(25, int(available_w * chars_per_inch)) + wrapped = self._wrap_text(text, wrap_chars) + w, h = self._measure_text(ax, wrapped, fontsize) + + # Only reduce if absolutely necessary + max_attempts = 30 + attempt = 0 + while ( + (w > max_width * 0.90 or h > max_height * 0.90) + and fontsize > min_font * 0.85 + and attempt < max_attempts + ): + attempt += 1 + scale = ( + min(max_width * 0.90 / max(0.01, w), max_height * 0.90 / max(0.01, h)) + * 0.98 + ) + fontsize = max(min_font * 0.85, fontsize * scale) + chars_per_inch = fontsize / 12.0 * 8 + wrap_chars = max(25, int(available_w * chars_per_inch)) + wrapped = self._wrap_text(text, wrap_chars) + w, h = self._measure_text(ax, wrapped, fontsize) + + # Final verification - ensure text actually fits + w, h = self._measure_text(ax, wrapped, fontsize) + if w > max_width * 0.90 or h > max_height * 0.90: + # Force one more reduction + scale = ( + min(max_width * 0.90 / max(0.01, w), max_height * 0.90 / max(0.01, h)) + * 0.99 + ) + fontsize = max(min_font * 0.85, fontsize * scale) + chars_per_inch = fontsize / 12.0 * 8 + wrap_chars = max(25, int((max_width - self.pad_x) * chars_per_inch)) + wrapped = self._wrap_text(text, wrap_chars) + w, h = self._measure_text(ax, wrapped, fontsize) + + # Final safety check - clip dimensions + w = min(w, max_width * 0.90) + h = min(h, max_height * 0.90) + + return fontsize, wrapped, w, h + + def _calculate_layout(self) -> Dict: + """Calculate complete layout with uniform dimensions and scaling.""" + fig_temp = plt.figure(figsize=(self.fig_width, self.fig_height)) + ax_temp = fig_temp.add_subplot(111) + ax_temp.axis("off") + + # Base font calculation + total_elements = self.n_main + self.n_sub_total + area_per_element = (self.available_height * self.available_width) / max( + 1, total_elements + ) + + ref_area = 1.0 + if area_per_element > ref_area: + area_factor = math.pow(area_per_element / ref_area, 0.6) + else: + area_factor = math.pow(area_per_element / ref_area, 0.7) + + area_factor = max(0.4, min(3.5, area_factor)) + base_font = max(8, min(26, 12 * area_factor)) + + # Initial dimensions (will be scaled if needed) + main_spacing = self.available_height * 0.06 + available_h = self.available_height - (self.n_main - 1) * main_spacing + uniform_main_h = max(0.7, min(1.6, available_h / max(1, self.n_main))) + + max_main_len = max([len(m) for m in self.main_branches], default=20) + uniform_main_w = max(1.3, min(2.5, max_main_len * 0.10)) + + # Theme width matches main branch width + theme_w = uniform_main_w + theme_h = uniform_main_h + + # Sub-branch dimensions + max_subs = max([len(subs) for subs in self.sub_branches.values()], default=1) + spacing_extra = main_spacing * 0.4 + available_sub_h = uniform_main_h + 2 * spacing_extra + + if max_subs > 0: + sub_spacing = 0.12 + available_for_subs = available_sub_h - (max_subs - 1) * sub_spacing + uniform_sub_h = max(0.55, min(1.5, available_for_subs / max_subs)) + else: + uniform_sub_h = 0.8 + sub_spacing = 0.12 + + all_subs = [s for subs in self.sub_branches.values() for s in subs] + max_sub_len = max([len(s) for s in all_subs], default=30) if all_subs else 30 + max_w = self.available_width * 0.28 + uniform_sub_w = max(2.2, min(max_w, max_sub_len * 0.12)) + + # Calculate column positions (accounting for edge padding and linewidth) + margin = self.edge_padding + self.linewidth_padding + col_spacing = self.available_width * 0.07 + + x_theme = margin + x_main = x_theme + theme_w + col_spacing + x_sub = x_main + uniform_main_w + col_spacing + total_width_needed = x_sub + uniform_sub_w + margin + + # Horizontal scaling if needed + h_scale = 1.0 + if total_width_needed > self.available_width * 0.96: + h_scale = (self.available_width * 0.96) / total_width_needed + uniform_main_w *= h_scale + theme_w *= h_scale + uniform_sub_w *= h_scale + margin *= h_scale + col_spacing *= h_scale + # Recalculate positions + x_theme = margin + x_main = x_theme + theme_w + col_spacing + x_sub = x_main + uniform_main_w + col_spacing + + # Vertical scaling check + total_h = uniform_main_h * self.n_main + main_spacing * (self.n_main - 1) + v_scale = 1.0 + if total_h > self.available_height * 0.94: + v_scale = (self.available_height * 0.94) / total_h + uniform_main_h *= v_scale + theme_h *= v_scale + uniform_sub_h *= v_scale + main_spacing *= v_scale + sub_spacing *= v_scale + + # Now fit text with final scaled dimensions + # Fit all main branches to find uniform font + main_fonts = [] + for main in self.main_branches: + font, _, _, _ = self._fit_text_main( + ax_temp, main, uniform_main_w, uniform_main_h, base_font * 0.9, 10 + ) + main_fonts.append(font) + + uniform_main_font = min(main_fonts) if main_fonts else base_font * 0.9 + + # Fit all main branches with uniform dimensions - STRICT no overflow + main_data = {} + for main in self.main_branches: + font, wrapped, w, h = self._fit_text_main( + ax_temp, main, uniform_main_w, uniform_main_h, uniform_main_font, 10 + ) + main_data[main] = { + "fontsize": font, + "text": wrapped, + "width": uniform_main_w, + "height": uniform_main_h, + } + + # Theme layout - use same width as main + theme_font, theme_wrapped, theme_w_actual, theme_h_actual = self._fit_text_main( + ax_temp, self.main_theme, theme_w, theme_h, base_font * 1.0, 11 + ) + # Use target dimensions, not measured + theme_w = uniform_main_w + theme_h = uniform_main_h + + # Fit all sub-branches - prioritize large font, minimal wrapping + sub_fonts = [] + for sub in all_subs: + font, _, _, _ = self._fit_text_sub( + ax_temp, + sub, + uniform_sub_w * 0.94, + uniform_sub_h * 0.94, + base_font * 1.0, + 9, + ) + sub_fonts.append(font) + + uniform_sub_font = min(sub_fonts) if sub_fonts else base_font * 1.0 + + # Fit all sub-branches with uniform dimensions + sub_data = {} + for main in self.main_branches: + subs = self.sub_branches[main] + main_sub_data = [] + + for sub in subs: + font, wrapped, w, h = self._fit_text_sub( + ax_temp, + sub, + uniform_sub_w * 0.94, + uniform_sub_h * 0.94, + uniform_sub_font, + 9, + ) + + main_sub_data.append( + { + "fontsize": font, + "text": wrapped, + "width": uniform_sub_w, + "height": uniform_sub_h, + } + ) + + sub_data[main] = main_sub_data + + plt.close(fig_temp) + + return { + "theme": { + "fontsize": theme_font, + "text": theme_wrapped, + "width": theme_w, + "height": theme_h, + }, + "main_data": main_data, + "sub_data": sub_data, + "uniform_main_w": uniform_main_w, + "uniform_main_h": uniform_main_h, + "uniform_sub_w": uniform_sub_w, + "uniform_sub_h": uniform_sub_h, + "main_spacing": main_spacing, + "sub_spacing": sub_spacing, + "margin": margin, + "col_spacing": col_spacing, + "x_theme": x_theme, + "x_main": x_main, + "x_sub": x_sub, + } + + def _check_overflow( + self, ax, layout, x_theme, x_main, x_sub, main_positions, theme_y + ) -> Dict: + """Check for text and rectangle overflow, return adjustments needed.""" + issues: dict[str, Any] = { + "theme_overflow": False, + "main_overflows": {}, + "sub_overflows": {}, + "bottom_cutoff": False, + } + + # Check theme text overflow + theme_text = ax.text( + x_theme + layout["theme"]["width"] / 2, + theme_y + layout["theme"]["height"] / 2, + layout["theme"]["text"], + ha="center", + va="center", + fontsize=layout["theme"]["fontsize"], + fontweight="bold", + ) + renderer = ax.figure.canvas.get_renderer() + bbox = theme_text.get_window_extent(renderer=renderer) + theme_text.remove() + text_w = bbox.width / ax.figure.dpi + text_h = bbox.height / ax.figure.dpi + if ( + text_w > layout["theme"]["width"] * 0.88 + or text_h > layout["theme"]["height"] * 0.88 + ): + issues["theme_overflow"] = True + + # Check main branch text overflow + for main in self.main_branches: + main_y = main_positions[main] + main_info = layout["main_data"][main] + main_text = ax.text( + x_main + layout["uniform_main_w"] / 2, + main_y + layout["uniform_main_h"] / 2, + main_info["text"], + ha="center", + va="center", + fontsize=main_info["fontsize"], + fontweight="bold", + ) + bbox = main_text.get_window_extent(renderer=renderer) + main_text.remove() + text_w = bbox.width / ax.figure.dpi + text_h = bbox.height / ax.figure.dpi + if ( + text_w > layout["uniform_main_w"] * 0.88 + or text_h > layout["uniform_main_h"] * 0.88 + ): + issues["main_overflows"][main] = True + + # Check sub-branch overflow and bottom cutoff + max_bottom_y = 0 + for main in self.main_branches: + main_y = main_positions[main] + subs = layout["sub_data"].get(main, []) + if subs: + spacing_extra = layout["main_spacing"] * 0.4 + min_sub_y = main_y - spacing_extra + max_sub_y = main_y + layout["uniform_main_h"] + spacing_extra + + sub_spacing = layout["sub_spacing"] + total_sub_h = ( + layout["uniform_sub_h"] * len(subs) + (len(subs) - 1) * sub_spacing + ) + main_center = main_y + layout["uniform_main_h"] / 2 + sub_y_start = main_center - total_sub_h / 2 + + if sub_y_start < min_sub_y: + sub_y_start = min_sub_y + if sub_y_start + total_sub_h > max_sub_y: + sub_y_start = max_sub_y - total_sub_h + # Calculate maximum allowed bottom position (use absolute bottom) + min_top_y = self.top_padding + self.linewidth_padding + + if sub_y_start < min_top_y: + sub_y_start = min_top_y + # Ensure total height doesn't exceed absolute bottom + if sub_y_start + total_sub_h > self.absolute_bottom: + sub_y_start = max(min_top_y, self.absolute_bottom - total_sub_h) + + sub_y = sub_y_start + for i, sub_info in enumerate(subs): + if sub_y + layout["uniform_sub_h"] > max_bottom_y: + issues["bottom_cutoff"] = True + break + + # Check text overflow + sub_text = ax.text( + x_sub + layout["uniform_sub_w"] / 2, + sub_y + layout["uniform_sub_h"] / 2, + sub_info["text"], + ha="center", + va="center", + fontsize=sub_info["fontsize"], + fontweight="bold", + ) + bbox = sub_text.get_window_extent(renderer=renderer) + sub_text.remove() + text_w = bbox.width / ax.figure.dpi + text_h = bbox.height / ax.figure.dpi + if ( + text_w > layout["uniform_sub_w"] * 0.90 + or text_h > layout["uniform_sub_h"] * 0.90 + ): + issues["sub_overflows"][(main, i)] = True + + bottom_y = sub_y + layout["uniform_sub_h"] + if bottom_y > max_bottom_y: + max_bottom_y = bottom_y + + sub_y += layout["uniform_sub_h"] + sub_spacing + if sub_y + layout["uniform_sub_h"] > max_sub_y: + break + + # Check if bottom sub-branch is too close to edge (accounting for padding and linewidth) + if max_bottom_y > self.absolute_bottom - 0.1: + issues["bottom_cutoff"] = True + + return issues + + def plot(self) -> Tuple[plt.Figure, plt.Axes]: + """Create the mindmap plot with iterative refinement.""" + layout = self._calculate_layout() + + # Iterative refinement loop + max_iterations = 5 + for iteration in range(max_iterations): + fig, ax = plt.subplots(figsize=(self.fig_width, self.fig_height)) + ax.axis("off") + + # Use pre-calculated positions from layout + x_theme = layout["x_theme"] + x_main = layout["x_main"] + x_sub = layout["x_sub"] + + # Main branch positions + total_h = layout["uniform_main_h"] * self.n_main + layout[ + "main_spacing" + ] * (self.n_main - 1) + y_start = (self.available_height - total_h) / 2 + + main_positions = {} + y = y_start + for main in self.main_branches: + main_positions[main] = y + y += layout["uniform_main_h"] + layout["main_spacing"] + + # Theme position (centered with main branches) + theme_y = y_start + (total_h - layout["theme"]["height"]) / 2 + + # Draw elements to check overflow + self._draw_elements( + ax, layout, x_theme, x_main, x_sub, main_positions, theme_y + ) + + # Ensure figure is drawn for accurate measurement + ax.figure.canvas.draw() + + # Check for overflow + issues = self._check_overflow( + ax, layout, x_theme, x_main, x_sub, main_positions, theme_y + ) + + # If no issues, we're done + if ( + not issues["theme_overflow"] + and not issues["main_overflows"] + and not issues["sub_overflows"] + and not issues["bottom_cutoff"] + ): + plt.close(fig) + break + + # Adjust layout based on issues + if ( + issues["theme_overflow"] + or issues["main_overflows"] + or issues["bottom_cutoff"] + ): + # Reduce font sizes for theme and main branches + layout = self._adjust_layout_for_overflow(layout, issues, iteration) + plt.close(fig) + continue + + plt.close(fig) + break + + # Final render + fig, ax = plt.subplots(figsize=(self.fig_width, self.fig_height)) + ax.axis("off") + + # Use pre-calculated positions from layout + x_theme = layout["x_theme"] + x_main = layout["x_main"] + x_sub = layout["x_sub"] + + # Main branch positions (accounting for top padding and linewidth) + total_h = layout["uniform_main_h"] * self.n_main + layout["main_spacing"] * ( + self.n_main - 1 + ) + y_start = ( + self.top_padding + + self.linewidth_padding + + (self.available_height - total_h) / 2 + ) + + main_positions = {} + y = y_start + for main in self.main_branches: + main_positions[main] = y + y += layout["uniform_main_h"] + layout["main_spacing"] + + # Theme position (centered with main branches) + theme_y = y_start + (total_h - layout["theme"]["height"]) / 2 + + # Draw all elements + self._draw_elements(ax, layout, x_theme, x_main, x_sub, main_positions, theme_y) + + # Set strict limits to prevent anything from being drawn outside bounds + # Account for linewidth extension - clip everything strictly + ax.set_xlim(0, self.fig_width) + ax.set_ylim(0, self.fig_height) + # Clip all patches and text to axes limits + ax.set_clip_on(True) + + return fig, ax + + def _draw_elements( + self, ax, layout, x_theme, x_main, x_sub, main_positions, theme_y + ): + """Draw all mindmap elements.""" + # Draw theme + theme_rect = patches.Rectangle( + (x_theme, theme_y), + layout["theme"]["width"], + layout["theme"]["height"], + linewidth=2.5, + edgecolor=self.colors["edge"], + facecolor=self.colors["background"], + zorder=2, + ) + ax.add_patch(theme_rect) + ax.text( + x_theme + layout["theme"]["width"] / 2, + theme_y + layout["theme"]["height"] / 2, + layout["theme"]["text"], + ha="center", + va="center", + fontsize=layout["theme"]["fontsize"], + color=self.colors["text"], + fontweight="bold", + zorder=3, + ) + + # Draw main branches and sub-branches + for main in self.main_branches: + main_y = main_positions[main] + main_info = layout["main_data"][main] + + # Main branch rectangle + main_rect = patches.Rectangle( + (x_main, main_y), + layout["uniform_main_w"], + layout["uniform_main_h"], + linewidth=2.5, + edgecolor=self.colors["edge"], + facecolor=self.colors["background"], + zorder=2, + ) + ax.add_patch(main_rect) + ax.text( + x_main + layout["uniform_main_w"] / 2, + main_y + layout["uniform_main_h"] / 2, + main_info["text"], + ha="center", + va="center", + fontsize=main_info["fontsize"], + color=self.colors["text"], + fontweight="bold", + zorder=3, + ) + + # Connection theme to main + ax.plot( + [x_theme + layout["theme"]["width"], x_main], + [ + theme_y + layout["theme"]["height"] / 2, + main_y + layout["uniform_main_h"] / 2, + ], + color=self.colors["link"], + linewidth=3, + alpha=0.6, + zorder=1, + solid_capstyle="round", + ) + + # Sub-branches + subs = layout["sub_data"].get(main, []) + if subs: + spacing_extra = layout["main_spacing"] * 0.4 + min_sub_y = main_y - spacing_extra + max_sub_y = main_y + layout["uniform_main_h"] + spacing_extra + # Ensure max_sub_y doesn't exceed absolute bottom + max_sub_y = min(max_sub_y, self.absolute_bottom) + available_sub_h = max_sub_y - min_sub_y + + sub_spacing = layout["sub_spacing"] + total_sub_h = ( + layout["uniform_sub_h"] * len(subs) + (len(subs) - 1) * sub_spacing + ) + + # Adjust spacing if needed + if total_sub_h > available_sub_h: + max_sp = ( + available_sub_h - layout["uniform_sub_h"] * len(subs) + ) / max(1, len(subs) - 1) + sub_spacing = max(0.08, min(sub_spacing, max_sp)) + total_sub_h = ( + layout["uniform_sub_h"] * len(subs) + + (len(subs) - 1) * sub_spacing + ) + + # Center on main branch - calculate ideal center position + main_center = main_y + layout["uniform_main_h"] / 2 + ideal_sub_y_start = main_center - total_sub_h / 2 + + # Determine available space boundaries (use the most restrictive) + absolute_min = self.top_padding + self.linewidth_padding + absolute_max = self.absolute_bottom + relative_min = min_sub_y + relative_max = max_sub_y + + # Use the most restrictive boundaries + min_top_y = max(absolute_min, relative_min) + max_bottom_y = min(absolute_max, relative_max) + + # Start with ideal centered position + sub_y_start = ideal_sub_y_start + + # If the entire block fits within available space, use centered position + if ( + ideal_sub_y_start >= min_top_y + and ideal_sub_y_start + total_sub_h <= max_bottom_y + ): + sub_y_start = ideal_sub_y_start + else: + # Block doesn't fit centered - adjust to fit while maintaining centering as much as possible + if ideal_sub_y_start < min_top_y: + # Too high - push down to minimum + sub_y_start = min_top_y + elif ideal_sub_y_start + total_sub_h > max_bottom_y: + # Too low - push up to maximum + sub_y_start = max_bottom_y - total_sub_h + # Ensure we don't go below minimum + if sub_y_start < min_top_y: + sub_y_start = min_top_y + + # Final safety check: ensure we don't exceed absolute bottom + if sub_y_start + total_sub_h > absolute_max: + sub_y_start = max(min_top_y, absolute_max - total_sub_h) + + # Draw sub-branches + sub_y = sub_y_start + # Use pre-calculated sub_linewidth_padding + # Use absolute bottom boundary - no rectangle should exceed this + for sub_info in subs: + # STRICT check: rectangle bottom must not exceed absolute bottom + rect_bottom = sub_y + layout["uniform_sub_h"] + + # Don't draw if rectangle itself would exceed absolute bottom + if rect_bottom > self.absolute_bottom: + break # Don't draw this or any subsequent sub-branches + if sub_y + layout["uniform_sub_h"] > max_sub_y: + break + + # Extra safety check - leave margin for linewidth extension + # Linewidth extends by half outward, so ensure rect_bottom + half_linewidth <= absolute_bottom + half_linewidth_extension = (self.sub_linewidth / 2.0) / 72.0 + if rect_bottom + half_linewidth_extension > self.absolute_bottom: + break + + # Sub-branch rectangle - clip to axes to prevent overflow + sub_rect = patches.Rectangle( + (x_sub, sub_y), + layout["uniform_sub_w"], + layout["uniform_sub_h"], + linewidth=self.sub_linewidth, + edgecolor=self.colors["edge"], + facecolor=self.colors["background"], + zorder=2, + clip_on=True, + ) + ax.add_patch(sub_rect) + + # Sub-branch text + ax.text( + x_sub + layout["uniform_sub_w"] / 2, + sub_y + layout["uniform_sub_h"] / 2, + sub_info["text"], + ha="center", + va="center", + fontsize=sub_info["fontsize"], + color=self.colors["text"], + fontweight="bold", + zorder=3, + ) + + # Connection main to sub + ax.plot( + [x_main + layout["uniform_main_w"], x_sub], + [ + main_y + layout["uniform_main_h"] / 2, + sub_y + layout["uniform_sub_h"] / 2, + ], + color=self.colors["link"], + linewidth=2.5, + alpha=0.5, + zorder=1, + solid_capstyle="round", + ) + + sub_y += layout["uniform_sub_h"] + sub_spacing + + if sub_y + layout["uniform_sub_h"] > max_sub_y: + break + + # Title removed to maximize space for mindmap + + def _adjust_layout_for_overflow(self, layout, issues, iteration): + """Adjust layout to fix overflow issues.""" + # Create temp figure for re-fitting + fig_temp = plt.figure(figsize=(self.fig_width, self.fig_height)) + ax_temp = fig_temp.add_subplot(111) + ax_temp.axis("off") + + # Reduce font sizes more aggressively + reduction_factor = 0.92 - (iteration * 0.02) # More aggressive each iteration + + # Adjust theme + if issues["theme_overflow"]: + current_font = layout["theme"]["fontsize"] + new_font = max(9, current_font * reduction_factor) + font, wrapped, _, _ = self._fit_text_main( + ax_temp, + self.main_theme, + layout["theme"]["width"], + layout["theme"]["height"], + new_font, + 9, + ) + layout["theme"]["fontsize"] = font + layout["theme"]["text"] = wrapped + + # Adjust main branches + if issues["main_overflows"]: + for main in issues["main_overflows"]: + current_font = layout["main_data"][main]["fontsize"] + new_font = max(8, current_font * reduction_factor) + font, wrapped, _, _ = self._fit_text_main( + ax_temp, + main, + layout["uniform_main_w"], + layout["uniform_main_h"], + new_font, + 8, + ) + layout["main_data"][main]["fontsize"] = font + layout["main_data"][main]["text"] = wrapped + + # Adjust for bottom cutoff - reduce vertical spacing or sub-branch height + if issues["bottom_cutoff"]: + # Reduce sub-branch height slightly + layout["uniform_sub_h"] *= 0.95 + layout["sub_spacing"] *= 0.95 + # Re-fit all sub-branches + for main in self.main_branches: + subs = layout["sub_data"].get(main, []) + for i, sub_info in enumerate(subs): + sub_text = self.sub_branches[main][i] + font, wrapped, _, _ = self._fit_text_sub( + ax_temp, + sub_text, + layout["uniform_sub_w"] * 0.94, + layout["uniform_sub_h"] * 0.94, + layout["sub_data"][main][i]["fontsize"], + 8, + ) + layout["sub_data"][main][i]["fontsize"] = font + layout["sub_data"][main][i]["text"] = wrapped + + plt.close(fig_temp) + return layout + + def save(self, fig: plt.Figure): + """Save the figure as PNG and SVG.""" + filename = self.title.replace(" ", "_") + png_path = os.path.join(self.output_dir, f"{filename}.png") + svg_path = os.path.join(self.output_dir, f"{filename}.svg") + + # Don't use bbox_inches='tight' to ensure we stay within figure bounds + # The axes limits are already set correctly in plot() + fig.savefig(png_path, transparent=True, dpi=300) + fig.savefig(svg_path, transparent=True) + + print(f"Saved: {png_path}") + print(f"Saved: {svg_path}") + + +def plot_mindmap( + mindmap: pd.DataFrame | MindMap, + main_theme: str, + title: str = "Mind Map", + color_scheme: str = "gold", + output_dir: str = "./outputs", + aspect_ratio: float = 8 / 9, +) -> Tuple[plt.Figure, plt.Axes]: + """Plot a mindmap from a DataFrame.""" + plotter = MindmapPlotter( + mindmap, main_theme, title, color_scheme, output_dir, aspect_ratio + ) + fig, ax = plotter.plot() + plotter.save(fig) + return fig, ax diff --git a/src/bigdata_research_tools/workflows/risk_analyzer.py b/src/bigdata_research_tools/workflows/risk_analyzer.py index bb9873f..592af0e 100644 --- a/src/bigdata_research_tools/workflows/risk_analyzer.py +++ b/src/bigdata_research_tools/workflows/risk_analyzer.py @@ -9,6 +9,10 @@ from bigdata_research_tools.excel import check_excel_dependencies, save_to_excel from bigdata_research_tools.labeler.risk_labeler import RiskLabeler, map_risk_category from bigdata_research_tools.llm.base import LLMConfig +from bigdata_research_tools.mindmap.mindmap import ( + MindMap, +) +from bigdata_research_tools.mindmap.mindmap_generator import MindMapGenerator from bigdata_research_tools.portfolio.motivation import Motivation from bigdata_research_tools.prompts.motivation import MotivationType from bigdata_research_tools.search.screener_search import search_by_companies @@ -17,10 +21,6 @@ WorkflowTraceEvent, send_trace, ) -from bigdata_research_tools.tree import ( - SemanticTree, - generate_risk_tree, -) from bigdata_research_tools.workflows.base import Workflow from bigdata_research_tools.workflows.utils import get_scored_df @@ -44,6 +44,7 @@ def __init__( sources: list[str] | None = None, rerank_threshold: float | None = None, focus: str = "", + ground_mindmap: bool = False, ): """ This class will screen a universe's (specified in 'companies') exposure to a given theme ('main_theme'). @@ -79,6 +80,7 @@ def __init__( self.sources = sources self.rerank_threshold = rerank_threshold self.focus = focus + self.ground_mindmap = ground_mindmap if isinstance(llm_model_config, dict): self.llm_model_config = LLMConfig(**llm_model_config) @@ -90,15 +92,27 @@ def __init__( def create_taxonomy(self): """Create a risk taxonomy based on the main theme and focus. Returns: - SemanticTree: The generated risk tree. + MindMap: The generated risk tree. List[str]: A list of risk summaries for the terminal nodes. List[str]: A list of terminal labels for the risk categories. """ - risk_tree = generate_risk_tree( + # risk_tree = generate_risk_tree( + # main_theme=self.main_theme, + # focus=self.focus, + # llm_model_config=self.llm_model_config, + # ) + + mindmap_generator = MindMapGenerator( + llm_model_config_base=self.llm_model_config + ) + risk_tree, _ = mindmap_generator.generate_one_shot( main_theme=self.main_theme, focus=self.focus, - llm_model_config=self.llm_model_config, + allow_grounding=self.ground_mindmap, + instructions=None, + date_range=None, + map_type="risk", ) risk_summaries = risk_tree.get_terminal_summaries() @@ -174,7 +188,7 @@ def label_search_results( self, df_sentences, terminal_labels, - risk_tree: SemanticTree, + risk_tree: MindMap, additional_prompt_fields: list[str] | None = None, ): """ @@ -183,7 +197,7 @@ def label_search_results( Args: df_sentences (DataFrame): The DataFrame containing the search results. terminal_labels (List[str]): The terminal labels for the risk categories. - risk_tree (SemanticTree): The SemanticTree object containing the risk taxonomy. + risk_tree (MindMap): The MindMap object containing the risk taxonomy. prompt_fields (Dict): Additional fields to be used in the labeling prompt. Returns: @@ -257,6 +271,7 @@ def generate_results( df_industry = get_scored_df( df_labeled, index_columns=["Industry"], pivot_column="Sub-Scenario" ) + motivation_generator = Motivation(llm_model_config=self.llm_model_config) motivation_df = motivation_generator.generate_company_motivations( df=df_labeled.rename(columns={"Sub-Scenario": "Theme"}), @@ -264,6 +279,7 @@ def generate_results( word_range=word_range, use_case=MotivationType.RISK_ANALYZER, ) + print(motivation_df) return df_company, df_industry, motivation_df @@ -273,7 +289,7 @@ def save_results( df_company: DataFrame, df_industry: DataFrame, motivation_df: DataFrame, - risk_tree: SemanticTree, + risk_tree: MindMap, export_path: str, ): """ @@ -330,7 +346,7 @@ def screen_companies( - df_company: The DataFrame with the output by company. - df_industry: The DataFrame with the output by industry. - df_motivation: The DataFrame with the generated motivations. - - risk_tree: The SemanticTree created for the screening. + - risk_tree: The MindMap created for the screening. """ if export_path and not check_excel_dependencies(): diff --git a/src/bigdata_research_tools/workflows/thematic_screener.py b/src/bigdata_research_tools/workflows/thematic_screener.py index 9e83e7d..4573f8f 100644 --- a/src/bigdata_research_tools/workflows/thematic_screener.py +++ b/src/bigdata_research_tools/workflows/thematic_screener.py @@ -9,6 +9,7 @@ from bigdata_research_tools.excel import check_excel_dependencies, save_to_excel from bigdata_research_tools.labeler.screener_labeler import ScreenerLabeler from bigdata_research_tools.llm.base import LLMConfig +from bigdata_research_tools.mindmap.mindmap_generator import MindMapGenerator from bigdata_research_tools.portfolio.motivation import Motivation from bigdata_research_tools.prompts.motivation import MotivationType from bigdata_research_tools.search.screener_search import search_by_companies @@ -17,7 +18,6 @@ WorkflowTraceEvent, send_trace, ) -from bigdata_research_tools.tree import generate_theme_tree from bigdata_research_tools.workflows.base import Workflow from bigdata_research_tools.workflows.utils import get_scored_df @@ -39,6 +39,7 @@ def __init__( sources: list[str] | None = None, rerank_threshold: float | None = None, focus: str | None = None, + ground_mindmap: bool = False, ): """ This class will screen a universe's (specified in 'companies') exposure to a given theme ('main_theme'). @@ -84,6 +85,7 @@ def __init__( self.sources = sources self.rerank_threshold = rerank_threshold self.focus = focus or "" + self.ground_mindmap = ground_mindmap if isinstance(llm_model_config, dict): self.llm_model_config = LLMConfig(**llm_model_config) elif isinstance(llm_model_config, str): @@ -135,10 +137,22 @@ def screen_companies( try: self.notify_observers("Generating thematic tree") - theme_tree = generate_theme_tree( + # theme_tree = generate_theme_tree( + # main_theme=self.main_theme, + # focus=self.focus, + # llm_model_config=self.llm_model_config, + # ) + + mindmap_generator = MindMapGenerator( + llm_model_config_base=self.llm_model_config + ) + theme_tree, _ = mindmap_generator.generate_one_shot( main_theme=self.main_theme, focus=self.focus, - llm_model_config=self.llm_model_config, + allow_grounding=self.ground_mindmap, + instructions=None, + date_range=None, + map_type="theme", ) theme_summaries = theme_tree.get_terminal_summaries() diff --git a/uv.lock b/uv.lock index 0a200bb..ba80bc4 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10, <4.0" resolution-markers = [ "python_full_version >= '3.12'", @@ -222,6 +222,7 @@ dependencies = [ { name = "bigdata-client" }, { name = "graphviz" }, { name = "json-repair" }, + { name = "matplotlib" }, { name = "openpyxl" }, { name = "pandas" }, { name = "pillow" }, @@ -257,6 +258,7 @@ requires-dist = [ { name = "boto3", marker = "extra == 'bedrock'", specifier = ">=1.24.0,<2.0.0" }, { name = "graphviz", specifier = ">=0.20.3,<0.21.0" }, { name = "json-repair", specifier = ">=0.44.1" }, + { name = "matplotlib", specifier = ">=3.10.6,<4.0.0" }, { name = "openai", marker = "extra == 'azure'", specifier = ">=1.61.1,<2.0.0" }, { name = "openai", marker = "extra == 'openai'", specifier = ">=1.61.1,<2.0.0" }, { name = "openpyxl", specifier = ">=3.1.5,<4.0.0" }, @@ -452,6 +454,162 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b9/ede788a0b56fc5b071639d06c33cb893f68b1178938f3425debebe2dab78/contourpy-1.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a37a2fb93d4df3fc4c0e363ea4d16f83195fc09c891bc8ce072b9d084853445", size = 269636, upload-time = "2025-04-15T17:35:54.473Z" }, + { url = "https://files.pythonhosted.org/packages/e6/75/3469f011d64b8bbfa04f709bfc23e1dd71be54d05b1b083be9f5b22750d1/contourpy-1.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7cd50c38f500bbcc9b6a46643a40e0913673f869315d8e70de0438817cb7773", size = 254636, upload-time = "2025-04-15T17:35:58.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2f/95adb8dae08ce0ebca4fd8e7ad653159565d9739128b2d5977806656fcd2/contourpy-1.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6658ccc7251a4433eebd89ed2672c2ed96fba367fd25ca9512aa92a4b46c4f1", size = 313053, upload-time = "2025-04-15T17:36:03.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/a6/8ccf97a50f31adfa36917707fe39c9a0cbc24b3bbb58185577f119736cc9/contourpy-1.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70771a461aaeb335df14deb6c97439973d253ae70660ca085eec25241137ef43", size = 352985, upload-time = "2025-04-15T17:36:08.275Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/7925ab9b77386143f39d9c3243fdd101621b4532eb126743201160ffa7e6/contourpy-1.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a887a6e8c4cd0897507d814b14c54a8c2e2aa4ac9f7686292f9769fcf9a6ab", size = 323750, upload-time = "2025-04-15T17:36:13.29Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/20c5d1ef4f4748e52d60771b8560cf00b69d5c6368b5c2e9311bcfa2a08b/contourpy-1.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3859783aefa2b8355697f16642695a5b9792e7a46ab86da1118a4a23a51a33d7", size = 326246, upload-time = "2025-04-15T17:36:18.329Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e5/9dae809e7e0b2d9d70c52b3d24cba134dd3dad979eb3e5e71f5df22ed1f5/contourpy-1.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eab0f6db315fa4d70f1d8ab514e527f0366ec021ff853d7ed6a2d33605cf4b83", size = 1308728, upload-time = "2025-04-15T17:36:33.878Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/0058ba34aeea35c0b442ae61a4f4d4ca84d6df8f91309bc2d43bb8dd248f/contourpy-1.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d91a3ccc7fea94ca0acab82ceb77f396d50a1f67412efe4c526f5d20264e6ecd", size = 1375762, upload-time = "2025-04-15T17:36:51.295Z" }, + { url = "https://files.pythonhosted.org/packages/09/33/7174bdfc8b7767ef2c08ed81244762d93d5c579336fc0b51ca57b33d1b80/contourpy-1.3.2-cp311-cp311-win32.whl", hash = "sha256:1c48188778d4d2f3d48e4643fb15d8608b1d01e4b4d6b0548d9b336c28fc9b6f", size = 178196, upload-time = "2025-04-15T17:36:55.002Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fe/4029038b4e1c4485cef18e480b0e2cd2d755448bb071eb9977caac80b77b/contourpy-1.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:5ebac872ba09cb8f2131c46b8739a7ff71de28a24c869bcad554477eb089a878", size = 222017, upload-time = "2025-04-15T17:36:58.576Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/44785876384eff370c251d58fd65f6ad7f39adce4a093c934d4a67a7c6b6/contourpy-1.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4caf2bcd2969402bf77edc4cb6034c7dd7c0803213b3523f111eb7460a51b8d2", size = 271580, upload-time = "2025-04-15T17:37:03.105Z" }, + { url = "https://files.pythonhosted.org/packages/93/3b/0004767622a9826ea3d95f0e9d98cd8729015768075d61f9fea8eeca42a8/contourpy-1.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:82199cb78276249796419fe36b7386bd8d2cc3f28b3bc19fe2454fe2e26c4c15", size = 255530, upload-time = "2025-04-15T17:37:07.026Z" }, + { url = "https://files.pythonhosted.org/packages/e7/bb/7bd49e1f4fa805772d9fd130e0d375554ebc771ed7172f48dfcd4ca61549/contourpy-1.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:106fab697af11456fcba3e352ad50effe493a90f893fca6c2ca5c033820cea92", size = 307688, upload-time = "2025-04-15T17:37:11.481Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/e1d5dbbfa170725ef78357a9a0edc996b09ae4af170927ba8ce977e60a5f/contourpy-1.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d14f12932a8d620e307f715857107b1d1845cc44fdb5da2bc8e850f5ceba9f87", size = 347331, upload-time = "2025-04-15T17:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/6f/66/e69e6e904f5ecf6901be3dd16e7e54d41b6ec6ae3405a535286d4418ffb4/contourpy-1.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:532fd26e715560721bb0d5fc7610fce279b3699b018600ab999d1be895b09415", size = 318963, upload-time = "2025-04-15T17:37:22.76Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/b8a1c8965e4f72482ff2d1ac2cd670ce0b542f203c8e1d34e7c3e6925da7/contourpy-1.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f26b383144cf2d2c29f01a1e8170f50dacf0eac02d64139dcd709a8ac4eb3cfe", size = 323681, upload-time = "2025-04-15T17:37:33.001Z" }, + { url = "https://files.pythonhosted.org/packages/30/c6/12a7e6811d08757c7162a541ca4c5c6a34c0f4e98ef2b338791093518e40/contourpy-1.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c49f73e61f1f774650a55d221803b101d966ca0c5a2d6d5e4320ec3997489441", size = 1308674, upload-time = "2025-04-15T17:37:48.64Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8a/bebe5a3f68b484d3a2b8ffaf84704b3e343ef1addea528132ef148e22b3b/contourpy-1.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d80b2c0300583228ac98d0a927a1ba6a2ba6b8a742463c564f1d419ee5b211e", size = 1380480, upload-time = "2025-04-15T17:38:06.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/db/fcd325f19b5978fb509a7d55e06d99f5f856294c1991097534360b307cf1/contourpy-1.3.2-cp312-cp312-win32.whl", hash = "sha256:90df94c89a91b7362e1142cbee7568f86514412ab8a2c0d0fca72d7e91b62912", size = 178489, upload-time = "2025-04-15T17:38:10.338Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/fadd0b92ffa7b5eb5949bf340a63a4a496a6930a6c37a7ba0f12acb076d6/contourpy-1.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:8c942a01d9163e2e5cfb05cb66110121b8d07ad438a17f9e766317bcb62abf73", size = 223042, upload-time = "2025-04-15T17:38:14.239Z" }, + { url = "https://files.pythonhosted.org/packages/2e/61/5673f7e364b31e4e7ef6f61a4b5121c5f170f941895912f773d95270f3a2/contourpy-1.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de39db2604ae755316cb5967728f4bea92685884b1e767b7c24e983ef5f771cb", size = 271630, upload-time = "2025-04-15T17:38:19.142Z" }, + { url = "https://files.pythonhosted.org/packages/ff/66/a40badddd1223822c95798c55292844b7e871e50f6bfd9f158cb25e0bd39/contourpy-1.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3f9e896f447c5c8618f1edb2bafa9a4030f22a575ec418ad70611450720b5b08", size = 255670, upload-time = "2025-04-15T17:38:23.688Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c7/cf9fdee8200805c9bc3b148f49cb9482a4e3ea2719e772602a425c9b09f8/contourpy-1.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71e2bd4a1c4188f5c2b8d274da78faab884b59df20df63c34f74aa1813c4427c", size = 306694, upload-time = "2025-04-15T17:38:28.238Z" }, + { url = "https://files.pythonhosted.org/packages/dd/e7/ccb9bec80e1ba121efbffad7f38021021cda5be87532ec16fd96533bb2e0/contourpy-1.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de425af81b6cea33101ae95ece1f696af39446db9682a0b56daaa48cfc29f38f", size = 345986, upload-time = "2025-04-15T17:38:33.502Z" }, + { url = "https://files.pythonhosted.org/packages/dc/49/ca13bb2da90391fa4219fdb23b078d6065ada886658ac7818e5441448b78/contourpy-1.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:977e98a0e0480d3fe292246417239d2d45435904afd6d7332d8455981c408b85", size = 318060, upload-time = "2025-04-15T17:38:38.672Z" }, + { url = "https://files.pythonhosted.org/packages/c8/65/5245ce8c548a8422236c13ffcdcdada6a2a812c361e9e0c70548bb40b661/contourpy-1.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:434f0adf84911c924519d2b08fc10491dd282b20bdd3fa8f60fd816ea0b48841", size = 322747, upload-time = "2025-04-15T17:38:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/72/30/669b8eb48e0a01c660ead3752a25b44fdb2e5ebc13a55782f639170772f9/contourpy-1.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c66c4906cdbc50e9cba65978823e6e00b45682eb09adbb78c9775b74eb222422", size = 1308895, upload-time = "2025-04-15T17:39:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/05/5a/b569f4250decee6e8d54498be7bdf29021a4c256e77fe8138c8319ef8eb3/contourpy-1.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8b7fc0cd78ba2f4695fd0a6ad81a19e7e3ab825c31b577f384aa9d7817dc3bef", size = 1379098, upload-time = "2025-04-15T17:43:29.649Z" }, + { url = "https://files.pythonhosted.org/packages/19/ba/b227c3886d120e60e41b28740ac3617b2f2b971b9f601c835661194579f1/contourpy-1.3.2-cp313-cp313-win32.whl", hash = "sha256:15ce6ab60957ca74cff444fe66d9045c1fd3e92c8936894ebd1f3eef2fff075f", size = 178535, upload-time = "2025-04-15T17:44:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/12/6e/2fed56cd47ca739b43e892707ae9a13790a486a3173be063681ca67d2262/contourpy-1.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e1578f7eafce927b168752ed7e22646dad6cd9bca673c60bff55889fa236ebf9", size = 223096, upload-time = "2025-04-15T17:44:48.194Z" }, + { url = "https://files.pythonhosted.org/packages/54/4c/e76fe2a03014a7c767d79ea35c86a747e9325537a8b7627e0e5b3ba266b4/contourpy-1.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0475b1f6604896bc7c53bb070e355e9321e1bc0d381735421a2d2068ec56531f", size = 285090, upload-time = "2025-04-15T17:43:34.084Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e2/5aba47debd55d668e00baf9651b721e7733975dc9fc27264a62b0dd26eb8/contourpy-1.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c85bb486e9be652314bb5b9e2e3b0d1b2e643d5eec4992c0fbe8ac71775da739", size = 268643, upload-time = "2025-04-15T17:43:38.626Z" }, + { url = "https://files.pythonhosted.org/packages/a1/37/cd45f1f051fe6230f751cc5cdd2728bb3a203f5619510ef11e732109593c/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:745b57db7758f3ffc05a10254edd3182a2a83402a89c00957a8e8a22f5582823", size = 310443, upload-time = "2025-04-15T17:43:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a2/36ea6140c306c9ff6dd38e3bcec80b3b018474ef4d17eb68ceecd26675f4/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:970e9173dbd7eba9b4e01aab19215a48ee5dd3f43cef736eebde064a171f89a5", size = 349865, upload-time = "2025-04-15T17:43:49.545Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/2fc76bc539693180488f7b6cc518da7acbbb9e3b931fd9280504128bf956/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6c4639a9c22230276b7bffb6a850dfc8258a2521305e1faefe804d006b2e532", size = 321162, upload-time = "2025-04-15T17:43:54.203Z" }, + { url = "https://files.pythonhosted.org/packages/f4/10/76d4f778458b0aa83f96e59d65ece72a060bacb20cfbee46cf6cd5ceba41/contourpy-1.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc829960f34ba36aad4302e78eabf3ef16a3a100863f0d4eeddf30e8a485a03b", size = 327355, upload-time = "2025-04-15T17:44:01.025Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/10cf483ea683f9f8ab096c24bad3cce20e0d1dd9a4baa0e2093c1c962d9d/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d32530b534e986374fc19eaa77fcb87e8a99e5431499949b828312bdcd20ac52", size = 1307935, upload-time = "2025-04-15T17:44:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/78/73/69dd9a024444489e22d86108e7b913f3528f56cfc312b5c5727a44188471/contourpy-1.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e298e7e70cf4eb179cc1077be1c725b5fd131ebc81181bf0c03525c8abc297fd", size = 1372168, upload-time = "2025-04-15T17:44:33.43Z" }, + { url = "https://files.pythonhosted.org/packages/0f/1b/96d586ccf1b1a9d2004dd519b25fbf104a11589abfd05484ff12199cca21/contourpy-1.3.2-cp313-cp313t-win32.whl", hash = "sha256:d0e589ae0d55204991450bb5c23f571c64fe43adaa53f93fc902a84c96f52fe1", size = 189550, upload-time = "2025-04-15T17:44:37.092Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e6/6000d0094e8a5e32ad62591c8609e269febb6e4db83a1c75ff8868b42731/contourpy-1.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:78e9253c3de756b3f6a5174d024c4835acd59eb3f8e2ca13e775dbffe1558f69", size = 238214, upload-time = "2025-04-15T17:44:40.827Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/91f1215d0d9f9f343e4773ba6c9b89e8c0cc7a64a6263f21139da639d848/contourpy-1.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5f5964cdad279256c084b69c3f412b7801e15356b16efa9d78aa974041903da0", size = 266807, upload-time = "2025-04-15T17:45:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/d4/79/6be7e90c955c0487e7712660d6cead01fa17bff98e0ea275737cc2bc8e71/contourpy-1.3.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49b65a95d642d4efa8f64ba12558fcb83407e58a2dfba9d796d77b63ccfcaff5", size = 318729, upload-time = "2025-04-15T17:45:20.166Z" }, + { url = "https://files.pythonhosted.org/packages/87/68/7f46fb537958e87427d98a4074bcde4b67a70b04900cfc5ce29bc2f556c1/contourpy-1.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8c5acb8dddb0752bf252e01a3035b21443158910ac16a3b0d20e7fed7d534ce5", size = 221791, upload-time = "2025-04-15T17:45:24.794Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "numpy", version = "2.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + [[package]] name = "coverage" version = "7.10.7" @@ -603,6 +761,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/23/87/7ce86f3fa14bc11a5a48c30d8103c26e09b6465f8d8e9d74cf7a0714f043/cryptography-45.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f3d56f73595376f4244646dd5c5870c14c196949807be39e79e7bd9bac3da63", size = 3332908, upload-time = "2025-09-01T11:14:58.78Z" }, ] +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -651,6 +818,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, ] +[[package]] +name = "fonttools" +version = "4.60.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/42/97a13e47a1e51a5a7142475bbcf5107fe3a68fc34aef331c897d5fb98ad0/fonttools-4.60.1.tar.gz", hash = "sha256:ef00af0439ebfee806b25f24c8f92109157ff3fac5731dc7867957812e87b8d9", size = 3559823, upload-time = "2025-09-29T21:13:27.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/70/03e9d89a053caff6ae46053890eba8e4a5665a7c5638279ed4492e6d4b8b/fonttools-4.60.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9a52f254ce051e196b8fe2af4634c2d2f02c981756c6464dc192f1b6050b4e28", size = 2810747, upload-time = "2025-09-29T21:10:59.653Z" }, + { url = "https://files.pythonhosted.org/packages/6f/41/449ad5aff9670ab0df0f61ee593906b67a36d7e0b4d0cd7fa41ac0325bf5/fonttools-4.60.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7420a2696a44650120cdd269a5d2e56a477e2bfa9d95e86229059beb1c19e15", size = 2346909, upload-time = "2025-09-29T21:11:02.882Z" }, + { url = "https://files.pythonhosted.org/packages/9a/18/e5970aa96c8fad1cb19a9479cc3b7602c0c98d250fcdc06a5da994309c50/fonttools-4.60.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee0c0b3b35b34f782afc673d503167157094a16f442ace7c6c5e0ca80b08f50c", size = 4864572, upload-time = "2025-09-29T21:11:05.096Z" }, + { url = "https://files.pythonhosted.org/packages/ce/20/9b2b4051b6ec6689480787d506b5003f72648f50972a92d04527a456192c/fonttools-4.60.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:282dafa55f9659e8999110bd8ed422ebe1c8aecd0dc396550b038e6c9a08b8ea", size = 4794635, upload-time = "2025-09-29T21:11:08.651Z" }, + { url = "https://files.pythonhosted.org/packages/10/52/c791f57347c1be98f8345e3dca4ac483eb97666dd7c47f3059aeffab8b59/fonttools-4.60.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4ba4bd646e86de16160f0fb72e31c3b9b7d0721c3e5b26b9fa2fc931dfdb2652", size = 4843878, upload-time = "2025-09-29T21:11:10.893Z" }, + { url = "https://files.pythonhosted.org/packages/69/e9/35c24a8d01644cee8c090a22fad34d5b61d1e0a8ecbc9945ad785ebf2e9e/fonttools-4.60.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0b0835ed15dd5b40d726bb61c846a688f5b4ce2208ec68779bc81860adb5851a", size = 4954555, upload-time = "2025-09-29T21:11:13.24Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/fb1e994971be4bdfe3a307de6373ef69a9df83fb66e3faa9c8114893d4cc/fonttools-4.60.1-cp310-cp310-win32.whl", hash = "sha256:1525796c3ffe27bb6268ed2a1bb0dcf214d561dfaf04728abf01489eb5339dce", size = 2232019, upload-time = "2025-09-29T21:11:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/40/84/62a19e2bd56f0e9fb347486a5b26376bade4bf6bbba64dda2c103bd08c94/fonttools-4.60.1-cp310-cp310-win_amd64.whl", hash = "sha256:268ecda8ca6cb5c4f044b1fb9b3b376e8cd1b361cef275082429dc4174907038", size = 2276803, upload-time = "2025-09-29T21:11:18.152Z" }, + { url = "https://files.pythonhosted.org/packages/ea/85/639aa9bface1537e0fb0f643690672dde0695a5bbbc90736bc571b0b1941/fonttools-4.60.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7b4c32e232a71f63a5d00259ca3d88345ce2a43295bb049d21061f338124246f", size = 2831872, upload-time = "2025-09-29T21:11:20.329Z" }, + { url = "https://files.pythonhosted.org/packages/6b/47/3c63158459c95093be9618794acb1067b3f4d30dcc5c3e8114b70e67a092/fonttools-4.60.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3630e86c484263eaac71d117085d509cbcf7b18f677906824e4bace598fb70d2", size = 2356990, upload-time = "2025-09-29T21:11:22.754Z" }, + { url = "https://files.pythonhosted.org/packages/94/dd/1934b537c86fcf99f9761823f1fc37a98fbd54568e8e613f29a90fed95a9/fonttools-4.60.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5c1015318e4fec75dd4943ad5f6a206d9727adf97410d58b7e32ab644a807914", size = 5042189, upload-time = "2025-09-29T21:11:25.061Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d2/9f4e4c4374dd1daa8367784e1bd910f18ba886db1d6b825b12edf6db3edc/fonttools-4.60.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e6c58beb17380f7c2ea181ea11e7db8c0ceb474c9dd45f48e71e2cb577d146a1", size = 4978683, upload-time = "2025-09-29T21:11:27.693Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c4/0fb2dfd1ecbe9a07954cc13414713ed1eab17b1c0214ef07fc93df234a47/fonttools-4.60.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ec3681a0cb34c255d76dd9d865a55f260164adb9fa02628415cdc2d43ee2c05d", size = 5021372, upload-time = "2025-09-29T21:11:30.257Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d5/495fc7ae2fab20223cc87179a8f50f40f9a6f821f271ba8301ae12bb580f/fonttools-4.60.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f4b5c37a5f40e4d733d3bbaaef082149bee5a5ea3156a785ff64d949bd1353fa", size = 5132562, upload-time = "2025-09-29T21:11:32.737Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fa/021dab618526323c744e0206b3f5c8596a2e7ae9aa38db5948a131123e83/fonttools-4.60.1-cp311-cp311-win32.whl", hash = "sha256:398447f3d8c0c786cbf1209711e79080a40761eb44b27cdafffb48f52bcec258", size = 2230288, upload-time = "2025-09-29T21:11:35.015Z" }, + { url = "https://files.pythonhosted.org/packages/bb/78/0e1a6d22b427579ea5c8273e1c07def2f325b977faaf60bb7ddc01456cb1/fonttools-4.60.1-cp311-cp311-win_amd64.whl", hash = "sha256:d066ea419f719ed87bc2c99a4a4bfd77c2e5949cb724588b9dd58f3fd90b92bf", size = 2278184, upload-time = "2025-09-29T21:11:37.434Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f7/a10b101b7a6f8836a5adb47f2791f2075d044a6ca123f35985c42edc82d8/fonttools-4.60.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b0c6d57ab00dae9529f3faf187f2254ea0aa1e04215cf2f1a8ec277c96661bc", size = 2832953, upload-time = "2025-09-29T21:11:39.616Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/7bd094b59c926acf2304d2151354ddbeb74b94812f3dc943c231db09cb41/fonttools-4.60.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:839565cbf14645952d933853e8ade66a463684ed6ed6c9345d0faf1f0e868877", size = 2352706, upload-time = "2025-09-29T21:11:41.826Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ca/4bb48a26ed95a1e7eba175535fe5805887682140ee0a0d10a88e1de84208/fonttools-4.60.1-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8177ec9676ea6e1793c8a084a90b65a9f778771998eb919d05db6d4b1c0b114c", size = 4923716, upload-time = "2025-09-29T21:11:43.893Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9f/2cb82999f686c1d1ddf06f6ae1a9117a880adbec113611cc9d22b2fdd465/fonttools-4.60.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:996a4d1834524adbb423385d5a629b868ef9d774670856c63c9a0408a3063401", size = 4968175, upload-time = "2025-09-29T21:11:46.439Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/be569699e37d166b78e6218f2cde8c550204f2505038cdd83b42edc469b9/fonttools-4.60.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a46b2f450bc79e06ef3b6394f0c68660529ed51692606ad7f953fc2e448bc903", size = 4911031, upload-time = "2025-09-29T21:11:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9f/89411cc116effaec5260ad519162f64f9c150e5522a27cbb05eb62d0c05b/fonttools-4.60.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6ec722ee589e89a89f5b7574f5c45604030aa6ae24cb2c751e2707193b466fed", size = 5062966, upload-time = "2025-09-29T21:11:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/62/a1/f888221934b5731d46cb9991c7a71f30cb1f97c0ef5fcf37f8da8fce6c8e/fonttools-4.60.1-cp312-cp312-win32.whl", hash = "sha256:b2cf105cee600d2de04ca3cfa1f74f1127f8455b71dbad02b9da6ec266e116d6", size = 2218750, upload-time = "2025-09-29T21:11:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/88/8f/a55b5550cd33cd1028601df41acd057d4be20efa5c958f417b0c0613924d/fonttools-4.60.1-cp312-cp312-win_amd64.whl", hash = "sha256:992775c9fbe2cf794786fa0ffca7f09f564ba3499b8fe9f2f80bd7197db60383", size = 2267026, upload-time = "2025-09-29T21:11:58.852Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5b/cdd2c612277b7ac7ec8c0c9bc41812c43dc7b2d5f2b0897e15fdf5a1f915/fonttools-4.60.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6f68576bb4bbf6060c7ab047b1574a1ebe5c50a17de62830079967b211059ebb", size = 2825777, upload-time = "2025-09-29T21:12:01.22Z" }, + { url = "https://files.pythonhosted.org/packages/d6/8a/de9cc0540f542963ba5e8f3a1f6ad48fa211badc3177783b9d5cadf79b5d/fonttools-4.60.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:eedacb5c5d22b7097482fa834bda0dafa3d914a4e829ec83cdea2a01f8c813c4", size = 2348080, upload-time = "2025-09-29T21:12:03.785Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/371ab3cec97ee3fe1126b3406b7abd60c8fec8975fd79a3c75cdea0c3d83/fonttools-4.60.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b33a7884fabd72bdf5f910d0cf46be50dce86a0362a65cfc746a4168c67eb96c", size = 4903082, upload-time = "2025-09-29T21:12:06.382Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/06b1455e4bc653fcb2117ac3ef5fa3a8a14919b93c60742d04440605d058/fonttools-4.60.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2409d5fb7b55fd70f715e6d34e7a6e4f7511b8ad29a49d6df225ee76da76dd77", size = 4960125, upload-time = "2025-09-29T21:12:09.314Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/f3b840fcb2666f6cb97038793606bdd83488dca2d0b0fc542ccc20afa668/fonttools-4.60.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c8651e0d4b3bdeda6602b85fdc2abbefc1b41e573ecb37b6779c4ca50753a199", size = 4901454, upload-time = "2025-09-29T21:12:11.931Z" }, + { url = "https://files.pythonhosted.org/packages/fd/9e/eb76f77e82f8d4a46420aadff12cec6237751b0fb9ef1de373186dcffb5f/fonttools-4.60.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:145daa14bf24824b677b9357c5e44fd8895c2a8f53596e1b9ea3496081dc692c", size = 5044495, upload-time = "2025-09-29T21:12:15.241Z" }, + { url = "https://files.pythonhosted.org/packages/f8/b3/cede8f8235d42ff7ae891bae8d619d02c8ac9fd0cfc450c5927a6200c70d/fonttools-4.60.1-cp313-cp313-win32.whl", hash = "sha256:2299df884c11162617a66b7c316957d74a18e3758c0274762d2cc87df7bc0272", size = 2217028, upload-time = "2025-09-29T21:12:17.96Z" }, + { url = "https://files.pythonhosted.org/packages/75/4d/b022c1577807ce8b31ffe055306ec13a866f2337ecee96e75b24b9b753ea/fonttools-4.60.1-cp313-cp313-win_amd64.whl", hash = "sha256:a3db56f153bd4c5c2b619ab02c5db5192e222150ce5a1bc10f16164714bc39ac", size = 2266200, upload-time = "2025-09-29T21:12:20.14Z" }, + { url = "https://files.pythonhosted.org/packages/9a/83/752ca11c1aa9a899b793a130f2e466b79ea0cf7279c8d79c178fc954a07b/fonttools-4.60.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a884aef09d45ba1206712c7dbda5829562d3fea7726935d3289d343232ecb0d3", size = 2822830, upload-time = "2025-09-29T21:12:24.406Z" }, + { url = "https://files.pythonhosted.org/packages/57/17/bbeab391100331950a96ce55cfbbff27d781c1b85ebafb4167eae50d9fe3/fonttools-4.60.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8a44788d9d91df72d1a5eac49b31aeb887a5f4aab761b4cffc4196c74907ea85", size = 2345524, upload-time = "2025-09-29T21:12:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2e/d4831caa96d85a84dd0da1d9f90d81cec081f551e0ea216df684092c6c97/fonttools-4.60.1-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e852d9dda9f93ad3651ae1e3bb770eac544ec93c3807888798eccddf84596537", size = 4843490, upload-time = "2025-09-29T21:12:29.123Z" }, + { url = "https://files.pythonhosted.org/packages/49/13/5e2ea7c7a101b6fc3941be65307ef8df92cbbfa6ec4804032baf1893b434/fonttools-4.60.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:154cb6ee417e417bf5f7c42fe25858c9140c26f647c7347c06f0cc2d47eff003", size = 4944184, upload-time = "2025-09-29T21:12:31.414Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2b/cf9603551c525b73fc47c52ee0b82a891579a93d9651ed694e4e2cd08bb8/fonttools-4.60.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5664fd1a9ea7f244487ac8f10340c4e37664675e8667d6fee420766e0fb3cf08", size = 4890218, upload-time = "2025-09-29T21:12:33.936Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2f/933d2352422e25f2376aae74f79eaa882a50fb3bfef3c0d4f50501267101/fonttools-4.60.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:583b7f8e3c49486e4d489ad1deacfb8d5be54a8ef34d6df824f6a171f8511d99", size = 4999324, upload-time = "2025-09-29T21:12:36.637Z" }, + { url = "https://files.pythonhosted.org/packages/38/99/234594c0391221f66216bc2c886923513b3399a148defaccf81dc3be6560/fonttools-4.60.1-cp314-cp314-win32.whl", hash = "sha256:66929e2ea2810c6533a5184f938502cfdaea4bc3efb7130d8cc02e1c1b4108d6", size = 2220861, upload-time = "2025-09-29T21:12:39.108Z" }, + { url = "https://files.pythonhosted.org/packages/3e/1d/edb5b23726dde50fc4068e1493e4fc7658eeefcaf75d4c5ffce067d07ae5/fonttools-4.60.1-cp314-cp314-win_amd64.whl", hash = "sha256:f3d5be054c461d6a2268831f04091dc82753176f6ea06dc6047a5e168265a987", size = 2270934, upload-time = "2025-09-29T21:12:41.339Z" }, + { url = "https://files.pythonhosted.org/packages/fb/da/1392aaa2170adc7071fe7f9cfd181a5684a7afcde605aebddf1fb4d76df5/fonttools-4.60.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:b6379e7546ba4ae4b18f8ae2b9bc5960936007a1c0e30b342f662577e8bc3299", size = 2894340, upload-time = "2025-09-29T21:12:43.774Z" }, + { url = "https://files.pythonhosted.org/packages/bf/a7/3b9f16e010d536ce567058b931a20b590d8f3177b2eda09edd92e392375d/fonttools-4.60.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9d0ced62b59e0430b3690dbc5373df1c2aa7585e9a8ce38eff87f0fd993c5b01", size = 2375073, upload-time = "2025-09-29T21:12:46.437Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/e9bcf51980f98e59bb5bb7c382a63c6f6cac0eec5f67de6d8f2322382065/fonttools-4.60.1-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:875cb7764708b3132637f6c5fb385b16eeba0f7ac9fa45a69d35e09b47045801", size = 4849758, upload-time = "2025-09-29T21:12:48.694Z" }, + { url = "https://files.pythonhosted.org/packages/e3/dc/1d2cf7d1cba82264b2f8385db3f5960e3d8ce756b4dc65b700d2c496f7e9/fonttools-4.60.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a184b2ea57b13680ab6d5fbde99ccef152c95c06746cb7718c583abd8f945ccc", size = 5085598, upload-time = "2025-09-29T21:12:51.081Z" }, + { url = "https://files.pythonhosted.org/packages/5d/4d/279e28ba87fb20e0c69baf72b60bbf1c4d873af1476806a7b5f2b7fac1ff/fonttools-4.60.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:026290e4ec76583881763fac284aca67365e0be9f13a7fb137257096114cb3bc", size = 4957603, upload-time = "2025-09-29T21:12:53.423Z" }, + { url = "https://files.pythonhosted.org/packages/78/d4/ff19976305e0c05aa3340c805475abb00224c954d3c65e82c0a69633d55d/fonttools-4.60.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0e8817c7d1a0c2eedebf57ef9a9896f3ea23324769a9a2061a80fe8852705ed", size = 4974184, upload-time = "2025-09-29T21:12:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/63/22/8553ff6166f5cd21cfaa115aaacaa0dc73b91c079a8cfd54a482cbc0f4f5/fonttools-4.60.1-cp314-cp314t-win32.whl", hash = "sha256:1410155d0e764a4615774e5c2c6fc516259fe3eca5882f034eb9bfdbee056259", size = 2282241, upload-time = "2025-09-29T21:12:58.179Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cb/fa7b4d148e11d5a72761a22e595344133e83a9507a4c231df972e657579b/fonttools-4.60.1-cp314-cp314t-win_amd64.whl", hash = "sha256:022beaea4b73a70295b688f817ddc24ed3e3418b5036ffcd5658141184ef0d0c", size = 2345760, upload-time = "2025-09-29T21:13:00.375Z" }, + { url = "https://files.pythonhosted.org/packages/c7/93/0dd45cd283c32dea1545151d8c3637b4b8c53cdb3a625aeb2885b184d74d/fonttools-4.60.1-py3-none-any.whl", hash = "sha256:906306ac7afe2156fcf0042173d6ebbb05416af70f6b370967b47f8f00103bbb", size = 1143175, upload-time = "2025-09-29T21:13:24.134Z" }, +] + [[package]] name = "frozenlist" version = "1.7.0" @@ -908,6 +1132,189 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/35/c2/93368d4c9355e8ad1f6d62b804de241939d0796b2a3a73737f665b802808/json_repair-0.50.0-py3-none-any.whl", hash = "sha256:b15da2c42deb43419b182d97dcfde6cd86d0b18ccd18ed1a887104ce85e7a364", size = 25985, upload-time = "2025-08-20T15:01:56.567Z" }, ] +[[package]] +name = "kiwisolver" +version = "1.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564, upload-time = "2025-08-10T21:27:49.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/5d/8ce64e36d4e3aac5ca96996457dcf33e34e6051492399a3f1fec5657f30b/kiwisolver-1.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b4b4d74bda2b8ebf4da5bd42af11d02d04428b2c32846e4c2c93219df8a7987b", size = 124159, upload-time = "2025-08-10T21:25:35.472Z" }, + { url = "https://files.pythonhosted.org/packages/96/1e/22f63ec454874378175a5f435d6ea1363dd33fb2af832c6643e4ccea0dc8/kiwisolver-1.4.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fb3b8132019ea572f4611d770991000d7f58127560c4889729248eb5852a102f", size = 66578, upload-time = "2025-08-10T21:25:36.73Z" }, + { url = "https://files.pythonhosted.org/packages/41/4c/1925dcfff47a02d465121967b95151c82d11027d5ec5242771e580e731bd/kiwisolver-1.4.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84fd60810829c27ae375114cd379da1fa65e6918e1da405f356a775d49a62bcf", size = 65312, upload-time = "2025-08-10T21:25:37.658Z" }, + { url = "https://files.pythonhosted.org/packages/d4/42/0f333164e6307a0687d1eb9ad256215aae2f4bd5d28f4653d6cd319a3ba3/kiwisolver-1.4.9-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b78efa4c6e804ecdf727e580dbb9cba85624d2e1c6b5cb059c66290063bd99a9", size = 1628458, upload-time = "2025-08-10T21:25:39.067Z" }, + { url = "https://files.pythonhosted.org/packages/86/b6/2dccb977d651943995a90bfe3495c2ab2ba5cd77093d9f2318a20c9a6f59/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4efec7bcf21671db6a3294ff301d2fc861c31faa3c8740d1a94689234d1b415", size = 1225640, upload-time = "2025-08-10T21:25:40.489Z" }, + { url = "https://files.pythonhosted.org/packages/50/2b/362ebd3eec46c850ccf2bfe3e30f2fc4c008750011f38a850f088c56a1c6/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90f47e70293fc3688b71271100a1a5453aa9944a81d27ff779c108372cf5567b", size = 1244074, upload-time = "2025-08-10T21:25:42.221Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bb/f09a1e66dab8984773d13184a10a29fe67125337649d26bdef547024ed6b/kiwisolver-1.4.9-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fdca1def57a2e88ef339de1737a1449d6dbf5fab184c54a1fca01d541317154", size = 1293036, upload-time = "2025-08-10T21:25:43.801Z" }, + { url = "https://files.pythonhosted.org/packages/ea/01/11ecf892f201cafda0f68fa59212edaea93e96c37884b747c181303fccd1/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9cf554f21be770f5111a1690d42313e140355e687e05cf82cb23d0a721a64a48", size = 2175310, upload-time = "2025-08-10T21:25:45.045Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5f/bfe11d5b934f500cc004314819ea92427e6e5462706a498c1d4fc052e08f/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1795ac5cd0510207482c3d1d3ed781143383b8cfd36f5c645f3897ce066220", size = 2270943, upload-time = "2025-08-10T21:25:46.393Z" }, + { url = "https://files.pythonhosted.org/packages/3d/de/259f786bf71f1e03e73d87e2db1a9a3bcab64d7b4fd780167123161630ad/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:ccd09f20ccdbbd341b21a67ab50a119b64a403b09288c27481575105283c1586", size = 2440488, upload-time = "2025-08-10T21:25:48.074Z" }, + { url = "https://files.pythonhosted.org/packages/1b/76/c989c278faf037c4d3421ec07a5c452cd3e09545d6dae7f87c15f54e4edf/kiwisolver-1.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:540c7c72324d864406a009d72f5d6856f49693db95d1fbb46cf86febef873634", size = 2246787, upload-time = "2025-08-10T21:25:49.442Z" }, + { url = "https://files.pythonhosted.org/packages/a2/55/c2898d84ca440852e560ca9f2a0d28e6e931ac0849b896d77231929900e7/kiwisolver-1.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:ede8c6d533bc6601a47ad4046080d36b8fc99f81e6f1c17b0ac3c2dc91ac7611", size = 73730, upload-time = "2025-08-10T21:25:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/e8/09/486d6ac523dd33b80b368247f238125d027964cfacb45c654841e88fb2ae/kiwisolver-1.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:7b4da0d01ac866a57dd61ac258c5607b4cd677f63abaec7b148354d2b2cdd536", size = 65036, upload-time = "2025-08-10T21:25:52.063Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167, upload-time = "2025-08-10T21:25:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579, upload-time = "2025-08-10T21:25:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309, upload-time = "2025-08-10T21:25:55.76Z" }, + { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596, upload-time = "2025-08-10T21:25:56.861Z" }, + { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548, upload-time = "2025-08-10T21:25:58.246Z" }, + { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618, upload-time = "2025-08-10T21:25:59.857Z" }, + { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437, upload-time = "2025-08-10T21:26:01.105Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742, upload-time = "2025-08-10T21:26:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810, upload-time = "2025-08-10T21:26:04.009Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579, upload-time = "2025-08-10T21:26:05.317Z" }, + { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071, upload-time = "2025-08-10T21:26:06.686Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840, upload-time = "2025-08-10T21:26:07.94Z" }, + { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159, upload-time = "2025-08-10T21:26:09.048Z" }, + { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686, upload-time = "2025-08-10T21:26:10.034Z" }, + { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460, upload-time = "2025-08-10T21:26:11.083Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952, upload-time = "2025-08-10T21:26:12.058Z" }, + { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756, upload-time = "2025-08-10T21:26:13.096Z" }, + { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404, upload-time = "2025-08-10T21:26:14.457Z" }, + { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410, upload-time = "2025-08-10T21:26:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631, upload-time = "2025-08-10T21:26:17.045Z" }, + { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963, upload-time = "2025-08-10T21:26:18.737Z" }, + { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295, upload-time = "2025-08-10T21:26:20.11Z" }, + { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987, upload-time = "2025-08-10T21:26:21.49Z" }, + { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817, upload-time = "2025-08-10T21:26:22.812Z" }, + { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895, upload-time = "2025-08-10T21:26:24.37Z" }, + { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992, upload-time = "2025-08-10T21:26:25.732Z" }, + { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681, upload-time = "2025-08-10T21:26:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464, upload-time = "2025-08-10T21:26:27.733Z" }, + { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961, upload-time = "2025-08-10T21:26:28.729Z" }, + { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607, upload-time = "2025-08-10T21:26:29.798Z" }, + { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546, upload-time = "2025-08-10T21:26:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482, upload-time = "2025-08-10T21:26:32.721Z" }, + { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720, upload-time = "2025-08-10T21:26:34.032Z" }, + { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907, upload-time = "2025-08-10T21:26:35.824Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334, upload-time = "2025-08-10T21:26:37.534Z" }, + { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313, upload-time = "2025-08-10T21:26:39.191Z" }, + { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970, upload-time = "2025-08-10T21:26:40.828Z" }, + { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894, upload-time = "2025-08-10T21:26:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995, upload-time = "2025-08-10T21:26:43.889Z" }, + { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510, upload-time = "2025-08-10T21:26:44.915Z" }, + { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903, upload-time = "2025-08-10T21:26:45.934Z" }, + { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402, upload-time = "2025-08-10T21:26:47.101Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135, upload-time = "2025-08-10T21:26:48.665Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409, upload-time = "2025-08-10T21:26:50.335Z" }, + { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763, upload-time = "2025-08-10T21:26:51.867Z" }, + { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643, upload-time = "2025-08-10T21:26:53.592Z" }, + { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818, upload-time = "2025-08-10T21:26:55.051Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963, upload-time = "2025-08-10T21:26:56.421Z" }, + { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639, upload-time = "2025-08-10T21:26:57.882Z" }, + { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741, upload-time = "2025-08-10T21:26:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646, upload-time = "2025-08-10T21:27:00.52Z" }, + { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806, upload-time = "2025-08-10T21:27:01.537Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605, upload-time = "2025-08-10T21:27:03.335Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925, upload-time = "2025-08-10T21:27:04.339Z" }, + { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414, upload-time = "2025-08-10T21:27:05.437Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272, upload-time = "2025-08-10T21:27:07.063Z" }, + { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578, upload-time = "2025-08-10T21:27:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607, upload-time = "2025-08-10T21:27:10.125Z" }, + { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150, upload-time = "2025-08-10T21:27:11.484Z" }, + { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979, upload-time = "2025-08-10T21:27:12.917Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456, upload-time = "2025-08-10T21:27:14.353Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621, upload-time = "2025-08-10T21:27:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417, upload-time = "2025-08-10T21:27:17.436Z" }, + { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582, upload-time = "2025-08-10T21:27:18.436Z" }, + { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514, upload-time = "2025-08-10T21:27:19.465Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905, upload-time = "2025-08-10T21:27:20.51Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399, upload-time = "2025-08-10T21:27:21.496Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197, upload-time = "2025-08-10T21:27:22.604Z" }, + { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125, upload-time = "2025-08-10T21:27:24.036Z" }, + { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612, upload-time = "2025-08-10T21:27:25.773Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990, upload-time = "2025-08-10T21:27:27.089Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601, upload-time = "2025-08-10T21:27:29.343Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041, upload-time = "2025-08-10T21:27:30.754Z" }, + { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897, upload-time = "2025-08-10T21:27:32.803Z" }, + { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835, upload-time = "2025-08-10T21:27:34.23Z" }, + { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988, upload-time = "2025-08-10T21:27:35.587Z" }, + { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/a2/63/fde392691690f55b38d5dd7b3710f5353bf7a8e52de93a22968801ab8978/kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4d1d9e582ad4d63062d34077a9a1e9f3c34088a2ec5135b1f7190c07cf366527", size = 60183, upload-time = "2025-08-10T21:27:37.669Z" }, + { url = "https://files.pythonhosted.org/packages/27/b1/6aad34edfdb7cced27f371866f211332bba215bfd918ad3322a58f480d8b/kiwisolver-1.4.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:deed0c7258ceb4c44ad5ec7d9918f9f14fd05b2be86378d86cf50e63d1e7b771", size = 58675, upload-time = "2025-08-10T21:27:39.031Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1a/23d855a702bb35a76faed5ae2ba3de57d323f48b1f6b17ee2176c4849463/kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a590506f303f512dff6b7f75fd2fd18e16943efee932008fe7140e5fa91d80e", size = 80277, upload-time = "2025-08-10T21:27:40.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5b/5239e3c2b8fb5afa1e8508f721bb77325f740ab6994d963e61b2b7abcc1e/kiwisolver-1.4.9-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e09c2279a4d01f099f52d5c4b3d9e208e91edcbd1a175c9662a8b16e000fece9", size = 77994, upload-time = "2025-08-10T21:27:41.181Z" }, + { url = "https://files.pythonhosted.org/packages/f9/1c/5d4d468fb16f8410e596ed0eac02d2c68752aa7dc92997fe9d60a7147665/kiwisolver-1.4.9-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:c9e7cdf45d594ee04d5be1b24dd9d49f3d1590959b2271fb30b5ca2b262c00fb", size = 73744, upload-time = "2025-08-10T21:27:42.254Z" }, + { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104, upload-time = "2025-08-10T21:27:43.287Z" }, + { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592, upload-time = "2025-08-10T21:27:44.314Z" }, + { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281, upload-time = "2025-08-10T21:27:45.369Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009, upload-time = "2025-08-10T21:27:46.376Z" }, + { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929, upload-time = "2025-08-10T21:27:48.236Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "contourpy", version = "1.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/e2/d2d5295be2f44c678ebaf3544ba32d20c1f9ef08c49fe47f496180e1db15/matplotlib-3.10.7.tar.gz", hash = "sha256:a06ba7e2a2ef9131c79c49e63dad355d2d878413a0376c1727c8b9335ff731c7", size = 34804865, upload-time = "2025-10-09T00:28:00.669Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/87/3932d5778ab4c025db22710b61f49ccaed3956c5cf46ffb2ffa7492b06d9/matplotlib-3.10.7-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:7ac81eee3b7c266dd92cee1cd658407b16c57eed08c7421fa354ed68234de380", size = 8247141, upload-time = "2025-10-09T00:26:06.023Z" }, + { url = "https://files.pythonhosted.org/packages/45/a8/bfed45339160102bce21a44e38a358a1134a5f84c26166de03fb4a53208f/matplotlib-3.10.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:667ecd5d8d37813a845053d8f5bf110b534c3c9f30e69ebd25d4701385935a6d", size = 8107995, upload-time = "2025-10-09T00:26:08.669Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3c/5692a2d9a5ba848fda3f48d2b607037df96460b941a59ef236404b39776b/matplotlib-3.10.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc1c51b846aca49a5a8b44fbba6a92d583a35c64590ad9e1e950dc88940a4297", size = 8680503, upload-time = "2025-10-09T00:26:10.607Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a0/86ace53c48b05d0e6e9c127b2ace097434901f3e7b93f050791c8243201a/matplotlib-3.10.7-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a11c2e9e72e7de09b7b72e62f3df23317c888299c875e2b778abf1eda8c0a42", size = 9514982, upload-time = "2025-10-09T00:26:12.594Z" }, + { url = "https://files.pythonhosted.org/packages/a6/81/ead71e2824da8f72640a64166d10e62300df4ae4db01a0bac56c5b39fa51/matplotlib-3.10.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f19410b486fdd139885ace124e57f938c1e6a3210ea13dd29cab58f5d4bc12c7", size = 9566429, upload-time = "2025-10-09T00:26:14.758Z" }, + { url = "https://files.pythonhosted.org/packages/65/7d/954b3067120456f472cce8fdcacaf4a5fcd522478db0c37bb243c7cb59dd/matplotlib-3.10.7-cp310-cp310-win_amd64.whl", hash = "sha256:b498e9e4022f93de2d5a37615200ca01297ceebbb56fe4c833f46862a490f9e3", size = 8108174, upload-time = "2025-10-09T00:26:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bc/0fb489005669127ec13f51be0c6adc074d7cf191075dab1da9fe3b7a3cfc/matplotlib-3.10.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:53b492410a6cd66c7a471de6c924f6ede976e963c0f3097a3b7abfadddc67d0a", size = 8257507, upload-time = "2025-10-09T00:26:19.073Z" }, + { url = "https://files.pythonhosted.org/packages/e2/6a/d42588ad895279ff6708924645b5d2ed54a7fb2dc045c8a804e955aeace1/matplotlib-3.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9749313deb729f08207718d29c86246beb2ea3fdba753595b55901dee5d2fd6", size = 8119565, upload-time = "2025-10-09T00:26:21.023Z" }, + { url = "https://files.pythonhosted.org/packages/10/b7/4aa196155b4d846bd749cf82aa5a4c300cf55a8b5e0dfa5b722a63c0f8a0/matplotlib-3.10.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2222c7ba2cbde7fe63032769f6eb7e83ab3227f47d997a8453377709b7fe3a5a", size = 8692668, upload-time = "2025-10-09T00:26:22.967Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e7/664d2b97016f46683a02d854d730cfcf54ff92c1dafa424beebef50f831d/matplotlib-3.10.7-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e91f61a064c92c307c5a9dc8c05dc9f8a68f0a3be199d9a002a0622e13f874a1", size = 9521051, upload-time = "2025-10-09T00:26:25.041Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a3/37aef1404efa615f49b5758a5e0261c16dd88f389bc1861e722620e4a754/matplotlib-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f1851eab59ca082c95df5a500106bad73672645625e04538b3ad0f69471ffcc", size = 9576878, upload-time = "2025-10-09T00:26:27.478Z" }, + { url = "https://files.pythonhosted.org/packages/33/cd/b145f9797126f3f809d177ca378de57c45413c5099c5990de2658760594a/matplotlib-3.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:6516ce375109c60ceec579e699524e9d504cd7578506f01150f7a6bc174a775e", size = 8115142, upload-time = "2025-10-09T00:26:29.774Z" }, + { url = "https://files.pythonhosted.org/packages/2e/39/63bca9d2b78455ed497fcf51a9c71df200a11048f48249038f06447fa947/matplotlib-3.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:b172db79759f5f9bc13ef1c3ef8b9ee7b37b0247f987fbbbdaa15e4f87fd46a9", size = 7992439, upload-time = "2025-10-09T00:26:40.32Z" }, + { url = "https://files.pythonhosted.org/packages/be/b3/09eb0f7796932826ec20c25b517d568627754f6c6462fca19e12c02f2e12/matplotlib-3.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a0edb7209e21840e8361e91ea84ea676658aa93edd5f8762793dec77a4a6748", size = 8272389, upload-time = "2025-10-09T00:26:42.474Z" }, + { url = "https://files.pythonhosted.org/packages/11/0b/1ae80ddafb8652fd8046cb5c8460ecc8d4afccb89e2c6d6bec61e04e1eaf/matplotlib-3.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c380371d3c23e0eadf8ebff114445b9f970aff2010198d498d4ab4c3b41eea4f", size = 8128247, upload-time = "2025-10-09T00:26:44.77Z" }, + { url = "https://files.pythonhosted.org/packages/7d/18/95ae2e242d4a5c98bd6e90e36e128d71cf1c7e39b0874feaed3ef782e789/matplotlib-3.10.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d5f256d49fea31f40f166a5e3131235a5d2f4b7f44520b1cf0baf1ce568ccff0", size = 8696996, upload-time = "2025-10-09T00:26:46.792Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3d/5b559efc800bd05cb2033aa85f7e13af51958136a48327f7c261801ff90a/matplotlib-3.10.7-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11ae579ac83cdf3fb72573bb89f70e0534de05266728740d478f0f818983c695", size = 9530153, upload-time = "2025-10-09T00:26:49.07Z" }, + { url = "https://files.pythonhosted.org/packages/88/57/eab4a719fd110312d3c220595d63a3c85ec2a39723f0f4e7fa7e6e3f74ba/matplotlib-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4c14b6acd16cddc3569a2d515cfdd81c7a68ac5639b76548cfc1a9e48b20eb65", size = 9593093, upload-time = "2025-10-09T00:26:51.067Z" }, + { url = "https://files.pythonhosted.org/packages/31/3c/80816f027b3a4a28cd2a0a6ef7f89a2db22310e945cd886ec25bfb399221/matplotlib-3.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:0d8c32b7ea6fb80b1aeff5a2ceb3fb9778e2759e899d9beff75584714afcc5ee", size = 8122771, upload-time = "2025-10-09T00:26:53.296Z" }, + { url = "https://files.pythonhosted.org/packages/de/77/ef1fc78bfe99999b2675435cc52120887191c566b25017d78beaabef7f2d/matplotlib-3.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:5f3f6d315dcc176ba7ca6e74c7768fb7e4cf566c49cb143f6bc257b62e634ed8", size = 7992812, upload-time = "2025-10-09T00:26:54.882Z" }, + { url = "https://files.pythonhosted.org/packages/02/9c/207547916a02c78f6bdd83448d9b21afbc42f6379ed887ecf610984f3b4e/matplotlib-3.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1d9d3713a237970569156cfb4de7533b7c4eacdd61789726f444f96a0d28f57f", size = 8273212, upload-time = "2025-10-09T00:26:56.752Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d0/b3d3338d467d3fc937f0bb7f256711395cae6f78e22cef0656159950adf0/matplotlib-3.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37a1fea41153dd6ee061d21ab69c9cf2cf543160b1b85d89cd3d2e2a7902ca4c", size = 8128713, upload-time = "2025-10-09T00:26:59.001Z" }, + { url = "https://files.pythonhosted.org/packages/22/ff/6425bf5c20d79aa5b959d1ce9e65f599632345391381c9a104133fe0b171/matplotlib-3.10.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b3c4ea4948d93c9c29dc01c0c23eef66f2101bf75158c291b88de6525c55c3d1", size = 8698527, upload-time = "2025-10-09T00:27:00.69Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7f/ccdca06f4c2e6c7989270ed7829b8679466682f4cfc0f8c9986241c023b6/matplotlib-3.10.7-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22df30ffaa89f6643206cf13877191c63a50e8f800b038bc39bee9d2d4957632", size = 9529690, upload-time = "2025-10-09T00:27:02.664Z" }, + { url = "https://files.pythonhosted.org/packages/b8/95/b80fc2c1f269f21ff3d193ca697358e24408c33ce2b106a7438a45407b63/matplotlib-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b69676845a0a66f9da30e87f48be36734d6748024b525ec4710be40194282c84", size = 9593732, upload-time = "2025-10-09T00:27:04.653Z" }, + { url = "https://files.pythonhosted.org/packages/e1/b6/23064a96308b9aeceeffa65e96bcde459a2ea4934d311dee20afde7407a0/matplotlib-3.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:744991e0cc863dd669c8dc9136ca4e6e0082be2070b9d793cbd64bec872a6815", size = 8122727, upload-time = "2025-10-09T00:27:06.814Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a6/2faaf48133b82cf3607759027f82b5c702aa99cdfcefb7f93d6ccf26a424/matplotlib-3.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:fba2974df0bf8ce3c995fa84b79cde38326e0f7b5409e7a3a481c1141340bcf7", size = 7992958, upload-time = "2025-10-09T00:27:08.567Z" }, + { url = "https://files.pythonhosted.org/packages/4a/f0/b018fed0b599bd48d84c08794cb242227fe3341952da102ee9d9682db574/matplotlib-3.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:932c55d1fa7af4423422cb6a492a31cbcbdbe68fd1a9a3f545aa5e7a143b5355", size = 8316849, upload-time = "2025-10-09T00:27:10.254Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b7/bb4f23856197659f275e11a2a164e36e65e9b48ea3e93c4ec25b4f163198/matplotlib-3.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e38c2d581d62ee729a6e144c47a71b3f42fb4187508dbbf4fe71d5612c3433b", size = 8178225, upload-time = "2025-10-09T00:27:12.241Z" }, + { url = "https://files.pythonhosted.org/packages/62/56/0600609893ff277e6f3ab3c0cef4eafa6e61006c058e84286c467223d4d5/matplotlib-3.10.7-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:786656bb13c237bbcebcd402f65f44dd61ead60ee3deb045af429d889c8dbc67", size = 8711708, upload-time = "2025-10-09T00:27:13.879Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1a/6bfecb0cafe94d6658f2f1af22c43b76cf7a1c2f0dc34ef84cbb6809617e/matplotlib-3.10.7-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09d7945a70ea43bf9248f4b6582734c2fe726723204a76eca233f24cffc7ef67", size = 9541409, upload-time = "2025-10-09T00:27:15.684Z" }, + { url = "https://files.pythonhosted.org/packages/08/50/95122a407d7f2e446fd865e2388a232a23f2b81934960ea802f3171518e4/matplotlib-3.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0b181e9fa8daf1d9f2d4c547527b167cb8838fc587deabca7b5c01f97199e84", size = 9594054, upload-time = "2025-10-09T00:27:17.547Z" }, + { url = "https://files.pythonhosted.org/packages/13/76/75b194a43b81583478a81e78a07da8d9ca6ddf50dd0a2ccabf258059481d/matplotlib-3.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:31963603041634ce1a96053047b40961f7a29eb8f9a62e80cc2c0427aa1d22a2", size = 8200100, upload-time = "2025-10-09T00:27:20.039Z" }, + { url = "https://files.pythonhosted.org/packages/f5/9e/6aefebdc9f8235c12bdeeda44cc0383d89c1e41da2c400caf3ee2073a3ce/matplotlib-3.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:aebed7b50aa6ac698c90f60f854b47e48cd2252b30510e7a1feddaf5a3f72cbf", size = 8042131, upload-time = "2025-10-09T00:27:21.608Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/e5bc2c321b6a7e3a75638d937d19ea267c34bd5a90e12bee76c4d7c7a0d9/matplotlib-3.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d883460c43e8c6b173fef244a2341f7f7c0e9725c7fe68306e8e44ed9c8fb100", size = 8273787, upload-time = "2025-10-09T00:27:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/86/ad/6efae459c56c2fbc404da154e13e3a6039129f3c942b0152624f1c621f05/matplotlib-3.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07124afcf7a6504eafcb8ce94091c5898bbdd351519a1beb5c45f7a38c67e77f", size = 8131348, upload-time = "2025-10-09T00:27:24.926Z" }, + { url = "https://files.pythonhosted.org/packages/a6/5a/a4284d2958dee4116359cc05d7e19c057e64ece1b4ac986ab0f2f4d52d5a/matplotlib-3.10.7-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c17398b709a6cce3d9fdb1595c33e356d91c098cd9486cb2cc21ea2ea418e715", size = 9533949, upload-time = "2025-10-09T00:27:26.704Z" }, + { url = "https://files.pythonhosted.org/packages/de/ff/f3781b5057fa3786623ad8976fc9f7b0d02b2f28534751fd5a44240de4cf/matplotlib-3.10.7-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7146d64f561498764561e9cd0ed64fcf582e570fc519e6f521e2d0cfd43365e1", size = 9804247, upload-time = "2025-10-09T00:27:28.514Z" }, + { url = "https://files.pythonhosted.org/packages/47/5a/993a59facb8444efb0e197bf55f545ee449902dcee86a4dfc580c3b61314/matplotlib-3.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90ad854c0a435da3104c01e2c6f0028d7e719b690998a2333d7218db80950722", size = 9595497, upload-time = "2025-10-09T00:27:30.418Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a5/77c95aaa9bb32c345cbb49626ad8eb15550cba2e6d4c88081a6c2ac7b08d/matplotlib-3.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:4645fc5d9d20ffa3a39361fcdbcec731382763b623b72627806bf251b6388866", size = 8252732, upload-time = "2025-10-09T00:27:32.332Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/45d269b4268d222390d7817dae77b159651909669a34ee9fdee336db5883/matplotlib-3.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:9257be2f2a03415f9105c486d304a321168e61ad450f6153d77c69504ad764bb", size = 8124240, upload-time = "2025-10-09T00:27:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/4b/c7/ca01c607bb827158b439208c153d6f14ddb9fb640768f06f7ca3488ae67b/matplotlib-3.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1e4bbad66c177a8fdfa53972e5ef8be72a5f27e6a607cec0d8579abd0f3102b1", size = 8316938, upload-time = "2025-10-09T00:27:35.534Z" }, + { url = "https://files.pythonhosted.org/packages/84/d2/5539e66e9f56d2fdec94bb8436f5e449683b4e199bcc897c44fbe3c99e28/matplotlib-3.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8eb7194b084b12feb19142262165832fc6ee879b945491d1c3d4660748020c4", size = 8178245, upload-time = "2025-10-09T00:27:37.334Z" }, + { url = "https://files.pythonhosted.org/packages/77/b5/e6ca22901fd3e4fe433a82e583436dd872f6c966fca7e63cf806b40356f8/matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d41379b05528091f00e1728004f9a8d7191260f3862178b88e8fd770206318", size = 9541411, upload-time = "2025-10-09T00:27:39.387Z" }, + { url = "https://files.pythonhosted.org/packages/9e/99/a4524db57cad8fee54b7237239a8f8360bfcfa3170d37c9e71c090c0f409/matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a74f79fafb2e177f240579bc83f0b60f82cc47d2f1d260f422a0627207008ca", size = 9803664, upload-time = "2025-10-09T00:27:41.492Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a5/85e2edf76ea0ad4288d174926d9454ea85f3ce5390cc4e6fab196cbf250b/matplotlib-3.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:702590829c30aada1e8cef0568ddbffa77ca747b4d6e36c6d173f66e301f89cc", size = 9594066, upload-time = "2025-10-09T00:27:43.694Z" }, + { url = "https://files.pythonhosted.org/packages/39/69/9684368a314f6d83fe5c5ad2a4121a3a8e03723d2e5c8ea17b66c1bad0e7/matplotlib-3.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:f79d5de970fc90cd5591f60053aecfce1fcd736e0303d9f0bf86be649fa68fb8", size = 8342832, upload-time = "2025-10-09T00:27:45.543Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/e22e08da14bc1a0894184640d47819d2338b792732e20d292bf86e5ab785/matplotlib-3.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:cb783436e47fcf82064baca52ce748af71725d0352e1d31564cbe9c95df92b9c", size = 8172585, upload-time = "2025-10-09T00:27:47.185Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6c/a9bcf03e9afb2a873e0a5855f79bce476d1023f26f8212969f2b7504756c/matplotlib-3.10.7-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5c09cf8f2793f81368f49f118b6f9f937456362bee282eac575cca7f84cda537", size = 8241204, upload-time = "2025-10-09T00:27:48.806Z" }, + { url = "https://files.pythonhosted.org/packages/5b/fd/0e6f5aa762ed689d9fa8750b08f1932628ffa7ed30e76423c399d19407d2/matplotlib-3.10.7-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:de66744b2bb88d5cd27e80dfc2ec9f0517d0a46d204ff98fe9e5f2864eb67657", size = 8104607, upload-time = "2025-10-09T00:27:50.876Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a9/21c9439d698fac5f0de8fc68b2405b738ed1f00e1279c76f2d9aa5521ead/matplotlib-3.10.7-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:53cc80662dd197ece414dd5b66e07370201515a3eaf52e7c518c68c16814773b", size = 8682257, upload-time = "2025-10-09T00:27:52.597Z" }, + { url = "https://files.pythonhosted.org/packages/58/8f/76d5dc21ac64a49e5498d7f0472c0781dae442dd266a67458baec38288ec/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:15112bcbaef211bd663fa935ec33313b948e214454d949b723998a43357b17b0", size = 8252283, upload-time = "2025-10-09T00:27:54.739Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/9c5d4c2317feb31d819e38c9f947c942f42ebd4eb935fc6fd3518a11eaa7/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d2a959c640cdeecdd2ec3136e8ea0441da59bcaf58d67e9c590740addba2cb68", size = 8116733, upload-time = "2025-10-09T00:27:56.406Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cc/3fe688ff1355010937713164caacf9ed443675ac48a997bab6ed23b3f7c0/matplotlib-3.10.7-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3886e47f64611046bc1db523a09dd0a0a6bed6081e6f90e13806dd1d1d1b5e91", size = 8693919, upload-time = "2025-10-09T00:27:58.41Z" }, +] + [[package]] name = "msal" version = "1.33.0" @@ -1688,6 +2095,15 @@ crypto = [ { name = "cryptography" }, ] +[[package]] +name = "pyparsing" +version = "3.2.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274, upload-time = "2025-09-21T04:11:06.277Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890, upload-time = "2025-09-21T04:11:04.117Z" }, +] + [[package]] name = "pytest" version = "8.4.1"