From 65f9aac160bc1686367d31ef6def53be861f61f1 Mon Sep 17 00:00:00 2001 From: Victor Santos Date: Thu, 30 Oct 2025 18:58:13 -0300 Subject: [PATCH 1/3] feat(folders): add V2Folders resource with create, list, and get folder by ID methods --- infisical_sdk/api_types.py | 99 +++++++++++++++++++++++++++++ infisical_sdk/client.py | 2 + infisical_sdk/resources/__init__.py | 3 +- infisical_sdk/resources/folders.py | 82 ++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 infisical_sdk/resources/folders.py diff --git a/infisical_sdk/api_types.py b/infisical_sdk/api_types.py index 467673e..2b0fe29 100644 --- a/infisical_sdk/api_types.py +++ b/infisical_sdk/api_types.py @@ -194,3 +194,102 @@ class KmsKeyEncryptDataResponse(BaseModel): class KmsKeyDecryptDataResponse(BaseModel): """Response model for decrypt data API""" plaintext: str + +@dataclass +class CreateFolderResponseItem(BaseModel): + """Folder model with path for create response""" + id: str + name: str + createdAt: str + updatedAt: str + envId: str + path: str + version: Optional[int] = 1 + parentId: Optional[str] = None + isReserved: Optional[bool] = False + description: Optional[str] = None + lastSecretModified: Optional[str] = None + +@dataclass +class CreateFolderResponse(BaseModel): + """Response model for create folder API""" + folder: CreateFolderResponseItem + + @classmethod + def from_dict(cls, data: Dict) -> 'CreateFolderResponse': + return cls( + folder=CreateFolderResponseItem.from_dict(data['folder']), + ) + + +@dataclass +class ListFoldersResponseItem(BaseModel): + """Response model for list folders API""" + id: str + name: str + createdAt: str + updatedAt: str + envId: str + version: Optional[int] = 1 + parentId: Optional[str] = None + isReserved: Optional[bool] = False + description: Optional[str] = None + lastSecretModified: Optional[str] = None + relativePath: Optional[str] = None + + +@dataclass +class ListFoldersResponse(BaseModel): + """Complete response model for folders API""" + folders: List[ListFoldersResponseItem] + + @classmethod + def from_dict(cls, data: Dict) -> 'ListFoldersResponse': + """Create model from dictionary with camelCase keys, handling nested objects""" + return cls( + folders=[ListFoldersResponseItem.from_dict(folder) for folder in data['folders']] + ) + + +@dataclass +class Environment(BaseModel): + """Environment model""" + envId: str + envName: str + envSlug: str + +@dataclass +class SingleFolderResponseItem(BaseModel): + """Response model for get folder API""" + id: str + name: str + createdAt: str + updatedAt: str + envId: str + path: str + projectId: str + environment: Environment + version: Optional[int] = 1 + parentId: Optional[str] = None + isReserved: Optional[bool] = False + description: Optional[str] = None + lastSecretModified: Optional[str] = None + + @classmethod + def from_dict(cls, data: Dict) -> 'SingleFolderResponseItem': + """Create model from dictionary with nested Environment""" + folder_data = data.copy() + folder_data['environment'] = Environment.from_dict(data['environment']) + + return super().from_dict(folder_data) + +@dataclass +class SingleFolderResponse(BaseModel): + """Response model for get/create folder API""" + folder: SingleFolderResponseItem + + @classmethod + def from_dict(cls, data: Dict) -> 'SingleFolderResponse': + return cls( + folder=SingleFolderResponseItem.from_dict(data['folder']), + ) diff --git a/infisical_sdk/client.py b/infisical_sdk/client.py index 9913f12..1a91c2d 100644 --- a/infisical_sdk/client.py +++ b/infisical_sdk/client.py @@ -3,6 +3,7 @@ from infisical_sdk.resources import Auth from infisical_sdk.resources import V3RawSecrets from infisical_sdk.resources import KMS +from infisical_sdk.resources import V2Folders from infisical_sdk.util import SecretsCache @@ -24,6 +25,7 @@ def __init__(self, host: str, token: str = None, cache_ttl: int = 60): self.auth = Auth(self.api, self.set_token) self.secrets = V3RawSecrets(self.api, self.cache) self.kms = KMS(self.api) + self.folders = V2Folders(self.api) def set_token(self, token: str): """ diff --git a/infisical_sdk/resources/__init__.py b/infisical_sdk/resources/__init__.py index ee1bcb2..77a23f5 100644 --- a/infisical_sdk/resources/__init__.py +++ b/infisical_sdk/resources/__init__.py @@ -1,3 +1,4 @@ from .secrets import V3RawSecrets from .kms import KMS -from .auth import Auth \ No newline at end of file +from .auth import Auth +from .folders import V2Folders \ No newline at end of file diff --git a/infisical_sdk/resources/folders.py b/infisical_sdk/resources/folders.py new file mode 100644 index 0000000..c8b5a1e --- /dev/null +++ b/infisical_sdk/resources/folders.py @@ -0,0 +1,82 @@ +from typing import Optional +from datetime import datetime + +from infisical_sdk.infisical_requests import InfisicalRequests +from infisical_sdk.api_types import ListFoldersResponse, SingleFolderResponse, SingleFolderResponseItem, CreateFolderResponse, CreateFolderResponseItem + + +class V2Folders: + def __init__(self, requests: InfisicalRequests) -> None: + self.requests = requests + + def create_folder( + self, + name: str, + environment_slug: str, + project_id: str, + path: str = "/", + description: str = None) -> CreateFolderResponseItem: + + request_body = { + "projectId": project_id, + "environment": environment_slug, + "name": name, + "path": path, + "description": description, + } + + result = self.requests.post( + path="/api/v2/folders", + json=request_body, + model=CreateFolderResponse + ) + + return result.data.folder + + def list_folders( + self, + project_id: str, + environment_slug: str, + path: str, + lastSecretModified: Optional[datetime] = None, + recursive: bool = False) -> ListFoldersResponse: + + params = { + "projectId": project_id, + "environment": environment_slug, + "path": path, + "recursive": recursive, + } + + if lastSecretModified is not None: + # Format as RFC 3339 (ISO 8601 profile) - uses 'Z' for UTC + # Workaround for the zod datetime() validation in the API + iso_string = lastSecretModified.isoformat(timespec='seconds') + if iso_string.endswith('+00:00'): + iso_string = iso_string[:-6] + 'Z' + params["lastSecretModified"] = iso_string + + result = self.requests.get( + path="/api/v2/folders", + params=params, + model=ListFoldersResponse + ) + + return result.data + + def get_folder_by_id( + self, + id: str) -> SingleFolderResponseItem: + + params = { + "id": id, + } + + result = self.requests.get( + path=f"/api/v2/folders/{id}", + params=params, + model=SingleFolderResponse + ) + + return result.data.folder + From a2b8a566cef2007ee2c5b9f2a950590ba58974a6 Mon Sep 17 00:00:00 2001 From: Victor Santos Date: Thu, 30 Oct 2025 19:07:53 -0300 Subject: [PATCH 2/3] fix(folders): update lastSecretModified handling to ensure UTC formatting --- infisical_sdk/resources/folders.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/infisical_sdk/resources/folders.py b/infisical_sdk/resources/folders.py index c8b5a1e..f4ead7d 100644 --- a/infisical_sdk/resources/folders.py +++ b/infisical_sdk/resources/folders.py @@ -1,5 +1,5 @@ from typing import Optional -from datetime import datetime +from datetime import datetime, timezone from infisical_sdk.infisical_requests import InfisicalRequests from infisical_sdk.api_types import ListFoldersResponse, SingleFolderResponse, SingleFolderResponseItem, CreateFolderResponse, CreateFolderResponseItem @@ -15,7 +15,7 @@ def create_folder( environment_slug: str, project_id: str, path: str = "/", - description: str = None) -> CreateFolderResponseItem: + description: Optional[str] = None) -> CreateFolderResponseItem: request_body = { "projectId": project_id, @@ -49,12 +49,10 @@ def list_folders( } if lastSecretModified is not None: - # Format as RFC 3339 (ISO 8601 profile) - uses 'Z' for UTC - # Workaround for the zod datetime() validation in the API - iso_string = lastSecretModified.isoformat(timespec='seconds') - if iso_string.endswith('+00:00'): - iso_string = iso_string[:-6] + 'Z' - params["lastSecretModified"] = iso_string + # Convert to UTC and format as RFC 3339 with 'Z' suffix + # The API expects UTC times in 'Z' format (e.g., 2023-11-07T05:31:56Z) + utc_datetime = lastSecretModified.astimezone(timezone.utc) if lastSecretModified.tzinfo else lastSecretModified.replace(tzinfo=timezone.utc) + params["lastSecretModified"] = utc_datetime.strftime('%Y-%m-%dT%H:%M:%SZ') result = self.requests.get( path="/api/v2/folders", From be36d47a0dc9b67e0c13d531e3f78e64e7bc9150 Mon Sep 17 00:00:00 2001 From: Victor Santos Date: Thu, 30 Oct 2025 19:17:58 -0300 Subject: [PATCH 3/3] refactor(folders): rename lastSecretModified to last_secret_modified for consistency --- infisical_sdk/resources/folders.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/infisical_sdk/resources/folders.py b/infisical_sdk/resources/folders.py index f4ead7d..519bf8b 100644 --- a/infisical_sdk/resources/folders.py +++ b/infisical_sdk/resources/folders.py @@ -38,7 +38,7 @@ def list_folders( project_id: str, environment_slug: str, path: str, - lastSecretModified: Optional[datetime] = None, + last_secret_modified: Optional[datetime] = None, recursive: bool = False) -> ListFoldersResponse: params = { @@ -48,10 +48,10 @@ def list_folders( "recursive": recursive, } - if lastSecretModified is not None: + if last_secret_modified is not None: # Convert to UTC and format as RFC 3339 with 'Z' suffix # The API expects UTC times in 'Z' format (e.g., 2023-11-07T05:31:56Z) - utc_datetime = lastSecretModified.astimezone(timezone.utc) if lastSecretModified.tzinfo else lastSecretModified.replace(tzinfo=timezone.utc) + utc_datetime = last_secret_modified.astimezone(timezone.utc) if last_secret_modified.tzinfo else last_secret_modified.replace(tzinfo=timezone.utc) params["lastSecretModified"] = utc_datetime.strftime('%Y-%m-%dT%H:%M:%SZ') result = self.requests.get( @@ -66,13 +66,8 @@ def get_folder_by_id( self, id: str) -> SingleFolderResponseItem: - params = { - "id": id, - } - result = self.requests.get( path=f"/api/v2/folders/{id}", - params=params, model=SingleFolderResponse )