Skip to main content
Glama
penpot_api.py38.1 kB
import argparse import json import os from typing import Any, Dict, List, Optional, Union import requests from dotenv import load_dotenv class CloudFlareError(Exception): """Exception raised when CloudFlare protection blocks the request.""" def __init__(self, message: str, status_code: int = None, response_text: str = None): super().__init__(message) self.status_code = status_code self.response_text = response_text def __str__(self): return f"CloudFlare Protection Error: {super().__str__()}" class PenpotAPIError(Exception): """General exception for Penpot API errors.""" def __init__(self, message: str, status_code: int = None, response_text: str = None, is_cloudflare: bool = False): super().__init__(message) self.status_code = status_code self.response_text = response_text self.is_cloudflare = is_cloudflare class PenpotAPI: def __init__( self, base_url: str = None, debug: bool = False, email: Optional[str] = None, password: Optional[str] = None): # Load environment variables if not already loaded load_dotenv() # Use base_url from parameters if provided, otherwise from environment, # fallback to default URL self.base_url = base_url or os.getenv("PENPOT_API_URL", "https://design.penpot.app/api") self.session = requests.Session() self.access_token = None self.debug = debug self.email = email or os.getenv("PENPOT_USERNAME") self.password = password or os.getenv("PENPOT_PASSWORD") self.profile_id = None # Set default headers - we'll use different headers at request time # based on the required content type (JSON vs Transit+JSON) self.session.headers.update({ "Accept": "application/json, application/transit+json", "Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" }) def _is_cloudflare_error(self, response: requests.Response) -> bool: """Check if the response indicates a CloudFlare error.""" # Check for CloudFlare-specific indicators cloudflare_indicators = [ 'cloudflare', 'cf-ray', 'attention required', 'checking your browser', 'challenge', 'ddos protection', 'security check', 'cf-browser-verification', 'cf-challenge-running', 'please wait while we are checking your browser', 'enable cookies and reload the page', 'this process is automatic' ] # Check response headers for CloudFlare server_header = response.headers.get('server', '').lower() cf_ray = response.headers.get('cf-ray') if 'cloudflare' in server_header or cf_ray: return True # Check response content for CloudFlare indicators try: response_text = response.text.lower() for indicator in cloudflare_indicators: if indicator in response_text: return True except: # If we can't read the response text, don't assume it's CloudFlare pass # Check for specific status codes that might indicate CloudFlare blocks if response.status_code in [403, 429, 503]: # Additional check for CloudFlare-specific error pages try: response_text = response.text.lower() if any(['cloudflare' in response_text, 'cf-ray' in response_text, 'attention required' in response_text]): return True except: pass return False def _create_cloudflare_error_message(self, response: requests.Response) -> str: """Create a user-friendly CloudFlare error message.""" base_message = ( "CloudFlare protection has blocked this request. This is common on penpot.app. " "To resolve this issue:\\n\\n" "1. Open your web browser and navigate to https://design.penpot.app\\n" "2. Log in to your Penpot account\\n" "3. Complete any CloudFlare human verification challenges if prompted\\n" "4. Once verified, try your request again\\n\\n" "The verification typically lasts for a period of time, after which you may need to repeat the process." ) if response.status_code: return f"{base_message}\\n\\nHTTP Status: {response.status_code}" return base_message def set_access_token(self, token: str): """Set the auth token for authentication.""" self.access_token = token # For cookie-based auth, set the auth-token cookie self.session.cookies.set("auth-token", token) # Also set Authorization header for APIs that use it self.session.headers.update({ "Authorization": f"Token {token}" }) def login_with_password( self, email: Optional[str] = None, password: Optional[str] = None) -> str: """ Login with email and password to get an auth token. This method uses the same cookie-based auth approach as the export methods. Args: email: Email for Penpot account (if None, will use stored email or PENPOT_USERNAME env var) password: Password for Penpot account (if None, will use stored password or PENPOT_PASSWORD env var) Returns: Auth token for API calls """ # Use the export authentication which also extracts profile ID token = self.login_for_export(email, password) self.set_access_token(token) # Profile ID is now extracted during login_for_export, no need to call get_profile if self.debug and self.profile_id: print(f"\nProfile ID available: {self.profile_id}") return token def get_profile(self) -> Dict[str, Any]: """ Get profile information for the current authenticated user. Returns: Dictionary containing profile information, including the profile ID """ url = f"{self.base_url}/rpc/command/get-profile" payload = {} # No parameters needed response = self._make_authenticated_request('post', url, json=payload, use_transit=False) # Parse and normalize the response data = response.json() normalized_data = self._normalize_transit_response(data) if self.debug: print("\nProfile data retrieved:") print(json.dumps(normalized_data, indent=2)[:200] + "...") # Store profile ID for later use if 'id' in normalized_data: self.profile_id = normalized_data['id'] if self.debug: print(f"\nStored profile ID: {self.profile_id}") return normalized_data def login_for_export(self, email: Optional[str] = None, password: Optional[str] = None) -> str: """ Login with email and password to get an auth token for export operations. This is required for export operations which use a different authentication mechanism than the standard API access token. Args: email: Email for Penpot account (if None, will use stored email or PENPOT_USERNAME env var) password: Password for Penpot account (if None, will use stored password or PENPOT_PASSWORD env var) Returns: Auth token extracted from cookies """ # Use parameters if provided, else use instance variables, else check environment variables email = email or self.email or os.getenv("PENPOT_USERNAME") password = password or self.password or os.getenv("PENPOT_PASSWORD") if not email or not password: raise ValueError( "Email and password are required for export authentication. " "Please provide them as parameters or set PENPOT_USERNAME and " "PENPOT_PASSWORD environment variables." ) url = f"{self.base_url}/rpc/command/login-with-password" # Use Transit+JSON format payload = { "~:email": email, "~:password": password } if self.debug: print("\nLogin request payload (Transit+JSON format):") print(json.dumps(payload, indent=2).replace(password, "********")) # Create a new session just for this request login_session = requests.Session() # Set headers headers = { "Content-Type": "application/transit+json", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } response = login_session.post(url, json=payload, headers=headers) if self.debug and response.status_code != 200: print(f"\nError response: {response.status_code}") print(f"Response text: {response.text}") response.raise_for_status() # Extract profile ID from response try: # The response is in Transit+JSON array format data = response.json() if isinstance(data, list): # Convert Transit array to dict transit_dict = {} i = 1 # Skip the "^ " marker while i < len(data) - 1: key = data[i] value = data[i + 1] transit_dict[key] = value i += 2 # Extract profile ID if "~:id" in transit_dict: profile_id = transit_dict["~:id"] # Remove the ~u prefix for UUID if isinstance(profile_id, str) and profile_id.startswith("~u"): profile_id = profile_id[2:] self.profile_id = profile_id if self.debug: print(f"\nExtracted profile ID from login response: {profile_id}") except Exception as e: if self.debug: print(f"\nCouldn't extract profile ID from response: {e}") # Also try to extract profile ID from auth-data cookie if not self.profile_id: for cookie in login_session.cookies: if cookie.name == "auth-data": # Cookie value is like: "profile-id=7ae66c33-6ede-81e2-8006-6a1b4dce3d2b" if "profile-id=" in cookie.value: profile_id = cookie.value.split("profile-id=")[1].split(";")[0].strip('"') self.profile_id = profile_id if self.debug: print(f"\nExtracted profile ID from auth-data cookie: {profile_id}") break # Extract auth token from cookies if 'Set-Cookie' in response.headers: if self.debug: print("\nSet-Cookie header found") for cookie in login_session.cookies: if cookie.name == "auth-token": if self.debug: print(f"\nAuth token extracted from cookies: {cookie.value[:10]}...") return cookie.value raise ValueError("Auth token not found in response cookies") else: # Try to extract from response JSON if available try: data = response.json() if 'auth-token' in data: return data['auth-token'] except Exception: pass # If we reached here, we couldn't find the token raise ValueError("Auth token not found in response cookies or JSON body") def _make_authenticated_request(self, method: str, url: str, retry_auth: bool = True, **kwargs) -> requests.Response: """ Make an authenticated request, handling re-auth if needed. This internal method handles lazy authentication when a request fails due to authentication issues, using the same cookie-based approach as the export methods. Args: method: HTTP method (post, get, etc.) url: URL to make the request to **kwargs: Additional arguments to pass to requests Returns: The response object """ # If we don't have a token yet but have credentials, login first if not self.access_token and self.email and self.password: if self.debug: print("\nNo access token set, logging in with credentials...") self.login_with_password() # Set up headers headers = kwargs.get('headers', {}) if 'headers' in kwargs: del kwargs['headers'] # Use Transit+JSON format for API calls (required by Penpot) use_transit = kwargs.pop('use_transit', True) if use_transit: headers['Content-Type'] = 'application/transit+json' headers['Accept'] = 'application/transit+json' # Convert payload to Transit+JSON format if present if 'json' in kwargs and kwargs['json']: payload = kwargs['json'] # Only transform if not already in Transit format if not any(isinstance(k, str) and k.startswith('~:') for k in payload.keys()): transit_payload = {} # Add cmd if not present if 'cmd' not in payload and '~:cmd' not in payload: # Extract command from URL cmd = url.split('/')[-1] transit_payload['~:cmd'] = f"~:{cmd}" # Convert standard JSON to Transit+JSON format for key, value in payload.items(): # Skip command if already added if key == 'cmd': continue transit_key = f"~:{key}" if not key.startswith('~:') else key # Handle special UUID conversion for IDs if isinstance(value, str) and ('-' in value) and len(value) > 30: transit_value = f"~u{value}" else: transit_value = value transit_payload[transit_key] = transit_value if self.debug: print("\nConverted payload to Transit+JSON format:") print(f"Original: {payload}") print(f"Transit: {transit_payload}") kwargs['json'] = transit_payload else: headers['Content-Type'] = 'application/json' headers['Accept'] = 'application/json' # Ensure the Authorization header is set if we have a token if self.access_token: headers['Authorization'] = f"Token {self.access_token}" # Combine with session headers combined_headers = {**self.session.headers, **headers} # Make the request try: response = getattr(self.session, method)(url, headers=combined_headers, **kwargs) if self.debug: print(f"\nRequest to: {url}") print(f"Method: {method}") print(f"Headers: {combined_headers}") if 'json' in kwargs: print(f"Payload: {json.dumps(kwargs['json'], indent=2)}") print(f"Response status: {response.status_code}") response.raise_for_status() return response except requests.HTTPError as e: # Check for CloudFlare errors first if self._is_cloudflare_error(e.response): cloudflare_message = self._create_cloudflare_error_message(e.response) raise CloudFlareError(cloudflare_message, e.response.status_code, e.response.text) # Handle authentication errors if e.response.status_code in (401, 403) and self.email and self.password and retry_auth: # Special case: don't retry auth for get-profile to avoid infinite loops if url.endswith('/get-profile'): raise if self.debug: print("\nAuthentication failed. Trying to re-login...") # Re-login and update token self.login_with_password() # Update headers with new token headers['Authorization'] = f"Token {self.access_token}" combined_headers = {**self.session.headers, **headers} # Retry the request with the new token (but don't retry auth again) response = getattr(self.session, method)(url, headers=combined_headers, **kwargs) response.raise_for_status() return response else: # Re-raise other errors raise except requests.RequestException as e: # Handle other request exceptions (connection errors, timeouts, etc.) # Check if we have a response to analyze if hasattr(e, 'response') and e.response is not None: if self._is_cloudflare_error(e.response): cloudflare_message = self._create_cloudflare_error_message(e.response) raise CloudFlareError(cloudflare_message, e.response.status_code, e.response.text) # Re-raise if not a CloudFlare error raise def _normalize_transit_response(self, data: Union[Dict, List, Any]) -> Union[Dict, List, Any]: """ Normalize a Transit+JSON response to a more usable format. This recursively processes the response data, handling special Transit types like UUIDs, keywords, and nested structures. Args: data: The data to normalize, can be a dict, list, or other value Returns: Normalized data """ if isinstance(data, dict): # Normalize dictionary result = {} for key, value in data.items(): # Convert transit keywords in keys (~:key -> key) norm_key = key.replace( '~:', '') if isinstance( key, str) and key.startswith('~:') else key # Recursively normalize values result[norm_key] = self._normalize_transit_response(value) return result elif isinstance(data, list): # Normalize list items return [self._normalize_transit_response(item) for item in data] elif isinstance(data, str) and data.startswith('~u'): # Convert Transit UUIDs (~u123-456 -> 123-456) return data[2:] else: # Return other types as-is return data def list_projects(self) -> Dict[str, Any]: """ List all available projects for the authenticated user. Returns: Dictionary containing project information """ url = f"{self.base_url}/rpc/command/get-all-projects" payload = {} # No parameters required response = self._make_authenticated_request('post', url, json=payload, use_transit=False) if self.debug: content_type = response.headers.get('Content-Type', '') print(f"\nResponse content type: {content_type}") print(f"Response preview: {response.text[:100]}...") # Parse JSON data = response.json() if self.debug: print("\nData preview:") print(json.dumps(data, indent=2)[:200] + "...") return data def get_project(self, project_id: str) -> Optional[Dict[str, Any]]: """ Get details for a specific project. Args: project_id: The ID of the project to retrieve Returns: Dictionary containing project information """ # First get all projects projects = self.list_projects() # Find the specific project by ID for project in projects: if project.get('id') == project_id: return project return None def get_project_files(self, project_id: str) -> List[Dict[str, Any]]: """ Get all files for a specific project. Args: project_id: The ID of the project Returns: List of file information dictionaries """ url = f"{self.base_url}/rpc/command/get-project-files" payload = { "project-id": project_id } response = self._make_authenticated_request('post', url, json=payload, use_transit=False) # Parse JSON files = response.json() return files def get_file(self, file_id: str, save_data: bool = False, save_raw_response: bool = False) -> Dict[str, Any]: """ Get details for a specific file. Args: file_id: The ID of the file to retrieve features: List of features to include in the response project_id: Optional project ID if known save_data: Whether to save the data to a file save_raw_response: Whether to save the raw response Returns: Dictionary containing file information """ url = f"{self.base_url}/rpc/command/get-file" payload = { "id": file_id, } response = self._make_authenticated_request('post', url, json=payload, use_transit=False) # Save raw response if requested if save_raw_response: raw_filename = f"{file_id}_raw_response.json" with open(raw_filename, 'w') as f: f.write(response.text) if self.debug: print(f"\nSaved raw response to {raw_filename}") # Parse JSON data = response.json() # Save normalized data if requested if save_data: filename = f"{file_id}.json" with open(filename, 'w') as f: json.dump(data, f, indent=2) if self.debug: print(f"\nSaved file data to {filename}") return data def create_export(self, file_id: str, page_id: str, object_id: str, export_type: str = "png", scale: int = 1, email: Optional[str] = None, password: Optional[str] = None, profile_id: Optional[str] = None): """ Create an export job for a Penpot object. Args: file_id: The file ID page_id: The page ID object_id: The object ID to export export_type: Type of export (png, svg, pdf) scale: Scale factor for the export name: Name for the export suffix: Suffix to add to the export name email: Email for authentication (if different from instance) password: Password for authentication (if different from instance) profile_id: Optional profile ID (if not provided, will be fetched automatically) Returns: Export resource ID """ # This uses the cookie auth approach, which requires login token = self.login_for_export(email, password) # If profile_id is not provided, get it from instance variable if not profile_id: profile_id = self.profile_id if not profile_id: raise ValueError("Profile ID not available. It should be automatically extracted during login.") # Build the URL for export creation url = f"{self.base_url}/export" # Set up the data for the export payload = { "~:wait": True, "~:exports": [ {"~:type": f"~:{export_type}", "~:suffix": "", "~:scale": scale, "~:page-id": f"~u{page_id}", "~:file-id": f"~u{file_id}", "~:name": "", "~:object-id": f"~u{object_id}"} ], "~:profile-id": f"~u{profile_id}", "~:cmd": "~:export-shapes" } if self.debug: print("\nCreating export with parameters:") print(json.dumps(payload, indent=2)) # Create a session with the auth token export_session = requests.Session() export_session.cookies.set("auth-token", token) headers = { "Content-Type": "application/transit+json", "Accept": "application/transit+json", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } # Make the request response = export_session.post(url, json=payload, headers=headers) if self.debug and response.status_code != 200: print(f"\nError response: {response.status_code}") print(f"Response text: {response.text}") response.raise_for_status() # Parse the response data = response.json() if self.debug: print("\nExport created successfully") print(f"Response: {json.dumps(data, indent=2)}") # Extract and return the resource ID resource_id = data.get("~:id") if not resource_id: raise ValueError("Resource ID not found in response") return resource_id def get_export_resource(self, resource_id: str, save_to_file: Optional[str] = None, email: Optional[str] = None, password: Optional[str] = None) -> Union[bytes, str]: """ Download an export resource by ID. Args: resource_id: The resource ID from create_export save_to_file: Path to save the file (if None, returns the content) email: Email for authentication (if different from instance) password: Password for authentication (if different from instance) Returns: Either the file content as bytes, or the path to the saved file """ # This uses the cookie auth approach, which requires login token = self.login_for_export(email, password) # Build the URL for the resource url = f"{self.base_url}/export" payload = { "~:wait": False, "~:cmd": "~:get-resource", "~:id": resource_id } headers = { "Content-Type": "application/transit+json", "Accept": "*/*", "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" } if self.debug: print(f"\nFetching export resource: {url}") # Create a session with the auth token export_session = requests.Session() export_session.cookies.set("auth-token", token) # Make the request response = export_session.post(url, json=payload, headers=headers) if self.debug and response.status_code != 200: print(f"\nError response: {response.status_code}") print(f"Response headers: {response.headers}") response.raise_for_status() # Get the content type content_type = response.headers.get('Content-Type', '') if self.debug: print(f"\nResource fetched successfully") print(f"Content-Type: {content_type}") print(f"Content length: {len(response.content)} bytes") # Determine filename if saving to file if save_to_file: if os.path.isdir(save_to_file): # If save_to_file is a directory, we need to figure out the filename filename = None # Try to get filename from Content-Disposition header content_disp = response.headers.get('Content-Disposition', '') if 'filename=' in content_disp: filename = content_disp.split('filename=')[1].strip('"\'') # If we couldn't get a filename, use the resource_id with an extension if not filename: ext = content_type.split('/')[-1].split(';')[0] if ext in ('jpeg', 'png', 'pdf', 'svg+xml'): if ext == 'svg+xml': ext = 'svg' filename = f"{resource_id}.{ext}" else: filename = f"{resource_id}" save_path = os.path.join(save_to_file, filename) else: # Use the provided path directly save_path = save_to_file # Ensure the directory exists os.makedirs(os.path.dirname(os.path.abspath(save_path)), exist_ok=True) # Save the content to file with open(save_path, 'wb') as f: f.write(response.content) if self.debug: print(f"\nSaved resource to {save_path}") return save_path else: # Return the content return response.content def export_and_download(self, file_id: str, page_id: str, object_id: str, save_to_file: Optional[str] = None, export_type: str = "png", scale: int = 1, name: str = "Board", suffix: str = "", email: Optional[str] = None, password: Optional[str] = None, profile_id: Optional[str] = None) -> Union[bytes, str]: """ Create and download an export in one step. This is a convenience method that combines create_export and get_export_resource. Args: file_id: The file ID page_id: The page ID object_id: The object ID to export save_to_file: Path to save the file (if None, returns the content) export_type: Type of export (png, svg, pdf) scale: Scale factor for the export name: Name for the export suffix: Suffix to add to the export name email: Email for authentication (if different from instance) password: Password for authentication (if different from instance) profile_id: Optional profile ID (if not provided, will be fetched automatically) Returns: Either the file content as bytes, or the path to the saved file """ # Create the export resource_id = self.create_export( file_id=file_id, page_id=page_id, object_id=object_id, export_type=export_type, scale=scale, email=email, password=password, profile_id=profile_id ) # Download the resource return self.get_export_resource( resource_id=resource_id, save_to_file=save_to_file, email=email, password=password ) def extract_components(self, file_data: Dict[str, Any]) -> Dict[str, Any]: """ Extract components from file data. This processes a file's data to extract and normalize component information. Args: file_data: The file data from get_file Returns: Dictionary containing components information """ components = {} components_index = file_data.get('data', {}).get('componentsIndex', {}) for component_id, component_data in components_index.items(): # Extract basic component info component = { 'id': component_id, 'name': component_data.get('name', 'Unnamed'), 'path': component_data.get('path', []), 'shape': component_data.get('shape', ''), 'fileId': component_data.get('fileId', file_data.get('id')), 'created': component_data.get('created'), 'modified': component_data.get('modified') } # Add the component to our collection components[component_id] = component return {'components': components} def analyze_file_structure(self, file_data: Dict[str, Any]) -> Dict[str, Any]: """ Analyze file structure and return summary information. Args: file_data: The file data from get_file Returns: Dictionary containing analysis information """ data = file_data.get('data', {}) # Count pages pages = data.get('pagesIndex', {}) page_count = len(pages) # Count objects by type object_types = {} total_objects = 0 for page_id, page_data in pages.items(): objects = page_data.get('objects', {}) total_objects += len(objects) for obj_id, obj_data in objects.items(): obj_type = obj_data.get('type', 'unknown') object_types[obj_type] = object_types.get(obj_type, 0) + 1 # Count components components = data.get('componentsIndex', {}) component_count = len(components) # Count colors, typographies, etc. colors = data.get('colorsIndex', {}) color_count = len(colors) typographies = data.get('typographiesIndex', {}) typography_count = len(typographies) return { 'pageCount': page_count, 'objectCount': total_objects, 'objectTypes': object_types, 'componentCount': component_count, 'colorCount': color_count, 'typographyCount': typography_count, 'fileName': file_data.get('name', 'Unknown'), 'fileId': file_data.get('id') } def main(): # Set up argument parser parser = argparse.ArgumentParser(description='Penpot API Tool') parser.add_argument('--debug', action='store_true', help='Enable debug output') # Create subparsers for different commands subparsers = parser.add_subparsers(dest='command', help='Command to run') # List projects command list_parser = subparsers.add_parser('list-projects', help='List all projects') # Get project command project_parser = subparsers.add_parser('get-project', help='Get project details') project_parser.add_argument('--id', required=True, help='Project ID') # List files command files_parser = subparsers.add_parser('list-files', help='List files in a project') files_parser.add_argument('--project-id', required=True, help='Project ID') # Get file command file_parser = subparsers.add_parser('get-file', help='Get file details') file_parser.add_argument('--file-id', required=True, help='File ID') file_parser.add_argument('--save', action='store_true', help='Save file data to JSON') # Export command export_parser = subparsers.add_parser('export', help='Export an object') export_parser.add_argument( '--profile-id', required=False, help='Profile ID (optional, will be fetched automatically if not provided)') export_parser.add_argument('--file-id', required=True, help='File ID') export_parser.add_argument('--page-id', required=True, help='Page ID') export_parser.add_argument('--object-id', required=True, help='Object ID') export_parser.add_argument( '--type', default='png', choices=[ 'png', 'svg', 'pdf'], help='Export type') export_parser.add_argument('--scale', type=int, default=1, help='Scale factor') export_parser.add_argument('--output', required=True, help='Output file path') # Parse arguments args = parser.parse_args() # Create API client api = PenpotAPI(debug=args.debug) # Handle different commands if args.command == 'list-projects': projects = api.list_projects() print(f"Found {len(projects)} projects:") for project in projects: print(f"- {project.get('name')} - {project.get('teamName')} (ID: {project.get('id')})") elif args.command == 'get-project': project = api.get_project(args.id) if project: print(f"Project: {project.get('name')}") print(json.dumps(project, indent=2)) else: print(f"Project not found: {args.id}") elif args.command == 'list-files': files = api.get_project_files(args.project_id) print(f"Found {len(files)} files:") for file in files: print(f"- {file.get('name')} (ID: {file.get('id')})") elif args.command == 'get-file': file_data = api.get_file(args.file_id, save_data=args.save) print(f"File: {file_data.get('name')}") if args.save: print(f"Data saved to {args.file_id}.json") else: print("File metadata:") print(json.dumps({k: v for k, v in file_data.items() if k != 'data'}, indent=2)) elif args.command == 'export': output_path = api.export_and_download( file_id=args.file_id, page_id=args.page_id, object_id=args.object_id, export_type=args.type, scale=args.scale, save_to_file=args.output, profile_id=args.profile_id ) print(f"Exported to: {output_path}") else: parser.print_help() if __name__ == '__main__': main()

Implementation Reference

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/montevive/penpot-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server