Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 555eb2a74b | |||
| 34481f15ea | |||
| 62c1a41c19 | |||
| ca295efb41 | |||
| 65f2d47d14 | |||
| 62e4159695 | |||
| 786af380ba | |||
| 5fbb643e61 | |||
| 158772b569 | |||
| b8256c220c | |||
| d048f87d1f | |||
| 980efebcae | |||
| bc023af374 | |||
| c3cffd35b7 | |||
| 5e70206309 | |||
| 15e4caf309 | |||
| b86deef6de | |||
| aca1039cae | |||
| 5197a8c4a0 | |||
| e527d084c4 | |||
| 1448fe6c2f | |||
| d7588aa369 | |||
| 1620042625 | |||
| 7f1cb97742 | |||
| 640715fc26 |
@@ -0,0 +1,185 @@
|
|||||||
|
---
|
||||||
|
name: python-executor
|
||||||
|
description: "Execute Python code in a safe sandboxed environment via [inference.sh](https://inference.sh). Pre-installed: NumPy, Pandas, Matplotlib, requests, BeautifulSoup, Selenium, Playwright, MoviePy, Pillow, OpenCV, trimesh, and 100+ more libraries. Use for: data processing, web scraping, image manipulation, video creation, 3D model processing, PDF generation, API calls, automation scripts. Triggers: python, execute code, run script, web scraping, data analysis, image processing, video editing, 3D models, automation, pandas, matplotlib"
|
||||||
|
allowed-tools: Bash(belt *)
|
||||||
|
---
|
||||||
|
|
||||||
|
# Python Code Executor
|
||||||
|
|
||||||
|
Execute Python code in a safe, sandboxed environment with 100+ pre-installed libraries.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
> Requires inference.sh CLI (`belt`). [Install instructions](https://raw.githubusercontent.com/inference-sh/skills/refs/heads/main/cli-install.md)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
belt login
|
||||||
|
|
||||||
|
# Run Python code
|
||||||
|
belt app run infsh/python-executor --input '{
|
||||||
|
"code": "import pandas as pd\nprint(pd.__version__)"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## App Details
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| App ID | `infsh/python-executor` |
|
||||||
|
| Environment | Python 3.10, CPU-only |
|
||||||
|
| RAM | 8GB (default) / 16GB (high_memory) |
|
||||||
|
| Timeout | 1-300 seconds (default: 30) |
|
||||||
|
|
||||||
|
## Input Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": "print('Hello World!')",
|
||||||
|
"timeout": 30,
|
||||||
|
"capture_output": true,
|
||||||
|
"working_dir": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pre-installed Libraries
|
||||||
|
|
||||||
|
### Web Scraping & HTTP
|
||||||
|
- `requests`, `httpx`, `aiohttp` - HTTP clients
|
||||||
|
- `beautifulsoup4`, `lxml` - HTML/XML parsing
|
||||||
|
- `selenium`, `playwright` - Browser automation
|
||||||
|
- `scrapy` - Web scraping framework
|
||||||
|
|
||||||
|
### Data Processing
|
||||||
|
- `numpy`, `pandas`, `scipy` - Numerical computing
|
||||||
|
- `matplotlib`, `seaborn`, `plotly` - Visualization
|
||||||
|
|
||||||
|
### Image Processing
|
||||||
|
- `pillow`, `opencv-python-headless` - Image manipulation
|
||||||
|
- `scikit-image`, `imageio` - Image algorithms
|
||||||
|
|
||||||
|
### Video & Audio
|
||||||
|
- `moviepy` - Video editing
|
||||||
|
- `av` (PyAV), `ffmpeg-python` - Video processing
|
||||||
|
- `pydub` - Audio manipulation
|
||||||
|
|
||||||
|
### 3D Processing
|
||||||
|
- `trimesh`, `open3d` - 3D mesh processing
|
||||||
|
- `numpy-stl`, `meshio`, `pyvista` - 3D file formats
|
||||||
|
|
||||||
|
### Documents & Graphics
|
||||||
|
- `svgwrite`, `cairosvg` - SVG creation
|
||||||
|
- `reportlab`, `pypdf2` - PDF generation
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
### Web Scraping
|
||||||
|
|
||||||
|
```bash
|
||||||
|
belt app run infsh/python-executor --input '{
|
||||||
|
"code": "import requests\nfrom bs4 import BeautifulSoup\n\nresponse = requests.get(\"https://example.com\")\nsoup = BeautifulSoup(response.content, \"html.parser\")\nprint(soup.find(\"title\").text)"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Analysis with Visualization
|
||||||
|
|
||||||
|
```bash
|
||||||
|
belt app run infsh/python-executor --input '{
|
||||||
|
"code": "import pandas as pd\nimport matplotlib.pyplot as plt\n\ndata = {\"name\": [\"Alice\", \"Bob\"], \"sales\": [100, 150]}\ndf = pd.DataFrame(data)\n\nplt.bar(df[\"name\"], df[\"sales\"])\nplt.savefig(\"outputs/chart.png\")\nprint(\"Chart saved!\")"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Image Processing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
belt app run infsh/python-executor --input '{
|
||||||
|
"code": "from PIL import Image\nimport numpy as np\n\n# Create gradient image\narr = np.linspace(0, 255, 256*256, dtype=np.uint8).reshape(256, 256)\nimg = Image.fromarray(arr, mode=\"L\")\nimg.save(\"outputs/gradient.png\")\nprint(\"Image created!\")"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Video Creation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
belt app run infsh/python-executor --input '{
|
||||||
|
"code": "from moviepy.editor import ColorClip, TextClip, CompositeVideoClip\n\nclip = ColorClip(size=(640, 480), color=(0, 100, 200), duration=3)\ntxt = TextClip(\"Hello!\", fontsize=70, color=\"white\").set_position(\"center\").set_duration(3)\nvideo = CompositeVideoClip([clip, txt])\nvideo.write_videofile(\"outputs/hello.mp4\", fps=24)\nprint(\"Video created!\")",
|
||||||
|
"timeout": 120
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3D Model Processing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
belt app run infsh/python-executor --input '{
|
||||||
|
"code": "import trimesh\n\nsphere = trimesh.creation.icosphere(subdivisions=3, radius=1.0)\nsphere.export(\"outputs/sphere.stl\")\nprint(f\"Created sphere with {len(sphere.vertices)} vertices\")"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Calls
|
||||||
|
|
||||||
|
```bash
|
||||||
|
belt app run infsh/python-executor --input '{
|
||||||
|
"code": "import requests\nimport json\n\nresponse = requests.get(\"https://api.github.com/users/octocat\")\ndata = response.json()\nprint(json.dumps(data, indent=2))"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Output
|
||||||
|
|
||||||
|
Files saved to `outputs/` are automatically returned:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# These files will be in the response
|
||||||
|
plt.savefig('outputs/chart.png')
|
||||||
|
df.to_csv('outputs/data.csv')
|
||||||
|
video.write_videofile('outputs/video.mp4')
|
||||||
|
mesh.export('outputs/model.stl')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variants
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Default (8GB RAM)
|
||||||
|
belt app run infsh/python-executor --input input.json
|
||||||
|
|
||||||
|
# High memory (16GB RAM) for large datasets
|
||||||
|
belt app run infsh/python-executor@high_memory --input input.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
- **Web scraping** - Extract data from websites
|
||||||
|
- **Data analysis** - Process and visualize datasets
|
||||||
|
- **Image manipulation** - Resize, crop, composite images
|
||||||
|
- **Video creation** - Generate videos with text overlays
|
||||||
|
- **3D processing** - Load, transform, export 3D models
|
||||||
|
- **API integration** - Call external APIs
|
||||||
|
- **PDF generation** - Create reports and documents
|
||||||
|
- **Automation** - Run any Python script
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- **CPU-only** - No GPU/ML libraries (use dedicated AI apps for that)
|
||||||
|
- **Safe execution** - Runs in isolated subprocess
|
||||||
|
- **Non-interactive** - Use `plt.savefig()` not `plt.show()`
|
||||||
|
- **File detection** - Output files are auto-detected and returned
|
||||||
|
|
||||||
|
## Related Skills
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# AI image generation (for ML-based images)
|
||||||
|
npx skills add inference-sh/skills@ai-image-generation
|
||||||
|
|
||||||
|
# AI video generation (for ML-based videos)
|
||||||
|
npx skills add inference-sh/skills@ai-video-generation
|
||||||
|
|
||||||
|
# LLM models (for text generation)
|
||||||
|
npx skills add inference-sh/skills@llm-models
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Running Apps](https://inference.sh/docs/apps/running) - How to run apps via CLI
|
||||||
|
- [App Code](https://inference.sh/docs/extend/app-code) - Understanding app execution
|
||||||
|
- [Sandboxed Code Execution](https://inference.sh/blog/tools/sandboxed-execution) - Safe code execution for agents
|
||||||
|
|
||||||
@@ -0,0 +1,750 @@
|
|||||||
|
---
|
||||||
|
name: python-patterns
|
||||||
|
description: Pythonic idioms, PEP 8 standards, type hints, and best practices for building robust, efficient, and maintainable Python applications.
|
||||||
|
origin: ECC
|
||||||
|
---
|
||||||
|
|
||||||
|
# Python Development Patterns
|
||||||
|
|
||||||
|
Idiomatic Python patterns and best practices for building robust, efficient, and maintainable applications.
|
||||||
|
|
||||||
|
## When to Activate
|
||||||
|
|
||||||
|
- Writing new Python code
|
||||||
|
- Reviewing Python code
|
||||||
|
- Refactoring existing Python code
|
||||||
|
- Designing Python packages/modules
|
||||||
|
|
||||||
|
## Core Principles
|
||||||
|
|
||||||
|
### 1. Readability Counts
|
||||||
|
|
||||||
|
Python prioritizes readability. Code should be obvious and easy to understand.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Good: Clear and readable
|
||||||
|
def get_active_users(users: list[User]) -> list[User]:
|
||||||
|
"""Return only active users from the provided list."""
|
||||||
|
return [user for user in users if user.is_active]
|
||||||
|
|
||||||
|
|
||||||
|
# Bad: Clever but confusing
|
||||||
|
def get_active_users(u):
|
||||||
|
return [x for x in u if x.a]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Explicit is Better Than Implicit
|
||||||
|
|
||||||
|
Avoid magic; be clear about what your code does.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Good: Explicit configuration
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Bad: Hidden side effects
|
||||||
|
import some_module
|
||||||
|
some_module.setup() # What does this do?
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. EAFP - Easier to Ask Forgiveness Than Permission
|
||||||
|
|
||||||
|
Python prefers exception handling over checking conditions.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Good: EAFP style
|
||||||
|
def get_value(dictionary: dict, key: str) -> Any:
|
||||||
|
try:
|
||||||
|
return dictionary[key]
|
||||||
|
except KeyError:
|
||||||
|
return default_value
|
||||||
|
|
||||||
|
# Bad: LBYL (Look Before You Leap) style
|
||||||
|
def get_value(dictionary: dict, key: str) -> Any:
|
||||||
|
if key in dictionary:
|
||||||
|
return dictionary[key]
|
||||||
|
else:
|
||||||
|
return default_value
|
||||||
|
```
|
||||||
|
|
||||||
|
## Type Hints
|
||||||
|
|
||||||
|
### Basic Type Annotations
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
|
def process_user(
|
||||||
|
user_id: str,
|
||||||
|
data: Dict[str, Any],
|
||||||
|
active: bool = True
|
||||||
|
) -> Optional[User]:
|
||||||
|
"""Process a user and return the updated User or None."""
|
||||||
|
if not active:
|
||||||
|
return None
|
||||||
|
return User(user_id, data)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modern Type Hints (Python 3.9+)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Python 3.9+ - Use built-in types
|
||||||
|
def process_items(items: list[str]) -> dict[str, int]:
|
||||||
|
return {item: len(item) for item in items}
|
||||||
|
|
||||||
|
# Python 3.8 and earlier - Use typing module
|
||||||
|
from typing import List, Dict
|
||||||
|
|
||||||
|
def process_items(items: List[str]) -> Dict[str, int]:
|
||||||
|
return {item: len(item) for item in items}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type Aliases and TypeVar
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import TypeVar, Union
|
||||||
|
|
||||||
|
# Type alias for complex types
|
||||||
|
JSON = Union[dict[str, Any], list[Any], str, int, float, bool, None]
|
||||||
|
|
||||||
|
def parse_json(data: str) -> JSON:
|
||||||
|
return json.loads(data)
|
||||||
|
|
||||||
|
# Generic types
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
def first(items: list[T]) -> T | None:
|
||||||
|
"""Return the first item or None if list is empty."""
|
||||||
|
return items[0] if items else None
|
||||||
|
```
|
||||||
|
|
||||||
|
### Protocol-Based Duck Typing
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
class Renderable(Protocol):
|
||||||
|
def render(self) -> str:
|
||||||
|
"""Render the object to a string."""
|
||||||
|
|
||||||
|
def render_all(items: list[Renderable]) -> str:
|
||||||
|
"""Render all items that implement the Renderable protocol."""
|
||||||
|
return "\n".join(item.render() for item in items)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling Patterns
|
||||||
|
|
||||||
|
### Specific Exception Handling
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Good: Catch specific exceptions
|
||||||
|
def load_config(path: str) -> Config:
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
return Config.from_json(f.read())
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise ConfigError(f"Config file not found: {path}") from e
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
raise ConfigError(f"Invalid JSON in config: {path}") from e
|
||||||
|
|
||||||
|
# Bad: Bare except
|
||||||
|
def load_config(path: str) -> Config:
|
||||||
|
try:
|
||||||
|
with open(path) as f:
|
||||||
|
return Config.from_json(f.read())
|
||||||
|
except:
|
||||||
|
return None # Silent failure!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Exception Chaining
|
||||||
|
|
||||||
|
```python
|
||||||
|
def process_data(data: str) -> Result:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(data)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
# Chain exceptions to preserve the traceback
|
||||||
|
raise ValueError(f"Failed to parse data: {data}") from e
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Exception Hierarchy
|
||||||
|
|
||||||
|
```python
|
||||||
|
class AppError(Exception):
|
||||||
|
"""Base exception for all application errors."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ValidationError(AppError):
|
||||||
|
"""Raised when input validation fails."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
class NotFoundError(AppError):
|
||||||
|
"""Raised when a requested resource is not found."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
def get_user(user_id: str) -> User:
|
||||||
|
user = db.find_user(user_id)
|
||||||
|
if not user:
|
||||||
|
raise NotFoundError(f"User not found: {user_id}")
|
||||||
|
return user
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context Managers
|
||||||
|
|
||||||
|
### Resource Management
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Good: Using context managers
|
||||||
|
def process_file(path: str) -> str:
|
||||||
|
with open(path, 'r') as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
# Bad: Manual resource management
|
||||||
|
def process_file(path: str) -> str:
|
||||||
|
f = open(path, 'r')
|
||||||
|
try:
|
||||||
|
return f.read()
|
||||||
|
finally:
|
||||||
|
f.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom Context Managers
|
||||||
|
|
||||||
|
```python
|
||||||
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def timer(name: str):
|
||||||
|
"""Context manager to time a block of code."""
|
||||||
|
start = time.perf_counter()
|
||||||
|
yield
|
||||||
|
elapsed = time.perf_counter() - start
|
||||||
|
print(f"{name} took {elapsed:.4f} seconds")
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
with timer("data processing"):
|
||||||
|
process_large_dataset()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Context Manager Classes
|
||||||
|
|
||||||
|
```python
|
||||||
|
class DatabaseTransaction:
|
||||||
|
def __init__(self, connection):
|
||||||
|
self.connection = connection
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.connection.begin_transaction()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
if exc_type is None:
|
||||||
|
self.connection.commit()
|
||||||
|
else:
|
||||||
|
self.connection.rollback()
|
||||||
|
return False # Don't suppress exceptions
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
with DatabaseTransaction(conn):
|
||||||
|
user = conn.create_user(user_data)
|
||||||
|
conn.create_profile(user.id, profile_data)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comprehensions and Generators
|
||||||
|
|
||||||
|
### List Comprehensions
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Good: List comprehension for simple transformations
|
||||||
|
names = [user.name for user in users if user.is_active]
|
||||||
|
|
||||||
|
# Bad: Manual loop
|
||||||
|
names = []
|
||||||
|
for user in users:
|
||||||
|
if user.is_active:
|
||||||
|
names.append(user.name)
|
||||||
|
|
||||||
|
# Complex comprehensions should be expanded
|
||||||
|
# Bad: Too complex
|
||||||
|
result = [x * 2 for x in items if x > 0 if x % 2 == 0]
|
||||||
|
|
||||||
|
# Good: Use a generator function
|
||||||
|
def filter_and_transform(items: Iterable[int]) -> list[int]:
|
||||||
|
result = []
|
||||||
|
for x in items:
|
||||||
|
if x > 0 and x % 2 == 0:
|
||||||
|
result.append(x * 2)
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generator Expressions
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Good: Generator for lazy evaluation
|
||||||
|
total = sum(x * x for x in range(1_000_000))
|
||||||
|
|
||||||
|
# Bad: Creates large intermediate list
|
||||||
|
total = sum([x * x for x in range(1_000_000)])
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generator Functions
|
||||||
|
|
||||||
|
```python
|
||||||
|
def read_large_file(path: str) -> Iterator[str]:
|
||||||
|
"""Read a large file line by line."""
|
||||||
|
with open(path) as f:
|
||||||
|
for line in f:
|
||||||
|
yield line.strip()
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
for line in read_large_file("huge.txt"):
|
||||||
|
process(line)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Classes and Named Tuples
|
||||||
|
|
||||||
|
### Data Classes
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class User:
|
||||||
|
"""User entity with automatic __init__, __repr__, and __eq__."""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
created_at: datetime = field(default_factory=datetime.now)
|
||||||
|
is_active: bool = True
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
user = User(
|
||||||
|
id="123",
|
||||||
|
name="Alice",
|
||||||
|
email="alice@example.com"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data Classes with Validation
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class User:
|
||||||
|
email: str
|
||||||
|
age: int
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
# Validate email format
|
||||||
|
if "@" not in self.email:
|
||||||
|
raise ValueError(f"Invalid email: {self.email}")
|
||||||
|
# Validate age range
|
||||||
|
if self.age < 0 or self.age > 150:
|
||||||
|
raise ValueError(f"Invalid age: {self.age}")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Named Tuples
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
class Point(NamedTuple):
|
||||||
|
"""Immutable 2D point."""
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
|
||||||
|
def distance(self, other: 'Point') -> float:
|
||||||
|
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
|
||||||
|
|
||||||
|
# Usage
|
||||||
|
p1 = Point(0, 0)
|
||||||
|
p2 = Point(3, 4)
|
||||||
|
print(p1.distance(p2)) # 5.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Decorators
|
||||||
|
|
||||||
|
### Function Decorators
|
||||||
|
|
||||||
|
```python
|
||||||
|
import functools
|
||||||
|
import time
|
||||||
|
|
||||||
|
def timer(func: Callable) -> Callable:
|
||||||
|
"""Decorator to time function execution."""
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
start = time.perf_counter()
|
||||||
|
result = func(*args, **kwargs)
|
||||||
|
elapsed = time.perf_counter() - start
|
||||||
|
print(f"{func.__name__} took {elapsed:.4f}s")
|
||||||
|
return result
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
@timer
|
||||||
|
def slow_function():
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
# slow_function() prints: slow_function took 1.0012s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Parameterized Decorators
|
||||||
|
|
||||||
|
```python
|
||||||
|
def repeat(times: int):
|
||||||
|
"""Decorator to repeat a function multiple times."""
|
||||||
|
def decorator(func: Callable) -> Callable:
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
results = []
|
||||||
|
for _ in range(times):
|
||||||
|
results.append(func(*args, **kwargs))
|
||||||
|
return results
|
||||||
|
return wrapper
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
@repeat(times=3)
|
||||||
|
def greet(name: str) -> str:
|
||||||
|
return f"Hello, {name}!"
|
||||||
|
|
||||||
|
# greet("Alice") returns ["Hello, Alice!", "Hello, Alice!", "Hello, Alice!"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Class-Based Decorators
|
||||||
|
|
||||||
|
```python
|
||||||
|
class CountCalls:
|
||||||
|
"""Decorator that counts how many times a function is called."""
|
||||||
|
def __init__(self, func: Callable):
|
||||||
|
functools.update_wrapper(self, func)
|
||||||
|
self.func = func
|
||||||
|
self.count = 0
|
||||||
|
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
self.count += 1
|
||||||
|
print(f"{self.func.__name__} has been called {self.count} times")
|
||||||
|
return self.func(*args, **kwargs)
|
||||||
|
|
||||||
|
@CountCalls
|
||||||
|
def process():
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Each call to process() prints the call count
|
||||||
|
```
|
||||||
|
|
||||||
|
## Concurrency Patterns
|
||||||
|
|
||||||
|
### Threading for I/O-Bound Tasks
|
||||||
|
|
||||||
|
```python
|
||||||
|
import concurrent.futures
|
||||||
|
import threading
|
||||||
|
|
||||||
|
def fetch_url(url: str) -> str:
|
||||||
|
"""Fetch a URL (I/O-bound operation)."""
|
||||||
|
import urllib.request
|
||||||
|
with urllib.request.urlopen(url) as response:
|
||||||
|
return response.read().decode()
|
||||||
|
|
||||||
|
def fetch_all_urls(urls: list[str]) -> dict[str, str]:
|
||||||
|
"""Fetch multiple URLs concurrently using threads."""
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
|
||||||
|
future_to_url = {executor.submit(fetch_url, url): url for url in urls}
|
||||||
|
results = {}
|
||||||
|
for future in concurrent.futures.as_completed(future_to_url):
|
||||||
|
url = future_to_url[future]
|
||||||
|
try:
|
||||||
|
results[url] = future.result()
|
||||||
|
except Exception as e:
|
||||||
|
results[url] = f"Error: {e}"
|
||||||
|
return results
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multiprocessing for CPU-Bound Tasks
|
||||||
|
|
||||||
|
```python
|
||||||
|
def process_data(data: list[int]) -> int:
|
||||||
|
"""CPU-intensive computation."""
|
||||||
|
return sum(x ** 2 for x in data)
|
||||||
|
|
||||||
|
def process_all(datasets: list[list[int]]) -> list[int]:
|
||||||
|
"""Process multiple datasets using multiple processes."""
|
||||||
|
with concurrent.futures.ProcessPoolExecutor() as executor:
|
||||||
|
results = list(executor.map(process_data, datasets))
|
||||||
|
return results
|
||||||
|
```
|
||||||
|
|
||||||
|
### Async/Await for Concurrent I/O
|
||||||
|
|
||||||
|
```python
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def fetch_async(url: str) -> str:
|
||||||
|
"""Fetch a URL asynchronously."""
|
||||||
|
import aiohttp
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url) as response:
|
||||||
|
return await response.text()
|
||||||
|
|
||||||
|
async def fetch_all(urls: list[str]) -> dict[str, str]:
|
||||||
|
"""Fetch multiple URLs concurrently."""
|
||||||
|
tasks = [fetch_async(url) for url in urls]
|
||||||
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
return dict(zip(urls, results))
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package Organization
|
||||||
|
|
||||||
|
### Standard Project Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
myproject/
|
||||||
|
├── src/
|
||||||
|
│ └── mypackage/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── main.py
|
||||||
|
│ ├── api/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ └── routes.py
|
||||||
|
│ ├── models/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ └── user.py
|
||||||
|
│ └── utils/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ └── helpers.py
|
||||||
|
├── tests/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── conftest.py
|
||||||
|
│ ├── test_api.py
|
||||||
|
│ └── test_models.py
|
||||||
|
├── pyproject.toml
|
||||||
|
├── README.md
|
||||||
|
└── .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
### Import Conventions
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Good: Import order - stdlib, third-party, local
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from mypackage.models import User
|
||||||
|
from mypackage.utils import format_name
|
||||||
|
|
||||||
|
# Good: Use isort for automatic import sorting
|
||||||
|
# pip install isort
|
||||||
|
```
|
||||||
|
|
||||||
|
### __init__.py for Package Exports
|
||||||
|
|
||||||
|
```python
|
||||||
|
# mypackage/__init__.py
|
||||||
|
"""mypackage - A sample Python package."""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
|
|
||||||
|
# Export main classes/functions at package level
|
||||||
|
from mypackage.models import User, Post
|
||||||
|
from mypackage.utils import format_name
|
||||||
|
|
||||||
|
__all__ = ["User", "Post", "format_name"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Memory and Performance
|
||||||
|
|
||||||
|
### Using __slots__ for Memory Efficiency
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Bad: Regular class uses __dict__ (more memory)
|
||||||
|
class Point:
|
||||||
|
def __init__(self, x: float, y: float):
|
||||||
|
self.x = x
|
||||||
|
self.y = y
|
||||||
|
|
||||||
|
# Good: __slots__ reduces memory usage
|
||||||
|
class Point:
|
||||||
|
__slots__ = ['x', 'y']
|
||||||
|
|
||||||
|
def __init__(self, x: float, y: float):
|
||||||
|
self.x = x
|
||||||
|
self.y = y
|
||||||
|
```
|
||||||
|
|
||||||
|
### Generator for Large Data
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Bad: Returns full list in memory
|
||||||
|
def read_lines(path: str) -> list[str]:
|
||||||
|
with open(path) as f:
|
||||||
|
return [line.strip() for line in f]
|
||||||
|
|
||||||
|
# Good: Yields lines one at a time
|
||||||
|
def read_lines(path: str) -> Iterator[str]:
|
||||||
|
with open(path) as f:
|
||||||
|
for line in f:
|
||||||
|
yield line.strip()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Avoid String Concatenation in Loops
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Bad: O(n²) due to string immutability
|
||||||
|
result = ""
|
||||||
|
for item in items:
|
||||||
|
result += str(item)
|
||||||
|
|
||||||
|
# Good: O(n) using join
|
||||||
|
result = "".join(str(item) for item in items)
|
||||||
|
|
||||||
|
# Good: Using StringIO for building
|
||||||
|
from io import StringIO
|
||||||
|
|
||||||
|
buffer = StringIO()
|
||||||
|
for item in items:
|
||||||
|
buffer.write(str(item))
|
||||||
|
result = buffer.getvalue()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Python Tooling Integration
|
||||||
|
|
||||||
|
### Essential Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Code formatting
|
||||||
|
black .
|
||||||
|
isort .
|
||||||
|
|
||||||
|
# Linting
|
||||||
|
ruff check .
|
||||||
|
pylint mypackage/
|
||||||
|
|
||||||
|
# Type checking
|
||||||
|
mypy .
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
pytest --cov=mypackage --cov-report=html
|
||||||
|
|
||||||
|
# Security scanning
|
||||||
|
bandit -r .
|
||||||
|
|
||||||
|
# Dependency management
|
||||||
|
pip-audit
|
||||||
|
safety check
|
||||||
|
```
|
||||||
|
|
||||||
|
### pyproject.toml Configuration
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project]
|
||||||
|
name = "mypackage"
|
||||||
|
version = "1.0.0"
|
||||||
|
requires-python = ">=3.9"
|
||||||
|
dependencies = [
|
||||||
|
"requests>=2.31.0",
|
||||||
|
"pydantic>=2.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.4.0",
|
||||||
|
"pytest-cov>=4.1.0",
|
||||||
|
"black>=23.0.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
"mypy>=1.5.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 88
|
||||||
|
target-version = ['py39']
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 88
|
||||||
|
select = ["E", "F", "I", "N", "W"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.9"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "--cov=mypackage --cov-report=term-missing"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Reference: Python Idioms
|
||||||
|
|
||||||
|
| Idiom | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| EAFP | Easier to Ask Forgiveness than Permission |
|
||||||
|
| Context managers | Use `with` for resource management |
|
||||||
|
| List comprehensions | For simple transformations |
|
||||||
|
| Generators | For lazy evaluation and large datasets |
|
||||||
|
| Type hints | Annotate function signatures |
|
||||||
|
| Dataclasses | For data containers with auto-generated methods |
|
||||||
|
| `__slots__` | For memory optimization |
|
||||||
|
| f-strings | For string formatting (Python 3.6+) |
|
||||||
|
| `pathlib.Path` | For path operations (Python 3.4+) |
|
||||||
|
| `enumerate` | For index-element pairs in loops |
|
||||||
|
|
||||||
|
## Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Bad: Mutable default arguments
|
||||||
|
def append_to(item, items=[]):
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
|
||||||
|
# Good: Use None and create new list
|
||||||
|
def append_to(item, items=None):
|
||||||
|
if items is None:
|
||||||
|
items = []
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
|
||||||
|
# Bad: Checking type with type()
|
||||||
|
if type(obj) == list:
|
||||||
|
process(obj)
|
||||||
|
|
||||||
|
# Good: Use isinstance
|
||||||
|
if isinstance(obj, list):
|
||||||
|
process(obj)
|
||||||
|
|
||||||
|
# Bad: Comparing to None with ==
|
||||||
|
if value == None:
|
||||||
|
process()
|
||||||
|
|
||||||
|
# Good: Use is
|
||||||
|
if value is None:
|
||||||
|
process()
|
||||||
|
|
||||||
|
# Bad: from module import *
|
||||||
|
from os.path import *
|
||||||
|
|
||||||
|
# Good: Explicit imports
|
||||||
|
from os.path import join, exists
|
||||||
|
|
||||||
|
# Bad: Bare except
|
||||||
|
try:
|
||||||
|
risky_operation()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Good: Specific exception
|
||||||
|
try:
|
||||||
|
risky_operation()
|
||||||
|
except SpecificError as e:
|
||||||
|
logger.error(f"Operation failed: {e}")
|
||||||
|
```
|
||||||
|
|
||||||
|
__Remember__: Python code should be readable, explicit, and follow the principle of least surprise. When in doubt, prioritize clarity over cleverness.
|
||||||
@@ -0,0 +1,622 @@
|
|||||||
|
---
|
||||||
|
name: python-testing-patterns
|
||||||
|
description: Implement comprehensive testing strategies with pytest, fixtures, mocking, and test-driven development. Use when writing Python tests, setting up test suites, or implementing testing best practices.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Python Testing Patterns
|
||||||
|
|
||||||
|
Comprehensive guide to implementing robust testing strategies in Python using pytest, fixtures, mocking, parameterization, and test-driven development practices.
|
||||||
|
|
||||||
|
## When to Use This Skill
|
||||||
|
|
||||||
|
- Writing unit tests for Python code
|
||||||
|
- Setting up test suites and test infrastructure
|
||||||
|
- Implementing test-driven development (TDD)
|
||||||
|
- Creating integration tests for APIs and services
|
||||||
|
- Mocking external dependencies and services
|
||||||
|
- Testing async code and concurrent operations
|
||||||
|
- Setting up continuous testing in CI/CD
|
||||||
|
- Implementing property-based testing
|
||||||
|
- Testing database operations
|
||||||
|
- Debugging failing tests
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
### 1. Test Types
|
||||||
|
|
||||||
|
- **Unit Tests**: Test individual functions/classes in isolation
|
||||||
|
- **Integration Tests**: Test interaction between components
|
||||||
|
- **Functional Tests**: Test complete features end-to-end
|
||||||
|
- **Performance Tests**: Measure speed and resource usage
|
||||||
|
|
||||||
|
### 2. Test Structure (AAA Pattern)
|
||||||
|
|
||||||
|
- **Arrange**: Set up test data and preconditions
|
||||||
|
- **Act**: Execute the code under test
|
||||||
|
- **Assert**: Verify the results
|
||||||
|
|
||||||
|
### 3. Test Coverage
|
||||||
|
|
||||||
|
- Measure what code is exercised by tests
|
||||||
|
- Identify untested code paths
|
||||||
|
- Aim for meaningful coverage, not just high percentages
|
||||||
|
|
||||||
|
### 4. Test Isolation
|
||||||
|
|
||||||
|
- Tests should be independent
|
||||||
|
- No shared state between tests
|
||||||
|
- Each test should clean up after itself
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_example.py
|
||||||
|
def add(a, b):
|
||||||
|
return a + b
|
||||||
|
|
||||||
|
def test_add():
|
||||||
|
"""Basic test example."""
|
||||||
|
result = add(2, 3)
|
||||||
|
assert result == 5
|
||||||
|
|
||||||
|
def test_add_negative():
|
||||||
|
"""Test with negative numbers."""
|
||||||
|
assert add(-1, 1) == 0
|
||||||
|
|
||||||
|
# Run with: pytest test_example.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fundamental Patterns
|
||||||
|
|
||||||
|
### Pattern 1: Basic pytest Tests
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_calculator.py
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
class Calculator:
|
||||||
|
"""Simple calculator for testing."""
|
||||||
|
|
||||||
|
def add(self, a: float, b: float) -> float:
|
||||||
|
return a + b
|
||||||
|
|
||||||
|
def subtract(self, a: float, b: float) -> float:
|
||||||
|
return a - b
|
||||||
|
|
||||||
|
def multiply(self, a: float, b: float) -> float:
|
||||||
|
return a * b
|
||||||
|
|
||||||
|
def divide(self, a: float, b: float) -> float:
|
||||||
|
if b == 0:
|
||||||
|
raise ValueError("Cannot divide by zero")
|
||||||
|
return a / b
|
||||||
|
|
||||||
|
|
||||||
|
def test_addition():
|
||||||
|
"""Test addition."""
|
||||||
|
calc = Calculator()
|
||||||
|
assert calc.add(2, 3) == 5
|
||||||
|
assert calc.add(-1, 1) == 0
|
||||||
|
assert calc.add(0, 0) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_subtraction():
|
||||||
|
"""Test subtraction."""
|
||||||
|
calc = Calculator()
|
||||||
|
assert calc.subtract(5, 3) == 2
|
||||||
|
assert calc.subtract(0, 5) == -5
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiplication():
|
||||||
|
"""Test multiplication."""
|
||||||
|
calc = Calculator()
|
||||||
|
assert calc.multiply(3, 4) == 12
|
||||||
|
assert calc.multiply(0, 5) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_division():
|
||||||
|
"""Test division."""
|
||||||
|
calc = Calculator()
|
||||||
|
assert calc.divide(6, 3) == 2
|
||||||
|
assert calc.divide(5, 2) == 2.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_division_by_zero():
|
||||||
|
"""Test division by zero raises error."""
|
||||||
|
calc = Calculator()
|
||||||
|
with pytest.raises(ValueError, match="Cannot divide by zero"):
|
||||||
|
calc.divide(5, 0)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Fixtures for Setup and Teardown
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_database.py
|
||||||
|
import pytest
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
"""Simple database class."""
|
||||||
|
|
||||||
|
def __init__(self, connection_string: str):
|
||||||
|
self.connection_string = connection_string
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
"""Connect to database."""
|
||||||
|
self.connected = True
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
"""Disconnect from database."""
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
def query(self, sql: str) -> list:
|
||||||
|
"""Execute query."""
|
||||||
|
if not self.connected:
|
||||||
|
raise RuntimeError("Not connected")
|
||||||
|
return [{"id": 1, "name": "Test"}]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def db() -> Generator[Database, None, None]:
|
||||||
|
"""Fixture that provides connected database."""
|
||||||
|
# Setup
|
||||||
|
database = Database("sqlite:///:memory:")
|
||||||
|
database.connect()
|
||||||
|
|
||||||
|
# Provide to test
|
||||||
|
yield database
|
||||||
|
|
||||||
|
# Teardown
|
||||||
|
database.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_query(db):
|
||||||
|
"""Test database query with fixture."""
|
||||||
|
results = db.query("SELECT * FROM users")
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["name"] == "Test"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def app_config():
|
||||||
|
"""Session-scoped fixture - created once per test session."""
|
||||||
|
return {
|
||||||
|
"database_url": "postgresql://localhost/test",
|
||||||
|
"api_key": "test-key",
|
||||||
|
"debug": True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def api_client(app_config):
|
||||||
|
"""Module-scoped fixture - created once per test module."""
|
||||||
|
# Setup expensive resource
|
||||||
|
client = {"config": app_config, "session": "active"}
|
||||||
|
yield client
|
||||||
|
# Cleanup
|
||||||
|
client["session"] = "closed"
|
||||||
|
|
||||||
|
|
||||||
|
def test_api_client(api_client):
|
||||||
|
"""Test using api client fixture."""
|
||||||
|
assert api_client["session"] == "active"
|
||||||
|
assert api_client["config"]["debug"] is True
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: Parameterized Tests
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_validation.py
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
def is_valid_email(email: str) -> bool:
|
||||||
|
"""Check if email is valid."""
|
||||||
|
return "@" in email and "." in email.split("@")[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("email,expected", [
|
||||||
|
("user@example.com", True),
|
||||||
|
("test.user@domain.co.uk", True),
|
||||||
|
("invalid.email", False),
|
||||||
|
("@example.com", False),
|
||||||
|
("user@domain", False),
|
||||||
|
("", False),
|
||||||
|
])
|
||||||
|
def test_email_validation(email, expected):
|
||||||
|
"""Test email validation with various inputs."""
|
||||||
|
assert is_valid_email(email) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("a,b,expected", [
|
||||||
|
(2, 3, 5),
|
||||||
|
(0, 0, 0),
|
||||||
|
(-1, 1, 0),
|
||||||
|
(100, 200, 300),
|
||||||
|
(-5, -5, -10),
|
||||||
|
])
|
||||||
|
def test_addition_parameterized(a, b, expected):
|
||||||
|
"""Test addition with multiple parameter sets."""
|
||||||
|
from test_calculator import Calculator
|
||||||
|
calc = Calculator()
|
||||||
|
assert calc.add(a, b) == expected
|
||||||
|
|
||||||
|
|
||||||
|
# Using pytest.param for special cases
|
||||||
|
@pytest.mark.parametrize("value,expected", [
|
||||||
|
pytest.param(1, True, id="positive"),
|
||||||
|
pytest.param(0, False, id="zero"),
|
||||||
|
pytest.param(-1, False, id="negative"),
|
||||||
|
])
|
||||||
|
def test_is_positive(value, expected):
|
||||||
|
"""Test with custom test IDs."""
|
||||||
|
assert (value > 0) == expected
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Mocking with unittest.mock
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_api_client.py
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import Mock, patch, MagicMock
|
||||||
|
import requests
|
||||||
|
|
||||||
|
class APIClient:
|
||||||
|
"""Simple API client."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str):
|
||||||
|
self.base_url = base_url
|
||||||
|
|
||||||
|
def get_user(self, user_id: int) -> dict:
|
||||||
|
"""Fetch user from API."""
|
||||||
|
response = requests.get(f"{self.base_url}/users/{user_id}")
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def create_user(self, data: dict) -> dict:
|
||||||
|
"""Create new user."""
|
||||||
|
response = requests.post(f"{self.base_url}/users", json=data)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_user_success():
|
||||||
|
"""Test successful API call with mock."""
|
||||||
|
client = APIClient("https://api.example.com")
|
||||||
|
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.json.return_value = {"id": 1, "name": "John Doe"}
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
|
||||||
|
with patch("requests.get", return_value=mock_response) as mock_get:
|
||||||
|
user = client.get_user(1)
|
||||||
|
|
||||||
|
assert user["id"] == 1
|
||||||
|
assert user["name"] == "John Doe"
|
||||||
|
mock_get.assert_called_once_with("https://api.example.com/users/1")
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_user_not_found():
|
||||||
|
"""Test API call with 404 error."""
|
||||||
|
client = APIClient("https://api.example.com")
|
||||||
|
|
||||||
|
mock_response = Mock()
|
||||||
|
mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
|
||||||
|
|
||||||
|
with patch("requests.get", return_value=mock_response):
|
||||||
|
with pytest.raises(requests.HTTPError):
|
||||||
|
client.get_user(999)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("requests.post")
|
||||||
|
def test_create_user(mock_post):
|
||||||
|
"""Test user creation with decorator syntax."""
|
||||||
|
client = APIClient("https://api.example.com")
|
||||||
|
|
||||||
|
mock_post.return_value.json.return_value = {"id": 2, "name": "Jane Doe"}
|
||||||
|
mock_post.return_value.raise_for_status.return_value = None
|
||||||
|
|
||||||
|
user_data = {"name": "Jane Doe", "email": "jane@example.com"}
|
||||||
|
result = client.create_user(user_data)
|
||||||
|
|
||||||
|
assert result["id"] == 2
|
||||||
|
mock_post.assert_called_once()
|
||||||
|
call_args = mock_post.call_args
|
||||||
|
assert call_args.kwargs["json"] == user_data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 5: Testing Exceptions
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_exceptions.py
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
def divide(a: float, b: float) -> float:
|
||||||
|
"""Divide a by b."""
|
||||||
|
if b == 0:
|
||||||
|
raise ZeroDivisionError("Division by zero")
|
||||||
|
if not isinstance(a, (int, float)) or not isinstance(b, (int, float)):
|
||||||
|
raise TypeError("Arguments must be numbers")
|
||||||
|
return a / b
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_division():
|
||||||
|
"""Test exception is raised for division by zero."""
|
||||||
|
with pytest.raises(ZeroDivisionError):
|
||||||
|
divide(10, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zero_division_with_message():
|
||||||
|
"""Test exception message."""
|
||||||
|
with pytest.raises(ZeroDivisionError, match="Division by zero"):
|
||||||
|
divide(5, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_error():
|
||||||
|
"""Test type error exception."""
|
||||||
|
with pytest.raises(TypeError, match="must be numbers"):
|
||||||
|
divide("10", 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exception_info():
|
||||||
|
"""Test accessing exception info."""
|
||||||
|
with pytest.raises(ValueError) as exc_info:
|
||||||
|
int("not a number")
|
||||||
|
|
||||||
|
assert "invalid literal" in str(exc_info.value)
|
||||||
|
```
|
||||||
|
|
||||||
|
For advanced patterns including async testing, monkeypatching, temporary files, conftest setup, property-based testing, database testing, CI/CD integration, and configuration files, see [references/advanced-patterns.md](references/advanced-patterns.md)
|
||||||
|
|
||||||
|
## Test Design Principles
|
||||||
|
|
||||||
|
### One Behavior Per Test
|
||||||
|
|
||||||
|
Each test should verify exactly one behavior. This makes failures easy to diagnose and tests easy to maintain.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# BAD - testing multiple behaviors
|
||||||
|
def test_user_service():
|
||||||
|
user = service.create_user(data)
|
||||||
|
assert user.id is not None
|
||||||
|
assert user.email == data["email"]
|
||||||
|
updated = service.update_user(user.id, {"name": "New"})
|
||||||
|
assert updated.name == "New"
|
||||||
|
|
||||||
|
# GOOD - focused tests
|
||||||
|
def test_create_user_assigns_id():
|
||||||
|
user = service.create_user(data)
|
||||||
|
assert user.id is not None
|
||||||
|
|
||||||
|
def test_create_user_stores_email():
|
||||||
|
user = service.create_user(data)
|
||||||
|
assert user.email == data["email"]
|
||||||
|
|
||||||
|
def test_update_user_changes_name():
|
||||||
|
user = service.create_user(data)
|
||||||
|
updated = service.update_user(user.id, {"name": "New"})
|
||||||
|
assert updated.name == "New"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Error Paths
|
||||||
|
|
||||||
|
Always test failure cases, not just happy paths.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_get_user_raises_not_found():
|
||||||
|
with pytest.raises(UserNotFoundError) as exc_info:
|
||||||
|
service.get_user("nonexistent-id")
|
||||||
|
|
||||||
|
assert "nonexistent-id" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_create_user_rejects_invalid_email():
|
||||||
|
with pytest.raises(ValueError, match="Invalid email format"):
|
||||||
|
service.create_user({"email": "not-an-email"})
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Best Practices
|
||||||
|
|
||||||
|
### Test Organization
|
||||||
|
|
||||||
|
```python
|
||||||
|
# tests/
|
||||||
|
# __init__.py
|
||||||
|
# conftest.py # Shared fixtures
|
||||||
|
# test_unit/ # Unit tests
|
||||||
|
# test_models.py
|
||||||
|
# test_utils.py
|
||||||
|
# test_integration/ # Integration tests
|
||||||
|
# test_api.py
|
||||||
|
# test_database.py
|
||||||
|
# test_e2e/ # End-to-end tests
|
||||||
|
# test_workflows.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Naming Convention
|
||||||
|
|
||||||
|
A common pattern: `test_<unit>_<scenario>_<expected_outcome>`. Adapt to your team's preferences.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Pattern: test_<unit>_<scenario>_<expected>
|
||||||
|
def test_create_user_with_valid_data_returns_user():
|
||||||
|
...
|
||||||
|
|
||||||
|
def test_create_user_with_duplicate_email_raises_conflict():
|
||||||
|
...
|
||||||
|
|
||||||
|
def test_get_user_with_unknown_id_returns_none():
|
||||||
|
...
|
||||||
|
|
||||||
|
# Good test names - clear and descriptive
|
||||||
|
def test_user_creation_with_valid_data():
|
||||||
|
"""Clear name describes what is being tested."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_login_fails_with_invalid_password():
|
||||||
|
"""Name describes expected behavior."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_api_returns_404_for_missing_resource():
|
||||||
|
"""Specific about inputs and expected outcomes."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Bad test names - avoid these
|
||||||
|
def test_1(): # Not descriptive
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_user(): # Too vague
|
||||||
|
pass
|
||||||
|
|
||||||
|
def test_function(): # Doesn't explain what's tested
|
||||||
|
pass
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Retry Behavior
|
||||||
|
|
||||||
|
Verify that retry logic works correctly using mock side effects.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
def test_retries_on_transient_error():
|
||||||
|
"""Test that service retries on transient failures."""
|
||||||
|
client = Mock()
|
||||||
|
# Fail twice, then succeed
|
||||||
|
client.request.side_effect = [
|
||||||
|
ConnectionError("Failed"),
|
||||||
|
ConnectionError("Failed"),
|
||||||
|
{"status": "ok"},
|
||||||
|
]
|
||||||
|
|
||||||
|
service = ServiceWithRetry(client, max_retries=3)
|
||||||
|
result = service.fetch()
|
||||||
|
|
||||||
|
assert result == {"status": "ok"}
|
||||||
|
assert client.request.call_count == 3
|
||||||
|
|
||||||
|
def test_gives_up_after_max_retries():
|
||||||
|
"""Test that service stops retrying after max attempts."""
|
||||||
|
client = Mock()
|
||||||
|
client.request.side_effect = ConnectionError("Failed")
|
||||||
|
|
||||||
|
service = ServiceWithRetry(client, max_retries=3)
|
||||||
|
|
||||||
|
with pytest.raises(ConnectionError):
|
||||||
|
service.fetch()
|
||||||
|
|
||||||
|
assert client.request.call_count == 3
|
||||||
|
|
||||||
|
def test_does_not_retry_on_permanent_error():
|
||||||
|
"""Test that permanent errors are not retried."""
|
||||||
|
client = Mock()
|
||||||
|
client.request.side_effect = ValueError("Invalid input")
|
||||||
|
|
||||||
|
service = ServiceWithRetry(client, max_retries=3)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
service.fetch()
|
||||||
|
|
||||||
|
# Only called once - no retry for ValueError
|
||||||
|
assert client.request.call_count == 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mocking Time with Freezegun
|
||||||
|
|
||||||
|
Use freezegun to control time in tests for predictable time-dependent behavior.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from freezegun import freeze_time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
@freeze_time("2026-01-15 10:00:00")
|
||||||
|
def test_token_expiry():
|
||||||
|
"""Test token expires at correct time."""
|
||||||
|
token = create_token(expires_in_seconds=3600)
|
||||||
|
assert token.expires_at == datetime(2026, 1, 15, 11, 0, 0)
|
||||||
|
|
||||||
|
@freeze_time("2026-01-15 10:00:00")
|
||||||
|
def test_is_expired_returns_false_before_expiry():
|
||||||
|
"""Test token is not expired when within validity period."""
|
||||||
|
token = create_token(expires_in_seconds=3600)
|
||||||
|
assert not token.is_expired()
|
||||||
|
|
||||||
|
@freeze_time("2026-01-15 12:00:00")
|
||||||
|
def test_is_expired_returns_true_after_expiry():
|
||||||
|
"""Test token is expired after validity period."""
|
||||||
|
token = Token(expires_at=datetime(2026, 1, 15, 11, 30, 0))
|
||||||
|
assert token.is_expired()
|
||||||
|
|
||||||
|
def test_with_time_travel():
|
||||||
|
"""Test behavior across time using freeze_time context."""
|
||||||
|
with freeze_time("2026-01-01") as frozen_time:
|
||||||
|
item = create_item()
|
||||||
|
assert item.created_at == datetime(2026, 1, 1)
|
||||||
|
|
||||||
|
# Move forward in time
|
||||||
|
frozen_time.move_to("2026-01-15")
|
||||||
|
assert item.age_days == 14
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test Markers
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_markers.py
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
@pytest.mark.slow
|
||||||
|
def test_slow_operation():
|
||||||
|
"""Mark slow tests."""
|
||||||
|
import time
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
def test_database_integration():
|
||||||
|
"""Mark integration tests."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skip(reason="Feature not implemented yet")
|
||||||
|
def test_future_feature():
|
||||||
|
"""Skip tests temporarily."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(os.name == "nt", reason="Unix only test")
|
||||||
|
def test_unix_specific():
|
||||||
|
"""Conditional skip."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.xfail(reason="Known bug #123")
|
||||||
|
def test_known_bug():
|
||||||
|
"""Mark expected failures."""
|
||||||
|
assert False
|
||||||
|
|
||||||
|
|
||||||
|
# Run with:
|
||||||
|
# pytest -m slow # Run only slow tests
|
||||||
|
# pytest -m "not slow" # Skip slow tests
|
||||||
|
# pytest -m integration # Run integration tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coverage Reporting
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install coverage
|
||||||
|
pip install pytest-cov
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
pytest --cov=myapp tests/
|
||||||
|
|
||||||
|
# Generate HTML report
|
||||||
|
pytest --cov=myapp --cov-report=html tests/
|
||||||
|
|
||||||
|
# Fail if coverage below threshold
|
||||||
|
pytest --cov=myapp --cov-fail-under=80 tests/
|
||||||
|
|
||||||
|
# Show missing lines
|
||||||
|
pytest --cov=myapp --cov-report=term-missing tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
For advanced patterns (async testing, monkeypatching, property-based testing, database testing, CI/CD integration, and configuration), see [references/advanced-patterns.md](references/advanced-patterns.md)
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
# Python Testing Patterns — Advanced Reference
|
||||||
|
|
||||||
|
Advanced testing patterns including async code, monkeypatching, temporary files, conftest setup, property-based testing, database testing, CI/CD integration, and configuration.
|
||||||
|
|
||||||
|
## Pattern 6: Testing Async Code
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_async.py
|
||||||
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
async def fetch_data(url: str) -> dict:
|
||||||
|
"""Fetch data asynchronously."""
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
return {"url": url, "data": "result"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetch_data():
|
||||||
|
"""Test async function."""
|
||||||
|
result = await fetch_data("https://api.example.com")
|
||||||
|
assert result["url"] == "https://api.example.com"
|
||||||
|
assert "data" in result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_concurrent_fetches():
|
||||||
|
"""Test concurrent async operations."""
|
||||||
|
urls = ["url1", "url2", "url3"]
|
||||||
|
tasks = [fetch_data(url) for url in urls]
|
||||||
|
results = await asyncio.gather(*tasks)
|
||||||
|
|
||||||
|
assert len(results) == 3
|
||||||
|
assert all("data" in r for r in results)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def async_client():
|
||||||
|
"""Async fixture."""
|
||||||
|
client = {"connected": True}
|
||||||
|
yield client
|
||||||
|
client["connected"] = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_with_async_fixture(async_client):
|
||||||
|
"""Test using async fixture."""
|
||||||
|
assert async_client["connected"] is True
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pattern 7: Monkeypatch for Testing
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_environment.py
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
def get_database_url() -> str:
|
||||||
|
"""Get database URL from environment."""
|
||||||
|
return os.environ.get("DATABASE_URL", "sqlite:///:memory:")
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_url_default():
|
||||||
|
"""Test default database URL."""
|
||||||
|
# Will use actual environment variable if set
|
||||||
|
url = get_database_url()
|
||||||
|
assert url
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_url_custom(monkeypatch):
|
||||||
|
"""Test custom database URL with monkeypatch."""
|
||||||
|
monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/test")
|
||||||
|
assert get_database_url() == "postgresql://localhost/test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_database_url_not_set(monkeypatch):
|
||||||
|
"""Test when env var is not set."""
|
||||||
|
monkeypatch.delenv("DATABASE_URL", raising=False)
|
||||||
|
assert get_database_url() == "sqlite:///:memory:"
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
"""Configuration class."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.api_key = "production-key"
|
||||||
|
|
||||||
|
def get_api_key(self):
|
||||||
|
return self.api_key
|
||||||
|
|
||||||
|
|
||||||
|
def test_monkeypatch_attribute(monkeypatch):
|
||||||
|
"""Test monkeypatching object attributes."""
|
||||||
|
config = Config()
|
||||||
|
monkeypatch.setattr(config, "api_key", "test-key")
|
||||||
|
assert config.get_api_key() == "test-key"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pattern 8: Temporary Files and Directories
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_file_operations.py
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def save_data(filepath: Path, data: str):
|
||||||
|
"""Save data to file."""
|
||||||
|
filepath.write_text(data)
|
||||||
|
|
||||||
|
|
||||||
|
def load_data(filepath: Path) -> str:
|
||||||
|
"""Load data from file."""
|
||||||
|
return filepath.read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_operations(tmp_path):
|
||||||
|
"""Test file operations with temporary directory."""
|
||||||
|
# tmp_path is a pathlib.Path object
|
||||||
|
test_file = tmp_path / "test_data.txt"
|
||||||
|
|
||||||
|
# Save data
|
||||||
|
save_data(test_file, "Hello, World!")
|
||||||
|
|
||||||
|
# Verify file exists
|
||||||
|
assert test_file.exists()
|
||||||
|
|
||||||
|
# Load and verify data
|
||||||
|
data = load_data(test_file)
|
||||||
|
assert data == "Hello, World!"
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_files(tmp_path):
|
||||||
|
"""Test with multiple temporary files."""
|
||||||
|
files = {
|
||||||
|
"file1.txt": "Content 1",
|
||||||
|
"file2.txt": "Content 2",
|
||||||
|
"file3.txt": "Content 3"
|
||||||
|
}
|
||||||
|
|
||||||
|
for filename, content in files.items():
|
||||||
|
filepath = tmp_path / filename
|
||||||
|
save_data(filepath, content)
|
||||||
|
|
||||||
|
# Verify all files created
|
||||||
|
assert len(list(tmp_path.iterdir())) == 3
|
||||||
|
|
||||||
|
# Verify contents
|
||||||
|
for filename, expected_content in files.items():
|
||||||
|
filepath = tmp_path / filename
|
||||||
|
assert load_data(filepath) == expected_content
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pattern 9: Custom Fixtures and Conftest
|
||||||
|
|
||||||
|
```python
|
||||||
|
# conftest.py
|
||||||
|
"""Shared fixtures for all tests."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def database_url():
|
||||||
|
"""Provide database URL for all tests."""
|
||||||
|
return "postgresql://localhost/test_db"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_database(database_url):
|
||||||
|
"""Auto-use fixture that runs before each test."""
|
||||||
|
# Setup: Clear database
|
||||||
|
print(f"Clearing database: {database_url}")
|
||||||
|
yield
|
||||||
|
# Teardown: Clean up
|
||||||
|
print("Test completed")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_user():
|
||||||
|
"""Provide sample user data."""
|
||||||
|
return {
|
||||||
|
"id": 1,
|
||||||
|
"name": "Test User",
|
||||||
|
"email": "test@example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sample_users():
|
||||||
|
"""Provide list of sample users."""
|
||||||
|
return [
|
||||||
|
{"id": 1, "name": "User 1"},
|
||||||
|
{"id": 2, "name": "User 2"},
|
||||||
|
{"id": 3, "name": "User 3"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Parametrized fixture
|
||||||
|
@pytest.fixture(params=["sqlite", "postgresql", "mysql"])
|
||||||
|
def db_backend(request):
|
||||||
|
"""Fixture that runs tests with different database backends."""
|
||||||
|
return request.param
|
||||||
|
|
||||||
|
|
||||||
|
def test_with_db_backend(db_backend):
|
||||||
|
"""This test will run 3 times with different backends."""
|
||||||
|
print(f"Testing with {db_backend}")
|
||||||
|
assert db_backend in ["sqlite", "postgresql", "mysql"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Pattern 10: Property-Based Testing
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_properties.py
|
||||||
|
from hypothesis import given, strategies as st
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
def reverse_string(s: str) -> str:
|
||||||
|
"""Reverse a string."""
|
||||||
|
return s[::-1]
|
||||||
|
|
||||||
|
|
||||||
|
@given(st.text())
|
||||||
|
def test_reverse_twice_is_original(s):
|
||||||
|
"""Property: reversing twice returns original."""
|
||||||
|
assert reverse_string(reverse_string(s)) == s
|
||||||
|
|
||||||
|
|
||||||
|
@given(st.text())
|
||||||
|
def test_reverse_length(s):
|
||||||
|
"""Property: reversed string has same length."""
|
||||||
|
assert len(reverse_string(s)) == len(s)
|
||||||
|
|
||||||
|
|
||||||
|
@given(st.integers(), st.integers())
|
||||||
|
def test_addition_commutative(a, b):
|
||||||
|
"""Property: addition is commutative."""
|
||||||
|
assert a + b == b + a
|
||||||
|
|
||||||
|
|
||||||
|
@given(st.lists(st.integers()))
|
||||||
|
def test_sorted_list_properties(lst):
|
||||||
|
"""Property: sorted list is ordered."""
|
||||||
|
sorted_lst = sorted(lst)
|
||||||
|
|
||||||
|
# Same length
|
||||||
|
assert len(sorted_lst) == len(lst)
|
||||||
|
|
||||||
|
# All elements present
|
||||||
|
assert set(sorted_lst) == set(lst)
|
||||||
|
|
||||||
|
# Is ordered
|
||||||
|
for i in range(len(sorted_lst) - 1):
|
||||||
|
assert sorted_lst[i] <= sorted_lst[i + 1]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Database Code
|
||||||
|
|
||||||
|
```python
|
||||||
|
# test_database_models.py
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import create_engine, Column, Integer, String
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker, Session
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""User model."""
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
name = Column(String(50))
|
||||||
|
email = Column(String(100), unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="function")
|
||||||
|
def db_session() -> Session:
|
||||||
|
"""Create in-memory database for testing."""
|
||||||
|
engine = create_engine("sqlite:///:memory:")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(bind=engine)
|
||||||
|
session = SessionLocal()
|
||||||
|
|
||||||
|
yield session
|
||||||
|
|
||||||
|
session.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user(db_session):
|
||||||
|
"""Test creating a user."""
|
||||||
|
user = User(name="Test User", email="test@example.com")
|
||||||
|
db_session.add(user)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
assert user.id is not None
|
||||||
|
assert user.name == "Test User"
|
||||||
|
|
||||||
|
|
||||||
|
def test_query_user(db_session):
|
||||||
|
"""Test querying users."""
|
||||||
|
user1 = User(name="User 1", email="user1@example.com")
|
||||||
|
user2 = User(name="User 2", email="user2@example.com")
|
||||||
|
|
||||||
|
db_session.add_all([user1, user2])
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
users = db_session.query(User).all()
|
||||||
|
assert len(users) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_unique_email_constraint(db_session):
|
||||||
|
"""Test unique email constraint."""
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
|
user1 = User(name="User 1", email="same@example.com")
|
||||||
|
user2 = User(name="User 2", email="same@example.com")
|
||||||
|
|
||||||
|
db_session.add(user1)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
db_session.add(user2)
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
db_session.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/test.yml
|
||||||
|
name: Tests
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
pip install pytest pytest-cov
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: |
|
||||||
|
pytest --cov=myapp --cov-report=xml
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: codecov/codecov-action@v3
|
||||||
|
with:
|
||||||
|
file: ./coverage.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration Files
|
||||||
|
|
||||||
|
```ini
|
||||||
|
# pytest.ini
|
||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
addopts =
|
||||||
|
-v
|
||||||
|
--strict-markers
|
||||||
|
--tb=short
|
||||||
|
--cov=myapp
|
||||||
|
--cov-report=term-missing
|
||||||
|
markers =
|
||||||
|
slow: marks tests as slow
|
||||||
|
integration: marks integration tests
|
||||||
|
unit: marks unit tests
|
||||||
|
e2e: marks end-to-end tests
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# pyproject.toml
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
addopts = [
|
||||||
|
"-v",
|
||||||
|
"--cov=myapp",
|
||||||
|
"--cov-report=term-missing",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["myapp"]
|
||||||
|
omit = ["*/tests/*", "*/migrations/*"]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover",
|
||||||
|
"def __repr__",
|
||||||
|
"raise AssertionError",
|
||||||
|
"raise NotImplementedError",
|
||||||
|
]
|
||||||
|
```
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
name: Build
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build_arch:
|
||||||
|
runs-on: archlinux
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.pkg_version.outputs.version }}
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: Checkout Current Repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Clone External Repository
|
||||||
|
run: |
|
||||||
|
git clone https://git.elordenador.org/RedSocial/cli_archpkg.git external
|
||||||
|
chown -R builder:builder external
|
||||||
|
- name: Makepkg
|
||||||
|
run: |
|
||||||
|
cd external && makepkg -s --noconfirm
|
||||||
|
- name: Extract package version
|
||||||
|
id: pkg_version
|
||||||
|
run: |
|
||||||
|
PKGVERSION=$(pacman -Qp external/*.pkg.tar.zst | awk '{print $2}')
|
||||||
|
echo "PKGVERSION=$PKGVERSION" >> $GITHUB_ENV
|
||||||
|
echo "version=$PKGVERSION" >> $GITHUB_OUTPUT
|
||||||
|
- name: Upload Artifact
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: arch-package
|
||||||
|
path: external/*.pkg.tar.zst
|
||||||
|
publish-release:
|
||||||
|
runs-on: ubuntu-26.04
|
||||||
|
needs: build_arch
|
||||||
|
steps:
|
||||||
|
- name: Checkout Current Repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download Artifact
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: arch-package
|
||||||
|
path: ./dist
|
||||||
|
- name: Create Git Tag
|
||||||
|
run: |
|
||||||
|
VERSION="${{ needs.build_arch.outputs.version }}"
|
||||||
|
git tag "$VERSION"
|
||||||
|
git push origin "$VERSION"
|
||||||
|
echo "VERSION=$VERSION" >> $GITHUB_ENV
|
||||||
|
- name: Create Gitea Release
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ env.VERSION }}
|
||||||
|
run: |
|
||||||
|
PACKAGE_FILE=$(ls dist/*.pkg.tar.zst)
|
||||||
|
FILE_NAME=$(basename "$PACKAGE_FILE")
|
||||||
|
RELEASE_JSON=$(curl -s -X POST "https://git.elordenador.org/api/v1/repos/RedSocial/cli/releases" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{
|
||||||
|
\"tag_name\": \"$TAG\",
|
||||||
|
\"name\": \"Release $TAG\",
|
||||||
|
\"draft\": false,
|
||||||
|
\"prerelease\": false
|
||||||
|
}")
|
||||||
|
RELEASE_ID=$(echo "$RELEASE_JSON" | grep -o '"id":[0-9]*' | head -1 | cut -d':' -f2)
|
||||||
|
curl -X POST "https://git.elordenador.org/api/v1/repos/RedSocial/cli/releases/$RELEASE_ID/assets?name=$FILE_NAME" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" \
|
||||||
|
--data-binary "@$PACKAGE_FILE"
|
||||||
|
|
||||||
@@ -8,3 +8,4 @@ wheels/
|
|||||||
|
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
*.tar.xz
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"tamasfe.even-better-toml"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
- **Project**: rscli - Python CLI for a social network API
|
||||||
|
- **Entry point**: `rcli` command (defined in pyproject.toml as `rscli.main:app`)
|
||||||
|
- **Python version**: >= 3.14
|
||||||
|
- **Dependencies**: prettytable, requests, typer
|
||||||
|
|
||||||
|
## Running the CLI
|
||||||
|
```bash
|
||||||
|
rcli --help # Show commands
|
||||||
|
rcli version # Show version
|
||||||
|
rcli login # Login to API
|
||||||
|
rcli api_uri <url> # Set API URI
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
- Config stored at: `~/.config/rs-cli/config.json`
|
||||||
|
- Contains: `token`, `API_URI`
|
||||||
|
|
||||||
|
## Development
|
||||||
|
- Install locally: `pip install -e .` or use `uv pip install -e .`
|
||||||
|
- Project uses **uv** for dependency management (see uv.lock)
|
||||||
|
|
||||||
|
## CI/Build
|
||||||
|
- Gitea workflows in `.gitea/workflows/build.yml`
|
||||||
|
- Builds Arch Linux package (.pkg.tar.zst)
|
||||||
|
- Publishes releases to Gitea
|
||||||
|
|
||||||
|
## Gitea / tea
|
||||||
|
|
||||||
|
Login to the Gitea instance:
|
||||||
|
```bash
|
||||||
|
tea login add --name elordenador --url https://git.elordenador.org
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issues
|
||||||
|
```bash
|
||||||
|
tea issues # List issues
|
||||||
|
tea issues create # Create new issue
|
||||||
|
tea issues close # Close an issue
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pull Requests
|
||||||
|
```bash
|
||||||
|
tea pr # List pull requests
|
||||||
|
tea pr create # Create PR
|
||||||
|
tea pr checkout # Checkout PR locally
|
||||||
|
tea pr merge # Merge a PR
|
||||||
|
```
|
||||||
|
|
||||||
|
### Actions (Workflows)
|
||||||
|
```bash
|
||||||
|
tea actions info # Show workflow info
|
||||||
|
tea actions list # List workflow runs
|
||||||
|
tea actions logs # View logs for a run
|
||||||
|
tea actions re-run # Re-run a failed workflow
|
||||||
|
```
|
||||||
|
|
||||||
|
### Releases
|
||||||
|
```bash
|
||||||
|
tea release # List releases
|
||||||
|
tea release create # Create a release
|
||||||
|
```
|
||||||
|
|
||||||
|
### API
|
||||||
|
```bash
|
||||||
|
tea api GET /api/v1/repos/RedSocial/cli/releases
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Skills
|
||||||
|
- python-patterns
|
||||||
|
- python-testing-patterns
|
||||||
|
- python-executor
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- No test suite currently exists in the repo
|
||||||
|
- Config class supports dot-notation paths (e.g., `config.get("token")`)
|
||||||
|
- Use `tea` (Gitea CLI) instead of `gh` for Gitea operations
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
#!/bin/env python
|
|
||||||
import typer, json, os, sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
VERSION = "1.0"
|
|
||||||
|
|
||||||
app = typer.Typer()
|
|
||||||
|
|
||||||
@app.command()
|
|
||||||
def version():
|
|
||||||
print(f"Version: {VERSION}")
|
|
||||||
|
|
||||||
@app.command()
|
|
||||||
def login():
|
|
||||||
if not Path(f"/home/{os.getlogin()}/.config"): os.mkdir(f"/home/{os.getlogin()}/.config")
|
|
||||||
if not Path(f"/home/{os.getlogin()}/.config/redsocial-cli"): os.mkdir(f"/home/{os.getlogin()}/.config")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
app()
|
|
||||||
+10
-1
@@ -1,9 +1,18 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "cli"
|
name = "rscli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "Add your description here"
|
description = "Add your description here"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"prettytable>=3.17.0",
|
||||||
|
"requests>=2.34.0",
|
||||||
"typer>=0.25.1",
|
"typer>=0.25.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
rcli = "rscli.main:app"
|
||||||
+136
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
def __init__(self, file_path: str | Path | None = None) -> None:
|
||||||
|
default_path = Path.home() / ".config" / "rs-cli" / "config.json"
|
||||||
|
self.file_path = Path(file_path).expanduser() if file_path else default_path
|
||||||
|
self.data: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def load(self) -> dict[str, Any]:
|
||||||
|
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if not self.file_path.exists():
|
||||||
|
self.data = {}
|
||||||
|
self.save()
|
||||||
|
return self.data
|
||||||
|
|
||||||
|
content = self.file_path.read_text(encoding="utf-8").strip()
|
||||||
|
if not content:
|
||||||
|
self.data = {}
|
||||||
|
self.save()
|
||||||
|
return self.data
|
||||||
|
|
||||||
|
parsed = json.loads(content)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise ValueError("El archivo de configuracion debe contener un objeto JSON.")
|
||||||
|
|
||||||
|
self.data = parsed
|
||||||
|
return self.data
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.file_path.write_text(
|
||||||
|
json.dumps(self.data, indent=2, ensure_ascii=False) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get(self, path: str, default: Any = None) -> Any:
|
||||||
|
tokens = self._parse_path(path)
|
||||||
|
current: Any = self.data
|
||||||
|
|
||||||
|
for token in tokens:
|
||||||
|
if isinstance(token, str):
|
||||||
|
if not isinstance(current, dict) or token not in current:
|
||||||
|
return default
|
||||||
|
current = current[token]
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(current, list) or token >= len(current):
|
||||||
|
return default
|
||||||
|
current = current[token]
|
||||||
|
|
||||||
|
return current
|
||||||
|
|
||||||
|
def set(self, path: str, value: Any) -> None:
|
||||||
|
tokens = self._parse_path(path)
|
||||||
|
current: Any = self.data
|
||||||
|
|
||||||
|
for idx, token in enumerate(tokens):
|
||||||
|
is_last = idx == len(tokens) - 1
|
||||||
|
next_token = None if is_last else tokens[idx + 1]
|
||||||
|
|
||||||
|
if isinstance(token, str):
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
raise TypeError(f"No se puede usar clave '{token}' en un nodo no-dict.")
|
||||||
|
|
||||||
|
if is_last:
|
||||||
|
current[token] = value
|
||||||
|
break
|
||||||
|
|
||||||
|
expected_type = list if isinstance(next_token, int) else dict
|
||||||
|
if token not in current or current[token] is None:
|
||||||
|
current[token] = [] if expected_type is list else {}
|
||||||
|
elif not isinstance(current[token], expected_type):
|
||||||
|
current[token] = [] if expected_type is list else {}
|
||||||
|
|
||||||
|
current = current[token]
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(current, list):
|
||||||
|
raise TypeError(f"No se puede usar indice [{token}] en un nodo no-list.")
|
||||||
|
|
||||||
|
while len(current) <= token:
|
||||||
|
current.append(None)
|
||||||
|
|
||||||
|
if is_last:
|
||||||
|
current[token] = value
|
||||||
|
break
|
||||||
|
|
||||||
|
expected_type = list if isinstance(next_token, int) else dict
|
||||||
|
if current[token] is None or not isinstance(current[token], expected_type):
|
||||||
|
current[token] = [] if expected_type is list else {}
|
||||||
|
|
||||||
|
current = current[token]
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def _parse_path(self, path: str) -> list[str | int]:
|
||||||
|
if not path or not isinstance(path, str):
|
||||||
|
raise ValueError("La ruta no puede estar vacia.")
|
||||||
|
|
||||||
|
tokens: list[str | int] = []
|
||||||
|
|
||||||
|
for segment in path.split("."):
|
||||||
|
if not segment:
|
||||||
|
raise ValueError(f"Ruta invalida: '{path}'")
|
||||||
|
|
||||||
|
index = 0
|
||||||
|
if segment[0] != "[":
|
||||||
|
match = re.match(r"[^\[\]]+", segment)
|
||||||
|
if not match:
|
||||||
|
raise ValueError(f"Ruta invalida: '{path}'")
|
||||||
|
key = match.group(0)
|
||||||
|
tokens.append(key)
|
||||||
|
index = len(key)
|
||||||
|
|
||||||
|
while index < len(segment):
|
||||||
|
if segment[index] != "[":
|
||||||
|
raise ValueError(f"Ruta invalida: '{path}'")
|
||||||
|
end = segment.find("]", index)
|
||||||
|
if end == -1:
|
||||||
|
raise ValueError(f"Ruta invalida: '{path}'")
|
||||||
|
|
||||||
|
raw_index = segment[index + 1 : end]
|
||||||
|
if not raw_index.isdigit():
|
||||||
|
raise ValueError(f"Indice invalido en ruta: '{path}'")
|
||||||
|
|
||||||
|
tokens.append(int(raw_index))
|
||||||
|
index = end + 1
|
||||||
|
|
||||||
|
return tokens
|
||||||
Executable
+44
@@ -0,0 +1,44 @@
|
|||||||
|
#!/bin/env python
|
||||||
|
import typer, json, os, sys
|
||||||
|
from pathlib import Path
|
||||||
|
from .config import Config
|
||||||
|
from getpass import getpass
|
||||||
|
from prettytable import PrettyTable
|
||||||
|
import requests
|
||||||
|
from typing import Annotated
|
||||||
|
VERSION = "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
app = typer.Typer()
|
||||||
|
config = Config()
|
||||||
|
config.load()
|
||||||
|
API_URI = config.get("API_URI")
|
||||||
|
@app.command()
|
||||||
|
def version():
|
||||||
|
print(f"Version: {VERSION}")
|
||||||
|
|
||||||
|
@app.command()
|
||||||
|
def login():
|
||||||
|
username: str = input("Username: ")
|
||||||
|
password: str = getpass("Password: ")
|
||||||
|
|
||||||
|
result = requests.post(API_URI+"/api/login/", json={"username":username, "password":password})
|
||||||
|
data = result.json()
|
||||||
|
table = PrettyTable()
|
||||||
|
table.field_names = ["Status Code", "Message"]
|
||||||
|
if result.status_code != 200:
|
||||||
|
table.add_row([result.status_code, data["detail"]])
|
||||||
|
else:
|
||||||
|
table.add_row([result.status_code, "success"])
|
||||||
|
config.set("token", data["access_token"])
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
print(table)
|
||||||
|
@app.command()
|
||||||
|
def api_uri(url: Annotated[str, typer.Argument(help="The API Url (without /api...)")]):
|
||||||
|
config.set("API_URI", url)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"skills": {
|
||||||
|
"python-executor": {
|
||||||
|
"source": "inferen-sh/skills",
|
||||||
|
"sourceType": "autoskills-registry",
|
||||||
|
"computedHash": "bd2d874c27788964fadf03c0840049de0409407f06f883ee90886164cb22ef69"
|
||||||
|
},
|
||||||
|
"python-patterns": {
|
||||||
|
"source": "affaan-m/everything-claude-code",
|
||||||
|
"sourceType": "autoskills-registry",
|
||||||
|
"computedHash": "5c344cc64b19a9a7aecb9b5fa977175acd8e55a169f86901d28eb57c637869b4"
|
||||||
|
},
|
||||||
|
"python-testing-patterns": {
|
||||||
|
"source": "wshobson/agents",
|
||||||
|
"sourceType": "autoskills-registry",
|
||||||
|
"computedHash": "07b87d62993c0b6159a91d18fc8723b7f4c13d5000c0984266c207493ce641ff"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,16 +11,72 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2026.4.22"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
version = "3.4.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cli"
|
name = "cli"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "prettytable" },
|
||||||
|
{ name = "requests" },
|
||||||
{ name = "typer" },
|
{ name = "typer" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [{ name = "typer", specifier = ">=0.25.1" }]
|
requires-dist = [
|
||||||
|
{ name = "prettytable", specifier = ">=3.17.0" },
|
||||||
|
{ name = "requests", specifier = ">=2.34.0" },
|
||||||
|
{ name = "typer", specifier = ">=0.25.1" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
@@ -43,6 +99,15 @@ 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" },
|
{ 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 = "idna"
|
||||||
|
version = "3.14"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/05/b1/efac073e0c297ecf2fb33c346989a529d4e19164f1759102dee5953ee17e/idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3", size = 198272, upload-time = "2026-05-10T20:32:15.935Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown-it-py"
|
name = "markdown-it-py"
|
||||||
version = "4.2.0"
|
version = "4.2.0"
|
||||||
@@ -64,6 +129,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prettytable"
|
||||||
|
version = "3.17.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "wcwidth" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/79/45/b0847d88d6cfeb4413566738c8bbf1e1995fad3d42515327ff32cc1eb578/prettytable-3.17.0.tar.gz", hash = "sha256:59f2590776527f3c9e8cf9fe7b66dd215837cca96a9c39567414cbc632e8ddb0", size = 67892, upload-time = "2025-11-14T17:33:20.212Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/8c/83087ebc47ab0396ce092363001fa37c17153119ee282700c0713a195853/prettytable-3.17.0-py3-none-any.whl", hash = "sha256:aad69b294ddbe3e1f95ef8886a060ed1666a0b83018bbf56295f6f226c43d287", size = 34433, upload-time = "2025-11-14T17:33:19.093Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.20.0"
|
version = "2.20.0"
|
||||||
@@ -73,6 +150,21 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.34.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/43/b8/7a707d60fea4c49094e40262cc0e2ca6c768cca21587e34d3f705afec47e/requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a", size = 142436, upload-time = "2026-05-11T19:29:51.717Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/e6/e300fce5fe83c30520607a015dabd985df3251e188d234bfe9492e17a389/requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60", size = 73021, upload-time = "2026-05-11T19:29:49.923Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rich"
|
name = "rich"
|
||||||
version = "15.0.0"
|
version = "15.0.0"
|
||||||
@@ -109,3 +201,21 @@ sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b8
|
|||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wcwidth"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" },
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user