feat(phase1): add static file serving for /images/
This commit is contained in:
@@ -2,6 +2,7 @@ import os
|
||||
from . import config_loader # This triggers the automatic environment loading
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from . import models
|
||||
@@ -10,7 +11,7 @@ from .routers import items, operations, users, auth, sync, categories
|
||||
from .routers.admin import backups, ai_config, db_config
|
||||
from .logger import log
|
||||
from .scheduler import scheduler, sync_scheduler_config
|
||||
from .services.image_storage import ensure_image_directories
|
||||
from .services.image_storage import ensure_image_directories, IMAGES_ROOT
|
||||
|
||||
# Create the database tables
|
||||
from .database import DATA_DIR, db_path
|
||||
@@ -95,6 +96,14 @@ app.include_router(backups.router)
|
||||
app.include_router(ai_config.router)
|
||||
app.include_router(db_config.router)
|
||||
|
||||
# [STATIC FILES] Mount /images/ directory for serving uploaded photos
|
||||
# Ensure directory exists before mounting (StaticFiles requires pre-existing directory)
|
||||
IMAGES_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
# Mount at root level after all API routes to avoid conflicts with dynamic routes.
|
||||
# MIME types are auto-detected by StaticFiles based on file extensions.
|
||||
# Supported formats: JPEG (.jpg/.jpeg), PNG (.png), WebP (.webp), GIF (.gif)
|
||||
app.mount("/images", StaticFiles(directory=str(IMAGES_ROOT)), name="images")
|
||||
|
||||
@app.on_event("startup")
|
||||
def startup_event():
|
||||
log.info("[STARTUP] Initializing image storage directories...")
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from typing import List
|
||||
from typing import List, Optional, Dict
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
from .. import models, schemas, auth
|
||||
from ..database import get_db
|
||||
from ..services.image_processing import ImageProcessor
|
||||
from ..services.image_storage import save_image, get_unique_filename
|
||||
|
||||
# [H-02] Rate limiter for extract-label endpoint
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
@@ -52,10 +56,21 @@ def read_item(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: auth.TokenData = Depends(auth.get_current_user)
|
||||
):
|
||||
"""[C-01] Get item — only for authenticated users."""
|
||||
"""[C-01] Get item — only for authenticated users. [PHASE1-T5] Include photo URLs if set."""
|
||||
item = db.query(models.Item).filter(models.Item.id == item_id).first()
|
||||
if item is None:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
# Build photo object if photo_path is set
|
||||
if item.photo_path and item.photo_thumbnail_path and item.photo_upload_date:
|
||||
item.photo = schemas.PhotoResponse(
|
||||
thumbnail_url=item.photo_thumbnail_path,
|
||||
full_url=item.photo_path,
|
||||
uploaded_at=item.photo_upload_date
|
||||
)
|
||||
else:
|
||||
item.photo = None
|
||||
|
||||
return item
|
||||
|
||||
_ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||
@@ -233,8 +248,177 @@ def delete_item(
|
||||
|
||||
# [CLEANUP] Delete related InterventionItems to prevent foreign key issues
|
||||
db.query(models.InterventionItem).filter(models.InterventionItem.item_id == item_id).delete()
|
||||
|
||||
|
||||
# Audit Logs in database are NOT deleted here to preserve history of actions
|
||||
db.delete(db_item)
|
||||
db.commit()
|
||||
return {"message": "Item deleted successfully. History logs preserved."}
|
||||
|
||||
|
||||
@router.post("/{item_id}/photos")
|
||||
async def upload_photo(
|
||||
item_id: int,
|
||||
file: UploadFile = File(...),
|
||||
crop_bounds: Optional[str] = "",
|
||||
replace_existing: Optional[str] = "",
|
||||
db: Session = Depends(get_db),
|
||||
current_user: auth.TokenData = Depends(auth.get_current_user)
|
||||
):
|
||||
"""
|
||||
[PHASE1-T4] Upload/replace photo for an item.
|
||||
|
||||
- Accept multipart file upload (photo binary)
|
||||
- Accept optional crop_bounds JSON (x, y, w, h for manual crop override)
|
||||
- Accept optional replace_existing flag (delete old photo if true)
|
||||
- Validate file size, MIME type
|
||||
- Call ImageProcessor.process_photo(file_bytes, crop_bounds)
|
||||
- Get unique filename from ImageStorage.get_unique_filename()
|
||||
- Save original and thumbnail using ImageStorage.save_image()
|
||||
- Delete old photo file if replacing
|
||||
- Update Item.photo_path, photo_thumbnail_path, photo_upload_date
|
||||
- Return: {status: "ok", photo: {thumbnail_url, full_url, uploaded_at}}
|
||||
"""
|
||||
# Verify item exists
|
||||
db_item = db.query(models.Item).filter(models.Item.id == item_id).first()
|
||||
if not db_item:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
|
||||
# Validate file type
|
||||
if file.content_type not in _ALLOWED_IMAGE_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
||||
detail=f"File type not allowed: {file.content_type}. Accepted: {', '.join(_ALLOWED_IMAGE_TYPES)}"
|
||||
)
|
||||
|
||||
# Read file and validate size
|
||||
file_bytes = await file.read()
|
||||
if len(file_bytes) > _MAX_IMAGE_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||
detail="File exceeds 10MB limit."
|
||||
)
|
||||
|
||||
try:
|
||||
# Parse crop_bounds if provided
|
||||
crop_bounds_dict = None
|
||||
if crop_bounds and crop_bounds.strip():
|
||||
try:
|
||||
crop_bounds_dict = json.loads(crop_bounds)
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Invalid crop_bounds JSON"
|
||||
)
|
||||
|
||||
# Parse replace_existing flag (comes as form string)
|
||||
should_replace = replace_existing and replace_existing.lower() in ("true", "1", "yes")
|
||||
|
||||
# Process image (handles EXIF, smart crop, compression, thumbnail)
|
||||
processor = ImageProcessor()
|
||||
process_result = processor.process_photo(file_bytes, crop_bounds_dict)
|
||||
|
||||
if process_result['status'] != 'success':
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Image processing failed: {process_result.get('error', 'Unknown error')}"
|
||||
)
|
||||
|
||||
# Get processed bytes
|
||||
cropped_bytes = process_result['cropped_image_bytes']
|
||||
thumbnail_bytes = process_result['thumbnail_bytes']
|
||||
|
||||
if not cropped_bytes or not thumbnail_bytes:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Failed to process image data"
|
||||
)
|
||||
|
||||
# Get category for file storage (use "items" as default category if no category set)
|
||||
category = db_item.category or "items"
|
||||
|
||||
# Get unique filenames (no collision)
|
||||
existing_files = []
|
||||
cat_dir = Path("images") / category.lower()
|
||||
if cat_dir.exists():
|
||||
existing_files = [f.name for f in cat_dir.iterdir() if f.is_file()]
|
||||
|
||||
filename_base = db_item.name or f"item_{item_id}"
|
||||
original_filename = get_unique_filename(filename_base, category, existing_files, variant="original")
|
||||
thumbnail_filename = get_unique_filename(filename_base, category, existing_files, variant="thumb")
|
||||
|
||||
# Save original image
|
||||
try:
|
||||
original_path = save_image(cropped_bytes, category, original_filename.replace("_original.jpg", ""), variant="original")
|
||||
except (OSError, IOError) as e:
|
||||
if "No space left" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
|
||||
detail="Disk space full"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to save image: {str(e)}"
|
||||
)
|
||||
|
||||
# Save thumbnail
|
||||
try:
|
||||
thumbnail_path = save_image(thumbnail_bytes, category, thumbnail_filename.replace("_thumb.jpg", ""), variant="thumb")
|
||||
except (OSError, IOError) as e:
|
||||
if "No space left" in str(e):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_507_INSUFFICIENT_STORAGE,
|
||||
detail="Disk space full"
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to save thumbnail: {str(e)}"
|
||||
)
|
||||
|
||||
# Delete old photo files if replacing
|
||||
if should_replace and db_item.photo_path:
|
||||
try:
|
||||
old_photo = Path(db_item.photo_path.lstrip("/"))
|
||||
if old_photo.exists():
|
||||
old_photo.unlink()
|
||||
except Exception as e:
|
||||
# Log but don't fail if cleanup fails
|
||||
from ..logger import log
|
||||
log.warning(f"Failed to delete old photo: {str(e)}")
|
||||
|
||||
try:
|
||||
if db_item.photo_thumbnail_path:
|
||||
old_thumb = Path(db_item.photo_thumbnail_path.lstrip("/"))
|
||||
if old_thumb.exists():
|
||||
old_thumb.unlink()
|
||||
except Exception as e:
|
||||
from ..logger import log
|
||||
log.warning(f"Failed to delete old thumbnail: {str(e)}")
|
||||
|
||||
# Update database (transaction safety)
|
||||
db_item.photo_path = original_path
|
||||
db_item.photo_thumbnail_path = thumbnail_path
|
||||
db_item.photo_upload_date = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_item)
|
||||
|
||||
# Return response with URLs
|
||||
return {
|
||||
"status": "ok",
|
||||
"photo": {
|
||||
"thumbnail_url": db_item.photo_thumbnail_path,
|
||||
"full_url": db_item.photo_path,
|
||||
"uploaded_at": db_item.photo_upload_date
|
||||
}
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
from ..logger import log
|
||||
log.error(f"Unexpected error in photo upload: {str(e)}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Internal server error: {str(e)}"
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ from .items import (
|
||||
ColorBase,
|
||||
ColorCreate,
|
||||
Color,
|
||||
PhotoResponse,
|
||||
ItemBase,
|
||||
ItemCreate,
|
||||
Item,
|
||||
@@ -57,6 +58,7 @@ __all__ = [
|
||||
"ColorBase",
|
||||
"ColorCreate",
|
||||
"Color",
|
||||
"PhotoResponse",
|
||||
"ItemBase",
|
||||
"ItemCreate",
|
||||
"Item",
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# --- Photo Response ---
|
||||
class PhotoResponse(BaseModel):
|
||||
"""Photo metadata for item responses."""
|
||||
thumbnail_url: str
|
||||
full_url: str
|
||||
uploaded_at: datetime
|
||||
|
||||
|
||||
# --- Categories ---
|
||||
@@ -62,6 +71,10 @@ class ItemCreate(ItemBase):
|
||||
|
||||
class Item(ItemBase):
|
||||
id: int
|
||||
photo_path: Optional[str] = None
|
||||
photo_thumbnail_path: Optional[str] = None
|
||||
photo_upload_date: Optional[datetime] = None
|
||||
photo: Optional[PhotoResponse] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
376
backend/tests/test_photo_endpoints.py
Normal file
376
backend/tests/test_photo_endpoints.py
Normal file
@@ -0,0 +1,376 @@
|
||||
"""
|
||||
Comprehensive test suite for photo upload/replace API endpoints.
|
||||
|
||||
Tests cover:
|
||||
- Upload success: photo saved, DB updated, URLs returned
|
||||
- File replacement: old file deleted, new file saved
|
||||
- Auth: user can upload, guest cannot
|
||||
- Invalid file: wrong MIME type rejected
|
||||
- File too large: >10MB rejected with 413
|
||||
- Item not found: 404
|
||||
- GET returns photo URLs when set, null when not
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend import models, auth
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FIXTURES
|
||||
# ============================================================================
|
||||
|
||||
@pytest.fixture
|
||||
def test_item(test_db: Session):
|
||||
"""Create a test item in the database."""
|
||||
item = models.Item(
|
||||
id=1,
|
||||
barcode="TEST123",
|
||||
name="Test Item",
|
||||
category="networking",
|
||||
quantity=5.0
|
||||
)
|
||||
test_db.add(item)
|
||||
test_db.commit()
|
||||
test_db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_image():
|
||||
"""Create a minimal valid JPEG image for testing."""
|
||||
from PIL import Image
|
||||
|
||||
# Create a simple 100x100 red image
|
||||
img = Image.new('RGB', (100, 100), color='red')
|
||||
img_bytes = io.BytesIO()
|
||||
img.save(img_bytes, format='JPEG')
|
||||
img_bytes.seek(0)
|
||||
return img_bytes.getvalue()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def large_image():
|
||||
"""Create an image > 10MB to test size limit."""
|
||||
from PIL import Image
|
||||
|
||||
img = Image.new('RGB', (100, 100), color='blue')
|
||||
img_bytes = io.BytesIO()
|
||||
img.save(img_bytes, format='JPEG')
|
||||
img_bytes.seek(0)
|
||||
small_img = img_bytes.getvalue()
|
||||
|
||||
# Pad it to >10MB
|
||||
large_img = small_img + b'X' * (11 * 1024 * 1024)
|
||||
return large_img
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def invalid_file():
|
||||
"""Create an invalid file (text instead of image)."""
|
||||
return b"This is not an image file"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: UPLOAD SUCCESS
|
||||
# ============================================================================
|
||||
|
||||
def test_upload_photo_success(user_client: TestClient, test_db: Session, test_item, sample_image):
|
||||
"""Test successful photo upload."""
|
||||
response = user_client.post(
|
||||
f"/items/{test_item.id}/photos",
|
||||
files={"file": ("test.jpg", io.BytesIO(sample_image), "image/jpeg")}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["status"] == "ok"
|
||||
assert "photo" in data
|
||||
assert "thumbnail_url" in data["photo"]
|
||||
assert "full_url" in data["photo"]
|
||||
assert "uploaded_at" in data["photo"]
|
||||
|
||||
# Verify DB was updated
|
||||
updated_item = test_db.query(models.Item).filter(
|
||||
models.Item.id == test_item.id
|
||||
).first()
|
||||
assert updated_item.photo_path is not None
|
||||
assert updated_item.photo_thumbnail_path is not None
|
||||
assert updated_item.photo_upload_date is not None
|
||||
|
||||
# Cleanup
|
||||
Path(updated_item.photo_path.lstrip("/")).unlink()
|
||||
Path(updated_item.photo_thumbnail_path.lstrip("/")).unlink()
|
||||
|
||||
|
||||
def test_upload_photo_creates_files(user_client: TestClient, test_db: Session, test_item, sample_image):
|
||||
"""Test that photo upload creates image files on disk."""
|
||||
response = user_client.post(
|
||||
f"/items/{test_item.id}/photos",
|
||||
files={"file": ("test.jpg", io.BytesIO(sample_image), "image/jpeg")}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
# Check that files exist
|
||||
original_path = Path(data["photo"]["full_url"].lstrip("/"))
|
||||
thumbnail_path = Path(data["photo"]["thumbnail_url"].lstrip("/"))
|
||||
|
||||
assert original_path.exists(), f"Original image not found at {original_path}"
|
||||
assert thumbnail_path.exists(), f"Thumbnail not found at {thumbnail_path}"
|
||||
|
||||
# Cleanup
|
||||
original_path.unlink()
|
||||
thumbnail_path.unlink()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: FILE REPLACEMENT
|
||||
# ============================================================================
|
||||
|
||||
def test_upload_photo_replace_existing(user_client: TestClient, test_db: Session, test_item, sample_image):
|
||||
"""Test photo replacement with replace_existing=true."""
|
||||
# First upload
|
||||
response1 = user_client.post(
|
||||
f"/items/{test_item.id}/photos",
|
||||
files={"file": ("test1.jpg", io.BytesIO(sample_image), "image/jpeg")}
|
||||
)
|
||||
assert response1.status_code == 200
|
||||
old_data = response1.json()
|
||||
first_url = old_data["photo"]["full_url"]
|
||||
|
||||
# Create different image for second upload
|
||||
from PIL import Image
|
||||
img2 = Image.new('RGB', (150, 150), color='green')
|
||||
img2_bytes = io.BytesIO()
|
||||
img2.save(img2_bytes, format='JPEG')
|
||||
img2_bytes.seek(0)
|
||||
|
||||
# Second upload with replace_existing=true
|
||||
response2 = user_client.post(
|
||||
f"/items/{test_item.id}/photos",
|
||||
data={"replace_existing": "true"},
|
||||
files={"file": ("test2.jpg", io.BytesIO(img2_bytes.getvalue()), "image/jpeg")}
|
||||
)
|
||||
|
||||
assert response2.status_code == 200
|
||||
new_data = response2.json()
|
||||
second_url = new_data["photo"]["full_url"]
|
||||
|
||||
# URL should have changed (file was replaced)
|
||||
assert first_url != second_url, "Photo URL should change when replacing"
|
||||
|
||||
# New files should exist
|
||||
new_original = Path(new_data["photo"]["full_url"].lstrip("/"))
|
||||
new_thumbnail = Path(new_data["photo"]["thumbnail_url"].lstrip("/"))
|
||||
assert new_original.exists()
|
||||
assert new_thumbnail.exists()
|
||||
|
||||
# Cleanup
|
||||
new_original.unlink()
|
||||
new_thumbnail.unlink()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: AUTHENTICATION
|
||||
# ============================================================================
|
||||
|
||||
def test_upload_photo_requires_auth(test_client: TestClient, test_item, sample_image):
|
||||
"""Test that unauthenticated upload fails with 401."""
|
||||
response = test_client.post(
|
||||
f"/items/{test_item.id}/photos",
|
||||
files={"file": ("test.jpg", io.BytesIO(sample_image), "image/jpeg")}
|
||||
)
|
||||
|
||||
assert response.status_code == 401 # No auth header (unauthorized)
|
||||
|
||||
|
||||
def test_upload_photo_with_valid_token(user_client: TestClient, test_db: Session, test_item, sample_image):
|
||||
"""Test that authenticated user can upload."""
|
||||
response = user_client.post(
|
||||
f"/items/{test_item.id}/photos",
|
||||
files={"file": ("test.jpg", io.BytesIO(sample_image), "image/jpeg")}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Cleanup
|
||||
data = response.json()
|
||||
Path(data["photo"]["full_url"].lstrip("/")).unlink()
|
||||
Path(data["photo"]["thumbnail_url"].lstrip("/")).unlink()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: FILE VALIDATION
|
||||
# ============================================================================
|
||||
|
||||
def test_upload_photo_invalid_mime_type(user_client: TestClient, test_item, invalid_file):
|
||||
"""Test that invalid MIME type is rejected."""
|
||||
response = user_client.post(
|
||||
f"/items/{test_item.id}/photos",
|
||||
files={"file": ("test.txt", io.BytesIO(invalid_file), "text/plain")}
|
||||
)
|
||||
|
||||
assert response.status_code == 415 # Unsupported Media Type
|
||||
data = response.json()
|
||||
assert "File type not allowed" in data["detail"]
|
||||
|
||||
|
||||
def test_upload_photo_accepted_mime_types(user_client: TestClient, test_db: Session, test_item, sample_image):
|
||||
"""Test all accepted MIME types."""
|
||||
mime_types = ["image/jpeg", "image/png", "image/webp", "image/gif"]
|
||||
|
||||
for mime_type in mime_types:
|
||||
response = user_client.post(
|
||||
f"/items/{test_item.id}/photos",
|
||||
files={"file": ("test.jpg", io.BytesIO(sample_image), mime_type)}
|
||||
)
|
||||
|
||||
assert response.status_code == 200, f"Failed for {mime_type}"
|
||||
|
||||
# Cleanup
|
||||
data = response.json()
|
||||
Path(data["photo"]["full_url"].lstrip("/")).unlink()
|
||||
Path(data["photo"]["thumbnail_url"].lstrip("/")).unlink()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: FILE SIZE LIMITS
|
||||
# ============================================================================
|
||||
|
||||
def test_upload_photo_too_large(user_client: TestClient, test_item, large_image):
|
||||
"""Test that files > 10MB are rejected with 413."""
|
||||
response = user_client.post(
|
||||
f"/items/{test_item.id}/photos",
|
||||
files={"file": ("large.jpg", io.BytesIO(large_image), "image/jpeg")}
|
||||
)
|
||||
|
||||
assert response.status_code == 413 # Payload Too Large
|
||||
data = response.json()
|
||||
assert "exceeds 10MB limit" in data["detail"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: ITEM NOT FOUND
|
||||
# ============================================================================
|
||||
|
||||
def test_upload_photo_item_not_found(user_client: TestClient, sample_image):
|
||||
"""Test that upload to non-existent item returns 404."""
|
||||
response = user_client.post(
|
||||
f"/items/99999/photos",
|
||||
files={"file": ("test.jpg", io.BytesIO(sample_image), "image/jpeg")}
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
data = response.json()
|
||||
assert "Item not found" in data["detail"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: GET ENDPOINT PHOTO RESPONSE
|
||||
# ============================================================================
|
||||
|
||||
def test_get_item_includes_photo_urls(user_client: TestClient, test_db: Session, test_item, sample_image):
|
||||
"""Test that GET /items/{id} includes photo URLs when photo is set."""
|
||||
# First upload a photo
|
||||
response = user_client.post(
|
||||
f"/items/{test_item.id}/photos",
|
||||
files={"file": ("test.jpg", io.BytesIO(sample_image), "image/jpeg")}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Get the item
|
||||
response = user_client.get(f"/items/{test_item.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "photo" in data
|
||||
assert data["photo"] is not None
|
||||
assert "thumbnail_url" in data["photo"]
|
||||
assert "full_url" in data["photo"]
|
||||
assert "uploaded_at" in data["photo"]
|
||||
|
||||
# Cleanup
|
||||
Path(data["photo"]["full_url"].lstrip("/")).unlink()
|
||||
Path(data["photo"]["thumbnail_url"].lstrip("/")).unlink()
|
||||
|
||||
|
||||
def test_get_item_no_photo_returns_null(user_client: TestClient, test_item):
|
||||
"""Test that GET /items/{id} returns photo: null when no photo is set."""
|
||||
response = user_client.get(f"/items/{test_item.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "photo" in data
|
||||
assert data["photo"] is None
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: MULTIPLE UPLOADS
|
||||
# ============================================================================
|
||||
|
||||
def test_upload_multiple_photos_without_replace(user_client: TestClient, test_db: Session, test_item, sample_image):
|
||||
"""Test multiple uploads without replace_existing (new files created)."""
|
||||
from PIL import Image
|
||||
|
||||
response1 = user_client.post(
|
||||
f"/items/{test_item.id}/photos",
|
||||
files={"file": ("test1.jpg", io.BytesIO(sample_image), "image/jpeg")}
|
||||
)
|
||||
assert response1.status_code == 200
|
||||
path1 = response1.json()["photo"]["full_url"]
|
||||
|
||||
# Create a different image
|
||||
img2 = Image.new('RGB', (200, 200), color='yellow')
|
||||
img2_bytes = io.BytesIO()
|
||||
img2.save(img2_bytes, format='JPEG')
|
||||
img2_bytes.seek(0)
|
||||
|
||||
response2 = user_client.post(
|
||||
f"/items/{test_item.id}/photos",
|
||||
files={"file": ("test2.jpg", io.BytesIO(img2_bytes.getvalue()), "image/jpeg")}
|
||||
)
|
||||
assert response2.status_code == 200
|
||||
path2 = response2.json()["photo"]["full_url"]
|
||||
|
||||
# Paths should be different (new file created)
|
||||
assert path1 != path2
|
||||
|
||||
# Cleanup
|
||||
Path(path1.lstrip("/")).unlink()
|
||||
Path(path2.lstrip("/")).unlink()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TESTS: EDGE CASES
|
||||
# ============================================================================
|
||||
|
||||
def test_upload_photo_empty_file(user_client: TestClient, test_item):
|
||||
"""Test that empty file is handled gracefully."""
|
||||
response = user_client.post(
|
||||
f"/items/{test_item.id}/photos",
|
||||
files={"file": ("test.jpg", io.BytesIO(b""), "image/jpeg")}
|
||||
)
|
||||
|
||||
# Should fail during image processing
|
||||
assert response.status_code >= 400
|
||||
|
||||
|
||||
def test_upload_photo_corrupted_jpeg(user_client: TestClient, test_item):
|
||||
"""Test that corrupted JPEG is handled gracefully."""
|
||||
corrupted = b"\xFF\xD8\xFF\xE0" + b"corrupted data"
|
||||
|
||||
response = user_client.post(
|
||||
f"/items/{test_item.id}/photos",
|
||||
files={"file": ("test.jpg", io.BytesIO(corrupted), "image/jpeg")}
|
||||
)
|
||||
|
||||
# Should fail during image processing
|
||||
assert response.status_code >= 400
|
||||
256
backend/tests/test_static_files.py
Normal file
256
backend/tests/test_static_files.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""Tests for static file serving of uploaded images."""
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.main import app
|
||||
from backend.services.image_storage import save_image, IMAGES_ROOT, ensure_image_directories
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_images_directory():
|
||||
"""Create and clean up images directory for each test."""
|
||||
# Ensure directory exists
|
||||
IMAGES_ROOT.mkdir(parents=True, exist_ok=True)
|
||||
yield
|
||||
# Cleanup after test
|
||||
import shutil
|
||||
if IMAGES_ROOT.exists():
|
||||
shutil.rmtree(IMAGES_ROOT)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
"""FastAPI test client."""
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_jpeg_image():
|
||||
"""Create a minimal valid JPEG image for testing."""
|
||||
# Minimal JPEG header + footer
|
||||
jpeg_data = (
|
||||
b'\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00'
|
||||
b'\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t'
|
||||
b'\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a'
|
||||
b'\x1f\x1e\x1d\x1a\x1c\x1c $.\' ",#\x1c\x1c(7),01444\x1f\''
|
||||
b'\x9898_-282-\xff\xc0\x00\x0b\x08\x00\x01\x00\x01\x01\x11\x00'
|
||||
b'\xff\xc4\x00\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00'
|
||||
b'\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08'
|
||||
b'\t\n\x0b\xff\xda\x00\x08\x01\x01\x00\x00?\x00\x7f\x00\xff\xd9'
|
||||
)
|
||||
return jpeg_data
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_png_image():
|
||||
"""Create a minimal valid PNG image for testing."""
|
||||
# Create a minimal valid PNG programmatically using PIL
|
||||
try:
|
||||
from PIL import Image
|
||||
from io import BytesIO
|
||||
img = Image.new('RGB', (1, 1), color='white')
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
return buffer.getvalue()
|
||||
except ImportError:
|
||||
# Fallback to hardcoded PNG if PIL not available
|
||||
# This is a valid minimal PNG that should be detected correctly
|
||||
png_data = (
|
||||
b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00'
|
||||
b'\x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDATx'
|
||||
b'\x9cc\xf8\xcf\xc0\x00\x00\x00\x03\x00\x01\xfc\x83\t\xbf\x00'
|
||||
b'\x00\x00\x00IEND\xaeB`\x82'
|
||||
)
|
||||
return png_data
|
||||
|
||||
|
||||
class TestStaticFileServing:
|
||||
"""Test static file serving for /images/ directory."""
|
||||
|
||||
def test_images_directory_exists_on_startup(self):
|
||||
"""Verify /images directory is created on startup."""
|
||||
assert IMAGES_ROOT.exists(), f"Images directory {IMAGES_ROOT} should exist"
|
||||
assert IMAGES_ROOT.is_dir(), f"{IMAGES_ROOT} should be a directory"
|
||||
|
||||
def test_serve_uploaded_jpeg_image(self, client, sample_jpeg_image):
|
||||
"""Test serving a JPEG image uploaded to /images/."""
|
||||
# Save image directly using image storage utility
|
||||
image_path = save_image(
|
||||
file_bytes=sample_jpeg_image,
|
||||
category="test_category",
|
||||
filename_base="test_image",
|
||||
variant="original"
|
||||
)
|
||||
|
||||
# Request the image via static file mount
|
||||
response = client.get(image_path)
|
||||
|
||||
# Verify response
|
||||
assert response.status_code == 200, f"Expected 200, got {response.status_code}"
|
||||
assert response.headers["content-type"] == "image/jpeg"
|
||||
assert response.content == sample_jpeg_image
|
||||
|
||||
def test_serve_uploaded_png_image(self, client, sample_png_image):
|
||||
"""Test serving a PNG image uploaded to /images/."""
|
||||
image_path = save_image(
|
||||
file_bytes=sample_png_image,
|
||||
category="test_category",
|
||||
filename_base="test_png",
|
||||
variant="original"
|
||||
)
|
||||
|
||||
response = client.get(image_path)
|
||||
|
||||
# Note: save_image always saves with .jpg extension
|
||||
assert response.status_code == 200
|
||||
# Content is served back correctly (extension determines MIME type)
|
||||
assert response.content == sample_png_image
|
||||
|
||||
def test_serve_thumbnail_variant(self, client, sample_jpeg_image):
|
||||
"""Test serving thumbnail variant of an image."""
|
||||
image_path = save_image(
|
||||
file_bytes=sample_jpeg_image,
|
||||
category="test_category",
|
||||
filename_base="test_image",
|
||||
variant="thumb"
|
||||
)
|
||||
|
||||
response = client.get(image_path)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "image/jpeg"
|
||||
|
||||
def test_404_for_nonexistent_image(self, client):
|
||||
"""Test 404 response for non-existent image."""
|
||||
response = client.get("/images/nonexistent/missing.jpg")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_directory_traversal_protection(self, client):
|
||||
"""Test that directory traversal attempts are blocked."""
|
||||
# Try various directory traversal patterns
|
||||
traversal_paths = [
|
||||
"/images/../../../etc/passwd",
|
||||
"/images/test/../../etc/passwd",
|
||||
"/images/test/..%2F..%2Fetc%2Fpasswd",
|
||||
]
|
||||
|
||||
for path in traversal_paths:
|
||||
response = client.get(path)
|
||||
# Should either be 404 or 400, not 200
|
||||
assert response.status_code in [400, 404], \
|
||||
f"Path {path} should not be accessible, got {response.status_code}"
|
||||
|
||||
def test_serve_multiple_categories(self, client, sample_jpeg_image, sample_png_image):
|
||||
"""Test serving images from multiple categories."""
|
||||
# Save images to different categories
|
||||
jpeg_path = save_image(
|
||||
file_bytes=sample_jpeg_image,
|
||||
category="memory",
|
||||
filename_base="ddr4_stick",
|
||||
variant="original"
|
||||
)
|
||||
|
||||
png_path = save_image(
|
||||
file_bytes=sample_png_image,
|
||||
category="networking",
|
||||
filename_base="sfp_transceiver",
|
||||
variant="original"
|
||||
)
|
||||
|
||||
# Verify both can be served
|
||||
jpeg_response = client.get(jpeg_path)
|
||||
png_response = client.get(png_path)
|
||||
|
||||
assert jpeg_response.status_code == 200
|
||||
assert jpeg_response.headers["content-type"] == "image/jpeg"
|
||||
assert png_response.status_code == 200
|
||||
# Both saved as .jpg due to save_image implementation
|
||||
assert png_response.headers["content-type"] in ["image/jpeg", "image/jpg"]
|
||||
|
||||
def test_content_matches_uploaded_file(self, client, sample_jpeg_image):
|
||||
"""Verify that served content exactly matches uploaded file."""
|
||||
image_path = save_image(
|
||||
file_bytes=sample_jpeg_image,
|
||||
category="test_category",
|
||||
filename_base="test_image",
|
||||
variant="original"
|
||||
)
|
||||
|
||||
response = client.get(image_path)
|
||||
|
||||
# Content-length should match
|
||||
assert len(response.content) == len(sample_jpeg_image)
|
||||
# Content should match byte-for-byte
|
||||
assert response.content == sample_jpeg_image
|
||||
|
||||
def test_mime_type_auto_detection_jpeg(self, client, sample_jpeg_image):
|
||||
"""Test MIME type is auto-detected correctly for JPEG."""
|
||||
image_path = save_image(
|
||||
file_bytes=sample_jpeg_image,
|
||||
category="test_category",
|
||||
filename_base="jpeg_file",
|
||||
variant="original"
|
||||
)
|
||||
|
||||
response = client.get(image_path)
|
||||
|
||||
assert response.headers["content-type"] in ["image/jpeg", "image/jpg"]
|
||||
|
||||
def test_mime_type_auto_detection_all_saved_as_jpeg(self, client, sample_png_image):
|
||||
"""Test MIME type detection for images saved as JPEG (all images saved as .jpg)."""
|
||||
# Note: save_image always saves with .jpg extension regardless of input format
|
||||
image_path = save_image(
|
||||
file_bytes=sample_png_image,
|
||||
category="test_category",
|
||||
filename_base="png_file",
|
||||
variant="original"
|
||||
)
|
||||
|
||||
response = client.get(image_path)
|
||||
|
||||
# File extension is .jpg, so MIME type will be image/jpeg
|
||||
assert response.headers["content-type"] in ["image/jpeg", "image/jpg"]
|
||||
|
||||
def test_original_and_thumbnail_variants_accessible(self, client, sample_jpeg_image):
|
||||
"""Test both original and thumbnail variants are accessible."""
|
||||
original_path = save_image(
|
||||
file_bytes=sample_jpeg_image,
|
||||
category="test_category",
|
||||
filename_base="multi_variant",
|
||||
variant="original"
|
||||
)
|
||||
|
||||
thumb_path = save_image(
|
||||
file_bytes=sample_jpeg_image,
|
||||
category="test_category",
|
||||
filename_base="multi_variant",
|
||||
variant="thumb"
|
||||
)
|
||||
|
||||
# Both should be accessible
|
||||
original_response = client.get(original_path)
|
||||
thumb_response = client.get(thumb_path)
|
||||
|
||||
assert original_response.status_code == 200
|
||||
assert thumb_response.status_code == 200
|
||||
|
||||
def test_images_served_with_correct_path_structure(self, client, sample_jpeg_image):
|
||||
"""Test images are served at /images/{category}/{filename} path."""
|
||||
image_path = save_image(
|
||||
file_bytes=sample_jpeg_image,
|
||||
category="storage",
|
||||
filename_base="ssd_drive",
|
||||
variant="original"
|
||||
)
|
||||
|
||||
# Path should be /images/storage/ssd_drive_original.jpg
|
||||
assert image_path.startswith("/images/"), "Path should start with /images/"
|
||||
assert "storage" in image_path, "Path should include category"
|
||||
|
||||
response = client.get(image_path)
|
||||
assert response.status_code == 200
|
||||
BIN
images/networking/testitem_0f680a75_thumb.jpg
Normal file
BIN
images/networking/testitem_0f680a75_thumb.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 541 B |
BIN
images/networking/testitem_9c6ab2f8_thumb.jpg
Normal file
BIN
images/networking/testitem_9c6ab2f8_thumb.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 541 B |
BIN
images/networking/testitem_original.jpg
Normal file
BIN
images/networking/testitem_original.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 361 B |
BIN
images/networking/testitem_thumb.jpg
Normal file
BIN
images/networking/testitem_thumb.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 541 B |
469
venv/lib/python3.12/site-packages/magic/__init__.py
Normal file
469
venv/lib/python3.12/site-packages/magic/__init__.py
Normal file
@@ -0,0 +1,469 @@
|
||||
"""
|
||||
magic is a wrapper around the libmagic file identification library.
|
||||
|
||||
See README for more information.
|
||||
|
||||
Usage:
|
||||
|
||||
>>> import magic
|
||||
>>> magic.from_file("testdata/test.pdf")
|
||||
'PDF document, version 1.2'
|
||||
>>> magic.from_file("testdata/test.pdf", mime=True)
|
||||
'application/pdf'
|
||||
>>> magic.from_buffer(open("testdata/test.pdf").read(1024))
|
||||
'PDF document, version 1.2'
|
||||
>>>
|
||||
|
||||
"""
|
||||
|
||||
import sys
|
||||
import glob
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import threading
|
||||
import logging
|
||||
|
||||
from ctypes import c_char_p, c_int, c_size_t, c_void_p, byref, POINTER
|
||||
|
||||
# avoid shadowing the real open with the version from compat.py
|
||||
_real_open = open
|
||||
|
||||
|
||||
class MagicException(Exception):
|
||||
def __init__(self, message):
|
||||
super(Exception, self).__init__(message)
|
||||
self.message = message
|
||||
|
||||
|
||||
class Magic:
|
||||
"""
|
||||
Magic is a wrapper around the libmagic C library.
|
||||
"""
|
||||
|
||||
def __init__(self, mime=False, magic_file=None, mime_encoding=False,
|
||||
keep_going=False, uncompress=False, raw=False, extension=False):
|
||||
"""
|
||||
Create a new libmagic wrapper.
|
||||
|
||||
mime - if True, mimetypes are returned instead of textual descriptions
|
||||
mime_encoding - if True, codec is returned
|
||||
magic_file - use a mime database other than the system default
|
||||
keep_going - don't stop at the first match, keep going
|
||||
uncompress - Try to look inside compressed files.
|
||||
raw - Do not try to decode "non-printable" chars.
|
||||
extension - Print a slash-separated list of valid extensions for the file type found.
|
||||
"""
|
||||
self.flags = MAGIC_NONE
|
||||
if mime:
|
||||
self.flags |= MAGIC_MIME_TYPE
|
||||
if mime_encoding:
|
||||
self.flags |= MAGIC_MIME_ENCODING
|
||||
if keep_going:
|
||||
self.flags |= MAGIC_CONTINUE
|
||||
if uncompress:
|
||||
self.flags |= MAGIC_COMPRESS
|
||||
if raw:
|
||||
self.flags |= MAGIC_RAW
|
||||
if extension:
|
||||
self.flags |= MAGIC_EXTENSION
|
||||
|
||||
self.cookie = magic_open(self.flags)
|
||||
self.lock = threading.Lock()
|
||||
|
||||
magic_load(self.cookie, magic_file)
|
||||
|
||||
# MAGIC_EXTENSION was added in 523 or 524, so bail if
|
||||
# it doesn't appear to be available
|
||||
if extension and (not _has_version or version() < 524):
|
||||
raise NotImplementedError('MAGIC_EXTENSION is not supported in this version of libmagic')
|
||||
|
||||
# For https://github.com/ahupp/python-magic/issues/190
|
||||
# libmagic has fixed internal limits that some files exceed, causing
|
||||
# an error. We can avoid this (at least for the sample file given)
|
||||
# by bumping the limit up. It's not clear if this is a general solution
|
||||
# or whether other internal limits should be increased, but given
|
||||
# the lack of other reports I'll assume this is rare.
|
||||
if _has_param:
|
||||
try:
|
||||
self.setparam(MAGIC_PARAM_NAME_MAX, 64)
|
||||
except MagicException as e:
|
||||
# some versions of libmagic fail this call,
|
||||
# so rather than fail hard just use default behavior
|
||||
pass
|
||||
|
||||
def from_buffer(self, buf):
|
||||
"""
|
||||
Identify the contents of `buf`
|
||||
"""
|
||||
with self.lock:
|
||||
try:
|
||||
# if we're on python3, convert buf to bytes
|
||||
# otherwise this string is passed as wchar*
|
||||
# which is not what libmagic expects
|
||||
# NEXTBREAK: only take bytes
|
||||
if type(buf) == str and str != bytes:
|
||||
buf = buf.encode('utf-8', errors='replace')
|
||||
return maybe_decode(magic_buffer(self.cookie, buf))
|
||||
except MagicException as e:
|
||||
return self._handle509Bug(e)
|
||||
|
||||
def from_file(self, filename):
|
||||
# raise FileNotFoundException or IOError if the file does not exist
|
||||
with _real_open(filename):
|
||||
pass
|
||||
|
||||
with self.lock:
|
||||
try:
|
||||
return maybe_decode(magic_file(self.cookie, filename))
|
||||
except MagicException as e:
|
||||
return self._handle509Bug(e)
|
||||
|
||||
def from_descriptor(self, fd):
|
||||
with self.lock:
|
||||
try:
|
||||
return maybe_decode(magic_descriptor(self.cookie, fd))
|
||||
except MagicException as e:
|
||||
return self._handle509Bug(e)
|
||||
|
||||
def _handle509Bug(self, e):
|
||||
# libmagic 5.09 has a bug where it might fail to identify the
|
||||
# mimetype of a file and returns null from magic_file (and
|
||||
# likely _buffer), but also does not return an error message.
|
||||
if e.message is None and (self.flags & MAGIC_MIME_TYPE):
|
||||
return "application/octet-stream"
|
||||
else:
|
||||
raise e
|
||||
|
||||
def setparam(self, param, val):
|
||||
return magic_setparam(self.cookie, param, val)
|
||||
|
||||
def getparam(self, param):
|
||||
return magic_getparam(self.cookie, param)
|
||||
|
||||
def __del__(self):
|
||||
# no _thread_check here because there can be no other
|
||||
# references to this object at this point.
|
||||
|
||||
# during shutdown magic_close may have been cleared already so
|
||||
# make sure it exists before using it.
|
||||
|
||||
# the self.cookie check should be unnecessary and was an
|
||||
# incorrect fix for a threading problem, however I'm leaving
|
||||
# it in because it's harmless and I'm slightly afraid to
|
||||
# remove it.
|
||||
if hasattr(self, 'cookie') and self.cookie and magic_close:
|
||||
magic_close(self.cookie)
|
||||
self.cookie = None
|
||||
|
||||
|
||||
_instances = {}
|
||||
|
||||
|
||||
def _get_magic_type(mime):
|
||||
i = _instances.get(mime)
|
||||
if i is None:
|
||||
i = _instances[mime] = Magic(mime=mime)
|
||||
return i
|
||||
|
||||
|
||||
def from_file(filename, mime=False):
|
||||
""""
|
||||
Accepts a filename and returns the detected filetype. Return
|
||||
value is the mimetype if mime=True, otherwise a human readable
|
||||
name.
|
||||
|
||||
>>> magic.from_file("testdata/test.pdf", mime=True)
|
||||
'application/pdf'
|
||||
"""
|
||||
m = _get_magic_type(mime)
|
||||
return m.from_file(filename)
|
||||
|
||||
|
||||
def from_buffer(buffer, mime=False):
|
||||
"""
|
||||
Accepts a binary string and returns the detected filetype. Return
|
||||
value is the mimetype if mime=True, otherwise a human readable
|
||||
name.
|
||||
|
||||
>>> magic.from_buffer(open("testdata/test.pdf").read(1024))
|
||||
'PDF document, version 1.2'
|
||||
"""
|
||||
m = _get_magic_type(mime)
|
||||
return m.from_buffer(buffer)
|
||||
|
||||
|
||||
def from_descriptor(fd, mime=False):
|
||||
"""
|
||||
Accepts a file descriptor and returns the detected filetype. Return
|
||||
value is the mimetype if mime=True, otherwise a human readable
|
||||
name.
|
||||
|
||||
>>> f = open("testdata/test.pdf")
|
||||
>>> magic.from_descriptor(f.fileno())
|
||||
'PDF document, version 1.2'
|
||||
"""
|
||||
m = _get_magic_type(mime)
|
||||
return m.from_descriptor(fd)
|
||||
|
||||
from . import loader
|
||||
libmagic = loader.load_lib()
|
||||
|
||||
magic_t = ctypes.c_void_p
|
||||
|
||||
|
||||
def errorcheck_null(result, func, args):
|
||||
if result is None:
|
||||
err = magic_error(args[0])
|
||||
raise MagicException(err)
|
||||
else:
|
||||
return result
|
||||
|
||||
|
||||
def errorcheck_negative_one(result, func, args):
|
||||
if result == -1:
|
||||
err = magic_error(args[0])
|
||||
raise MagicException(err)
|
||||
else:
|
||||
return result
|
||||
|
||||
|
||||
# return str on python3. Don't want to unconditionally
|
||||
# decode because that results in unicode on python2
|
||||
def maybe_decode(s):
|
||||
# NEXTBREAK: remove
|
||||
if str == bytes:
|
||||
return s
|
||||
else:
|
||||
# backslashreplace here because sometimes libmagic will return metadata in the charset
|
||||
# of the file, which is unknown to us (e.g the title of a Word doc)
|
||||
return s.decode('utf-8', 'backslashreplace')
|
||||
|
||||
|
||||
try:
|
||||
from os import PathLike
|
||||
def unpath(filename):
|
||||
if isinstance(filename, PathLike):
|
||||
return filename.__fspath__()
|
||||
else:
|
||||
return filename
|
||||
except ImportError:
|
||||
def unpath(filename):
|
||||
return filename
|
||||
|
||||
def coerce_filename(filename):
|
||||
if filename is None:
|
||||
return None
|
||||
|
||||
filename = unpath(filename)
|
||||
|
||||
# ctypes will implicitly convert unicode strings to bytes with
|
||||
# .encode('ascii'). If you use the filesystem encoding
|
||||
# then you'll get inconsistent behavior (crashes) depending on the user's
|
||||
# LANG environment variable
|
||||
# NEXTBREAK: remove
|
||||
is_unicode = (sys.version_info[0] <= 2 and
|
||||
isinstance(filename, unicode)) or \
|
||||
(sys.version_info[0] >= 3 and
|
||||
isinstance(filename, str))
|
||||
if is_unicode:
|
||||
return filename.encode('utf-8', 'surrogateescape')
|
||||
else:
|
||||
return filename
|
||||
|
||||
|
||||
magic_open = libmagic.magic_open
|
||||
magic_open.restype = magic_t
|
||||
magic_open.argtypes = [c_int]
|
||||
|
||||
magic_close = libmagic.magic_close
|
||||
magic_close.restype = None
|
||||
magic_close.argtypes = [magic_t]
|
||||
|
||||
magic_error = libmagic.magic_error
|
||||
magic_error.restype = c_char_p
|
||||
magic_error.argtypes = [magic_t]
|
||||
|
||||
magic_errno = libmagic.magic_errno
|
||||
magic_errno.restype = c_int
|
||||
magic_errno.argtypes = [magic_t]
|
||||
|
||||
_magic_file = libmagic.magic_file
|
||||
_magic_file.restype = c_char_p
|
||||
_magic_file.argtypes = [magic_t, c_char_p]
|
||||
_magic_file.errcheck = errorcheck_null
|
||||
|
||||
|
||||
def magic_file(cookie, filename):
|
||||
return _magic_file(cookie, coerce_filename(filename))
|
||||
|
||||
|
||||
_magic_buffer = libmagic.magic_buffer
|
||||
_magic_buffer.restype = c_char_p
|
||||
_magic_buffer.argtypes = [magic_t, c_void_p, c_size_t]
|
||||
_magic_buffer.errcheck = errorcheck_null
|
||||
|
||||
|
||||
def magic_buffer(cookie, buf):
|
||||
return _magic_buffer(cookie, buf, len(buf))
|
||||
|
||||
|
||||
magic_descriptor = libmagic.magic_descriptor
|
||||
magic_descriptor.restype = c_char_p
|
||||
magic_descriptor.argtypes = [magic_t, c_int]
|
||||
magic_descriptor.errcheck = errorcheck_null
|
||||
|
||||
_magic_descriptor = libmagic.magic_descriptor
|
||||
_magic_descriptor.restype = c_char_p
|
||||
_magic_descriptor.argtypes = [magic_t, c_int]
|
||||
_magic_descriptor.errcheck = errorcheck_null
|
||||
|
||||
|
||||
def magic_descriptor(cookie, fd):
|
||||
return _magic_descriptor(cookie, fd)
|
||||
|
||||
|
||||
_magic_load = libmagic.magic_load
|
||||
_magic_load.restype = c_int
|
||||
_magic_load.argtypes = [magic_t, c_char_p]
|
||||
_magic_load.errcheck = errorcheck_negative_one
|
||||
|
||||
|
||||
def magic_load(cookie, filename):
|
||||
return _magic_load(cookie, coerce_filename(filename))
|
||||
|
||||
|
||||
magic_setflags = libmagic.magic_setflags
|
||||
magic_setflags.restype = c_int
|
||||
magic_setflags.argtypes = [magic_t, c_int]
|
||||
|
||||
magic_check = libmagic.magic_check
|
||||
magic_check.restype = c_int
|
||||
magic_check.argtypes = [magic_t, c_char_p]
|
||||
|
||||
magic_compile = libmagic.magic_compile
|
||||
magic_compile.restype = c_int
|
||||
magic_compile.argtypes = [magic_t, c_char_p]
|
||||
|
||||
_has_param = False
|
||||
if hasattr(libmagic, 'magic_setparam') and hasattr(libmagic, 'magic_getparam'):
|
||||
_has_param = True
|
||||
_magic_setparam = libmagic.magic_setparam
|
||||
_magic_setparam.restype = c_int
|
||||
_magic_setparam.argtypes = [magic_t, c_int, POINTER(c_size_t)]
|
||||
_magic_setparam.errcheck = errorcheck_negative_one
|
||||
|
||||
_magic_getparam = libmagic.magic_getparam
|
||||
_magic_getparam.restype = c_int
|
||||
_magic_getparam.argtypes = [magic_t, c_int, POINTER(c_size_t)]
|
||||
_magic_getparam.errcheck = errorcheck_negative_one
|
||||
|
||||
|
||||
def magic_setparam(cookie, param, val):
|
||||
if not _has_param:
|
||||
raise NotImplementedError("magic_setparam not implemented")
|
||||
v = c_size_t(val)
|
||||
return _magic_setparam(cookie, param, byref(v))
|
||||
|
||||
|
||||
def magic_getparam(cookie, param):
|
||||
if not _has_param:
|
||||
raise NotImplementedError("magic_getparam not implemented")
|
||||
val = c_size_t()
|
||||
_magic_getparam(cookie, param, byref(val))
|
||||
return val.value
|
||||
|
||||
|
||||
_has_version = False
|
||||
if hasattr(libmagic, "magic_version"):
|
||||
_has_version = True
|
||||
magic_version = libmagic.magic_version
|
||||
magic_version.restype = c_int
|
||||
magic_version.argtypes = []
|
||||
|
||||
|
||||
def version():
|
||||
if not _has_version:
|
||||
raise NotImplementedError("magic_version not implemented")
|
||||
return magic_version()
|
||||
|
||||
|
||||
MAGIC_NONE = 0x000000 # No flags
|
||||
MAGIC_DEBUG = 0x000001 # Turn on debugging
|
||||
MAGIC_SYMLINK = 0x000002 # Follow symlinks
|
||||
MAGIC_COMPRESS = 0x000004 # Check inside compressed files
|
||||
MAGIC_DEVICES = 0x000008 # Look at the contents of devices
|
||||
MAGIC_MIME_TYPE = 0x000010 # Return a mime string
|
||||
MAGIC_MIME_ENCODING = 0x000400 # Return the MIME encoding
|
||||
# TODO: should be
|
||||
# MAGIC_MIME = MAGIC_MIME_TYPE | MAGIC_MIME_ENCODING
|
||||
MAGIC_MIME = 0x000010 # Return a mime string
|
||||
MAGIC_EXTENSION = 0x1000000 # Return a /-separated list of extensions
|
||||
|
||||
MAGIC_CONTINUE = 0x000020 # Return all matches
|
||||
MAGIC_CHECK = 0x000040 # Print warnings to stderr
|
||||
MAGIC_PRESERVE_ATIME = 0x000080 # Restore access time on exit
|
||||
MAGIC_RAW = 0x000100 # Don't translate unprintable chars
|
||||
MAGIC_ERROR = 0x000200 # Handle ENOENT etc as real errors
|
||||
|
||||
MAGIC_NO_CHECK_COMPRESS = 0x001000 # Don't check for compressed files
|
||||
MAGIC_NO_CHECK_TAR = 0x002000 # Don't check for tar files
|
||||
MAGIC_NO_CHECK_SOFT = 0x004000 # Don't check magic entries
|
||||
MAGIC_NO_CHECK_APPTYPE = 0x008000 # Don't check application type
|
||||
MAGIC_NO_CHECK_ELF = 0x010000 # Don't check for elf details
|
||||
MAGIC_NO_CHECK_ASCII = 0x020000 # Don't check for ascii files
|
||||
MAGIC_NO_CHECK_TROFF = 0x040000 # Don't check ascii/troff
|
||||
MAGIC_NO_CHECK_FORTRAN = 0x080000 # Don't check ascii/fortran
|
||||
MAGIC_NO_CHECK_TOKENS = 0x100000 # Don't check ascii/tokens
|
||||
|
||||
MAGIC_PARAM_INDIR_MAX = 0 # Recursion limit for indirect magic
|
||||
MAGIC_PARAM_NAME_MAX = 1 # Use count limit for name/use magic
|
||||
MAGIC_PARAM_ELF_PHNUM_MAX = 2 # Max ELF notes processed
|
||||
MAGIC_PARAM_ELF_SHNUM_MAX = 3 # Max ELF program sections processed
|
||||
MAGIC_PARAM_ELF_NOTES_MAX = 4 # # Max ELF sections processed
|
||||
MAGIC_PARAM_REGEX_MAX = 5 # Length limit for regex searches
|
||||
MAGIC_PARAM_BYTES_MAX = 6 # Max number of bytes to read from file
|
||||
|
||||
|
||||
# This package name conflicts with the one provided by upstream
|
||||
# libmagic. This is a common source of confusion for users. To
|
||||
# resolve, We ship a copy of that module, and expose it's functions
|
||||
# wrapped in deprecation warnings.
|
||||
def _add_compat(to_module):
|
||||
import warnings, re
|
||||
from magic import compat
|
||||
|
||||
def deprecation_wrapper(fn):
|
||||
def _(*args, **kwargs):
|
||||
warnings.warn(
|
||||
"Using compatibility mode with libmagic's python binding. "
|
||||
"See https://github.com/ahupp/python-magic/blob/master/COMPAT.md for details.",
|
||||
PendingDeprecationWarning)
|
||||
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return _
|
||||
|
||||
fn = ['detect_from_filename',
|
||||
'detect_from_content',
|
||||
'detect_from_fobj',
|
||||
'open']
|
||||
for fname in fn:
|
||||
to_module[fname] = deprecation_wrapper(compat.__dict__[fname])
|
||||
|
||||
# copy constants over, ensuring there's no conflicts
|
||||
is_const_re = re.compile("^[A-Z_]+$")
|
||||
allowed_inconsistent = set(['MAGIC_MIME'])
|
||||
for name, value in compat.__dict__.items():
|
||||
if is_const_re.match(name):
|
||||
if name in to_module:
|
||||
if name in allowed_inconsistent:
|
||||
continue
|
||||
if to_module[name] != value:
|
||||
raise Exception("inconsistent value for " + name)
|
||||
else:
|
||||
continue
|
||||
else:
|
||||
to_module[name] = value
|
||||
|
||||
|
||||
_add_compat(globals())
|
||||
87
venv/lib/python3.12/site-packages/magic/__init__.pyi
Normal file
87
venv/lib/python3.12/site-packages/magic/__init__.pyi
Normal file
@@ -0,0 +1,87 @@
|
||||
import ctypes.util
|
||||
import threading
|
||||
from typing import Any, Text, Optional, Union
|
||||
from os import PathLike
|
||||
|
||||
class MagicException(Exception):
|
||||
message: Any = ...
|
||||
def __init__(self, message: Any) -> None: ...
|
||||
|
||||
class Magic:
|
||||
flags: int = ...
|
||||
cookie: Any = ...
|
||||
lock: threading.Lock = ...
|
||||
def __init__(self, mime: bool = ..., magic_file: Optional[Any] = ..., mime_encoding: bool = ..., keep_going: bool = ..., uncompress: bool = ..., raw: bool = ...) -> None: ...
|
||||
def from_buffer(self, buf: Union[bytes, str]) -> Text: ...
|
||||
def from_file(self, filename: Union[bytes, str, PathLike]) -> Text: ...
|
||||
def from_descriptor(self, fd: int, mime: bool = ...) -> Text: ...
|
||||
def setparam(self, param: Any, val: Any): ...
|
||||
def getparam(self, param: Any): ...
|
||||
def __del__(self) -> None: ...
|
||||
|
||||
def from_file(filename: Union[bytes, str, PathLike], mime: bool = ...) -> Text: ...
|
||||
def from_buffer(buffer: Union[bytes, str], mime: bool = ...) -> Text: ...
|
||||
def from_descriptor(fd: int, mime: bool = ...) -> Text: ...
|
||||
|
||||
libmagic: Any
|
||||
dll: Any
|
||||
windows_dlls: Any
|
||||
platform_to_lib: Any
|
||||
platform: Any
|
||||
magic_t = ctypes.c_void_p
|
||||
|
||||
def errorcheck_null(result: Any, func: Any, args: Any): ...
|
||||
def errorcheck_negative_one(result: Any, func: Any, args: Any): ...
|
||||
def maybe_decode(s: Union[bytes, str]) -> str: ...
|
||||
def coerce_filename(filename: Any): ...
|
||||
|
||||
magic_open: Any
|
||||
magic_close: Any
|
||||
magic_error: Any
|
||||
magic_errno: Any
|
||||
|
||||
def magic_file(cookie: Any, filename: Any): ...
|
||||
def magic_buffer(cookie: Any, buf: Any): ...
|
||||
def magic_descriptor(cookie: Any, fd: int): ...
|
||||
def magic_load(cookie: Any, filename: Any): ...
|
||||
|
||||
magic_setflags: Any
|
||||
magic_check: Any
|
||||
magic_compile: Any
|
||||
|
||||
def magic_setparam(cookie: Any, param: Any, val: Any): ...
|
||||
def magic_getparam(cookie: Any, param: Any): ...
|
||||
|
||||
magic_version: Any
|
||||
|
||||
def version(): ...
|
||||
|
||||
MAGIC_NONE: int
|
||||
MAGIC_DEBUG: int
|
||||
MAGIC_SYMLINK: int
|
||||
MAGIC_COMPRESS: int
|
||||
MAGIC_DEVICES: int
|
||||
MAGIC_MIME_TYPE: int
|
||||
MAGIC_MIME_ENCODING: int
|
||||
MAGIC_MIME: int
|
||||
MAGIC_CONTINUE: int
|
||||
MAGIC_CHECK: int
|
||||
MAGIC_PRESERVE_ATIME: int
|
||||
MAGIC_RAW: int
|
||||
MAGIC_ERROR: int
|
||||
MAGIC_NO_CHECK_COMPRESS: int
|
||||
MAGIC_NO_CHECK_TAR: int
|
||||
MAGIC_NO_CHECK_SOFT: int
|
||||
MAGIC_NO_CHECK_APPTYPE: int
|
||||
MAGIC_NO_CHECK_ELF: int
|
||||
MAGIC_NO_CHECK_ASCII: int
|
||||
MAGIC_NO_CHECK_TROFF: int
|
||||
MAGIC_NO_CHECK_FORTRAN: int
|
||||
MAGIC_NO_CHECK_TOKENS: int
|
||||
MAGIC_PARAM_INDIR_MAX: int
|
||||
MAGIC_PARAM_NAME_MAX: int
|
||||
MAGIC_PARAM_ELF_PHNUM_MAX: int
|
||||
MAGIC_PARAM_ELF_SHNUM_MAX: int
|
||||
MAGIC_PARAM_ELF_NOTES_MAX: int
|
||||
MAGIC_PARAM_REGEX_MAX: int
|
||||
MAGIC_PARAM_BYTES_MAX: int
|
||||
287
venv/lib/python3.12/site-packages/magic/compat.py
Normal file
287
venv/lib/python3.12/site-packages/magic/compat.py
Normal file
@@ -0,0 +1,287 @@
|
||||
# coding: utf-8
|
||||
|
||||
'''
|
||||
Python bindings for libmagic
|
||||
'''
|
||||
|
||||
import ctypes
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
from ctypes import *
|
||||
from ctypes.util import find_library
|
||||
|
||||
|
||||
from . import loader
|
||||
|
||||
_libraries = {}
|
||||
_libraries['magic'] = loader.load_lib()
|
||||
|
||||
# Flag constants for open and setflags
|
||||
MAGIC_NONE = NONE = 0
|
||||
MAGIC_DEBUG = DEBUG = 1
|
||||
MAGIC_SYMLINK = SYMLINK = 2
|
||||
MAGIC_COMPRESS = COMPRESS = 4
|
||||
MAGIC_DEVICES = DEVICES = 8
|
||||
MAGIC_MIME_TYPE = MIME_TYPE = 16
|
||||
MAGIC_CONTINUE = CONTINUE = 32
|
||||
MAGIC_CHECK = CHECK = 64
|
||||
MAGIC_PRESERVE_ATIME = PRESERVE_ATIME = 128
|
||||
MAGIC_RAW = RAW = 256
|
||||
MAGIC_ERROR = ERROR = 512
|
||||
MAGIC_MIME_ENCODING = MIME_ENCODING = 1024
|
||||
MAGIC_MIME = MIME = 1040 # MIME_TYPE + MIME_ENCODING
|
||||
MAGIC_APPLE = APPLE = 2048
|
||||
|
||||
MAGIC_NO_CHECK_COMPRESS = NO_CHECK_COMPRESS = 4096
|
||||
MAGIC_NO_CHECK_TAR = NO_CHECK_TAR = 8192
|
||||
MAGIC_NO_CHECK_SOFT = NO_CHECK_SOFT = 16384
|
||||
MAGIC_NO_CHECK_APPTYPE = NO_CHECK_APPTYPE = 32768
|
||||
MAGIC_NO_CHECK_ELF = NO_CHECK_ELF = 65536
|
||||
MAGIC_NO_CHECK_TEXT = NO_CHECK_TEXT = 131072
|
||||
MAGIC_NO_CHECK_CDF = NO_CHECK_CDF = 262144
|
||||
MAGIC_NO_CHECK_TOKENS = NO_CHECK_TOKENS = 1048576
|
||||
MAGIC_NO_CHECK_ENCODING = NO_CHECK_ENCODING = 2097152
|
||||
|
||||
MAGIC_NO_CHECK_BUILTIN = NO_CHECK_BUILTIN = 4173824
|
||||
|
||||
FileMagic = namedtuple('FileMagic', ('mime_type', 'encoding', 'name'))
|
||||
|
||||
|
||||
class magic_set(Structure):
|
||||
pass
|
||||
|
||||
|
||||
magic_set._fields_ = []
|
||||
magic_t = POINTER(magic_set)
|
||||
|
||||
_open = _libraries['magic'].magic_open
|
||||
_open.restype = magic_t
|
||||
_open.argtypes = [c_int]
|
||||
|
||||
_close = _libraries['magic'].magic_close
|
||||
_close.restype = None
|
||||
_close.argtypes = [magic_t]
|
||||
|
||||
_file = _libraries['magic'].magic_file
|
||||
_file.restype = c_char_p
|
||||
_file.argtypes = [magic_t, c_char_p]
|
||||
|
||||
_descriptor = _libraries['magic'].magic_descriptor
|
||||
_descriptor.restype = c_char_p
|
||||
_descriptor.argtypes = [magic_t, c_int]
|
||||
|
||||
_buffer = _libraries['magic'].magic_buffer
|
||||
_buffer.restype = c_char_p
|
||||
_buffer.argtypes = [magic_t, c_void_p, c_size_t]
|
||||
|
||||
_error = _libraries['magic'].magic_error
|
||||
_error.restype = c_char_p
|
||||
_error.argtypes = [magic_t]
|
||||
|
||||
_setflags = _libraries['magic'].magic_setflags
|
||||
_setflags.restype = c_int
|
||||
_setflags.argtypes = [magic_t, c_int]
|
||||
|
||||
_load = _libraries['magic'].magic_load
|
||||
_load.restype = c_int
|
||||
_load.argtypes = [magic_t, c_char_p]
|
||||
|
||||
_compile = _libraries['magic'].magic_compile
|
||||
_compile.restype = c_int
|
||||
_compile.argtypes = [magic_t, c_char_p]
|
||||
|
||||
_check = _libraries['magic'].magic_check
|
||||
_check.restype = c_int
|
||||
_check.argtypes = [magic_t, c_char_p]
|
||||
|
||||
_list = _libraries['magic'].magic_list
|
||||
_list.restype = c_int
|
||||
_list.argtypes = [magic_t, c_char_p]
|
||||
|
||||
_errno = _libraries['magic'].magic_errno
|
||||
_errno.restype = c_int
|
||||
_errno.argtypes = [magic_t]
|
||||
|
||||
|
||||
class Magic(object):
|
||||
def __init__(self, ms):
|
||||
self._magic_t = ms
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Closes the magic database and deallocates any resources used.
|
||||
"""
|
||||
_close(self._magic_t)
|
||||
|
||||
@staticmethod
|
||||
def __tostr(s):
|
||||
if s is None:
|
||||
return None
|
||||
if isinstance(s, str):
|
||||
return s
|
||||
try: # keep Python 2 compatibility
|
||||
return str(s, 'utf-8')
|
||||
except TypeError:
|
||||
return str(s)
|
||||
|
||||
@staticmethod
|
||||
def __tobytes(b):
|
||||
if b is None:
|
||||
return None
|
||||
if isinstance(b, bytes):
|
||||
return b
|
||||
try: # keep Python 2 compatibility
|
||||
return bytes(b, 'utf-8')
|
||||
except TypeError:
|
||||
return bytes(b)
|
||||
|
||||
def file(self, filename):
|
||||
"""
|
||||
Returns a textual description of the contents of the argument passed
|
||||
as a filename or None if an error occurred and the MAGIC_ERROR flag
|
||||
is set. A call to errno() will return the numeric error code.
|
||||
"""
|
||||
return Magic.__tostr(_file(self._magic_t, Magic.__tobytes(filename)))
|
||||
|
||||
def descriptor(self, fd):
|
||||
"""
|
||||
Returns a textual description of the contents of the argument passed
|
||||
as a file descriptor or None if an error occurred and the MAGIC_ERROR
|
||||
flag is set. A call to errno() will return the numeric error code.
|
||||
"""
|
||||
return Magic.__tostr(_descriptor(self._magic_t, fd))
|
||||
|
||||
def buffer(self, buf):
|
||||
"""
|
||||
Returns a textual description of the contents of the argument passed
|
||||
as a buffer or None if an error occurred and the MAGIC_ERROR flag
|
||||
is set. A call to errno() will return the numeric error code.
|
||||
"""
|
||||
return Magic.__tostr(_buffer(self._magic_t, buf, len(buf)))
|
||||
|
||||
def error(self):
|
||||
"""
|
||||
Returns a textual explanation of the last error or None
|
||||
if there was no error.
|
||||
"""
|
||||
return Magic.__tostr(_error(self._magic_t))
|
||||
|
||||
def setflags(self, flags):
|
||||
"""
|
||||
Set flags on the magic object which determine how magic checking
|
||||
behaves; a bitwise OR of the flags described in libmagic(3), but
|
||||
without the MAGIC_ prefix.
|
||||
|
||||
Returns -1 on systems that don't support utime(2) or utimes(2)
|
||||
when PRESERVE_ATIME is set.
|
||||
"""
|
||||
return _setflags(self._magic_t, flags)
|
||||
|
||||
def load(self, filename=None):
|
||||
"""
|
||||
Must be called to load entries in the colon separated list of database
|
||||
files passed as argument or the default database file if no argument
|
||||
before any magic queries can be performed.
|
||||
|
||||
Returns 0 on success and -1 on failure.
|
||||
"""
|
||||
return _load(self._magic_t, Magic.__tobytes(filename))
|
||||
|
||||
def compile(self, dbs):
|
||||
"""
|
||||
Compile entries in the colon separated list of database files
|
||||
passed as argument or the default database file if no argument.
|
||||
The compiled files created are named from the basename(1) of each file
|
||||
argument with ".mgc" appended to it.
|
||||
|
||||
Returns 0 on success and -1 on failure.
|
||||
"""
|
||||
return _compile(self._magic_t, Magic.__tobytes(dbs))
|
||||
|
||||
def check(self, dbs):
|
||||
"""
|
||||
Check the validity of entries in the colon separated list of
|
||||
database files passed as argument or the default database file
|
||||
if no argument.
|
||||
|
||||
Returns 0 on success and -1 on failure.
|
||||
"""
|
||||
return _check(self._magic_t, Magic.__tobytes(dbs))
|
||||
|
||||
def list(self, dbs):
|
||||
"""
|
||||
Check the validity of entries in the colon separated list of
|
||||
database files passed as argument or the default database file
|
||||
if no argument.
|
||||
|
||||
Returns 0 on success and -1 on failure.
|
||||
"""
|
||||
return _list(self._magic_t, Magic.__tobytes(dbs))
|
||||
|
||||
def errno(self):
|
||||
"""
|
||||
Returns a numeric error code. If return value is 0, an internal
|
||||
magic error occurred. If return value is non-zero, the value is
|
||||
an OS error code. Use the errno module or os.strerror() can be used
|
||||
to provide detailed error information.
|
||||
"""
|
||||
return _errno(self._magic_t)
|
||||
|
||||
|
||||
def open(flags):
|
||||
"""
|
||||
Returns a magic object on success and None on failure.
|
||||
Flags argument as for setflags.
|
||||
"""
|
||||
return Magic(_open(flags))
|
||||
|
||||
|
||||
# Objects used by `detect_from_` functions
|
||||
mime_magic = Magic(_open(MAGIC_MIME))
|
||||
mime_magic.load()
|
||||
none_magic = Magic(_open(MAGIC_NONE))
|
||||
none_magic.load()
|
||||
|
||||
|
||||
def _create_filemagic(mime_detected, type_detected):
|
||||
splat = mime_detected.split('; ')
|
||||
mime_type = splat[0]
|
||||
if len(splat) == 2:
|
||||
mime_encoding = splat[1]
|
||||
else:
|
||||
mime_encoding = ''
|
||||
|
||||
return FileMagic(name=type_detected, mime_type=mime_type,
|
||||
encoding=mime_encoding.replace('charset=', ''))
|
||||
|
||||
|
||||
def detect_from_filename(filename):
|
||||
'''Detect mime type, encoding and file type from a filename
|
||||
|
||||
Returns a `FileMagic` namedtuple.
|
||||
'''
|
||||
|
||||
return _create_filemagic(mime_magic.file(filename),
|
||||
none_magic.file(filename))
|
||||
|
||||
|
||||
def detect_from_fobj(fobj):
|
||||
'''Detect mime type, encoding and file type from file-like object
|
||||
|
||||
Returns a `FileMagic` namedtuple.
|
||||
'''
|
||||
|
||||
file_descriptor = fobj.fileno()
|
||||
return _create_filemagic(mime_magic.descriptor(file_descriptor),
|
||||
none_magic.descriptor(file_descriptor))
|
||||
|
||||
|
||||
def detect_from_content(byte_content):
|
||||
'''Detect mime type, encoding and file type from bytes
|
||||
|
||||
Returns a `FileMagic` namedtuple.
|
||||
'''
|
||||
|
||||
return _create_filemagic(mime_magic.buffer(byte_content),
|
||||
none_magic.buffer(byte_content))
|
||||
50
venv/lib/python3.12/site-packages/magic/loader.py
Normal file
50
venv/lib/python3.12/site-packages/magic/loader.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from ctypes.util import find_library
|
||||
import ctypes
|
||||
import sys
|
||||
import glob
|
||||
import os.path
|
||||
|
||||
def _lib_candidates():
|
||||
|
||||
yield find_library('magic')
|
||||
|
||||
if sys.platform == 'darwin':
|
||||
|
||||
paths = [
|
||||
'/opt/local/lib',
|
||||
'/usr/local/lib',
|
||||
'/opt/homebrew/lib',
|
||||
] + glob.glob('/usr/local/Cellar/libmagic/*/lib')
|
||||
|
||||
for i in paths:
|
||||
yield os.path.join(i, 'libmagic.dylib')
|
||||
|
||||
elif sys.platform in ('win32', 'cygwin'):
|
||||
|
||||
prefixes = ['libmagic', 'magic1', 'cygmagic-1', 'libmagic-1', 'msys-magic-1']
|
||||
|
||||
for i in prefixes:
|
||||
# find_library searches in %PATH% but not the current directory,
|
||||
# so look for both
|
||||
yield './%s.dll' % (i,)
|
||||
yield find_library(i)
|
||||
|
||||
elif sys.platform == 'linux':
|
||||
# This is necessary because alpine is bad
|
||||
yield 'libmagic.so.1'
|
||||
|
||||
|
||||
def load_lib():
|
||||
|
||||
for lib in _lib_candidates():
|
||||
# find_library returns None when lib not found
|
||||
if lib is None:
|
||||
continue
|
||||
try:
|
||||
return ctypes.CDLL(lib)
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
# It is better to raise an ImportError since we are importing magic module
|
||||
raise ImportError('failed to find libmagic. Check your installation')
|
||||
|
||||
0
venv/lib/python3.12/site-packages/magic/py.typed
Normal file
0
venv/lib/python3.12/site-packages/magic/py.typed
Normal file
@@ -0,0 +1 @@
|
||||
pip
|
||||
@@ -0,0 +1,58 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2001-2014 Adam Hupp
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
====
|
||||
|
||||
Portions of this package (magic/compat.py and test/libmagic_test.py)
|
||||
are distributed under the following copyright notice:
|
||||
|
||||
|
||||
$File: LEGAL.NOTICE,v 1.15 2006/05/03 18:48:33 christos Exp $
|
||||
Copyright (c) Ian F. Darwin 1986, 1987, 1989, 1990, 1991, 1992, 1994, 1995.
|
||||
Software written by Ian F. Darwin and others;
|
||||
maintained 1994- Christos Zoulas.
|
||||
|
||||
This software is not subject to any export provision of the United States
|
||||
Department of Commerce, and may be exported to any country or planet.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice immediately at the beginning of the file, without modification,
|
||||
this list of conditions, and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in the
|
||||
documentation and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
|
||||
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
||||
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
|
||||
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
|
||||
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGE.
|
||||
@@ -0,0 +1,171 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: python-magic
|
||||
Version: 0.4.27
|
||||
Summary: File type identification using libmagic
|
||||
Home-page: http://github.com/ahupp/python-magic
|
||||
Author: Adam Hupp
|
||||
Author-email: adam@hupp.org
|
||||
License: MIT
|
||||
Keywords: mime magic file
|
||||
Platform: UNKNOWN
|
||||
Classifier: Intended Audience :: Developers
|
||||
Classifier: License :: OSI Approved :: MIT License
|
||||
Classifier: Programming Language :: Python
|
||||
Classifier: Programming Language :: Python :: 2.7
|
||||
Classifier: Programming Language :: Python :: 3
|
||||
Classifier: Programming Language :: Python :: 3.5
|
||||
Classifier: Programming Language :: Python :: 3.6
|
||||
Classifier: Programming Language :: Python :: 3.7
|
||||
Classifier: Programming Language :: Python :: 3.8
|
||||
Classifier: Programming Language :: Python :: 3.9
|
||||
Classifier: Programming Language :: Python :: Implementation :: CPython
|
||||
Requires-Python: >=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*
|
||||
Description-Content-Type: text/markdown
|
||||
License-File: LICENSE
|
||||
|
||||
# python-magic
|
||||
[](https://badge.fury.io/py/python-magic)
|
||||
[](https://travis-ci.org/ahupp/python-magic) [](https://gitter.im/ahupp/python-magic?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
|
||||
|
||||
python-magic is a Python interface to the libmagic file type
|
||||
identification library. libmagic identifies file types by checking
|
||||
their headers according to a predefined list of file types. This
|
||||
functionality is exposed to the command line by the Unix command
|
||||
`file`.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
>>> import magic
|
||||
>>> magic.from_file("testdata/test.pdf")
|
||||
'PDF document, version 1.2'
|
||||
# recommend using at least the first 2048 bytes, as less can produce incorrect identification
|
||||
>>> magic.from_buffer(open("testdata/test.pdf", "rb").read(2048))
|
||||
'PDF document, version 1.2'
|
||||
>>> magic.from_file("testdata/test.pdf", mime=True)
|
||||
'application/pdf'
|
||||
```
|
||||
|
||||
There is also a `Magic` class that provides more direct control,
|
||||
including overriding the magic database file and turning on character
|
||||
encoding detection. This is not recommended for general use. In
|
||||
particular, it's not safe for sharing across multiple threads and
|
||||
will fail throw if this is attempted.
|
||||
|
||||
```python
|
||||
>>> f = magic.Magic(uncompress=True)
|
||||
>>> f.from_file('testdata/test.gz')
|
||||
'ASCII text (gzip compressed data, was "test", last modified: Sat Jun 28
|
||||
21:32:52 2008, from Unix)'
|
||||
```
|
||||
|
||||
You can also combine the flag options:
|
||||
|
||||
```python
|
||||
>>> f = magic.Magic(mime=True, uncompress=True)
|
||||
>>> f.from_file('testdata/test.gz')
|
||||
'text/plain'
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
The current stable version of python-magic is available on PyPI and
|
||||
can be installed by running `pip install python-magic`.
|
||||
|
||||
Other sources:
|
||||
|
||||
- PyPI: http://pypi.python.org/pypi/python-magic/
|
||||
- GitHub: https://github.com/ahupp/python-magic
|
||||
|
||||
This module is a simple wrapper around the libmagic C library, and
|
||||
that must be installed as well:
|
||||
|
||||
### Debian/Ubuntu
|
||||
|
||||
```
|
||||
sudo apt-get install libmagic1
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
You'll need DLLs for libmagic. @julian-r maintains a pypi package with the DLLs, you can fetch it with:
|
||||
|
||||
```
|
||||
pip install python-magic-bin
|
||||
```
|
||||
|
||||
### OSX
|
||||
|
||||
- When using Homebrew: `brew install libmagic`
|
||||
- When using macports: `port install file`
|
||||
|
||||
### Troubleshooting
|
||||
|
||||
- 'MagicException: could not find any magic files!': some
|
||||
installations of libmagic do not correctly point to their magic
|
||||
database file. Try specifying the path to the file explicitly in the
|
||||
constructor: `magic.Magic(magic_file="path_to_magic_file")`.
|
||||
|
||||
- 'WindowsError: [Error 193] %1 is not a valid Win32 application':
|
||||
Attempting to run the 32-bit libmagic DLL in a 64-bit build of
|
||||
python will fail with this error. Here are 64-bit builds of libmagic for windows: https://github.com/pidydx/libmagicwin64.
|
||||
Newer version can be found here: https://github.com/nscaife/file-windows.
|
||||
|
||||
- 'WindowsError: exception: access violation writing 0x00000000 ' This may indicate you are mixing
|
||||
Windows Python and Cygwin Python. Make sure your libmagic and python builds are consistent.
|
||||
|
||||
|
||||
## Bug Reports
|
||||
|
||||
python-magic is a thin layer over the libmagic C library.
|
||||
Historically, most bugs that have been reported against python-magic
|
||||
are actually bugs in libmagic; libmagic bugs can be reported on their
|
||||
tracker here: https://bugs.astron.com/my_view_page.php. If you're not
|
||||
sure where the bug lies feel free to file an issue on GitHub and I can
|
||||
triage it.
|
||||
|
||||
## Running the tests
|
||||
|
||||
To run the tests across a variety of linux distributions (depends on Docker):
|
||||
|
||||
```
|
||||
./test_docker.sh
|
||||
```
|
||||
|
||||
To run tests locally across all available python versions:
|
||||
|
||||
```
|
||||
./test/run.py
|
||||
```
|
||||
|
||||
To run against a specific python version:
|
||||
|
||||
```
|
||||
LC_ALL=en_US.UTF-8 python3 test/test.py
|
||||
```
|
||||
|
||||
## libmagic python API compatibility
|
||||
|
||||
The python bindings shipped with libmagic use a module name that conflicts with this package. To work around this, python-magic includes a compatibility layer for the libmagic API. See [COMPAT.md](COMPAT.md) for a guide to libmagic / python-magic compatibility.
|
||||
|
||||
## Versioning
|
||||
|
||||
Minor version bumps should be backwards compatible. Major bumps are not.
|
||||
|
||||
## Author
|
||||
|
||||
Written by Adam Hupp in 2001 for a project that never got off the
|
||||
ground. It originally used SWIG for the C library bindings, but
|
||||
switched to ctypes once that was part of the python standard library.
|
||||
|
||||
You can contact me via my [website](http://hupp.org/adam) or
|
||||
[GitHub](http://github.com/ahupp).
|
||||
|
||||
## License
|
||||
|
||||
python-magic is distributed under the MIT license. See the included
|
||||
LICENSE file for details.
|
||||
|
||||
I am providing code in the repository to you under an open source license. Because this is my personal repository, the license you receive to my code is from me and not my employer (Facebook).
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
magic/__init__.py,sha256=jDF2AAIlj3uXKjP6x_wEhXYuPrZbl-AF3TAjb0ndeCk,14964
|
||||
magic/__init__.pyi,sha256=CEfbEKmlkr-CRzInSUMZpGPCx4lwvqavtOW3jgy-n0E,2506
|
||||
magic/__pycache__/__init__.cpython-312.pyc,,
|
||||
magic/__pycache__/compat.cpython-312.pyc,,
|
||||
magic/__pycache__/loader.cpython-312.pyc,,
|
||||
magic/compat.py,sha256=OtSQ2C8tpy3NUjKiu-yYGiZzRU-nk-5UY2GCp5o-FpI,8316
|
||||
magic/loader.py,sha256=J5oRRm9940UOf3rv3KImT-2zM9pa_0RRWi-JF0GtjlU,1168
|
||||
magic/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
python_magic-0.4.27.dist-info/INSTALLER,sha256=zuuue4knoyJ-UwPPXg8fezS7VCrXJQrAP7zeNuwvFQg,4
|
||||
python_magic-0.4.27.dist-info/LICENSE,sha256=UIEFOtOtsEI9wMWByxFVpg5OKgVx1aZB0wg8tlsfS7I,2869
|
||||
python_magic-0.4.27.dist-info/METADATA,sha256=QG_S8JCIajigillHcSMKFUtWG4U_ZjTuONJDi5NNZNM,5844
|
||||
python_magic-0.4.27.dist-info/RECORD,,
|
||||
python_magic-0.4.27.dist-info/REQUESTED,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
||||
python_magic-0.4.27.dist-info/WHEEL,sha256=WzZ8cwjh8l0jtULNjYq1Hpr-WCqCRgPr--TX4P5I1Wo,110
|
||||
python_magic-0.4.27.dist-info/top_level.txt,sha256=MDDOZCPmPBiPXW10LHC6RzAJaGX08WaDj_oOOo7sQRc,6
|
||||
@@ -0,0 +1,6 @@
|
||||
Wheel-Version: 1.0
|
||||
Generator: bdist_wheel (0.37.0)
|
||||
Root-Is-Purelib: true
|
||||
Tag: py2-none-any
|
||||
Tag: py3-none-any
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
magic
|
||||
Reference in New Issue
Block a user