Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
||||
.venv
|
||||
*.tar.xz
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"tamasfe.even-better-toml"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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
|
||||
|
||||
## 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")`)
|
||||
@@ -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]
|
||||
name = "cli"
|
||||
name = "rscli"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"prettytable>=3.17.0",
|
||||
"requests>=2.34.0",
|
||||
"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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "cli"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "prettytable" },
|
||||
{ name = "requests" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
|
||||
[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]]
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "markdown-it-py"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "pygments"
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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]]
|
||||
name = "rich"
|
||||
version = "15.0.0"
|
||||
@@ -109,3 +201,21 @@ sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b8
|
||||
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" },
|
||||
]
|
||||
|
||||
[[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