Skip to content

Commit 46a9f39

Browse files
authored
Refactor user tokens, introduce Logfire client (#981)
1 parent 9da0d24 commit 46a9f39

File tree

9 files changed

+601
-421
lines changed

9 files changed

+601
-421
lines changed

logfire/_internal/auth.py

Lines changed: 165 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,198 @@
11
from __future__ import annotations
22

33
import platform
4+
import re
45
import warnings
6+
from dataclasses import dataclass
57
from datetime import datetime, timezone
68
from pathlib import Path
7-
from typing import TypedDict
9+
from typing import TypedDict, cast
810
from urllib.parse import urljoin
911

1012
import requests
13+
from rich.prompt import IntPrompt
14+
from typing_extensions import Self
1115

1216
from logfire.exceptions import LogfireConfigError
1317

14-
from .utils import UnexpectedResponse
18+
from .utils import UnexpectedResponse, read_toml_file
1519

1620
HOME_LOGFIRE = Path.home() / '.logfire'
1721
"""Folder used to store global configuration, and user tokens."""
1822
DEFAULT_FILE = HOME_LOGFIRE / 'default.toml'
1923
"""File used to store user tokens."""
2024

2125

26+
PYDANTIC_LOGFIRE_TOKEN_PATTERN = re.compile(
27+
r'^(?P<safe_part>pylf_v(?P<version>[0-9]+)_(?P<region>[a-z]+)_)(?P<token>[a-zA-Z0-9]+)$'
28+
)
29+
30+
31+
class _RegionData(TypedDict):
32+
base_url: str
33+
gcp_region: str
34+
35+
36+
REGIONS: dict[str, _RegionData] = {
37+
'us': {
38+
'base_url': 'https://logfire-us.pydantic.dev',
39+
'gcp_region': 'us-east4',
40+
},
41+
'eu': {
42+
'base_url': 'https://logfire-eu.pydantic.dev',
43+
'gcp_region': 'europe-west4',
44+
},
45+
}
46+
"""The existing Logfire regions."""
47+
48+
2249
class UserTokenData(TypedDict):
2350
"""User token data."""
2451

2552
token: str
2653
expiration: str
2754

2855

29-
class DefaultFile(TypedDict):
30-
"""Content of the default.toml file."""
56+
class UserTokensFileData(TypedDict, total=False):
57+
"""Content of the file containing the user tokens."""
3158

3259
tokens: dict[str, UserTokenData]
3360

3461

62+
@dataclass
63+
class UserToken:
64+
"""A user token."""
65+
66+
token: str
67+
base_url: str
68+
expiration: str
69+
70+
@classmethod
71+
def from_user_token_data(cls, base_url: str, token: UserTokenData) -> Self:
72+
return cls(
73+
token=token['token'],
74+
base_url=base_url,
75+
expiration=token['expiration'],
76+
)
77+
78+
@property
79+
def is_expired(self) -> bool:
80+
"""Whether the token is expired."""
81+
return datetime.now(tz=timezone.utc) >= datetime.fromisoformat(self.expiration.rstrip('Z')).replace(
82+
tzinfo=timezone.utc
83+
)
84+
85+
def __str__(self) -> str:
86+
region = 'us'
87+
if match := PYDANTIC_LOGFIRE_TOKEN_PATTERN.match(self.token):
88+
region = match.group('region')
89+
if region not in REGIONS:
90+
region = 'us'
91+
92+
token_repr = f'{region.upper()} ({self.base_url}) - '
93+
if match:
94+
token_repr += match.group('safe_part') + match.group('token')[:5]
95+
else:
96+
token_repr += self.token[:5]
97+
token_repr += '****'
98+
return token_repr
99+
100+
101+
@dataclass
102+
class UserTokenCollection:
103+
"""A collection of user tokens, read from a user tokens file.
104+
105+
Args:
106+
path: The path where the user tokens will be stored. If the path doesn't exist,
107+
an empty collection is created. Defaults to `~/.logfire/default.toml`.
108+
"""
109+
110+
user_tokens: dict[str, UserToken]
111+
"""A mapping between base URLs and user tokens."""
112+
113+
path: Path
114+
"""The path where the user tokens are stored."""
115+
116+
def __init__(self, path: Path | None = None) -> None:
117+
# FIXME: we can't set the default value of `path` to `DEFAULT_FILE`, otherwise
118+
# `mock.patch()` doesn't work:
119+
self.path = path if path is not None else DEFAULT_FILE
120+
try:
121+
data = cast(UserTokensFileData, read_toml_file(self.path))
122+
except FileNotFoundError:
123+
data: UserTokensFileData = {}
124+
self.user_tokens = {url: UserToken(base_url=url, **token) for url, token in data.get('tokens', {}).items()}
125+
126+
def get_token(self, base_url: str | None = None) -> UserToken:
127+
"""Get a user token from the collection.
128+
129+
Args:
130+
base_url: Only look for user tokens valid for this base URL. If not provided,
131+
all the tokens of the collection will be considered: if only one token is
132+
available, it will be used, otherwise the user will be prompted to choose
133+
a token.
134+
135+
Raises:
136+
LogfireConfigError: If no user token is found (no token matched the base URL,
137+
the collection is empty, or the selected token is expired).
138+
"""
139+
tokens_list = list(self.user_tokens.values())
140+
141+
if base_url is not None:
142+
token = self.user_tokens.get(base_url)
143+
if token is None:
144+
raise LogfireConfigError(
145+
f'No user token was found matching the {base_url} Logfire URL. '
146+
'Please run `logfire auth` to authenticate.'
147+
)
148+
elif len(tokens_list) == 1:
149+
token = tokens_list[0]
150+
elif len(tokens_list) >= 2:
151+
choices_str = '\n'.join(
152+
f'{i}. {token} ({"expired" if token.is_expired else "valid"})'
153+
for i, token in enumerate(tokens_list, start=1)
154+
)
155+
int_choice = IntPrompt.ask(
156+
f'Multiple user tokens found. Please select one:\n{choices_str}\n',
157+
choices=[str(i) for i in range(1, len(tokens_list) + 1)],
158+
)
159+
token = tokens_list[int_choice - 1]
160+
else: # tokens_list == []
161+
raise LogfireConfigError('No user tokens are available. Please run `logfire auth` to authenticate.')
162+
163+
if token.is_expired:
164+
raise LogfireConfigError(f'User token {token} is expired. Please run `logfire auth` to authenticate.')
165+
return token
166+
167+
def is_logged_in(self, base_url: str | None = None) -> bool:
168+
"""Check whether the user token collection contains at least one valid user token.
169+
170+
Args:
171+
base_url: Only check for user tokens valid for this base URL. If not provided,
172+
all the tokens of the collection will be considered.
173+
"""
174+
if base_url is not None:
175+
tokens = (t for t in self.user_tokens.values() if t.base_url == base_url)
176+
else:
177+
tokens = self.user_tokens.values()
178+
return any(not t.is_expired for t in tokens)
179+
180+
def add_token(self, base_url: str, token: UserTokenData) -> UserToken:
181+
"""Add a user token to the collection."""
182+
self.user_tokens[base_url] = user_token = UserToken.from_user_token_data(base_url, token)
183+
self._dump()
184+
return user_token
185+
186+
def _dump(self) -> None:
187+
"""Dump the user token collection as TOML to the provided path."""
188+
# There's no standard library package to write TOML files, so we'll write it manually.
189+
with self.path.open('w') as f:
190+
for base_url, user_token in self.user_tokens.items():
191+
f.write(f'[tokens."{base_url}"]\n')
192+
f.write(f'token = "{user_token.token}"\n')
193+
f.write(f'expiration = "{user_token.expiration}"\n')
194+
195+
35196
class NewDeviceFlow(TypedDict):
36197
"""Matches model of the same name in the backend."""
37198

@@ -91,17 +252,3 @@ def poll_for_token(session: requests.Session, device_code: str, base_api_url: st
91252
opt_user_token: UserTokenData | None = res.json()
92253
if opt_user_token:
93254
return opt_user_token
94-
95-
96-
def is_logged_in(data: DefaultFile, logfire_url: str) -> bool:
97-
"""Check if the user is logged in.
98-
99-
Returns:
100-
True if the user is logged in, False otherwise.
101-
"""
102-
for url, info in data['tokens'].items(): # pragma: no branch
103-
# token expirations are in UTC
104-
expiry_date = datetime.fromisoformat(info['expiration'].rstrip('Z')).replace(tzinfo=timezone.utc)
105-
if url == logfire_url and datetime.now(tz=timezone.utc) < expiry_date: # pragma: no branch
106-
return True
107-
return False # pragma: no cover

logfire/_internal/cli.py

Lines changed: 31 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from collections.abc import Sequence
1515
from operator import itemgetter
1616
from pathlib import Path
17-
from typing import Any, cast
17+
from typing import Any
1818
from urllib.parse import urlparse
1919

2020
import requests
@@ -24,11 +24,17 @@
2424
from logfire.propagate import ContextCarrier, get_context
2525

2626
from ..version import VERSION
27-
from .auth import DEFAULT_FILE, HOME_LOGFIRE, DefaultFile, is_logged_in, poll_for_token, request_device_code
27+
from .auth import (
28+
DEFAULT_FILE,
29+
HOME_LOGFIRE,
30+
UserTokenCollection,
31+
poll_for_token,
32+
request_device_code,
33+
)
34+
from .client import LogfireClient
2835
from .config import REGIONS, LogfireCredentials, get_base_url_from_token
2936
from .config_params import ParamManager
3037
from .tracer import SDKTracerProvider
31-
from .utils import read_toml_file
3238

3339
BASE_OTEL_INTEGRATION_URL = 'https://opentelemetry-python-contrib.readthedocs.io/en/latest/instrumentation/'
3440
BASE_DOCS_URL = 'https://logfire.pydantic.dev/docs'
@@ -51,7 +57,7 @@ def parse_whoami(args: argparse.Namespace) -> None:
5157
"""Show user authenticated username and the URL to your Logfire project."""
5258
data_dir = Path(args.data_dir)
5359
param_manager = ParamManager.create(data_dir)
54-
base_url = param_manager.load_param('base_url', args.logfire_url)
60+
base_url: str | None = param_manager.load_param('base_url', args.logfire_url)
5561
token = param_manager.load_param('token')
5662

5763
if token:
@@ -61,12 +67,15 @@ def parse_whoami(args: argparse.Namespace) -> None:
6167
credentials.print_token_summary()
6268
return
6369

64-
current_user = LogfireCredentials.get_current_user(session=args._session, logfire_api_url=base_url)
65-
if current_user is None:
70+
try:
71+
client = LogfireClient.from_url(base_url)
72+
except LogfireConfigError:
6673
sys.stderr.write('Not logged in. Run `logfire auth` to log in.\n')
6774
else:
75+
current_user = client.get_user_information()
6876
username = current_user['name']
6977
sys.stderr.write(f'Logged in as: {username}\n')
78+
7079
credentials = LogfireCredentials.load_creds_file(data_dir)
7180
if credentials is None:
7281
sys.stderr.write(f'No Logfire credentials found in {data_dir.resolve()}\n')
@@ -198,16 +207,10 @@ def parse_auth(args: argparse.Namespace) -> None:
198207
199208
This will authenticate your machine with Logfire and store the credentials.
200209
"""
201-
logfire_url = args.logfire_url
202-
if DEFAULT_FILE.is_file():
203-
data = cast(DefaultFile, read_toml_file(DEFAULT_FILE))
204-
else:
205-
data: DefaultFile = {'tokens': {}}
210+
logfire_url: str | None = args.logfire_url
206211

207-
if logfire_url:
208-
logged_in = is_logged_in(data, logfire_url)
209-
else:
210-
logged_in = any(is_logged_in(data, url) for url in data['tokens'])
212+
tokens_collection = UserTokenCollection()
213+
logged_in = tokens_collection.is_logged_in(logfire_url)
211214

212215
if logged_in:
213216
sys.stderr.writelines(
@@ -256,22 +259,16 @@ def parse_auth(args: argparse.Namespace) -> None:
256259
)
257260
)
258261

259-
data['tokens'][logfire_url] = poll_for_token(args._session, device_code, logfire_url)
262+
tokens_collection.add_token(logfire_url, poll_for_token(args._session, device_code, logfire_url))
260263
sys.stderr.write('Successfully authenticated!\n')
261-
262-
# There's no standard library package to write TOML files, so we'll write it manually.
263-
with DEFAULT_FILE.open('w') as f:
264-
for url, info in data['tokens'].items():
265-
f.write(f'[tokens."{url}"]\n')
266-
f.write(f'token = "{info["token"]}"\n')
267-
f.write(f'expiration = "{info["expiration"]}"\n')
268-
269264
sys.stderr.write(f'\nYour Logfire credentials are stored in {DEFAULT_FILE}\n')
270265

271266

272267
def parse_list_projects(args: argparse.Namespace) -> None:
273268
"""List user projects."""
274-
projects = LogfireCredentials.get_user_projects(session=args._session, logfire_api_url=args.logfire_url)
269+
client = LogfireClient.from_url(args.logfire_url)
270+
271+
projects = client.get_user_projects()
275272
if projects:
276273
sys.stderr.write(
277274
_pretty_table(
@@ -300,42 +297,37 @@ def _write_credentials(project_info: dict[str, Any], data_dir: Path, logfire_api
300297
def parse_create_new_project(args: argparse.Namespace) -> None:
301298
"""Create a new project."""
302299
data_dir = Path(args.data_dir)
303-
logfire_url = args.logfire_url
304-
if logfire_url is None: # pragma: no cover
305-
_, logfire_url = LogfireCredentials._get_user_token_data() # type: ignore
300+
client = LogfireClient.from_url(args.logfire_url)
301+
306302
project_name = args.project_name
307303
organization = args.org
308304
default_organization = args.default_org
309305
project_info = LogfireCredentials.create_new_project(
310-
session=args._session,
311-
logfire_api_url=logfire_url,
306+
client=client,
312307
organization=organization,
313308
default_organization=default_organization,
314309
project_name=project_name,
315310
)
316-
credentials = _write_credentials(project_info, data_dir, logfire_url)
311+
credentials = _write_credentials(project_info, data_dir, client.base_url)
317312
sys.stderr.write(f'Project created successfully. You will be able to view it at: {credentials.project_url}\n')
318313

319314

320315
def parse_use_project(args: argparse.Namespace) -> None:
321316
"""Use an existing project."""
322317
data_dir = Path(args.data_dir)
323-
logfire_url = args.logfire_url
324-
if logfire_url is None: # pragma: no cover
325-
_, logfire_url = LogfireCredentials._get_user_token_data() # type: ignore
318+
client = LogfireClient.from_url(args.logfire_url)
319+
326320
project_name = args.project_name
327321
organization = args.org
328-
329-
projects = LogfireCredentials.get_user_projects(session=args._session, logfire_api_url=logfire_url)
322+
projects = client.get_user_projects()
330323
project_info = LogfireCredentials.use_existing_project(
331-
session=args._session,
332-
logfire_api_url=logfire_url,
324+
client=client,
333325
projects=projects,
334326
organization=organization,
335327
project_name=project_name,
336328
)
337329
if project_info:
338-
credentials = _write_credentials(project_info, data_dir, logfire_url)
330+
credentials = _write_credentials(project_info, data_dir, client.base_url)
339331
sys.stderr.write(
340332
f'Project configured successfully. You will be able to view it at: {credentials.project_url}\n'
341333
)

0 commit comments

Comments
 (0)