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 +13 -0
- fileroute/auth/__init__.py +17 -0
- fileroute/auth/google.py +178 -0
- fileroute/auth/microsoft.py +126 -0
- fileroute/auth/settings.py +220 -0
- fileroute/auth/token_store.py +24 -0
- fileroute/cli.py +40 -0
- fileroute/clients/__init__.py +24 -0
- fileroute/clients/base.py +59 -0
- fileroute/clients/googledrive.py +963 -0
- fileroute/clients/s3.py +301 -0
- fileroute/clients/sharepoint.py +792 -0
- fileroute/commands/__init__.py +9 -0
- fileroute/commands/auth.py +156 -0
- fileroute/commands/config.py +38 -0
- fileroute/commands/descriptor.py +445 -0
- fileroute/commands/diagram.py +63 -0
- fileroute/commands/toolkit.py +134 -0
- fileroute/descriptor.py +286 -0
- fileroute/diagram.py +335 -0
- fileroute/diagram_reports.py +258 -0
- fileroute/exceptions.py +65 -0
- fileroute/item.py +332 -0
- fileroute/migration.py +157 -0
- fileroute/models.py +179 -0
- fileroute/transfer.py +262 -0
- fileroute-0.1.2.dist-info/METADATA +493 -0
- fileroute-0.1.2.dist-info/RECORD +31 -0
- fileroute-0.1.2.dist-info/WHEEL +5 -0
- fileroute-0.1.2.dist-info/entry_points.txt +2 -0
- fileroute-0.1.2.dist-info/top_level.txt +1 -0
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
|
+
]
|
fileroute/auth/google.py
ADDED
|
@@ -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"]
|