mainsequence 9.0.1.dev30__py3-none-any.whl → 9.0.1.dev31__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.
@@ -756,6 +756,24 @@ def secure_store_available() -> bool:
756
756
  return _secure_keyring_backend() is not None
757
757
 
758
758
 
759
+ def saved_session_summary(backend: str | None = None) -> dict:
760
+ """
761
+ Describe the saved session of a backend without any token value.
762
+
763
+ `usable` says whether its refresh token has not expired by its own claim,
764
+ `expires_at` is that claim in epoch seconds (None when absent) and
765
+ `username` is the recorded user. The process environment is not consulted.
766
+ """
767
+ tokens = _read_secure_tokens(backend)
768
+ refresh = (tokens.get("refresh") or "").strip()
769
+ expires_at = token_expiry(refresh)
770
+ return {
771
+ "username": str(tokens.get("username") or ""),
772
+ "expires_at": expires_at,
773
+ "usable": bool(refresh) and (expires_at is None or expires_at > int(time.time())),
774
+ }
775
+
776
+
759
777
  def stored_session_available(backend: str | None = None) -> bool:
760
778
  """
761
779
  Return whether the credential store holds a renewable session for a backend.
@@ -70,42 +70,145 @@ def set_mainsequence_endpoint(endpoint: str) -> None:
70
70
  AUTH_ENDPOINT = normalized
71
71
 
72
72
 
73
- def _jwt_reauth_hint() -> str:
73
+ _SESSION_TOKEN_VARIABLES = ("MAINSEQUENCE_ACCESS_TOKEN", "MAINSEQUENCE_REFRESH_TOKEN")
74
+ # Renewal answers that refuse the credentials, as opposed to a backend that failed.
75
+ _REFUSED_RENEWAL_STATUSES = frozenset({400, 401, 403})
76
+
77
+
78
+ def _format_utc(epoch: int) -> str:
79
+ return datetime.datetime.fromtimestamp(epoch, tz=datetime.UTC).strftime(
80
+ "%Y-%m-%d %H:%M UTC"
81
+ )
82
+
83
+
84
+ def _session_backend() -> str:
85
+ return (AUTH_ENDPOINT or "").rstrip("/")
86
+
87
+
88
+ def _login_command(backend: str) -> str:
74
89
  """
75
- Say how to repair rejected JWT credentials, according to where they came from.
90
+ Return the CLI command that signs in to `backend`.
76
91
 
77
- A pair that was already in the process environment wins over the saved CLI
78
- session. When that pair is stale the saved session is not tried, because the
79
- pair may belong to another user or backend; the message names it instead.
92
+ `mainsequence login` alone signs in to the configured backend. Another
93
+ backend needs its address and the folder that holds its repositories.
80
94
  """
81
95
  try:
82
- from mainsequence import bootstrap
83
96
  from mainsequence.cli import config as cli_config
84
97
 
85
- from_environment = bootstrap.credential_source() == bootstrap.CREDENTIALS_FROM_ENVIRONMENT
86
- saved_session = from_environment and cli_config.stored_session_available()
98
+ configured = cli_config.normalize_backend_url(
99
+ cli_config.get_persistent_config().get("backend_url")
100
+ )
87
101
  except Exception:
88
- from_environment, saved_session = False, False
102
+ configured = ""
103
+ if not backend or backend == configured:
104
+ return "`mainsequence login`"
105
+ return f"`mainsequence login {backend} <base folder>`"
106
+
107
+
108
+ def _env_file_holding_process_credentials() -> pathlib.Path | None:
109
+ """
110
+ Return the working directory's `.env` when it holds this process's tokens.
111
+
112
+ IDE run configurations, launchers and dotenv calls load that file into a
113
+ process's environment. Values are compared here and never shown.
114
+ """
115
+ from mainsequence.cli import config as cli_config
116
+
117
+ env_path = pathlib.Path.cwd() / ".env"
118
+ try:
119
+ lines = env_path.read_text(encoding="utf-8").splitlines()
120
+ except (OSError, UnicodeError):
121
+ return None
122
+ for line in lines:
123
+ key = cli_config.env_line_key(line)
124
+ if key not in _SESSION_TOKEN_VARIABLES:
125
+ continue
126
+ value = line.split("=", 1)[1].split(" #", 1)[0].strip().strip("\"'")
127
+ if value and value == (os.environ.get(key) or "").strip():
128
+ return env_path
129
+ return None
130
+
131
+
132
+ def _jwt_reauth_hint(refresh_token: str | None = None) -> str:
133
+ """
134
+ Say why the backend refused this process's credentials and how to repair them.
135
+
136
+ The facts come from this process and this machine: the refresh token's own
137
+ expiry, whether the credentials were already in the environment when the SDK
138
+ started (and which `.env` holds them, when one does), and the saved session
139
+ of the backend. A pair from the environment wins over the saved session, so
140
+ the saved session is named and not used: the pair may belong to another user
141
+ or backend. No token value is included.
142
+ """
143
+ backend = _session_backend()
144
+ refresh = refresh_token if refresh_token is not None else os.getenv("MAINSEQUENCE_REFRESH_TOKEN")
145
+ expiry = _decode_jwt_exp(refresh)
146
+ try:
147
+ from mainsequence import bootstrap
148
+
149
+ source = bootstrap.credential_source()
150
+ from_environment = source == bootstrap.CREDENTIALS_FROM_ENVIRONMENT
151
+ from_store = source == bootstrap.CREDENTIALS_FROM_STORE
152
+ except Exception:
153
+ from_environment = from_store = False
154
+ login = _login_command(backend)
155
+
156
+ parts = []
157
+ if expiry is not None and expiry <= int(time.time()):
158
+ parts.append(f" The refresh token expired on {_format_utc(expiry)}.")
159
+ elif expiry is not None:
160
+ parts.append(
161
+ " The refresh token has not expired: the backend revoked it, or it was "
162
+ "issued by another backend."
163
+ )
89
164
 
90
165
  if from_environment:
91
- return (
92
- " The rejected credentials were set in this process's environment "
93
- "(MAINSEQUENCE_ACCESS_TOKEN / MAINSEQUENCE_REFRESH_TOKEN) before the SDK "
94
- "started: a shell export, an IDE run configuration, or a `.env` file that "
95
- "your tooling loads. "
96
- + (
97
- "A saved CLI session exists for this backend and was not used. Remove "
98
- "those variables to use it."
99
- if saved_session
100
- else "No saved CLI session exists for this backend. Remove those "
101
- "variables and run `mainsequence login`."
166
+ try:
167
+ env_file = _env_file_holding_process_credentials()
168
+ except Exception:
169
+ env_file = None
170
+ try:
171
+ from mainsequence.cli import config as cli_config
172
+
173
+ saved = cli_config.saved_session_summary(backend)
174
+ except Exception:
175
+ saved = {"usable": False, "username": "", "expires_at": None}
176
+ if env_file is not None:
177
+ parts.append(
178
+ f" These credentials come from the token lines of {env_file}, not from "
179
+ "your saved session."
180
+ )
181
+ cleanup = f"run `mainsequence refresh-token` in {env_file.parent} to remove those lines"
182
+ else:
183
+ parts.append(
184
+ " These credentials were in this process's environment "
185
+ "(MAINSEQUENCE_ACCESS_TOKEN / MAINSEQUENCE_REFRESH_TOKEN) when it started, "
186
+ "set by the program that started it, not taken from your saved session."
187
+ )
188
+ cleanup = "start the process without those two variables"
189
+ if saved["usable"]:
190
+ who = f" ({saved['username']})" if saved["username"] else ""
191
+ until = (
192
+ f", valid until {_format_utc(saved['expires_at'])}"
193
+ if saved["expires_at"] is not None
194
+ else ""
195
+ )
196
+ parts.append(
197
+ f" Your saved session for {backend}{who} is usable{until}: "
198
+ f"{cleanup}, and the next start uses it."
102
199
  )
200
+ else:
201
+ parts.append(
202
+ f" There is no usable saved session for {backend}. Sign in with {login}, "
203
+ f"then {cleanup}."
204
+ )
205
+ elif from_store:
206
+ parts.append(
207
+ f" These credentials are your saved session for {backend}. Sign in again with {login}."
103
208
  )
104
- return (
105
- " Refresh your credentials with `mainsequence logout` and "
106
- "`mainsequence login`. If this code runs in a separate shell or IDE, "
107
- "use `mainsequence login --export` and load the exported env vars there."
108
- )
209
+ else:
210
+ parts.append(f" Sign in with {login}.")
211
+ return "".join(parts)
109
212
 
110
213
 
111
214
  def _env_has_value(name: str) -> bool:
@@ -397,10 +500,14 @@ class JWTAuthProvider(BaseAuthProvider):
397
500
  if not force and not self._needs_refresh():
398
501
  return
399
502
 
503
+ backend = _session_backend()
400
504
  if not self.refresh_token:
401
505
  if self.access_token and not force:
402
506
  return
403
- raise AuthError("JWT refresh token is missing." + _jwt_reauth_hint())
507
+ raise AuthError(
508
+ f"Main Sequence cannot renew the session of this process for {backend}: "
509
+ "it has no refresh token." + _jwt_reauth_hint(self.refresh_token)
510
+ )
404
511
 
405
512
  http_client = session or requests
406
513
 
@@ -411,16 +518,24 @@ class JWTAuthProvider(BaseAuthProvider):
411
518
  timeout=self.timeout,
412
519
  )
413
520
 
521
+ if r.status_code in _REFUSED_RENEWAL_STATUSES:
522
+ raise AuthError(
523
+ f"Main Sequence refused to renew the session of this process for {backend} "
524
+ f"(HTTP {r.status_code})." + _jwt_reauth_hint(self.refresh_token)
525
+ )
414
526
  if r.status_code != 200:
527
+ # The backend failed; the credentials were not judged.
415
528
  raise AuthError(
416
- f"JWT refresh failed with status {r.status_code}." + _jwt_reauth_hint()
529
+ f"Main Sequence could not renew the session of this process for {backend}: "
530
+ f"the backend answered HTTP {r.status_code}. Try again when it is available."
417
531
  )
418
532
 
419
533
  data = r.json()
420
534
  access = data.get("access")
421
535
  if not access:
422
536
  raise AuthError(
423
- "JWT refresh response did not include access token." + _jwt_reauth_hint()
537
+ f"Main Sequence renewed the session for {backend} without an access token."
538
+ + _jwt_reauth_hint(self.refresh_token)
424
539
  )
425
540
 
426
541
  # Important if ROTATE_REFRESH_TOKENS=True
@@ -604,15 +719,12 @@ def make_request(
604
719
 
605
720
  if r.status_code == 401 and loaders is not None and not auth_retried:
606
721
  logger.warning(f"Error {r.status_code}; forcing auth refresh once")
607
- try:
608
- s.headers.update(loaders.refresh_headers(force=True, session=s))
609
- req = get_req(session=s)
610
- auth_retried = True
611
- continue
612
- except AuthError:
613
- logger.exception("Auth refresh failed")
614
- keep_request = False
615
- break
722
+ # A refused renewal raises AuthError: it is reported below with the
723
+ # reason and the repair, not as a traceback.
724
+ s.headers.update(loaders.refresh_headers(force=True, session=s))
725
+ req = get_req(session=s)
726
+ auth_retried = True
727
+ continue
616
728
 
617
729
  keep_request = False
618
730
  break
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mainsequence
3
- Version: 9.0.1.dev30
3
+ Version: 9.0.1.dev31
4
4
  Summary: Main Sequence SDK
5
5
  Author-email: Main Sequence GmbH <dev@main-sequence.io>
6
6
  License-Expression: MIT
@@ -23,7 +23,7 @@ mainsequence/cli/api.py,sha256=r9D0lSaw2Ah-39i04w9JgNkg3yjq88TLesB63B6nvug,12858
23
23
  mainsequence/cli/browser_auth.py,sha256=C-Sbpt0Pr9Pv3Z9dO80TMk7zPcaaZ-4zvW0tbRVMrTU,9863
24
24
  mainsequence/cli/cli.py,sha256=RYtT5DDCvoIrZQixAQWycJyqHxa4kD0xxoXj3DU7_0Y,275424
25
25
  mainsequence/cli/code_repository_status.py,sha256=SHEnU8YSWFowPG6dEdE4Yd8CS1tIcJSE1MXFLfvD3Os,5975
26
- mainsequence/cli/config.py,sha256=IT42UzG93uBhR98yUyg8YR-wMlQAgfx8UrybJcpih7Y,44884
26
+ mainsequence/cli/config.py,sha256=4AGDEUPAJ1GzoDX3FzYYFvGMKfeAXM38Kh7Yv-OQLQQ,45595
27
27
  mainsequence/cli/docker_utils.py,sha256=eAnp9MI042cHWwKasA5IF2I7INL3A6tV2JX-cSIe7rc,10687
28
28
  mainsequence/cli/doctor.py,sha256=_1XHd72jheRDt7dEJHd6hbBSSpCPpMwwFDUOLcdkii8,4550
29
29
  mainsequence/cli/local_ops.py,sha256=oeYA2KXDLEjiENVZYJCc5-loNZxK3PK4G4sXldrDfm4,6495
@@ -43,16 +43,16 @@ mainsequence/client/models_foundry.py,sha256=iN1jJzDokfniEQF0Ac5eepAB7L5pVjJnKav
43
43
  mainsequence/client/models_helpers.py,sha256=rE7wPc9llKkctAKdJcak0kXJagjLivUzO2ebUGhz1M8,45820
44
44
  mainsequence/client/models_user.py,sha256=BU_hfn4-4KqYz3nsaPlXuYZmUG6_-7YM892mkM92Zyo,37613
45
45
  mainsequence/client/observability.py,sha256=_WrDKCaNk_TXmKElKCXC1inork6Exxxe8FRY4m1GI9s,16008
46
- mainsequence/client/utils.py,sha256=ZJNmUh9JNslznNMYvTGy26h0V9SMvYVqMX3Pva8l0lw,27549
46
+ mainsequence/client/utils.py,sha256=fQur4p-ojLWTMcNWlYWx7uZKbGNEc4c2kJq1Wl_w070,31925
47
47
  mainsequence/client/value_sets.py,sha256=7TA-mpuAd3hOduNgO1WTQQTuNOGaFgue-VqCsDWKaC8,6181
48
48
  mainsequence/instrumentation/__init__.py,sha256=J_9XmfjprElvz0u2t9rTyJT0kFdyKpxdbkdQiaw28_U,316
49
49
  mainsequence/instrumentation/utils.py,sha256=b-ihjSioAepeLfMpudM2SqCtvnxMqIXileHx3klBCQU,3377
50
50
  mainsequence/server/__init__.py,sha256=559DiiBcCP6veWRmK2mXUEbsl7gbZofTvsbTUFjn0VE,82
51
51
  mainsequence/server/caller_assertions.py,sha256=5q8LZrDYozaonG1eBb9P8DojKQlB2i2qUJwO5VhsrW4,9780
52
52
  mainsequence/server/fastapi.py,sha256=tD1aVuJOgoBTu7H8bs9IldPNYRT4fhkr1leN3KZP1pI,10521
53
- mainsequence-9.0.1.dev30.dist-info/licenses/LICENSE,sha256=b6Ecsk8wjsEby3lbSzdljR6hHiFGHBizt9iWV2p1c_I,1074
54
- mainsequence-9.0.1.dev30.dist-info/METADATA,sha256=Q29OofPgcbN1MAT1LORvrCW5DOi5sG1epdwOEw7xqLk,7435
55
- mainsequence-9.0.1.dev30.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
56
- mainsequence-9.0.1.dev30.dist-info/entry_points.txt,sha256=XGAOq3-mF2lWWpeRJvoaWyMarRcK167mH1g124aGdUs,88
57
- mainsequence-9.0.1.dev30.dist-info/top_level.txt,sha256=BFeblM_Q9q0cUWBvTxnfWcWQEHtf5XZi9nOS-Gdfwus,28
58
- mainsequence-9.0.1.dev30.dist-info/RECORD,,
53
+ mainsequence-9.0.1.dev31.dist-info/licenses/LICENSE,sha256=b6Ecsk8wjsEby3lbSzdljR6hHiFGHBizt9iWV2p1c_I,1074
54
+ mainsequence-9.0.1.dev31.dist-info/METADATA,sha256=tBbhF0Sh9XpmEJpvbriT_X9qLEDwZ5tRQqHdiM2wBPU,7435
55
+ mainsequence-9.0.1.dev31.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
56
+ mainsequence-9.0.1.dev31.dist-info/entry_points.txt,sha256=XGAOq3-mF2lWWpeRJvoaWyMarRcK167mH1g124aGdUs,88
57
+ mainsequence-9.0.1.dev31.dist-info/top_level.txt,sha256=BFeblM_Q9q0cUWBvTxnfWcWQEHtf5XZi9nOS-Gdfwus,28
58
+ mainsequence-9.0.1.dev31.dist-info/RECORD,,