diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000000000000000000000000000000000000..39249ed1a2b4edc7e10e4b738b0a899b868a0d3f --- /dev/null +++ b/examples/README.md @@ -0,0 +1,2 @@ +To run examples, use the following command: +`python <example.py>` \ No newline at end of file diff --git a/examples/configuration/config.yaml b/examples/configuration/config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..77c630803c3eae1e6a941b7d78110e391099d3b5 --- /dev/null +++ b/examples/configuration/config.yaml @@ -0,0 +1,44 @@ +# PyFed Configuration Example + +# Core Settings +domain: example.com +debug: false + +# Database +database: + url: postgresql://user:pass@localhost/pyfed + min_connections: 5 + max_connections: 20 + timeout: 30 + +# Redis +redis: + url: redis://localhost + pool_size: 10 + timeout: 30 + +# Security +security: + key_path: keys/ + signature_ttl: 300 + max_payload_size: 5000000 + allowed_algorithms: + - rsa-sha256 + +# Federation +federation: + shared_inbox: true + delivery_timeout: 30 + max_recipients: 100 + retry_delay: 300 + +# Media +media: + upload_path: uploads/ + max_size: 10000000 + allowed_types: + - image/jpeg + - image/png + - image/gif + - video/mp4 + - audio/mpeg \ No newline at end of file diff --git a/examples/federation_example.py b/examples/federation_example.py new file mode 100644 index 0000000000000000000000000000000000000000..8c2d0661af79511f7197bced024ff90d72511b69 --- /dev/null +++ b/examples/federation_example.py @@ -0,0 +1,200 @@ +""" +Federation examples showing common ActivityPub interactions. +""" + +import asyncio +import logging +from datetime import datetime, timezone +from pathlib import Path +import sys + +# Add src directory to Python path +src_path = Path(__file__).parent.parent / "src" +sys.path.insert(0, str(src_path)) + +from pyfed.security.key_management import KeyManager +from pyfed.federation.delivery import ActivityDelivery +from pyfed.federation.discovery import InstanceDiscovery +from pyfed.protocols.webfinger import WebFingerClient +from pyfed.models import APCreate, APNote, APPerson, APFollow, APLike, APAnnounce + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +class FederationExample: + """Federation interaction examples.""" + + def __init__(self, domain: str = "example.com"): + self.domain = domain + self.key_manager = None + self.delivery = None + self.webfinger = None + self.discovery = None + + async def initialize(self): + """Initialize components.""" + # Initialize key manager + self.key_manager = KeyManager( + domain=self.domain, + keys_path=str(Path("example_keys").resolve()) + ) + logger.info("Initializing key manager...") + await self.key_manager.initialize() + + # Initialize delivery + self.delivery = ActivityDelivery( + key_manager=self.key_manager, + timeout=30 + ) + logger.info("Initializing delivery...") + await self.delivery.initialize() + + # Initialize WebFinger client with SSL verification disabled for testing + self.webfinger = WebFingerClient(verify_ssl=False) + logger.info("Initializing WebFinger client...") + await self.webfinger.initialize() + + async def send_public_post(self, content: str): + """Send a public post to the Fediverse.""" + logger.info(f"Sending public post: {content}") + + # Create local actor + actor = APPerson( + id=f"https://{self.domain}/users/alice", + name="Alice", + preferred_username="alice", + inbox=f"https://{self.domain}/users/alice/inbox", + outbox=f"https://{self.domain}/users/alice/outbox" + ) + + # Create note + note = APNote( + id=f"https://{self.domain}/notes/{datetime.utcnow().timestamp()}", + content=content, + attributed_to=str(actor.id), + to=["https://www.w3.org/ns/activitystreams#Public"], + published=datetime.utcnow().isoformat() + ) + + # Create activity + create_activity = APCreate( + id=f"https://{self.domain}/activities/{datetime.utcnow().timestamp()}", + actor=str(actor.id), + object=note, + to=note.to, + published=datetime.utcnow().isoformat() + ) + + # Deliver to followers (example) + activity_dict = create_activity.serialize() + result = await self.delivery.deliver_activity( + activity=activity_dict, + recipients=[f"https://{self.domain}/followers"] + ) + logger.info(f"Delivery result: {result}") + + async def send_direct_message(self, recipient: str, content: str): + """Send a direct message to a specific user.""" + logger.info(f"Sending direct message to {recipient}") + + # Resolve recipient's inbox + inbox_url = await self.webfinger.get_inbox_url(recipient) + if not inbox_url: + logger.error(f"Could not find inbox for {recipient}") + return + + # Create note + note = APNote( + id=f"https://{self.domain}/notes/{datetime.utcnow().timestamp()}", + content=content, + attributed_to=f"https://{self.domain}/users/alice", + to=[inbox_url], + published=datetime.utcnow().isoformat() + ) + + # Create activity + create_activity = APCreate( + id=f"https://{self.domain}/activities/{datetime.utcnow().timestamp()}", + actor=f"https://{self.domain}/users/alice", + object=note, + to=note.to, + published=datetime.utcnow().isoformat() + ) + + # Deliver direct message + activity_dict = create_activity.serialize() + result = await self.delivery.deliver_activity( + activity=activity_dict, + recipients=[inbox_url] + ) + logger.info(f"Delivery result: {result}") + + async def follow_account(self, account: str): + """Follow a remote account.""" + logger.info(f"Following account: {account}") + + # Resolve account + actor_url = await self.webfinger.get_actor_url(account) + if not actor_url: + logger.error(f"Could not resolve account {account}") + return + + # Create Follow activity + follow = APFollow( + id=f"https://{self.domain}/activities/follow_{datetime.utcnow().timestamp()}", + actor=f"https://{self.domain}/users/alice", + object=actor_url, + published=datetime.utcnow().isoformat() + ) + + # Get target inbox + inbox_url = await self.webfinger.get_inbox_url(account) + if not inbox_url: + logger.error(f"Could not find inbox for {account}") + return + + # Deliver Follow activity + activity_dict = follow.serialize() + result = await self.delivery.deliver_activity( + activity=activity_dict, + recipients=[inbox_url] + ) + logger.info(f"Follow result: {result}") + + async def close(self): + """Clean up resources.""" + if self.delivery and hasattr(self.delivery, 'close'): + await self.delivery.close() + if self.webfinger and hasattr(self.webfinger, 'close'): + await self.webfinger.close() + +async def main(): + """Run federation examples.""" + federation = FederationExample() + logger.info("Initializing federation...") + await federation.initialize() + + try: + # Example 1: Send public post + logger.info("Sending public post...") + await federation.send_public_post( + "Hello #Fediverse! This is a test post from PyFed!" + ) + + # Example 2: Send direct message + logger.info("Sending direct message...") + await federation.send_direct_message( + "kene29@mastodon.social", + "Hello! This is a direct message test from PyFed." + ) + + # Example 3: Follow account + logger.info("Following account...") + await federation.follow_account("kene29@mastodon.social") + + finally: + await federation.close() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/examples/integration_examples.py b/examples/integration_examples.py new file mode 100644 index 0000000000000000000000000000000000000000..3c483af555494f80313a6625b6957d47eb6116a3 --- /dev/null +++ b/examples/integration_examples.py @@ -0,0 +1,73 @@ +""" +Framework integration examples. +""" + +# FastAPI Integration +from fastapi import FastAPI +from pyfed.integration.frameworks.fastapi import FastAPIIntegration +from pyfed.integration.config import IntegrationConfig + +async def fastapi_example(): + """FastAPI integration example.""" + # Create config + config = IntegrationConfig( + domain="example.com", + database_url="postgresql://user:pass@localhost/pyfed", + redis_url="redis://localhost", + media_path="uploads/", + key_path="keys/" + ) + + # Initialize integration + integration = FastAPIIntegration(config) + await integration.initialize() + + app = integration.app + + # Add custom routes + @app.get("/custom") + async def custom_route(): + return {"message": "Custom route"} + + return app + +# Django Integration +from django.urls import path +from pyfed.integration.frameworks.django import DjangoIntegration + +# settings.py +PYFED_CONFIG = { + 'domain': 'example.com', + 'database_url': 'postgresql://user:pass@localhost/pyfed', + 'redis_url': 'redis://localhost', + 'media_path': 'uploads/', + 'key_path': 'keys/', +} + +# urls.py +from django.urls import path, include + +urlpatterns = [ + path('', include('pyfed.integration.frameworks.django.urls')), +] + +# Flask Integration +from flask import Flask +from pyfed.integration.frameworks.flask import FlaskIntegration + +def flask_example(): + """Flask integration example.""" + app = Flask(__name__) + + config = IntegrationConfig( + domain="example.com", + database_url="postgresql://user:pass@localhost/pyfed", + redis_url="redis://localhost", + media_path="uploads/", + key_path="keys/" + ) + + integration = FlaskIntegration(config) + integration.initialize() + + return app \ No newline at end of file diff --git a/examples/note_example.py b/examples/note_example.py new file mode 100644 index 0000000000000000000000000000000000000000..c966d54594fc4141f92563910cf5459e5a5b5d6e --- /dev/null +++ b/examples/note_example.py @@ -0,0 +1,147 @@ +""" +Note creation and interaction examples. +""" + +import asyncio +import logging +from datetime import datetime, timezone +import os +import sys +from pathlib import Path +import ssl + +# Add src directory to Python path +src_path = Path(__file__).parent.parent / "src" +sys.path.insert(0, str(src_path)) + +from pyfed.models import APCreate, APNote, APPerson +from pyfed.security import KeyManager +from pyfed.storage import StorageBackend +from pyfed.federation import ActivityDelivery +from pyfed.protocols.webfinger import WebFingerClient +from pyfed.serializers.json_serializer import to_json, from_json + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def ensure_directories(): + """Ensure required directories exist.""" + dirs = [ + "example_keys", + "example_data", + "example_media" + ] + + for dir_name in dirs: + path = Path(dir_name) + path.mkdir(parents=True, exist_ok=True) + logger.info(f"Ensured directory exists: {path}") + +async def create_note_example(): + """Example of creating and delivering a note.""" + try: + ensure_directories() + + # Initialize storage + storage = StorageBackend.create( + provider="sqlite", + database_url="example_data/pyfed_example.db" + ) + await storage.initialize() + + # Initialize key manager + key_manager = KeyManager( + domain="example.com", + active_keys=Path("example_keys").resolve(), + keys_path=str(Path("example_keys").resolve()) + ) + await key_manager.initialize() + + # Initialize delivery + delivery = ActivityDelivery( + key_manager=key_manager, + timeout=30 + ) + await delivery.initialize() + + # Initialize WebFinger client with SSL verification disabled for testing + webfinger = WebFingerClient(verify_ssl=False) + await webfinger.initialize() + + # Get inbox URL for recipient + recipient = "kene29@mastodon.social" + logger.info(f"Looking up inbox for {recipient}...") + inbox_url = await webfinger.get_inbox_url(recipient) + if not inbox_url: + logger.error(f"Could not find inbox for {recipient}") + return + + logger.info(f"Found inbox URL: {inbox_url}") + + # Create actor + actor = APPerson( + id="https://example.com/users/alice", + name="Alice", + preferred_username="alice", + inbox="https://example.com/users/alice/inbox", + outbox="https://example.com/users/alice/outbox", + followers="https://example.com/users/alice/followers" + ) + + # Create note with string attributed_to + note = APNote( + id=f"https://example.com/notes/{datetime.now(timezone.utc).timestamp()}", + content=f"Hello @{recipient}! This is a test note!", + attributed_to=str(actor.id), # Convert URL to string + to=[inbox_url], + cc=["https://www.w3.org/ns/activitystreams#Public"], + published=datetime.now(timezone.utc).isoformat() + ) + + # Create activity + create_activity = APCreate( + id=f"https://example.com/activities/{datetime.now(timezone.utc).timestamp()}", + actor=str(actor.id), # Convert URL to string + object=note, + to=note.to, + cc=note.cc, + published=datetime.now(timezone.utc).isoformat(), + ) + + # Serialize and deliver + logger.info("Serializing activity...") + activity_dict = create_activity.serialize() + logger.info(f"Serialized activity: {activity_dict}") + logger.info(f"Activity: {to_json(create_activity, indent=2)}") + + logger.info("Delivering activity...") + result = await delivery.deliver_activity( + activity=activity_dict, + recipients=[inbox_url] + ) + logger.info(f"Delivery result: {result}") + + except Exception as e: + logger.error(f"Error in note example: {e}") + raise + finally: + if 'storage' in locals(): + await storage.close() + if 'delivery' in locals(): + await delivery.close() + if 'webfinger' in locals(): + await webfinger.close() + +def main(): + """Main entry point.""" + try: + asyncio.run(create_note_example()) + except KeyboardInterrupt: + logger.info("Example stopped by user") + except Exception as e: + logger.error(f"Example failed: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/examples/run_example.py b/examples/run_example.py new file mode 100644 index 0000000000000000000000000000000000000000..420ec82ea95b1e9e4196ebaaa6e8248b3c6b4aa7 --- /dev/null +++ b/examples/run_example.py @@ -0,0 +1,145 @@ +""" +PyFed runnable example. +""" + +import asyncio +import logging +from datetime import datetime +import os +import sys +from pathlib import Path + +# Add src directory to Python path +src_path = Path(__file__).parent.parent / "src" +sys.path.insert(0, str(src_path)) + +from pyfed.models import APCreate, APNote, APPerson, APLike +from pyfed.security import KeyManager +from pyfed.storage import StorageBackend +from pyfed.federation import ActivityDelivery +from pyfed.serializers.json_serializer import to_json, from_json + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +def ensure_directories(): + """Ensure required directories exist.""" + dirs = [ + "example_keys", + "example_data", + "example_media" + ] + + for dir_name in dirs: + path = Path(dir_name) + path.mkdir(parents=True, exist_ok=True) + logger.info(f"Ensured directory exists: {path}") + +async def run_examples(): + """Run PyFed examples.""" + try: + logger.info("Setting up PyFed components...") + ensure_directories() + + # Initialize storage + storage = StorageBackend.create( + provider="sqlite", + database_url="example_data/pyfed_example.db" + ) + await storage.initialize() + + # Initialize key manager + key_manager = KeyManager( + domain="example.com", + active_keys=Path("example_keys").resolve(), + keys_path=str(Path("example_keys").resolve()) + ) + await key_manager.initialize() + + # Initialize delivery + delivery = ActivityDelivery( + key_manager=key_manager, + timeout=30 + ) + + # Create actor + actor = APPerson( + id="https://example.com/users/alice", + name="Alice", + preferred_username="alice", + inbox="https://example.com/users/alice/inbox", + outbox="https://example.com/users/alice/outbox", + followers="https://example.com/users/alice/followers" + ) + + # Create note + note = APNote( + id="https://example.com/notes/123", + content="Hello, Federation! #test @bob@remote.com", + attributed_to=str(actor.id), + to=["https://www.w3.org/ns/activitystreams#Public"], + published=datetime.utcnow().isoformat() + ) + + # Create activity + create_activity = APCreate( + id=f"https://example.com/activities/{datetime.utcnow().timestamp()}", + actor=str(actor.id), + object=note, + to=note.to, + published=datetime.utcnow().isoformat() + ) + + # Store activity + logger.info("Storing activity...") + activity_dict = create_activity.serialize() + logger.info(f"Serialized activity (with context):\n{to_json(create_activity, indent=2)}") + activity_id = await storage.create_activity(activity_dict) + logger.info(f"Activity stored with ID: {activity_id}") + + # Create like activity + like_activity = APLike( + id=f"https://example.com/activities/like_{datetime.utcnow().timestamp()}", + actor=str(actor.id), + object=note.id, + to=["https://www.w3.org/ns/activitystreams#Public"], + published=datetime.utcnow().isoformat() + ) + + # Store like + logger.info("Storing like activity...") + like_dict = like_activity.serialize() + logger.info(f"Serialized like activity (with context):\n{to_json(like_activity, indent=2)}") + like_id = await storage.create_activity(like_dict) + logger.info(f"Like activity stored with ID: {like_id}") + + # Retrieve activities + logger.info("\nRetrieving activities...") + stored_activity = await storage.get_activity(activity_id) + stored_like = await storage.get_activity(like_id) + + logger.info("Retrieved Create activity:") + logger.info(stored_activity) + logger.info("\nRetrieved Like activity:") + logger.info(stored_like) + + except Exception as e: + logger.error(f"Error running examples: {e}") + raise + finally: + if storage: + await storage.close() + +def main(): + """Main entry point.""" + try: + asyncio.run(run_examples()) + except KeyboardInterrupt: + logger.info("Example stopped by user") + except Exception as e: + logger.error(f"Example failed: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e07d7b75db7c25ec7707d23720ca688a55ad5d7f..c8fb7e92494ece87076a27f43ca73e70b0a92c52 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,20 +5,25 @@ aioredis==2.0.1 aiosignal==1.3.1 aiosqlite==0.20.0 annotated-types==0.7.0 +anyio==4.6.2.post1 async-timeout==5.0.0 asyncpg==0.30.0 attrs==24.2.0 backoff==2.2.1 +beautifulsoup4==4.12.3 +bs4==0.0.2 cachetools==5.5.0 cffi==1.17.1 cryptography==43.0.3 dnspython==2.7.0 factory_boy==3.3.1 Faker==30.8.0 +fastapi==0.115.4 frozenlist==1.4.1 greenlet==3.1.1 idna==3.10 iniconfig==2.0.0 +Markdown==3.7 motor==3.6.0 multidict==6.1.0 packaging==24.1 @@ -34,8 +39,12 @@ pytest==8.3.3 pytest-aiohttp==1.0.5 pytest-asyncio==0.24.0 python-dateutil==2.9.0.post0 +PyYAML==6.0.2 redis==5.2.0 six==1.16.0 +sniffio==1.3.1 +soupsieve==2.6 SQLAlchemy==2.0.36 +starlette==0.41.2 typing_extensions==4.12.2 -yarl==1.15.2 +yarl==1.15.2 \ No newline at end of file diff --git a/setup.py b/setup.py index ca99640739d50ee7f5a4aed9f3282945f107bf88..331dde77841330d9a91d82aca17be3d441bb0b48 100644 --- a/setup.py +++ b/setup.py @@ -2,6 +2,29 @@ from setuptools import setup, find_packages setup( name="pyfed", - packages=find_packages(where="src"), + version="0.1.0", package_dir={"": "src"}, + packages=find_packages(where="src"), + python_requires=">=3.8", + install_requires=[ + "cryptography>=3.4.7", + "aiohttp>=3.8.0", + "pydantic>=1.8.2", + "sqlalchemy>=1.4.0", + "aiosqlite>=0.17.0", + "asyncpg>=0.25.0", + "redis>=4.0.0", + "beautifulsoup4>=4.9.3", + "markdown>=3.3.4", + ], + extras_require={ + "fastapi": ["fastapi>=0.68.0", "uvicorn>=0.15.0"], + "django": ["django>=3.2.0"], + "flask": ["flask>=2.0.0"], + "dev": [ + "pytest>=6.2.5", + "pytest-asyncio>=0.15.1", + "pytest-cov>=2.12.1", + ], + }, ) \ No newline at end of file diff --git a/src/pyfed/models/actors.py b/src/pyfed/models/actors.py index 0a5fc0b1cbc777d4546e64271e059a6331350ea7..0056ff87f7e0d365aede0d9b3d3df86e90f759b7 100644 --- a/src/pyfed/models/actors.py +++ b/src/pyfed/models/actors.py @@ -11,10 +11,9 @@ from __future__ import annotations from pydantic import Field, HttpUrl, field_validator from typing import Optional, List, Dict, Any, TypedDict, Literal from datetime import datetime -from pyfed.utils.exceptions import InvalidURLError -# from pyfed.plugins import plugin_manager -from pyfed.utils.logging import get_logger -from pyfed.cache import object_cache +from ..utils.exceptions import InvalidURLError +from ..utils.logging import get_logger +from ..cache import object_cache from .objects import APObject @@ -64,52 +63,52 @@ class APActor(APObject): except ValueError: raise InvalidURLError(f"Invalid URL: {v}") - async def send_to_inbox(self, activity: Dict[str, Any]) -> bool: - """ - Send an activity to this actor's inbox. + # async def send_to_inbox(self, activity: Dict[str, Any]) -> bool: + # """ + # Send an activity to this actor's inbox. - Args: - activity (Dict[str, Any]): The activity to send. + # Args: + # activity (Dict[str, Any]): The activity to send. - Returns: - bool: True if the activity was successfully delivered, False otherwise. + # Returns: + # bool: True if the activity was successfully delivered, False otherwise. - Note: This method should implement the logic described in: - https://www.w3.org/TR/activitypub/#delivery - """ - logger.info(f"Sending activity to inbox: {self.inbox}") - # Execute pre-send hook - # plugin_manager.execute_hook('pre_send_to_inbox', self, activity) + # Note: This method should implement the logic described in: + # https://www.w3.org/TR/activitypub/#delivery + # """ + # logger.info(f"Sending activity to inbox: {self.inbox}") + # # Execute pre-send hook + # # plugin_manager.execute_hook('pre_send_to_inbox', self, activity) - # Placeholder for actual implementation - return True + # # Placeholder for actual implementation + # return True - async def fetch_followers(self) -> List[APActor]: - """ - Fetch the followers of this actor. + # async def fetch_followers(self) -> List[APActor]: + # """ + # Fetch the followers of this actor. - Returns: - List[APActor]: A list of actors following this actor. - """ - logger.info(f"Fetching followers for actor: {self.id}") + # Returns: + # List[APActor]: A list of actors following this actor. + # """ + # logger.info(f"Fetching followers for actor: {self.id}") - # Check cache first - cached_followers = object_cache.get(f"followers:{self.id}") - if cached_followers is not None: - return cached_followers + # # Check cache first + # cached_followers = object_cache.get(f"followers:{self.id}") + # if cached_followers is not None: + # return cached_followers - # Execute pre-fetch hook - # plugin_manager.execute_hook('pre_fetch_followers', self) + # # Execute pre-fetch hook + # # plugin_manager.execute_hook('pre_fetch_followers', self) - # Fetch followers (placeholder implementation) - followers = [] # Actual implementation would go here + # # Fetch followers (placeholder implementation) + # followers = [] # Actual implementation would go here - # Cache the result - object_cache.set(f"followers:{self.id}", followers) + # # Cache the result + # object_cache.set(f"followers:{self.id}", followers) - return followers + # return followers - async def create_activity(self, activity_type: str, object: Dict[str, Any]) -> ActivityDict: + # async def create_activity(self, activity_type: str, object: Dict[str, Any]) -> ActivityDict: """ Create an activity with this actor as the 'actor'. diff --git a/src/pyfed/models/objects.py b/src/pyfed/models/objects.py index 032a573d01d4c48b14957b40e5a63d28c0202a1a..cd49aab7ffe88048ce0155293535b28ffa76eedf 100644 --- a/src/pyfed/models/objects.py +++ b/src/pyfed/models/objects.py @@ -52,7 +52,7 @@ class APObject(ActivityPubBase): Usage: https://www.w3.org/TR/activitypub/#object """ id: HttpUrl = Field(..., description="Unique identifier for the object") - type: str = Field(..., description="The type of the object") + type: Literal["Object"] = Field(..., description="The type of the object") attachment: Optional[List[Union[str, 'APObject']]] = Field(default=None, description="Files attached to the object") attributed_to: Optional[Union[str, 'APObject']] = Field(default=None, description="Entity attributed to this object") audience: Optional[List[Union[str, 'APObject']]] = Field(default=None, description="Intended audience") diff --git a/src/pyfed/security/__init__.py b/src/pyfed/security/__init__.py index 0519ecba6ea913e21689ec692e81e9e4973fbf73..f6bdfbdf557a34b8d9362cf5161dc20f769d1ac6 100644 --- a/src/pyfed/security/__init__.py +++ b/src/pyfed/security/__init__.py @@ -1 +1,5 @@ - \ No newline at end of file +from .key_management import KeyManager + +__all__ = [ + 'KeyManager' +] diff --git a/src/pyfed/security/http_signatures.py b/src/pyfed/security/http_signatures.py index 4e579332f763e66c83fd4e703197dd80bf0de8e7..a4db3edf6bdbf84fdb8d239276e5be4a5d497646 100644 --- a/src/pyfed/security/http_signatures.py +++ b/src/pyfed/security/http_signatures.py @@ -1,4 +1,5 @@ """ +security/http_signatures.py Enhanced HTTP Signatures implementation. Implements HTTP Signatures (draft-cavage-http-signatures) with: diff --git a/src/pyfed/security/key_management.py b/src/pyfed/security/key_management.py index 92a1aeafe377bdba3dc4a8f9d2b4f9268688250f..1b8f392677c8bfdd00a3f76e4aba539ffe885f92 100644 --- a/src/pyfed/security/key_management.py +++ b/src/pyfed/security/key_management.py @@ -46,10 +46,11 @@ class KeyManager: def __init__(self, domain: str, - keys_dir: str = "keys", + keys_path: str = "keys", + active_keys: Optional[Path] = None, rotation_config: Optional[KeyRotation] = None): self.domain = domain - self.keys_dir = Path(keys_dir) + self.keys_path = Path(keys_path) self.rotation_config = rotation_config or KeyRotation() self.active_keys: Dict[str, KeyPair] = {} self._rotation_task = None @@ -58,7 +59,7 @@ class KeyManager: """Initialize key manager.""" try: # Create keys directory - self.keys_dir.mkdir(parents=True, exist_ok=True) + self.keys_path.mkdir(parents=True, exist_ok=True) # Load existing keys await self._load_existing_keys() @@ -74,7 +75,7 @@ class KeyManager: logger.error(f"Failed to initialize key manager: {e}") raise KeyManagementError(f"Key manager initialization failed: {e}") - async def generate_key_pair(self) -> KeyPair: + async def generate_key_pair(self) -> KeyPair: """Generate new key pair.""" try: # Generate keys @@ -170,12 +171,12 @@ class KeyManager: async def _load_existing_keys(self) -> None: """Load existing keys from disk.""" try: - for key_file in self.keys_dir.glob("*.json"): + for key_file in self.keys_path.glob("*.json"): async with aiofiles.open(key_file, 'r') as f: metadata = json.loads(await f.read()) # Load private key - private_key_path = self.keys_dir / f"{metadata['key_id']}_private.pem" + private_key_path = self.keys_path / f"{metadata['key_id']}_private.pem" async with aiofiles.open(private_key_path, 'rb') as f: private_key = serialization.load_pem_private_key( await f.read(), @@ -203,7 +204,7 @@ class KeyManager: """Save key pair to disk.""" try: # Save private key - private_key_path = self.keys_dir / f"{key_pair.key_id}_private.pem" + private_key_path = self.keys_path / f"{key_pair.key_id}_private.pem" private_pem = key_pair.private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, @@ -213,7 +214,7 @@ class KeyManager: await f.write(private_pem) # Save public key - public_key_path = self.keys_dir / f"{key_pair.key_id}_public.pem" + public_key_path = self.keys_path / f"{key_pair.key_id}_public.pem" public_pem = key_pair.public_key.public_bytes( encoding=serialization.Encoding.PEM, format=serialization.PublicFormat.SubjectPublicKeyInfo @@ -227,7 +228,7 @@ class KeyManager: 'created_at': key_pair.created_at.isoformat(), 'expires_at': key_pair.expires_at.isoformat() } - metadata_path = self.keys_dir / f"{key_pair.key_id}.json" + metadata_path = self.keys_path / f"{key_pair.key_id}.json" async with aiofiles.open(metadata_path, 'w') as f: await f.write(json.dumps(metadata)) @@ -238,12 +239,12 @@ class KeyManager: async def _archive_key_pair(self, key_pair: KeyPair) -> None: """Archive an expired key pair.""" try: - archive_dir = self.keys_dir / "archive" + archive_dir = self.keys_path / "archive" archive_dir.mkdir(exist_ok=True) # Move key files to archive for ext in ['_private.pem', '_public.pem', '.json']: - src = self.keys_dir / f"{key_pair.key_id}{ext}" + src = self.keys_path / f"{key_pair.key_id}{ext}" dst = archive_dir / f"{key_pair.key_id}{ext}" if src.exists(): src.rename(dst) diff --git a/src/pyfed/security/validators.py b/src/pyfed/security/validators.py index e416a528ea3f06e86419e3a373737cf52e47d35e..0519ecba6ea913e21689ec692e81e9e4973fbf73 100644 --- a/src/pyfed/security/validators.py +++ b/src/pyfed/security/validators.py @@ -1,119 +1 @@ -import asyncio -from typing import Dict, Any, Optional, Set, Union -from datetime import datetime -from dataclasses import dataclass -from ..utils.logging import get_logger -from ..utils.exceptions import ValidationError -from .interfaces import SignatureVerifier - -logger = get_logger(__name__) - -@dataclass -class ValidationConfig: - """Validation configuration.""" - max_date_diff: int = 30 - require_https: bool = True - allowed_schemes: Set[str] = {'https'} - max_audience_size: int = 100 - required_headers: Set[str] = {'Host', 'Date', 'Signature'} - -class SecurityValidator: - """Enhanced security validation.""" - - def __init__(self, - signature_verifier: SignatureVerifier, - config: Optional[ValidationConfig] = None): - self.signature_verifier = signature_verifier - self.config = config or ValidationConfig() - self.seen_requests: Dict[str, datetime] = {} - self._cleanup_task = asyncio.create_task(self._cleanup_seen_requests()) - - async def validate_request(self, - headers: Dict[str, str], - body: Dict[str, Any], - method: str, - path: str, - validate_signature: bool = True) -> None: - """Enhanced request validation.""" - try: - # Basic header validation - self._validate_required_headers(headers) - self._validate_date_header(headers.get('Date')) - - # Replay protection - request_id = self._get_request_id(headers, method, path) - if request_id in self.seen_requests: - raise ValidationError("Duplicate request detected") - self.seen_requests[request_id] = datetime.utcnow() - - # Signature validation - if validate_signature: - if not await self.signature_verifier.verify_request(headers): - raise ValidationError("Invalid HTTP signature") - - # Content validation - if body: - content_type = headers.get('Content-Type', '') - if not content_type.startswith('application/activity+json'): - raise ValidationError("Invalid content type") - await self.validate_activity_security(body) - - except Exception as e: - logger.error(f"Request validation failed: {e}") - raise ValidationError(f"Validation failed: {e}") - - async def validate_activity_security(self, activity: Dict[str, Any]) -> None: - """Enhanced activity validation.""" - # Validate basic structure - if not isinstance(activity, dict): - raise ValidationError("Activity must be a JSON object") - - # Validate required fields - required_fields = {'type', 'actor'} - missing = required_fields - set(activity.keys()) - if missing: - raise ValidationError(f"Missing required fields: {missing}") - - # Validate actor - await self._validate_actor(activity['actor']) - - # Validate object if present - if 'object' in activity: - await self._validate_object(activity['object']) - - # Validate audience - await self._validate_audience_fields(activity) - - async def _validate_actor(self, actor: Union[str, Dict[str, Any]]) -> None: - """Enhanced actor validation.""" - if isinstance(actor, str): - await self.validate_url(actor) - elif isinstance(actor, dict): - if 'id' not in actor: - raise ValidationError("Actor object must have an id") - await self.validate_url(actor['id']) - - # Validate actor ownership if possible - if hasattr(self.signature_verifier, 'verify_actor_ownership'): - if not await self.signature_verifier.verify_actor_ownership(actor): - raise ValidationError("Invalid actor ownership") - else: - raise ValidationError("Invalid actor format") - - async def _validate_audience_fields(self, obj: Dict[str, Any]) -> None: - """Enhanced audience validation.""" - audience_fields = {'to', 'cc', 'bto', 'bcc'} - total_recipients = 0 - - for field in audience_fields: - if field in obj: - value = obj[field] - if isinstance(value, list): - total_recipients += len(value) - if total_recipients > self.config.max_audience_size: - raise ValidationError("Too many recipients") - for item in value: - await self.validate_url(item) - elif isinstance(value, str): - total_recipients += 1 - await self.validate_url(value) \ No newline at end of file + \ No newline at end of file diff --git a/src/pyfed/serializers/json_serializer.py b/src/pyfed/serializers/json_serializer.py index 3cbb28b73720c8153a5c4be7107a4dc89f9c2017..54e34d70453cbc1e788196b13f4f41d59d2664ee 100644 --- a/src/pyfed/serializers/json_serializer.py +++ b/src/pyfed/serializers/json_serializer.py @@ -1,113 +1,238 @@ """ -json_serializer.py -This module provides JSON serialization for ActivityPub objects. +JSON serializer for ActivityPub objects. """ +from typing import Any, Dict, Union, List, Optional, Type, get_origin, get_args +from datetime import datetime, timezone import json -from datetime import datetime -from typing import Any, Dict -from pydantic import BaseModel +import re +from pydantic import BaseModel, AnyUrl, HttpUrl from pydantic_core import Url -from ..utils.logging import get_logger -# from ..plugins import plugin_manager - -logger = get_logger(__name__) def to_camel_case(snake_str: str) -> str: - """Converts snake_case to camelCase.""" + """Convert snake_case to camelCase.""" components = snake_str.split('_') return components[0] + ''.join(x.title() for x in components[1:]) -def convert_dict_keys_to_camel_case(data: Dict[str, Any]) -> Dict[str, Any]: - """Recursively converts all dictionary keys from snake_case to camelCase.""" - if not isinstance(data, dict): - return data - - return { - to_camel_case(key): ( - convert_dict_keys_to_camel_case(value) if isinstance(value, (dict, list)) - else value - ) - for key, value in data.items() - } - -class ActivityPubJSONEncoder(json.JSONEncoder): - """Custom JSON encoder for ActivityPub objects.""" - - def default(self, obj: Any) -> Any: - if isinstance(obj, BaseModel): - return convert_dict_keys_to_camel_case(obj.model_dump()) - if isinstance(obj, Url): - return str(obj) - if isinstance(obj, datetime): - return obj.isoformat() - if isinstance(obj, list): - return [self.default(item) for item in obj] - return super().default(obj) +def is_url_field(field_name: str) -> bool: + """Check if field name suggests it's a URL.""" + url_indicators = [ + 'url', 'href', 'id', 'inbox', 'outbox', 'following', + 'followers', 'liked', 'icon', 'image', 'avatar', + 'endpoints', 'featured', 'streams' + ] + return any(indicator in field_name.lower() for indicator in url_indicators) -class ActivityPubBase(BaseModel): - """Base class for all ActivityPub models.""" +class ActivityPubSerializer: + """ActivityPub serializer implementation.""" - class Config: - """Pydantic model configuration.""" - populate_by_name = True - use_enum_values = True - alias_generator = to_camel_case + @staticmethod + def _process_value(value: Any, field_name: str = "", depth: int = 0) -> Any: + """ + Process a single value for serialization. + + Args: + value: Value to process + field_name: Name of the field being processed + depth: Current recursion depth + + Returns: + Processed value + """ + # Prevent infinite recursion + if depth > 10: # Maximum nesting depth + return str(value) -class ActivityPubSerializer: - """Serializer for ActivityPub objects.""" + if value is None: + return None + + # Handle BaseModel instances (nested objects) + if isinstance(value, BaseModel): + # Recursively serialize nested objects + serialized = value.model_dump(exclude_none=True) + return { + to_camel_case(k): ActivityPubSerializer._process_value(v, k, depth + 1) + for k, v in serialized.items() + } + + # Handle URL types - using pydantic_core.Url instead of AnyUrl + if isinstance(value, Url): + return str(value) + + # Handle datetime + if isinstance(value, datetime): + return value.astimezone(timezone.utc).isoformat() + + # Handle lists with potential nested objects + if isinstance(value, list): + return [ + ActivityPubSerializer._process_value(item, field_name, depth + 1) + for item in value + ] + + # Handle dictionaries with potential nested objects + if isinstance(value, dict): + return { + to_camel_case(k): ActivityPubSerializer._process_value(v, k, depth + 1) + for k, v in value.items() + } + + # Convert string to URL if field name suggests it's a URL + if isinstance(value, str) and is_url_field(field_name): + if not value.startswith(('http://', 'https://')): + value = f"https://{value}" + return value + + return value @staticmethod - def serialize(obj: ActivityPubBase, include_context: bool = True, **kwargs) -> str: + def serialize(obj: Any, include_context: bool = True) -> Dict[str, Any]: """ - Serialize an ActivityPub object to JSON string. - + Serialize object to dictionary. + Args: - obj (ActivityPubBase): The object to serialize. - include_context (bool): Whether to include @context field. - **kwargs: Additional arguments passed to json.dumps. - + obj: Object to serialize + include_context: Whether to include @context + Returns: - str: JSON string representation of the object. + Serialized dictionary """ - logger.debug("Serializing object") - - # Execute pre-serialize hook - # plugin_manager.execute_hook('pre_serialize', obj) - - # Convert to dictionary and convert keys to camelCase - data = convert_dict_keys_to_camel_case(obj.model_dump()) + if not isinstance(obj, BaseModel): + return ActivityPubSerializer._process_value(obj) + + # Process each field + processed_data = ActivityPubSerializer._process_value(obj) # Add context if needed if include_context: - data["@context"] = "https://www.w3.org/ns/activitystreams" - - # Serialize to JSON - serialized = json.dumps(data, cls=ActivityPubJSONEncoder, **kwargs) - - return serialized + processed_data["@context"] = "https://www.w3.org/ns/activitystreams" + + return processed_data @staticmethod - def deserialize(json_str: str, model_class: type[ActivityPubBase]) -> ActivityPubBase: + def _process_field_value(value: Any, field_type: Any) -> Any: """ - Deserialize a JSON string to an ActivityPub object. - + Process field value during deserialization. + Args: - json_str (str): The JSON string to deserialize. - model_class (type[ActivityPubBase]): The class to deserialize into. - + value: Value to process + field_type: Type annotation for the field + Returns: - ActivityPubBase: The deserialized object. + Processed value """ - logger.debug(f"Deserializing to {model_class.__name__}") + # Handle None values + if value is None: + return None + + # Handle nested BaseModel + if hasattr(field_type, 'model_fields'): + return ActivityPubSerializer.deserialize(value, field_type) + + # Handle lists + origin = get_origin(field_type) + if origin is list: + args = get_args(field_type) + if args and hasattr(args[0], 'model_fields'): + return [ + ActivityPubSerializer.deserialize(item, args[0]) + if isinstance(item, dict) + else item + for item in value + ] + + # Handle dictionaries + if origin is dict: + key_type, val_type = get_args(field_type) + if hasattr(val_type, 'model_fields'): + return { + k: ActivityPubSerializer.deserialize(v, val_type) + if isinstance(v, dict) + else v + for k, v in value.items() + } + + return value + + @staticmethod + def deserialize(data: Union[str, Dict[str, Any]], model_class: Type[BaseModel]) -> BaseModel: + """ + Deserialize data to object. - # Parse JSON - data = json.loads(json_str) + Args: + data: JSON string or dictionary to deserialize + model_class: Class to deserialize into + + Returns: + Deserialized object + """ + # Handle JSON string input + if isinstance(data, str): + try: + data_dict = json.loads(data) + except json.JSONDecodeError: + raise ValueError("Invalid JSON string") + else: + data_dict = data + + if not isinstance(data_dict, dict): + raise ValueError("Data must be a dictionary or JSON string") + + # Make a copy of the data + data_dict = dict(data_dict) # Remove context if present - data.pop("@context", None) + data_dict.pop('@context', None) - # Create object - obj = model_class.model_validate(data) - - return obj + # Convert keys from camelCase to snake_case and process values + processed_data = {} + for key, value in data_dict.items(): + if key == '@context': + continue + + snake_key = re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', key).lower() + + # Get field info from model + field_info = model_class.model_fields.get(snake_key) + if field_info is None: + continue + + # Process the field value + processed_value = ActivityPubSerializer._process_field_value( + value, field_info.annotation + ) + + processed_data[snake_key] = processed_value + + # Use model_validate instead of direct construction + return model_class.model_validate(processed_data) + +class ActivityPubBase(BaseModel): + """Base class for all ActivityPub objects.""" + + def serialize(self, include_context: bool = True) -> Dict[str, Any]: + """Serialize object to dictionary.""" + return ActivityPubSerializer.serialize(self, include_context) + + @classmethod + def deserialize(cls, data: Union[str, Dict[str, Any]]) -> 'ActivityPubBase': + """Deserialize dictionary to object.""" + return ActivityPubSerializer.deserialize(data, cls) + + class Config: + """Pydantic config.""" + alias_generator = to_camel_case + populate_by_alias = True + extra = "allow" + arbitrary_types_allowed = True + populate_by_name = True + +def to_json(obj: ActivityPubBase, **kwargs) -> str: + """Convert object to JSON string.""" + return json.dumps(ActivityPubSerializer.serialize(obj), **kwargs) + +def from_json(json_str: str, model_class: Type[ActivityPubBase]) -> ActivityPubBase: + """Convert JSON string to object.""" + return ActivityPubSerializer.deserialize(json_str, model_class) + + diff --git a/src/pyfed/utils/exceptions.py b/src/pyfed/utils/exceptions.py index 09885a4d270c26f6d5f2061f1cb302dd91c3d41b..50c854e79e1293277dacb24f92c53d657740dc06 100644 --- a/src/pyfed/utils/exceptions.py +++ b/src/pyfed/utils/exceptions.py @@ -118,4 +118,21 @@ class WebFingerError(ActivityPubException): class KeyManagementError(ActivityPubException): """Raised when key manager-related errors occur.""" - pass \ No newline at end of file + pass + +class CollectionError(ActivityPubException): + """Raised when collection-related errors occur.""" + pass + +class ContentError(ActivityPubException): + """Raised when content-related errors occur.""" + pass + +class ContentHandlerError(ActivityPubException): + """Raised when content handler-related errors occur.""" + pass + +class CollectionHandlerError(ActivityPubException): + """Raised when collection handler-related errors occur.""" + pass + diff --git a/tests/models/__init__.py b/tests/unit_tests/models/__init__.py similarity index 100% rename from tests/models/__init__.py rename to tests/unit_tests/models/__init__.py diff --git a/tests/models/test_activities.py b/tests/unit_tests/models/test_activities.py similarity index 94% rename from tests/models/test_activities.py rename to tests/unit_tests/models/test_activities.py index 544ec39e5db8e1e3a61b1e6dd0507f6f36232031..5f516325245806df0c0e4248ae07efb22dd8289a 100644 --- a/tests/models/test_activities.py +++ b/tests/unit_tests/models/test_activities.py @@ -14,7 +14,6 @@ def test_create_activity(): """Test creating a Create activity.""" activity = APCreate( id="https://example.com/activity/123", - type="Create", actor="https://example.com/user/1", object={ "id": "https://example.com/note/123", @@ -30,7 +29,6 @@ def test_update_activity(): """Test creating an Update activity.""" activity = APUpdate( id="https://example.com/activity/123", - type="Update", actor="https://example.com/user/1", object={ "id": "https://example.com/note/123", @@ -45,7 +43,6 @@ def test_delete_activity(): """Test creating a Delete activity.""" activity = APDelete( id="https://example.com/activity/123", - type="Delete", actor="https://example.com/user/1", object="https://example.com/note/123" ) @@ -56,7 +53,6 @@ def test_follow_activity(): """Test creating a Follow activity.""" activity = APFollow( id="https://example.com/activity/123", - type="Follow", actor="https://example.com/user/1", object="https://example.com/user/2" ) @@ -67,7 +63,6 @@ def test_undo_activity(): """Test creating an Undo activity.""" activity = APUndo( id="https://example.com/activity/123", - type="Undo", actor="https://example.com/user/1", object={ "id": "https://example.com/activity/456", @@ -83,7 +78,6 @@ def test_like_activity(): """Test creating a Like activity.""" activity = APLike( id="https://example.com/activity/123", - type="Like", actor="https://example.com/user/1", object="https://example.com/note/123" ) @@ -94,7 +88,6 @@ def test_announce_activity(): """Test creating an Announce activity.""" activity = APAnnounce( id="https://example.com/activity/123", - type="Announce", actor="https://example.com/user/1", object="https://example.com/note/123" ) @@ -105,7 +98,6 @@ def test_activity_with_target(): """Test creating an activity with a target.""" activity = APCreate( id="https://example.com/activity/123", - type="Create", actor="https://example.com/user/1", object="https://example.com/note/123", target="https://example.com/collection/1" @@ -116,7 +108,6 @@ def test_activity_with_result(): """Test creating an activity with a result.""" activity = APCreate( id="https://example.com/activity/123", - type="Create", actor="https://example.com/user/1", object="https://example.com/note/123", result={ @@ -132,7 +123,6 @@ def test_invalid_activity_missing_actor(): with pytest.raises(ValidationError): APCreate( id="https://example.com/activity/123", - type="Create", object="https://example.com/note/123" ) @@ -141,6 +131,5 @@ def test_invalid_activity_missing_object(): with pytest.raises(ValidationError): APCreate( id="https://example.com/activity/123", - type="Create", actor="https://example.com/user/1" ) diff --git a/tests/models/test_actors.py b/tests/unit_tests/models/test_actors.py similarity index 94% rename from tests/models/test_actors.py rename to tests/unit_tests/models/test_actors.py index 29659a4fe627d1f3d6e9a44ed8fec3dfa69d7624..1a1b72b8a4786e9d55858994f066b7fc319a61f0 100644 --- a/tests/models/test_actors.py +++ b/tests/unit_tests/models/test_actors.py @@ -12,7 +12,6 @@ from pyfed.models import ( def test_valid_person(): person = APPerson( id="https://example.com/users/alice", - type="Person", name="Alice", inbox="https://example.com/users/alice/inbox", outbox="https://example.com/users/alice/outbox", @@ -21,12 +20,12 @@ def test_valid_person(): assert person.type == "Person" assert str(person.inbox) == "https://example.com/users/alice/inbox" assert str(person.outbox) == "https://example.com/users/alice/outbox" + print(person.preferred_username) assert person.preferred_username == "alice" def test_person_with_optional_fields(): person = APPerson( id="https://example.com/users/alice", - type="Person", name="Alice", inbox="https://example.com/users/alice/inbox", outbox="https://example.com/users/alice/outbox", @@ -44,7 +43,6 @@ def test_invalid_person_missing_required(): with pytest.raises(ValidationError): APPerson( id="https://example.com/users/alice", - type="Person", name="Alice" # Missing required inbox and outbox ) @@ -53,7 +51,6 @@ def test_invalid_person_invalid_url(): with pytest.raises(ValidationError): APPerson( id="https://example.com/users/alice", - type="Person", name="Alice", inbox="not-a-url", # Invalid URL outbox="https://example.com/users/alice/outbox" @@ -62,7 +59,6 @@ def test_invalid_person_invalid_url(): def test_valid_group(): group = APGroup( id="https://example.com/groups/admins", - type="Group", name="Administrators", inbox="https://example.com/groups/admins/inbox", outbox="https://example.com/groups/admins/outbox" @@ -73,7 +69,6 @@ def test_valid_group(): def test_valid_organization(): org = APOrganization( id="https://example.com/org/acme", - type="Organization", name="ACME Corporation", inbox="https://example.com/org/acme/inbox", outbox="https://example.com/org/acme/outbox" @@ -84,7 +79,6 @@ def test_valid_organization(): def test_valid_application(): app = APApplication( id="https://example.com/apps/bot", - type="Application", name="Bot Application", inbox="https://example.com/apps/bot/inbox", outbox="https://example.com/apps/bot/outbox" @@ -95,7 +89,6 @@ def test_valid_application(): def test_valid_service(): service = APService( id="https://example.com/services/api", - type="Service", name="API Service", inbox="https://example.com/services/api/inbox", outbox="https://example.com/services/api/outbox" @@ -106,7 +99,6 @@ def test_valid_service(): def test_actor_with_public_key(): person = APPerson( id="https://example.com/users/alice", - type="Person", name="Alice", inbox="https://example.com/users/alice/inbox", outbox="https://example.com/users/alice/outbox", @@ -122,7 +114,6 @@ def test_actor_with_public_key(): def test_actor_with_endpoints(): person = APPerson( id="https://example.com/users/alice", - type="Person", name="Alice", inbox="https://example.com/users/alice/inbox", outbox="https://example.com/users/alice/outbox", diff --git a/tests/models/test_collections.py b/tests/unit_tests/models/test_collections.py similarity index 93% rename from tests/models/test_collections.py rename to tests/unit_tests/models/test_collections.py index 1d715263452da32f6a3e294069565da76a013224..b54833f6d6acf0519b15de4cca81293f0779fb6a 100644 --- a/tests/models/test_collections.py +++ b/tests/unit_tests/models/test_collections.py @@ -13,7 +13,6 @@ def test_valid_collection(): """Test creating a valid Collection.""" collection = APCollection( id="https://example.com/collection/123", - type="Collection", total_items=10, items=["https://example.com/item/1", "https://example.com/item/2"] ) @@ -25,7 +24,6 @@ def test_collection_with_optional_fields(): """Test creating a Collection with all optional fields.""" collection = APCollection( id="https://example.com/collection/123", - type="Collection", total_items=2, current="https://example.com/collection/123/current", first="https://example.com/collection/123/first", @@ -49,7 +47,6 @@ def test_valid_ordered_collection(): """Test creating a valid OrderedCollection.""" collection = APOrderedCollection( id="https://example.com/collection/123", - type="OrderedCollection", total_items=2, ordered_items=["https://example.com/item/1", "https://example.com/item/2"] ) @@ -60,7 +57,6 @@ def test_valid_collection_page(): """Test creating a valid CollectionPage.""" page = APCollectionPage( id="https://example.com/collection/123/page/1", - type="CollectionPage", part_of="https://example.com/collection/123", items=["https://example.com/item/1"] ) @@ -71,7 +67,6 @@ def test_collection_page_with_navigation(): """Test creating a CollectionPage with navigation links.""" page = APCollectionPage( id="https://example.com/collection/123/page/2", - type="CollectionPage", part_of="https://example.com/collection/123", items=["https://example.com/item/2"], next="https://example.com/collection/123/page/3", @@ -84,7 +79,6 @@ def test_valid_ordered_collection_page(): """Test creating a valid OrderedCollectionPage.""" page = APOrderedCollectionPage( id="https://example.com/collection/123/page/1", - type="OrderedCollectionPage", part_of="https://example.com/collection/123", ordered_items=["https://example.com/item/1"], start_index=0 @@ -97,7 +91,6 @@ def test_invalid_ordered_page_negative_index(): with pytest.raises(ValidationError): APOrderedCollectionPage( id="https://example.com/collection/123/page/1", - type="OrderedCollectionPage", start_index=-1 ) @@ -105,7 +98,6 @@ def test_collection_with_object_items(): """Test creating a Collection with APObject items.""" collection = APCollection( id="https://example.com/collection/123", - type="Collection", items=[{ "id": "https://example.com/item/1", "type": "Note", @@ -119,7 +111,6 @@ def test_ordered_collection_empty(): """Test creating an empty OrderedCollection.""" collection = APOrderedCollection( id="https://example.com/collection/123", - type="OrderedCollection", total_items=0 ) assert collection.total_items == 0 diff --git a/tests/models/test_factories.py b/tests/unit_tests/models/test_factories.py similarity index 100% rename from tests/models/test_factories.py rename to tests/unit_tests/models/test_factories.py diff --git a/tests/models/test_imports.py b/tests/unit_tests/models/test_imports.py similarity index 100% rename from tests/models/test_imports.py rename to tests/unit_tests/models/test_imports.py diff --git a/tests/models/test_interactions.py b/tests/unit_tests/models/test_interactions.py similarity index 97% rename from tests/models/test_interactions.py rename to tests/unit_tests/models/test_interactions.py index 3464332d56a7c0627fb8212c63300e6054c63344..7389fdd9b7ef5df93bfd7a573a6258a81f4a3e00 100644 --- a/tests/models/test_interactions.py +++ b/tests/unit_tests/models/test_interactions.py @@ -72,13 +72,11 @@ def test_collection_with_pagination(): items = [ APNote( id=f"https://example.com/note/{i}", - type="Note", content=f"Note {i}" ) for i in range(1, 6) ] collection = APCollection( id="https://example.com/collection/123", - type="Collection", total_items=len(items), items=items, first="https://example.com/collection/123?page=1", @@ -186,7 +184,6 @@ def test_mention_in_content(): mention = APMention( id="https://example.com/mention/123", - type="Mention", href=mentioned.id, name=f"@{mentioned.preferred_username}" ) @@ -206,21 +203,18 @@ def test_collection_pagination_interaction(): items = [ APNote( id=f"https://example.com/note/{i}", - type="Note", content=f"Note {i}" ) for i in range(1, 11) ] collection = APOrderedCollection( id="https://example.com/collection/123", - type="OrderedCollection", total_items=len(items), ordered_items=items[:5] # First page ) page = APOrderedCollectionPage( id="https://example.com/collection/123/page/1", - type="OrderedCollectionPage", part_of=collection.id, ordered_items=items[5:], # Second page start_index=5 @@ -256,7 +250,6 @@ def test_actor_relationships(): relationship = APRelationship( id="https://example.com/relationship/1", - type="Relationship", subject=member.id, object=group.id, relationship="member" @@ -270,7 +263,6 @@ def test_content_with_multiple_attachments(): """Test creating content with multiple types of attachments.""" image = APImage( id="https://example.com/image/1", - type="Image", url="https://example.com/image.jpg", width=800, height=600 @@ -278,7 +270,6 @@ def test_content_with_multiple_attachments(): document = APDocument( id="https://example.com/document/1", - type="Document", name="Specification", url="https://example.com/spec.pdf" ) @@ -314,7 +305,6 @@ def test_event_series(): collection = APOrderedCollection( id="https://example.com/collection/workshop-series", - type="OrderedCollection", name="Workshop Series", ordered_items=events ) diff --git a/tests/models/test_links.py b/tests/unit_tests/models/test_links.py similarity index 94% rename from tests/models/test_links.py rename to tests/unit_tests/models/test_links.py index 40a3f3f7561fa972496853d85712196cb8f63fba..5e47e287e65c8f839ec3c5f5f195dc97dc461c35 100644 --- a/tests/models/test_links.py +++ b/tests/unit_tests/models/test_links.py @@ -10,7 +10,7 @@ def test_valid_link(): """Test creating a valid Link object.""" link = APLink( id="https://example.com/link/123", - type="Link", + href="https://example.com/resource" ) assert link.type == "Link" @@ -20,7 +20,7 @@ def test_link_with_optional_fields(): """Test creating a Link with all optional fields.""" link = APLink( id="https://example.com/link/123", - type="Link", + href="https://example.com/resource", name="Test Link", hreflang="en", @@ -40,7 +40,6 @@ def test_link_with_rel(): """Test creating a Link with relationship fields.""" link = APLink( id="https://example.com/link/123", - type="Link", href="https://example.com/resource", rel=["canonical", "alternate"] ) @@ -51,7 +50,6 @@ def test_valid_mention(): """Test creating a valid Mention object.""" mention = APMention( id="https://example.com/mention/123", - type="Mention", href="https://example.com/user/alice", name="@alice" ) @@ -64,7 +62,6 @@ def test_invalid_link_missing_href(): with pytest.raises(ValidationError): APLink( id="https://example.com/link/123", - type="Link" ) def test_invalid_link_invalid_url(): @@ -72,7 +69,6 @@ def test_invalid_link_invalid_url(): with pytest.raises(ValidationError): APLink( id="https://example.com/link/123", - type="Link", href="not-a-url" ) @@ -81,7 +77,6 @@ def test_invalid_link_invalid_media_type(): with pytest.raises(ValidationError): APLink( id="https://example.com/link/123", - type="Link", href="https://example.com/resource", media_type="invalid/type" ) diff --git a/tests/models/test_objects.py b/tests/unit_tests/models/test_objects.py similarity index 100% rename from tests/models/test_objects.py rename to tests/unit_tests/models/test_objects.py diff --git a/tests/serializers/__init__.py b/tests/unit_tests/serializers/__init__.py similarity index 100% rename from tests/serializers/__init__.py rename to tests/unit_tests/serializers/__init__.py diff --git a/tests/serializers/test_serialization.py b/tests/unit_tests/serializers/test_serialization.py similarity index 55% rename from tests/serializers/test_serialization.py rename to tests/unit_tests/serializers/test_serialization.py index 5f9468fe2a7c1d2e590da55a80645d1c82812951..9d30b32eedf11935f2298dc2f2575637cb9f82fb 100644 --- a/tests/serializers/test_serialization.py +++ b/tests/unit_tests/serializers/test_serialization.py @@ -1,13 +1,14 @@ """ -test_serialization.py -This module contains tests for JSON serialization of ActivityPub objects. +Tests for ActivityPub serialization. """ + import pytest -from datetime import datetime -from pydantic import ValidationError +from datetime import datetime, timezone +import json + from pyfed.models import ( - APObject, APPerson, APNote, APImage, APCollection, - APCreate, APLike, APFollow, APPlace, APEvent + APObject, APNote, APPerson, APCollection, + APCreate, APPlace, APEvent ) from pyfed.serializers.json_serializer import ActivityPubSerializer @@ -19,32 +20,29 @@ def test_serialize_ap_object(): name="Test Object", content="This is a test object." ) - serialized = ActivityPubSerializer.serialize(obj) - assert '"@context"' in serialized - assert '"type": "Object"' in serialized - assert '"name": "Test Object"' in serialized - -def test_serialize_without_context(): - """Test serialization without @context field.""" - obj = APObject( - id="https://example.com/object/123", - type="Object", - name="Test Object" - ) - serialized = ActivityPubSerializer.serialize(obj, include_context=False) - assert '"@context"' not in serialized + serialized = obj.serialize() + + # Verify serialization + assert serialized["@context"] == "https://www.w3.org/ns/activitystreams" + assert serialized["id"] == "https://example.com/object/123" + assert serialized["type"] == "Object" + assert serialized["name"] == "Test Object" + assert serialized["content"] == "This is a test object." def test_serialize_with_datetime(): """Test serialization of objects with datetime fields.""" - now = datetime.utcnow() + now = datetime.now(timezone.utc) obj = APObject( id="https://example.com/object/123", type="Object", published=now, updated=now ) - serialized = ActivityPubSerializer.serialize(obj) - assert now.isoformat() in serialized + serialized = obj.serialize() + + # Verify datetime serialization + assert serialized["published"] == now.isoformat() + assert serialized["updated"] == now.isoformat() def test_serialize_nested_objects(): """Test serialization of objects with nested objects.""" @@ -61,9 +59,12 @@ def test_serialize_nested_objects(): content="Hello, World!", attributed_to=author ) - serialized = ActivityPubSerializer.serialize(note) - assert '"attributedTo"' in serialized - assert '"type": "Person"' in serialized + serialized = note.serialize() + + # Verify nested object serialization + assert serialized["attributedTo"]["id"] == "https://example.com/users/alice" + assert serialized["attributedTo"]["type"] == "Person" + assert serialized["attributedTo"]["name"] == "Alice" def test_serialize_collection(): """Test serialization of collections.""" @@ -80,10 +81,13 @@ def test_serialize_collection(): total_items=len(items), items=items ) - serialized = ActivityPubSerializer.serialize(collection) - assert '"type": "Collection"' in serialized - assert '"totalItems": 3' in serialized - assert '"items"' in serialized + serialized = collection.serialize() + + # Verify collection serialization + assert serialized["type"] == "Collection" + assert serialized["totalItems"] == 3 + assert len(serialized["items"]) == 3 + assert all(item["type"] == "Note" for item in serialized["items"]) def test_serialize_activity(): """Test serialization of activities.""" @@ -98,46 +102,44 @@ def test_serialize_activity(): actor="https://example.com/users/alice", object=note ) - serialized = ActivityPubSerializer.serialize(create) - assert '"type": "Create"' in serialized - assert '"object"' in serialized - assert '"content": "Hello, World!"' in serialized - -def test_serialize_with_urls(): - """Test serialization of objects with URL fields.""" - place = APPlace( - id="https://example.com/places/123", - type="Place", - name="Test Place", - latitude=51.5074, - longitude=-0.1278 - ) - event = APEvent( - id="https://example.com/events/123", - type="Event", - name="Test Event", - location=place, - url="https://example.com/events/123/details" - ) - serialized = ActivityPubSerializer.serialize(event) - assert '"url":' in serialized - assert '"location"' in serialized + serialized = create.serialize() + + # Verify activity serialization + assert serialized["type"] == "Create" + assert serialized["actor"] == "https://example.com/users/alice" + assert serialized["object"]["type"] == "Note" + assert serialized["object"]["content"] == "Hello, World!" def test_deserialize_ap_object(): """Test basic object deserialization.""" - json_str = ''' - { + data = { "@context": "https://www.w3.org/ns/activitystreams", "type": "Object", "id": "https://example.com/object/123", "name": "Test Object", "content": "This is a test object." } - ''' - obj = ActivityPubSerializer.deserialize(json_str, APObject) + obj = ActivityPubSerializer.deserialize(data, APObject) + + # Verify deserialization + assert str(obj.id) == "https://example.com/object/123" assert obj.type == "Object" assert obj.name == "Test Object" + assert obj.content == "This is a test object." + +def test_deserialize_from_json_string(): + """Test deserialization from JSON string.""" + json_str = json.dumps({ + "type": "Object", + "id": "https://example.com/object/123", + "name": "Test Object" + }) + obj = ActivityPubSerializer.deserialize(json_str, APObject) + + # Verify deserialization from string assert str(obj.id) == "https://example.com/object/123" + assert obj.type == "Object" + assert obj.name == "Test Object" def test_deserialize_invalid_json(): """Test deserialization of invalid JSON.""" @@ -146,9 +148,9 @@ def test_deserialize_invalid_json(): def test_deserialize_missing_required_fields(): """Test deserialization with missing required fields.""" - json_str = '{"type": "Object", "name": "Test"}' # Missing required 'id' - with pytest.raises(ValidationError): - ActivityPubSerializer.deserialize(json_str, APObject) + data = {"type": "Object", "name": "Test"} # Missing required 'id' + with pytest.raises(Exception): # Pydantic will raise validation error + ActivityPubSerializer.deserialize(data, APObject) def test_serialize_deserialize_complex_object(): """Test round-trip serialization and deserialization.""" @@ -159,32 +161,27 @@ def test_serialize_deserialize_complex_object(): to=["https://example.com/users/bob"], cc=["https://www.w3.org/ns/activitystreams#Public"] ) - serialized = ActivityPubSerializer.serialize(original) + serialized = original.serialize() deserialized = ActivityPubSerializer.deserialize(serialized, APNote) - assert deserialized.id == original.id + + # Verify round-trip + assert str(deserialized.id) == str(original.id) + assert deserialized.type == original.type assert deserialized.content == original.content assert deserialized.to == original.to assert deserialized.cc == original.cc -def test_serialize_with_custom_json_options(): - """Test serialization with custom JSON options.""" - obj = APObject( - id="https://example.com/object/123", - type="Object", - name="Test Object" - ) - serialized = ActivityPubSerializer.serialize(obj, indent=2) - assert '\n "' in serialized # Check for indentation - def test_deserialize_with_extra_fields(): """Test deserialization with extra fields in JSON.""" - json_str = ''' - { + data = { "type": "Object", "id": "https://example.com/object/123", "name": "Test Object", "extra_field": "Should be ignored" } - ''' - obj = ActivityPubSerializer.deserialize(json_str, APObject) - assert not hasattr(obj, "extra_field") + obj = ActivityPubSerializer.deserialize(data, APObject) + + # Verify extra fields are handled + assert str(obj.id) == "https://example.com/object/123" + assert obj.type == "Object" + assert obj.name == "Test Object"