diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..0b84a243 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +# Keep GitHub Actions up to date with GitHub's Dependabot... +# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot +# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + groups: + github-actions: + patterns: + - "*" # Group all Actions updates into a single larger pull request + schedule: + interval: monthly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 325d8e55..d52f1711 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,13 +14,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: '3.11' - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 @@ -29,7 +29,7 @@ jobs: - run: npm install - - uses: pre-commit/action@v3.0.0 + - uses: pre-commit/action@v3.0.1 with: extra_args: --all-files env: @@ -50,18 +50,20 @@ jobs: OS: ${{ matrix.os }} steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: set up python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - run: pip install -r src/python-fastui/requirements/test.txt - run: pip install -r src/python-fastui/requirements/pyproject.txt - - run: pip install src/python-fastui + - run: pip install -e src/python-fastui - run: coverage run -m pytest src + # display coverage and fail if it's below 80%, which shouldn't happen + - run: coverage report --fail-under=80 # test demo on 3.11 and 3.12, these tests are intentionally omitted from coverage - if: matrix.python-version == '3.11' || matrix.python-version == '3.12' @@ -69,7 +71,7 @@ jobs: - run: coverage xml - - uses: codecov/codecov-action@v3 + - uses: codecov/codecov-action@v4 with: file: ./coverage.xml env_vars: PYTHON,OS @@ -78,9 +80,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v4 with: node-version: 18 @@ -110,9 +112,9 @@ jobs: id-token: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - - uses: actions/setup-python@v4 + - uses: actions/setup-python@v5 with: python-version: '3.11' diff --git a/LICENSE b/LICENSE index 286f4f19..e93c72cf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2023 to present Samuel Colvin +Copyright (c) 2023 to present Pydantic Services inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 666f1b26..5923b746 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ Building an application this way has a number of significant advantages: - You only need to write code in one place to build a new feature — add a new view, change the behavior of an existing view or alter the URL structure - Deploying the front and backend can be completely decoupled, provided the frontend knows how to render all the components the backend is going to ask it to use, you're good to go - You should be able to reuse a rich set of opensource components, they should end up being better tested and more reliable than anything you could build yourself, this is possible because the components need no context about how they're going to be used (note: since FastUI is brand new, this isn't true yet, hopefully we get there) -- We can use Pydantic, TypeScript and JSON Schema to provide guarantees that the two sides are communicating with an agreed schema (note: this is not complete yet, see [#18](https://github.com/pydantic/FastUI/issues/18)) +- We can use Pydantic, TypeScript and JSON Schema to provide guarantees that the two sides are communicating with an agreed schema In the abstract, FastUI is like the opposite of GraphQL but with the same goal — GraphQL lets frontend developers extend an application without any new backend development; FastUI lets backend developers extend an application without any new frontend development. diff --git a/bump_npm.py b/bump_npm.py new file mode 100755 index 00000000..9f8c8a85 --- /dev/null +++ b/bump_npm.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python +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) -> 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}' + + return package_json, 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}' + bootstrap_package_json = this_dir / 'src/npm-fastui-bootstrap/package.json' + prebuilt_package_json = this_dir / 'src/npm-fastui-prebuilt/package.json' + 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}' + to_update.append((python_init, python_content)) + + # 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) + + print(f""" +Bumped from `{old_version}` to `{new_version}` in {len(to_update)} files. + +To publish the new version, run: + +> npm --workspaces publish +""") + + +if __name__ == '__main__': + main() diff --git a/demo/__init__.py b/demo/__init__.py index 616139d2..3f58bf24 100644 --- a/demo/__init__.py +++ b/demo/__init__.py @@ -6,12 +6,12 @@ from fastapi import FastAPI from fastapi.responses import HTMLResponse, PlainTextResponse from fastui import prebuilt_html +from fastui.auth import fastapi_auth_exception_handling from fastui.dev import dev_fastapi_app from httpx import AsyncClient from .auth import router as auth_router from .components_list import router as components_router -from .db import create_db from .forms import router as forms_router from .main import router as main_router from .sse import router as sse_router @@ -20,7 +20,6 @@ @asynccontextmanager async def lifespan(app_: FastAPI): - await create_db() async with AsyncClient() as client: app_.state.httpx_client = client yield @@ -33,6 +32,7 @@ async def lifespan(app_: FastAPI): else: app = FastAPI(lifespan=lifespan) +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 b9ba8da5..065d222b 100644 --- a/demo/auth.py +++ b/demo/auth.py @@ -1,49 +1,111 @@ from __future__ import annotations as _annotations -from typing import Annotated +import asyncio +import json +import os +from dataclasses import asdict +from typing import Annotated, Literal, TypeAlias -from fastapi import APIRouter, Depends, Header +from fastapi import APIRouter, Depends, Request from fastui import AnyComponent, FastUI from fastui import components as c +from fastui.auth import AuthRedirect, GitHubAuthProvider from fastui.events import AuthEvent, GoToEvent, PageEvent from fastui.forms import fastui_form +from httpx import AsyncClient from pydantic import BaseModel, EmailStr, Field, SecretStr -from . import db +from .auth_user import User from .shared import demo_page 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')) +# use 'http://localhost:3000/auth/login/github/redirect' in development +GITHUB_REDIRECT = os.getenv('GITHUB_REDIRECT') + + +async def get_github_auth(request: Request) -> GitHubAuthProvider: + client: AsyncClient = request.app.state.httpx_client + return GitHubAuthProvider( + httpx_client=client, + github_client_id=GITHUB_CLIENT_ID, + github_client_secret=GITHUB_CLIENT_SECRET, + redirect_uri=GITHUB_REDIRECT, + scopes=['user:email'], + ) + + +LoginKind: TypeAlias = Literal['password', 'github'] -async def get_user(authorization: Annotated[str, Header()] = '') -> db.User | None: - try: - token = authorization.split(' ', 1)[1] - except IndexError: - return None - else: - return await db.get_user(token) - - -@router.get('/login', response_model=FastUI, response_model_exclude_none=True) -def auth_login(user: Annotated[str | None, Depends(get_user)]) -> list[AnyComponent]: - if user is None: - return demo_page( - c.Paragraph( - text=( - 'This is a very simple demo of authentication, ' - 'here you can "login" with any email address and password.' - ) - ), - c.Heading(text='Login'), - c.ModelForm(model=LoginForm, submit_url='/api/auth/login'), - title='Authentication', - ) - else: - return [c.FireEvent(event=GoToEvent(url='/auth/profile'))] + +@router.get('/login/{kind}', response_model=FastUI, response_model_exclude_none=True) +def auth_login( + kind: LoginKind, + user: Annotated[User | None, Depends(User.from_request_opt)], +) -> list[AnyComponent]: + 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=auth_login_content(kind), + ), + title='Authentication', + ) + + +@router.get('/login/content/{kind}', response_model=FastUI, response_model_exclude_none=True) +def auth_login_content(kind: LoginKind) -> list[AnyComponent]: + match kind: + case 'password': + return [ + c.Heading(text='Password Login', level=3), + c.Paragraph( + text=( + 'This is a very simple demo of password authentication, ' + 'here you can "login" with any email address and password.' + ) + ), + 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', display_mode='page'), + ] + case 'github': + 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/login/github/gen')), + ] + case _: + raise ValueError(f'Invalid kind {kind!r}') class LoginForm(BaseModel): - email: EmailStr = Field(title='Email Address', description='Enter whatever value you like') + email: EmailStr = Field( + title='Email Address', description='Enter whatever value you like', json_schema_extra={'autocomplete': 'email'} + ) password: SecretStr = Field( title='Password', description='Enter whatever value you like, password is not checked', @@ -53,31 +115,55 @@ class LoginForm(BaseModel): @router.post('/login', response_model=FastUI, response_model_exclude_none=True) async def login_form_post(form: Annotated[LoginForm, fastui_form(LoginForm)]) -> list[AnyComponent]: - token = await db.create_user(form.email) + user = User(email=form.email, extra={}) + token = user.encode_token() return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))] @router.get('/profile', response_model=FastUI, response_model_exclude_none=True) -async def profile(user: Annotated[db.User | None, Depends(get_user)]) -> list[AnyComponent]: - if user is None: - return [c.FireEvent(event=GoToEvent(url='/auth/login'))] - else: - active_count = await db.count_users() - return demo_page( - c.Paragraph(text=f'You are logged in as "{user.email}", {active_count} active users right now.'), - c.Button(text='Logout', on_click=PageEvent(name='submit-form')), - 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) -async def logout_form_post(user: Annotated[db.User | None, Depends(get_user)]) -> list[AnyComponent]: - if user is not None: - await db.delete_user(user) - return [c.FireEvent(event=AuthEvent(token=False, url='/auth/login'))] +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, + state: str | None, + github_auth: Annotated[GitHubAuthProvider, Depends(get_github_auth)], +) -> list[AnyComponent]: + exchange = await github_auth.exchange_code(code, state) + user_info, emails = await asyncio.gather( + github_auth.get_github_user(exchange), github_auth.get_github_user_emails(exchange) + ) + user = User( + email=next((e.email for e in emails if e.primary and e.verified), None), + extra={ + 'github_user_info': user_info.model_dump(), + 'github_emails': [e.model_dump() for e in emails], + }, + ) + token = user.encode_token() + return [c.FireEvent(event=AuthEvent(token=token, url='/auth/profile'))] diff --git a/demo/auth_user.py b/demo/auth_user.py new file mode 100644 index 00000000..0faf3fb0 --- /dev/null +++ b/demo/auth_user.py @@ -0,0 +1,55 @@ +import json +from dataclasses import asdict, dataclass +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' + + +@dataclass +class User: + email: str | None + extra: dict[str, Any] + + def encode_token(self) -> str: + payload = asdict(self) + payload['exp'] = datetime.now() + timedelta(hours=1) + return jwt.encode(payload, JWT_SECRET, algorithm='HS256', json_encoder=CustomJsonEncoder) + + @classmethod + 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: + payload = jwt.decode(token, JWT_SECRET, algorithms=['HS256']) + except jwt.ExpiredSignatureError: + return None + 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): + def default(self, obj: Any) -> Any: + if isinstance(obj, datetime): + return obj.isoformat() + else: + return super().default(obj) diff --git a/demo/components_list.py b/demo/components_list.py index 6e3622c1..e7ecd0c5 100644 --- a/demo/components_list.py +++ b/demo/components_list.py @@ -78,6 +78,10 @@ class Delivery(BaseModel): components=[c.Text(text='Pydantic (External link)')], on_click=GoToEvent(url='https://pydantic.dev'), ), + c.Link( + components=[c.Text(text='FastUI repo (New tab)')], + on_click=GoToEvent(url='https://github.com/pydantic/FastUI', target='_blank'), + ), ], ), ], @@ -88,6 +92,8 @@ class Delivery(BaseModel): c.Heading(text='Button and Modal', level=2), c.Paragraph(text='The button below will open a modal with static content.'), c.Button(text='Show Static Modal', on_click=PageEvent(name='static-modal')), + c.Button(text='Secondary Button', named_style='secondary', class_name='+ ms-2'), + c.Button(text='Warning Button', named_style='warning', class_name='+ ms-2'), c.Modal( title='Static Modal', body=[c.Paragraph(text='This is some static content that was set when the modal was defined.')], @@ -120,6 +126,56 @@ class Delivery(BaseModel): ], class_name='border-top mt-3 pt-1', ), + c.Div( + components=[ + c.Heading(text='Modal Form / Confirm prompt', level=2), + c.Markdown(text='The button below will open a modal with a form.'), + c.Button(text='Show Modal Form', on_click=PageEvent(name='modal-form')), + c.Modal( + title='Modal Form', + body=[ + c.Paragraph(text='Form inside a modal!'), + c.Form( + form_fields=[ + c.FormFieldInput(name='foobar', title='Foobar', required=True), + ], + submit_url='/api/components/modal-form', + footer=[], + submit_trigger=PageEvent(name='modal-form-submit'), + ), + ], + footer=[ + c.Button( + text='Cancel', named_style='secondary', on_click=PageEvent(name='modal-form', clear=True) + ), + c.Button(text='Submit', on_click=PageEvent(name='modal-form-submit')), + ], + open_trigger=PageEvent(name='modal-form'), + ), + c.Button(text='Show Modal Prompt', on_click=PageEvent(name='modal-prompt'), class_name='+ ms-2'), + c.Modal( + title='Form Prompt', + body=[ + c.Paragraph(text='Are you sure you want to do whatever?'), + c.Form( + form_fields=[], + submit_url='/api/components/modal-prompt', + loading=[c.Spinner(text='Okay, good luck...')], + footer=[], + submit_trigger=PageEvent(name='modal-form-submit'), + ), + ], + footer=[ + c.Button( + text='Cancel', named_style='secondary', on_click=PageEvent(name='modal-prompt', clear=True) + ), + c.Button(text='Submit', on_click=PageEvent(name='modal-form-submit')), + ], + open_trigger=PageEvent(name='modal-prompt'), + ), + ], + class_name='border-top mt-3 pt-1', + ), c.Div( components=[ c.Heading(text='Server Load', level=2), @@ -186,6 +242,19 @@ class Delivery(BaseModel): ], class_name='border-top mt-3 pt-1', ), + c.Div( + components=[ + c.Heading(text='Spinner', level=2), + c.Paragraph( + text=( + 'A component displayed while waiting for content to load, ' + 'this is also used automatically while loading server content.' + ) + ), + c.Spinner(text='Content incoming...'), + ], + class_name='border-top mt-3 pt-1', + ), c.Div( components=[ c.Heading(text='Video', level=2), @@ -221,3 +290,15 @@ class Delivery(BaseModel): async def modal_view() -> list[AnyComponent]: await asyncio.sleep(0.5) return [c.Paragraph(text='This is some dynamic content. Open devtools to see me being fetched from the server.')] + + +@router.post('/modal-form', response_model=FastUI, response_model_exclude_none=True) +async def modal_form_submit() -> list[AnyComponent]: + await asyncio.sleep(0.5) + return [c.FireEvent(event=PageEvent(name='modal-form', clear=True))] + + +@router.post('/modal-prompt', response_model=FastUI, response_model_exclude_none=True) +async def modal_prompt_submit() -> list[AnyComponent]: + await asyncio.sleep(0.5) + return [c.FireEvent(event=PageEvent(name='modal-prompt', clear=True))] diff --git a/demo/db.py b/demo/db.py deleted file mode 100644 index c3932518..00000000 --- a/demo/db.py +++ /dev/null @@ -1,73 +0,0 @@ -import os -import secrets -from contextlib import asynccontextmanager -from dataclasses import dataclass -from datetime import datetime - -import libsql_client - - -@dataclass -class User: - token: str - email: str - last_active: datetime - - -async def get_user(token: str) -> User | None: - async with _connect() as conn: - rs = await conn.execute('select * from users where token = ?', (token,)) - if rs.rows: - await conn.execute('update users set last_active = current_timestamp where token = ?', (token,)) - return User(*rs.rows[0]) - - -async def create_user(email: str) -> str: - async with _connect() as conn: - await _delete_old_users(conn) - token = secrets.token_hex() - await conn.execute('insert into users (token, email) values (?, ?)', (token, email)) - return token - - -async def delete_user(user: User) -> None: - async with _connect() as conn: - await conn.execute('delete from users where token = ?', (user.token,)) - - -async def count_users() -> int: - async with _connect() as conn: - await _delete_old_users(conn) - rs = await conn.execute('select count(*) from users') - return rs.rows[0][0] - - -async def create_db() -> None: - async with _connect() as conn: - rs = await conn.execute("select 1 from sqlite_master where type='table' and name='users'") - if not rs.rows: - await conn.execute(SCHEMA) - - -SCHEMA = """ -create table if not exists users ( - token varchar(255) primary key, - email varchar(255) not null unique, - last_active timestamp not null default current_timestamp -); -""" - - -async def _delete_old_users(conn: libsql_client.Client) -> None: - await conn.execute('delete from users where last_active < datetime(current_timestamp, "-1 hour")') - - -@asynccontextmanager -async def _connect() -> libsql_client.Client: - auth_token = os.getenv('SQLITE_AUTH_TOKEN') - if auth_token: - url = 'libsql://fastui-samuelcolvin.turso.io' - else: - url = 'file:users.db' - async with libsql_client.create_client(url, auth_token=auth_token) as conn: - yield conn diff --git a/demo/forms.py b/demo/forms.py index c94a60db..886ae77e 100644 --- a/demo/forms.py +++ b/demo/forms.py @@ -9,7 +9,7 @@ from fastui import AnyComponent, FastUI from fastui import components as c from fastui.events import GoToEvent, PageEvent -from fastui.forms import FormFile, SelectSearchResponse, fastui_form +from fastui.forms import FormFile, SelectSearchResponse, Textarea, fastui_form from httpx import AsyncClient from pydantic import BaseModel, EmailStr, Field, SecretStr, field_validator from pydantic_core import PydanticCustomError @@ -85,19 +85,19 @@ def form_content(kind: FormKind): return [ c.Heading(text='Login Form', level=2), c.Paragraph(text='Simple login form with email and password.'), - c.ModelForm(model=LoginForm, submit_url='/api/forms/login'), + c.ModelForm(model=LoginForm, display_mode='page', submit_url='/api/forms/login'), ] case 'select': return [ c.Heading(text='Select Form', level=2), c.Paragraph(text='Form showing different ways of doing select.'), - c.ModelForm(model=SelectForm, submit_url='/api/forms/select'), + c.ModelForm(model=SelectForm, display_mode='page', submit_url='/api/forms/select'), ] case 'big': return [ c.Heading(text='Large Form', level=2), c.Paragraph(text='Form with a lot of fields.'), - c.ModelForm(model=BigModel, submit_url='/api/forms/big'), + c.ModelForm(model=BigModel, display_mode='page', submit_url='/api/forms/big'), ] case _: raise ValueError(f'Invalid kind {kind!r}') @@ -143,6 +143,7 @@ class BigModel(BaseModel): name: str | None = Field( None, description='This field is not required, it must start with a capital letter if provided' ) + info: Annotated[str | None, Textarea(rows=5)] = Field(None, description='Optional free text information about you.') profile_pic: Annotated[UploadFile, FormFile(accept='image/*', max_size=16_000)] = Field( description='Upload a profile picture, must not be more than 16kb' ) diff --git a/demo/main.py b/demo/main.py index 1671b10d..cdba7e22 100644 --- a/demo/main.py +++ b/demo/main.py @@ -37,6 +37,10 @@ def api_index() -> list[AnyComponent]: * `Table` — See [cities table](/table/cities) and [users table](/table/users) * `Pagination` — See the bottom of the [cities table](/table/cities) * `ModelForm` — See [forms](/forms/login) + +Authentication is supported via: +* token based authentication — see [here](/auth/login/password) for an example of password authentication +* GitHub OAuth — see [here](/auth/login/github) for an example of GitHub OAuth login """ return demo_page(c.Markdown(text=markdown)) diff --git a/demo/shared.py b/demo/shared.py index 7b7263c7..70b44de4 100644 --- a/demo/shared.py +++ b/demo/shared.py @@ -11,7 +11,7 @@ def demo_page(*components: AnyComponent, title: str | None = None) -> list[AnyCo c.Navbar( title='FastUI Demo', title_event=GoToEvent(url='/'), - links=[ + start_links=[ c.Link( components=[c.Text(text='Components')], on_click=GoToEvent(url='/components'), @@ -24,7 +24,7 @@ def demo_page(*components: AnyComponent, title: str | None = None) -> list[AnyCo ), c.Link( components=[c.Text(text='Auth')], - on_click=GoToEvent(url='/auth/login'), + on_click=GoToEvent(url='/auth/login/password'), active='startswith:/auth', ), c.Link( diff --git a/demo/sse.py b/demo/sse.py index 068952c4..bcdb34b2 100644 --- a/demo/sse.py +++ b/demo/sse.py @@ -13,18 +13,11 @@ async def canned_ai_response_generator() -> AsyncIterable[str]: prompt = '**User:** What is SSE? Please include a javascript code example.\n\n**AI:** ' output = '' - msg = '' for time, text in chain([(0.5, prompt)], CANNED_RESPONSE): await asyncio.sleep(time) output += text m = FastUI(root=[c.Markdown(text=output)]) - msg = f'data: {m.model_dump_json(by_alias=True, exclude_none=True)}\n\n' - yield msg - - # avoid the browser reconnecting - while True: - yield msg - await asyncio.sleep(10) + yield f'data: {m.model_dump_json(by_alias=True, exclude_none=True)}\n\n' @router.get('/sse') diff --git a/demo/tests.py b/demo/tests.py index a6c02161..cda30982 100644 --- a/demo/tests.py +++ b/demo/tests.py @@ -6,17 +6,21 @@ from . import app -client = TestClient(app) +@pytest.fixture +def client(): + with TestClient(app) as test_client: + yield test_client -def test_index(): + +def test_index(client: TestClient): r = client.get('/') assert r.status_code == 200, r.text assert r.text.startswith('\n') assert r.headers.get('content-type') == 'text/html; charset=utf-8' -def test_api_root(): +def test_api_root(client: TestClient): r = client.get('/api/') assert r.status_code == 200 data = r.json() @@ -28,7 +32,8 @@ def test_api_root(): { 'title': 'FastUI Demo', 'titleEvent': {'url': '/', 'type': 'go-to'}, - 'links': IsList(length=4), + 'startLinks': IsList(length=4), + 'endLinks': [], 'type': 'Navbar', }, { @@ -52,16 +57,17 @@ def get_menu_links(): """ This is pretty cursory, we just go through the menu and load each page. """ - r = client.get('/api/') - assert r.status_code == 200 - data = r.json() - for link in data[1]['links']: - url = link['onClick']['url'] - yield pytest.param(f'/api{url}', id=url) + with TestClient(app) as client: + r = client.get('/api/') + assert r.status_code == 200 + data = r.json() + for link in data[1]['startLinks']: + url = link['onClick']['url'] + yield pytest.param(f'/api{url}', id=url) @pytest.mark.parametrize('url', get_menu_links()) -def test_menu_links(url: str): +def test_menu_links(client: TestClient, url: str): r = client.get(url) assert r.status_code == 200 data = r.json() diff --git a/package-lock.json b/package-lock.json index 3b287145..1c67808a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -829,6 +829,11 @@ "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", "dev": true }, + "node_modules/@microsoft/fetch-event-source": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@microsoft/fetch-event-source/-/fetch-event-source-2.0.1.tgz", + "integrity": "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==" + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -6702,14 +6707,13 @@ } }, "node_modules/vite": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.0.tgz", - "integrity": "sha512-ESJVM59mdyGpsiNAeHQOR/0fqNoOyWPYesFto8FFZugfmhdHx8Fzd8sF3Q/xkVhZsyOxHfdM7ieiVAorI9RjFw==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.12.tgz", + "integrity": "sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w==", "dev": true, - "peer": true, "dependencies": { "esbuild": "^0.19.3", - "postcss": "^8.4.31", + "postcss": "^8.4.32", "rollup": "^4.2.0" }, "bin": { @@ -6950,9 +6954,10 @@ }, "src/npm-fastui": { "name": "@pydantic/fastui", - "version": "0.0.14", + "version": "0.0.21", "license": "MIT", "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.1", @@ -6966,7 +6971,7 @@ }, "src/npm-fastui-bootstrap": { "name": "@pydantic/fastui-bootstrap", - "version": "0.0.14", + "version": "0.0.21", "license": "MIT", "dependencies": { "bootstrap": "^5.3.2", @@ -6976,71 +6981,16 @@ "sass": "^1.69.5" }, "peerDependencies": { - "@pydantic/fastui": "0.0.14" + "@pydantic/fastui": "0.0.21" } }, "src/npm-fastui-prebuilt": { "name": "@pydantic/fastui-prebuilt", - "version": "0.0.14", + "version": "0.0.21", "license": "MIT", "devDependencies": { "@vitejs/plugin-react-swc": "^3.3.2", - "vite": "^5.0.7" - } - }, - "src/npm-fastui-prebuilt/node_modules/vite": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.0.7.tgz", - "integrity": "sha512-B4T4rJCDPihrQo2B+h1MbeGL/k/GMAHzhQ8S0LjQ142s6/+l3hHTT095ORvsshj4QCkoWu3Xtmob5mazvakaOw==", - "dev": true, - "dependencies": { - "esbuild": "^0.19.3", - "postcss": "^8.4.32", - "rollup": "^4.2.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } + "vite": "^5.0.12" } } } diff --git a/pyproject.toml b/pyproject.toml index cae01196..d4518502 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,3 +26,15 @@ omit = [ "src/python-fastui/fastui/__main__.py", "src/python-fastui/fastui/generate_typescript.py", ] + +[tool.coverage.report] +precision = 2 +exclude_lines = [ + 'pragma: no cover', + 'raise NotImplementedError', + 'if TYPE_CHECKING:', + 'if typing.TYPE_CHECKING:', + '@overload', + '@typing.overload', + '\(Protocol\):$', +] diff --git a/src/npm-fastui-bootstrap/LICENSE b/src/npm-fastui-bootstrap/LICENSE index 286f4f19..e93c72cf 100644 --- a/src/npm-fastui-bootstrap/LICENSE +++ b/src/npm-fastui-bootstrap/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2023 to present Samuel Colvin +Copyright (c) 2023 to present Pydantic Services inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/npm-fastui-bootstrap/package.json b/src/npm-fastui-bootstrap/package.json index f48496e0..9827558e 100644 --- a/src/npm-fastui-bootstrap/package.json +++ b/src/npm-fastui-bootstrap/package.json @@ -1,7 +1,7 @@ { "name": "@pydantic/fastui-bootstrap", - "version": "0.0.15", - "description": "Boostrap renderer for FastUI", + "version": "0.0.22", + "description": "Bootstrap renderer for FastUI", "main": "dist/index.js", "types": "dist/index.d.ts", "author": "Samuel Colvin", @@ -29,6 +29,6 @@ "sass": "^1.69.5" }, "peerDependencies": { - "@pydantic/fastui": "0.0.15" + "@pydantic/fastui": "0.0.22" } } diff --git a/src/npm-fastui-bootstrap/src/index.tsx b/src/npm-fastui-bootstrap/src/index.tsx index cf355c90..2a9f01e5 100644 --- a/src/npm-fastui-bootstrap/src/index.tsx +++ b/src/npm-fastui-bootstrap/src/index.tsx @@ -31,7 +31,12 @@ export const classNameGenerator: ClassNameGenerator = ({ case 'Page': return 'container mt-80 mb-3 page' case 'Button': - return 'btn btn-primary' + return { + btn: true, + 'btn-primary': !props.namedStyle || props.namedStyle === 'primary', + 'btn-secondary': props.namedStyle === 'secondary', + 'btn-warning': props.namedStyle === 'warning', + } case 'Table': switch (subElement) { case 'no-data-message': @@ -57,20 +62,24 @@ export const classNameGenerator: ClassNameGenerator = ({ default: return 'row row-cols-lg-4 align-items-center justify-content-end' } - } else { + } else if (props.displayMode === 'page') { switch (subElement) { case 'form-container': return 'row justify-content-center' default: return 'col-md-4' } + } else { + break } case 'FormFieldInput': + case 'FormFieldTextarea': case 'FormFieldBoolean': case 'FormFieldSelect': case 'FormFieldSelectSearch': case 'FormFieldFile': switch (subElement) { + case 'textarea': case 'input': return { 'form-control': type !== 'FormFieldBoolean', @@ -137,5 +146,19 @@ export const classNameGenerator: ClassNameGenerator = ({ } case 'Code': return 'rounded' + case 'Error': + if (props.statusCode === 502) { + return 'm-3 text-muted' + } else { + return 'error-alert alert alert-danger m-3' + } + case 'Spinner': + if (subElement === 'text') { + return 'd-flex justify-content-center mb-2' + } else if (subElement === 'animation') { + return 'd-flex justify-content-center' + } else { + return 'my-4' + } } } diff --git a/src/npm-fastui-bootstrap/src/navbar.tsx b/src/npm-fastui-bootstrap/src/navbar.tsx index 4e84faf6..3f5adec8 100644 --- a/src/npm-fastui-bootstrap/src/navbar.tsx +++ b/src/npm-fastui-bootstrap/src/navbar.tsx @@ -3,7 +3,11 @@ import { components, useClassName, models } from 'fastui' import BootstrapNavbar from 'react-bootstrap/Navbar' export const Navbar: FC = (props) => { - const links = props.links.map((link) => { + const startLinks = props.startLinks.map((link) => { + link.mode = link.mode || 'navbar' + return link + }) + const endLinks = props.endLinks.map((link) => { link.mode = link.mode || 'navbar' return link }) @@ -14,7 +18,14 @@ export const Navbar: FC = (props) => {
    - {links.map((link, i) => ( + {startLinks.map((link, i) => ( +
  • + +
  • + ))} +
+
    + {endLinks.map((link, i) => (
  • diff --git a/src/npm-fastui-prebuilt/LICENSE b/src/npm-fastui-prebuilt/LICENSE index 286f4f19..e93c72cf 100644 --- a/src/npm-fastui-prebuilt/LICENSE +++ b/src/npm-fastui-prebuilt/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2023 to present Samuel Colvin +Copyright (c) 2023 to present Pydantic Services inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/npm-fastui-prebuilt/package.json b/src/npm-fastui-prebuilt/package.json index f404b454..fbb58e1c 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.15", + "version": "0.0.22", "description": "Pre-built files for FastUI", "main": "dist/index.html", "type": "module", @@ -23,6 +23,6 @@ }, "devDependencies": { "@vitejs/plugin-react-swc": "^3.3.2", - "vite": "^5.0.7" + "vite": "^5.0.12" } } diff --git a/src/npm-fastui-prebuilt/src/App.tsx b/src/npm-fastui-prebuilt/src/App.tsx index 398cfb5c..c43cfdd4 100644 --- a/src/npm-fastui-prebuilt/src/App.tsx +++ b/src/npm-fastui-prebuilt/src/App.tsx @@ -5,16 +5,21 @@ import { FC, ReactNode } from 'react' export default function App() { return ( ) } +function getMetaContent(name: string): string | undefined { + return document.querySelector(`meta[name="${name}"]`)?.getAttribute('content') || undefined +} + const NotFound = ({ url }: { url: string }) => (

    Page not found

    @@ -24,12 +29,6 @@ const NotFound = ({ url }: { url: string }) => (
    ) -const Spinner = () => ( -
    -
    -
    -) - const Transition: FC<{ children: ReactNode; transitioning: boolean }> = ({ children, transitioning }) => ( <>
    diff --git a/src/npm-fastui-prebuilt/src/main.scss b/src/npm-fastui-prebuilt/src/main.scss index 1a1e07fc..98e27566 100644 --- a/src/npm-fastui-prebuilt/src/main.scss +++ b/src/npm-fastui-prebuilt/src/main.scss @@ -1,9 +1,12 @@ $primary: black; +$secondary: white; $link-color: #0d6efd; // bootstrap primary @import 'bootstrap/scss/bootstrap'; -html, body, #root { +html, +body, +#root { height: 100%; } @@ -33,7 +36,12 @@ body { backdrop-filter: blur(8px); } -h1, h2, h3, h4, h5, h6 { +h1, +h2, +h3, +h4, +h5, +h6 { scroll-margin-top: 60px; } @@ -66,17 +74,17 @@ h1, h2, h3, h4, h5, h6 { } // custom spinner from https://cssloaders.github.io/ - -.spinner, -.spinner:before, -.spinner:after { +.fastui-spinner-animation, +.fastui-spinner-animation:before, +.fastui-spinner-animation:after { border-radius: 50%; width: 2.5em; height: 2.5em; animation-fill-mode: both; - animation: dots 1.8s infinite ease-in-out; + animation: spinner-dots 1.8s infinite ease-in-out; } -.spinner { +.fastui-spinner-animation { + top: -2.5em; color: var(--bs-dark); font-size: 7px; position: relative; @@ -97,8 +105,7 @@ h1, h2, h3, h4, h5, h6 { left: 3.5em; } } - -@keyframes dots { +@keyframes spinner-dots { 0%, 80%, 100% { @@ -108,3 +115,13 @@ h1, h2, h3, h4, h5, h6 { box-shadow: 0 2.5em 0 0; } } + +// make sure alerts aren't hidden behind the navbar +.error-alert { + position: relative; + top: 60px; +} + +.btn-secondary { + --bs-btn-border-color: #dee2e6; +} diff --git a/src/npm-fastui-prebuilt/vite.config.ts b/src/npm-fastui-prebuilt/vite.config.ts index c73c49a1..ee73325c 100644 --- a/src/npm-fastui-prebuilt/vite.config.ts +++ b/src/npm-fastui-prebuilt/vite.config.ts @@ -1,14 +1,25 @@ import path from 'path' import react from '@vitejs/plugin-react-swc' -import { defineConfig } from 'vite' +import { defineConfig, HttpProxy } from 'vite' export default () => { const serverConfig = { host: true, port: 3000, proxy: { - '/api': 'http://localhost:8000', + '/api': { + target: 'http://localhost:8000', + configure: (proxy: HttpProxy.Server) => { + proxy.on('error', (err, _, res) => { + const { code } = err as any + if (code === 'ECONNREFUSED') { + res.writeHead(502, { 'content-type': 'text/plain' }) + res.end('vite-proxy: Proxy connection refused') + } + }) + }, + }, }, } diff --git a/src/npm-fastui/LICENSE b/src/npm-fastui/LICENSE index 286f4f19..e93c72cf 100644 --- a/src/npm-fastui/LICENSE +++ b/src/npm-fastui/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2023 to present Samuel Colvin +Copyright (c) 2023 to present Pydantic Services inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/npm-fastui/package.json b/src/npm-fastui/package.json index bfafd41d..24766831 100644 --- a/src/npm-fastui/package.json +++ b/src/npm-fastui/package.json @@ -1,6 +1,6 @@ { "name": "@pydantic/fastui", - "version": "0.0.15", + "version": "0.0.22", "description": "Build better UIs faster.", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -21,6 +21,7 @@ "typewatch": "tsc --noEmit --watch" }, "dependencies": { + "@microsoft/fetch-event-source": "^2.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^9.0.1", diff --git a/src/npm-fastui/src/Defaults.tsx b/src/npm-fastui/src/Defaults.tsx index 3b636da5..a8469a61 100644 --- a/src/npm-fastui/src/Defaults.tsx +++ b/src/npm-fastui/src/Defaults.tsx @@ -1,7 +1,5 @@ import { FC, ReactNode } from 'react' -export const DefaultSpinner: FC = () =>
    loading...
    - export const DefaultNotFound: FC<{ url: string }> = ({ url }) =>
    Page not found: {url}
    // default here does nothing diff --git a/src/npm-fastui/src/components/Custom.tsx b/src/npm-fastui/src/components/Custom.tsx index 8ef1fb5f..f875fbaf 100644 --- a/src/npm-fastui/src/components/Custom.tsx +++ b/src/npm-fastui/src/components/Custom.tsx @@ -1,14 +1,13 @@ -import { FC, useContext } from 'react' +import { FC } from 'react' import type { Custom } from '../models' -import { ErrorContext } from '../hooks/error' +import { DisplayError } from '../hooks/error' import { JsonComp } from './Json' export const CustomComp: FC = (props) => { const { data, subType, library } = props - const { DisplayError } = useContext(ErrorContext) const description = [`The custom component "${subType}"`] if (library) { diff --git a/src/npm-fastui/src/components/FireEvent.tsx b/src/npm-fastui/src/components/FireEvent.tsx index 5949c122..7b4aa23c 100644 --- a/src/npm-fastui/src/components/FireEvent.tsx +++ b/src/npm-fastui/src/components/FireEvent.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useRef } from 'react' +import { FC, useEffect } from 'react' import type { FireEvent } from '../models' @@ -6,15 +6,12 @@ import { useFireEvent } from '../events' export const FireEventComp: FC = ({ event, message }) => { const { fireEvent } = useFireEvent() - const fireEventRef = useRef(fireEvent) useEffect(() => { - fireEventRef.current = fireEvent - }, [fireEvent]) - - useEffect(() => { - fireEventRef.current(event) - }, [event, fireEventRef]) + // debounce the event so changes to fireEvent (from location changes) don't trigger the event many times + const clear = setTimeout(() => fireEvent(event), 50) + return () => clearTimeout(clear) + }, [fireEvent, event]) return <>{message} } diff --git a/src/npm-fastui/src/components/FormField.tsx b/src/npm-fastui/src/components/FormField.tsx index 31eeca50..d1b30c78 100644 --- a/src/npm-fastui/src/components/FormField.tsx +++ b/src/npm-fastui/src/components/FormField.tsx @@ -4,6 +4,7 @@ import Select, { StylesConfig } from 'react-select' import type { FormFieldInput, + FormFieldTextarea, FormFieldBoolean, FormFieldFile, FormFieldSelect, @@ -23,7 +24,7 @@ interface FormFieldInputProps extends FormFieldInput { } export const FormFieldInputComp: FC = (props) => { - const { name, placeholder, required, htmlType, locked } = props + const { name, placeholder, required, htmlType, locked, autocomplete } = props return (
    @@ -37,6 +38,7 @@ export const FormFieldInputComp: FC = (props) => { required={required} disabled={locked} placeholder={placeholder} + autoComplete={autocomplete} aria-describedby={descId(props)} /> @@ -44,6 +46,33 @@ export const FormFieldInputComp: FC = (props) => { ) } +interface FormFieldTextareaProps extends FormFieldTextarea { + onChange?: PrivateOnChange +} + +export const FormFieldTextareaComp: FC = (props) => { + const { name, placeholder, required, locked, rows, cols, autocomplete } = props + return ( +
    +