sf-exit 0.1.0__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.
sf_exit/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """sf-exit: move an organisation's data out of Salesforce."""
2
+
3
+ __version__ = "0.1.0"
sf_exit/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from sf_exit.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
sf_exit/auth.py ADDED
@@ -0,0 +1,190 @@
1
+ """Ways to log in to Salesforce.
2
+
3
+ Resolution order (first one that has enough information wins):
4
+
5
+ 1. ``--access-token`` + ``--instance-url`` (any existing session)
6
+ 2. ``--org ALIAS`` (reuse a Salesforce CLI login: `sf org login web`)
7
+ 3. ``--jwt-key-file`` + ``--client-id`` + ``--username`` (JWT bearer flow, headless/CI)
8
+ 4. ``--username`` + ``--password`` + ``--client-id`` (OAuth username-password flow)
9
+ 5. ``--client-id`` + ``--client-secret`` + ``--instance-url`` (OAuth client-credentials flow)
10
+ 6. nothing at all → the Salesforce CLI's *default* org, if one is set
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import shutil
17
+ import subprocess
18
+ import time
19
+ from dataclasses import dataclass
20
+ from typing import Callable, Optional
21
+
22
+ import requests
23
+
24
+ from sf_exit.client import SalesforceError
25
+
26
+ PRODUCTION_LOGIN = "https://login.salesforce.com"
27
+ SANDBOX_LOGIN = "https://test.salesforce.com"
28
+
29
+
30
+ class AuthError(Exception):
31
+ pass
32
+
33
+
34
+ @dataclass
35
+ class AuthConfig:
36
+ access_token: Optional[str] = None
37
+ instance_url: Optional[str] = None
38
+ org: Optional[str] = None
39
+ client_id: Optional[str] = None
40
+ client_secret: Optional[str] = None
41
+ username: Optional[str] = None
42
+ password: Optional[str] = None
43
+ jwt_key_file: Optional[str] = None
44
+ login_url: str = PRODUCTION_LOGIN
45
+
46
+
47
+ @dataclass
48
+ class Session:
49
+ instance_url: str
50
+ access_token: str
51
+ method: str
52
+ refresh: Optional[Callable[[], str]] = None
53
+
54
+
55
+ def _token_request(url: str, data: dict) -> dict:
56
+ resp = requests.post(url.rstrip("/") + "/services/oauth2/token", data=data, timeout=60)
57
+ if resp.status_code >= 400:
58
+ try:
59
+ body = resp.json()
60
+ msg = f"{body.get('error')}: {body.get('error_description')}"
61
+ except ValueError:
62
+ msg = resp.text[:300]
63
+ raise AuthError(f"Salesforce login failed ({resp.status_code}) {msg}")
64
+ return resp.json()
65
+
66
+
67
+ def _sf_cli_binary() -> Optional[str]:
68
+ return shutil.which("sf") or shutil.which("sfdx")
69
+
70
+
71
+ def from_sf_cli(org: Optional[str]) -> Session:
72
+ binary = _sf_cli_binary()
73
+ if not binary:
74
+ raise AuthError(
75
+ "The Salesforce CLI ('sf') was not found on PATH. Install it from "
76
+ "https://developer.salesforce.com/tools/salesforcecli or pass other credentials."
77
+ )
78
+ cmd = [binary, "org", "display", "--json"]
79
+ if org:
80
+ cmd += ["--target-org", org]
81
+ try:
82
+ out = subprocess.run(cmd, capture_output=True, text=True, timeout=120, check=False)
83
+ except (OSError, subprocess.TimeoutExpired) as exc:
84
+ raise AuthError(f"Could not run the Salesforce CLI: {exc}") from exc
85
+ try:
86
+ payload = json.loads(out.stdout or "{}")
87
+ except ValueError as exc:
88
+ raise AuthError(f"Unexpected output from '{' '.join(cmd)}': {out.stdout[:300]}") from exc
89
+ result = payload.get("result") or {}
90
+ if payload.get("status", 1) != 0 or not result.get("accessToken"):
91
+ message = payload.get("message") or out.stderr.strip() or "no org found"
92
+ raise AuthError(f"Salesforce CLI could not provide a session for {org or 'the default org'}: {message}")
93
+
94
+ def refresh() -> str:
95
+ return from_sf_cli(org).access_token
96
+
97
+ return Session(result["instanceUrl"], result["accessToken"], f"sf-cli:{result.get('alias') or result.get('username') or org}", refresh)
98
+
99
+
100
+ def jwt_flow(cfg: AuthConfig) -> Session:
101
+ try:
102
+ import jwt # type: ignore
103
+ except ImportError as exc: # pragma: no cover - depends on extras
104
+ raise AuthError("JWT login needs an extra: pip install 'sf-exit[jwt]'") from exc
105
+ with open(cfg.jwt_key_file or "", encoding="utf-8") as fh:
106
+ key = fh.read()
107
+ sandbox = "test.salesforce.com" in cfg.login_url or ".sandbox.my.salesforce.com" in cfg.login_url
108
+ audience = SANDBOX_LOGIN if sandbox else PRODUCTION_LOGIN
109
+
110
+ def fetch() -> dict:
111
+ assertion = jwt.encode(
112
+ {"iss": cfg.client_id, "sub": cfg.username, "aud": audience, "exp": int(time.time()) + 180},
113
+ key,
114
+ algorithm="RS256",
115
+ )
116
+ return _token_request(
117
+ cfg.login_url,
118
+ {"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": assertion},
119
+ )
120
+
121
+ tok = fetch()
122
+ return Session(tok["instance_url"], tok["access_token"], "jwt", lambda: fetch()["access_token"])
123
+
124
+
125
+ def password_flow(cfg: AuthConfig) -> Session:
126
+ def fetch() -> dict:
127
+ data = {
128
+ "grant_type": "password",
129
+ "client_id": cfg.client_id,
130
+ "username": cfg.username,
131
+ "password": cfg.password,
132
+ }
133
+ if cfg.client_secret:
134
+ data["client_secret"] = cfg.client_secret
135
+ return _token_request(cfg.login_url, data)
136
+
137
+ tok = fetch()
138
+ return Session(tok["instance_url"], tok["access_token"], "password", lambda: fetch()["access_token"])
139
+
140
+
141
+ def client_credentials_flow(cfg: AuthConfig) -> Session:
142
+ def fetch() -> dict:
143
+ return _token_request(
144
+ cfg.instance_url or cfg.login_url,
145
+ {
146
+ "grant_type": "client_credentials",
147
+ "client_id": cfg.client_id,
148
+ "client_secret": cfg.client_secret,
149
+ },
150
+ )
151
+
152
+ tok = fetch()
153
+ return Session(
154
+ tok.get("instance_url") or cfg.instance_url or "",
155
+ tok["access_token"],
156
+ "client-credentials",
157
+ lambda: fetch()["access_token"],
158
+ )
159
+
160
+
161
+ def login(cfg: AuthConfig) -> Session:
162
+ try:
163
+ if cfg.access_token:
164
+ if not cfg.instance_url:
165
+ raise AuthError("--access-token also needs --instance-url (e.g. https://acme.my.salesforce.com)")
166
+ return Session(cfg.instance_url, cfg.access_token, "access-token")
167
+ if cfg.org:
168
+ return from_sf_cli(cfg.org)
169
+ if cfg.jwt_key_file:
170
+ if not (cfg.client_id and cfg.username):
171
+ raise AuthError("JWT login needs --client-id and --username as well as --jwt-key-file")
172
+ return jwt_flow(cfg)
173
+ if cfg.username and cfg.password:
174
+ if not cfg.client_id:
175
+ raise AuthError("Username/password login needs --client-id (a connected app consumer key)")
176
+ return password_flow(cfg)
177
+ if cfg.client_id and cfg.client_secret:
178
+ if not cfg.instance_url:
179
+ raise AuthError("Client-credentials login needs --instance-url (your My Domain URL)")
180
+ return client_credentials_flow(cfg)
181
+ if _sf_cli_binary():
182
+ return from_sf_cli(None)
183
+ except SalesforceError as exc: # pragma: no cover - defensive
184
+ raise AuthError(str(exc)) from exc
185
+ raise AuthError(
186
+ "No Salesforce credentials given.\n"
187
+ " Easiest: install the Salesforce CLI, run `sf org login web --alias my-org`,\n"
188
+ " then `sf-exit export --org my-org`.\n"
189
+ " See `sf-exit export --help` for token, JWT, password and client-credentials options."
190
+ )