jinja2 support
This commit is contained in:
118
README.md
118
README.md
@@ -1,6 +1,6 @@
|
|||||||
# LLMUtils
|
# 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
|
## Installation
|
||||||
|
|
||||||
@@ -18,6 +18,7 @@ pip install git+https://git.project-insanity.de/gmarth/LLMUtils.git
|
|||||||
|
|
||||||
## Features
|
## 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
|
- **Smart Prompt Management**: Load and manage prompt templates with variable substitution
|
||||||
- **On-demand Loading**: Prompts are loaded lazily at runtime for better performance
|
- **On-demand Loading**: Prompts are loaded lazily at runtime for better performance
|
||||||
- **Caching Support**: Optional caching to avoid repeated disk reads
|
- **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)
|
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
|
### JSON Schema Support
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@@ -98,14 +130,59 @@ Place your prompt templates in the `prompts/` directory:
|
|||||||
- `prompts/greeting.md` - Markdown file with template
|
- `prompts/greeting.md` - Markdown file with template
|
||||||
- `prompts/greeting.json` - Optional JSON schema for structured output
|
- `prompts/greeting.json` - Optional JSON schema for structured output
|
||||||
|
|
||||||
Example prompt template (`greeting.md`):
|
### Simple Template Example (`greeting.md`):
|
||||||
```markdown
|
```markdown
|
||||||
Hello {{name}},
|
Hello {{name}},
|
||||||
|
|
||||||
You are {{age}} years old.
|
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
|
```json
|
||||||
{
|
{
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -119,22 +196,22 @@ Example schema (`greeting.json`):
|
|||||||
|
|
||||||
## API Reference
|
## 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
|
- `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
|
- `schema: Optional[Dict]` - Associated JSON schema
|
||||||
- `prompt: str` - Property that returns filled prompt or template
|
- `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
|
- `validate(**kwargs) -> bool` - Check if all variables provided
|
||||||
- `get_missing_variables(**kwargs) -> Set[str]` - Get missing variables
|
- `get_missing_variables(**kwargs) -> Set[str]` - Get missing variables
|
||||||
|
|
||||||
### PromptManager Methods
|
### 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
|
- `get_schema(prompt_name) -> Optional[Dict]` - Get just the schema
|
||||||
- `has_schema(prompt_name) -> bool` - Check if prompt has schema
|
- `has_schema(prompt_name) -> bool` - Check if prompt has schema
|
||||||
- `list_prompts() -> Dict` - List all available prompts
|
- `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
|
- `configure(path=None, caching=None)` - Configure settings
|
||||||
- `reload_prompts()` - Clear the cache
|
- `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
|
## License
|
||||||
|
|
||||||
MIT
|
MIT
|
||||||
|
|||||||
221
example_jinja2.py
Normal file
221
example_jinja2.py
Normal file
@@ -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)
|
||||||
44
prompts/api_documentation.md
Normal file
44
prompts/api_documentation.md
Normal file
@@ -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 %}
|
||||||
32
prompts/code_generator.md
Normal file
32
prompts/code_generator.md
Normal file
@@ -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 %}
|
||||||
@@ -8,7 +8,7 @@ version = "0.1.0"
|
|||||||
description = "Utilities for working with LLMs"
|
description = "Utilities for working with LLMs"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = []
|
dependencies = ["jinja2>=3.0.0"]
|
||||||
authors = [
|
authors = [
|
||||||
{name = "Alexander Thiess", email = "thiess.alexander@googlemail.com"}
|
{name = "Alexander Thiess", email = "thiess.alexander@googlemail.com"}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Set, Optional
|
from typing import Dict, Any, Set, Optional
|
||||||
import logging
|
import logging
|
||||||
|
from jinja2 import Template, Environment, meta, TemplateSyntaxError, UndefinedError
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -16,8 +17,9 @@ class ManagedPrompt:
|
|||||||
name: str
|
name: str
|
||||||
variables: Set[str]
|
variables: Set[str]
|
||||||
schema: Optional[Dict[str, Any]] = None
|
schema: Optional[Dict[str, Any]] = None
|
||||||
_filled_prompt: Optional[str] = None
|
_filled_prompt: Optional[str] = field(default=None, init=False, repr=False)
|
||||||
_context: Optional[Dict[str, Any]] = None
|
_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:
|
def validate(self, **kwargs) -> bool:
|
||||||
"""Validate that all required variables are provided.
|
"""Validate that all required variables are provided.
|
||||||
@@ -39,7 +41,7 @@ class ManagedPrompt:
|
|||||||
return self.variables - provided_vars
|
return self.variables - provided_vars
|
||||||
|
|
||||||
def fill(self, **kwargs) -> str:
|
def fill(self, **kwargs) -> str:
|
||||||
"""Fill the template with provided variables.
|
"""Fill the template with provided variables using Jinja2.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
**kwargs: Variables to fill in the template
|
**kwargs: Variables to fill in the template
|
||||||
@@ -48,8 +50,15 @@ class ManagedPrompt:
|
|||||||
The filled prompt string
|
The filled prompt string
|
||||||
|
|
||||||
Raises:
|
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 no variables required and none provided, return template as-is
|
||||||
if not self.variables and not kwargs:
|
if not self.variables and not kwargs:
|
||||||
self._filled_prompt = self.template
|
self._filled_prompt = self.template
|
||||||
@@ -63,15 +72,11 @@ class ManagedPrompt:
|
|||||||
f"Required: {self.variables}, Provided: {set(kwargs.keys())}"
|
f"Required: {self.variables}, Provided: {set(kwargs.keys())}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only process the template if there are actually variables to replace
|
try:
|
||||||
if self.variables:
|
# Render the template with Jinja2
|
||||||
result = self.template
|
result = self._jinja_template.render(**kwargs)
|
||||||
for key, value in kwargs.items():
|
except UndefinedError as e:
|
||||||
if key in self.variables: # Only replace known variables
|
raise ValueError(f"Error rendering template '{self.name}': {e}")
|
||||||
placeholder = f"{{{{{key}}}}}" # {{key}}
|
|
||||||
result = result.replace(placeholder, str(value))
|
|
||||||
else:
|
|
||||||
result = self.template
|
|
||||||
|
|
||||||
# Cache the filled result
|
# Cache the filled result
|
||||||
self._filled_prompt = result
|
self._filled_prompt = result
|
||||||
@@ -182,10 +187,19 @@ class PromptManager:
|
|||||||
return Path.cwd() / 'prompts'
|
return Path.cwd() / 'prompts'
|
||||||
|
|
||||||
def _extract_variables(self, template: str) -> Set[str]:
|
def _extract_variables(self, template: str) -> Set[str]:
|
||||||
"""Extract all {{variable}} placeholders from template"""
|
"""Extract all variables from Jinja2 template"""
|
||||||
pattern = r'\{\{(\w+)\}\}'
|
try:
|
||||||
variables = set(re.findall(pattern, template))
|
# Create a Jinja2 environment and parse the template
|
||||||
return variables
|
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:
|
def _validate_context(self, prompt_name: str, context: Dict[str, Any]) -> None:
|
||||||
"""Validate that all required variables are provided"""
|
"""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}")
|
logger.warning(f"Extra variables provided for prompt '{prompt_name}': {extra_vars}")
|
||||||
|
|
||||||
def _fill_template(self, template: str, context: Dict[str, Any]) -> str:
|
def _fill_template(self, template: str, context: Dict[str, Any]) -> str:
|
||||||
"""Fill template with context variables"""
|
"""Fill template with context variables using Jinja2"""
|
||||||
result = template
|
try:
|
||||||
|
jinja_template = Template(template)
|
||||||
for key, value in context.items():
|
return jinja_template.render(**context)
|
||||||
placeholder = f"{{{{{key}}}}}" # {{key}}
|
except (TemplateSyntaxError, UndefinedError) as e:
|
||||||
result = result.replace(placeholder, str(value))
|
raise ValueError(f"Error rendering template: {e}")
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def configure(cls, path: Optional[Path] = None, caching: Optional[bool] = None):
|
def configure(cls, path: Optional[Path] = None, caching: Optional[bool] = None):
|
||||||
|
|||||||
156
test_jinja2_prompts.py
Normal file
156
test_jinja2_prompts.py
Normal file
@@ -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)
|
||||||
46
uv.lock
generated
46
uv.lock
generated
@@ -2,7 +2,53 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.13"
|
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]]
|
[[package]]
|
||||||
name = "llmutils"
|
name = "llmutils"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { editable = "." }
|
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" },
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user