diff --git a/README.md b/README.md index 87b64e3..866e9c1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # LLMUtils -A Python utility library for managing LLM prompts with template variables and JSON schemas. +A Python utility library for managing LLM prompts with Jinja2 template support and JSON schemas. ## Installation @@ -18,6 +18,7 @@ pip install git+https://git.project-insanity.de/gmarth/LLMUtils.git ## Features +- **Jinja2 Template Engine**: Full support for loops, conditionals, filters, and complex data structures - **Smart Prompt Management**: Load and manage prompt templates with variable substitution - **On-demand Loading**: Prompts are loaded lazily at runtime for better performance - **Caching Support**: Optional caching to avoid repeated disk reads @@ -64,6 +65,37 @@ if not result.validate(name='Alice'): filled = result.fill(name='Alice', age=30) ``` +### Advanced Jinja2 Features + +```python +# Using lists and loops +result = PromptManager.get_prompt('task_list') +filled = result.fill( + tasks=['Write code', 'Review PR', 'Deploy'], + priority='high' +) + +# Using conditionals +result = PromptManager.get_prompt('status_report') +filled = result.fill( + error='Connection timeout', # Will show error message + items=[] # Will show "No items" +) + +# Using complex nested data +result = PromptManager.get_prompt('user_profile') +filled = result.fill( + user={ + 'name': 'Alice', + 'roles': ['admin', 'developer'], + 'projects': [ + {'name': 'Project A', 'status': 'active'}, + {'name': 'Project B', 'status': 'completed'} + ] + } +) +``` + ### JSON Schema Support ```python @@ -98,14 +130,59 @@ Place your prompt templates in the `prompts/` directory: - `prompts/greeting.md` - Markdown file with template - `prompts/greeting.json` - Optional JSON schema for structured output -Example prompt template (`greeting.md`): +### Simple Template Example (`greeting.md`): ```markdown Hello {{name}}, You are {{age}} years old. ``` -Example schema (`greeting.json`): +### Jinja2 Template Examples + +#### Lists and Loops (`task_list.md`): +```markdown +Priority: {{ priority }} + +Tasks to complete: +{% for task in tasks %} +- {{ task }} +{% endfor %} + +Total: {{ tasks | length }} tasks +``` + +#### Conditionals (`status_report.md`): +```markdown +{% if error %} +⚠️ ERROR: {{ error }} +{% else %} +✅ All systems operational +{% endif %} + +{% if items %} +Items ({{ items | length }}): +{% for item in items %} + {{ loop.index }}. {{ item }} +{% endfor %} +{% else %} +No items to process. +{% endif %} +``` + +#### Complex Data (`user_profile.md`): +```markdown +# User: {{ user.name }} + +## Roles +{{ user.roles | join(', ') }} + +## Projects +{% for project in user.projects %} +- {{ project.name }} [{{ project.status | upper }}] +{% endfor %} +``` + +### JSON Schema Example (`greeting.json`): ```json { "type": "object", @@ -119,22 +196,22 @@ Example schema (`greeting.json`): ## API Reference -### PromptResult Class +### ManagedPrompt Class -The `PromptResult` dataclass returned by `get_prompt()`: +The `ManagedPrompt` dataclass returned by `get_prompt()`: -- `template: str` - The original template string +- `template: str` - The original Jinja2 template string - `name: str` - The prompt name -- `variables: Set[str]` - Required template variables +- `variables: Set[str]` - Required template variables (auto-extracted from Jinja2) - `schema: Optional[Dict]` - Associated JSON schema - `prompt: str` - Property that returns filled prompt or template -- `fill(**kwargs) -> str` - Fill template with variables +- `fill(**kwargs) -> str` - Fill template with variables using Jinja2 - `validate(**kwargs) -> bool` - Check if all variables provided - `get_missing_variables(**kwargs) -> Set[str]` - Get missing variables ### PromptManager Methods -- `get_prompt(prompt_name, **kwargs) -> PromptResult` - Get a prompt template +- `get_prompt(prompt_name, **kwargs) -> ManagedPrompt` - Get a prompt template - `get_schema(prompt_name) -> Optional[Dict]` - Get just the schema - `has_schema(prompt_name) -> bool` - Check if prompt has schema - `list_prompts() -> Dict` - List all available prompts @@ -142,6 +219,29 @@ The `PromptResult` dataclass returned by `get_prompt()`: - `configure(path=None, caching=None)` - Configure settings - `reload_prompts()` - Clear the cache +### Jinja2 Template Features + +The library supports all standard Jinja2 features: + +#### Filters +- `{{ items | length }}` - Get length of list +- `{{ name | upper }}` - Convert to uppercase +- `{{ name | lower }}` - Convert to lowercase +- `{{ skills | join(', ') }}` - Join list items +- `{{ data | tojson }}` - Convert to JSON +- `{{ price | round(2) }}` - Round numbers + +#### Loops +- `{% for item in items %}...{% endfor %}` - Iterate over lists +- `{{ loop.index }}` - Current iteration (1-indexed) +- `{{ loop.index0 }}` - Current iteration (0-indexed) +- `{% for key, value in dict.items() %}...{% endfor %}` - Iterate over dictionaries + +#### Conditionals +- `{% if condition %}...{% endif %}` - Simple conditional +- `{% if condition %}...{% else %}...{% endif %}` - If/else +- `{% if condition %}...{% elif other %}...{% else %}...{% endif %}` - Multiple conditions + ## License MIT diff --git a/example_jinja2.py b/example_jinja2.py new file mode 100644 index 0000000..8e8b8a9 --- /dev/null +++ b/example_jinja2.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""Example showcasing Jinja2 template features in prompt management""" + +from pathlib import Path +from src.llmutils.prompt_manager import PromptManager + +# Create example prompts directory +prompts_dir = Path("prompts") +prompts_dir.mkdir(exist_ok=True) + +# Create a code generation prompt with lists +code_gen_prompt = prompts_dir / "code_generator.md" +code_gen_prompt.write_text("""Generate a Python class with the following specifications: + +Class Name: {{ class_name }} +{% if parent_class %} +Inherits from: {{ parent_class }} +{% endif %} + +## Attributes: +{% for attr in attributes %} +- {{ attr.name }}: {{ attr.type }}{% if attr.default %} = {{ attr.default }}{% endif %} +{% endfor %} + +## Methods: +{% for method in methods %} +### {{ method.name }}({{ method.params | join(', ') }}) +{{ method.description }} +Returns: {{ method.returns }} +{% endfor %} + +## Example Usage: +```python +{% for example in examples %} +{{ example }} +{% endfor %} +``` + +{% if additional_notes %} +## Notes: +{% for note in additional_notes %} +- {{ note }} +{% endfor %} +{% endif %}""") + +# Create an API documentation prompt +api_doc_prompt = prompts_dir / "api_documentation.md" +api_doc_prompt.write_text("""# API Documentation: {{ api_name }} + +Base URL: `{{ base_url }}` +Version: {{ version }} + +## Authentication +{{ auth_method }} + +## Endpoints + +{% for endpoint in endpoints %} +### {{ endpoint.method }} {{ endpoint.path }} + +**Description:** {{ endpoint.description }} + +{% if endpoint.params %} +**Parameters:** +{% for param in endpoint.params %} +- `{{ param.name }}` ({{ param.type }}): {{ param.description }}{% if param.required %} *[Required]*{% endif %} +{% endfor %} +{% endif %} + +{% if endpoint.request_body %} +**Request Body:** +```json +{{ endpoint.request_body | tojson(indent=2) }} +``` +{% endif %} + +**Response:** `{{ endpoint.response_code }}` +```json +{{ endpoint.response_example | tojson(indent=2) }} +``` + +--- +{% endfor %} + +## Rate Limiting +{{ rate_limit }} requests per {{ rate_limit_window }} + +## Error Codes +{% for error in error_codes %} +- `{{ error.code }}`: {{ error.message }} +{% endfor %}""") + +# Configure PromptManager +PromptManager.configure(path=prompts_dir) + +print("=" * 70) +print("Jinja2 Template Examples with Lists and Complex Data") +print("=" * 70) + +# Example 1: Code Generation +print("\n1. CODE GENERATION PROMPT") +print("-" * 70) + +prompt = PromptManager.get_prompt("code_generator") +filled = prompt.fill( + class_name="UserProfile", + parent_class="BaseModel", + attributes=[ + {"name": "user_id", "type": "int"}, + {"name": "username", "type": "str"}, + {"name": "email", "type": "str"}, + {"name": "created_at", "type": "datetime", "default": "datetime.now()"}, + {"name": "is_active", "type": "bool", "default": "True"} + ], + methods=[ + { + "name": "validate_email", + "params": ["self"], + "description": "Validates the email format", + "returns": "bool" + }, + { + "name": "update_profile", + "params": ["self", "**kwargs"], + "description": "Updates user profile with provided fields", + "returns": "None" + }, + { + "name": "to_dict", + "params": ["self"], + "description": "Converts the profile to a dictionary", + "returns": "Dict[str, Any]" + } + ], + examples=[ + "user = UserProfile(user_id=1, username='alice', email='alice@example.com')", + "if user.validate_email():", + " user.update_profile(is_active=False)", + " print(user.to_dict())" + ], + additional_notes=[ + "Email validation should follow RFC 5322", + "All datetime values should be UTC", + "Profile updates should be logged" + ] +) +print(filled) + +# Example 2: API Documentation +print("\n2. API DOCUMENTATION PROMPT") +print("-" * 70) + +prompt = PromptManager.get_prompt("api_documentation") +filled = prompt.fill( + api_name="User Management API", + base_url="https://api.example.com/v1", + version="1.0.0", + auth_method="Bearer token in Authorization header", + endpoints=[ + { + "method": "GET", + "path": "/users", + "description": "List all users with optional filtering", + "params": [ + {"name": "page", "type": "integer", "description": "Page number", "required": False}, + {"name": "limit", "type": "integer", "description": "Items per page", "required": False}, + {"name": "status", "type": "string", "description": "Filter by status", "required": False} + ], + "response_code": "200 OK", + "response_example": { + "users": [ + {"id": 1, "username": "alice", "status": "active"}, + {"id": 2, "username": "bob", "status": "inactive"} + ], + "total": 2, + "page": 1 + } + }, + { + "method": "POST", + "path": "/users", + "description": "Create a new user", + "request_body": { + "username": "string", + "email": "string", + "password": "string" + }, + "response_code": "201 Created", + "response_example": { + "id": 3, + "username": "charlie", + "email": "charlie@example.com", + "created_at": "2024-01-15T10:30:00Z" + } + }, + { + "method": "DELETE", + "path": "/users/{id}", + "description": "Delete a user by ID", + "params": [ + {"name": "id", "type": "integer", "description": "User ID", "required": True} + ], + "response_code": "204 No Content", + "response_example": {} + } + ], + rate_limit=1000, + rate_limit_window="hour", + error_codes=[ + {"code": "400", "message": "Bad Request - Invalid parameters"}, + {"code": "401", "message": "Unauthorized - Invalid or missing token"}, + {"code": "404", "message": "Not Found - Resource doesn't exist"}, + {"code": "429", "message": "Too Many Requests - Rate limit exceeded"}, + {"code": "500", "message": "Internal Server Error"} + ] +) +print(filled) + +print("\n" + "=" * 70) +print("Examples completed successfully!") +print("=" * 70) \ No newline at end of file diff --git a/prompts/api_documentation.md b/prompts/api_documentation.md new file mode 100644 index 0000000..cdab7dc --- /dev/null +++ b/prompts/api_documentation.md @@ -0,0 +1,44 @@ +# API Documentation: {{ api_name }} + +Base URL: `{{ base_url }}` +Version: {{ version }} + +## Authentication +{{ auth_method }} + +## Endpoints + +{% for endpoint in endpoints %} +### {{ endpoint.method }} {{ endpoint.path }} + +**Description:** {{ endpoint.description }} + +{% if endpoint.params %} +**Parameters:** +{% for param in endpoint.params %} +- `{{ param.name }}` ({{ param.type }}): {{ param.description }}{% if param.required %} *[Required]*{% endif %} +{% endfor %} +{% endif %} + +{% if endpoint.request_body %} +**Request Body:** +```json +{{ endpoint.request_body | tojson(indent=2) }} +``` +{% endif %} + +**Response:** `{{ endpoint.response_code }}` +```json +{{ endpoint.response_example | tojson(indent=2) }} +``` + +--- +{% endfor %} + +## Rate Limiting +{{ rate_limit }} requests per {{ rate_limit_window }} + +## Error Codes +{% for error in error_codes %} +- `{{ error.code }}`: {{ error.message }} +{% endfor %} \ No newline at end of file diff --git a/prompts/code_generator.md b/prompts/code_generator.md new file mode 100644 index 0000000..4929d08 --- /dev/null +++ b/prompts/code_generator.md @@ -0,0 +1,32 @@ +Generate a Python class with the following specifications: + +Class Name: {{ class_name }} +{% if parent_class %} +Inherits from: {{ parent_class }} +{% endif %} + +## Attributes: +{% for attr in attributes %} +- {{ attr.name }}: {{ attr.type }}{% if attr.default %} = {{ attr.default }}{% endif %} +{% endfor %} + +## Methods: +{% for method in methods %} +### {{ method.name }}({{ method.params | join(', ') }}) +{{ method.description }} +Returns: {{ method.returns }} +{% endfor %} + +## Example Usage: +```python +{% for example in examples %} +{{ example }} +{% endfor %} +``` + +{% if additional_notes %} +## Notes: +{% for note in additional_notes %} +- {{ note }} +{% endfor %} +{% endif %} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 02a2288..241acd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ version = "0.1.0" description = "Utilities for working with LLMs" readme = "README.md" requires-python = ">=3.13" -dependencies = [] +dependencies = ["jinja2>=3.0.0"] authors = [ {name = "Alexander Thiess", email = "thiess.alexander@googlemail.com"} ] diff --git a/src/llmutils/prompt_manager.py b/src/llmutils/prompt_manager.py index 9b28057..c4ac1c9 100644 --- a/src/llmutils/prompt_manager.py +++ b/src/llmutils/prompt_manager.py @@ -1,10 +1,11 @@ import os import re import json -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Dict, Any, Set, Optional import logging +from jinja2 import Template, Environment, meta, TemplateSyntaxError, UndefinedError logger = logging.getLogger(__name__) @@ -16,8 +17,9 @@ class ManagedPrompt: name: str variables: Set[str] schema: Optional[Dict[str, Any]] = None - _filled_prompt: Optional[str] = None - _context: Optional[Dict[str, Any]] = None + _filled_prompt: Optional[str] = field(default=None, init=False, repr=False) + _context: Optional[Dict[str, Any]] = field(default=None, init=False, repr=False) + _jinja_template: Optional[Template] = field(default=None, init=False, repr=False) def validate(self, **kwargs) -> bool: """Validate that all required variables are provided. @@ -39,7 +41,7 @@ class ManagedPrompt: return self.variables - provided_vars def fill(self, **kwargs) -> str: - """Fill the template with provided variables. + """Fill the template with provided variables using Jinja2. Args: **kwargs: Variables to fill in the template @@ -48,8 +50,15 @@ class ManagedPrompt: The filled prompt string Raises: - ValueError: If required variables are missing + ValueError: If required variables are missing or template syntax error """ + # Create Jinja2 template if not already created + if self._jinja_template is None: + try: + self._jinja_template = Template(self.template) + except TemplateSyntaxError as e: + raise ValueError(f"Invalid template syntax in prompt '{self.name}': {e}") + # If no variables required and none provided, return template as-is if not self.variables and not kwargs: self._filled_prompt = self.template @@ -63,15 +72,11 @@ class ManagedPrompt: f"Required: {self.variables}, Provided: {set(kwargs.keys())}" ) - # Only process the template if there are actually variables to replace - if self.variables: - result = self.template - for key, value in kwargs.items(): - if key in self.variables: # Only replace known variables - placeholder = f"{{{{{key}}}}}" # {{key}} - result = result.replace(placeholder, str(value)) - else: - result = self.template + try: + # Render the template with Jinja2 + result = self._jinja_template.render(**kwargs) + except UndefinedError as e: + raise ValueError(f"Error rendering template '{self.name}': {e}") # Cache the filled result self._filled_prompt = result @@ -182,10 +187,19 @@ class PromptManager: return Path.cwd() / 'prompts' def _extract_variables(self, template: str) -> Set[str]: - """Extract all {{variable}} placeholders from template""" - pattern = r'\{\{(\w+)\}\}' - variables = set(re.findall(pattern, template)) - return variables + """Extract all variables from Jinja2 template""" + try: + # Create a Jinja2 environment and parse the template + env = Environment() + ast = env.parse(template) + # Get all undeclared variables from the template + variables = meta.find_undeclared_variables(ast) + return variables + except TemplateSyntaxError: + # Fallback to simple regex for backwards compatibility + pattern = r'\{\{\s*(\w+)\s*\}\}' + variables = set(re.findall(pattern, template)) + return variables def _validate_context(self, prompt_name: str, context: Dict[str, Any]) -> None: """Validate that all required variables are provided""" @@ -208,14 +222,12 @@ class PromptManager: logger.warning(f"Extra variables provided for prompt '{prompt_name}': {extra_vars}") def _fill_template(self, template: str, context: Dict[str, Any]) -> str: - """Fill template with context variables""" - result = template - - for key, value in context.items(): - placeholder = f"{{{{{key}}}}}" # {{key}} - result = result.replace(placeholder, str(value)) - - return result + """Fill template with context variables using Jinja2""" + try: + jinja_template = Template(template) + return jinja_template.render(**context) + except (TemplateSyntaxError, UndefinedError) as e: + raise ValueError(f"Error rendering template: {e}") @classmethod def configure(cls, path: Optional[Path] = None, caching: Optional[bool] = None): diff --git a/test_jinja2_prompts.py b/test_jinja2_prompts.py new file mode 100644 index 0000000..3fb73aa --- /dev/null +++ b/test_jinja2_prompts.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""Test script for Jinja2-enhanced prompt management with list support""" + +from pathlib import Path +from src.llmutils.prompt_manager import PromptManager, ManagedPrompt + +# Create a test prompts directory +test_prompts_dir = Path("test_prompts") +test_prompts_dir.mkdir(exist_ok=True) + +# Create test prompt with simple variable +simple_prompt = test_prompts_dir / "simple.md" +simple_prompt.write_text("""Hello {{name}}! +Your age is {{age}}.""") + +# Create test prompt with list iteration +list_prompt = test_prompts_dir / "list_example.md" +list_prompt.write_text("""# Task List + +Here are your tasks: +{% for task in tasks %} +- {{ task }} +{% endfor %} + +Total tasks: {{ tasks | length }}""") + +# Create test prompt with complex data structures +complex_prompt = test_prompts_dir / "complex.md" +complex_prompt.write_text("""# User Report + +Name: {{ user.name }} +Email: {{ user.email }} + +## Assigned Tasks: +{% for task in user.tasks %} +- [{{ task.status }}] {{ task.title }} + Priority: {{ task.priority }} +{% endfor %} + +## Skills: +{{ skills | join(', ') }} + +## Recent Activity: +{% for date, activity in activities.items() %} +- {{ date }}: {{ activity }} +{% endfor %}""") + +# Create prompt with conditionals +conditional_prompt = test_prompts_dir / "conditional.md" +conditional_prompt.write_text("""# Status Report + +{% if error %} +⚠️ ERROR: {{ error }} +{% else %} +✅ All systems operational +{% endif %} + +{% if items %} +Items to process ({{ items | length }}): +{% for item in items %} + {{ loop.index }}. {{ item | upper }} +{% endfor %} +{% else %} +No items to process. +{% endif %} + +{% if debug %} +Debug info: {{ debug_data | tojson }} +{% endif %}""") + +# Configure PromptManager +PromptManager.configure(path=test_prompts_dir) + +print("=" * 60) +print("Testing Jinja2-Enhanced Prompt Management") +print("=" * 60) + +# Test 1: Simple variable replacement +print("\n1. Simple variable replacement:") +print("-" * 40) +prompt = PromptManager.get_prompt("simple") +filled = prompt.fill(name="Alice", age=30) +print(filled) + +# Test 2: List iteration +print("\n2. List iteration:") +print("-" * 40) +prompt = PromptManager.get_prompt("list_example") +filled = prompt.fill(tasks=["Write code", "Review PR", "Update documentation", "Deploy to staging"]) +print(filled) + +# Test 3: Complex data structures +print("\n3. Complex data structures:") +print("-" * 40) +prompt = PromptManager.get_prompt("complex") +filled = prompt.fill( + user={ + "name": "Bob Smith", + "email": "bob@example.com", + "tasks": [ + {"title": "Fix bug #123", "status": "✓", "priority": "High"}, + {"title": "Implement feature X", "status": "○", "priority": "Medium"}, + {"title": "Code review", "status": "→", "priority": "Low"} + ] + }, + skills=["Python", "JavaScript", "Docker", "Kubernetes"], + activities={ + "2024-01-15": "Deployed v2.3.0", + "2024-01-14": "Fixed critical security issue", + "2024-01-13": "Merged 5 PRs" + } +) +print(filled) + +# Test 4: Conditionals +print("\n4. Conditionals (with items):") +print("-" * 40) +prompt = PromptManager.get_prompt("conditional") +filled = prompt.fill( + error=None, # Provide None for optional conditional variables + items=["apple", "banana", "cherry"], + debug=True, + debug_data={"version": "1.0", "env": "dev"} +) +print(filled) + +print("\n5. Conditionals (with error):") +print("-" * 40) +prompt = PromptManager.get_prompt("conditional") +filled = prompt.fill( + error="Connection timeout", + items=[], + debug=False, + debug_data={} +) +print(filled) + +# Test 6: Pre-filled prompt +print("\n6. Pre-filled prompt on retrieval:") +print("-" * 40) +prompt = PromptManager.get_prompt("simple", name="Charlie", age=25) +print(prompt.prompt) # Should already be filled + +# Test 7: Variable extraction +print("\n7. Variable extraction from complex template:") +print("-" * 40) +prompt = PromptManager.get_prompt("complex") +print(f"Required variables: {prompt.variables}") + +print("\n" + "=" * 60) +print("All tests completed successfully!") +print("=" * 60) + +# Cleanup +import shutil +shutil.rmtree(test_prompts_dir) \ No newline at end of file diff --git a/uv.lock b/uv.lock index 36475ce..cd82ab8 100644 --- a/uv.lock +++ b/uv.lock @@ -2,7 +2,53 @@ version = 1 revision = 3 requires-python = ">=3.13" +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + [[package]] name = "llmutils" version = "0.1.0" source = { editable = "." } +dependencies = [ + { name = "jinja2" }, +] + +[package.metadata] +requires-dist = [{ name = "jinja2", specifier = ">=3.0.0" }] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +]