From a314ebb54bf58d6bc1e7aa80e1a47ea614251a35 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Sat, 10 Feb 2024 14:33:03 +0000 Subject: [PATCH 1/9] add 345 response and exception to allow auth redirecting --- demo/__init__.py | 4 +- demo/auth.py | 98 ++++++++++---------- demo/auth_user.py | 25 ++++- src/npm-fastui/src/components/ServerLoad.tsx | 39 ++++---- src/python-fastui/fastui/auth/__init__.py | 13 ++- src/python-fastui/fastui/auth/github.py | 49 +--------- src/python-fastui/fastui/auth/shared.py | 88 ++++++++++++++++++ 7 files changed, 194 insertions(+), 122 deletions(-) create mode 100644 src/python-fastui/fastui/auth/shared.py diff --git a/demo/__init__.py b/demo/__init__.py index b93b1828..3f58bf24 100644 --- a/demo/__init__.py +++ b/demo/__init__.py @@ -6,7 +6,7 @@ from fastapi import FastAPI from fastapi.responses import HTMLResponse, PlainTextResponse from fastui import prebuilt_html -from fastui.auth import AuthError +from fastui.auth import fastapi_auth_exception_handling from fastui.dev import dev_fastapi_app from httpx import AsyncClient @@ -32,7 +32,7 @@ async def lifespan(app_: FastAPI): else: app = FastAPI(lifespan=lifespan) -app.exception_handler(AuthError)(AuthError.fastapi_handle) +fastapi_auth_exception_handling(app) app.include_router(components_router, prefix='/api/components') app.include_router(sse_router, prefix='/api/components') app.include_router(table_router, prefix='/api/table') diff --git a/demo/auth.py b/demo/auth.py index cd41a656..e6354949 100644 --- a/demo/auth.py +++ b/demo/auth.py @@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, Request from fastui import AnyComponent, FastUI from fastui import components as c -from fastui.auth import GitHubAuthProvider +from fastui.auth import AuthRedirect, GitHubAuthProvider from fastui.events import AuthEvent, GoToEvent, PageEvent from fastui.forms import fastui_form from httpx import AsyncClient @@ -20,7 +20,7 @@ router = APIRouter() - +GITHUB_CLIENT_ID = os.getenv('GITHUB_CLIENT_ID', '0d0315f9c2e055d032e2') # this will give an error when making requests to GitHub, but at least the app will run GITHUB_CLIENT_SECRET = SecretStr(os.getenv('GITHUB_CLIENT_SECRET', 'dummy-secret')) @@ -29,7 +29,7 @@ async def get_github_auth(request: Request) -> GitHubAuthProvider: client: AsyncClient = request.app.state.httpx_client return GitHubAuthProvider( httpx_client=client, - github_client_id='9eddf87b27f71f52194a', + github_client_id=GITHUB_CLIENT_ID, github_client_secret=GITHUB_CLIENT_SECRET, scopes=['user:email'], ) @@ -41,36 +41,37 @@ async def get_github_auth(request: Request) -> GitHubAuthProvider: @router.get('/login/{kind}', response_model=FastUI, response_model_exclude_none=True) async def auth_login( kind: LoginKind, - user: Annotated[User | None, Depends(User.from_request)], + user: Annotated[User | None, Depends(User.from_request_opt)], github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)], ) -> list[AnyComponent]: - if user is None: - return demo_page( - c.LinkList( - links=[ - c.Link( - components=[c.Text(text='Password Login')], - on_click=PageEvent(name='tab', push_path='/auth/login/password', context={'kind': 'password'}), - active='/auth/login/password', - ), - c.Link( - components=[c.Text(text='GitHub Login')], - on_click=PageEvent(name='tab', push_path='/auth/login/github', context={'kind': 'github'}), - active='/auth/login/github', - ), - ], - mode='tabs', - class_name='+ mb-4', - ), - c.ServerLoad( - path='/auth/login/content/{kind}', - load_trigger=PageEvent(name='tab'), - components=await auth_login_content(kind, github_auth), - ), - title='Authentication', - ) - else: - return [c.FireEvent(event=GoToEvent(url='/auth/profile'))] + if user is not None: + # already logged in + raise AuthRedirect('/auth/profile') + + return demo_page( + c.LinkList( + links=[ + c.Link( + components=[c.Text(text='Password Login')], + on_click=PageEvent(name='tab', push_path='/auth/login/password', context={'kind': 'password'}), + active='/auth/login/password', + ), + c.Link( + components=[c.Text(text='GitHub Login')], + on_click=PageEvent(name='tab', push_path='/auth/login/github', context={'kind': 'github'}), + active='/auth/login/github', + ), + ], + mode='tabs', + class_name='+ mb-4', + ), + c.ServerLoad( + path='/auth/login/content/{kind}', + load_trigger=PageEvent(name='tab'), + components=await auth_login_content(kind, github_auth), + ), + title='Authentication', + ) @router.get('/login/content/{kind}', response_model=FastUI, response_model_exclude_none=True) @@ -87,7 +88,7 @@ async def auth_login_content( 'here you can "login" with any email address and password.' ) ), - c.Paragraph(text='(Passwords are not saved and email stored in the browser via a JWT)'), + c.Paragraph(text='(Passwords are not saved and is email stored in the browser via a JWT only)'), c.ModelForm(model=LoginForm, submit_url='/api/auth/login'), ] case 'github': @@ -95,7 +96,7 @@ async def auth_login_content( return [ c.Heading(text='GitHub Login', level=3), c.Paragraph(text='Demo of GitHub authentication.'), - c.Paragraph(text='(Credentials are stored in the browser via a JWT)'), + c.Paragraph(text='(Credentials are stored in the browser via a JWT only)'), c.Button(text='Login with GitHub', on_click=GoToEvent(url=auth_url)), ] case _: @@ -121,23 +122,20 @@ async def login_form_post(form: Annotated[LoginForm, fastui_form(LoginForm)]) -> @router.get('/profile', response_model=FastUI, response_model_exclude_none=True) -async def profile(user: Annotated[User | None, Depends(User.from_request)]) -> list[AnyComponent]: - if user is None: - return [c.FireEvent(event=GoToEvent(url='/auth/login'))] - else: - return demo_page( - c.Paragraph(text=f'You are logged in as "{user.email}".'), - c.Button(text='Logout', on_click=PageEvent(name='submit-form')), - c.Heading(text='User Data:', level=3), - c.Code(language='json', text=json.dumps(asdict(user), indent=2)), - c.Form( - submit_url='/api/auth/logout', - form_fields=[c.FormFieldInput(name='test', title='', initial='data', html_type='hidden')], - footer=[], - submit_trigger=PageEvent(name='submit-form'), - ), - title='Authentication', - ) +async def profile(user: Annotated[User, Depends(User.from_request)]) -> list[AnyComponent]: + return demo_page( + c.Paragraph(text=f'You are logged in as "{user.email}".'), + c.Button(text='Logout', on_click=PageEvent(name='submit-form')), + c.Heading(text='User Data:', level=3), + c.Code(language='json', text=json.dumps(asdict(user), indent=2)), + c.Form( + submit_url='/api/auth/logout', + form_fields=[c.FormFieldInput(name='test', title='', initial='data', html_type='hidden')], + footer=[], + submit_trigger=PageEvent(name='submit-form'), + ), + title='Authentication', + ) @router.post('/logout', response_model=FastUI, response_model_exclude_none=True) diff --git a/demo/auth_user.py b/demo/auth_user.py index e837d7f9..c36bf03b 100644 --- a/demo/auth_user.py +++ b/demo/auth_user.py @@ -1,10 +1,11 @@ import json from dataclasses import asdict, dataclass -from datetime import datetime +from datetime import datetime, timedelta from typing import Annotated, Any, Self import jwt from fastapi import Header, HTTPException +from fastui.auth import AuthRedirect JWT_SECRET = 'secret' @@ -15,19 +16,35 @@ class User: extra: dict[str, Any] def encode_token(self) -> str: - return jwt.encode(asdict(self), JWT_SECRET, algorithm='HS256', json_encoder=CustomJsonEncoder) + payload = asdict(self) + payload['exp'] = datetime.now() + timedelta(hours=1) + return jwt.encode(payload, JWT_SECRET, algorithm='HS256', json_encoder=CustomJsonEncoder) @classmethod - async def from_request(cls, authorization: Annotated[str, Header()] = '') -> Self | None: + def from_request(cls, authorization: Annotated[str, Header()] = '') -> Self: + user = cls.from_request_opt(authorization) + if user is None: + raise AuthRedirect('/auth/login/password') + else: + return user + + @classmethod + def from_request_opt(cls, authorization: Annotated[str, Header()] = '') -> Self | None: try: token = authorization.split(' ', 1)[1] except IndexError: return None try: - return cls(**jwt.decode(token, JWT_SECRET, algorithms=['HS256'])) + payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256']) + except jwt.ExpiredSignatureError as e: + raise AuthRedirect('/auth/login/password', 'Token expired') from e except jwt.DecodeError: raise HTTPException(status_code=401, detail='Invalid token') + else: + # existing token might not have 'exp' field + payload.pop('exp', None) + return cls(**payload) class CustomJsonEncoder(json.JSONEncoder): diff --git a/src/npm-fastui/src/components/ServerLoad.tsx b/src/npm-fastui/src/components/ServerLoad.tsx index d8730502..defda042 100644 --- a/src/npm-fastui/src/components/ServerLoad.tsx +++ b/src/npm-fastui/src/components/ServerLoad.tsx @@ -52,32 +52,31 @@ export const ServerLoadFetch: FC<{ path: string; devReload?: number }> = ({ path useEffect(() => { setTransitioning(true) - let componentUnloaded = false - request({ url, expectedStatus: [200, 404] }).then(([status, data]) => { - if (componentUnloaded) { - setTransitioning(false) - return - } - if (status === 200) { - setComponentProps(data as FastProps[]) - // if there's a fragment, scroll to that ID once the page is loaded - const fragment = getFragment(path) - if (fragment) { - setTimeout(() => { - const element = document.getElementById(fragment) - if (element) { - element.scrollIntoView() - } - }, 50) + let componentLoaded = true + request({ url, expectedStatus: [200, 345, 404] }).then(([status, data]) => { + if (componentLoaded) { + // 345 is treat the same as 200 - the server is expected to return valid FastUI components + if (status === 200 || status === 345) { + setComponentProps(data as FastProps[]) + // if there's a fragment, scroll to that ID once the page is loaded + const fragment = getFragment(path) + if (fragment) { + setTimeout(() => { + const element = document.getElementById(fragment) + if (element) { + element.scrollIntoView() + } + }, 50) + } + } else { + setNotFoundUrl(url) } - } else { - setNotFoundUrl(url) } setTransitioning(false) }) return () => { - componentUnloaded = true + componentLoaded = false } }, [url, path, request, devReload]) diff --git a/src/python-fastui/fastui/auth/__init__.py b/src/python-fastui/fastui/auth/__init__.py index 23f60222..89377824 100644 --- a/src/python-fastui/fastui/auth/__init__.py +++ b/src/python-fastui/fastui/auth/__init__.py @@ -1,3 +1,12 @@ -from .github import AuthError, GitHubAuthProvider +from .github import GitHubAuthProvider, GitHubEmail, GitHubExchange, GithubUser +from .shared import AuthError, AuthRedirect, fastapi_auth_exception_handling -__all__ = 'GitHubAuthProvider', 'AuthError' +__all__ = ( + 'GitHubAuthProvider', + 'GitHubExchange', + 'GithubUser', + 'GitHubEmail', + 'AuthError', + 'AuthRedirect', + 'fastapi_auth_exception_handling', +) diff --git a/src/python-fastui/fastui/auth/github.py b/src/python-fastui/fastui/auth/github.py index d53ba69f..5448699e 100644 --- a/src/python-fastui/fastui/auth/github.py +++ b/src/python-fastui/fastui/auth/github.py @@ -6,10 +6,13 @@ from pydantic import BaseModel, SecretStr, TypeAdapter, field_validator +from .shared import AuthError, StateProvider + if TYPE_CHECKING: import httpx - from fastapi import Request - from fastapi.responses import JSONResponse + + +__all__ = 'GitHubAuthProvider', 'GitHubExchange', 'GithubUser', 'GitHubEmail' @dataclass @@ -240,45 +243,3 @@ def _purge(self, max_age: timedelta) -> None: # exchange cache is a singleton so instantiating a new GitHubAuthProvider reuse the same cache EXCHANGE_CACHE = ExchangeCache() - - -class AuthError(RuntimeError): - # TODO if we add other providers, this should be shared - - def __init__(self, message: str, *, code: str): - super().__init__(message) - self.code = code - - @staticmethod - def fastapi_handle(_request: 'Request', e: 'AuthError') -> 'JSONResponse': - from fastapi.responses import JSONResponse - - return JSONResponse({'detail': str(e)}, status_code=400) - - -class StateProvider: - """ - This is a simple state provider for the GitHub OAuth flow which uses a JWT to create an unguessable state. - """ - - # TODO if we add other providers, this could be shared - - def __init__(self, secret: SecretStr, max_age: timedelta = timedelta(minutes=5)): - self._secret = secret - self._max_age = max_age - - async def new_state(self) -> str: - import jwt - - data = {'created_at': datetime.now().isoformat()} - return jwt.encode(data, self._secret.get_secret_value(), algorithm='HS256') - - async def check_state(self, state: str) -> bool: - import jwt - - try: - d = jwt.decode(state, self._secret.get_secret_value(), algorithms=['HS256']) - except jwt.DecodeError: - return False - else: - return datetime.fromisoformat(d['created_at']) > datetime.now() - self._max_age diff --git a/src/python-fastui/fastui/auth/shared.py b/src/python-fastui/fastui/auth/shared.py new file mode 100644 index 00000000..9c2a0616 --- /dev/null +++ b/src/python-fastui/fastui/auth/shared.py @@ -0,0 +1,88 @@ +import json +from abc import ABC, abstractmethod +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, List, Tuple, Union + +from pydantic import SecretStr + +from .. import AnyComponent, FastUI, events +from .. import components as c + +if TYPE_CHECKING: + from fastapi import FastAPI, Request + +__all__ = 'AuthError', 'AuthRedirect', 'fastapi_auth_exception_handling', 'StateProvider' + + +class AuthException(ABC, Exception): + """ + Base exception for all auth-related errors. + """ + + @abstractmethod + def response_data(self) -> Tuple[int, str]: + pass + + +class AuthError(AuthException): + def __init__(self, message: str, *, code: str): + super().__init__(message) + self.code = code + + def response_data(self) -> Tuple[int, str]: + return 401, json.dumps({'detail': str(self)}) + + +class AuthRedirect(AuthException): + """ + Special exception which should cause a 345 HTTP response with location specified via JSON in the body. + """ + + def __init__(self, path: str, message: Union[str, None] = None): + super().__init__(f'Auth redirect to `{path}`' + (f': {message}' if message else '')) + self.path = path + self.message = message + + def response_data(self) -> Tuple[int, str]: + components: List[AnyComponent] = [c.FireEvent(event=events.GoToEvent(url=self.path), message=self.message)] + return 345, FastUI(root=components).model_dump_json(exclude_none=True) + + +def fastapi_auth_exception_handling(app: 'FastAPI') -> None: + """ + Register an exception handler for `AuthException` in a FastAPI app. + """ + from fastapi.responses import Response + + @app.exception_handler(AuthException) + def auth_exception_handler(_request: 'Request', e: AuthException) -> Response: + status_code, body = e.response_data() + return Response(body, media_type='application/json', status_code=status_code) + + +class StateProvider: + """ + This is a simple state provider for the GitHub OAuth flow which uses a JWT to create an unguessable state. + + It's in shared in case it's useful for other OAuth providers. + """ + + def __init__(self, secret: SecretStr, max_age: timedelta = timedelta(minutes=5)): + self._secret = secret + self._max_age = max_age + + async def new_state(self) -> str: + import jwt + + data = {'exp': datetime.now(tz=timezone.utc) + self._max_age} + return jwt.encode(data, self._secret.get_secret_value(), algorithm='HS256') + + async def check_state(self, state: str) -> bool: + import jwt + + try: + jwt.decode(state, self._secret.get_secret_value(), algorithms=['HS256']) + except (jwt.DecodeError, jwt.ExpiredSignatureError): + return False + else: + return True From 59721fd6d79b11e1fd3406764d999eef5e728ffa Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Sat, 10 Feb 2024 15:20:49 +0000 Subject: [PATCH 2/9] adding tests --- src/python-fastui/fastui/auth/github.py | 48 +++++++++++++++++--- src/python-fastui/fastui/auth/shared.py | 46 ++++--------------- src/python-fastui/tests/test_auth_github.py | 49 +++++++++++++++++---- src/python-fastui/tests/test_auth_shared.py | 39 ++++++++++++++++ 4 files changed, 129 insertions(+), 53 deletions(-) create mode 100644 src/python-fastui/tests/test_auth_shared.py diff --git a/src/python-fastui/fastui/auth/github.py b/src/python-fastui/fastui/auth/github.py index 5448699e..e32d41dd 100644 --- a/src/python-fastui/fastui/auth/github.py +++ b/src/python-fastui/fastui/auth/github.py @@ -1,12 +1,12 @@ from contextlib import asynccontextmanager from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, AsyncIterator, Dict, List, Tuple, Union, cast from urllib.parse import urlencode from pydantic import BaseModel, SecretStr, TypeAdapter, field_validator -from .shared import AuthError, StateProvider +from .shared import AuthError if TYPE_CHECKING: import httpx @@ -221,25 +221,59 @@ def _auth_headers(exchange: GitHubExchange) -> Dict[str, str]: class ExchangeCache: def __init__(self): - self._cache: Dict[str, Tuple[datetime, GitHubExchange]] = {} + self._data: Dict[str, Tuple[datetime, GitHubExchange]] = {} def get(self, key: str, max_age: timedelta) -> Union[GitHubExchange, None]: self._purge(max_age) - if v := self._cache.get(key): + if v := self._data.get(key): return v[1] def set(self, key: str, value: GitHubExchange) -> None: - self._cache[key] = (datetime.now(), value) + self._data[key] = (datetime.now(), value) def _purge(self, max_age: timedelta) -> None: """ Remove old items from the exchange cache """ min_timestamp = datetime.now() - max_age - to_remove = [k for k, (ts, _) in self._cache.items() if ts < min_timestamp] + to_remove = [k for k, (ts, _) in self._data.items() if ts < min_timestamp] for k in to_remove: - del self._cache[k] + del self._data[k] + + def __len__(self) -> int: + return len(self._data) + + def clear(self) -> None: + self._data.clear() # exchange cache is a singleton so instantiating a new GitHubAuthProvider reuse the same cache EXCHANGE_CACHE = ExchangeCache() + + +class StateProvider: + """ + This is a simple state provider for the GitHub OAuth flow which uses a JWT to create an unguessable "state" string. + + Requires `PyJWT` to be installed. + """ + + def __init__(self, secret: SecretStr, max_age: timedelta = timedelta(minutes=5)): + self._secret = secret + self._max_age = max_age + + async def new_state(self) -> str: + import jwt + + data = {'exp': datetime.now(tz=timezone.utc) + self._max_age} + return jwt.encode(data, self._secret.get_secret_value(), algorithm='HS256') + + async def check_state(self, state: str) -> bool: + import jwt + + try: + jwt.decode(state, self._secret.get_secret_value(), algorithms=['HS256']) + except (jwt.DecodeError, jwt.ExpiredSignatureError): + return False + else: + return True diff --git a/src/python-fastui/fastui/auth/shared.py b/src/python-fastui/fastui/auth/shared.py index 9c2a0616..37abedc5 100644 --- a/src/python-fastui/fastui/auth/shared.py +++ b/src/python-fastui/fastui/auth/shared.py @@ -1,17 +1,14 @@ import json from abc import ABC, abstractmethod -from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, List, Tuple, Union -from pydantic import SecretStr - from .. import AnyComponent, FastUI, events from .. import components as c if TYPE_CHECKING: - from fastapi import FastAPI, Request + from fastapi import FastAPI -__all__ = 'AuthError', 'AuthRedirect', 'fastapi_auth_exception_handling', 'StateProvider' +__all__ = 'AuthError', 'AuthRedirect', 'fastapi_auth_exception_handling' class AuthException(ABC, Exception): @@ -21,7 +18,7 @@ class AuthException(ABC, Exception): @abstractmethod def response_data(self) -> Tuple[int, str]: - pass + raise NotImplementedError class AuthError(AuthException): @@ -35,7 +32,8 @@ def response_data(self) -> Tuple[int, str]: class AuthRedirect(AuthException): """ - Special exception which should cause a 345 HTTP response with location specified via JSON in the body. + Special exception which should cause a 345 HTTP response with a body containing + FastUI components to redirect the user to a new page. """ def __init__(self, path: str, message: Union[str, None] = None): @@ -50,39 +48,11 @@ def response_data(self) -> Tuple[int, str]: def fastapi_auth_exception_handling(app: 'FastAPI') -> None: """ - Register an exception handler for `AuthException` in a FastAPI app. + Register an exception handler for any `AuthException` in a FastAPI app. """ - from fastapi.responses import Response + from fastapi import Request, Response @app.exception_handler(AuthException) - def auth_exception_handler(_request: 'Request', e: AuthException) -> Response: + def auth_exception_handler(_request: Request, e: AuthException) -> Response: status_code, body = e.response_data() return Response(body, media_type='application/json', status_code=status_code) - - -class StateProvider: - """ - This is a simple state provider for the GitHub OAuth flow which uses a JWT to create an unguessable state. - - It's in shared in case it's useful for other OAuth providers. - """ - - def __init__(self, secret: SecretStr, max_age: timedelta = timedelta(minutes=5)): - self._secret = secret - self._max_age = max_age - - async def new_state(self) -> str: - import jwt - - data = {'exp': datetime.now(tz=timezone.utc) + self._max_age} - return jwt.encode(data, self._secret.get_secret_value(), algorithm='HS256') - - async def check_state(self, state: str) -> bool: - import jwt - - try: - jwt.decode(state, self._secret.get_secret_value(), algorithms=['HS256']) - except (jwt.DecodeError, jwt.ExpiredSignatureError): - return False - else: - return True diff --git a/src/python-fastui/tests/test_auth_github.py b/src/python-fastui/tests/test_auth_github.py index 1723eed0..c34f8f1e 100644 --- a/src/python-fastui/tests/test_auth_github.py +++ b/src/python-fastui/tests/test_auth_github.py @@ -1,10 +1,11 @@ +from datetime import datetime from typing import List, Optional import httpx import pytest from fastapi import FastAPI -from fastui.auth import AuthError, GitHubAuthProvider -from fastui.auth.github import GitHubEmail +from fastui.auth import AuthError, GitHubAuthProvider, GitHubEmail +from fastui.auth.github import EXCHANGE_CACHE from pydantic import SecretStr @@ -73,7 +74,7 @@ async def httpx_client(fake_github_app: FastAPI): @pytest.fixture -async def github_auth_provider(fake_github_app: FastAPI, httpx_client: httpx.AsyncClient): +async def github_auth_provider(httpx_client: httpx.AsyncClient): return GitHubAuthProvider( httpx_client=httpx_client, github_client_id='1234', @@ -89,6 +90,20 @@ async def test_get_auth_url(github_auth_provider: GitHubAuthProvider): assert url == 'https://github.com/login/oauth/authorize?client_id=1234' +async def test_get_auth_url_scopes(httpx_client: httpx.AsyncClient): + provider = GitHubAuthProvider( + httpx_client=httpx_client, + github_client_id='1234', + github_client_secret=SecretStr('secret'), + scopes=['user:email', 'read:org'], + state_provider=False, + exchange_cache_age=None, + ) + url = await provider.authorization_url() + # insert_assert(url) + assert url == 'https://github.com/login/oauth/authorize?client_id=1234&scope=user%3Aemail+read%3Aorg' + + async def test_exchange_ok(github_auth_provider: GitHubAuthProvider, github_requests: List[str]): assert github_requests == [] exchange = await github_auth_provider.exchange_code('good') @@ -106,13 +121,9 @@ async def test_exchange_ok_user(github_auth_provider: GitHubAuthProvider): async def test_exchange_bad_expected(github_auth_provider: GitHubAuthProvider): - with pytest.raises(AuthError, match='^Invalid GitHub verification code') as exc_info: + with pytest.raises(AuthError, match='^Invalid GitHub verification code'): await github_auth_provider.exchange_code('bad_expected') - # request argument is ignored - r = AuthError.fastapi_handle(object(), exc_info.value) - assert r.status_code == 400 - async def test_exchange_bad_unexpected(github_auth_provider: GitHubAuthProvider): with pytest.raises(RuntimeError, match='^Unexpected response from GitHub access token exchange'): @@ -179,8 +190,11 @@ async def test_exchange_ok_repeat_cached( github_client_secret=SecretStr('secret'), state_provider=False, ) + EXCHANGE_CACHE.clear() + assert len(EXCHANGE_CACHE) == 0 assert github_requests == [] await github_auth_provider.exchange_code('good') + assert len(EXCHANGE_CACHE) == 1 assert github_requests == ['/login/oauth/access_token code=good'] await github_auth_provider.exchange_code('good') assert github_requests == ['/login/oauth/access_token code=good'] # no repeat request to github @@ -188,6 +202,25 @@ async def test_exchange_ok_repeat_cached( assert github_requests == ['/login/oauth/access_token code=good', '/login/oauth/access_token code=good_user'] +async def test_exchange_cached_purge(fake_github_app: FastAPI, httpx_client: httpx.AsyncClient): + github_auth_provider = GitHubAuthProvider( + httpx_client=httpx_client, + github_client_id='1234', + github_client_secret=SecretStr('secret'), + state_provider=False, + ) + EXCHANGE_CACHE.clear() + await github_auth_provider.exchange_code('good') + assert len(EXCHANGE_CACHE) == 1 + + # manually add an old entry + EXCHANGE_CACHE._data['old'] = (datetime(2020, 1, 1), 'old_token') + assert len(EXCHANGE_CACHE) == 2 + + await github_auth_provider.exchange_code('good') + assert len(EXCHANGE_CACHE) == 1 + + async def test_exchange_redirect_url( fake_github_app: FastAPI, httpx_client: httpx.AsyncClient, github_requests: List[str] ): diff --git a/src/python-fastui/tests/test_auth_shared.py b/src/python-fastui/tests/test_auth_shared.py new file mode 100644 index 00000000..c9c956b1 --- /dev/null +++ b/src/python-fastui/tests/test_auth_shared.py @@ -0,0 +1,39 @@ +import pytest +from fastapi import FastAPI +from fastui.auth import AuthError, AuthRedirect, fastapi_auth_exception_handling +from starlette.testclient import TestClient + + +@pytest.fixture(name='app') +def app_fixture() -> FastAPI: + app = FastAPI() + fastapi_auth_exception_handling(app) + + @app.post('/do-redirect/') + async def do_redirect(): + raise AuthRedirect('/new-path') + + @app.post('/do-error/') + async def do_error(): + raise AuthError('error message', code='error-code') + + return app + + +@pytest.fixture +def client(app: FastAPI): + with TestClient(app) as test_client: + yield test_client + + +def test_auth_redirect(client: TestClient): + r = client.post('/do-redirect/') + assert r.status_code == 345 + # insert_assert(r.json()) + assert r.json() == [{'event': {'url': '/new-path', 'type': 'go-to'}, 'type': 'FireEvent'}] + + +def test_auth_error(client: TestClient): + r = client.post('/do-error/') + assert r.status_code == 401 + assert r.json() == {'detail': 'error message'} From b18ccc878a294af9db1f50f859a671814b94f07f Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Sat, 10 Feb 2024 15:37:31 +0000 Subject: [PATCH 3/9] uprev js --- src/npm-fastui-bootstrap/package.json | 4 +-- src/npm-fastui-prebuilt/package.json | 2 +- src/npm-fastui/package.json | 2 +- src/python-fastui/fastui/__init__.py | 2 +- uprev_npm_packages.py | 47 +++++++++++++++++++++++++++ 5 files changed, 52 insertions(+), 5 deletions(-) create mode 100644 uprev_npm_packages.py diff --git a/src/npm-fastui-bootstrap/package.json b/src/npm-fastui-bootstrap/package.json index 817b42f5..ee2b80d2 100644 --- a/src/npm-fastui-bootstrap/package.json +++ b/src/npm-fastui-bootstrap/package.json @@ -1,6 +1,6 @@ { "name": "@pydantic/fastui-bootstrap", - "version": "0.0.16", + "version": "0.0.17", "description": "Boostrap renderer for FastUI", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -29,6 +29,6 @@ "sass": "^1.69.5" }, "peerDependencies": { - "@pydantic/fastui": "0.0.16" + "@pydantic/fastui": "0.0.17" } } diff --git a/src/npm-fastui-prebuilt/package.json b/src/npm-fastui-prebuilt/package.json index 00ef82f5..bb02466a 100644 --- a/src/npm-fastui-prebuilt/package.json +++ b/src/npm-fastui-prebuilt/package.json @@ -1,6 +1,6 @@ { "name": "@pydantic/fastui-prebuilt", - "version": "0.0.16", + "version": "0.0.17", "description": "Pre-built files for FastUI", "main": "dist/index.html", "type": "module", diff --git a/src/npm-fastui/package.json b/src/npm-fastui/package.json index 9979788c..0950ecb6 100644 --- a/src/npm-fastui/package.json +++ b/src/npm-fastui/package.json @@ -1,6 +1,6 @@ { "name": "@pydantic/fastui", - "version": "0.0.16", + "version": "0.0.17", "description": "Build better UIs faster.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/python-fastui/fastui/__init__.py b/src/python-fastui/fastui/__init__.py index 44069064..294aeeee 100644 --- a/src/python-fastui/fastui/__init__.py +++ b/src/python-fastui/fastui/__init__.py @@ -23,7 +23,7 @@ def coerce_to_list(cls, v): return [v] -_PREBUILT_VERSION = '0.0.16' +_PREBUILT_VERSION = '0.0.17' _PREBUILT_CDN_URL = f'https://cdn.jsdelivr.net/npm/@pydantic/fastui-prebuilt@{_PREBUILT_VERSION}/dist/assets' diff --git a/uprev_npm_packages.py b/uprev_npm_packages.py new file mode 100644 index 00000000..eda71043 --- /dev/null +++ b/uprev_npm_packages.py @@ -0,0 +1,47 @@ +import json +import re +from pathlib import Path + + +def replace_package_json(package_json: Path, new_version: str, deps: bool = False) -> None: + content = package_json.read_text() + content = re.sub(r'"version": *".*?"', f'"version": "{new_version}"', content) + if deps: + content = re.sub(r'"(@pydantic/.+?)": *".*?"', fr'"\1": "{new_version}"', content) + package_json.write_text(content) + + +def main(): + this_dir = Path(__file__).parent + fastui_package_json = this_dir / 'src/npm-fastui/package.json' + with fastui_package_json.open() as f: + old_version = json.load(f)['version'] + + rest, patch_version = old_version.rsplit('.', 1) + new_version = f'{rest}.{int(patch_version) + 1}' + replace_package_json(fastui_package_json, new_version) + bootstrap_package_json = this_dir / 'src/npm-fastui-bootstrap/package.json' + replace_package_json(bootstrap_package_json, new_version, deps=True) + prebuilt_package_json = this_dir / 'src/npm-fastui-prebuilt/package.json' + replace_package_json(prebuilt_package_json, new_version, deps=True) + + python_init = this_dir / 'src/python-fastui/fastui/__init__.py' + python_content = python_init.read_text() + python_content = re.sub(r"(_PREBUILT_VERSION = )'.+'", fr"\1'{new_version}'", python_content) + python_init.write_text(python_content) + + files = fastui_package_json, bootstrap_package_json, prebuilt_package_json, python_init + files = '\n'.join(str(f.relative_to(this_dir)) for f in files) + print(f"""\ +Updated version from `{old_version}` to `{new_version}` in: + +{files} + +To publish the new version, run: + + npm --workspaces publish +""") + + +if __name__ == '__main__': + main() From 6d29b85c0b8bd7acda72eb3a7925cd91d8966b13 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Sat, 10 Feb 2024 19:42:10 +0000 Subject: [PATCH 4/9] tweaks to demo auth --- demo/auth.py | 3 +++ demo/auth_user.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/demo/auth.py b/demo/auth.py index e6354949..5055278e 100644 --- a/demo/auth.py +++ b/demo/auth.py @@ -23,6 +23,8 @@ GITHUB_CLIENT_ID = os.getenv('GITHUB_CLIENT_ID', '0d0315f9c2e055d032e2') # this will give an error when making requests to GitHub, but at least the app will run GITHUB_CLIENT_SECRET = SecretStr(os.getenv('GITHUB_CLIENT_SECRET', 'dummy-secret')) +# use 'http://localhost:3000/auth/login/github/redirect' in development +GITHUB_REDIRECT = os.getenv('GITHUB_REDIRECT') async def get_github_auth(request: Request) -> GitHubAuthProvider: @@ -31,6 +33,7 @@ async def get_github_auth(request: Request) -> GitHubAuthProvider: httpx_client=client, github_client_id=GITHUB_CLIENT_ID, github_client_secret=GITHUB_CLIENT_SECRET, + redirect_uri=GITHUB_REDIRECT, scopes=['user:email'], ) diff --git a/demo/auth_user.py b/demo/auth_user.py index c36bf03b..995b1903 100644 --- a/demo/auth_user.py +++ b/demo/auth_user.py @@ -17,7 +17,7 @@ class User: def encode_token(self) -> str: payload = asdict(self) - payload['exp'] = datetime.now() + timedelta(hours=1) + payload['exp'] = datetime.now() + timedelta(minutes=1) return jwt.encode(payload, JWT_SECRET, algorithm='HS256', json_encoder=CustomJsonEncoder) @classmethod From fb68649497c695841a9ce3dead582d21012f4bc0 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Sat, 10 Feb 2024 19:57:01 +0000 Subject: [PATCH 5/9] fix demo from_request_opt --- demo/auth_user.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/demo/auth_user.py b/demo/auth_user.py index 995b1903..0faf3fb0 100644 --- a/demo/auth_user.py +++ b/demo/auth_user.py @@ -17,7 +17,7 @@ class User: def encode_token(self) -> str: payload = asdict(self) - payload['exp'] = datetime.now() + timedelta(minutes=1) + payload['exp'] = datetime.now() + timedelta(hours=1) return jwt.encode(payload, JWT_SECRET, algorithm='HS256', json_encoder=CustomJsonEncoder) @classmethod @@ -37,8 +37,8 @@ def from_request_opt(cls, authorization: Annotated[str, Header()] = '') -> Self try: payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256']) - except jwt.ExpiredSignatureError as e: - raise AuthRedirect('/auth/login/password', 'Token expired') from e + except jwt.ExpiredSignatureError: + return None except jwt.DecodeError: raise HTTPException(status_code=401, detail='Invalid token') else: From e36314fec00ea63b4040a7244d2b5063110a1e69 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Sat, 10 Feb 2024 20:10:19 +0000 Subject: [PATCH 6/9] use localStorage, generate auth link on click --- demo/auth.py | 18 ++++++++++-------- src/npm-fastui/src/events.ts | 4 ++-- src/npm-fastui/src/tools.ts | 2 +- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/demo/auth.py b/demo/auth.py index 5055278e..bae81311 100644 --- a/demo/auth.py +++ b/demo/auth.py @@ -42,10 +42,9 @@ async def get_github_auth(request: Request) -> GitHubAuthProvider: @router.get('/login/{kind}', response_model=FastUI, response_model_exclude_none=True) -async def auth_login( +def auth_login( kind: LoginKind, user: Annotated[User | None, Depends(User.from_request_opt)], - github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)], ) -> list[AnyComponent]: if user is not None: # already logged in @@ -71,16 +70,14 @@ async def auth_login( c.ServerLoad( path='/auth/login/content/{kind}', load_trigger=PageEvent(name='tab'), - components=await auth_login_content(kind, github_auth), + components=auth_login_content(kind), ), title='Authentication', ) @router.get('/login/content/{kind}', response_model=FastUI, response_model_exclude_none=True) -async def auth_login_content( - kind: LoginKind, github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)] -) -> list[AnyComponent]: +def auth_login_content(kind: LoginKind) -> list[AnyComponent]: match kind: case 'password': return [ @@ -95,12 +92,11 @@ async def auth_login_content( c.ModelForm(model=LoginForm, submit_url='/api/auth/login'), ] case 'github': - auth_url = await github_auth.authorization_url() return [ c.Heading(text='GitHub Login', level=3), c.Paragraph(text='Demo of GitHub authentication.'), c.Paragraph(text='(Credentials are stored in the browser via a JWT only)'), - c.Button(text='Login with GitHub', on_click=GoToEvent(url=auth_url)), + c.Button(text='Login with GitHub', on_click=GoToEvent(url='/auth/login/github/gen')), ] case _: raise ValueError(f'Invalid kind {kind!r}') @@ -146,6 +142,12 @@ async def logout_form_post() -> list[AnyComponent]: return [c.FireEvent(event=AuthEvent(token=False, url='/auth/login/password'))] +@router.get('/login/github/gen', response_model=FastUI, response_model_exclude_none=True) +async def auth_github_gen(github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)]) -> list[AnyComponent]: + auth_url = await github_auth.authorization_url() + return [c.FireEvent(event=GoToEvent(url=auth_url))] + + @router.get('/login/github/redirect', response_model=FastUI, response_model_exclude_none=True) async def github_redirect( code: str, diff --git a/src/npm-fastui/src/events.ts b/src/npm-fastui/src/events.ts index 45300aac..55d9d90c 100644 --- a/src/npm-fastui/src/events.ts +++ b/src/npm-fastui/src/events.ts @@ -47,9 +47,9 @@ export function useFireEvent(): { fireEvent: (event?: AnyEvent) => void } { break case 'auth': if (event.token) { - sessionStorage.setItem(AUTH_TOKEN_KEY, event.token) + localStorage.setItem(AUTH_TOKEN_KEY, event.token) } else { - sessionStorage.removeItem(AUTH_TOKEN_KEY) + localStorage.removeItem(AUTH_TOKEN_KEY) } if (event.url) { location.goto(event.url) diff --git a/src/npm-fastui/src/tools.ts b/src/npm-fastui/src/tools.ts index dd698f2f..18b55535 100644 --- a/src/npm-fastui/src/tools.ts +++ b/src/npm-fastui/src/tools.ts @@ -101,7 +101,7 @@ async function request({ init.headers.set('Content-Type', contentType) } - const authToken = sessionStorage.getItem(AUTH_TOKEN_KEY) + const authToken = localStorage.getItem(AUTH_TOKEN_KEY) if (authToken) { // we use a custom auth-schema as well-known values like `Basic` and `Bearer` are not correct here init.headers.set('Authorization', `Token ${authToken}`) From a05e9fa158cfa640de8391b0a8ce7335eb11e9fa Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Sat, 10 Feb 2024 20:19:13 +0000 Subject: [PATCH 7/9] uprev frontend --- src/npm-fastui-bootstrap/package.json | 4 ++-- src/npm-fastui-prebuilt/package.json | 2 +- src/npm-fastui/package.json | 2 +- src/python-fastui/fastui/__init__.py | 2 +- uprev_npm_packages.py | 11 +++++++---- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/npm-fastui-bootstrap/package.json b/src/npm-fastui-bootstrap/package.json index ee2b80d2..7070b8ed 100644 --- a/src/npm-fastui-bootstrap/package.json +++ b/src/npm-fastui-bootstrap/package.json @@ -1,6 +1,6 @@ { "name": "@pydantic/fastui-bootstrap", - "version": "0.0.17", + "version": "0.0.18", "description": "Boostrap renderer for FastUI", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -29,6 +29,6 @@ "sass": "^1.69.5" }, "peerDependencies": { - "@pydantic/fastui": "0.0.17" + "@pydantic/fastui": "0.0.18" } } diff --git a/src/npm-fastui-prebuilt/package.json b/src/npm-fastui-prebuilt/package.json index bb02466a..4d04d982 100644 --- a/src/npm-fastui-prebuilt/package.json +++ b/src/npm-fastui-prebuilt/package.json @@ -1,6 +1,6 @@ { "name": "@pydantic/fastui-prebuilt", - "version": "0.0.17", + "version": "0.0.18", "description": "Pre-built files for FastUI", "main": "dist/index.html", "type": "module", diff --git a/src/npm-fastui/package.json b/src/npm-fastui/package.json index 0950ecb6..98ea7cca 100644 --- a/src/npm-fastui/package.json +++ b/src/npm-fastui/package.json @@ -1,6 +1,6 @@ { "name": "@pydantic/fastui", - "version": "0.0.17", + "version": "0.0.18", "description": "Build better UIs faster.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/python-fastui/fastui/__init__.py b/src/python-fastui/fastui/__init__.py index 294aeeee..df20f909 100644 --- a/src/python-fastui/fastui/__init__.py +++ b/src/python-fastui/fastui/__init__.py @@ -23,7 +23,7 @@ def coerce_to_list(cls, v): return [v] -_PREBUILT_VERSION = '0.0.17' +_PREBUILT_VERSION = '0.0.18' _PREBUILT_CDN_URL = f'https://cdn.jsdelivr.net/npm/@pydantic/fastui-prebuilt@{_PREBUILT_VERSION}/dist/assets' diff --git a/uprev_npm_packages.py b/uprev_npm_packages.py index eda71043..e317b427 100644 --- a/uprev_npm_packages.py +++ b/uprev_npm_packages.py @@ -5,9 +5,11 @@ def replace_package_json(package_json: Path, new_version: str, deps: bool = False) -> None: content = package_json.read_text() - content = re.sub(r'"version": *".*?"', f'"version": "{new_version}"', content) + content, r_count = re.subn(r'"version": *".*?"', f'"version": "{new_version}"', content, count=1) + assert r_count == 1 , f'Failed to update version in {package_json}, expect replacement count 1, got {r_count}' if deps: - content = re.sub(r'"(@pydantic/.+?)": *".*?"', fr'"\1": "{new_version}"', content) + content, r_count = re.subn(r'"(@pydantic/.+?)": *".*?"', fr'"\1": "{new_version}"', content) + assert r_count == 1, f'Failed to update version in {package_json}, expect replacement count 1, got {r_count}' package_json.write_text(content) @@ -23,11 +25,12 @@ def main(): bootstrap_package_json = this_dir / 'src/npm-fastui-bootstrap/package.json' replace_package_json(bootstrap_package_json, new_version, deps=True) prebuilt_package_json = this_dir / 'src/npm-fastui-prebuilt/package.json' - replace_package_json(prebuilt_package_json, new_version, deps=True) + replace_package_json(prebuilt_package_json, new_version) python_init = this_dir / 'src/python-fastui/fastui/__init__.py' python_content = python_init.read_text() - python_content = re.sub(r"(_PREBUILT_VERSION = )'.+'", fr"\1'{new_version}'", python_content) + python_content, r_count = re.subn(r"(_PREBUILT_VERSION = )'.+'", fr"\1'{new_version}'", python_content) + assert r_count == 1, f'Failed to update version in {python_init}, expect replacement count 1, got {r_count}' python_init.write_text(python_content) files = fastui_package_json, bootstrap_package_json, prebuilt_package_json, python_init From 97163efec4e03228078a4834618ce797c26b932f Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Sat, 10 Feb 2024 20:20:23 +0000 Subject: [PATCH 8/9] uprev frontend, again to 0.0.19 --- src/npm-fastui-bootstrap/package.json | 4 ++-- src/npm-fastui-prebuilt/package.json | 2 +- src/npm-fastui/package.json | 2 +- src/python-fastui/fastui/__init__.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/npm-fastui-bootstrap/package.json b/src/npm-fastui-bootstrap/package.json index 7070b8ed..15a34586 100644 --- a/src/npm-fastui-bootstrap/package.json +++ b/src/npm-fastui-bootstrap/package.json @@ -1,6 +1,6 @@ { "name": "@pydantic/fastui-bootstrap", - "version": "0.0.18", + "version": "0.0.19", "description": "Boostrap renderer for FastUI", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -29,6 +29,6 @@ "sass": "^1.69.5" }, "peerDependencies": { - "@pydantic/fastui": "0.0.18" + "@pydantic/fastui": "0.0.19" } } diff --git a/src/npm-fastui-prebuilt/package.json b/src/npm-fastui-prebuilt/package.json index 4d04d982..5d8adea9 100644 --- a/src/npm-fastui-prebuilt/package.json +++ b/src/npm-fastui-prebuilt/package.json @@ -1,6 +1,6 @@ { "name": "@pydantic/fastui-prebuilt", - "version": "0.0.18", + "version": "0.0.19", "description": "Pre-built files for FastUI", "main": "dist/index.html", "type": "module", diff --git a/src/npm-fastui/package.json b/src/npm-fastui/package.json index 98ea7cca..e2252f5d 100644 --- a/src/npm-fastui/package.json +++ b/src/npm-fastui/package.json @@ -1,6 +1,6 @@ { "name": "@pydantic/fastui", - "version": "0.0.18", + "version": "0.0.19", "description": "Build better UIs faster.", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/python-fastui/fastui/__init__.py b/src/python-fastui/fastui/__init__.py index df20f909..eef5ddea 100644 --- a/src/python-fastui/fastui/__init__.py +++ b/src/python-fastui/fastui/__init__.py @@ -23,7 +23,7 @@ def coerce_to_list(cls, v): return [v] -_PREBUILT_VERSION = '0.0.18' +_PREBUILT_VERSION = '0.0.19' _PREBUILT_CDN_URL = f'https://cdn.jsdelivr.net/npm/@pydantic/fastui-prebuilt@{_PREBUILT_VERSION}/dist/assets' From 58504c0a4c868060e64bba4227233002d9cd95ae Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Sat, 10 Feb 2024 20:28:30 +0000 Subject: [PATCH 9/9] fix bump_npm.py --- uprev_npm_packages.py => bump_npm.py | 31 +++++++++++++++++----------- 1 file changed, 19 insertions(+), 12 deletions(-) rename uprev_npm_packages.py => bump_npm.py (66%) diff --git a/uprev_npm_packages.py b/bump_npm.py similarity index 66% rename from uprev_npm_packages.py rename to bump_npm.py index e317b427..22e424b1 100644 --- a/uprev_npm_packages.py +++ b/bump_npm.py @@ -1,16 +1,19 @@ +from __future__ import annotations + import json import re from pathlib import Path -def replace_package_json(package_json: Path, new_version: str, deps: bool = False) -> None: +def replace_package_json(package_json: Path, new_version: str, deps: bool = False) -> tuple[Path, str]: content = package_json.read_text() content, r_count = re.subn(r'"version": *".*?"', f'"version": "{new_version}"', content, count=1) assert r_count == 1 , f'Failed to update version in {package_json}, expect replacement count 1, got {r_count}' if deps: content, r_count = re.subn(r'"(@pydantic/.+?)": *".*?"', fr'"\1": "{new_version}"', content) assert r_count == 1, f'Failed to update version in {package_json}, expect replacement count 1, got {r_count}' - package_json.write_text(content) + + return package_json, content def main(): @@ -21,28 +24,32 @@ def main(): rest, patch_version = old_version.rsplit('.', 1) new_version = f'{rest}.{int(patch_version) + 1}' - replace_package_json(fastui_package_json, new_version) bootstrap_package_json = this_dir / 'src/npm-fastui-bootstrap/package.json' - replace_package_json(bootstrap_package_json, new_version, deps=True) prebuilt_package_json = this_dir / 'src/npm-fastui-prebuilt/package.json' - replace_package_json(prebuilt_package_json, new_version) + to_update: list[tuple[Path, str]] = [ + replace_package_json(fastui_package_json, new_version), + replace_package_json(bootstrap_package_json, new_version, deps=True), + replace_package_json(prebuilt_package_json, new_version), + ] python_init = this_dir / 'src/python-fastui/fastui/__init__.py' python_content = python_init.read_text() python_content, r_count = re.subn(r"(_PREBUILT_VERSION = )'.+'", fr"\1'{new_version}'", python_content) assert r_count == 1, f'Failed to update version in {python_init}, expect replacement count 1, got {r_count}' - python_init.write_text(python_content) + to_update.append((python_init, python_content)) - files = fastui_package_json, bootstrap_package_json, prebuilt_package_json, python_init - files = '\n'.join(str(f.relative_to(this_dir)) for f in files) - print(f"""\ -Updated version from `{old_version}` to `{new_version}` in: + # logic is finished, no update all files + print(f'Updating files:') + for package_json, content in to_update: + print(f' {package_json.relative_to(this_dir)}') + package_json.write_text(content) -{files} + print(f""" +Bumped from `{old_version}` to `{new_version}` in {len(to_update)} files. To publish the new version, run: - npm --workspaces publish +> npm --workspaces publish """)