bazaar-contrib-server 0.3.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. bazaar_contrib_server/__init__.py +112 -0
  2. bazaar_contrib_server/clock.py +40 -0
  3. bazaar_contrib_server/contracts.py +196 -0
  4. bazaar_contrib_server/i18n.py +84 -0
  5. bazaar_contrib_server/markdown.py +83 -0
  6. bazaar_contrib_server/pages.py +226 -0
  7. bazaar_contrib_server/py.typed +0 -0
  8. bazaar_contrib_server/refs.py +94 -0
  9. bazaar_contrib_server/resources/locales/en.toml +23 -0
  10. bazaar_contrib_server/resources/locales/zh-CN.toml +23 -0
  11. bazaar_contrib_server/resources/static/THIRD_PARTY.md +58 -0
  12. bazaar_contrib_server/resources/static/alpine-3.17.4.min.js +21 -0
  13. bazaar_contrib_server/resources/static/alpine-LICENSE.txt +21 -0
  14. bazaar_contrib_server/resources/static/alpine-collapse-3.17.4.min.js +1 -0
  15. bazaar_contrib_server/resources/static/alpine-collapse-LICENSE.txt +21 -0
  16. bazaar_contrib_server/resources/static/alpine-focus-3.17.4.min.js +18 -0
  17. bazaar_contrib_server/resources/static/alpine-focus-LICENSE.txt +21 -0
  18. bazaar_contrib_server/resources/static/base.css +175 -0
  19. bazaar_contrib_server/resources/static/embed.js +86 -0
  20. bazaar_contrib_server/resources/static/favicon.svg +9 -0
  21. bazaar_contrib_server/resources/static/fonts/fraunces-LICENSE.txt +93 -0
  22. bazaar_contrib_server/resources/static/fonts/fraunces-latin-full-italic.woff2 +0 -0
  23. bazaar_contrib_server/resources/static/fonts/fraunces-latin-full-normal.woff2 +0 -0
  24. bazaar_contrib_server/resources/static/fonts/inter-LICENSE.txt +93 -0
  25. bazaar_contrib_server/resources/static/fonts/inter-latin-wght-normal.woff2 +0 -0
  26. bazaar_contrib_server/resources/static/fonts/jetbrains-mono-LICENSE.txt +93 -0
  27. bazaar_contrib_server/resources/static/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  28. bazaar_contrib_server/resources/static/htmx-LICENSE.txt +13 -0
  29. bazaar_contrib_server/resources/static/htmx.min.js +1 -0
  30. bazaar_contrib_server/resources/static/hx-alpine-compat-4.0.0.js +110 -0
  31. bazaar_contrib_server/resources/static/shell.css +45 -0
  32. bazaar_contrib_server/resources/templates/server/base.html +27 -0
  33. bazaar_contrib_server/resources/templates/server/code.html +1 -0
  34. bazaar_contrib_server/resources/templates/server/error.html +8 -0
  35. bazaar_contrib_server/resources/templates/server/icons/chevron-left.svg +15 -0
  36. bazaar_contrib_server/resources/templates/server/icons/chevron-right.svg +1 -0
  37. bazaar_contrib_server/resources/templates/server/icons/layout-dashboard.svg +1 -0
  38. bazaar_contrib_server/resources/templates/server/icons/log-out.svg +1 -0
  39. bazaar_contrib_server/resources/templates/server/icons/settings.svg +1 -0
  40. bazaar_contrib_server/resources/templates/server/icons.html +3 -0
  41. bazaar_contrib_server/resources/templates/server/image.html +1 -0
  42. bazaar_contrib_server/resources/templates/server/mark.svg +9 -0
  43. bazaar_contrib_server/resources/templates/server/overview.html +5 -0
  44. bazaar_contrib_server/resources/templates/server/overview_board.html +17 -0
  45. bazaar_contrib_server/resources/templates/server/settings.html +16 -0
  46. bazaar_contrib_server/resources/templates/server/shell.html +36 -0
  47. bazaar_contrib_server/resources/templates/server/theme.css +9 -0
  48. bazaar_contrib_server/resources/themes/day.toml +41 -0
  49. bazaar_contrib_server/resources/themes/night.toml +37 -0
  50. bazaar_contrib_server/schema.py +41 -0
  51. bazaar_contrib_server/shell.py +505 -0
  52. bazaar_contrib_server/themes.py +48 -0
  53. bazaar_contrib_server-0.3.0rc1.dist-info/METADATA +88 -0
  54. bazaar_contrib_server-0.3.0rc1.dist-info/RECORD +57 -0
  55. bazaar_contrib_server-0.3.0rc1.dist-info/WHEEL +4 -0
  56. bazaar_contrib_server-0.3.0rc1.dist-info/entry_points.txt +5 -0
  57. bazaar_contrib_server-0.3.0rc1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,112 @@
1
+ """The server itself: the shell modules put their pages in, its registrations,
2
+ the themes, and the two themes it comes with."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from contextlib import asynccontextmanager
7
+ lazy import asyncio
8
+ lazy import tomllib
9
+ lazy from collections.abc import AsyncIterator
10
+ lazy from importlib.resources import files
11
+
12
+ from bazaar_contrib_http import HTTP
13
+ from bazaar_contrib_server_database import SERVER_DB, SERVER_SCHEMA
14
+ from bazaar_contrib_server_migrations import SERVER_MIGRATIONS
15
+ from bazaar_core import Context, Contrib
16
+ lazy from starlette.exceptions import HTTPException
17
+ lazy from starlette.requests import HTTPConnection
18
+
19
+ from .contracts import (
20
+ SERVER,
21
+ THEME,
22
+ Account,
23
+ Login,
24
+ OverviewItem,
25
+ PermissionItem,
26
+ Server,
27
+ SettingsItem,
28
+ SidebarItem,
29
+ Theme,
30
+ Themes,
31
+ Tokens,
32
+ )
33
+ lazy from .i18n import LANGUAGES, Translator
34
+ lazy from .pages import Pages
35
+ lazy from .refs import Refs
36
+ lazy from .schema import MODULE
37
+ lazy from .shell import Shell
38
+
39
+ __all__ = [
40
+ "LANGUAGES",
41
+ "SERVER",
42
+ "THEME",
43
+ "Account",
44
+ "Login",
45
+ "OverviewItem",
46
+ "Pages",
47
+ "PermissionItem",
48
+ "Refs",
49
+ "Server",
50
+ "SettingsItem",
51
+ "SidebarItem",
52
+ "Theme",
53
+ "Themes",
54
+ "Tokens",
55
+ "Translator",
56
+ "account",
57
+ "day",
58
+ "night",
59
+ "permitted",
60
+ "server",
61
+ ]
62
+
63
+
64
+ def account(connection: HTTPConnection) -> Account:
65
+ """The account a request or a WebSocket behind the login is from; a
66
+ FastAPI dependency."""
67
+ return connection.state.account
68
+
69
+
70
+ def permitted(point: str):
71
+ """A FastAPI dependency refusing accounts without the permission point."""
72
+
73
+ def check(connection: HTTPConnection) -> None:
74
+ if not account(connection).allows(point):
75
+ raise HTTPException(status_code=403)
76
+
77
+ return check
78
+
79
+
80
+ @asynccontextmanager
81
+ async def _server(ctx: Context) -> AsyncIterator[None]:
82
+ async with ctx.accept(SERVER_SCHEMA).register(MODULE):
83
+ shell = await Shell.open(ctx.accept(HTTP), ctx.accept(SERVER_DB))
84
+ async with shell.serving():
85
+ ctx.offer(SERVER, shell)
86
+ ctx.offer(THEME, shell.themes)
87
+ yield
88
+
89
+
90
+ def _theme(name: str) -> Contrib:
91
+ """A theme plugin for the theme in resources/themes/<name>.toml."""
92
+
93
+ @asynccontextmanager
94
+ async def lifespan(ctx: Context) -> AsyncIterator[None]:
95
+ path = files(__name__) / "resources" / "themes" / f"{name}.toml"
96
+ theme = Theme.model_validate(
97
+ tomllib.loads(await asyncio.to_thread(path.read_text, encoding="utf-8"))
98
+ )
99
+ async with ctx.accept(THEME).register(theme):
100
+ yield
101
+
102
+ return Contrib(accepts=[THEME], lifespan=lifespan)
103
+
104
+
105
+ server = Contrib(
106
+ accepts=[HTTP, SERVER_DB, SERVER_SCHEMA, SERVER_MIGRATIONS],
107
+ offers=[SERVER, THEME],
108
+ lifespan=_server,
109
+ )
110
+ # the night market and the morning market of the approved design
111
+ night = _theme("night")
112
+ day = _theme("day")
@@ -0,0 +1,40 @@
1
+ """The server's clock, and moments as the viewer reads them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ lazy from datetime import datetime, tzinfo
6
+ lazy from time import time_ns
7
+ lazy from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
8
+
9
+
10
+ def now_ms() -> int:
11
+ return time_ns() // 1_000_000
12
+
13
+
14
+ def zone(name: str | None) -> tzinfo | None:
15
+ """The viewer's zone by IANA name; nothing when there is none, so each
16
+ moment is read on the server's own clock, daylight time as it was then."""
17
+ if name:
18
+ try:
19
+ return ZoneInfo(name)
20
+ except ZoneInfoNotFoundError, ValueError:
21
+ pass
22
+ return None
23
+
24
+
25
+ def clock_text(at_ms: int, tz: tzinfo | None) -> str:
26
+ """A moment on the viewer's clock: the time of day, with the date before
27
+ it when the day is not today's."""
28
+ today = datetime.now(tz).replace(hour=0, minute=0, second=0, microsecond=0)
29
+ form = "%H:%M:%S" if at_ms >= today.timestamp() * 1000 else "%Y-%m-%d %H:%M:%S"
30
+ return datetime.fromtimestamp(at_ms / 1000, tz).strftime(form)
31
+
32
+
33
+ def day_text(at_ms: int, tz: tzinfo | None) -> str:
34
+ """The day a moment falls on, on the viewer's calendar."""
35
+ return datetime.fromtimestamp(at_ms / 1000, tz).strftime("%Y-%m-%d")
36
+
37
+
38
+ def time_text(at_ms: int, tz: tzinfo | None) -> str:
39
+ """The time of day of a moment, whatever the day."""
40
+ return datetime.fromtimestamp(at_ms / 1000, tz).strftime("%H:%M:%S")
@@ -0,0 +1,196 @@
1
+ """What modules, login modules and theme plugins hold the server to."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal, Protocol
6
+ lazy from collections.abc import Sequence
7
+ lazy from contextlib import AbstractAsyncContextManager
8
+ lazy from pathlib import Path
9
+
10
+ from bazaar_core import ServiceKey
11
+ from pydantic import BaseModel, ConfigDict, Field
12
+ lazy from starlette.requests import HTTPConnection
13
+ lazy from starlette.responses import Response
14
+ lazy from starlette.types import ASGIApp
15
+
16
+ lazy from .i18n import Translator
17
+ lazy from .pages import Pages
18
+ lazy from .refs import Refs
19
+
20
+ # one segment of an address, and a name a stylesheet can select on
21
+ NAME = r"^[a-z][a-z0-9-]*$"
22
+ # the addresses the server and the modules behind them answer themselves
23
+ RESERVED = frozenset({"ui", "api", "static", "login", "starlink"})
24
+
25
+
26
+ class _Item(BaseModel):
27
+ model_config = ConfigDict(extra="forbid", frozen=True)
28
+
29
+
30
+ class SidebarItem(_Item):
31
+ """A link in the sidebar, for the accounts holding its permission point."""
32
+
33
+ text: str
34
+ # an SVG icon stroked in currentColor, as lucide's are
35
+ icon: str
36
+ path: str
37
+ permission: str | None = None
38
+ order: int = 0
39
+
40
+
41
+ class OverviewItem(_Item):
42
+ """Content of the overview, loaded in an iframe."""
43
+
44
+ key: str = Field(pattern=NAME)
45
+ title: str
46
+ src: str
47
+ permission: str | None = None
48
+
49
+
50
+ class SettingsItem(_Item):
51
+ """A part of the settings page, loaded in an iframe."""
52
+
53
+ key: str = Field(pattern=NAME)
54
+ title: str
55
+ src: str
56
+ permission: str | None = None
57
+ admin: bool = False
58
+
59
+
60
+ class PermissionItem(_Item):
61
+ """A permission point, written module.action; it is also its text key."""
62
+
63
+ point: str = Field(pattern=r"^[a-z][a-z0-9-]*\.[a-z][a-z0-9-]*$")
64
+ description: str
65
+
66
+
67
+ class Tokens(_Item):
68
+ """Every value a theme sets, named as in the approved tokens.css."""
69
+
70
+ bg: str
71
+ bg_glow: str
72
+ side_bg: str
73
+ surface: str
74
+ surface_2: str
75
+ raised: str
76
+ sunken: str
77
+ line: str
78
+ line_soft: str
79
+ line_strong: str
80
+ text: str
81
+ text_2: str
82
+ muted: str
83
+ faint: str
84
+ saffron: str
85
+ saffron_ink: str
86
+ saffron_soft: str
87
+ saffron_line: str
88
+ ember: str
89
+ jade: str
90
+ jade_soft: str
91
+ crimson: str
92
+ crimson_soft: str
93
+ indigo: str
94
+ indigo_soft: str
95
+ plum: str
96
+ plum_soft: str
97
+ shadow: str
98
+ codex: str
99
+
100
+
101
+ class Theme(_Item):
102
+ """A look; its styles apply only while it is the page's theme."""
103
+
104
+ name: str = Field(pattern=NAME)
105
+ # what the theme is called, by language
106
+ labels: dict[str, str] = Field(min_length=1)
107
+ scheme: Literal["light", "dark"]
108
+ tokens: Tokens
109
+ styles: str = ""
110
+
111
+
112
+ class Account(Protocol):
113
+ """Who a request is from, as the login module says."""
114
+
115
+ @property
116
+ def id(self) -> str: ...
117
+
118
+ @property
119
+ def name(self) -> str: ...
120
+
121
+ @property
122
+ def theme(self) -> str | None: ...
123
+
124
+ @property
125
+ def language(self) -> str | None: ...
126
+
127
+ @property
128
+ def admin(self) -> bool: ...
129
+
130
+ def allows(self, point: str) -> bool: ...
131
+
132
+
133
+ class Login(Protocol):
134
+ """The module that says who a request is from; one at a time."""
135
+
136
+ @property
137
+ def app(self) -> ASGIApp:
138
+ """Served at /login to anyone; signing out is a POST to /login/logout."""
139
+ ...
140
+
141
+ async def account(self, connection: HTTPConnection) -> Account | Response:
142
+ """The account a request or a WebSocket is from, or the answer it
143
+ gets instead."""
144
+ ...
145
+
146
+
147
+ class Themes(Protocol):
148
+ def register(self, theme: Theme) -> AbstractAsyncContextManager[None]:
149
+ """Offer the theme while open."""
150
+ ...
151
+
152
+
153
+ class Server(Protocol):
154
+ """Where modules put their pages, links and permission points; each
155
+ registration holds while its context is open."""
156
+
157
+ @property
158
+ def refs(self) -> Refs: ...
159
+
160
+ @property
161
+ def permissions(self) -> Sequence[PermissionItem]: ...
162
+
163
+ def module(
164
+ self,
165
+ name: str,
166
+ *,
167
+ ui: ASGIApp | None = None,
168
+ api: ASGIApp | None = None,
169
+ static: Path | None = None,
170
+ locales: Path | None = None,
171
+ ) -> AbstractAsyncContextManager[None]:
172
+ """Serve the module's interface at /ui/<name> inside the shell at
173
+ /<name>, its API at /api/<name> and its files at /static/<name>; its
174
+ locales directory holds en.toml and zh-CN.toml."""
175
+ ...
176
+
177
+ def sidebar(self, item: SidebarItem) -> AbstractAsyncContextManager[None]: ...
178
+
179
+ def overview(self, item: OverviewItem) -> AbstractAsyncContextManager[None]: ...
180
+
181
+ def settings(self, item: SettingsItem) -> AbstractAsyncContextManager[None]: ...
182
+
183
+ def permission(self, item: PermissionItem) -> AbstractAsyncContextManager[None]: ...
184
+
185
+ def login(self, login: Login) -> AbstractAsyncContextManager[None]: ...
186
+
187
+ async def pages(self, module: str, package: str) -> Pages:
188
+ """Jinja pages on the server's base template, from the package's
189
+ templates directory."""
190
+ ...
191
+
192
+ def translator(self, connection: HTTPConnection) -> Translator: ...
193
+
194
+
195
+ SERVER = ServiceKey[Server]("server")
196
+ THEME = ServiceKey[Themes]("server.theme")
@@ -0,0 +1,84 @@
1
+ """Every module's words, in the language of whoever is looking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ lazy import tomllib
7
+ lazy from collections.abc import Mapping
8
+ lazy from pathlib import Path
9
+
10
+ from jinja2 import Environment, StrictUndefined, Template
11
+
12
+ ENGLISH = "en"
13
+ SIMPLIFIED_CHINESE = "zh-CN"
14
+ LANGUAGES = (ENGLISH, SIMPLIFIED_CHINESE)
15
+
16
+ _environment = Environment(undefined=StrictUndefined, autoescape=False)
17
+
18
+
19
+ def catalogs(directory: Path) -> dict[str, dict[str, Template]]:
20
+ """The languages a locales directory has, every entry a template."""
21
+ return {
22
+ language: {
23
+ key: _environment.from_string(source)
24
+ for key, source in tomllib.loads(path.read_text(encoding="utf-8")).items()
25
+ }
26
+ for language in LANGUAGES
27
+ if (path := directory / f"{language}.toml").is_file()
28
+ }
29
+
30
+
31
+ def language(chosen: str | None, accept_language: str | None) -> str:
32
+ """The account's language, else the first one the browser asks for that
33
+ the pages speak, else English."""
34
+ if chosen in LANGUAGES:
35
+ return chosen
36
+ for part in (accept_language or "").split(","):
37
+ tag = part.split(";")[0].strip().lower()
38
+ if tag.startswith("zh"):
39
+ return SIMPLIFIED_CHINESE
40
+ if tag.startswith("en"):
41
+ return ENGLISH
42
+ return ENGLISH
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Translator:
47
+ language: str
48
+ _messages: Mapping[str, Template] = field(repr=False)
49
+
50
+ def text(self, key: str, arguments: Mapping[str, object] | None = None) -> str:
51
+ template = self._messages.get(key)
52
+ if template is None:
53
+ return key
54
+ return template.render(**(arguments or {}))
55
+
56
+ def has(self, key: str) -> bool:
57
+ return key in self._messages
58
+
59
+
60
+ class Catalogs:
61
+ """The registered modules' catalogs, merged per language."""
62
+
63
+ def __init__(self) -> None:
64
+ self._modules: dict[str, dict[str, dict[str, Template]]] = {}
65
+ self._translators: dict[str, Translator] = {}
66
+
67
+ def add(self, module: str, languages: dict[str, dict[str, Template]]) -> None:
68
+ self._modules[module] = languages
69
+ self._translators.clear()
70
+
71
+ def remove(self, module: str) -> None:
72
+ del self._modules[module]
73
+ self._translators.clear()
74
+
75
+ def translator(self, language: str) -> Translator:
76
+ if language not in self._translators:
77
+ messages: dict[str, Template] = {}
78
+ for languages in self._modules.values():
79
+ # a module that speaks one language is read in it
80
+ messages |= languages.get(language) or next(
81
+ iter(languages.values()), {}
82
+ )
83
+ self._translators[language] = Translator(language, messages)
84
+ return self._translators[language]
@@ -0,0 +1,83 @@
1
+ """What was written, as it was meant to be read: Markdown, and code within
2
+ it coloured by what it is."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Callable, Mapping, Sequence
7
+ from typing import Any
8
+
9
+ from markdown_it import MarkdownIt
10
+ from markdown_it.renderer import RendererHTML
11
+ from markdown_it.token import Token
12
+ from markdown_it.utils import EnvType, OptionsDict
13
+ from markupsafe import Markup
14
+ from pygments import highlight
15
+ from pygments.formatters import HtmlFormatter
16
+ from pygments.lexers import get_lexer_by_name
17
+ from pygments.lexers.special import TextLexer
18
+ from pygments.util import ClassNotFound
19
+
20
+ # tokens by class only, prefixed so they meet none of the page's own; the
21
+ # colours are the page's, in its stylesheet
22
+ _FORMATTER = HtmlFormatter(nowrap=True, classprefix="tok-")
23
+
24
+
25
+ def _fence(
26
+ renderer: Any,
27
+ tokens: Sequence[Token],
28
+ index: int,
29
+ options: Mapping[str, Any],
30
+ env: Mapping[str, Any],
31
+ ) -> str:
32
+ del renderer, options
33
+ token = tokens[index]
34
+ language = token.info.strip().split(" ", 1)[0] if token.info else ""
35
+ try:
36
+ lexer = get_lexer_by_name(language) if language else TextLexer()
37
+ except ClassNotFound:
38
+ lexer = TextLexer()
39
+ return env["code"](Markup(highlight(token.content, lexer, _FORMATTER))) + "\n"
40
+
41
+
42
+ def _image(
43
+ renderer: Any,
44
+ tokens: Sequence[Token],
45
+ index: int,
46
+ options: Mapping[str, Any],
47
+ env: Mapping[str, Any],
48
+ ) -> str:
49
+ token = tokens[index]
50
+ return env["image"](
51
+ src=token.attrGet("src") or "",
52
+ alt=renderer.renderInlineAsText(token.children or [], options, env),
53
+ )
54
+
55
+
56
+ class _ContentRenderer(RendererHTML):
57
+ def renderInline(
58
+ self, tokens: Sequence[Token], options: OptionsDict, env: EnvType
59
+ ) -> str:
60
+ # Prose stays outside Alpine; the trusted code-block copy control is
61
+ # a separate component, beside its own ignored code content.
62
+ return (
63
+ "<span x-ignore>" + super().renderInline(tokens, options, env) + "</span>"
64
+ )
65
+
66
+
67
+ # html off: what the agent wrote is text, however it looks; a link is only a
68
+ # link on a scheme markdown-it lets through
69
+ _MARKDOWN = MarkdownIt(
70
+ "commonmark", {"html": False}, renderer_cls=_ContentRenderer
71
+ ).enable(["table", "strikethrough"])
72
+ _MARKDOWN.add_render_rule("fence", _fence)
73
+ _MARKDOWN.add_render_rule("image", _image)
74
+
75
+
76
+ def render(
77
+ text: str, *, code: Callable[[Markup], str], image: Callable[..., str]
78
+ ) -> Markup:
79
+ """The text as HTML; each code block drawn by `code`, given its
80
+ highlighted content, and each image by `image`, given its `src` and
81
+ `alt`, so both are the page's own."""
82
+
83
+ return Markup(_MARKDOWN.render(text, {"code": code, "image": image}))