fileroute 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
fileroute/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ from .clients import get_provider
2
+ from .models import Catalog, Resource, Location, CatalogReference, ServiceType
3
+ from .exceptions import AmbiguousPathError
4
+
5
+ __all__ = [
6
+ "ServiceType",
7
+ "get_provider",
8
+ "Catalog",
9
+ "Resource",
10
+ "Location",
11
+ "CatalogReference",
12
+ "AmbiguousPathError",
13
+ ]
@@ -0,0 +1,17 @@
1
+ from fileroute.auth.google import (
2
+ GoogleAuth,
3
+ )
4
+ from fileroute.auth.microsoft import (
5
+ MicrosoftAuth
6
+ )
7
+ from fileroute.auth.settings import (
8
+ GoogleAuthConfig,
9
+ MicrosoftAuthConfig,
10
+ )
11
+
12
+ __all__ = [
13
+ "GoogleAuth",
14
+ "GoogleAuthConfig",
15
+ "MicrosoftAuth",
16
+ "MicrosoftAuthConfig"
17
+ ]
@@ -0,0 +1,178 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Sequence
5
+
6
+ import google.auth
7
+ from google.auth.credentials import Credentials
8
+ from google.oauth2.credentials import Credentials as UserCredentials
9
+ from google.auth.transport.requests import Request
10
+ from google.oauth2 import service_account
11
+ from google_auth_oauthlib.flow import InstalledAppFlow
12
+ from fileroute.exceptions import GoogleAuthError,GoogleRefreshError
13
+ from warnings import warn
14
+
15
+ DEFAULT_DRIVE_SCOPES = ("https://www.googleapis.com/auth/drive",)
16
+ DEFAULT_DRIVE_READONLY_SCOPES = (
17
+ "https://www.googleapis.com/auth/drive.readonly",
18
+ )
19
+
20
+
21
+ def normalize_google_scopes(
22
+ scopes: Sequence[str] | str | None,
23
+ *,
24
+ default: Sequence[str] = DEFAULT_DRIVE_SCOPES,
25
+ ) -> list[str]:
26
+ if scopes is None:
27
+ return list(default)
28
+ if isinstance(scopes, str):
29
+ return [scopes]
30
+ normalized = [scope for scope in scopes if scope]
31
+ return normalized or list(default)
32
+
33
+
34
+ class GoogleAuth:
35
+ """Google credential holder with named constructors for each auth mode.
36
+
37
+ The primary entry points are the ``from_*`` class methods. The
38
+ ``__init__`` constructor accepts a raw :class:`~google.auth.credentials.Credentials`
39
+ object and acts as a low-level escape hatch (e.g. for tests).
40
+
41
+ Usage::
42
+
43
+ # Application Default Credentials (default for most deployments)
44
+ auth = GoogleAuth.from_adc()
45
+
46
+ # Service account JSON key file
47
+ auth = GoogleAuth.from_service_account("path/to/key.json")
48
+
49
+ # Interactive OAuth (installed-app flow)
50
+ auth = GoogleAuth.from_user_oauth("path/to/client_secrets.json")
51
+
52
+ # Read mode + scopes from environment variables / .env file
53
+ auth = GoogleAuth.from_settings()
54
+ """
55
+
56
+ def __init__(self, credentials: Credentials) -> None:
57
+ """Low-level escape hatch: supply raw credentials directly."""
58
+ self._creds = credentials
59
+
60
+ def refresh(self) -> None:
61
+ """Refresh the access token"""
62
+ self._creds.refresh(Request())
63
+
64
+ @classmethod
65
+ def from_adc(cls, scopes: Sequence[str] | str | None = None) -> "GoogleAuth":
66
+ """Build from Application Default Credentials (``gcloud auth application-default login``)."""
67
+ normalized_scopes = normalize_google_scopes(scopes)
68
+ try:
69
+ creds, _project = google.auth.default(scopes=normalized_scopes)
70
+ if getattr(creds, "requires_scopes", False) and hasattr(creds, "with_scopes"):
71
+ creds = creds.with_scopes(normalized_scopes)
72
+ return cls(creds)
73
+ except Exception as exc:
74
+ raise GoogleAuthError(
75
+ "Failed to load Application Default Credentials: "
76
+ f"{exc}. To use fileroute user OAuth instead, set "
77
+ "GOOGLE_AUTH_MODE=user_oauth with GOOGLE_OAUTH_CREDENTIALS "
78
+ "and run 'fileroute auth login gdrive'."
79
+ ) from exc
80
+
81
+ @classmethod
82
+ def from_service_account(
83
+ cls,
84
+ credentials_path: str | Path,
85
+ scopes: Sequence[str] | str | None = None,
86
+ ) -> "GoogleAuth":
87
+ """Build from a service account JSON key file."""
88
+ normalized_scopes = normalize_google_scopes(scopes)
89
+ try:
90
+ creds = service_account.Credentials.from_service_account_file(
91
+ str(credentials_path),
92
+ scopes=normalized_scopes,
93
+ )
94
+ return cls(creds)
95
+ except Exception as exc:
96
+ raise GoogleAuthError(
97
+ f"Failed to load service account credentials from {credentials_path}: {exc}"
98
+ ) from exc
99
+
100
+
101
+ @classmethod
102
+ def from_user_oauth(
103
+ cls,
104
+ scopes: Sequence[str] | str,
105
+ client_secrets_path: str | Path = None,
106
+ token_path: str | Path = None,
107
+ token_store: Any = None,
108
+ ) -> "GoogleAuth":
109
+ """Build via the OAuth installed-app flow, with token persistence."""
110
+
111
+ def _interactive_login_from_client_secrets_file() -> UserCredentials:
112
+ flow = InstalledAppFlow.from_client_secrets_file(
113
+ str(client_secrets_path),
114
+ scopes=normalized_scopes,
115
+ )
116
+ creds = flow.run_local_server(port=0)
117
+ return creds
118
+
119
+ normalized_scopes = normalize_google_scopes(scopes)
120
+ if token_store is not None:
121
+ creds = token_store.load()
122
+ if creds is not None and getattr(creds, "valid", False):
123
+ return cls(creds)
124
+ if creds is not None and getattr(creds, "refresh_token", None):
125
+ creds.refresh(Request())
126
+ elif client_secrets_path:
127
+ creds = _interactive_login_from_client_secrets_file()
128
+ else:
129
+ raise GoogleAuthError(
130
+ "client_secrets_path is required when stored OAuth credentials are missing."
131
+ )
132
+ token_store.save(creds)
133
+ return cls(creds)
134
+
135
+ if Path(token_path).exists():
136
+ creds = UserCredentials.from_authorized_user_file(token_path, scopes=scopes)
137
+ try:
138
+ creds.refresh(Request())
139
+
140
+ except GoogleRefreshError as exc:
141
+ warn(f"Failed to refresh stored credentials: {exc}. Proceeding to interactive login.")
142
+ creds = _interactive_login_from_client_secrets_file()
143
+ elif client_secrets_path:
144
+ warn(f"Token file does not exist at {token_path}. Credentials will not be saved for future use.")
145
+ creds = _interactive_login_from_client_secrets_file()
146
+ else:
147
+ raise GoogleAuthError("At least one of token_path or client_secrets_path must be provided for user OAuth.")
148
+
149
+ if hasattr(creds, "to_json"):
150
+ Path(token_path).parent.mkdir(parents=True, exist_ok=True)
151
+ Path(token_path).write_text(creds.to_json())
152
+ return cls(creds)
153
+
154
+ @classmethod
155
+ def from_settings(cls, config: object | None = None) -> "GoogleAuth":
156
+ """Build from environment variables or a :class:`~fileroute.auth.settings.GoogleAuthConfig`.
157
+
158
+ When *config* is ``None`` the settings are read from the environment
159
+ (and any ``.env`` file in the working directory).
160
+ """
161
+ from fileroute.auth.settings import GoogleAuthConfig
162
+
163
+ resolved: GoogleAuthConfig = config if config is not None else GoogleAuthConfig() # type: ignore[assignment]
164
+ return resolved.to_auth()
165
+
166
+ @property
167
+ def credentials(self) -> Credentials:
168
+ """The underlying :class:`~google.auth.credentials.Credentials` object."""
169
+ return self._creds
170
+
171
+ def ensure_valid(self) -> None:
172
+ """Refresh credentials when the current token is not valid."""
173
+ if not self._creds.valid:
174
+ self.refresh()
175
+
176
+ __all__ = [
177
+ "GoogleAuth"
178
+ ]
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Sequence
4
+
5
+ import msal
6
+
7
+ from fileroute.exceptions import GraphAuthError
8
+
9
+ DEFAULT_MICROSOFT_GRAPH_SCOPES = ("https://graph.microsoft.com/.default",)
10
+
11
+
12
+ def normalize_microsoft_scopes(
13
+ scopes: Sequence[str] | str | None,
14
+ *,
15
+ default: Sequence[str] = DEFAULT_MICROSOFT_GRAPH_SCOPES,
16
+ ) -> list[str]:
17
+ if scopes is None:
18
+ return list(default)
19
+ if isinstance(scopes, str):
20
+ normalized = [scope.strip() for scope in scopes.split(",") if scope.strip()]
21
+ return normalized or list(default)
22
+ normalized = [scope for scope in scopes if scope]
23
+ return normalized or list(default)
24
+
25
+
26
+ def _extract_access_token(result: dict[str, object]) -> str:
27
+ token = result.get("access_token")
28
+ if isinstance(token, str) and token:
29
+ return token
30
+ raise GraphAuthError(f"Token error: {result.get('error_description')}")
31
+
32
+
33
+ class MicrosoftAuth:
34
+ """Microsoft access-token holder with named constructors for each auth mode.
35
+
36
+ The primary entry points are the ``from_*`` class methods. The
37
+ ``__init__`` constructor accepts a raw access token string and acts as a
38
+ low-level escape hatch (e.g. for tests).
39
+
40
+ Usage::
41
+
42
+ # App-only (client credentials) – most common for server-side automation
43
+ auth = MicrosoftAuth.from_app_only(tenant_id, client_id, client_secret)
44
+
45
+ # Delegated (interactive browser login)
46
+ auth = MicrosoftAuth.from_delegated(tenant_id, client_id)
47
+
48
+ # Read mode + credentials from environment variables / .env file
49
+ auth = MicrosoftAuth.from_settings()
50
+ """
51
+
52
+ def __init__(self, access_token: str) -> None:
53
+ """Low-level escape hatch: supply a raw access token directly."""
54
+ self._access_token = access_token
55
+
56
+ # ------------------------------------------------------------------
57
+ # Named constructors
58
+ # ------------------------------------------------------------------
59
+
60
+ @classmethod
61
+ def from_app_only(
62
+ cls,
63
+ tenant_id: str,
64
+ client_id: str,
65
+ client_secret: str,
66
+ scopes: Sequence[str] | str | None = None,
67
+ ) -> "MicrosoftAuth":
68
+ """Build using client-credential (app-only) flow via MSAL."""
69
+ if not client_secret:
70
+ raise GraphAuthError(
71
+ "client_secret is required for Microsoft app_only mode."
72
+ )
73
+ normalized_scopes = normalize_microsoft_scopes(scopes)
74
+ authority = f"https://login.microsoftonline.com/{tenant_id}"
75
+ app = msal.ConfidentialClientApplication(
76
+ client_id=client_id,
77
+ client_credential=client_secret,
78
+ authority=authority,
79
+ )
80
+ result = app.acquire_token_for_client(scopes=normalized_scopes)
81
+ return cls(_extract_access_token(result))
82
+
83
+ @classmethod
84
+ def from_delegated(
85
+ cls,
86
+ tenant_id: str,
87
+ client_id: str,
88
+ scopes: Sequence[str] | str | None = None,
89
+ ) -> "MicrosoftAuth":
90
+ """Build using interactive delegated (user) flow via MSAL."""
91
+ normalized_scopes = normalize_microsoft_scopes(scopes)
92
+ authority = f"https://login.microsoftonline.com/{tenant_id}"
93
+ app = msal.PublicClientApplication(client_id, authority=authority)
94
+ accounts = app.get_accounts()
95
+ if accounts:
96
+ result = app.acquire_token_silent(normalized_scopes, account=accounts[0])
97
+ if result and "access_token" in result:
98
+ return cls(_extract_access_token(result))
99
+ result = app.acquire_token_interactive(scopes=normalized_scopes)
100
+ return cls(_extract_access_token(result))
101
+
102
+ @classmethod
103
+ def from_settings(cls, config: object | None = None) -> "MicrosoftAuth":
104
+ """Build from environment variables or a :class:`~fileroute.auth.settings.MicrosoftAuthConfig`.
105
+
106
+ When *config* is ``None`` the settings are read from the environment
107
+ (and any ``.env`` file in the working directory).
108
+ """
109
+ from fileroute.auth.settings import MicrosoftAuthConfig
110
+
111
+ resolved: MicrosoftAuthConfig = config if config is not None else MicrosoftAuthConfig() # type: ignore[assignment]
112
+ return resolved.to_auth()
113
+
114
+ # ------------------------------------------------------------------
115
+ # Runtime helpers
116
+ # ------------------------------------------------------------------
117
+
118
+ @property
119
+ def access_token(self) -> str:
120
+ """The raw Bearer access token string."""
121
+ return self._access_token
122
+
123
+
124
+ __all__ = [
125
+ "MicrosoftAuth"
126
+ ]
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ from dotenv import find_dotenv
8
+ from pydantic import AliasChoices, Field, SecretStr, field_validator, model_validator
9
+ from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
10
+
11
+ from fileroute.auth.google import (
12
+ DEFAULT_DRIVE_SCOPES,
13
+ GoogleAuth,
14
+ )
15
+ from fileroute.auth.microsoft import (
16
+ DEFAULT_MICROSOFT_GRAPH_SCOPES,
17
+ MicrosoftAuth,
18
+ normalize_microsoft_scopes,
19
+ )
20
+
21
+
22
+ class GoogleAuthMode(str, Enum):
23
+ ADC = "adc"
24
+ SERVICE_ACCOUNT = "service_account"
25
+ USER_OAUTH = "user_oauth"
26
+
27
+
28
+ class MicrosoftAuthMode(str, Enum):
29
+ APP_ONLY = "app_only"
30
+ DELEGATED = "delegated"
31
+
32
+
33
+ class GoogleAuthConfig(BaseSettings):
34
+ model_config = SettingsConfigDict(
35
+ env_file=find_dotenv(".env", usecwd=True),
36
+ env_file_encoding="utf-8",
37
+ extra="ignore",
38
+ populate_by_name=True,
39
+ )
40
+
41
+ auth_mode: GoogleAuthMode = Field(
42
+ default=GoogleAuthMode.ADC,
43
+ alias="GOOGLE_AUTH_MODE",
44
+ )
45
+ service_account_credentials: Path | None = Field(
46
+ default=None,
47
+ validation_alias=AliasChoices(
48
+ "GOOGLE_SERVICE_ACCOUNT_CREDENTIALS",
49
+ "GOOGLE_APPLICATION_CREDENTIALS",
50
+ ),
51
+ )
52
+ oauth_client_secrets: Path | None = Field(
53
+ default=None,
54
+ alias="GOOGLE_OAUTH_CREDENTIALS",
55
+ )
56
+ oauth_token_path: Path = Field(
57
+ default=".google/token.json",
58
+ alias="GOOGLE_OAUTH_TOKEN_PATH",
59
+ )
60
+ scopes: Annotated[list[str], NoDecode] = Field(
61
+ default_factory=lambda: list(DEFAULT_DRIVE_SCOPES),
62
+ alias="GOOGLE_SCOPES",
63
+ )
64
+ use_local_server: bool = Field(
65
+ default=True,
66
+ alias="GOOGLE_OAUTH_USE_LOCAL_SERVER",
67
+ )
68
+
69
+ @field_validator("scopes", mode="before")
70
+ @classmethod
71
+ def to_scope_list(cls, value: str | list[str] | tuple[str, ...] | None) -> list[str]:
72
+ if value is None:
73
+ return list(DEFAULT_DRIVE_SCOPES)
74
+ if isinstance(value, str):
75
+ parts = [part.strip() for part in value.split(",")]
76
+ return [part for part in parts if part] or list(DEFAULT_DRIVE_SCOPES)
77
+ return [scope for scope in value if scope]
78
+
79
+ @model_validator(mode="after")
80
+ def validate_for_mode(self) -> GoogleAuthConfig:
81
+ if (
82
+ self.auth_mode == GoogleAuthMode.SERVICE_ACCOUNT
83
+ and self.service_account_credentials is None
84
+ ):
85
+ raise ValueError(
86
+ "GOOGLE_APPLICATION_CREDENTIALS or GOOGLE_SERVICE_ACCOUNT_CREDENTIALS is required for service_account mode."
87
+ )
88
+
89
+ if (
90
+ self.auth_mode == GoogleAuthMode.USER_OAUTH
91
+ and self.oauth_client_secrets is None
92
+ ):
93
+ raise ValueError(
94
+ "GOOGLE_OAUTH_CREDENTIALS is required for user_oauth mode."
95
+ )
96
+
97
+ return self
98
+
99
+ def to_auth(self) -> GoogleAuth:
100
+ """Return a :class:`~fileroute.auth.google.GoogleAuth` for this configuration."""
101
+ if self.auth_mode == GoogleAuthMode.ADC:
102
+ return GoogleAuth.from_adc(scopes=self.scopes)
103
+
104
+ elif self.auth_mode == GoogleAuthMode.SERVICE_ACCOUNT:
105
+ return GoogleAuth.from_service_account(
106
+ credentials_path=self.service_account_credentials,
107
+ scopes=self.scopes,
108
+ )
109
+
110
+ elif self.auth_mode == GoogleAuthMode.USER_OAUTH:
111
+ return GoogleAuth.from_user_oauth(
112
+ client_secrets_path=self.oauth_client_secrets,
113
+ token_path=self.oauth_token_path,
114
+ scopes=self.scopes
115
+ )
116
+ else:
117
+ if self.auth_mode:
118
+ raise ValueError(f"Unsupported Google auth mode: {self.auth_mode}")
119
+ else:
120
+ raise ValueError("GOOGLE_AUTH_MODE is required for Google authentication.")
121
+
122
+
123
+ class MicrosoftAuthConfig(BaseSettings):
124
+ model_config = SettingsConfigDict(
125
+ env_file=find_dotenv(".env", usecwd=True),
126
+ env_file_encoding="utf-8",
127
+ extra="ignore",
128
+ populate_by_name=True,
129
+ )
130
+
131
+ auth_mode: MicrosoftAuthMode = Field(
132
+ default=MicrosoftAuthMode.APP_ONLY,
133
+ alias="SHAREPOINT_AUTH_MODE",
134
+ )
135
+ tenant_id: str | None = Field(default=None, alias="AZURE_TENANT_ID")
136
+ client_id: str | None = Field(default=None, alias="AZURE_CLIENT_ID")
137
+ client_secret: SecretStr | None = Field(
138
+ default=None,
139
+ alias="AZURE_CLIENT_SECRET",
140
+ validate_default=True,
141
+ )
142
+ host_url: str = Field(
143
+ default="norc.sharepoint.com",
144
+ validation_alias=AliasChoices("SHAREPOINT_HOST_URL", "AZURE_HOST_URL"),
145
+ )
146
+ scopes: Annotated[list[str], NoDecode] = Field(
147
+ default_factory=lambda: list(DEFAULT_MICROSOFT_GRAPH_SCOPES),
148
+ validation_alias=AliasChoices("SHAREPOINT_SCOPES", "AZURE_SCOPES"),
149
+ )
150
+
151
+ @field_validator("tenant_id", "client_id", mode="before")
152
+ @classmethod
153
+ def empty_string_to_none(cls, value: str | None) -> str | None:
154
+ if value is None:
155
+ return None
156
+ normalized = value.strip()
157
+ return normalized or None
158
+
159
+ @field_validator("host_url", mode="before")
160
+ @classmethod
161
+ def normalize_host_url(cls, value: str | None) -> str:
162
+ if value is None:
163
+ return "norc.sharepoint.com"
164
+ normalized = value.strip()
165
+ return normalized or "norc.sharepoint.com"
166
+
167
+ @field_validator("scopes", mode="before")
168
+ @classmethod
169
+ def to_scope_list(
170
+ cls,
171
+ value: str | list[str] | tuple[str, ...] | None,
172
+ ) -> list[str]:
173
+ return normalize_microsoft_scopes(value)
174
+
175
+ @model_validator(mode="after")
176
+ def validate_for_mode(self) -> MicrosoftAuthConfig:
177
+ if self.tenant_id is None:
178
+ raise ValueError("AZURE_TENANT_ID is required for Microsoft authentication.")
179
+
180
+ if self.client_id is None:
181
+ raise ValueError("AZURE_CLIENT_ID is required for Microsoft authentication.")
182
+
183
+ if (
184
+ self.auth_mode == MicrosoftAuthMode.APP_ONLY
185
+ and self.client_secret is None
186
+ ):
187
+ raise ValueError("AZURE_CLIENT_SECRET is required for Microsoft app_only mode.")
188
+
189
+ return self
190
+
191
+ def to_auth(self) -> MicrosoftAuth:
192
+ """Return a :class:`~fileroute.auth.microsoft.MicrosoftAuth` for this configuration."""
193
+ if self.auth_mode == MicrosoftAuthMode.DELEGATED:
194
+ return MicrosoftAuth.from_delegated(
195
+ tenant_id=self.tenant_id,
196
+ client_id=self.client_id,
197
+ scopes=self.scopes,
198
+ )
199
+ elif self.auth_mode == MicrosoftAuthMode.APP_ONLY:
200
+ return MicrosoftAuth.from_app_only(
201
+ tenant_id=self.tenant_id,
202
+ client_id=self.client_id,
203
+ client_secret=self.client_secret.get_secret_value()
204
+ if self.client_secret is not None
205
+ else None,
206
+ scopes=self.scopes,
207
+ )
208
+ else:
209
+ if self.auth_mode:
210
+ raise ValueError(f"Unsupported Microsoft auth mode: {self.auth_mode}")
211
+ else:
212
+ raise ValueError("SHAREPOINT_AUTH_MODE or AZURE_AUTH_MODE is required for Microsoft authentication.")
213
+
214
+
215
+ __all__ = [
216
+ "GoogleAuthConfig",
217
+ "GoogleAuthMode",
218
+ "MicrosoftAuthConfig",
219
+ "MicrosoftAuthMode",
220
+ ]
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from google.oauth2.credentials import Credentials as UserCredentials
6
+
7
+
8
+ class JsonTokenStore:
9
+ """Persist Google authorized-user credentials as JSON."""
10
+
11
+ def __init__(self, path: Path | str) -> None:
12
+ self.path = Path(path)
13
+
14
+ def load(self) -> UserCredentials | None:
15
+ if not self.path.exists():
16
+ return None
17
+ return UserCredentials.from_authorized_user_file(str(self.path))
18
+
19
+ def save(self, creds: UserCredentials) -> None:
20
+ self.path.parent.mkdir(parents=True, exist_ok=True)
21
+ self.path.write_text(creds.to_json(), encoding="utf-8")
22
+
23
+
24
+ __all__ = ["JsonTokenStore"]
fileroute/cli.py ADDED
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from dotenv import find_dotenv, load_dotenv
4
+ import typer
5
+
6
+ from fileroute.commands import (
7
+ register_auth_commands,
8
+ register_descriptor_commands,
9
+ register_diagram_command,
10
+ )
11
+
12
+ load_dotenv(find_dotenv(usecwd=True))
13
+
14
+ app = typer.Typer(
15
+ name="fileroute",
16
+ help="Shared drive utilities for SharePoint, Google Drive, and S3.",
17
+ rich_markup_mode="markdown",
18
+ )
19
+ clone_app = typer.Typer(
20
+ help="Clone descriptor state for new local variants.", rich_markup_mode="markdown"
21
+ )
22
+ auth_app = typer.Typer(help="Authentication helpers.", rich_markup_mode="markdown")
23
+ auth_login_app = typer.Typer(
24
+ help="Interactive login commands.", rich_markup_mode="markdown"
25
+ )
26
+ app.add_typer(auth_app, name="auth")
27
+ app.add_typer(clone_app, name="clone")
28
+ auth_app.add_typer(auth_login_app, name="login")
29
+
30
+ register_descriptor_commands(app, clone_app)
31
+ register_diagram_command(app)
32
+ register_auth_commands(auth_app, auth_login_app)
33
+
34
+
35
+ def main() -> None:
36
+ app()
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
@@ -0,0 +1,24 @@
1
+ """Built-in clients selected by the descriptor's ServiceType enum."""
2
+
3
+ from fileroute.models import ServiceType
4
+ from fileroute.clients.base import BaseClient
5
+
6
+
7
+ def get_provider(service_type: ServiceType) -> type[BaseClient]:
8
+ """Select a client class without constructing it or authenticating."""
9
+ if service_type is ServiceType.GOOGLE_DRIVE:
10
+ from .googledrive import GoogleDriveClient
11
+
12
+ return GoogleDriveClient
13
+ if service_type is ServiceType.SHAREPOINT:
14
+ from .sharepoint import SharepointClient
15
+
16
+ return SharepointClient
17
+ if service_type is ServiceType.S3:
18
+ from .s3 import S3Client
19
+
20
+ return S3Client
21
+ raise ValueError(f"Expected a resolved ServiceType, got {service_type!r}")
22
+
23
+
24
+ __all__ = ["get_provider"]