diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md index ef9612d..1069b4b 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.agents/skills/code-review/SKILL.md @@ -99,3 +99,23 @@ A resource that requires deterministic release — database connection or cursor ### call-in-loop A database query, HTTP request, or other heavy I/O call is issued once per item inside a loop where the project already provides a batched or bulk equivalent. Only flag when the batched helper exists, cited by file and line, and the loop bound is data-dependent rather than a small fixed number. + +### possible-sql-injection + +SQL is built by interpolating or concatenating untrusted values into the query string with request parameters, user input, or other external data without using parameterized placeholders and bound arguments. Only flag when the interpolated value is not a constant or a trusted identifier whitelist, and when a parameterized API for that database driver or ORM exists in the project or standard library. + +### sensitive-data-logged + +A log call, print, or error report includes a secret or personally identifying value: password, API key, token, session cookie, authorization header, private key, etc. Only flag when the logged expression is clearly that sensitive value or a structure that embeds it (for example logging an entire headers dict that contains Authorization). Do not flag redacted, hashed, or truncated forms, nor logging of non-secret identifiers such as user ids or request ids. + +### hardcoded-secret + +A secret is committed as a string literal (API key, password, token, private key, connection string with credentials). Only flag when the value looks like a real credential, not a placeholder/test fixture. + +### ssrf + +An HTTP client fetches a URL taken from user/external input with no allowlist or host restriction. + +### command-injection + +subprocess/os.system/os.popen runs a shell with concatenated or formatted user/external input (shell=True, or a single string command). Only flag when the injected value is not a constant/whitelist. diff --git a/.github/workflows/build-wheel.yaml b/.github/workflows/build-wheel.yaml index fee796a..ac00f16 100644 --- a/.github/workflows/build-wheel.yaml +++ b/.github/workflows/build-wheel.yaml @@ -1,8 +1,15 @@ -name: Build wheel +name: "[python] Build wheel" on: - push +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: build-wheel: runs-on: ubuntu-latest @@ -15,13 +22,13 @@ jobs: node-version: "20" - name: Set up uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: Build wheel run: make wheel - name: Upload wheel artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: wheel path: dist/*.whl @@ -31,10 +38,10 @@ jobs: needs: build-wheel steps: - name: Set up uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: Download wheel artifact - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v8 with: name: wheel path: dist diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index aaca8ca..8d7256a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,8 +1,15 @@ -name: CI +name: "[python] CI" on: push: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + jobs: tests: runs-on: ubuntu-latest @@ -38,10 +45,10 @@ jobs: run: make gen - name: Set up Docker runtime - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Build image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@v7 with: context: . load: true @@ -55,7 +62,7 @@ jobs: run: docker save local/app:latest -o image.tar - name: Upload image - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: docker-image path: image.tar @@ -70,15 +77,15 @@ jobs: packages: write steps: - name: Download image - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: docker-image - name: Set up Docker runtime - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/release-wheel.yaml b/.github/workflows/release-wheel.yaml index 21226a5..ffcf67e 100644 --- a/.github/workflows/release-wheel.yaml +++ b/.github/workflows/release-wheel.yaml @@ -1,4 +1,4 @@ -name: Release wheels +name: "[python] Release wheels" on: workflow_dispatch: @@ -8,6 +8,9 @@ on: required: true type: string +permissions: + contents: read + jobs: create-tag: runs-on: ubuntu-latest @@ -27,7 +30,7 @@ jobs: runs-on: ubuntu-latest needs: create-tag steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 ref: "v${{ inputs.version }}" @@ -38,16 +41,17 @@ jobs: node-version: "20" - name: Set up uv - uses: astral-sh/setup-uv@v6 + uses: astral-sh/setup-uv@v7 - name: Build wheel run: make wheel - name: Upload wheel artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: wheel path: dist/*.whl + retention-days: 1 publish-release: runs-on: ubuntu-latest @@ -56,7 +60,7 @@ jobs: contents: write steps: - name: Download wheel artifacts - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v8 with: name: wheel path: dist @@ -68,7 +72,7 @@ jobs: echo "release_tag=v$version" >> "$GITHUB_OUTPUT" - name: Create GitHub release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.normalize.outputs.release_tag }} name: Release ${{ steps.normalize.outputs.release_tag }} diff --git a/.template.yaml b/.template.yaml index 88ddb51..b945204 100644 --- a/.template.yaml +++ b/.template.yaml @@ -1,6 +1,6 @@ # Autogenerated - DO NOT EDIT # Parameters of the project as generated from template -_commit: 6b97e37 +_commit: 2555e1a _src_path: gh:kraysent/python-template package_name: uploader project_name: uploader diff --git a/frontend/package.json b/frontend/package.json index 1995224..3e7b9d3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "@rjsf/validator-ajv8": "^6.4.1", "react": "^19.2.4", "react-dom": "^19.2.4", + "react-markdown": "^10.1.0", "react-router-dom": "^7.13.1" }, "devDependencies": { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 04e69ab..fe81d07 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -2,6 +2,7 @@ export type TaskInfo = { id: string; title: string; description: string; + additional_description?: string | null; group: string; rerunnable: boolean; }; @@ -15,6 +16,7 @@ export async function fetchTasks(): Promise { export type TaskSchemaResponse = { title: string; description: string; + additional_description?: string | null; schema: Record; }; diff --git a/frontend/src/components/Markdown.tsx b/frontend/src/components/Markdown.tsx new file mode 100644 index 0000000..3c0604a --- /dev/null +++ b/frontend/src/components/Markdown.tsx @@ -0,0 +1,59 @@ +import Box from "@mui/material/Box"; +import { useTheme } from "@mui/material/styles"; +import ReactMarkdown from "react-markdown"; + +type MarkdownProps = { + children: string; +}; + +export function Markdown({ children }: MarkdownProps) { + const theme = useTheme(); + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/TaskPage.test.tsx b/frontend/src/components/TaskPage.test.tsx index 2080036..4c49e42 100644 --- a/frontend/src/components/TaskPage.test.tsx +++ b/frontend/src/components/TaskPage.test.tsx @@ -6,13 +6,14 @@ import { TaskPage } from "./TaskPage"; const fakeTaskSchema = { title: "Fake Test Task", description: "A task used in tests.", + additional_description: '## Expression syntax\n\nUse `col("name")`.', schema: { type: "object", properties: { name: { type: "string", title: "Name", - description: "Your name", + description: "Your name. Target type: `string`.", }, count: { type: "integer", @@ -63,6 +64,21 @@ describe("TaskPage", () => { expect(screen.getByText("A task used in tests.")).toBeInTheDocument(); expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); expect(screen.getByLabelText(/count/i)).toBeInTheDocument(); + expect(screen.getByText("string")).toBeInTheDocument(); + expect(screen.getByText("string").tagName).toBe("CODE"); + }); + + it("shows additional description markdown behind info button", async () => { + renderTaskPage("fake-task"); + + await waitFor(() => { + expect(screen.getByText("Fake Test Task")).toBeInTheDocument(); + }); + + expect(screen.queryByText("Expression syntax")).not.toBeInTheDocument(); + screen.getByLabelText("Additional information").click(); + expect(await screen.findByText("Expression syntax")).toBeInTheDocument(); + expect(screen.getByText('col("name")')).toBeInTheDocument(); }); it("prefills form when formData is passed via location state", async () => { diff --git a/frontend/src/components/TaskPage.tsx b/frontend/src/components/TaskPage.tsx index 6f7d91b..e04b222 100644 --- a/frontend/src/components/TaskPage.tsx +++ b/frontend/src/components/TaskPage.tsx @@ -2,12 +2,16 @@ import { useEffect, useState } from "react"; import { useLocation, useParams } from "react-router-dom"; import Form from "@rjsf/mui"; import validator from "@rjsf/validator-ajv8"; +import InfoOutlined from "@mui/icons-material/InfoOutlined"; import Alert from "@mui/material/Alert"; import Box from "@mui/material/Box"; import CircularProgress from "@mui/material/CircularProgress"; +import IconButton from "@mui/material/IconButton"; +import Popover from "@mui/material/Popover"; import Typography from "@mui/material/Typography"; import { fetchTaskSchema, submitTask } from "../api"; import { FoldableObjectFieldTemplate } from "./FoldableObjectFieldTemplate"; +import { Markdown } from "./Markdown"; import { ProgressView } from "./ProgressView"; export function TaskPage() { @@ -16,6 +20,10 @@ export function TaskPage() { const [schema, setSchema] = useState | null>(null); const [taskTitle, setTaskTitle] = useState(null); const [taskDescription, setTaskDescription] = useState(null); + const [additionalDescription, setAdditionalDescription] = useState< + string | null + >(null); + const [infoAnchor, setInfoAnchor] = useState(null); const [loadError, setLoadError] = useState(null); const [submitError, setSubmitError] = useState(null); const [runId, setRunId] = useState(null); @@ -34,13 +42,21 @@ export function TaskPage() { } let alive = true; fetchTaskSchema(taskId) - .then(({ title, description, schema: s }) => { - if (!alive) return; - setSchema(s); - setTaskTitle(title); - setTaskDescription(description); - setLoadError(null); - }) + .then( + ({ + title, + description, + additional_description: extraDescription, + schema: s, + }) => { + if (!alive) return; + setSchema(s); + setTaskTitle(title); + setTaskDescription(description); + setAdditionalDescription(extraDescription ?? null); + setLoadError(null); + }, + ) .catch((e) => { if (alive) setLoadError(String(e)); }); @@ -75,9 +91,41 @@ export function TaskPage() { return ( - - {taskTitle ?? taskId} - + + {taskTitle ?? taskId} + {additionalDescription && ( + <> + setInfoAnchor(e.currentTarget)} + > + + + setInfoAnchor(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "left" }} + transformOrigin={{ vertical: "top", horizontal: "left" }} + slotProps={{ + paper: { + sx: { maxWidth: 480, p: 2 }, + }, + }} + > + {additionalDescription} + + + )} + {taskDescription && ( { setSubmitError(null); diff --git a/frontend/yarn.lock b/frontend/yarn.lock index fdc82fb..5b8655e 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -3,25 +3,28 @@ "@adobe/css-tools@^4.4.0": - version "4.4.4" + version "4.5.0" "@asamuzakjp/css-color@^5.0.1": - version "5.0.1" + version "5.1.11" dependencies: - "@csstools/css-calc" "^3.1.1" - "@csstools/css-color-parser" "^4.0.2" + "@asamuzakjp/generational-cache" "^1.0.1" + "@csstools/css-calc" "^3.2.0" + "@csstools/css-color-parser" "^4.1.0" "@csstools/css-parser-algorithms" "^4.0.0" "@csstools/css-tokenizer" "^4.0.0" - lru-cache "^11.2.6" "@asamuzakjp/dom-selector@^7.0.2": - version "7.0.3" + version "7.1.1" dependencies: + "@asamuzakjp/generational-cache" "^1.0.1" "@asamuzakjp/nwsapi" "^2.3.9" bidi-js "^1.0.3" css-tree "^3.2.1" is-potential-custom-element-name "^1.0.1" - lru-cache "^11.2.7" + +"@asamuzakjp/generational-cache@^1.0.1": + version "1.0.1" "@asamuzakjp/nwsapi@^2.3.9": version "2.3.9" @@ -178,46 +181,24 @@ "@csstools/color-helpers@^6.0.2": version "6.0.2" -"@csstools/css-calc@^3.1.1": - version "3.1.1" +"@csstools/css-calc@^3.2.0", "@csstools/css-calc@^3.2.1": + version "3.2.1" -"@csstools/css-color-parser@^4.0.2": - version "4.0.2" +"@csstools/css-color-parser@^4.1.0": + version "4.1.1" dependencies: "@csstools/color-helpers" "^6.0.2" - "@csstools/css-calc" "^3.1.1" + "@csstools/css-calc" "^3.2.1" "@csstools/css-parser-algorithms@^4.0.0": version "4.0.0" "@csstools/css-syntax-patches-for-csstree@^1.1.1": - version "1.1.1" + version "1.1.4" "@csstools/css-tokenizer@^4.0.0": version "4.0.0" -"@emnapi/core@^1.7.1": - version "1.9.1" - resolved "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz" - integrity sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA== - dependencies: - "@emnapi/wasi-threads" "1.2.0" - tslib "^2.4.0" - -"@emnapi/runtime@^1.7.1": - version "1.9.1" - resolved "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz" - integrity sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA== - dependencies: - tslib "^2.4.0" - -"@emnapi/wasi-threads@1.2.0": - version "1.2.0" - resolved "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz" - integrity sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg== - dependencies: - tslib "^2.4.0" - "@emotion/babel-plugin@^11.13.5": version "11.13.5" resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz" @@ -394,7 +375,7 @@ levn "^0.4.1" "@exodus/bytes@^1.11.0", "@exodus/bytes@^1.15.0", "@exodus/bytes@^1.6.0": - version "1.15.0" + version "1.15.1" "@humanfs/core@^0.19.1": version "0.19.1" @@ -547,9 +528,6 @@ resolved "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz" integrity sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw== -"@oxc-project/types@=0.120.0": - version "0.120.0" - "@popperjs/core@^2.11.8": version "2.11.8" resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz" @@ -592,17 +570,11 @@ lodash "^4.17.23" lodash-es "^4.17.23" -"@rolldown/binding-darwin-arm64@1.0.0-rc.10": - version "1.0.0-rc.10" - "@rolldown/binding-darwin-arm64@1.0.0-rc.9": version "1.0.0-rc.9" resolved "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz" integrity sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ== -"@rolldown/pluginutils@1.0.0-rc.10": - version "1.0.0-rc.10" - "@rolldown/pluginutils@1.0.0-rc.7": version "1.0.0-rc.7" resolved "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz" @@ -649,13 +621,6 @@ dependencies: "@babel/runtime" "^7.12.5" -"@tybys/wasm-util@^0.10.1": - version "0.10.1" - resolved "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz" - integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== - dependencies: - tslib "^2.4.0" - "@types/aria-query@^5.0.1": version "5.0.4" resolved "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz" @@ -667,19 +632,52 @@ "@types/deep-eql" "*" assertion-error "^2.0.1" +"@types/debug@^4.0.0": + version "4.1.13" + resolved "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz" + integrity sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw== + dependencies: + "@types/ms" "*" + "@types/deep-eql@*": version "4.0.2" -"@types/estree@^1.0.0", "@types/estree@^1.0.6": +"@types/estree-jsx@^1.0.0": + version "1.0.5" + resolved "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz" + integrity sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg== + dependencies: + "@types/estree" "*" + +"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.6": version "1.0.8" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== +"@types/hast@^3.0.0": + version "3.0.5" + resolved "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz" + integrity sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g== + dependencies: + "@types/unist" "*" + "@types/json-schema@^7.0.15": version "7.0.15" resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== +"@types/mdast@^4.0.0": + version "4.0.4" + resolved "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz" + integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA== + dependencies: + "@types/unist" "*" + +"@types/ms@*": + version "2.1.0" + resolved "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz" + integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== + "@types/node@^20.0.0 || ^22.0.0 || >=24.0.0", "@types/node@^20.19.0 || >=22.12.0", "@types/node@^24.12.0": version "24.12.0" resolved "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz" @@ -707,13 +705,23 @@ resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz" integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== -"@types/react@*", "@types/react@^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react@^18.0.0 || ^19.0.0", "@types/react@^19.2.0", "@types/react@^19.2.14": +"@types/react@*", "@types/react@^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react@^18.0.0 || ^19.0.0", "@types/react@^19.2.0", "@types/react@^19.2.14", "@types/react@>=18": version "19.2.14" resolved "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz" integrity sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w== dependencies: csstype "^3.2.2" +"@types/unist@*", "@types/unist@^3.0.0": + version "3.0.3" + resolved "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz" + integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q== + +"@types/unist@^2.0.0": + version "2.0.11" + resolved "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz" + integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA== + "@typescript-eslint/eslint-plugin@8.57.1": version "8.57.1" resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz" @@ -810,6 +818,11 @@ "@typescript-eslint/types" "8.57.1" eslint-visitor-keys "^5.0.0" +"@ungap/structured-clone@^1.0.0": + version "1.3.3" + resolved "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz" + integrity sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg== + "@vitejs/plugin-react@^6.0.0": version "6.0.1" resolved "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz" @@ -948,6 +961,11 @@ babel-plugin-macros@^3.1.0: cosmiconfig "^7.0.0" resolve "^1.19.0" +bail@^2.0.0: + version "2.0.2" + resolved "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz" + integrity sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw== + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" @@ -1004,6 +1022,11 @@ caniuse-lite@^1.0.30001759: resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001780.tgz" integrity sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ== +ccount@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz" + integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== + chai@^6.2.2: version "6.2.2" @@ -1015,6 +1038,26 @@ chalk@^4.0.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +character-entities-html4@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz" + integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA== + +character-entities-legacy@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz" + integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ== + +character-entities@^2.0.0: + version "2.0.2" + resolved "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz" + integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ== + +character-reference-invalid@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz" + integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw== + clsx@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz" @@ -1032,6 +1075,11 @@ color-name@~1.1.4: resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +comma-separated-tokens@^2.0.0: + version "2.0.3" + resolved "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz" + integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== + concat-map@0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz" @@ -1092,7 +1140,7 @@ data-urls@^7.0.0: whatwg-mimetype "^5.0.0" whatwg-url "^16.0.0" -debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.3: +debug@^4.0.0, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.3: version "4.4.3" resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== @@ -1102,12 +1150,19 @@ debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.4.3: decimal.js@^10.6.0: version "10.6.0" +decode-named-character-reference@^1.0.0: + version "1.3.0" + resolved "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz" + integrity sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q== + dependencies: + character-entities "^2.0.0" + deep-is@^0.1.3: version "0.1.4" resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== -dequal@^2.0.3: +dequal@^2.0.0, dequal@^2.0.3: version "2.0.3" resolved "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== @@ -1117,6 +1172,13 @@ detect-libc@^2.0.3: resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== +devlop@^1.0.0, devlop@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz" + integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA== + dependencies: + dequal "^2.0.0" + dom-accessibility-api@^0.5.9: version "0.5.16" resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz" @@ -1138,8 +1200,8 @@ electron-to-chromium@^1.5.263: resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz" integrity sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA== -entities@^6.0.0: - version "6.0.1" +entities@^8.0.0: + version "8.0.0" error-ex@^1.3.1: version "1.3.4" @@ -1149,7 +1211,7 @@ error-ex@^1.3.1: is-arrayish "^0.2.1" es-module-lexer@^2.0.0: - version "2.0.0" + version "2.1.0" escalade@^3.2.0: version "3.2.0" @@ -1273,6 +1335,11 @@ estraverse@^5.1.0, estraverse@^5.2.0: resolved "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-util-is-identifier-name@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz" + integrity sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg== + estree-walker@^3.0.3: version "3.0.3" dependencies: @@ -1286,6 +1353,11 @@ esutils@^2.0.2: expect-type@^1.3.0: version "1.3.0" +extend@^3.0.0: + version "3.0.2" + resolved "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" @@ -1388,6 +1460,34 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +hast-util-to-jsx-runtime@^2.0.0: + version "2.3.6" + resolved "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz" + integrity sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg== + dependencies: + "@types/estree" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + comma-separated-tokens "^2.0.0" + devlop "^1.0.0" + estree-util-is-identifier-name "^3.0.0" + hast-util-whitespace "^3.0.0" + mdast-util-mdx-expression "^2.0.0" + mdast-util-mdx-jsx "^3.0.0" + mdast-util-mdxjs-esm "^2.0.0" + property-information "^7.0.0" + space-separated-tokens "^2.0.0" + style-to-js "^1.0.0" + unist-util-position "^5.0.0" + vfile-message "^4.0.0" + +hast-util-whitespace@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz" + integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw== + dependencies: + "@types/hast" "^3.0.0" + hermes-estree@0.25.1: version "0.25.1" resolved "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz" @@ -1412,6 +1512,11 @@ html-encoding-sniffer@^6.0.0: dependencies: "@exodus/bytes" "^1.6.0" +html-url-attributes@^3.0.0: + version "3.0.1" + resolved "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz" + integrity sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ== + ignore@^5.2.0: version "5.3.2" resolved "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz" @@ -1438,6 +1543,24 @@ imurmurhash@^0.1.4: indent-string@^4.0.0: version "4.0.0" +inline-style-parser@0.2.7: + version "0.2.7" + resolved "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz" + integrity sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA== + +is-alphabetical@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz" + integrity sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ== + +is-alphanumerical@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz" + integrity sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw== + dependencies: + is-alphabetical "^2.0.0" + is-decimal "^2.0.0" + is-arrayish@^0.2.1: version "0.2.1" resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz" @@ -1450,6 +1573,11 @@ is-core-module@^2.16.1: dependencies: hasown "^2.0.2" +is-decimal@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz" + integrity sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A== + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz" @@ -1462,6 +1590,16 @@ is-glob@^4.0.0, is-glob@^4.0.3: dependencies: is-extglob "^2.1.1" +is-hexadecimal@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz" + integrity sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg== + +is-plain-obj@^4.0.0: + version "4.1.0" + resolved "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz" + integrity sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg== + is-potential-custom-element-name@^1.0.1: version "1.0.1" @@ -1615,6 +1753,11 @@ lodash@^4.17.23: resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz" integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== +longest-streak@^3.0.0: + version "3.1.0" + resolved "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz" + integrity sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g== + loose-envify@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz" @@ -1622,8 +1765,8 @@ loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" -lru-cache@^11.2.6, lru-cache@^11.2.7: - version "11.2.7" +lru-cache@^11.2.7: + version "11.5.0" lru-cache@^5.1.1: version "5.1.1" @@ -1647,9 +1790,308 @@ markdown-to-jsx@^8.0.0: resolved "https://registry.npmjs.org/markdown-to-jsx/-/markdown-to-jsx-8.0.0.tgz" integrity sha512-hWEaRxeCDjes1CVUQqU+Ov0mCqBqkGhLKjL98KdbwHSgEWZZSJQeGlJQatVfeZ3RaxrfTrZZ3eczl2dhp5c/pA== +mdast-util-from-markdown@^2.0.0: + version "2.0.3" + resolved "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz" + integrity sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q== + dependencies: + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + mdast-util-to-string "^4.0.0" + micromark "^4.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-decode-string "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + unist-util-stringify-position "^4.0.0" + +mdast-util-mdx-expression@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz" + integrity sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ== + dependencies: + "@types/estree-jsx" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-mdx-jsx@^3.0.0: + version "3.2.0" + resolved "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz" + integrity sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q== + dependencies: + "@types/estree-jsx" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + ccount "^2.0.0" + devlop "^1.1.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + parse-entities "^4.0.0" + stringify-entities "^4.0.0" + unist-util-stringify-position "^4.0.0" + vfile-message "^4.0.0" + +mdast-util-mdxjs-esm@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz" + integrity sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg== + dependencies: + "@types/estree-jsx" "^1.0.0" + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + mdast-util-from-markdown "^2.0.0" + mdast-util-to-markdown "^2.0.0" + +mdast-util-phrasing@^4.0.0: + version "4.1.0" + resolved "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz" + integrity sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w== + dependencies: + "@types/mdast" "^4.0.0" + unist-util-is "^6.0.0" + +mdast-util-to-hast@^13.0.0: + version "13.2.1" + resolved "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz" + integrity sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@ungap/structured-clone" "^1.0.0" + devlop "^1.0.0" + micromark-util-sanitize-uri "^2.0.0" + trim-lines "^3.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + +mdast-util-to-markdown@^2.0.0: + version "2.1.2" + resolved "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz" + integrity sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA== + dependencies: + "@types/mdast" "^4.0.0" + "@types/unist" "^3.0.0" + longest-streak "^3.0.0" + mdast-util-phrasing "^4.0.0" + mdast-util-to-string "^4.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-decode-string "^2.0.0" + unist-util-visit "^5.0.0" + zwitch "^2.0.0" + +mdast-util-to-string@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz" + integrity sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg== + dependencies: + "@types/mdast" "^4.0.0" + mdn-data@2.27.1: version "2.27.1" +micromark-core-commonmark@^2.0.0: + version "2.0.3" + resolved "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz" + integrity sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg== + dependencies: + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + micromark-factory-destination "^2.0.0" + micromark-factory-label "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-factory-title "^2.0.0" + micromark-factory-whitespace "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-classify-character "^2.0.0" + micromark-util-html-tag-name "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-destination@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz" + integrity sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-label@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz" + integrity sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg== + dependencies: + devlop "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-space@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz" + integrity sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-title@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz" + integrity sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw== + dependencies: + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-factory-whitespace@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz" + integrity sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ== + dependencies: + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-character@^2.0.0: + version "2.1.1" + resolved "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz" + integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q== + dependencies: + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-chunked@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz" + integrity sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-classify-character@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz" + integrity sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-combine-extensions@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz" + integrity sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg== + dependencies: + micromark-util-chunked "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-decode-numeric-character-reference@^2.0.0: + version "2.0.2" + resolved "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz" + integrity sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-decode-string@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz" + integrity sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ== + dependencies: + decode-named-character-reference "^1.0.0" + micromark-util-character "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-encode@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz" + integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw== + +micromark-util-html-tag-name@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz" + integrity sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA== + +micromark-util-normalize-identifier@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz" + integrity sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q== + dependencies: + micromark-util-symbol "^2.0.0" + +micromark-util-resolve-all@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz" + integrity sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg== + dependencies: + micromark-util-types "^2.0.0" + +micromark-util-sanitize-uri@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz" + integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-subtokenize@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz" + integrity sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA== + dependencies: + devlop "^1.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-symbol@^2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz" + integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q== + +micromark-util-types@^2.0.0: + version "2.0.2" + resolved "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz" + integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA== + +micromark@^4.0.0: + version "4.0.2" + resolved "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz" + integrity sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA== + dependencies: + "@types/debug" "^4.0.0" + debug "^4.0.0" + decode-named-character-reference "^1.0.0" + devlop "^1.0.0" + micromark-core-commonmark "^2.0.0" + micromark-factory-space "^2.0.0" + micromark-util-character "^2.0.0" + micromark-util-chunked "^2.0.0" + micromark-util-combine-extensions "^2.0.0" + micromark-util-decode-numeric-character-reference "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-normalize-identifier "^2.0.0" + micromark-util-resolve-all "^2.0.0" + micromark-util-sanitize-uri "^2.0.0" + micromark-util-subtokenize "^2.0.0" + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + min-indent@^1.0.0: version "1.0.1" @@ -1728,6 +2170,19 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" +parse-entities@^4.0.0: + version "4.0.2" + resolved "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz" + integrity sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw== + dependencies: + "@types/unist" "^2.0.0" + character-entities-legacy "^3.0.0" + character-reference-invalid "^2.0.0" + decode-named-character-reference "^1.0.0" + is-alphanumerical "^2.0.0" + is-decimal "^2.0.0" + is-hexadecimal "^2.0.0" + parse-json@^5.0.0: version "5.2.0" resolved "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz" @@ -1739,9 +2194,9 @@ parse-json@^5.0.0: lines-and-columns "^1.1.6" parse5@^8.0.0: - version "8.0.0" + version "8.0.1" dependencies: - entities "^6.0.0" + entities "^8.0.0" path-exists@^4.0.0: version "4.0.0" @@ -1813,6 +2268,11 @@ prop-types@^15.6.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +property-information@^7.0.0: + version "7.2.0" + resolved "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz" + integrity sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg== + punycode@^2.1.0, punycode@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" @@ -1845,6 +2305,23 @@ react-is@^19.2.3: resolved "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz" integrity sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA== +react-markdown@^10.1.0: + version "10.1.0" + resolved "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz" + integrity sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + devlop "^1.0.0" + hast-util-to-jsx-runtime "^2.0.0" + html-url-attributes "^3.0.0" + mdast-util-to-hast "^13.0.0" + remark-parse "^11.0.0" + remark-rehype "^11.0.0" + unified "^11.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + react-router-dom@^7.13.1: version "7.13.1" resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz" @@ -1881,6 +2358,27 @@ redent@^3.0.0: indent-string "^4.0.0" strip-indent "^3.0.0" +remark-parse@^11.0.0: + version "11.0.0" + resolved "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz" + integrity sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA== + dependencies: + "@types/mdast" "^4.0.0" + mdast-util-from-markdown "^2.0.0" + micromark-util-types "^2.0.0" + unified "^11.0.0" + +remark-rehype@^11.0.0: + version "11.1.2" + resolved "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz" + integrity sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + mdast-util-to-hast "^13.0.0" + unified "^11.0.0" + vfile "^6.0.0" + require-from-string@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz" @@ -1974,11 +2472,24 @@ source-map@^0.5.7: resolved "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz" integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== +space-separated-tokens@^2.0.0: + version "2.0.2" + resolved "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz" + integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== + stackback@0.0.2: version "0.0.2" std-env@^4.0.0-rc.1: - version "4.0.0" + version "4.1.0" + +stringify-entities@^4.0.0: + version "4.0.4" + resolved "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz" + integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg== + dependencies: + character-entities-html4 "^2.0.0" + character-entities-legacy "^3.0.0" strip-indent@^3.0.0: version "3.0.0" @@ -1990,6 +2501,20 @@ strip-json-comments@^3.1.1: resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== +style-to-js@^1.0.0: + version "1.1.21" + resolved "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz" + integrity sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ== + dependencies: + style-to-object "1.0.14" + +style-to-object@1.0.14: + version "1.0.14" + resolved "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz" + integrity sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw== + dependencies: + inline-style-parser "0.2.7" + stylis@4.2.0: version "4.2.0" resolved "https://registry.npmjs.org/stylis/-/stylis-4.2.0.tgz" @@ -2014,7 +2539,7 @@ tinybench@^2.9.0: version "2.9.0" tinyexec@^1.0.2: - version "1.0.4" + version "1.2.2" tinyglobby@^0.2.15: version "0.2.15" @@ -2027,13 +2552,13 @@ tinyglobby@^0.2.15: tinyrainbow@^3.0.3: version "3.1.0" -tldts-core@^7.0.26: - version "7.0.26" +tldts-core@^7.0.32: + version "7.0.32" tldts@^7.0.5: - version "7.0.26" + version "7.0.32" dependencies: - tldts-core "^7.0.26" + tldts-core "^7.0.32" tough-cookie@^6.0.1: version "6.0.1" @@ -2045,16 +2570,21 @@ tr46@^6.0.0: dependencies: punycode "^2.3.1" +trim-lines@^3.0.0: + version "3.0.1" + resolved "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz" + integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== + +trough@^2.0.0: + version "2.2.0" + resolved "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz" + integrity sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw== + ts-api-utils@^2.4.0: version "2.4.0" resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz" integrity sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA== -tslib@^2.4.0: - version "2.8.1" - resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" - integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== - type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz" @@ -2083,7 +2613,58 @@ undici-types@~7.16.0: integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== undici@^7.24.3: - version "7.24.5" + version "7.25.0" + +unified@^11.0.0: + version "11.0.5" + resolved "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz" + integrity sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA== + dependencies: + "@types/unist" "^3.0.0" + bail "^2.0.0" + devlop "^1.0.0" + extend "^3.0.0" + is-plain-obj "^4.0.0" + trough "^2.0.0" + vfile "^6.0.0" + +unist-util-is@^6.0.0: + version "6.0.1" + resolved "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz" + integrity sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-position@^5.0.0: + version "5.0.0" + resolved "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz" + integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-stringify-position@^4.0.0: + version "4.0.0" + resolved "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz" + integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-visit-parents@^6.0.0: + version "6.0.2" + resolved "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz" + integrity sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + +unist-util-visit@^5.0.0: + version "5.1.0" + resolved "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz" + integrity sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" update-browserslist-db@^1.2.0: version "1.2.3" @@ -2100,6 +2681,22 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +vfile-message@^4.0.0: + version "4.0.3" + resolved "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz" + integrity sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-stringify-position "^4.0.0" + +vfile@^6.0.0: + version "6.0.3" + resolved "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz" + integrity sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q== + dependencies: + "@types/unist" "^3.0.0" + vfile-message "^4.0.0" + "vite@^6.0.0 || ^7.0.0 || ^8.0.0-0", vite@^8.0.0: version "8.0.0" resolved "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz" @@ -2188,9 +2785,9 @@ yallist@^3.0.2: integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== yaml@^1.10.0: - version "1.10.2" - resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz" - integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== + version "1.10.3" + resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz" + integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA== yocto-queue@^0.1.0: version "0.1.0" @@ -2206,3 +2803,8 @@ yocto-queue@^0.1.0: version "4.3.6" resolved "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz" integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== + +zwitch@^2.0.0: + version "2.0.4" + resolved "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz" + integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== diff --git a/tests/test_designation_upload.py b/tests/test_designation_upload.py index cbf4396..67a5ea4 100644 --- a/tests/test_designation_upload.py +++ b/tests/test_designation_upload.py @@ -31,7 +31,7 @@ def test_simple_column_expression( total = upload_designations( _mock_storage(), "test_table", - "name", + 'col("name")', 100, _mock_client(), report_func=lambda _: None, @@ -56,7 +56,7 @@ def test_composed_string_expression( total = upload_designations( _mock_storage(), "test_table", - 'prefix + " " + number', + 'col("prefix") + " " + col("number")', 100, _mock_client(), report_func=lambda _: None, @@ -73,7 +73,7 @@ def test_missing_referenced_columns(mock_fetch_column_units: Mock) -> None: upload_designations( _mock_storage(), "test_table", - "name", + 'col("name")', 100, _mock_client(), report_func=lambda _: None, @@ -94,7 +94,7 @@ def test_null_referenced_values_counted_as_unmatched( total = upload_designations( _mock_storage(), "test_table", - "name", + 'col("name")', 100, _mock_client(), report_func=lambda _: None, @@ -118,7 +118,7 @@ def test_expression_evaluation_error_becomes_runtime_error( upload_designations( _mock_storage(), "test_table", - "text_col + num_col", + 'col("text_col") + col("num_col")', 100, _mock_client(), report_func=lambda _: None, @@ -141,7 +141,7 @@ def test_output_file_writes_id_designation_pairs( upload_designations( _mock_storage(), "test_table", - "name", + 'col("name")', 100, _mock_client(), output_file=str(output_file), diff --git a/tests/test_formula_parse.py b/tests/test_formula_parse.py index 7a0ccb0..f5270e1 100644 --- a/tests/test_formula_parse.py +++ b/tests/test_formula_parse.py @@ -3,14 +3,13 @@ from uploader.app.lib.formula import ExpressionSyntaxError, parse PARSE_CASES: list[tuple[str, set[str] | type[ExpressionSyntaxError]]] = [ - ("e_logd25 + logd25", {"e_logd25", "logd25"}), ('col("a") + col("b")', {"a", "b"}), ('col("weird name")', {"weird name"}), ('sin(col("pa")) + pi', {"pa"}), - ("logd25 + logd25", {"logd25"}), - ('3 * 10 ** col("logd25") * e_logd25 * arcsec', {"logd25", "e_logd25"}), + ('3 * 10 ** col("logd25") * col("e_logd25") * arcsec', {"logd25", "e_logd25"}), ('"M " + col("id")', {"id"}), ("1 + 2", set()), + ("pi + deg", set()), ("", ExpressionSyntaxError), ("1 +", ExpressionSyntaxError), ("col(", ExpressionSyntaxError), @@ -20,6 +19,11 @@ ("col(x)", ExpressionSyntaxError), ('col("a", "b")', ExpressionSyntaxError), ("col(1)", ExpressionSyntaxError), + ("e_logd25 + logd25", ExpressionSyntaxError), + ("logd25 + logd25", ExpressionSyntaxError), + ('3 * 10 ** col("logd25") * e_logd25 * arcsec', ExpressionSyntaxError), + ("unknown(1)", ExpressionSyntaxError), + ("col", ExpressionSyntaxError), ] diff --git a/tests/test_geometry_upload.py b/tests/test_geometry_upload.py deleted file mode 100644 index f4d75a2..0000000 --- a/tests/test_geometry_upload.py +++ /dev/null @@ -1,127 +0,0 @@ -from unittest.mock import Mock, patch - -import pytest - -from uploader.app.structured.geometry.upload import upload_geometry_isophotal -from uploader.clients.gen.client import adminapi - - -def _mock_storage(total: int = 1) -> Mock: - storage = Mock() - storage.query.return_value = [{"cnt": total}] - return storage - - -def _mock_client() -> Mock: - return Mock(spec=adminapi.AuthenticatedClient) - - -def _base_expressions() -> dict[str, str]: - return { - "a": '3 * 10 ** col("logd25") * arcsec', - "e_a": '3 * 10 ** col("logd25") * 2.302585093 * e_logd25 * arcsec', - "b": '3 * 10 ** (col("logd25") - col("logr25")) * arcsec', - "e_b": ( - '3 * 10 ** (col("logd25") - col("logr25")) * 2.302585093 ' - '* (col("e_logd25") ** 2 + col("e_logr25") ** 2) ** 0.5 * arcsec' - ), - "isophote": 'col("bri25")', - } - - -@patch("uploader.app.structured.geometry.upload.rawdata_batches") -@patch("uploader.app.structured.geometry.upload.fetch_column_units") -def test_isophote_unit_conversion_error_includes_field_details( - mock_fetch_column_units: Mock, - mock_rawdata_batches: Mock, -) -> None: - mock_fetch_column_units.return_value = ( - {"logd25", "logr25", "e_logd25", "e_logr25", "bri25"}, - { - "logd25": "", - "logr25": "", - "e_logd25": "", - "e_logr25": "", - "bri25": "", - }, - ) - mock_rawdata_batches.return_value = iter( - [ - [ - { - "hyperleda_internal_id": "000079ce-5ffd-82c6-3f75-3a083f0fde80", - "logd25": 1.5, - "logr25": 0.3, - "e_logd25": 0.05, - "e_logr25": 0.04, - "bri25": 25.0, - }, - ], - ], - ) - - with pytest.raises(RuntimeError, match="failed to evaluate expressions for row") as exc_info: - upload_geometry_isophotal( - _mock_storage(), - "test_table", - "B", - _base_expressions(), - 100, - _mock_client(), - report_func=lambda _: None, - ) - - message = str(exc_info.value) - assert "isophote" in message - assert 'col("bri25")' in message - assert "mag/arcmin2" in message - assert "columns: bri25=''" in message - - -@patch("uploader.app.structured.geometry.upload.rawdata_batches") -@patch("uploader.app.structured.geometry.upload.fetch_column_units") -def test_constant_isophote_unit_error_omits_empty_columns( - mock_fetch_column_units: Mock, - mock_rawdata_batches: Mock, -) -> None: - mock_fetch_column_units.return_value = ( - {"logd25", "logr25", "e_logd25", "e_logr25"}, - { - "logd25": "", - "logr25": "", - "e_logd25": "", - "e_logr25": "", - }, - ) - mock_rawdata_batches.return_value = iter( - [ - [ - { - "hyperleda_internal_id": "000079ce-5ffd-82c6-3f75-3a083f0fde80", - "logd25": 1.5, - "logr25": 0.3, - "e_logd25": 0.05, - "e_logr25": 0.04, - }, - ], - ], - ) - expressions = _base_expressions() - expressions["isophote"] = "22" - - with pytest.raises(RuntimeError, match="failed to evaluate expressions for row") as exc_info: - upload_geometry_isophotal( - _mock_storage(), - "test_table", - "B", - expressions, - 100, - _mock_client(), - report_func=lambda _: None, - ) - - message = str(exc_info.value) - assert "isophote" in message - assert "('22')" in message - assert "from dimensionless to mag/arcmin2" in message - assert "columns:" not in message diff --git a/tests/test_structured_catalog.py b/tests/test_structured_catalog.py index 80a28a9..b0046b8 100644 --- a/tests/test_structured_catalog.py +++ b/tests/test_structured_catalog.py @@ -2,15 +2,10 @@ import pytest -from uploader.app.lib import expression, formula from uploader.app.structured.generic.upload import is_numeric_datatype, upload_catalog_columns from uploader.clients.gen.client import adminapi -from uploader.clients.gen.client.adminapi.models.catalog_field import CatalogField -from uploader.clients.gen.client.adminapi.models.catalog_schema import CatalogSchema from uploader.clients.gen.client.adminapi.models.datatype_enum import DatatypeEnum from uploader.forms.structured_catalog import ( - _task_description, - build_catalog_form, register_structured_catalog_tasks, ) from uploader.tasks import TASKS @@ -26,19 +21,6 @@ def _mock_client() -> Mock: return Mock(spec=adminapi.AuthenticatedClient) -def _sample_schema() -> CatalogSchema: - return CatalogSchema( - catalog="demo", - title="Demo catalog", - description="Demo structured catalog.", - fields=[ - CatalogField(name="label", data_type=DatatypeEnum.STRING, description="Object label"), - CatalogField(name="mag", data_type=DatatypeEnum.FLOAT, unit="mag", description="Magnitude"), - CatalogField(name="n", data_type=DatatypeEnum.INTEGER, required=False), - ], - ) - - def test_is_numeric_datatype() -> None: assert is_numeric_datatype(DatatypeEnum.FLOAT) assert is_numeric_datatype(DatatypeEnum.INTEGER) @@ -46,31 +28,6 @@ def test_is_numeric_datatype() -> None: assert not is_numeric_datatype(DatatypeEnum.TIMESTAMP_WITHOUT_TIME_ZONE) -def test_build_catalog_form_numeric_fields_are_expressions() -> None: - form_model = build_catalog_form(_sample_schema()) - fields = form_model.model_fields - - assert fields["label"].title == "label column" - assert fields["label"].description == "Object label" - - assert fields["mag"].title == "mag" - assert fields["mag"].description == "Expression. Magnitude" - - assert fields["n"].title == "n" - assert fields["n"].description == "Expression. Value for n." - assert fields["n"].is_required() is False - - -def test_task_description_matches_upload_expression_engine() -> None: - description = _task_description(_sample_schema()) - assert "Demo structured catalog." in description - assert expression.expression_syntax_help() in description - assert formula.expression_syntax_help() not in description - assert "str(x)" not in description - assert "String literals and + concatenation are supported." not in description - assert "Modulo divisors must carry units" not in description - - @patch("uploader.forms.structured_catalog.log.logger.warning") @patch("uploader.forms.structured_catalog.fetch_catalogs", side_effect=RuntimeError("api down")) def test_register_structured_catalog_tasks_logs_fetch_failure( @@ -89,7 +46,7 @@ def test_register_structured_catalog_tasks_logs_fetch_failure( @patch("uploader.app.structured.generic.upload.save_structured_data.sync_detailed") @patch("uploader.app.structured.generic.upload.rawdata_batches") @patch("uploader.app.structured.generic.upload.fetch_column_units") -def test_upload_evaluates_numeric_expressions( +def test_upload_evaluates_numeric_and_text_expressions( mock_fetch_column_units: Mock, mock_rawdata_batches: Mock, mock_sync_detailed: Mock, @@ -116,8 +73,8 @@ def test_upload_evaluates_numeric_expressions( _mock_storage(), "test_table", "demo", - column_map={"label": "name"}, expressions={ + "label": '"X-" + col("name")', "mag": 'col("e_ra")', "n": 'col("count") * 2', }, @@ -142,7 +99,88 @@ def test_upload_evaluates_numeric_expressions( assert body.catalog == "demo" assert body.columns == ["label", "mag", "n"] assert body.ids == ["000079ce-5ffd-82c6-3f75-3a083f0fde80"] - assert body.data == [["NGC 1", 0.5, 6]] + assert body.data == [["X-NGC 1", 0.5, 6]] + + +@patch("uploader.app.structured.generic.upload.handle_call") +@patch("uploader.app.structured.generic.upload.save_structured_data.sync_detailed") +@patch("uploader.app.structured.generic.upload.rawdata_batches") +@patch("uploader.app.structured.generic.upload.fetch_column_units") +def test_upload_text_column_and_str_concat( + mock_fetch_column_units: Mock, + mock_rawdata_batches: Mock, + mock_sync_detailed: Mock, + mock_handle_call: Mock, +) -> None: + mock_fetch_column_units.return_value = ( + {"name", "count"}, + {"name": "", "count": ""}, + ) + mock_rawdata_batches.return_value = iter( + [ + [ + { + "hyperleda_internal_id": "000079ce-5ffd-82c6-3f75-3a083f0fde80", + "name": "NGC 1", + "count": 3, + }, + ], + ], + ) + + total = upload_catalog_columns( + _mock_storage(), + "test_table", + "demo", + expressions={ + "label": 'col("name")', + "n": 'col("count")', + }, + field_types={ + "label": DatatypeEnum.STRING, + "n": DatatypeEnum.INTEGER, + }, + field_units={}, + columns=["label", "n"], + batch_size=100, + client=_mock_client(), + write=True, + report_func=lambda _: None, + ) + + assert total == 1 + body = mock_sync_detailed.call_args.kwargs["body"] + assert body.data == [["NGC 1", 3]] + + mock_rawdata_batches.return_value = iter( + [ + [ + { + "hyperleda_internal_id": "000079ce-5ffd-82c6-3f75-3a083f0fde80", + "name": "NGC 1", + "count": 3, + }, + ], + ], + ) + total = upload_catalog_columns( + _mock_storage(), + "test_table", + "demo", + expressions={ + "label": '"n=" + str(col("count"))', + }, + field_types={"label": DatatypeEnum.STRING}, + field_units={}, + columns=["label"], + batch_size=100, + client=_mock_client(), + write=True, + report_func=lambda _: None, + ) + assert total == 1 + body = mock_sync_detailed.call_args.kwargs["body"] + assert body.data == [["n=3"]] @patch("uploader.app.structured.generic.upload.rawdata_batches") @@ -171,7 +209,6 @@ def test_upload_unit_conversion_error_includes_field_details( _mock_storage(), "test_table", "demo", - column_map={}, expressions={"mag": 'col("bt")'}, field_types={"mag": DatatypeEnum.FLOAT}, field_units={"mag": "arcsec"}, diff --git a/uploader/app/lib/formula/core.py b/uploader/app/lib/formula/core.py index cf1fdfa..b47d315 100644 --- a/uploader/app/lib/formula/core.py +++ b/uploader/app/lib/formula/core.py @@ -63,10 +63,18 @@ def visit_Call(self, node: ast.Call) -> None: if column is not None: self.columns.add(column) return + if not isinstance(node.func, ast.Name): + raise ValueError("only simple function calls are allowed") + if node.func.id not in FUNCTIONS: + raise ValueError(f"unknown function: {node.func.id}") + if node.keywords: + raise ValueError("keyword arguments are not allowed") for arg in node.args: self.visit(arg) def visit_Name(self, node: ast.Name) -> None: if node.id in NAMED_CONSTANTS or node.id in FUNCTIONS: return - self.columns.add(node.id) + raise ValueError( + f"unknown name {node.id!r}; use {COL_FUNCTION}() for columns or a predefined constant/function", + ) diff --git a/uploader/app/lib/formula/namespace.py b/uploader/app/lib/formula/namespace.py index cc73c2a..0d469dc 100644 --- a/uploader/app/lib/formula/namespace.py +++ b/uploader/app/lib/formula/namespace.py @@ -45,27 +45,37 @@ def _formula_str(value: Value) -> str | np.ndarray: def build_namespace(columns: Mapping[str, Value]) -> dict[str, object]: - bare = {name: value for name, value in columns.items() if name.isidentifier()} return { "__builtins__": {}, COL_FUNCTION: lambda name: columns[name], - **bare, **NAMED_CONSTANTS, **FUNCTIONS, } def expression_syntax_help() -> str: - constants = ", ".join(sorted(NAMED_CONSTANTS)) - return ( - f'Use {COL_FUNCTION}("name") or bare identifiers to refer to rawdata columns ' - '(e.g. col("a"), e_logd25).\n' - "Bare identifiers that match predefined constants use those constants.\n" - "Operators: + - * / ** %.\n" - "Functions: sin(x), cos(x) (argument must be an angle), str(x).\n" - "Numbers are dimensionless.\n" - "String literals and + concatenation are supported.\n" - 'Modulo divisors must carry units (e.g. col("pa") % (180 * deg)).\n' - "Log columns (mag/dex) yield the bare exponent; multiply by the scale yourself.\n" - f"Available constants: {constants}." - ) + constants = ", ".join(f"`{name}`" for name in sorted(NAMED_CONSTANTS)) + return f"""\ +## Expression syntax + +Use `{COL_FUNCTION}("name")` to refer to rawdata columns (e.g. `col("a")`). +Expressions are unit-aware and units are taken from column metadata. + +Mathematical operations: +- Operators: `+` `-` `*` `/` `**` `%` +- Functions: `sin(x)`, `cos(x)` (argument must be an angle), `str(x)` +- Numbers are dimensionless +- String literals and `+` concatenation are supported +- Modulo divisors must carry units (e.g. `col("pa") % (180 * deg)`) +- Log columns (`mag`/`dex`) yield the bare exponent; multiply by the scale yourself + +Available constants: {constants}. + +## Examples + +- Fill a column with a constant: + - `1.5` + - `180 * deg` + - `"G"` - fills the column with a text "G" +- Copy another column: `{COL_FUNCTION}("ra")` +- Mathematical expression: `3 * 10 ** {COL_FUNCTION}("logd25") * arcsec`""" diff --git a/uploader/app/structured/generic/upload.py b/uploader/app/structured/generic/upload.py index d03645d..899ac1b 100644 --- a/uploader/app/structured/generic/upload.py +++ b/uploader/app/structured/generic/upload.py @@ -1,12 +1,21 @@ from collections.abc import Callable, Mapping, Sequence from typing import Any +import astropy.units as u +import numpy as np from psycopg import sql import uploader.app.action_description as action_description import uploader.app.report as report from uploader.app.display import format_table -from uploader.app.lib.expression import Expression, evaluate_to_float, parse +from uploader.app.lib.formula import ( + Expression, + ExpressionEvaluationError, + Value, + column_quantity, + evaluate, + parse, +) from uploader.app.lib.rawdata import rawdata_batches from uploader.app.lib.table import fetch_column_units, validate_columns from uploader.app.storage import PgStorage @@ -52,33 +61,121 @@ def is_numeric_datatype(data_type: DatatypeEnum) -> bool: return data_type in _NUMERIC_TYPES +def _format_unit(unit: u.UnitBase) -> str: + text = f"{unit:s}".strip() + return text if text else "dimensionless" + + +def _eval_context_suffix(expr: Expression, column_units: Mapping[str, str]) -> str: + if not expr.referenced_columns: + return "" + parts = [f"{col}={column_units.get(col, '')!r}" for col in sorted(expr.referenced_columns)] + return f"; columns: {', '.join(parts)}" + + +def _as_quantity(value: object) -> u.Quantity: + if isinstance(value, u.Quantity): + return value + if isinstance(value, (int, float, np.number)) and not isinstance(value, bool): + return float(value) * u.dimensionless_unscaled + raise ValueError(f"numeric expression must evaluate to a quantity, got {type(value).__name__}") + + +def _scalar_numeric(quantity: u.Quantity) -> float: + scalar = quantity.value + if isinstance(scalar, np.ndarray): + if scalar.shape != (): + raise ValueError("expression must evaluate to a scalar value per row") + return float(scalar) + return float(scalar) + + +def _scalar_to_str(value: float | int | np.number) -> str: + numeric = float(value) + if numeric.is_integer(): + return str(int(numeric)) + return str(numeric) + + +def _value_to_str(value: Value) -> str: + if isinstance(value, str): + return value + if isinstance(value, u.Quantity): + scalar = value.value + if isinstance(scalar, np.ndarray): + if scalar.shape != (): + raise ValueError("expression must evaluate to a scalar value per row") + return _scalar_to_str(scalar.item()) + return _scalar_to_str(scalar) + if value.shape != (): + raise ValueError("expression must evaluate to a scalar value per row") + item = value.item() + if isinstance(item, str): + return item + if isinstance(item, (int, float, np.number)): + return _scalar_to_str(item) + raise ValueError(f"unsupported expression result type: {type(item).__name__}") + + def _evaluate_numeric_field( expr: Expression, - values: dict[str, float], - column_units: dict[str, str], + columns: Mapping[str, Value], + column_units: Mapping[str, str], field: str, source: str, target_unit: str | None, data_type: DatatypeEnum, ) -> float | int: - value = evaluate_to_float( - expr, - values, - column_units, - field, - source, - target_unit if target_unit is not None else "", - ) + try: + quantity = _as_quantity(evaluate(expr, columns)) + except (ExpressionEvaluationError, ValueError, u.UnitConversionError, u.UnitTypeError) as e: + raise RuntimeError( + f"failed to evaluate {field!r} ({source!r}){_eval_context_suffix(expr, column_units)}: {e}", + ) from e + try: + if target_unit: + converted = quantity.to(u.Unit(target_unit)) + else: + converted = quantity.to(u.dimensionless_unscaled) + value = _scalar_numeric(converted) + except (ValueError, u.UnitConversionError, u.UnitTypeError) as e: + raise RuntimeError( + f"failed to convert {field!r} ({source!r}) " + f"from {_format_unit(quantity.unit)} to {target_unit or 'dimensionless'}" + f"{_eval_context_suffix(expr, column_units)}: {e}", + ) from e if data_type in _INT_TYPES: return int(value) return value +def _evaluate_text_field( + expr: Expression, + columns: Mapping[str, Value], + column_units: Mapping[str, str], + field: str, + source: str, +) -> str: + try: + return _value_to_str(evaluate(expr, columns)) + except (ExpressionEvaluationError, ValueError, TypeError) as e: + raise RuntimeError( + f"failed to evaluate {field!r} ({source!r}){_eval_context_suffix(expr, column_units)}: {e}", + ) from e + + +def _build_column_values( + row: Mapping[str, Any], + referenced_columns: frozenset[str], + column_units: Mapping[str, str], +) -> dict[str, Value]: + return {col: column_quantity(row[col], column_units.get(col, "")) for col in referenced_columns} + + def upload_catalog_columns( storage: PgStorage, table_name: str, catalog: str, - column_map: Mapping[str, str], expressions: Mapping[str, str], field_types: Mapping[str, DatatypeEnum], field_units: Mapping[str, str], @@ -93,9 +190,7 @@ def upload_catalog_columns( raise RuntimeError("No catalog columns selected for upload") parsed = {field: parse(source) for field, source in expressions.items()} - expr_cols = set().union(*(expr.referenced_columns for expr in parsed.values())) if parsed else set() - mapped_cols = set(column_map.values()) - all_needed_cols = expr_cols | mapped_cols + all_needed_cols = set().union(*(expr.referenced_columns for expr in parsed.values())) if parsed else set() column_names, column_units = fetch_column_units(client, table_name) validate_columns(table_name, all_needed_cols, column_names) @@ -122,24 +217,34 @@ def upload_catalog_columns( skipped += 1 continue - values = {col: float(row[col]) for col in expr_cols} + column_values = _build_column_values(row, frozenset(all_needed_cols), column_units) row_values: list[Any] = [] try: for name in columns: - if name in parsed: + expr = parsed[name] + source = expressions[name] + if is_numeric_datatype(field_types[name]): row_values.append( _evaluate_numeric_field( - parsed[name], - values, + expr, + column_values, column_units, name, - expressions[name], + source, field_units.get(name), field_types[name], ) ) else: - row_values.append(str(row[column_map[name]])) + row_values.append( + _evaluate_text_field( + expr, + column_values, + column_units, + name, + source, + ) + ) except RuntimeError as e: raise RuntimeError( f"failed to evaluate expressions for row {row['hyperleda_internal_id']}: {e}", diff --git a/uploader/app/structured/geometry/__init__.py b/uploader/app/structured/geometry/__init__.py deleted file mode 100644 index 20acc88..0000000 --- a/uploader/app/structured/geometry/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from uploader.app.structured.geometry.upload import upload_geometry_isophotal - -__all__ = ["upload_geometry_isophotal"] diff --git a/uploader/app/structured/geometry/upload.py b/uploader/app/structured/geometry/upload.py deleted file mode 100644 index 1edc37f..0000000 --- a/uploader/app/structured/geometry/upload.py +++ /dev/null @@ -1,301 +0,0 @@ -from collections.abc import Callable - -import matplotlib.pyplot as plt -import numpy as np -from psycopg import sql - -import uploader.app.action_description as action_description -import uploader.app.report as report -from uploader.app.display import format_table -from uploader.app.lib.expression import Expression, evaluate_to_float, parse -from uploader.app.lib.rawdata import rawdata_batches -from uploader.app.lib.table import fetch_column_units, validate_columns -from uploader.app.storage import PgStorage -from uploader.app.upload import handle_call -from uploader.clients.gen.client import adminapi -from uploader.clients.gen.client.adminapi.api.default import save_structured_data -from uploader.clients.gen.client.adminapi.models.save_structured_data_request import ( - SaveStructuredDataRequest, -) -from uploader.clients.gen.client.adminapi.models.save_structured_data_request_units import ( - SaveStructuredDataRequestUnits, -) - -BASE_GEOMETRY_COLUMNS = ["band", "method", "isophote", "a", "e_a", "b", "e_b"] -OPTIONAL_GEOMETRY_COLUMNS = ["pa", "e_pa"] - -TARGET_UNITS = { - "a": "arcsec", - "e_a": "arcsec", - "b": "arcsec", - "e_b": "arcsec", - "pa": "deg", - "e_pa": "deg", - "isophote": "mag/arcmin2", -} - -CHART_FIGSIZE = (12, 6) -N_AXIS_BINS = 80 -AXIS_BIN_MIN = 0.05 -AXIS_BIN_MAX = 500.0 -AXIS_BIN_EDGES = np.logspace(np.log10(AXIS_BIN_MIN), np.log10(AXIS_BIN_MAX), N_AXIS_BINS + 1) - - -def _positive_histogram(values: list[float]) -> np.ndarray: - if not values: - return np.zeros(N_AXIS_BINS, dtype=np.int64) - positive = np.asarray([v for v in values if v > 0], dtype=np.float64) - if positive.size == 0: - return np.zeros(N_AXIS_BINS, dtype=np.int64) - batch_counts, _ = np.histogram(positive, bins=AXIS_BIN_EDGES) - return batch_counts.astype(np.int64) - - -def _plot_axis_panel( - ax: plt.Axes, - counts: np.ndarray, - *, - xlabel: str, - title: str, - vmin: float | None, - vmax: float | None, - vmean: float | None, -) -> None: - centers = np.sqrt(AXIS_BIN_EDGES[:-1] * AXIS_BIN_EDGES[1:]) - widths = np.diff(AXIS_BIN_EDGES) - ax.bar(centers, counts, width=widths, align="center") - ax.set_xscale("log") - ax.set_yscale("log") - ax.set_xlabel(xlabel) - ax.set_ylabel("Count") - ax.set_title(title) - if vmin is not None and vmin > 0: - ax.axvline(vmin, color="gray", linestyle=":", linewidth=1) - if vmax is not None and vmax > 0: - ax.axvline(vmax, color="gray", linestyle=":", linewidth=1) - if vmean is not None and vmean > 0: - ax.axvline(vmean, color="red", linestyle="--", linewidth=1, label=f"mean={vmean:.2f}") - ax.legend(loc="upper right", fontsize="small") - - -class _GeometryDistributionAccumulator: - def __init__(self) -> None: - self._a_counts = np.zeros(N_AXIS_BINS, dtype=np.int64) - self._b_counts = np.zeros(N_AXIS_BINS, dtype=np.int64) - - def add(self, a_values: list[float], b_values: list[float]) -> None: - self._a_counts += _positive_histogram(a_values) - self._b_counts += _positive_histogram(b_values) - - @property - def total(self) -> int: - return int(self._a_counts.sum()) - - def emit_image( - self, - report_func: Callable[[report.Event], None], - *, - caption: str, - a_mean: float | None = None, - a_min: float | None = None, - a_max: float | None = None, - b_mean: float | None = None, - b_min: float | None = None, - b_max: float | None = None, - ) -> None: - if self.total == 0: - return - fig, (ax_a, ax_b) = plt.subplots(1, 2, figsize=CHART_FIGSIZE) - _plot_axis_panel( - ax_a, - self._a_counts, - xlabel="a (arcsec)", - title="Semi-major axis distribution", - vmin=a_min, - vmax=a_max, - vmean=a_mean, - ) - _plot_axis_panel( - ax_b, - self._b_counts, - xlabel="b (arcsec)", - title="Semi-minor axis distribution", - vmin=b_min, - vmax=b_max, - vmean=b_mean, - ) - fig.tight_layout() - report_func(report.image_event_from_figure(fig, caption=caption)) - - -def _parse_expressions(expressions: dict[str, str]) -> dict[str, Expression]: - return {field: parse(source) for field, source in expressions.items()} - - -def upload_geometry_isophotal( - storage: PgStorage, - table_name: str, - band: str, - expressions: dict[str, str], - batch_size: int, - client: adminapi.AuthenticatedClient, - *, - write: bool = False, - report_func: Callable[[report.Event], None], -) -> int: - parsed = _parse_expressions(expressions) - geometry_columns = BASE_GEOMETRY_COLUMNS + [col for col in OPTIONAL_GEOMETRY_COLUMNS if col in parsed] - geometry_units = SaveStructuredDataRequestUnits.from_dict( - {col: TARGET_UNITS[col] for col in geometry_columns if col in TARGET_UNITS}, - ) - needed_cols = set().union(*(expr.referenced_columns for expr in parsed.values())) - column_names, column_units = fetch_column_units(client, table_name) - validate_columns(table_name, needed_cols, column_names) - - uploaded = 0 - skipped = 0 - a_min = float("inf") - a_max = float("-inf") - a_sum = 0.0 - b_min = float("inf") - b_max = float("-inf") - b_sum = 0.0 - cnt = storage.query( - sql.SQL("SELECT COUNT(*) AS cnt FROM rawdata.{}").format(sql.Identifier(table_name)), - (), - ) - total_count = int(cnt[0]["cnt"]) if cnt else 0 - processed_rows = 0 - axis_dist = _GeometryDistributionAccumulator() - - for rows in rawdata_batches(storage, table_name, sorted(needed_cols), batch_size): - batch_ids: list[str] = [] - batch_data: list[list[str | float]] = [] - batch_a: list[float] = [] - batch_b: list[float] = [] - - for row in rows: - if any(row[col] is None for col in needed_cols): - skipped += 1 - continue - - values = {col: float(row[col]) for col in needed_cols} - try: - evaluated = { - field: evaluate_to_float( - expr, - values, - column_units, - field, - expressions[field], - TARGET_UNITS[field], - ) - for field, expr in parsed.items() - } - except RuntimeError as e: - raise RuntimeError( - f"failed to evaluate expressions for row {row['hyperleda_internal_id']}: {e}", - ) from e - - row_data: dict[str, str | float | None] = { - "band": band, - "method": "isophotal", - "isophote": evaluated["isophote"], - "a": evaluated["a"], - "e_a": evaluated["e_a"], - "b": evaluated["b"], - "e_b": evaluated["e_b"], - } - for col in OPTIONAL_GEOMETRY_COLUMNS: - if col in parsed: - row_data[col] = evaluated[col] - - batch_ids.append(row["hyperleda_internal_id"]) - batch_data.append([row_data[col] for col in geometry_columns]) - uploaded += 1 - a_val = evaluated["a"] - a_min = min(a_min, a_val) - a_max = max(a_max, a_val) - a_sum += a_val - batch_a.append(a_val) - b_val = evaluated["b"] - b_min = min(b_min, b_val) - b_max = max(b_max, b_val) - b_sum += b_val - batch_b.append(b_val) - - axis_dist.add(batch_a, batch_b) - - if write and batch_ids: - handle_call( - save_structured_data.sync_detailed( - client=client, - body=action_description.apply( - SaveStructuredDataRequest( - catalog="geometry", - columns=geometry_columns, - ids=batch_ids, - data=batch_data, - units=geometry_units, - ), - ), - ), - ) - - processed_rows += len(rows) - row_pct = int(100 * processed_rows / total_count) if total_count else 0 - report_func(report.ProgressEvent(percent=min(99, row_pct))) - report_func( - report.LogEvent( - message=f"batch: rows_read={len(rows)} uploaded={uploaded} skipped={skipped}", - ), - ) - if uploaded > 0: - axis_dist.emit_image( - report_func, - caption=f"a/b distribution: {uploaded} objects", - a_mean=a_sum / uploaded, - a_min=a_min, - a_max=a_max, - b_mean=b_sum / uploaded, - b_min=b_min, - b_max=b_max, - ) - - total = uploaded + skipped - - def row_pct_label(n: int) -> float: - return (100.0 * n / total) if total else 0.0 - - table_rows: list[tuple[str, int | float, float | str]] = [ - ("Uploaded", uploaded, row_pct_label(uploaded)), - ("Skipped (null)", skipped, row_pct_label(skipped)), - ] - if uploaded > 0: - a_mean = a_sum / uploaded - table_rows.extend( - [ - ("a min (arcsec)", round(a_min, 3), "-"), - ("a max (arcsec)", round(a_max, 3), "-"), - ("a mean (arcsec)", round(a_mean, 3), "-"), - ], - ) - report_func(report.ProgressEvent(percent=100)) - if uploaded > 0: - axis_dist.emit_image( - report_func, - caption=f"Final: {uploaded} objects", - a_mean=a_sum / uploaded, - a_min=a_min, - a_max=a_max, - b_mean=b_sum / uploaded, - b_min=b_min, - b_max=b_max, - ) - summary = format_table( - ("Status", "Count", "%"), - table_rows, - title=f"Total rows: {total}\n", - ) - report_func(report.DoneEvent(message=summary)) - return total diff --git a/uploader/app/structured/icrs/__init__.py b/uploader/app/structured/icrs/__init__.py deleted file mode 100644 index e88555b..0000000 --- a/uploader/app/structured/icrs/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from uploader.app.structured.icrs.upload import upload_icrs - -__all__ = ["upload_icrs"] diff --git a/uploader/app/structured/icrs/upload.py b/uploader/app/structured/icrs/upload.py deleted file mode 100644 index 00e788b..0000000 --- a/uploader/app/structured/icrs/upload.py +++ /dev/null @@ -1,237 +0,0 @@ -from collections.abc import Callable - -import matplotlib.pyplot as plt -import numpy as np -from psycopg import sql - -import uploader.app.action_description as action_description -import uploader.app.report as report -from uploader.app.display import format_table -from uploader.app.lib.expression import Expression, evaluate_to_float, parse -from uploader.app.lib.rawdata import rawdata_batches -from uploader.app.lib.table import fetch_column_units, validate_columns -from uploader.app.storage import PgStorage -from uploader.app.upload import handle_call -from uploader.clients.gen.client import adminapi -from uploader.clients.gen.client.adminapi.api.default import save_structured_data -from uploader.clients.gen.client.adminapi.models.save_structured_data_request import ( - SaveStructuredDataRequest, -) -from uploader.clients.gen.client.adminapi.models.save_structured_data_request_units import ( - SaveStructuredDataRequestUnits, -) - -ICRS_COLUMNS = ["ra", "dec", "e_ra", "e_dec"] - -CHART_FIGSIZE = (10, 5) -N_RA_BINS = 72 -N_DEC_BINS = 36 -RA_BIN_EDGES = np.linspace(-180.0, 180.0, N_RA_BINS + 1) -DEC_BIN_EDGES = np.linspace(-90.0, 90.0, N_DEC_BINS + 1) - - -def _ra_to_longitude_deg(ra: np.ndarray) -> np.ndarray: - return (np.mod(ra, 360.0) + 180.0) % 360.0 - 180.0 - - -class _SkyCoverageAccumulator: - def __init__(self) -> None: - self._counts = np.zeros((N_RA_BINS, N_DEC_BINS), dtype=np.int64) - - def add(self, ra: list[float], dec: list[float]) -> None: - if not ra: - return - ra_arr = _ra_to_longitude_deg(np.asarray(ra, dtype=np.float64)) - dec_arr = np.clip(np.asarray(dec, dtype=np.float64), -90.0, 90.0) - batch_counts, _, _ = np.histogram2d(ra_arr, dec_arr, bins=[RA_BIN_EDGES, DEC_BIN_EDGES]) - self._counts += batch_counts.astype(np.int64) - - @property - def total(self) -> int: - return int(self._counts.sum()) - - def emit_image( - self, - report_func: Callable[[report.Event], None], - *, - caption: str, - ) -> None: - if self.total == 0: - return - ra_centers = 0.5 * (RA_BIN_EDGES[:-1] + RA_BIN_EDGES[1:]) - dec_centers = 0.5 * (DEC_BIN_EDGES[:-1] + DEC_BIN_EDGES[1:]) - theta, phi = np.meshgrid(-np.deg2rad(ra_centers), np.deg2rad(dec_centers)) - fig = plt.figure(figsize=CHART_FIGSIZE) - ax = fig.add_subplot(111, projection="aitoff") - ax.pcolormesh(theta, phi, self._counts.T, shading="auto", cmap="viridis") - ax.set_title("ICRS sky coverage") - ax.grid(True) - report_func(report.image_event_from_figure(fig, caption=caption)) - - -TARGET_ERROR_UNITS = { - "e_ra": "arcsec", - "e_dec": "arcsec", -} - - -def _parse_expressions(expressions: dict[str, str]) -> dict[str, Expression]: - return {field: parse(source) for field, source in expressions.items()} - - -def upload_icrs( - storage: PgStorage, - table_name: str, - ra_column: str, - dec_column: str, - expressions: dict[str, str], - batch_size: int, - client: adminapi.AuthenticatedClient, - *, - write: bool = False, - report_func: Callable[[report.Event], None], -) -> int: - parsed = _parse_expressions(expressions) - column_names, column_units = fetch_column_units(client, table_name) - - error_cols = set().union(*(expr.referenced_columns for expr in parsed.values())) - all_needed_cols = {ra_column, dec_column} | error_cols - validate_columns(table_name, all_needed_cols, column_names) - - missing_units = [c for c in (ra_column, dec_column) if c not in column_units] - if missing_units: - raise RuntimeError(f"Table {table_name} has no unit for column(s): {missing_units}") - - units = SaveStructuredDataRequestUnits.from_dict( - { - "ra": column_units[ra_column], - "dec": column_units[dec_column], - **TARGET_ERROR_UNITS, - } - ) - - uploaded = 0 - skipped = 0 - ra_min = float("inf") - ra_max = float("-inf") - dec_min = float("inf") - dec_max = float("-inf") - ra_sum = 0.0 - dec_sum = 0.0 - cnt = storage.query( - sql.SQL("SELECT COUNT(*) AS cnt FROM rawdata.{}").format(sql.Identifier(table_name)), - (), - ) - total_count = int(cnt[0]["cnt"]) if cnt else 0 - processed_rows = 0 - sky = _SkyCoverageAccumulator() - - fetch_columns = sorted(all_needed_cols) - for rows in rawdata_batches(storage, table_name, fetch_columns, batch_size): - batch_ids: list[str] = [] - batch_data: list[list[float]] = [] - batch_ra: list[float] = [] - batch_dec: list[float] = [] - - for row in rows: - if any(row[col] is None for col in all_needed_cols): - skipped += 1 - continue - - ra_f = float(row[ra_column]) - dec_f = float(row[dec_column]) - - values = {col: float(row[col]) for col in error_cols} - try: - e_ra_val = evaluate_to_float( - parsed["e_ra"], - values, - column_units, - "e_ra", - expressions["e_ra"], - TARGET_ERROR_UNITS["e_ra"], - ) - e_dec_val = evaluate_to_float( - parsed["e_dec"], - values, - column_units, - "e_dec", - expressions["e_dec"], - TARGET_ERROR_UNITS["e_dec"], - ) - except RuntimeError as e: - raise RuntimeError( - f"failed to evaluate expressions for row {row['hyperleda_internal_id']}: {e}", - ) from e - - batch_ids.append(row["hyperleda_internal_id"]) - batch_data.append([ra_f, dec_f, e_ra_val, e_dec_val]) - uploaded += 1 - ra_min = min(ra_min, ra_f) - ra_max = max(ra_max, ra_f) - dec_min = min(dec_min, dec_f) - dec_max = max(dec_max, dec_f) - ra_sum += ra_f - dec_sum += dec_f - batch_ra.append(ra_f) - batch_dec.append(dec_f) - - sky.add(batch_ra, batch_dec) - - if write and batch_ids: - handle_call( - save_structured_data.sync_detailed( - client=client, - body=action_description.apply( - SaveStructuredDataRequest( - catalog="icrs", - columns=ICRS_COLUMNS, - ids=batch_ids, - data=batch_data, - units=units, - ), - ), - ) - ) - - processed_rows += len(rows) - row_pct = int(100 * processed_rows / total_count) if total_count else 0 - report_func(report.ProgressEvent(percent=min(99, row_pct))) - report_func( - report.LogEvent( - message=f"batch: rows_read={len(rows)} uploaded={uploaded} skipped={skipped}", - ), - ) - sky.emit_image(report_func, caption=f"Sky coverage: {uploaded} objects") - - total = uploaded + skipped - - def row_pct_label(n: int) -> float: - return (100.0 * n / total) if total else 0.0 - - table_rows: list[tuple[str, int | float, float | str]] = [ - ("Uploaded", uploaded, row_pct_label(uploaded)), - ("Skipped (null)", skipped, row_pct_label(skipped)), - ] - if uploaded > 0: - ra_mean = ra_sum / uploaded - dec_mean = dec_sum / uploaded - table_rows.extend( - [ - ("RA min", round(ra_min, 6), "-"), - ("RA max", round(ra_max, 6), "-"), - ("RA mean", round(ra_mean, 6), "-"), - ("Dec min", round(dec_min, 6), "-"), - ("Dec max", round(dec_max, 6), "-"), - ("Dec mean", round(dec_mean, 6), "-"), - ] - ) - report_func(report.ProgressEvent(percent=100)) - sky.emit_image(report_func, caption=f"Final: {uploaded} objects") - summary = format_table( - ("Status", "Count", "%"), - table_rows, - title=f"Total rows: {total}\n", - ) - report_func(report.DoneEvent(message=summary)) - return total diff --git a/uploader/app/structured/photometry/__init__.py b/uploader/app/structured/photometry/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/uploader/app/structured/photometry/upload.py b/uploader/app/structured/photometry/upload.py deleted file mode 100644 index 96d0f94..0000000 --- a/uploader/app/structured/photometry/upload.py +++ /dev/null @@ -1,131 +0,0 @@ -from collections.abc import Callable - -from psycopg import sql - -import uploader.app.action_description as action_description -import uploader.app.report as report -from uploader.app import log -from uploader.app.display import format_table -from uploader.app.lib.rawdata import rawdata_batches -from uploader.app.storage import PgStorage -from uploader.app.upload import handle_call -from uploader.clients.gen.client import adminapi -from uploader.clients.gen.client.adminapi.api.default import save_structured_data -from uploader.clients.gen.client.adminapi.models.save_structured_data_request import ( - SaveStructuredDataRequest, -) -from uploader.clients.gen.client.adminapi.models.save_structured_data_request_units import ( - SaveStructuredDataRequestUnits, -) - -PHOTOMETRY_COLUMNS = ["band", "mag", "e_mag", "method"] - -PHOTOMETRY_UNITS = SaveStructuredDataRequestUnits.from_dict({"mag": "mag", "e_mag": "mag"}) - -BANDS = [ - ("U", "ut", "e_ut"), - ("B", "bt", "e_bt"), - ("V", "vt", "e_vt"), - ("I", "it", "e_it"), - ("K", "kt", "e_kt"), -] - -PHOTOMETRY_RAW_COLUMNS = [c for _, mag, err in BANDS for c in (mag, err)] - - -def upload_photometry_hyperleda( - storage: PgStorage, - table_name: str, - batch_size: int, - client: adminapi.AuthenticatedClient, - report_func: Callable[[report.Event], None], - *, - write: bool = False, -) -> None: - uploaded_objects = 0 - skipped = 0 - total_source_rows = 0 - total_rows = int( - storage.query(sql.SQL("SELECT COUNT(*) AS cnt FROM rawdata.{t}").format(t=sql.Identifier(table_name)))[0]["cnt"] - ) - report_func(report.LogEvent(message=f"Starting photometry upload for {table_name} ({total_rows} rows).")) - band_counts: dict[str, int] = {band: 0 for band, _, _ in BANDS} - band_mag_sums: dict[str, float] = {band: 0.0 for band, _, _ in BANDS} - - try: - for rows in rawdata_batches(storage, table_name, PHOTOMETRY_RAW_COLUMNS, batch_size): - total_source_rows += len(rows) - batch_ids: list[str] = [] - batch_data: list[list[str | float]] = [] - - for row in rows: - internal_id = row["hyperleda_internal_id"] - had_any = False - for band, mag_col, err_col in BANDS: - mag_val = row.get(mag_col) - err_val = row.get(err_col) - if mag_val is not None and err_val is not None: - batch_ids.append(internal_id) - batch_data.append([band, float(mag_val), float(err_val), "asymptotic"]) - band_counts[band] += 1 - band_mag_sums[band] += float(mag_val) - had_any = True - if had_any: - uploaded_objects += 1 - else: - skipped += 1 - - if write and batch_ids: - handle_call( - save_structured_data.sync_detailed( - client=client, - body=action_description.apply( - SaveStructuredDataRequest( - catalog="photometry", - columns=PHOTOMETRY_COLUMNS, - ids=batch_ids, - data=batch_data, - units=PHOTOMETRY_UNITS, - ), - ), - ) - ) - - uploaded_rows = sum(band_counts.values()) - log.logger.info( - "processed batch", - source_rows=len(rows), - total_source_rows=total_source_rows, - objects=uploaded_objects, - photometry_rows=uploaded_rows, - ) - progress = 100.0 if total_rows == 0 else (100.0 * total_source_rows / total_rows) - report_func(report.ProgressEvent(percent=min(progress, 100.0))) - finally: - total = uploaded_objects + skipped - total_photometry_rows = sum(band_counts.values()) - - def pct(n: int, denom: int) -> float: - return (100.0 * n / denom) if denom else 0.0 - - table_rows: list[tuple[str | int | float, ...]] = [ - ("Source rows with ≥1 band", uploaded_objects, f"{pct(uploaded_objects, total):.1f}%", "-"), - ("Source rows with no band", skipped, f"{pct(skipped, total):.1f}%", "-"), - ("Total photometry rows", total_photometry_rows, "-", "-"), - ] - for band, _, _ in BANDS: - count = band_counts[band] - avg_mag = (band_mag_sums[band] / count) if count else 0.0 - pct_str = f"{pct(count, total_photometry_rows):.1f}%" if total_photometry_rows else "-" - avg_str = round(avg_mag, 3) if count else "-" - table_rows.append((band, count, pct_str, avg_str)) - - summary = format_table( - ("Status", "Uploaded", "% of total", "Avg mag"), - table_rows, - title=f"Total source rows: {total}\n", - percent_last_column=False, - ) - - report_func(report.ProgressEvent(percent=100)) - report_func(report.DoneEvent(message=summary)) diff --git a/uploader/app/structured/redshift/__init__.py b/uploader/app/structured/redshift/__init__.py deleted file mode 100644 index 2ae9ae5..0000000 --- a/uploader/app/structured/redshift/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from uploader.app.structured.redshift.upload import upload_redshift - -__all__ = ["upload_redshift"] diff --git a/uploader/app/structured/redshift/upload.py b/uploader/app/structured/redshift/upload.py deleted file mode 100644 index 4d2e4e4..0000000 --- a/uploader/app/structured/redshift/upload.py +++ /dev/null @@ -1,191 +0,0 @@ -from collections.abc import Callable - -import matplotlib.pyplot as plt -import numpy as np -from psycopg import sql - -import uploader.app.action_description as action_description -import uploader.app.report as report -from uploader.app.display import format_table -from uploader.app.lib.rawdata import rawdata_batches -from uploader.app.storage import PgStorage -from uploader.app.upload import handle_call -from uploader.clients.gen.client import adminapi -from uploader.clients.gen.client.adminapi.api.default import save_structured_data -from uploader.clients.gen.client.adminapi.models.save_structured_data_request import ( - SaveStructuredDataRequest, -) -from uploader.clients.gen.client.adminapi.models.save_structured_data_request_units import ( - SaveStructuredDataRequestUnits, -) - -C_KM_S = 299792.458 - -REDSHIFT_COLUMNS = ["cz", "e_cz"] - -REDSHIFT_UNITS = SaveStructuredDataRequestUnits.from_dict({"cz": "km/s", "e_cz": "km/s"}) - -CHART_FIGSIZE = (8, 6) -N_CZ_BINS = 80 -CZ_BIN_MIN = -10_000.0 -CZ_BIN_MAX = 350_000.0 -CZ_BIN_EDGES = np.linspace(CZ_BIN_MIN, CZ_BIN_MAX, N_CZ_BINS + 1) - - -class _CzDistributionAccumulator: - def __init__(self) -> None: - self._counts = np.zeros(N_CZ_BINS, dtype=np.int64) - - def add(self, cz_values: list[float]) -> None: - if not cz_values: - return - batch_counts, _ = np.histogram(np.asarray(cz_values, dtype=np.float64), bins=CZ_BIN_EDGES) - self._counts += batch_counts.astype(np.int64) - - @property - def total(self) -> int: - return int(self._counts.sum()) - - def emit_image( - self, - report_func: Callable[[report.Event], None], - *, - caption: str, - cz_mean: float | None = None, - cz_min: float | None = None, - cz_max: float | None = None, - ) -> None: - if self.total == 0: - return - centers = 0.5 * (CZ_BIN_EDGES[:-1] + CZ_BIN_EDGES[1:]) - widths = np.diff(CZ_BIN_EDGES) - fig, ax = plt.subplots(figsize=CHART_FIGSIZE) - ax.bar(centers, self._counts, width=widths, align="center") - ax.set_yscale("log") - ax.set_xlabel("cz (km/s)") - ax.set_ylabel("Count") - ax.set_title("Redshift (cz) distribution") - if cz_min is not None: - ax.axvline(cz_min, color="gray", linestyle=":", linewidth=1) - if cz_max is not None: - ax.axvline(cz_max, color="gray", linestyle=":", linewidth=1) - if cz_mean is not None: - ax.axvline(cz_mean, color="red", linestyle="--", linewidth=1, label=f"mean={cz_mean:.0f}") - ax.legend(loc="upper right", fontsize="small") - report_func(report.image_event_from_figure(fig, caption=caption)) - - -def upload_redshift( - storage: PgStorage, - table_name: str, - z_column: str, - batch_size: int, - client: adminapi.AuthenticatedClient, - *, - write: bool = False, - z_error: float, - report_func: Callable[[report.Event], None], -) -> int: - uploaded = 0 - skipped = 0 - cz_min = float("inf") - cz_max = float("-inf") - cz_sum = 0.0 - total_count = 0 - cnt = storage.query( - sql.SQL("SELECT COUNT(*) AS cnt FROM rawdata.{}").format(sql.Identifier(table_name)), - (), - ) - total_count = int(cnt[0]["cnt"]) if cnt else 0 - processed_rows = 0 - cz_dist = _CzDistributionAccumulator() - - for rows in rawdata_batches(storage, table_name, [z_column], batch_size): - batch_ids: list[str] = [] - batch_data: list[list[float]] = [] - batch_cz: list[float] = [] - - for row in rows: - z_val = row[z_column] - if z_val is None: - skipped += 1 - continue - cz_val = float(z_val) * C_KM_S - e_cz = float(z_error) * C_KM_S - batch_ids.append(row["hyperleda_internal_id"]) - batch_data.append([cz_val, e_cz]) - uploaded += 1 - cz_min = min(cz_min, cz_val) - cz_max = max(cz_max, cz_val) - cz_sum += cz_val - batch_cz.append(cz_val) - - cz_dist.add(batch_cz) - - if write and batch_ids: - handle_call( - save_structured_data.sync_detailed( - client=client, - body=action_description.apply( - SaveStructuredDataRequest( - catalog="redshift", - columns=REDSHIFT_COLUMNS, - ids=batch_ids, - data=batch_data, - units=REDSHIFT_UNITS, - ), - ), - ) - ) - - processed_rows += len(rows) - batch_pct = int(100 * processed_rows / total_count) if total_count else 0 - report_func(report.ProgressEvent(percent=min(99, batch_pct))) - report_func( - report.LogEvent( - message=f"batch: rows_read={len(rows)} uploaded={uploaded} skipped={skipped}", - ), - ) - if uploaded > 0: - cz_dist.emit_image( - report_func, - caption=f"cz distribution: {uploaded} rows", - cz_mean=cz_sum / uploaded, - cz_min=cz_min, - cz_max=cz_max, - ) - - total = uploaded + skipped - - def row_pct_label(n: int) -> float: - return (100.0 * n / total) if total else 0.0 - - table_rows: list[tuple[str, int | float, float | str]] = [ - ("Uploaded", uploaded, row_pct_label(uploaded)), - ("Skipped (null)", skipped, row_pct_label(skipped)), - ] - if uploaded > 0: - cz_mean = cz_sum / uploaded - table_rows.extend( - [ - ("cz min (km/s)", round(cz_min, 2), "-"), - ("cz max (km/s)", round(cz_max, 2), "-"), - ("cz mean (km/s)", round(cz_mean, 2), "-"), - ] - ) - report_func(report.ProgressEvent(percent=100)) - if uploaded > 0: - cz_dist.emit_image( - report_func, - caption=f"Final: {uploaded} rows", - cz_mean=cz_sum / uploaded, - cz_min=cz_min, - cz_max=cz_max, - ) - summary = format_table( - ("Status", "Count", "%"), - table_rows, - title=f"Total rows: {total}\n", - ) - report_func(report.DoneEvent(message=summary)) - return total diff --git a/uploader/cli.py b/uploader/cli.py index 3196dd0..be3675a 100644 --- a/uploader/cli.py +++ b/uploader/cli.py @@ -36,6 +36,7 @@ def list_tasks() -> list[dict[str, Any]]: "id": t.id, "title": t.title, "description": t.description, + "additional_description": t.additional_description, "group": t.group, "rerunnable": t.rerunnable, } @@ -55,7 +56,12 @@ def task_schema(task_id: str) -> dict[str, object]: task = TASKS[task_id] schema = task.form_model.model_json_schema() schema.pop("title", None) - return {"title": task.title, "description": task.description, "schema": schema} + return { + "title": task.title, + "description": task.description, + "additional_description": task.additional_description, + "schema": schema, + } @app.post("/api/tasks/{task_id}/submit") diff --git a/uploader/forms/structured_catalog.py b/uploader/forms/structured_catalog.py index 5431785..a9958cd 100644 --- a/uploader/forms/structured_catalog.py +++ b/uploader/forms/structured_catalog.py @@ -9,9 +9,9 @@ from uploader.app import log from uploader.app.catalogs import fetch_catalogs from uploader.app.endpoints import db_dsn_map, env_map -from uploader.app.lib.expression import expression_syntax_help +from uploader.app.lib.formula import expression_syntax_help from uploader.app.storage import PgStorage -from uploader.app.structured.generic import is_numeric_datatype, upload_catalog_columns +from uploader.app.structured.generic import upload_catalog_columns from uploader.clients.gen.client import adminapi from uploader.clients.gen.client.adminapi.models.catalog_field import CatalogField from uploader.clients.gen.client.adminapi.models.catalog_schema import CatalogSchema @@ -20,7 +20,7 @@ from uploader.credentials import load_credentials, load_token from uploader.tasks import TaskDefinition, register_task -GROUP = "Structured catalogs" +GROUP = "Catalogs" _RESERVED_FORM_FIELDS = frozenset({"table_name", "write", "advanced"}) @@ -36,22 +36,25 @@ def _field_required(field: CatalogField) -> bool: return bool(field.required) -def _field_description(field: CatalogField, *, numeric: bool) -> str: - if numeric: - if isinstance(field.description, str) and field.description: - return f"Expression. {field.description}" - return f"Expression. Value for {field.name}." - if isinstance(field.description, str) and field.description: - return field.description - return f"Source column for {field.name}." - - def _field_unit(field: CatalogField) -> str | None: - if isinstance(field.unit, str): + if isinstance(field.unit, str) and field.unit: return field.unit return None +def _field_description(field: CatalogField) -> str: + if isinstance(field.description, str) and field.description: + base = field.description + else: + base = f"Value for {field.name}." + if not base.endswith("."): + base = f"{base}." + parts = [base, f"Target type: `{field.data_type.value}`."] + if unit := _field_unit(field): + parts.append(f"Target unit: `{unit}`.") + return " ".join(parts) + + def build_catalog_form(schema: CatalogSchema) -> type[BaseModel]: field_definitions: dict[str, Any] = { "table_name": (str, Field(..., title="Table name")), @@ -59,18 +62,16 @@ def build_catalog_form(schema: CatalogSchema) -> type[BaseModel]: for field in schema.fields: if field.name in _RESERVED_FORM_FIELDS: raise RuntimeError(f"Catalog {schema.catalog!r} field {field.name!r} conflicts with reserved form field") - numeric = is_numeric_datatype(field.data_type) - title = field.name if numeric else f"{field.name} column" - description = _field_description(field, numeric=numeric) + description = _field_description(field) if _field_required(field): field_definitions[field.name] = ( str, - Field(..., title=title, description=description), + Field(..., title=field.name, description=description), ) else: field_definitions[field.name] = ( str, - Field(default="", title=title, description=description), + Field(default="", title=field.name, description=description), ) field_definitions["write"] = ( bool, @@ -100,7 +101,6 @@ def _make_handler( field_types: dict[str, DatatypeEnum] = {f.name: f.data_type for f in catalog_fields} field_units: dict[str, str] = {f.name: unit for f in catalog_fields if (unit := _field_unit(f)) is not None} field_order = [f.name for f in catalog_fields] - numeric_names = {f.name for f in catalog_fields if is_numeric_datatype(f.data_type)} def handler( form: BaseModel, @@ -111,7 +111,6 @@ def handler( endpoint = str(advanced["endpoint"]) batch_size = int(advanced["batch_size"]) table_name = str(values["table_name"]).strip() - column_map: dict[str, str] = {} expressions: dict[str, str] = {} provided: set[str] = set() for name in field_order: @@ -119,10 +118,7 @@ def handler( if not raw: continue provided.add(name) - if name in numeric_names: - expressions[name] = raw - else: - column_map[name] = raw + expressions[name] = raw missing = sorted(name for name in required_names if name not in provided) if missing: raise RuntimeError(f"Missing required field(s): {missing}") @@ -143,7 +139,6 @@ def handler( storage, table_name, catalog_name, - column_map, expressions, field_types, field_units, @@ -157,14 +152,6 @@ def handler( return handler -def _task_description(schema: CatalogSchema) -> str: - base = schema.description.strip() - help_text = expression_syntax_help() - if base: - return f"{base}\n\n{help_text}" - return help_text - - def register_structured_catalog_tasks( catalogs: list[CatalogSchema] | None = None, ) -> None: @@ -178,7 +165,8 @@ def register_structured_catalog_tasks( TaskDefinition( id=f"upload-{schema.catalog}", title=schema.title, - description=_task_description(schema), + description=schema.description.strip(), + additional_description=expression_syntax_help(), form_model=build_catalog_form(schema), handler=_make_handler(schema), group=GROUP, diff --git a/uploader/forms/structured_designation.py b/uploader/forms/structured_designation.py index 1d34d0e..8c21814 100644 --- a/uploader/forms/structured_designation.py +++ b/uploader/forms/structured_designation.py @@ -35,7 +35,7 @@ class StructuredDesignationForm(BaseModel): title="Designation expression", description=( "Expression yielding the object designation per row. " - 'Examples: designation, col("weird name"), prefix + " " + number.' + 'Examples: col("designation"), col("weird name"), col("prefix") + " " + col("number").' ), ) write: bool = Field( diff --git a/uploader/forms/structured_geometry_isophotal.py b/uploader/forms/structured_geometry_isophotal.py deleted file mode 100644 index 013c7bf..0000000 --- a/uploader/forms/structured_geometry_isophotal.py +++ /dev/null @@ -1,83 +0,0 @@ -from collections.abc import Callable -from typing import Literal, cast -from urllib.parse import quote_plus - -from psycopg import connect -from pydantic import BaseModel, Field - -import uploader.app.report as report -from uploader.app.endpoints import db_dsn_map, env_map -from uploader.app.storage import PgStorage -from uploader.app.structured.geometry import upload_geometry_isophotal -from uploader.clients.gen.client import adminapi -from uploader.credentials import load_credentials, load_token - - -class StructuredGeometryIsophotalAdvancedSettings(BaseModel): - endpoint: Literal["dev", "test", "prod"] = Field(default="prod", title="API endpoint") - batch_size: int = Field(default=10000, title="Batch size", ge=1, le=500_000) - - -class StructuredGeometryIsophotalForm(BaseModel): - table_name: str = Field(..., title="Rawdata table name") - band: str = Field(..., title="Band", description="Calibrated passband id.") - a: str = Field(..., title="a", description="Expression. Semi-major axis length.") - e_a: str = Field(..., title="e_a", description="Expression. Error on semi-major axis.") - b: str = Field(..., title="b", description="Expression. Semi-minor axis length.") - e_b: str = Field(..., title="e_b", description="Expression. Error on semi-minor axis.") - pa: str = Field( - default="", title="pa", description="Expression. Position angle (east of north). Leave empty to store null." - ) - e_pa: str = Field( - default="", title="e_pa", description="Expression. Error on position angle. Leave empty to store null." - ) - isophote: str = Field(..., title="isophote", description="Expression. Surface brightness level.") - write: bool = Field( - default=False, - title="Write to API", - description="If enabled, upload results; otherwise dry-run (statistics only).", - ) - advanced: StructuredGeometryIsophotalAdvancedSettings = Field( - default_factory=StructuredGeometryIsophotalAdvancedSettings, - title="Advanced settings", - ) - - -def handle_structured_geometry_isophotal( - form: BaseModel, - report_func: Callable[[report.Event], None], -) -> None: - f = cast(StructuredGeometryIsophotalForm, form) - advanced = f.advanced - db_user, db_password = load_credentials() - dsn = db_dsn_map[advanced.endpoint].format( - user=quote_plus(db_user), - password=quote_plus(db_password), - ) - client = adminapi.AuthenticatedClient( - base_url=env_map[advanced.endpoint], - token=load_token(), - ) - expressions: dict[str, str] = { - "a": f.a.strip(), - "e_a": f.e_a.strip(), - "b": f.b.strip(), - "e_b": f.e_b.strip(), - "isophote": f.isophote.strip(), - } - if f.pa.strip(): - expressions["pa"] = f.pa.strip() - if f.e_pa.strip(): - expressions["e_pa"] = f.e_pa.strip() - with connect(dsn) as conn: - storage = PgStorage(conn) - upload_geometry_isophotal( - storage, - f.table_name.strip(), - f.band.strip(), - expressions, - advanced.batch_size, - client, - write=f.write, - report_func=report_func, - ) diff --git a/uploader/forms/structured_icrs.py b/uploader/forms/structured_icrs.py deleted file mode 100644 index 722250c..0000000 --- a/uploader/forms/structured_icrs.py +++ /dev/null @@ -1,69 +0,0 @@ -from collections.abc import Callable -from typing import Literal, cast -from urllib.parse import quote_plus - -from psycopg import connect -from pydantic import BaseModel, Field - -import uploader.app.report as report -from uploader.app.endpoints import db_dsn_map, env_map -from uploader.app.storage import PgStorage -from uploader.app.structured.icrs import upload_icrs as run_upload_icrs -from uploader.clients.gen.client import adminapi -from uploader.credentials import load_credentials, load_token - - -class StructuredIcrsAdvancedSettings(BaseModel): - endpoint: Literal["dev", "test", "prod"] = Field(default="prod", title="API endpoint") - batch_size: int = Field(default=10000, title="Batch size", ge=1, le=500_000) - - -class StructuredIcrsForm(BaseModel): - table_name: str = Field(..., title="Rawdata table name") - ra_column: str = Field(..., title="RA column", description="Column containing right ascension.") - dec_column: str = Field(..., title="Dec column", description="Column containing declination.") - e_ra: str = Field(..., title="e_ra", description="Expression. Positional error for RA.") - e_dec: str = Field(..., title="e_dec", description="Expression. Positional error for Dec.") - write: bool = Field( - default=False, - title="Write to API", - description="If enabled, upload results; otherwise dry-run (statistics only).", - ) - advanced: StructuredIcrsAdvancedSettings = Field( - default_factory=StructuredIcrsAdvancedSettings, - title="Advanced settings", - ) - - -def handle_structured_icrs( - form: BaseModel, - report_func: Callable[[report.Event], None], -) -> None: - f = cast(StructuredIcrsForm, form) - advanced = f.advanced - db_user, db_password = load_credentials() - dsn = db_dsn_map[advanced.endpoint].format( - user=quote_plus(db_user), - password=quote_plus(db_password), - ) - client = adminapi.AuthenticatedClient( - base_url=env_map[advanced.endpoint], - token=load_token(), - ) - expressions: dict[str, str] = { - "e_ra": f.e_ra.strip(), - "e_dec": f.e_dec.strip(), - } - with connect(dsn) as conn: - storage = PgStorage(conn) - run_upload_icrs( - storage, - f.table_name.strip(), - f.ra_column.strip(), - f.dec_column.strip(), - expressions, - advanced.batch_size, - client, - write=f.write, - report_func=report_func, - ) diff --git a/uploader/forms/structured_nature.py b/uploader/forms/structured_nature.py index 59b345f..945f4ac 100644 --- a/uploader/forms/structured_nature.py +++ b/uploader/forms/structured_nature.py @@ -33,7 +33,7 @@ class StructuredNatureForm(BaseModel): type_mappings: list[str] = Field( default_factory=list, title="Type mappings", - description='Each entry "raw_value:leda_type" (e.g. G:galaxy).', + description='Each entry "raw\\_value:leda\\_type" (e.g. G:galaxy).', ) write: bool = Field( default=False, diff --git a/uploader/forms/structured_photometry_hyperleda.py b/uploader/forms/structured_photometry_hyperleda.py deleted file mode 100644 index 40f49b6..0000000 --- a/uploader/forms/structured_photometry_hyperleda.py +++ /dev/null @@ -1,60 +0,0 @@ -from collections.abc import Callable -from typing import Literal, cast -from urllib.parse import quote_plus - -from psycopg import connect -from pydantic import BaseModel, Field - -import uploader.app.report as report -from uploader.app.endpoints import db_dsn_map, env_map -from uploader.app.storage import PgStorage -from uploader.app.structured.photometry.upload import ( - upload_photometry_hyperleda as run_upload_photometry_hyperleda, -) -from uploader.clients.gen.client import adminapi -from uploader.credentials import load_credentials, load_token - - -class StructuredPhotometryHyperledaAdvancedSettings(BaseModel): - endpoint: Literal["dev", "test", "prod"] = Field(default="prod", title="API endpoint") - batch_size: int = Field(default=10000, title="Batch size", ge=1, le=500_000) - - -class StructuredPhotometryHyperledaForm(BaseModel): - table_name: str = Field(..., title="Rawdata table name") - write: bool = Field( - default=False, - title="Write to API", - description="If enabled, upload results; otherwise dry-run (statistics only).", - ) - advanced: StructuredPhotometryHyperledaAdvancedSettings = Field( - default_factory=StructuredPhotometryHyperledaAdvancedSettings, - title="Advanced settings", - ) - - -def handle_structured_photometry_hyperleda( - form: BaseModel, - report_func: Callable[[report.Event], None], -) -> None: - f = cast(StructuredPhotometryHyperledaForm, form) - advanced = f.advanced - db_user, db_password = load_credentials() - dsn = db_dsn_map[advanced.endpoint].format( - user=quote_plus(db_user), - password=quote_plus(db_password), - ) - client = adminapi.AuthenticatedClient( - base_url=env_map[advanced.endpoint], - token=load_token(), - ) - with connect(dsn) as conn: - storage = PgStorage(conn) - run_upload_photometry_hyperleda( - storage, - f.table_name.strip(), - advanced.batch_size, - client, - write=f.write, - report_func=report_func, - ) diff --git a/uploader/forms/structured_redshift.py b/uploader/forms/structured_redshift.py deleted file mode 100644 index 1c4aa84..0000000 --- a/uploader/forms/structured_redshift.py +++ /dev/null @@ -1,62 +0,0 @@ -from collections.abc import Callable -from typing import Literal, cast -from urllib.parse import quote_plus - -from psycopg import connect -from pydantic import BaseModel, Field - -import uploader.app.report as report -from uploader.app.endpoints import db_dsn_map, env_map -from uploader.app.storage import PgStorage -from uploader.app.structured.redshift import upload_redshift as run_upload_redshift -from uploader.clients.gen.client import adminapi -from uploader.credentials import load_credentials, load_token - - -class StructuredRedshiftAdvancedSettings(BaseModel): - endpoint: Literal["dev", "test", "prod"] = Field(default="prod", title="API endpoint") - batch_size: int = Field(default=10000, title="Batch size", ge=1, le=500_000) - write: bool = Field( - default=False, - title="Write to API", - description="If enabled, upload results; otherwise dry-run (statistics only).", - ) - - -class StructuredRedshiftForm(BaseModel): - table_name: str = Field(..., title="Rawdata table name") - z_column: str = Field(..., title="z column", description="Column with redshift z.") - z_error: float = Field(..., title="z error", description="Fixed error on z (same for all rows).") - advanced: StructuredRedshiftAdvancedSettings = Field( - default_factory=StructuredRedshiftAdvancedSettings, - title="Advanced settings", - ) - - -def handle_structured_redshift( - form: BaseModel, - report_func: Callable[[report.Event], None], -) -> None: - f = cast(StructuredRedshiftForm, form) - advanced = f.advanced - db_user, db_password = load_credentials() - dsn = db_dsn_map[advanced.endpoint].format( - user=quote_plus(db_user), - password=quote_plus(db_password), - ) - client = adminapi.AuthenticatedClient( - base_url=env_map[advanced.endpoint], - token=load_token(), - ) - with connect(dsn) as conn: - storage = PgStorage(conn) - run_upload_redshift( - storage, - f.table_name.strip(), - f.z_column.strip(), - advanced.batch_size, - client, - write=advanced.write, - z_error=f.z_error, - report_func=report_func, - ) diff --git a/uploader/task_registry.py b/uploader/task_registry.py index fad11f4..4d90b02 100644 --- a/uploader/task_registry.py +++ b/uploader/task_registry.py @@ -6,18 +6,8 @@ StructuredDesignationForm, handle_structured_designation, ) -from uploader.forms.structured_geometry_isophotal import ( - StructuredGeometryIsophotalForm, - handle_structured_geometry_isophotal, -) -from uploader.forms.structured_icrs import StructuredIcrsForm, handle_structured_icrs from uploader.forms.structured_nature import StructuredNatureForm, handle_structured_nature from uploader.forms.structured_note import StructuredNoteForm, handle_structured_note -from uploader.forms.structured_photometry_hyperleda import ( - StructuredPhotometryHyperledaForm, - handle_structured_photometry_hyperleda, -) -from uploader.forms.structured_redshift import StructuredRedshiftForm, handle_structured_redshift from uploader.forms.submit_crossmatch import SubmitCrossmatchForm, handle_submit_crossmatch from uploader.forms.upload_csv import UploadCsvForm, handle_upload_csv from uploader.forms.upload_fits import UploadFitsForm, handle_upload_fits @@ -70,8 +60,8 @@ def register_all_tasks() -> None: register_task( TaskDefinition( id="upload-structured-note", - title="Note", - description="Add free text note about a record.", + title="Single-record note", + description="Attach a free-text note to one existing record by ID.", form_model=StructuredNoteForm, handler=handle_structured_note, group="Catalogs", @@ -80,65 +70,23 @@ def register_all_tasks() -> None: register_task( TaskDefinition( id="upload-structured-designation", - title="Designations", - description=( - f"Convert designations to common format and upload them to the database.\n\n{expression_syntax_help()}" - ), + title="Normalize designations", + description=("Parse designations with catalog-specific rules, normalize them to common form, and upload."), + additional_description=expression_syntax_help(), form_model=StructuredDesignationForm, handler=handle_structured_designation, group="Catalogs", ), ) - register_task( - TaskDefinition( - id="upload-structured-icrs", - title="ICRS", - description=(f"Upload ICRS coordinates to the database.\n\n{expression_syntax_help()}"), - form_model=StructuredIcrsForm, - handler=handle_structured_icrs, - group="Catalogs", - ), - ) register_task( TaskDefinition( id="upload-structured-nature", - title="Nature", - description="Upload object type to the database.", - form_model=StructuredNatureForm, - handler=handle_structured_nature, - group="Catalogs", - ), - ) - register_task( - TaskDefinition( - id="upload-structured-redshift", - title="Redshift", - description="Upload redshift to the database.", - form_model=StructuredRedshiftForm, - handler=handle_structured_redshift, - group="Catalogs", - ), - ) - register_task( - TaskDefinition( - id="upload-structured-photometry-hyperleda", - title="Photometry (HyperLEDA)", - description="Upload U/B/V/I/K asymptotic magnitudes to photometry catalog.", - form_model=StructuredPhotometryHyperledaForm, - handler=handle_structured_photometry_hyperleda, - group="Catalogs", - ), - ) - register_task( - TaskDefinition( - id="upload-structured-geometry-isophotal", - title="Isophotal geometry", + title="Map object types", description=( - "Upload isophotal ellipse geometry (a, b, pa, isophote) from rawdata columns.\n\n" - f"{expression_syntax_help()}" + "Upload object nature with optional raw→LEDA type mappings and a default type for unmapped values." ), - form_model=StructuredGeometryIsophotalForm, - handler=handle_structured_geometry_isophotal, + form_model=StructuredNatureForm, + handler=handle_structured_nature, group="Catalogs", ), ) diff --git a/uploader/tasks.py b/uploader/tasks.py index cbb5a1d..9c3bdb8 100644 --- a/uploader/tasks.py +++ b/uploader/tasks.py @@ -24,6 +24,7 @@ class TaskDefinition: handler: Callable[[BaseModel, Callable[[report.Event], None]], None] group: str = "default" rerunnable: bool = True + additional_description: str | None = None TASKS: dict[str, TaskDefinition] = {}