From 950a608992e712161d10e98d7f9a8e5cf61637fe Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Tue, 9 Sep 2025 14:27:44 -0600 Subject: [PATCH] Initial project structure for rentcache - Set up Python package structure with pyproject.toml - Add comprehensive configuration system with Pydantic - Support for multiple cache backends (SQLite, Redis, memory) - Intelligent cache invalidation strategy (soft delete, serve stale) - Rate limiting and cost management configuration - Ready for FastAPI proxy implementation --- .gitignore | 72 +++++++++++++++ pyproject.toml | 110 +++++++++++++++++++++++ src/rentcache/__init__.py | 27 ++++++ src/rentcache/config.py | 179 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 388 insertions(+) create mode 100644 .gitignore create mode 100644 pyproject.toml create mode 100644 src/rentcache/__init__.py create mode 100644 src/rentcache/config.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a25bc3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# IDEs +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Testing +.coverage +.pytest_cache/ +htmlcov/ +.tox/ +.mypy_cache/ +.dmypy.json +dmypy.json + +# Database +*.db +*.sqlite +*.sqlite3 +data/ + +# Logs +*.log +logs/ + +# Environment +.env +.env.local +.env.*.local + +# Cache +cache/ +.cache/ +*.cache + +# Temporary +tmp/ +temp/ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9f29e03 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,110 @@ +[project] +name = "rentcache" +version = "0.1.0" +description = "Intelligent caching proxy for Rentcast API with cost management and rate limiting" +authors = [ + {name = "Your Name", email = "your.email@example.com"} +] +readme = "README.md" +license = {text = "MIT"} +requires-python = ">=3.10" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Internet :: Proxy Servers", + "Topic :: Database :: Front-Ends", +] +keywords = ["rentcast", "api", "cache", "proxy", "real-estate", "property-data", "rate-limiting"] + +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.32.0", + "httpx>=0.27.0", + "pydantic>=2.10.0", + "pydantic-settings>=2.0.0", + "sqlalchemy>=2.0.0", + "aiosqlite>=0.20.0", + "redis>=5.0.0", + "python-dotenv>=1.0.0", + "structlog>=24.0.0", + "tenacity>=9.0.0", + "click>=8.1.0", + "rich>=13.0.0", + "python-multipart>=0.0.12", +] + +[dependency-groups] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.24.0", + "pytest-cov>=5.0.0", + "pytest-mock>=3.14.0", + "ruff>=0.7.0", + "mypy>=1.13.0", + "black>=24.0.0", + "pre-commit>=4.0.0", +] + +[project.urls] +Homepage = "https://github.com/yourusername/rentcache" +Repository = "https://github.com/yourusername/rentcache.git" +Documentation = "https://github.com/yourusername/rentcache#readme" +"Bug Tracker" = "https://github.com/yourusername/rentcache/issues" + +[project.scripts] +rentcache = "rentcache.cli:main" +rentcache-server = "rentcache.server:run" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build] +packages = ["src/rentcache"] + +[tool.ruff] +target-version = "py310" +line-length = 100 +src = ["src", "tests"] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "SIM", # flake8-simplify +] +ignore = [ + "E501", # line too long (handled by formatter) + "B008", # do not perform function calls in argument defaults +] + +[tool.black] +line-length = 100 +target-version = ['py310', 'py311', 'py312', 'py313'] + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +addopts = "-v --tb=short --cov=src/rentcache --cov-report=html --cov-report=term" + +[tool.coverage.run] +source = ["src/rentcache"] +omit = ["*/tests/*", "*/test_*.py"] \ No newline at end of file diff --git a/src/rentcache/__init__.py b/src/rentcache/__init__.py new file mode 100644 index 0000000..b305fde --- /dev/null +++ b/src/rentcache/__init__.py @@ -0,0 +1,27 @@ +""" +Rentcache - Intelligent caching proxy for Rentcast API + +A sophisticated caching layer that sits between your application and the Rentcast API, +providing cost reduction through intelligent caching, rate limiting, and usage management. +""" + +__version__ = "0.1.0" +__author__ = "Rentcache Contributors" + +from .client import RentcacheClient +from .config import settings +from .exceptions import ( + RentcacheError, + RateLimitError, + CacheError, + APIKeyError, +) + +__all__ = [ + "RentcacheClient", + "settings", + "RentcacheError", + "RateLimitError", + "CacheError", + "APIKeyError", +] \ No newline at end of file diff --git a/src/rentcache/config.py b/src/rentcache/config.py new file mode 100644 index 0000000..db82896 --- /dev/null +++ b/src/rentcache/config.py @@ -0,0 +1,179 @@ +"""Configuration management for Rentcache.""" + +from pathlib import Path +from typing import Optional, Literal +from pydantic import Field, ConfigDict, field_validator +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """Rentcache configuration settings.""" + + model_config = ConfigDict( + env_file=".env", + env_file_encoding="utf-8", + case_sensitive=False, + extra="ignore" + ) + + # API Configuration + rentcast_api_key: Optional[str] = Field( + default=None, + description="Rentcast API key" + ) + rentcast_base_url: str = Field( + default="https://api.rentcast.io/v1", + description="Rentcast API base URL" + ) + + # Proxy Server Configuration + host: str = Field(default="0.0.0.0", description="Server host") + port: int = Field(default=8100, description="Server port") + reload: bool = Field(default=False, description="Auto-reload on code changes") + log_level: str = Field(default="INFO", description="Logging level") + + # Cache Configuration + cache_backend: Literal["sqlite", "redis", "memory"] = Field( + default="sqlite", + description="Cache backend to use" + ) + cache_ttl_hours: int = Field( + default=24, + description="Default cache TTL in hours" + ) + cache_database_url: str = Field( + default="sqlite:///./data/rentcache.db", + description="Cache database URL" + ) + redis_url: Optional[str] = Field( + default=None, + description="Redis URL for cache backend" + ) + cache_max_size_mb: int = Field( + default=500, + description="Maximum cache size in MB" + ) + + # Cache Invalidation Strategy + cache_soft_delete: bool = Field( + default=True, + description="Mark cache entries as invalid instead of deleting" + ) + cache_serve_stale: bool = Field( + default=True, + description="Serve stale cache on API errors" + ) + cache_stale_ttl_hours: int = Field( + default=168, # 7 days + description="How long to keep stale entries" + ) + + # Rate Limiting + rate_limit_enabled: bool = Field(default=True, description="Enable rate limiting") + daily_request_limit: int = Field(default=1000, description="Daily request limit") + monthly_request_limit: int = Field(default=10000, description="Monthly request limit") + requests_per_minute: int = Field(default=10, description="Requests per minute limit") + + # Cost Management + cost_per_request: float = Field( + default=0.10, + description="Estimated cost per API request in USD" + ) + require_confirmation: bool = Field( + default=False, + description="Require confirmation for cache misses" + ) + confirmation_threshold: float = Field( + default=10.00, + description="Cost threshold requiring confirmation" + ) + + # Security + api_key_header: str = Field( + default="X-API-Key", + description="Header name for API key" + ) + allowed_origins: list[str] = Field( + default=["*"], + description="Allowed CORS origins" + ) + + # Paths + data_dir: Path = Field( + default=Path("./data"), + description="Data directory" + ) + log_dir: Path = Field( + default=Path("./logs"), + description="Log directory" + ) + + # Mock Mode + use_mock_api: bool = Field( + default=False, + description="Use mock API for testing" + ) + mock_api_url: str = Field( + default="http://localhost:8001", + description="Mock API URL" + ) + + # Advanced Features + enable_analytics: bool = Field( + default=True, + description="Enable usage analytics" + ) + enable_compression: bool = Field( + default=True, + description="Enable response compression" + ) + enable_request_logging: bool = Field( + default=True, + description="Log all API requests" + ) + + @field_validator("log_level") + def validate_log_level(cls, v: str) -> str: + """Validate log level.""" + valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] + if v.upper() not in valid_levels: + raise ValueError(f"Invalid log level: {v}") + return v.upper() + + @field_validator("cache_backend") + def validate_cache_backend(cls, v: str, values) -> str: + """Validate cache backend configuration.""" + if v == "redis" and not values.data.get("redis_url"): + raise ValueError("Redis URL required when using redis backend") + return v + + def __init__(self, **data): + """Initialize settings and create directories.""" + super().__init__(**data) + self._ensure_directories() + + def _ensure_directories(self): + """Ensure required directories exist.""" + self.data_dir.mkdir(parents=True, exist_ok=True) + self.log_dir.mkdir(parents=True, exist_ok=True) + + @property + def is_development(self) -> bool: + """Check if running in development mode.""" + return self.reload or self.log_level == "DEBUG" + + @property + def has_api_key(self) -> bool: + """Check if API key is configured.""" + return bool(self.rentcast_api_key and self.rentcast_api_key.strip()) + + @property + def database_url(self) -> str: + """Get the appropriate database URL based on backend.""" + if self.cache_backend == "redis": + return self.redis_url or "redis://localhost:6379/0" + return self.cache_database_url + + +# Global settings instance +settings = Settings() \ No newline at end of file