diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 994d873..49d8c3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,77 +1,77 @@ name: CI on: - push: - branches: [main] - pull_request: - branches: [main] + push: + branches: [main] + pull_request: + branches: [main] jobs: - test: - name: Test - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.9", "3.10", "3.11", "3.12"] - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Cache pip - uses: actions/cache@v4 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-pip- - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" - - - name: Run linting - run: ruff check . - - - name: Run type checking - run: mypy permisio - - - name: Run tests - run: pytest --cov=permisio --cov-report=xml - - - name: Upload coverage - uses: codecov/codecov-action@v4 - with: - file: ./coverage.xml - flags: unittests - name: codecov-python-${{ matrix.python-version }} - - build: - name: Build - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Setup Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install build dependencies - run: | - python -m pip install --upgrade pip - pip install build twine - - - name: Build package - run: python -m build - - - name: Check package - run: twine check dist/* + test: + name: Test + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Cache pip + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-pip- + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run linting + run: ruff check . + + - name: Run type checking + run: mypy permissio + + - name: Run tests + run: pytest --cov=permissio --cov-report=xml + + - name: Upload coverage + uses: codecov/codecov-action@v4 + with: + file: ./coverage.xml + flags: unittests + name: codecov-python-${{ matrix.python-version }} + + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package + run: python -m build + + - name: Check package + run: twine check dist/* diff --git a/examples/async_example.py b/examples/async_example.py index b69c2c9..2500b81 100644 --- a/examples/async_example.py +++ b/examples/async_example.py @@ -11,65 +11,65 @@ import asyncio from permissio import Permissio -from permissio.enforcement import UserBuilder, ResourceBuilder -from permissio.models import UserCreate, TenantCreate +from permissio.enforcement import ResourceBuilder, UserBuilder from permissio.errors import PermissioApiError +from permissio.models import UserCreate async def main(): # ========================================================================= # Async Client Initialization # ========================================================================= - + print("=== Async Client Example ===\n") - + # Initialize the client (works for both sync and async) permissio = Permissio( token="permis_key_your_api_key_here", project_id="my-project", environment_id="production", ) - + try: # ===================================================================== # Async Permission Checks # ===================================================================== - + print("--- Permission Checks ---") - + # Simple async check allowed = await permissio.check_async("user@example.com", "read", "document") print(f"Can user read document? {allowed}") - + # Check with ABAC user = ( UserBuilder("user@example.com") .with_attribute("department", "engineering") .build() ) - + resource = ( ResourceBuilder("document") .with_key("doc-123") .with_tenant("acme-corp") .build() ) - + allowed = await permissio.check_async(user, "read", resource) print(f"Can engineering user read doc-123? {allowed}") - + # Get detailed response response = await permissio.check_with_details_async( "user@example.com", "write", "document" ) print(f"Write allowed: {response.allowed}") - + # ===================================================================== # Concurrent Permission Checks # ===================================================================== - + print("\n--- Concurrent Permission Checks ---") - + # Check multiple permissions concurrently checks = [ permissio.check_async("user1@example.com", "read", "document"), @@ -77,30 +77,30 @@ async def main(): permissio.check_async("user1@example.com", "write", "document"), permissio.check_async("user2@example.com", "delete", "document"), ] - + results = await asyncio.gather(*checks) - + check_descriptions = [ "user1 read document", "user2 read document", "user1 write document", "user2 delete document", ] - + for desc, result in zip(check_descriptions, results): print(f" {desc}: {result}") - + # ===================================================================== # Async API Operations # ===================================================================== - + print("\n--- Async API Operations ---") - + try: # List users async users = await permissio.api.users.list_async(page=1, per_page=5) print(f"Total users: {users.pagination.total}") - + # Create user async new_user = await permissio.api.users.create_async(UserCreate( key="async.user@example.com", @@ -109,24 +109,24 @@ async def main(): last_name="User", )) print(f"Created user: {new_user.key}") - + # Get user async user = await permissio.api.users.get_async("async.user@example.com") print(f"Got user: {user.key}") - + # Delete user async await permissio.api.users.delete_async("async.user@example.com") print("Deleted user") - + except PermissioApiError as e: print(f"API error: {e.message}") - + # ===================================================================== # Async Role Assignment # ===================================================================== - + print("\n--- Async Role Assignment ---") - + try: # Assign role async assignment = await permissio.api.role_assignments.assign_async( @@ -135,13 +135,13 @@ async def main(): tenant="acme-corp", ) print(f"Assigned role: {assignment.role_key}") - + # List assignments async assignments = await permissio.api.role_assignments.list_async( user="user@example.com" ) print(f"User has {len(assignments.data)} role assignments") - + # Unassign role async await permissio.api.role_assignments.unassign_async( user="user@example.com", @@ -149,40 +149,40 @@ async def main(): tenant="acme-corp", ) print("Unassigned role") - + except PermissioApiError as e: print(f"API error: {e.message}") - + # ===================================================================== # Parallel API Operations # ===================================================================== - + print("\n--- Parallel API Operations ---") - + # Fetch multiple resources concurrently try: users_task = permissio.api.users.list_async() tenants_task = permissio.api.tenants.list_async() roles_task = permissio.api.roles.list_async() resources_task = permissio.api.resources.list_async() - + users, tenants, roles, resources = await asyncio.gather( users_task, tenants_task, roles_task, resources_task ) - + print(f" Users: {users.pagination.total}") print(f" Tenants: {tenants.pagination.total}") print(f" Roles: {roles.pagination.total}") print(f" Resources: {resources.pagination.total}") - + except PermissioApiError as e: print(f"API error: {e.message}") - + finally: # ===================================================================== # Cleanup # ===================================================================== - + print("\n--- Cleanup ---") await permissio.close_async() print("Client closed") @@ -190,23 +190,23 @@ async def main(): async def context_manager_example(): """Example using async context manager.""" - + print("\n=== Context Manager Example ===") - + async with Permissio(token="permis_key_your_api_key") as permissio: allowed = await permissio.check_async("user@example.com", "read", "document") print(f"Permission check result: {allowed}") - + print("Client automatically closed") async def batch_operations_example(): """Example of batch operations.""" - + print("\n=== Batch Operations Example ===") - + permissio = Permissio(token="permis_key_your_api_key") - + try: # Create multiple users concurrently user_creates = [ @@ -216,23 +216,23 @@ async def batch_operations_example(): )) for i in range(5) ] - + created_users = await asyncio.gather(*user_creates, return_exceptions=True) - + successful = [u for u in created_users if not isinstance(u, Exception)] failed = [u for u in created_users if isinstance(u, Exception)] - + print(f"Created {len(successful)} users, {len(failed)} failed") - + # Clean up - delete created users delete_tasks = [ permissio.api.users.delete_async(f"batch.user{i}@example.com") for i in range(5) ] - + await asyncio.gather(*delete_tasks, return_exceptions=True) print("Cleaned up batch users") - + finally: await permissio.close_async() @@ -240,7 +240,7 @@ async def batch_operations_example(): if __name__ == "__main__": # Run main example asyncio.run(main()) - + # Run additional examples asyncio.run(context_manager_example()) asyncio.run(batch_operations_example()) diff --git a/examples/basic.py b/examples/basic.py index ee7a363..7ebeb30 100644 --- a/examples/basic.py +++ b/examples/basic.py @@ -8,24 +8,24 @@ - API operations for users, tenants, and roles """ -from permissio import Permissio, ConfigBuilder -from permissio.enforcement import UserBuilder, ResourceBuilder -from permissio.models import UserCreate, TenantCreate, RoleCreate +from permissio import ConfigBuilder, Permissio +from permissio.enforcement import ResourceBuilder, UserBuilder from permissio.errors import PermissioApiError, PermissioNotFoundError +from permissio.models import RoleCreate, TenantCreate, UserCreate def main(): # ========================================================================= # Client Initialization # ========================================================================= - + # Option 1: Simple initialization permissio = Permissio( token="permis_key_your_api_key_here", project_id="my-project", environment_id="production", ) - + # Option 2: Using ConfigBuilder config = ( ConfigBuilder("permis_key_your_api_key_here") @@ -36,17 +36,17 @@ def main(): .build() ) permissio = Permissio(config=config) - + # ========================================================================= # Simple Permission Check # ========================================================================= - + print("=== Simple Permission Check ===") - + # Check if user can read a document allowed = permissio.check("user@example.com", "read", "document") print(f"Can user read document? {allowed}") - + # Check with tenant context allowed = permissio.check( "user@example.com", @@ -55,7 +55,7 @@ def main(): tenant="acme-corp" ) print(f"Can user write document in acme-corp? {allowed}") - + # Check with resource instance allowed = permissio.check( "user@example.com", @@ -63,13 +63,13 @@ def main(): {"type": "document", "key": "doc-123"} ) print(f"Can user delete doc-123? {allowed}") - + # ========================================================================= # ABAC Permission Check # ========================================================================= - + print("\n=== ABAC Permission Check ===") - + # Build user with attributes user = ( UserBuilder("user@example.com") @@ -78,7 +78,7 @@ def main(): .with_attribute("location", "US") .build() ) - + # Build resource with attributes resource = ( ResourceBuilder("document") @@ -88,44 +88,44 @@ def main(): .with_attribute("owner_department", "engineering") .build() ) - + # Check permission with ABAC allowed = permissio.check(user, "read", resource) print(f"Can engineering user (level 5) read confidential doc? {allowed}") - + # Get detailed response response = permissio.check_with_details(user, "read", resource) print(f"Allowed: {response.allowed}") if response.reason: print(f"Reason: {response.reason}") - + # ========================================================================= # Bulk Permission Check # ========================================================================= - + print("\n=== Bulk Permission Check ===") - + checks = [ {"user": "user1@example.com", "action": "read", "resource": "document"}, {"user": "user1@example.com", "action": "write", "resource": "document"}, {"user": "user2@example.com", "action": "read", "resource": "document"}, {"user": "user2@example.com", "action": "delete", "resource": "document"}, ] - + results = permissio.bulk_check(checks) for i, result in enumerate(results.results): check = checks[i] print(f"{check['user']} can {check['action']} {check['resource']}: {result.allowed}") - + print(f"All allowed: {results.all_allowed()}") print(f"Any allowed: {results.any_allowed()}") - + # ========================================================================= # User Management # ========================================================================= - + print("\n=== User Management ===") - + try: # Create a user new_user = permissio.api.users.create(UserCreate( @@ -136,32 +136,32 @@ def main(): attributes={"department": "sales"}, )) print(f"Created user: {new_user.key}") - + # List users users = permissio.api.users.list(page=1, per_page=10) print(f"Total users: {users.pagination.total}") for user in users.data: print(f" - {user.key}") - + # Get a specific user user = permissio.api.users.get("new.user@example.com") print(f"Got user: {user.key} ({user.email})") - + # Delete the user permissio.api.users.delete("new.user@example.com") print("Deleted user") - + except PermissioNotFoundError as e: print(f"User not found: {e.message}") except PermissioApiError as e: print(f"API error: {e.message}") - + # ========================================================================= # Tenant Management # ========================================================================= - + print("\n=== Tenant Management ===") - + try: # Create a tenant tenant = permissio.api.tenants.create(TenantCreate( @@ -170,27 +170,27 @@ def main(): description="A demo tenant for testing", )) print(f"Created tenant: {tenant.key}") - + # Or use convenience method tenant2 = permissio.create_tenant({ "key": "demo-tenant-2", "name": "Demo Tenant 2", }) print(f"Created tenant: {tenant2.key}") - + # List tenants tenants = permissio.api.tenants.list() print(f"Total tenants: {tenants.pagination.total}") - + except PermissioApiError as e: print(f"API error: {e.message}") - + # ========================================================================= # Role Management # ========================================================================= - + print("\n=== Role Management ===") - + try: # Create a role role = permissio.api.roles.create(RoleCreate( @@ -200,22 +200,22 @@ def main(): permissions=["document:read", "document:write"], )) print(f"Created role: {role.key}") - + # List roles roles = permissio.api.roles.list() print(f"Total roles: {roles.pagination.total}") for role in roles.data: print(f" - {role.key}: {role.permissions}") - + except PermissioApiError as e: print(f"API error: {e.message}") - + # ========================================================================= # Role Assignment # ========================================================================= - + print("\n=== Role Assignment ===") - + try: # Assign a role to a user assignment = permissio.assign_role( @@ -224,11 +224,11 @@ def main(): tenant="acme-corp", ) print(f"Assigned role: {assignment.role_key} to {assignment.user_key}") - + # Get user's roles roles = permissio.api.users.get_roles("user@example.com") print(f"User roles: {[r.role_key for r in roles]}") - + # Unassign the role permissio.unassign_role( user="user@example.com", @@ -236,16 +236,16 @@ def main(): tenant="acme-corp", ) print("Unassigned role") - + except PermissioApiError as e: print(f"API error: {e.message}") - + # ========================================================================= # Sync User (Create or Update with Roles) # ========================================================================= - + print("\n=== Sync User ===") - + try: # Sync a user with roles synced_user = permissio.sync_user({ @@ -259,14 +259,14 @@ def main(): ], }) print(f"Synced user: {synced_user.key}") - + except PermissioApiError as e: print(f"API error: {e.message}") - + # ========================================================================= # Cleanup # ========================================================================= - + print("\n=== Cleanup ===") permissio.close() print("Client closed") diff --git a/examples/flask_example.py b/examples/flask_example.py index 2908cac..abe870d 100644 --- a/examples/flask_example.py +++ b/examples/flask_example.py @@ -9,13 +9,12 @@ """ from functools import wraps -from typing import Optional, Callable, Any +from typing import Callable, Optional -from flask import Flask, g, request, jsonify, abort +from flask import Flask, abort, g, jsonify, request from permissio import Permissio -from permissio.errors import PermissioApiError, PermissioNotFoundError - +from permissio.errors import PermissioApiError # ============================================================================ # Flask Application Setup @@ -44,7 +43,7 @@ def authenticate(): """ # Get auth token from header auth_header = request.headers.get("Authorization", "") - + if auth_header.startswith("Bearer "): token = auth_header[7:] # In a real app, validate the token and get user info @@ -67,12 +66,12 @@ def authenticate(): def require_permission(action: str, resource: str, get_resource_key: Optional[Callable] = None): """ Decorator to require a specific permission for a route. - + Args: action: The action to check (e.g., "read", "write", "delete") resource: The resource type (e.g., "document", "project") get_resource_key: Optional callable to extract resource key from request - + Example: @app.route("/documents/") @require_permission("read", "document", lambda: request.view_args.get("doc_id")) @@ -84,26 +83,26 @@ def decorator(f): def decorated_function(*args, **kwargs): if not g.user_id: abort(401, description="Authentication required") - + # Build resource for check resource_data = {"type": resource} - + # Add resource key if provided if get_resource_key: resource_key = get_resource_key() if resource_key: resource_data["key"] = resource_key - + # Add tenant if available if g.user and g.user.get("tenant"): resource_data["tenant"] = g.user["tenant"] - + # Check permission allowed = permissio.check(g.user_id, action, resource_data) - + if not allowed: abort(403, description=f"Permission denied: {action} on {resource}") - + return f(*args, **kwargs) return decorated_function return decorator @@ -112,10 +111,10 @@ def decorated_function(*args, **kwargs): def require_any_permission(*permissions): """ Decorator to require any of the specified permissions. - + Args: permissions: Tuples of (action, resource) - + Example: @app.route("/documents") @require_any_permission(("read", "document"), ("admin", "document")) @@ -127,16 +126,16 @@ def decorator(f): def decorated_function(*args, **kwargs): if not g.user_id: abort(401, description="Authentication required") - + # Check each permission for action, resource in permissions: resource_data = {"type": resource} if g.user and g.user.get("tenant"): resource_data["tenant"] = g.user["tenant"] - + if permissio.check(g.user_id, action, resource_data): return f(*args, **kwargs) - + abort(403, description="Permission denied") return decorated_function return decorator @@ -145,7 +144,7 @@ def decorated_function(*args, **kwargs): def require_all_permissions(*permissions): """ Decorator to require all of the specified permissions. - + Args: permissions: Tuples of (action, resource) """ @@ -154,26 +153,26 @@ def decorator(f): def decorated_function(*args, **kwargs): if not g.user_id: abort(401, description="Authentication required") - + # Build bulk check checks = [] for action, resource in permissions: resource_data = {"type": resource} if g.user and g.user.get("tenant"): resource_data["tenant"] = g.user["tenant"] - + checks.append({ "user": g.user_id, "action": action, "resource": resource_data, }) - + # Bulk check results = permissio.bulk_check(checks) - + if not results.all_allowed(): abort(403, description="Permission denied") - + return f(*args, **kwargs) return decorated_function return decorator @@ -190,13 +189,13 @@ def can_user(action: str, resource: str, resource_key: Optional[str] = None) -> """ if not g.user_id: return False - + resource_data = {"type": resource} if resource_key: resource_data["key"] = resource_key if g.user and g.user.get("tenant"): resource_data["tenant"] = g.user["tenant"] - + return permissio.check(g.user_id, action, resource_data) @@ -256,14 +255,14 @@ def get_document(doc_id): def create_document(): """Create a document - requires create permission.""" data = request.get_json() - + # In a real app, save to database new_doc = { "id": "doc-new", "title": data.get("title", "Untitled"), "content": data.get("content", ""), } - + return jsonify(new_doc), 201 @@ -272,14 +271,14 @@ def create_document(): def update_document(doc_id): """Update a document - requires write permission on the document.""" data = request.get_json() - + # In a real app, update in database updated_doc = { "id": doc_id, "title": data.get("title", f"Document {doc_id}"), "content": data.get("content", ""), } - + return jsonify(updated_doc) @@ -327,9 +326,9 @@ def sync_user(): """ if not g.user_id: abort(401) - + data = request.get_json() or {} - + try: synced_user = permissio.sync_user({ "key": g.user_id, @@ -338,12 +337,12 @@ def sync_user(): "last_name": data.get("last_name"), "attributes": data.get("attributes", {}), }) - + return jsonify({ "message": "User synced successfully", "user_key": synced_user.key, }) - + except PermissioApiError as e: return jsonify({"error": e.message}), e.status_code @@ -398,5 +397,5 @@ def cleanup(exception=None): print(" curl http://localhost:5000/") print(' curl -H "Authorization: Bearer user123" http://localhost:5000/documents') print(' curl -H "Authorization: Bearer user123" http://localhost:5000/documents/doc-1') - + app.run(debug=True, port=5000) diff --git a/examples/test_local.py b/examples/test_local.py index 0bed5e2..630581d 100644 --- a/examples/test_local.py +++ b/examples/test_local.py @@ -16,10 +16,9 @@ # Add parent directory to path for development sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from permissio import Permissio, ConfigBuilder -from permissio.models import UserCreate, TenantCreate, RoleCreate -from permissio.errors import PermissioApiError, PermissioNotFoundError - +from permissio import ConfigBuilder, Permissio +from permissio.errors import PermissioApiError +from permissio.models import UserCreate # ============================================================================ # Configuration - UPDATE THESE VALUES @@ -40,7 +39,7 @@ def main(): print("=" * 60) print("Permissio.io Python SDK - Local Backend Test") print("=" * 60) - print(f"\nConfiguration:") + print("\nConfiguration:") print(f" API URL: {API_URL}") print(f" Project: {PROJECT_ID}") print(f" Environment: {ENVIRONMENT_ID}") @@ -54,9 +53,9 @@ def main(): .with_debug(True) .build() ) - + permissio = Permissio(config=config) - + # Initialize the SDK to fetch project/environment scope from API key try: permissio.init() @@ -65,7 +64,7 @@ def main(): except Exception as e: print(f"✗ Failed to initialize SDK: {e}") return - + try: # ===================================================================== # Test 1: List Users @@ -78,7 +77,7 @@ def main(): print(f" - {user.key}") except PermissioApiError as e: print(f"✗ Error: {e.message} (status: {e.status_code})") - + # ===================================================================== # Test 2: List Tenants # ===================================================================== @@ -90,7 +89,7 @@ def main(): print(f" - {tenant.key}: {tenant.name}") except PermissioApiError as e: print(f"✗ Error: {e.message} (status: {e.status_code})") - + # ===================================================================== # Test 3: List Roles # ===================================================================== @@ -102,7 +101,7 @@ def main(): print(f" - {role.key}: {role.name}") except PermissioApiError as e: print(f"✗ Error: {e.message} (status: {e.status_code})") - + # ===================================================================== # Test 4: List Resources # ===================================================================== @@ -114,7 +113,7 @@ def main(): print(f" - {resource.key}: {resource.name}") except PermissioApiError as e: print(f"✗ Error: {e.message} (status: {e.status_code})") - + # ===================================================================== # Test 5: Create and Delete User # ===================================================================== @@ -129,23 +128,23 @@ def main(): last_name="Test", )) print(f"✓ Created user: {new_user.key}") - + # Get fetched = permissio.api.users.get(test_user_key) print(f"✓ Fetched user: {fetched.key}") - + # Delete permissio.api.users.delete(test_user_key) print(f"✓ Deleted user: {test_user_key}") - + except PermissioApiError as e: print(f"✗ Error: {e.message} (status: {e.status_code})") # Try to clean up try: permissio.api.users.delete(test_user_key) - except: + except Exception: pass - + # ===================================================================== # Test 6: Permission Check # ===================================================================== @@ -154,23 +153,23 @@ def main(): # Get a user and resource to test with users = permissio.api.users.list(per_page=1) resources = permissio.api.resources.list(per_page=1) - + if users.data and resources.data: user_key = users.data[0].key resource_key = resources.data[0].key - + allowed = permissio.check(user_key, "read", resource_key) print(f"✓ Check result: {user_key} can read {resource_key}: {allowed}") else: print("⚠ No users or resources found for permission check test") - + except PermissioApiError as e: print(f"✗ Error: {e.message} (status: {e.status_code})") - + print("\n" + "=" * 60) print("Tests completed!") print("=" * 60) - + finally: permissio.close() diff --git a/permissio/__init__.py b/permissio/__init__.py index 242e0b5..212da46 100644 --- a/permissio/__init__.py +++ b/permissio/__init__.py @@ -5,8 +5,8 @@ """ from permissio.client import Permissio -from permissio.config import PermissioConfig, ConfigBuilder -from permissio.errors import PermissioError, PermissioApiError, PermissioValidationError +from permissio.config import ConfigBuilder, PermissioConfig +from permissio.errors import PermissioApiError, PermissioError, PermissioValidationError __version__ = "0.1.0" __all__ = [ diff --git a/permissio/api/__init__.py b/permissio/api/__init__.py index 7d315d6..065bc2a 100644 --- a/permissio/api/__init__.py +++ b/permissio/api/__init__.py @@ -3,11 +3,11 @@ """ from permissio.api.base import BaseApiClient -from permissio.api.users import UsersApi -from permissio.api.tenants import TenantsApi -from permissio.api.roles import RolesApi from permissio.api.resources import ResourcesApi from permissio.api.role_assignments import RoleAssignmentsApi +from permissio.api.roles import RolesApi +from permissio.api.tenants import TenantsApi +from permissio.api.users import UsersApi __all__ = [ "BaseApiClient", diff --git a/permissio/api/base.py b/permissio/api/base.py index c065b65..b444258 100644 --- a/permissio/api/base.py +++ b/permissio/api/base.py @@ -2,9 +2,9 @@ Base API client for the Permissio.io SDK. """ -import time import logging -from typing import Optional, Dict, Any, TypeVar, Type, List, Union +import time +from typing import Any, Dict, Optional, TypeVar from urllib.parse import urljoin import httpx @@ -12,13 +12,13 @@ from permissio.config import PermissioConfig from permissio.errors import ( PermissioApiError, - PermissioNetworkError, - PermissioTimeoutError, - PermissioRateLimitError, PermissioAuthenticationError, - PermissioPermissionError, - PermissioNotFoundError, PermissioConflictError, + PermissioNetworkError, + PermissioNotFoundError, + PermissioPermissionError, + PermissioRateLimitError, + PermissioTimeoutError, ) T = TypeVar("T") @@ -98,7 +98,7 @@ def _build_facts_url(self, path: str) -> str: """ if not self.config.has_scope(): raise ValueError("project_id and environment_id are required for this operation") - + base_path = f"{self.FACTS_API_PREFIX}/{self.config.project_id}/{self.config.environment_id}" full_path = f"{base_path}/{path.lstrip('/')}" if path else base_path return urljoin(self.config.api_url + "/", full_path.lstrip("/")) @@ -118,7 +118,7 @@ def _build_schema_url(self, path: str) -> str: """ if not self.config.has_scope(): raise ValueError("project_id and environment_id are required for this operation") - + base_path = f"{self.SCHEMA_API_PREFIX}/{self.config.project_id}/{self.config.environment_id}" full_path = f"{base_path}/{path.lstrip('/')}" if path else base_path return urljoin(self.config.api_url + "/", full_path.lstrip("/")) @@ -135,7 +135,7 @@ def _build_allowed_url(self) -> str: """ if not self.config.has_scope(): raise ValueError("project_id and environment_id are required for this operation") - + path = f"{self.ALLOWED_API_PREFIX}/{self.config.project_id}/{self.config.environment_id}" return urljoin(self.config.api_url + "/", path.lstrip("/")) @@ -237,7 +237,7 @@ def _calculate_retry_delay(self, attempt: int, exception: Optional[Exception] = return float(exception.retry_after) # Exponential backoff: 1s, 2s, 4s, 8s, ... - return min(2**attempt, 30) # Cap at 30 seconds + return float(min(2**attempt, 30)) # Cap at 30 seconds def _log_request(self, method: str, url: str, body: Optional[Dict] = None) -> None: """Log a request if debug mode is enabled.""" diff --git a/permissio/api/resources.py b/permissio/api/resources.py index ebc0e03..0cd825c 100644 --- a/permissio/api/resources.py +++ b/permissio/api/resources.py @@ -2,19 +2,18 @@ Resources API client for the Permissio.io SDK. """ -from typing import Optional, Dict, Any, Union, List +from typing import Any, Dict, List, Optional, Union from permissio.api.base import BaseApiClient from permissio.config import PermissioConfig +from permissio.models.common import PaginatedResponse from permissio.models.resource import ( - Resource, - ResourceCreate, - ResourceUpdate, - ResourceRead, ResourceAction, ResourceAttribute, + ResourceCreate, + ResourceRead, + ResourceUpdate, ) -from permissio.models.common import PaginatedResponse class ResourcesApi(BaseApiClient): diff --git a/permissio/api/role_assignments.py b/permissio/api/role_assignments.py index fb43a09..4e5c79d 100644 --- a/permissio/api/role_assignments.py +++ b/permissio/api/role_assignments.py @@ -2,17 +2,15 @@ Role Assignments API client for the Permissio.io SDK. """ -from typing import Optional, Dict, Any, Union, List +from typing import Any, Dict, List, Optional, Union from permissio.api.base import BaseApiClient from permissio.config import PermissioConfig +from permissio.models.common import PaginatedResponse from permissio.models.role_assignment import ( - RoleAssignment, RoleAssignmentCreate, RoleAssignmentRead, - BulkRoleAssignment, ) -from permissio.models.common import PaginatedResponse class RoleAssignmentsApi(BaseApiClient): @@ -249,7 +247,7 @@ def bulk_assign(self, assignments: List[Union[RoleAssignmentCreate, Dict[str, An url = self._build_facts_url("role_assignments/bulk") response = self.post(url, json={"assignments": assignment_dicts}) data = response.json() - + if isinstance(data, list): return [RoleAssignmentRead.from_dict(item) for item in data] return [RoleAssignmentRead.from_dict(item) for item in data.get("data", [])] @@ -274,7 +272,7 @@ async def bulk_assign_async(self, assignments: List[Union[RoleAssignmentCreate, url = self._build_facts_url("role_assignments/bulk") response = await self.post_async(url, json={"assignments": assignment_dicts}) data = response.json() - + if isinstance(data, list): return [RoleAssignmentRead.from_dict(item) for item in data] return [RoleAssignmentRead.from_dict(item) for item in data.get("data", [])] diff --git a/permissio/api/roles.py b/permissio/api/roles.py index 88ec90f..e5a9263 100644 --- a/permissio/api/roles.py +++ b/permissio/api/roles.py @@ -2,12 +2,12 @@ Roles API client for the Permissio.io SDK. """ -from typing import Optional, Dict, Any, Union, List +from typing import Any, Dict, List, Optional, Union from permissio.api.base import BaseApiClient from permissio.config import PermissioConfig -from permissio.models.role import Role, RoleCreate, RoleUpdate, RoleRead from permissio.models.common import PaginatedResponse +from permissio.models.role import RoleCreate, RoleRead, RoleUpdate class RolesApi(BaseApiClient): diff --git a/permissio/api/tenants.py b/permissio/api/tenants.py index b48e4bd..108484c 100644 --- a/permissio/api/tenants.py +++ b/permissio/api/tenants.py @@ -2,12 +2,12 @@ Tenants API client for the Permissio.io SDK. """ -from typing import Optional, Dict, Any, Union +from typing import Any, Dict, Optional, Union from permissio.api.base import BaseApiClient from permissio.config import PermissioConfig -from permissio.models.tenant import Tenant, TenantCreate, TenantUpdate, TenantRead from permissio.models.common import PaginatedResponse +from permissio.models.tenant import TenantCreate, TenantRead, TenantUpdate class TenantsApi(BaseApiClient): diff --git a/permissio/api/users.py b/permissio/api/users.py index 0ec202a..ae5be9d 100644 --- a/permissio/api/users.py +++ b/permissio/api/users.py @@ -2,13 +2,13 @@ Users API client for the Permissio.io SDK. """ -from typing import Optional, Dict, Any, List, Union +from typing import Any, Dict, List, Optional, Union from permissio.api.base import BaseApiClient from permissio.config import PermissioConfig -from permissio.models.user import User, UserCreate, UserUpdate, UserRead, UserSync +from permissio.models.common import PaginatedResponse from permissio.models.role_assignment import RoleAssignment, RoleAssignmentCreate -from permissio.models.common import PaginatedResponse, ListParams +from permissio.models.user import UserCreate, UserRead, UserSync, UserUpdate class UsersApi(BaseApiClient): @@ -393,7 +393,7 @@ def get_roles(self, user_key: str, *, tenant: Optional[str] = None) -> List[Role url = self._build_facts_url("role_assignments") response = super().get(url, params=params) data = response.json() - + # Handle both paginated and non-paginated responses if isinstance(data, list): return [RoleAssignment.from_dict(item) for item in data] @@ -417,7 +417,7 @@ async def get_roles_async(self, user_key: str, *, tenant: Optional[str] = None) url = self._build_facts_url("role_assignments") response = await super().get_async(url, params=params) data = response.json() - + if isinstance(data, list): return [RoleAssignment.from_dict(item) for item in data] return [RoleAssignment.from_dict(item) for item in data.get("data", [])] diff --git a/permissio/client.py b/permissio/client.py index eada373..845e3f3 100644 --- a/permissio/client.py +++ b/permissio/client.py @@ -2,25 +2,24 @@ Main Permissio.io SDK client. """ -from typing import Optional, Dict, Any, Union +from typing import Any, Dict, Optional, Union -from permissio.config import PermissioConfig, ConfigBuilder, resolve_config from permissio.api.base import BaseApiClient -from permissio.api.users import UsersApi -from permissio.api.tenants import TenantsApi -from permissio.api.roles import RolesApi from permissio.api.resources import ResourcesApi from permissio.api.role_assignments import RoleAssignmentsApi -from permissio.models.check import CheckRequest, CheckResponse, BulkCheckRequest, BulkCheckResponse +from permissio.api.roles import RolesApi +from permissio.api.tenants import TenantsApi +from permissio.api.users import UsersApi +from permissio.config import ConfigBuilder, PermissioConfig, resolve_config from permissio.enforcement.models import ( - CheckUser, CheckResource, - UserBuilder, + CheckUser, ResourceBuilder, - normalize_user, + UserBuilder, normalize_resource, - normalize_context, + normalize_user, ) +from permissio.models.check import BulkCheckResponse, CheckResponse class PermissioApi: @@ -419,7 +418,7 @@ def check_with_details( ) # 2. Get unique role keys from assignments - role_keys = set(a.role for a in assignments) + role_keys = {a.role for a in assignments} if self._config.debug: logger.debug(f"User's role keys: {list(role_keys)}") @@ -560,7 +559,7 @@ async def check_with_details_async( ) # 2. Get unique role keys from assignments - role_keys = set(a.role for a in assignments) + role_keys = {a.role for a in assignments} if self._config.debug: logger.debug(f"User's role keys: {list(role_keys)}") diff --git a/permissio/config.py b/permissio/config.py index 23c9f0e..c5a371a 100644 --- a/permissio/config.py +++ b/permissio/config.py @@ -3,7 +3,8 @@ """ from dataclasses import dataclass, field -from typing import Optional, Dict, Any +from typing import Any, Dict, Optional + import httpx # Constants diff --git a/permissio/enforcement/__init__.py b/permissio/enforcement/__init__.py index 33befe5..bc854ce 100644 --- a/permissio/enforcement/__init__.py +++ b/permissio/enforcement/__init__.py @@ -3,12 +3,12 @@ """ from permissio.enforcement.models import ( - UserBuilder, - ResourceBuilder, - ContextBuilder, - CheckUser, - CheckResource, CheckContext, + CheckResource, + CheckUser, + ContextBuilder, + ResourceBuilder, + UserBuilder, ) __all__ = [ diff --git a/permissio/enforcement/models.py b/permissio/enforcement/models.py index 99a5d35..c16d642 100644 --- a/permissio/enforcement/models.py +++ b/permissio/enforcement/models.py @@ -6,7 +6,7 @@ """ from dataclasses import dataclass, field -from typing import Optional, Dict, Any, Union +from typing import Any, Dict, Optional, Union @dataclass diff --git a/permissio/errors.py b/permissio/errors.py index 17d2cd4..63b6c79 100644 --- a/permissio/errors.py +++ b/permissio/errors.py @@ -2,7 +2,7 @@ Error types for the Permissio.io SDK. """ -from typing import Optional, Dict, Any +from typing import Any, Dict, Optional class PermissioError(Exception): diff --git a/permissio/models/__init__.py b/permissio/models/__init__.py index ae37a4c..e1de81f 100644 --- a/permissio/models/__init__.py +++ b/permissio/models/__init__.py @@ -2,13 +2,13 @@ Permissio.io data models. """ +from permissio.models.check import CheckRequest, CheckResponse from permissio.models.common import PaginatedResponse, Pagination -from permissio.models.user import User, UserCreate, UserUpdate, UserRead -from permissio.models.tenant import Tenant, TenantCreate, TenantUpdate, TenantRead -from permissio.models.role import Role, RoleCreate, RoleUpdate, RoleRead -from permissio.models.resource import Resource, ResourceCreate, ResourceUpdate, ResourceRead +from permissio.models.resource import Resource, ResourceCreate, ResourceRead, ResourceUpdate +from permissio.models.role import Role, RoleCreate, RoleRead, RoleUpdate from permissio.models.role_assignment import RoleAssignment, RoleAssignmentCreate, RoleAssignmentRead -from permissio.models.check import CheckRequest, CheckResponse +from permissio.models.tenant import Tenant, TenantCreate, TenantRead, TenantUpdate +from permissio.models.user import User, UserCreate, UserRead, UserUpdate __all__ = [ # Common diff --git a/permissio/models/check.py b/permissio/models/check.py index 33a8a87..f647387 100644 --- a/permissio/models/check.py +++ b/permissio/models/check.py @@ -3,7 +3,7 @@ """ from dataclasses import dataclass, field -from typing import Optional, Dict, Any, Union, List +from typing import Any, Dict, List, Optional, Union @dataclass diff --git a/permissio/models/common.py b/permissio/models/common.py index cf224d8..50c3edf 100644 --- a/permissio/models/common.py +++ b/permissio/models/common.py @@ -2,10 +2,9 @@ Common models and utilities for the Permissio.io SDK. """ -from dataclasses import dataclass, field -from typing import TypeVar, Generic, List, Optional, Dict, Any +from dataclasses import dataclass from datetime import datetime - +from typing import Any, Callable, Dict, Generic, List, Optional, TypeVar T = TypeVar("T") @@ -52,7 +51,7 @@ class PaginatedResponse(Generic[T]): pagination: Pagination @classmethod - def from_dict(cls, data: Dict[str, Any], item_factory: callable) -> "PaginatedResponse[T]": + def from_dict(cls, data: Dict[str, Any], item_factory: Callable[[Dict[str, Any]], T]) -> "PaginatedResponse[T]": """ Create a PaginatedResponse from a dictionary. @@ -64,14 +63,14 @@ def from_dict(cls, data: Dict[str, Any], item_factory: callable) -> "PaginatedRe A PaginatedResponse instance. """ items = [item_factory(item) for item in data.get("data", [])] - + # Handle both nested pagination object and flat response with total_count/page_count if "pagination" in data: pagination = Pagination.from_dict(data.get("pagination", {})) else: # Flat response format: total_count, page_count at top level pagination = Pagination.from_dict(data) - + return cls(data=items, pagination=pagination) diff --git a/permissio/models/resource.py b/permissio/models/resource.py index f7d868b..c431452 100644 --- a/permissio/models/resource.py +++ b/permissio/models/resource.py @@ -3,10 +3,10 @@ """ from dataclasses import dataclass, field -from typing import Optional, Dict, Any, List from datetime import datetime +from typing import Any, Dict, List, Optional -from permissio.models.common import parse_datetime, format_datetime +from permissio.models.common import format_datetime, parse_datetime @dataclass @@ -233,7 +233,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "ResourceRead": actions.append(ResourceAction(key=a)) elif isinstance(a, dict): actions.append(ResourceAction.from_dict(a)) - + # Handle attributes as either strings or objects raw_attributes = data.get("attributes", []) attributes = [] @@ -243,7 +243,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "ResourceRead": attributes.append(ResourceAttribute(key=a, type="string")) elif isinstance(a, dict): attributes.append(ResourceAttribute.from_dict(a)) - + return cls( id=data.get("id", ""), key=data.get("key", ""), diff --git a/permissio/models/role.py b/permissio/models/role.py index 9bd7944..1004844 100644 --- a/permissio/models/role.py +++ b/permissio/models/role.py @@ -3,10 +3,10 @@ """ from dataclasses import dataclass, field -from typing import Optional, Dict, Any, List from datetime import datetime +from typing import Any, Dict, List, Optional -from permissio.models.common import parse_datetime, format_datetime +from permissio.models.common import format_datetime, parse_datetime @dataclass diff --git a/permissio/models/role_assignment.py b/permissio/models/role_assignment.py index aae0164..df895b9 100644 --- a/permissio/models/role_assignment.py +++ b/permissio/models/role_assignment.py @@ -3,10 +3,10 @@ """ from dataclasses import dataclass, field -from typing import Optional, Dict, Any from datetime import datetime +from typing import Any, Dict, Optional -from permissio.models.common import parse_datetime, format_datetime +from permissio.models.common import format_datetime, parse_datetime @dataclass diff --git a/permissio/models/tenant.py b/permissio/models/tenant.py index 0710050..0eabb9f 100644 --- a/permissio/models/tenant.py +++ b/permissio/models/tenant.py @@ -3,10 +3,10 @@ """ from dataclasses import dataclass, field -from typing import Optional, Dict, Any from datetime import datetime +from typing import Any, Dict, Optional -from permissio.models.common import parse_datetime, format_datetime +from permissio.models.common import format_datetime, parse_datetime @dataclass diff --git a/permissio/models/user.py b/permissio/models/user.py index 0e69153..e646871 100644 --- a/permissio/models/user.py +++ b/permissio/models/user.py @@ -3,10 +3,10 @@ """ from dataclasses import dataclass, field -from typing import Optional, Dict, Any, List from datetime import datetime +from typing import Any, Dict, List, Optional -from permissio.models.common import parse_datetime, format_datetime +from permissio.models.common import format_datetime, parse_datetime @dataclass diff --git a/permissio/sync.py b/permissio/sync.py index a2de47b..ec8cbb5 100644 --- a/permissio/sync.py +++ b/permissio/sync.py @@ -21,43 +21,43 @@ # Re-export the main Permissio class for convenience # The Permissio class already provides both sync and async methods from permissio.client import Permissio, PermissioApi -from permissio.config import PermissioConfig, ConfigBuilder +from permissio.config import ConfigBuilder, PermissioConfig +from permissio.enforcement import ( + CheckContext, + CheckResource, + CheckUser, + ResourceBuilder, + UserBuilder, +) from permissio.errors import ( - PermissioError, PermissioApiError, - PermissioValidationError, - PermissioNetworkError, - PermissioTimeoutError, - PermissioRateLimitError, PermissioAuthenticationError, - PermissioPermissionError, - PermissioNotFoundError, PermissioConflictError, -) -from permissio.enforcement import ( - UserBuilder, - ResourceBuilder, - CheckUser, - CheckResource, - CheckContext, + PermissioError, + PermissioNetworkError, + PermissioNotFoundError, + PermissioPermissionError, + PermissioRateLimitError, + PermissioTimeoutError, + PermissioValidationError, ) from permissio.models import ( - User, - UserCreate, - UserUpdate, - Tenant, - TenantCreate, - TenantUpdate, - Role, - RoleCreate, - RoleUpdate, + CheckRequest, + CheckResponse, Resource, ResourceCreate, ResourceUpdate, + Role, RoleAssignment, RoleAssignmentCreate, - CheckRequest, - CheckResponse, + RoleCreate, + RoleUpdate, + Tenant, + TenantCreate, + TenantUpdate, + User, + UserCreate, + UserUpdate, ) __all__ = [ diff --git a/pyproject.toml b/pyproject.toml index 8aa5fd0..2a5c04c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,11 +89,19 @@ strict_optional = true warn_redundant_casts = true warn_unused_ignores = true show_error_codes = true -files = ["permisio"] +files = ["permissio"] + +# Disable override checks for API client methods that intentionally +# have different signatures in subclasses (e.g., get, delete methods) +[[tool.mypy.overrides]] +module = "permissio.api.*" +disable_error_code = ["override"] [tool.ruff] target-version = "py39" line-length = 120 + +[tool.ruff.lint] select = [ "E", # pycodestyle errors "W", # pycodestyle warnings @@ -106,10 +114,14 @@ select = [ ignore = [ "E501", # line too long (handled by black) "B008", # do not perform function calls in argument defaults + "UP006", # Use `list` instead of `List` for type annotation (keep for Python 3.9 compat) + "UP007", # Use `X | Y` for Union types (keep for Python 3.9 compat) + "UP035", # `typing.Dict` is deprecated (keep for Python 3.9 compat) + "C901", # Function is too complex (allow complex functions) ] -[tool.ruff.isort] -known-first-party = ["permisio"] +[tool.ruff.lint.isort] +known-first-party = ["permissio"] [tool.black] line-length = 120 diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 0f679f1..0be02f2 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -5,19 +5,20 @@ """ import pytest -from permissio import Permissio, PermissioConfig, ConfigBuilder + +from permissio import ConfigBuilder, Permissio, PermissioConfig +from permissio.enforcement import ContextBuilder, ResourceBuilder, UserBuilder from permissio.errors import ( - PermissioError, PermissioApiError, - PermissioValidationError, PermissioAuthenticationError, + PermissioError, + PermissioValidationError, ) -from permissio.enforcement import UserBuilder, ResourceBuilder, ContextBuilder class TestConfig: """Tests for configuration.""" - + def test_permis_config_defaults(self): config = PermissioConfig(token="test_token") assert config.token == "test_token" @@ -26,7 +27,7 @@ def test_permis_config_defaults(self): assert config.environment_id is None assert config.timeout == 30.0 assert config.debug is False - + def test_permis_config_custom_values(self): config = PermissioConfig( token="test_token", @@ -41,7 +42,7 @@ def test_permis_config_custom_values(self): assert config.environment_id == "production" assert config.timeout == 60.0 assert config.debug is True - + def test_config_builder(self): config = ( ConfigBuilder("test_token") @@ -54,7 +55,7 @@ def test_config_builder(self): .with_throw_on_error(False) .build() ) - + assert config.token == "test_token" assert config.api_url == "http://localhost:8080" assert config.project_id == "my-project" @@ -63,7 +64,7 @@ def test_config_builder(self): assert config.debug is True assert config.retry_attempts == 5 assert config.throw_on_error is False - + def test_config_builder_with_custom_headers(self): config = ( ConfigBuilder("test_token") @@ -71,18 +72,18 @@ def test_config_builder_with_custom_headers(self): .with_custom_header("X-Another", "another") .build() ) - + assert config.custom_headers == {"X-Custom": "value", "X-Another": "another"} class TestClient: """Tests for client initialization.""" - + def test_client_init_with_token(self): client = Permissio(token="test_token") assert client.config.token == "test_token" client.close() - + def test_client_init_with_config(self): config = PermissioConfig( token="test_token", @@ -91,7 +92,7 @@ def test_client_init_with_config(self): client = Permissio(config=config) assert client.config.project_id == "my-project" client.close() - + def test_client_init_with_kwargs(self): client = Permissio( token="test_token", @@ -101,11 +102,11 @@ def test_client_init_with_kwargs(self): assert client.config.project_id == "custom-project" assert client.config.environment_id == "production" client.close() - + def test_client_requires_token(self): with pytest.raises(ValueError): # Raises ValueError, not PermissioValidationError Permissio() - + def test_client_api_property(self): client = Permissio(token="test_token") assert client.api is not None @@ -119,12 +120,12 @@ def test_client_api_property(self): class TestEnforcementBuilders: """Tests for enforcement builders.""" - + def test_user_builder_simple(self): user = UserBuilder("user@example.com").build() assert user.key == "user@example.com" assert user.attributes == {} - + def test_user_builder_with_attributes(self): user = ( UserBuilder("user@example.com") @@ -133,19 +134,19 @@ def test_user_builder_with_attributes(self): .with_attributes({"location": "US", "team": "platform"}) .build() ) - + assert user.key == "user@example.com" assert user.attributes["department"] == "engineering" assert user.attributes["level"] == 5 assert user.attributes["location"] == "US" assert user.attributes["team"] == "platform" - + def test_resource_builder_simple(self): resource = ResourceBuilder("document").build() assert resource.type == "document" assert resource.key is None assert resource.tenant is None - + def test_resource_builder_with_all_options(self): resource = ( ResourceBuilder("document") @@ -155,13 +156,13 @@ def test_resource_builder_with_all_options(self): .with_attribute("owner", "user@example.com") .build() ) - + assert resource.type == "document" assert resource.key == "doc-123" assert resource.tenant == "acme-corp" assert resource.attributes["classification"] == "confidential" assert resource.attributes["owner"] == "user@example.com" - + def test_context_builder(self): context = ( ContextBuilder() @@ -170,7 +171,7 @@ def test_context_builder(self): .with_values({"request_id": "abc123"}) .build() ) - + # CheckContext uses 'data' attribute, not 'values' assert context.data["ip_address"] == "192.168.1.1" assert context.data["time_of_day"] == "business_hours" @@ -179,12 +180,12 @@ def test_context_builder(self): class TestErrors: """Tests for error handling.""" - + def test_permis_error_hierarchy(self): assert issubclass(PermissioApiError, PermissioError) assert issubclass(PermissioValidationError, PermissioError) assert issubclass(PermissioAuthenticationError, PermissioApiError) - + def test_permis_api_error(self): # PermissioApiError uses 'code' not 'error_code' error = PermissioApiError( @@ -192,28 +193,28 @@ def test_permis_api_error(self): status_code=404, code="RESOURCE_NOT_FOUND", ) - + assert error.message == "Not found" assert error.status_code == 404 assert error.code == "RESOURCE_NOT_FOUND" assert "Not found" in str(error) - + def test_permis_validation_error(self): error = PermissioValidationError( message="Invalid input", field="email", ) - + assert error.message == "Invalid input" assert error.field == "email" class TestModels: """Tests for data models.""" - + def test_user_create(self): from permissio.models import UserCreate - + user = UserCreate( key="user@example.com", email="user@example.com", @@ -221,74 +222,74 @@ def test_user_create(self): last_name="Doe", attributes={"department": "sales"}, ) - + assert user.key == "user@example.com" assert user.email == "user@example.com" assert user.first_name == "John" assert user.attributes["department"] == "sales" - + def test_tenant_create(self): from permissio.models import TenantCreate - + tenant = TenantCreate( key="acme-corp", name="Acme Corporation", description="A test tenant", ) - + assert tenant.key == "acme-corp" assert tenant.name == "Acme Corporation" - + def test_role_create(self): from permissio.models import RoleCreate - + role = RoleCreate( key="editor", name="Editor", permissions=["read", "write"], ) - + assert role.key == "editor" assert "read" in role.permissions class TestNormalizeFunctions: """Tests for input normalization.""" - + def test_normalize_user_string(self): from permissio.enforcement.models import normalize_user - + # normalize_user returns a dict, not CheckUser result = normalize_user("user@example.com") assert result["key"] == "user@example.com" - + def test_normalize_user_dict(self): from permissio.enforcement.models import normalize_user - + result = normalize_user({ "key": "user@example.com", "attributes": {"level": 5}, }) assert result["key"] == "user@example.com" assert result["attributes"]["level"] == 5 - + def test_normalize_user_object(self): - from permissio.enforcement.models import normalize_user, CheckUser - + from permissio.enforcement.models import CheckUser, normalize_user + user = CheckUser(key="user@example.com", attributes={}) result = normalize_user(user) # Returns a dict representation, not the same object assert result["key"] == "user@example.com" - + def test_normalize_resource_string(self): from permissio.enforcement.models import normalize_resource - + result = normalize_resource("document") assert result["type"] == "document" - + def test_normalize_resource_dict(self): from permissio.enforcement.models import normalize_resource - + result = normalize_resource({ "type": "document", "key": "doc-123",