commit b35185b2cfa3ae30f20c24403ced0125d55e40b2
parent 32f699788705ef291ecfa1a79eac888a8e206e6a
Author: Andy Khramtsov <>
Date: Wed, 27 May 2026 17:14:02 +0300
feat: add file upload
Diffstat:
9 files changed, 431 insertions(+), 10 deletions(-)
diff --git a/justfile b/justfile
@@ -24,7 +24,7 @@ jupyter:
# Starts the app
start:
- uv run deps
+ uv run src/deps/dash_app.py --debug
# Alias to start
run: start
diff --git a/pyproject.toml b/pyproject.toml
@@ -14,9 +14,6 @@ dependencies = [
"polars>=1.40.1",
]
-[project.scripts]
-deps = "deps:main"
-
[build-system]
requires = ["uv_build>=0.10.9,<0.11.0"]
build-backend = "uv_build"
diff --git a/src/deps/__init__.py b/src/deps/__init__.py
@@ -1,6 +0,0 @@
-def hello() -> str:
- return "Hello from deps!"
-
-
-def main():
- print("Deps")
diff --git a/src/deps/app_state.py b/src/deps/app_state.py
@@ -0,0 +1,10 @@
+from deps.config import Config
+
+
+class AppState:
+ """Immutable application state"""
+
+ config: Config
+
+ def __init__(self, config: Config) -> None:
+ self.config = config
diff --git a/src/deps/assets/styles.css b/src/deps/assets/styles.css
@@ -0,0 +1,298 @@
+/* This stylesheet uses BEM */
+
+/* Reset layer */
+
+*, *::before, *::after {
+ box-sizing: border-box;
+ margin: unset;
+ padding: unset;
+}
+
+button, input, textarea, select {
+ font: inherit;
+}
+
+img, picture, svg, canvas {
+ display: block;
+ max-inline-size: 100%;
+ height: auto;
+}
+
+/* Theme layer */
+
+:root {
+ --main-hue: 266;
+ --c-bg: hsl(var(--main-hue), 0%, 100%);
+ --c-bg-contrast: hsl(var(--main-hue), 0%, 95%);
+ --c-bg-contrast-hover: hsl(var(--main-hue), 0%, 97%);
+ --c-bg-contrast-pressed: hsl(var(--main-hue), 0%, 95%);
+ --c-font: hsl(var(--main-hue), 10%, 15%);
+ --c-border: hsl(var(--main-hue), 0%, 45%);
+ --c-hr: hsl(var(--main-hue), 10%, 40%);
+ --c-hr-faint: hsl(var(--main-hue), 10%, 80%);
+ --c-link: hsl(var(--main-hue), 40%, 30%);
+ --c-shadow: hsl(var(--main-hue), 0%, 20%, 35%);
+ --font-bold: 600;
+ --font-tiny: 0.75rem;
+ --font-small: 0.8rem;
+ --font-normal: 1rem;
+ --font-big: 1.5rem;
+ --row-gap-small: 0.3em;
+ --row-gap-normal: 0.65em;
+ --row-gap-big: 1.5em;
+ --font-family-sans: Arial, Helvetica, Verdana, Tahoma, sans-serif;
+ --font-family-monospace: "SF Mono", "Menlo", "Consolas", "Courier New", monospace;
+}
+
+.dark, :root[data-theme='dark'] {
+ --c-bg: hsl(var(--main-hue), 0%, 20%);
+ --c-bg-contrast: hsl(var(--main-hue), 0%, 25%);
+ --c-bg-contrast-hover: hsl(var(--main-hue), 0%, 30%);
+ --c-bg-contrast-pressed: hsl(var(--main-hue), 0%, 25%);
+ --c-font: hsl(var(--main-hue), 10%, 85%);
+ --c-border: hsl(var(--main-hue), 0%, 55%);
+ --c-hr: hsl(var(--main-hue), 10%, 70%);
+ --c-hr-faint: hsl(var(--main-hue), 10%, 30%);
+ --c-link: hsl(var(--main-hue), 60%, 70%);
+ --c-shadow: hsl(var(--main-hue), 0%, 10%, 65%);
+}
+
+/* Global layer */
+
+body {
+ background-color: var(--c-bg);
+ font-family: var(--font-family-sans);
+ font-optical-sizing: auto;
+ font-style: normal;
+ font-size: 12pt;
+ font-weight: 400;
+ color: var(--c-font);
+ min-height: 100vh;
+ min-height: 100dvh;
+}
+
+h1, h2, h3, h4, h5, h6 {
+ padding: 0.25em 0;
+}
+
+p {
+ padding: 0.25em 0;
+}
+
+/* BEM layer */
+
+.padded-box {
+ padding: 0.5em;
+}
+
+.border {
+ border: solid 1px var(--c-border);
+}
+
+.shadow {
+ box-shadow: 0 0 0.5em var(--c-shadow);
+}
+
+.shadow_small {
+ box-shadow: 0 0 0.25em var(--c-shadow);
+}
+
+.shadow_large {
+ box-shadow: 0 0 1em var(--c-shadow);
+}
+
+.vertical-content {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5em;
+}
+
+.vertical-content_small-gap {
+ gap: 0.25em;
+}
+
+.vertical-content_medium-gap {
+ gap: 1em;
+}
+
+.vertical-content_large-gap {
+ gap: 1.5em;
+}
+
+.horizontal-content {
+ display: flex;
+ gap: 1em;
+ align-items: baseline;
+}
+
+.horizontal-content_small-gap {
+ gap: 0.5em;
+}
+
+.horizontal-content_large-gap {
+ gap: 2em;
+}
+
+.horizontal-content_spread {
+ justify-content: space-between;
+}
+
+.horizontal-content_center {
+ align-items: center;
+}
+
+.link {
+ text-decoration: none;
+ color: var(--c-link);
+}
+
+.list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25em;
+}
+
+.list__item {
+ margin-left: 1.5em;
+}
+
+.dcc-checklist__label {
+ color: var(--c-font);
+}
+
+.dcc-input {
+ color: var(--c-font);
+ background-color: var(--c-bg);
+ border-color: var(--c-border);
+}
+
+.dcc-tab {
+ color: var(--c-font);
+ background-color: var(--c-bg);
+ border-color: var(--c-border);
+}
+
+.dcc-tab:hover {
+ background-color: var(--c-bg-contrast-hover);
+}
+
+.dcc-tab:active {
+ background-color: var(--c-bg-contrast);
+}
+
+.dcc-tab_selected {
+ color: var(--c-font);
+ background-color: var(--c-bg-contrast);
+ border-color: var(--c-border);
+}
+
+.button {
+ color: var(--c-font);
+ background-color: var(--c-bg-contrast);
+ border-color: var(--c-border);
+}
+
+.button:hover {
+ color: var(--c-font);
+ background-color: var(--c-bg-contrast-hover);
+}
+
+.button:active {
+ color: var(--c-font);
+ background-color: var(--c-bg-contrast-pressed);
+}
+
+.collapse-button {
+ display: flex;
+ gap: 0.5em;
+ align-items: center;
+ color: var(--c-font);
+ background-color: var(--c-bg-contrast);
+ border: unset;
+ border-radius: 0;
+}
+
+.collapse-button:hover {
+ color: var(--c-font);
+ background-color: var(--c-bg-contrast-hover);
+}
+
+.collapse-button:active {
+ color: var(--c-font);
+ background-color: var(--c-bg-contrast-pressed);
+}
+
+.collapse-button__arrow-right {
+ border: 0.5em solid;
+ border-color: transparent transparent transparent var(--c-font);
+}
+
+.collapse-button__arrow-down {
+ border: 0.5em solid;
+ margin-top: 0.5em;
+ border-color: var(--c-font) transparent transparent;
+}
+
+.hr {
+ border: none;
+ border-top: solid 1px var(--c-hr);
+ width: 100%;
+}
+
+.hr_faint {
+ border: none;
+ border-top: solid 1px var(--c-hr-faint);
+}
+
+.help-icon {
+ background-color: unset;
+ text-decoration: unset;
+ cursor: help;
+ font-weight: bold;
+ border: solid 2px var(--c-border);
+ border-radius: 50%;
+ width: 1.25em;
+ height: 1.25em;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.help-icon:hover {
+ background-color: var(--c-bg-contrast-hover);
+}
+
+.hidden {
+ visibility: hidden;
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ clip-path: inset(50%);
+ white-space: nowrap;
+ border: 0;
+}
+
+/* Restylings (not BEM) */
+
+.dash-dropdown,
+.dash-dropdown-content,
+.dash-dropdown-clear,
+.dash-dropdown-value-count,
+.dash-dropdown-value,
+.dash-dropdown-trigger-icon,
+.dash-dropdown-actions,
+.dash-dropdown-action-button,
+.dash-dropdown-options,
+.dash-dropdown-option,
+.dash-dropdown-search-container,
+.dash-dropdown-search-icon,
+.dash-dropdown-search
+{
+ color: var(--c-font);
+ background: var(--c-bg);
+ border-color: var(--c-border);
+}
diff --git a/src/deps/config.py b/src/deps/config.py
@@ -0,0 +1,2 @@
+class Config:
+ pass
diff --git a/src/deps/dash_app.py b/src/deps/dash_app.py
@@ -0,0 +1,71 @@
+import argparse
+
+import dash
+from dash import Dash, dcc, html
+
+from deps.app_state import AppState
+from deps.config import Config
+
+
+def new_app_state() -> AppState:
+ config = Config()
+ return AppState(config=config)
+
+
+def main():
+ state = new_app_state()
+
+ app = Dash(use_pages=True)
+ app.server.config["APP_STATE"] = state
+
+ app.layout = html.Div(
+ className="vertical-content vertical-content_large-gap",
+ children=[
+ html.Div(
+ className=(
+ "padded-box horizontal-content horizontal-content_large-gap "
+ "horizontal-content_spread horizontal-content_center"
+ ),
+ children=[
+ html.Div(
+ className="horizontal-content horizontal-content_large-gap",
+ children=[
+ html.Div(children="Dash App"),
+ html.Div(
+ className="horizontal-content",
+ children=[
+ html.Div(
+ dcc.Link(
+ f"{page['name']}",
+ className="link",
+ href=page["relative_path"],
+ )
+ )
+ for page in dash.page_registry.values()
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ dash.page_container,
+ ],
+ )
+
+ parser = argparse.ArgumentParser(description="Start Dash App")
+ parser.add_argument(
+ "--debug",
+ action="store_true",
+ help="Start Dash in debug mode",
+ )
+ args = parser.parse_args()
+
+ if args.debug:
+ app.run(debug=True)
+ else:
+ # todo: use gunicorn or wsgi
+ raise Exception("Production server is not yet implemented")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/src/deps/pages/__init__.py b/src/deps/pages/__init__.py
diff --git a/src/deps/pages/home.py b/src/deps/pages/home.py
@@ -0,0 +1,49 @@
+import base64
+
+import dash
+from dash import Input, Output, State, callback, dcc, html
+from dash.exceptions import PreventUpdate
+
+dash.register_page(__name__, path="/", order=1)
+
+
+def layout():
+ return html.Div(
+ className="padded-box vertical-content vertical-content_large-gap",
+ children=[
+ html.H2("Cargo dependency visualisation"),
+ dcc.Textarea(id="text-content", placeholder="Cargo.lock contents", style={}),
+ dcc.Upload(
+ id="upload-file",
+ children=[
+ dcc.Button("Upload Cargo.lock"),
+ html.Div("Uploaded file name:"),
+ html.Div(id="upload-file-filename"),
+ ],
+ ),
+ ],
+ )
+
+
+@callback(
+ dict(
+ textarea=Output("text-content", "value"),
+ filename=Output("upload-file-filename", "children"),
+ ),
+ dict(
+ upload_contents=Input("upload-file", "contents"),
+ ),
+ dict(
+ upload_filename=State("upload-file", "filename"),
+ ),
+)
+def upload_cargo_lock(inputs, state):
+ contents = inputs["upload_contents"]
+ if not contents:
+ raise PreventUpdate
+ content_type, content_string = contents.split(",")
+ decoded = base64.b64decode(content_string)
+ return dict(
+ textarea=decoded.decode("utf-8"),
+ filename=state["upload_filename"],
+ )