diff --git a/.flake8 b/.flake8 deleted file mode 100644 index 833ca122..00000000 --- a/.flake8 +++ /dev/null @@ -1,9 +0,0 @@ -[flake8] -ignore = D205, D400, D301, W503 -max-line-length=88 -exclude = - .git - __pycache__ - curation/version.py -per-file-ignores = - */tests/*:D403 diff --git a/.github/ISSUE_TEMPLATE/bug-report.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml new file mode 100644 index 00000000..5b3f7c8d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -0,0 +1,85 @@ +name: Bug Report +description: Report a bug. +labels: ["bug"] +body: + - type: textarea + attributes: + label: Describe the bug + description: Provide a clear and concise description of what the bug is. + validations: + required: true + - type: textarea + attributes: + label: Steps to reproduce + description: Provide detailed steps to replicate the bug. + placeholder: | + 1. In this environment... + 2. With this config... + 3. Run '...' + 4. See error... + validations: + required: true + - type: textarea + attributes: + label: Expected behavior + description: What did you expect to happen? + validations: + required: true + - type: textarea + attributes: + label: Current behavior + description: | + What actually happened? + + Include full errors, stack traces, and/or relevant logs. + validations: + required: true + - type: textarea + attributes: + label: Possible reason(s) + description: Provide any insights into what might be causing the issue. + validations: + required: false + - type: textarea + attributes: + label: Suggested fix + description: Provide any suggestions on how to resolve the bug. + validations: + required: false + - type: textarea + attributes: + label: Branch, commit, and/or version + description: Provide the branch, commit, and/or version you're using. + placeholder: | + branch: issue-1 + commit: abc123d + validations: + required: true + - type: textarea + attributes: + label: Screenshots + description: If applicable, add screenshots with descriptions to help explain your problem. + validations: + required: false + - type: textarea + attributes: + label: Environment details + description: Provide environment details (OS name and version, etc). + validations: + required: true + - type: textarea + attributes: + label: Additional details + description: Provide any other additional details about the problem. + validations: + required: false + - type: dropdown + attributes: + label: Contribution + description: Can you contribute to the development of this feature? + options: + - "Yes, I can create a PR for this fix." + - "Yes, but I can only provide ideas and feedback." + - "No, I cannot contribute." + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml new file mode 100644 index 00000000..2e1ae644 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -0,0 +1,60 @@ +name: Feature Request +description: Suggest an idea for this project. +labels: ["enhancement"] +body: + - type: textarea + attributes: + label: Feature description + description: Provide a clear and concise description of what you want to happen. + validations: + required: true + - type: textarea + attributes: + label: Use case + description: | + Why do you need this feature? For example: "I'm always frustrated when..." + validations: + required: true + - type: textarea + attributes: + label: Proposed solution + description: Provide proposed solution. + validations: + required: false + - type: textarea + attributes: + label: Alternatives considered + description: Describe any alternative solutions you've considered. + validations: + required: false + - type: textarea + attributes: + label: Implementation details + description: Provide any technical details on how the feature might be implemented. + validations: + required: false + - type: textarea + attributes: + label: Potential Impact + description: | + Discuss any potential impacts of this feature on existing functionality or performance, if known. + Will this feature cause breaking changes? + What challenges might arise? + validations: + required: false + - type: textarea + attributes: + label: Additional context + description: Provide any other context or screenshots about the feature. + validations: + required: false + - type: dropdown + attributes: + label: Contribution + description: Can you contribute to the development of this feature? + options: + - "Yes, I can create a PR for this feature." + - "Yes, but I can only provide ideas and feedback." + - "No, I cannot contribute." + validations: + required: false diff --git a/.github/workflows/backend_checks.yml b/.github/workflows/backend_checks.yml new file mode 100644 index 00000000..6f243f49 --- /dev/null +++ b/.github/workflows/backend_checks.yml @@ -0,0 +1,31 @@ +name: backend_checks +on: [push, pull_request] +jobs: + build: + name: build + runs-on: ubuntu-latest + strategy: + fail-fast: false + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: 3.11 + + - name: Install dependencies + run: pip install server/ + lint: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: black + uses: psf/black@stable + with: + src: "./server" + + - name: ruff + uses: chartboost/ruff-action@v1 + with: + src: "./server" diff --git a/.github/workflows/pr-priority-label.yaml b/.github/workflows/pr-priority-label.yaml new file mode 100644 index 00000000..0bef462d --- /dev/null +++ b/.github/workflows/pr-priority-label.yaml @@ -0,0 +1,23 @@ +name: Pull Request Has Priority Label +on: + pull_request: + types: [opened, labeled, unlabeled, synchronize] +jobs: + pr-priority-label: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + outputs: + status: ${{ steps.check-labels.outputs.status }} + steps: + - id: check-labels + uses: mheap/github-action-required-labels@v5 + with: + mode: exactly + count: 1 + labels: "priority:*" + use_regex: true + add_comment: true + message: "PRs require a priority label. Please add one." + exit_type: failure diff --git a/.github/workflows/stale.yaml b/.github/workflows/stale.yaml new file mode 100644 index 00000000..66bb8613 --- /dev/null +++ b/.github/workflows/stale.yaml @@ -0,0 +1,27 @@ +name: "Stalebot for issues and PRs" + +on: + schedule: + - cron: "30 13 * * 1-5" + +jobs: + stale-high-priority: + uses: genomicmedlab/software-templates/.github/workflows/reusable-stale.yaml@main + with: + days-before-issue-stale: 90 + days-before-pr-stale: 1 + labels: priority:high + + stale-medium-priority: + uses: genomicmedlab/software-templates/.github/workflows/reusable-stale.yaml@main + with: + days-before-issue-stale: 135 + days-before-pr-stale: 3 + labels: priority:medium + + stale-low-priority: + uses: genomicmedlab/software-templates/.github/workflows/reusable-stale.yaml@main + with: + days-before-issue-stale: 180 + days-before-pr-stale: 7 + labels: priority:low diff --git a/.gitignore b/.gitignore index 1b417e78..d25a2f73 100644 --- a/.gitignore +++ b/.gitignore @@ -91,7 +91,7 @@ ipython_config.py # pyenv # For a library or package, you might want to ignore these files since the code is # intended to run in multiple environments; otherwise, check them in: -# .python-version +**/.python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. @@ -160,7 +160,6 @@ dynamodb_local_latest/* # Build files Pipfile.lock -pyproject.toml # client-side things curation/client/node_modules @@ -177,8 +176,8 @@ yarn-debug.log* yarn-error.log* frontend/public/ frontend/node_modules -node_modules/ +**/node_modules/ +**/.yarn # Data -server/curfu/data -client/node_modules +server/src/curfu/data diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ba775bbf..5ebc9143 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,18 +1,23 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v1.4.0 + rev: v4.6.0 # pre-commit-hooks version hooks: - - id: flake8 - additional_dependencies: [flake8-docstrings] - args: ["--config=server/.flake8"] - id: check-added-large-files - args: ["--maxkb=2000"] - id: detect-private-key + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: detect-aws-credentials - repo: https://github.com/psf/black - rev: 22.6.0 + rev: 23.7.0 hooks: - id: black - args: [--diff, --check] + language_version: python3.11 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.286 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/pre-commit/mirrors-eslint rev: "v8.20.0" hooks: @@ -30,3 +35,4 @@ repos: rev: "v2.7.1" hooks: - id: prettier +minimum_pre_commit_version: 3.7.1 diff --git a/LICENSE b/LICENSE index 859f3c0e..8cb143ef 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021-2022 Alex H. Wagner +Copyright (c) 2021-2024 Genomic Medicine Lab Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Procfile b/Procfile index d2d9113c..1687f997 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: sh -c 'cd ./server/ && gunicorn -k uvicorn.workers.UvicornWorker curfu.main:app --timeout 1000 --log-level debug' +web: sh -c 'cd ./server/src/ && gunicorn -k uvicorn.workers.UvicornWorker curfu.main:app --timeout 1000 --log-level debug' diff --git a/client/src/components/Pages/CausativeEvent/CausativeEvent.tsx b/client/src/components/Pages/CausativeEvent/CausativeEvent.tsx index 1c9eaac4..1e5c3aa1 100644 --- a/client/src/components/Pages/CausativeEvent/CausativeEvent.tsx +++ b/client/src/components/Pages/CausativeEvent/CausativeEvent.tsx @@ -102,32 +102,23 @@ export const CausativeEvent: React.FC = () => { - - The type of event that generated the fusion. - - } + - - {["rearrangement", "trans-splicing", "read-through"].map( - (value, index) => ( - } - label={eventDisplayMap[value]} - key={index} - /> - ) - )} - - + {["rearrangement", "trans-splicing", "read-through"].map( + (value, index) => ( + } + label={eventDisplayMap[value]} + key={index} + /> + ) + )} + diff --git a/client/src/components/Pages/Structure/Input/GeneElementInput/GeneElementInput.tsx b/client/src/components/Pages/Structure/Input/GeneElementInput/GeneElementInput.tsx index 5ceba851..7b34f4d7 100644 --- a/client/src/components/Pages/Structure/Input/GeneElementInput/GeneElementInput.tsx +++ b/client/src/components/Pages/Structure/Input/GeneElementInput/GeneElementInput.tsx @@ -9,7 +9,7 @@ import { getGeneElement, getGeneNomenclature, } from "../../../../../services/main"; -import ElementInputAccordion from "../StructuralElementInputAccordion"; +import StructuralElementInputAccordion from "../StructuralElementInputAccordion"; interface GeneElementInputProps extends StructuralElementInputProps { element: ClientGeneElement; @@ -28,12 +28,14 @@ const GeneElementInput: React.FC = ({ const [geneText, setGeneText] = useState(""); const validated = gene !== "" && geneText == ""; const [expanded, setExpanded] = useState(!validated); + const [pendingResponse, setPendingResponse] = useState(false); useEffect(() => { if (validated) buildGeneElement(); }, [gene, geneText]); const buildGeneElement = () => { + setPendingResponse(true) getGeneElement(gene).then((geneElementResponse) => { if ( geneElementResponse.warnings && @@ -56,6 +58,7 @@ const GeneElementInput: React.FC = ({ nomenclature: nomenclatureResponse.nomenclature, }; handleSave(index, clientGeneElement); + setPendingResponse(false) } } ); @@ -74,7 +77,7 @@ const GeneElementInput: React.FC = ({ /> ); - return ElementInputAccordion({ + return StructuralElementInputAccordion({ expanded, setExpanded, element, @@ -82,6 +85,7 @@ const GeneElementInput: React.FC = ({ inputElements, validated, icon, + pendingResponse }); }; diff --git a/client/src/components/Pages/Structure/Input/StaticElement/StaticElement.tsx b/client/src/components/Pages/Structure/Input/StaticElement/StaticElement.tsx index 9ff0d2b0..c97b963f 100644 --- a/client/src/components/Pages/Structure/Input/StaticElement/StaticElement.tsx +++ b/client/src/components/Pages/Structure/Input/StaticElement/StaticElement.tsx @@ -1,17 +1,18 @@ import { BaseStructuralElementProps } from "../StructuralElementInputProps"; -import CompInputAccordion from "../StructuralElementInputAccordion"; +import StructuralElementInputAccordion from "../StructuralElementInputAccordion"; const StaticElement: React.FC = ({ element, handleDelete, icon, }) => - CompInputAccordion({ + StructuralElementInputAccordion({ expanded: false, element, handleDelete, validated: true, icon, + pendingResponse: false }); export default StaticElement; diff --git a/client/src/components/Pages/Structure/Input/StructuralElementInputAccordion.tsx b/client/src/components/Pages/Structure/Input/StructuralElementInputAccordion.tsx index f47cc2ca..05b4fe92 100644 --- a/client/src/components/Pages/Structure/Input/StructuralElementInputAccordion.tsx +++ b/client/src/components/Pages/Structure/Input/StructuralElementInputAccordion.tsx @@ -1,4 +1,4 @@ -import { Tooltip, makeStyles } from "@material-ui/core"; +import { Tooltip, makeStyles, CircularProgress } from "@material-ui/core"; import { styled } from "@mui/material/styles"; import IconButton, { IconButtonProps } from "@mui/material/IconButton"; import Card from "@mui/material/Card"; @@ -43,6 +43,7 @@ interface StructuralElementInputAccordionProps inputElements?: JSX.Element; validated: boolean; icon: JSX.Element; + pendingResponse?: boolean; } interface ExpandMoreProps extends IconButtonProps { @@ -71,6 +72,7 @@ const StructuralElementInputAccordion: React.FC< inputElements, validated, icon, + pendingResponse }) => { const classes = useStyles(); @@ -79,6 +81,8 @@ const StructuralElementInputAccordion: React.FC< : void; icon: JSX.Element; + pendingResponse?: boolean } export interface StructuralElementInputProps diff --git a/client/src/components/Pages/Structure/Input/TemplatedSequenceElementInput/TemplatedSequenceElementInput.tsx b/client/src/components/Pages/Structure/Input/TemplatedSequenceElementInput/TemplatedSequenceElementInput.tsx index c45e59f0..f7c60221 100644 --- a/client/src/components/Pages/Structure/Input/TemplatedSequenceElementInput/TemplatedSequenceElementInput.tsx +++ b/client/src/components/Pages/Structure/Input/TemplatedSequenceElementInput/TemplatedSequenceElementInput.tsx @@ -18,6 +18,7 @@ interface TemplatedSequenceElementInputProps const TemplatedSequenceElementInput: React.FC< TemplatedSequenceElementInputProps > = ({ element, index, handleSave, handleDelete, icon }) => { + const [chromosome, setChromosome] = useState( element.input_chromosome || "" ); @@ -39,6 +40,8 @@ const TemplatedSequenceElementInput: React.FC< const [expanded, setExpanded] = useState(!validated); + const [pendingResponse, setPendingResponse] = useState(false); + useEffect(() => { if (inputComplete) { buildTemplatedSequenceElement(); @@ -64,6 +67,7 @@ const TemplatedSequenceElementInput: React.FC< ) { // TODO visible error handling setInputError("element validation unsuccessful"); + setPendingResponse(false) return; } else if (templatedSequenceResponse.element) { setInputError(""); @@ -83,6 +87,7 @@ const TemplatedSequenceElementInput: React.FC< } }); } + setPendingResponse(false) }); }; @@ -162,6 +167,7 @@ const TemplatedSequenceElementInput: React.FC< inputElements, validated, icon, + pendingResponse }); }; diff --git a/client/src/components/Pages/Structure/Input/TxSegmentElementInput/TxSegmentElementInput.tsx b/client/src/components/Pages/Structure/Input/TxSegmentElementInput/TxSegmentElementInput.tsx index 5b8abe46..077808d0 100644 --- a/client/src/components/Pages/Structure/Input/TxSegmentElementInput/TxSegmentElementInput.tsx +++ b/client/src/components/Pages/Structure/Input/TxSegmentElementInput/TxSegmentElementInput.tsx @@ -19,7 +19,7 @@ import { } from "../../../../../services/main"; import { GeneAutocomplete } from "../../../../main/shared/GeneAutocomplete/GeneAutocomplete"; import { StructuralElementInputProps } from "../StructuralElementInputProps"; -import CompInputAccordion from "../StructuralElementInputAccordion"; +import StructuralElementInputAccordion from "../StructuralElementInputAccordion"; import { FusionContext } from "../../../../../global/contexts/FusionContext"; import StrandSwitch from "../../../../main/shared/StrandSwitch/StrandSwitch"; import HelpTooltip from "../../../../main/shared/HelpTooltip/HelpTooltip"; @@ -85,6 +85,8 @@ const TxSegmentCompInput: React.FC = ({ ); const [endingExonOffsetText, setEndingExonOffsetText] = useState(""); + const [pendingResponse, setPendingResponse] = useState(false); + /* Depending on this element's location in the structure array, the user needs to provide some kind of coordinate input for either one or both ends @@ -156,7 +158,6 @@ const TxSegmentCompInput: React.FC = ({ ...responseElement, ...inputParams, }; - if (!hasRequiredEnds) { finishedElement.nomenclature = "ERROR"; } else { @@ -170,6 +171,7 @@ const TxSegmentCompInput: React.FC = ({ } }); } + setPendingResponse(false); }; /** @@ -226,6 +228,7 @@ const TxSegmentCompInput: React.FC = ({ * Request construction of tx segment element from server and handle response */ const buildTranscriptSegmentElement = () => { + setPendingResponse(true); // fire constructor request switch (txInputType) { case InputType.gcg: @@ -297,11 +300,28 @@ const TxSegmentCompInput: React.FC = ({ txSegmentResponse.warnings && txSegmentResponse.warnings?.length > 0 ) { + // transcript invalid const txWarning = `Unable to get exons for ${txAc}`; if (txSegmentResponse.warnings.includes(txWarning)) { setTxAcText("Unrecognized value"); } + // exon(s) invalid + if (startingExon !== undefined) { + const startWarning = `Exon ${startingExon} does not exist on ${txAc}`; + if (txSegmentResponse.warnings.includes(startWarning)) { + setStartingExonText("Invalid"); + } + } + if (endingExon !== undefined) { + const endWarning = `Exon ${endingExon} does not exist on ${txAc}`; + if (txSegmentResponse.warnings.includes(endWarning)) { + setEndingExonText("Invalid"); + } + } } else { + setTxAcText(""); + setStartingExonText(""); + setEndingExonText(""); const inputParams = { input_type: txInputType, input_tx: txAc, @@ -419,9 +439,7 @@ const TxSegmentCompInput: React.FC = ({ @@ -649,7 +667,7 @@ const TxSegmentCompInput: React.FC = ({ ); - return CompInputAccordion({ + return StructuralElementInputAccordion({ expanded, setExpanded, element, @@ -657,6 +675,7 @@ const TxSegmentCompInput: React.FC = ({ inputElements, validated, icon, + pendingResponse, }); }; diff --git a/client/src/components/Pages/Summary/Invalid/Invalid.tsx b/client/src/components/Pages/Summary/Invalid/Invalid.tsx index 97bca2dd..8672978c 100644 --- a/client/src/components/Pages/Summary/Invalid/Invalid.tsx +++ b/client/src/components/Pages/Summary/Invalid/Invalid.tsx @@ -98,63 +98,6 @@ export const Invalid: React.FC = ({ ); - const noEventError = ( - - The causative event is not specified.{" "} - setVisibleTab(2)}> - Declare the event type - {" "} - to resolve. - - ); - - const noAssayError = ( - - No assay metadata is provided. You must{" "} - setVisibleTab(3)}> - identify the assay, detection method, and methodology that was used to - uncover the fusion - {" "} - in order to resolve. - - ); - - const assayIdCurieError = ( - - The provided assay ID is not a valid{" "} - - W3 CURIE - - .{" "} - setVisibleTab(3)}> - Update the assay ID - {" "} - to resolve. - - ); - - const assayMethodUriCurieError = ( - - The provided assay method URI is not a valid{" "} - - W3 CURIE - - .{" "} - setVisibleTab(3)}> - Update the method URI - {" "} - to resolve. - - ); - const unknownError = ( <> @@ -164,8 +107,6 @@ export const Invalid: React.FC = ({ ); - const CURIE_PATTERN = /^\w[^:]*:.+$/; - const geneElements = fusion.structural_elements.filter(el => el.type === "GeneElement").map(el => { return el.nomenclature }) const findDuplicates = arr => arr.filter((item, index) => arr.indexOf(item) !== index) const duplicateGenes = findDuplicates(geneElements) @@ -193,28 +134,6 @@ export const Invalid: React.FC = ({ if (duplicateGenes.length > 0) { errorElements.push(duplicateGeneError(duplicateGenes)) } - if (fusion.type == "AssayedFusion") { - if ( - !( - fusion.assay && - fusion.assay.assay_name && - fusion.assay.assay_id && - fusion.assay.method_uri - ) - ) { - errorElements.push(noAssayError); - } else { - if (!fusion.assay.assay_id.match(CURIE_PATTERN)) { - errorElements.push(assayIdCurieError); - } - if (!fusion.assay.method_uri.match(CURIE_PATTERN)) { - errorElements.push(assayMethodUriCurieError); - } - } - if (!fusion.causative_event) { - errorElements.push(noEventError); - } - } if (errorElements.length == 0) { errorElements.push( <> diff --git a/client/src/components/Pages/Summary/Readable/Readable.tsx b/client/src/components/Pages/Summary/Readable/Readable.tsx index 1632cf83..291464f2 100644 --- a/client/src/components/Pages/Summary/Readable/Readable.tsx +++ b/client/src/components/Pages/Summary/Readable/Readable.tsx @@ -31,6 +31,9 @@ export const Readable: React.FC = ({ validatedFusion }) => { ); }, [validatedFusion]); + const assayName = fusion.assay?.assay_name ? fusion.assay.assay_name : "" + const assayId = fusion.assay?.assay_id ? `(${fusion.assay.assay_id})` : "" + /** * Render rows specific to assayed fusion fields * @returns React component containing table rows @@ -43,7 +46,7 @@ export const Readable: React.FC = ({ validatedFusion }) => { - {eventDisplayMap[fusion.causative_event.event_type] || ""} + {eventDisplayMap[fusion.causative_event?.event_type] || ""} @@ -52,7 +55,7 @@ export const Readable: React.FC = ({ validatedFusion }) => { Assay - {`${fusion.assay.assay_name} (${fusion.assay.assay_id})`} + {fusion.assay ? `${assayName} ${assayId}` : ""} diff --git a/client/src/components/Utilities/GetCoordinates/GetCoordinates.tsx b/client/src/components/Utilities/GetCoordinates/GetCoordinates.tsx index 5e74cdb6..7e8f7431 100644 --- a/client/src/components/Utilities/GetCoordinates/GetCoordinates.tsx +++ b/client/src/components/Utilities/GetCoordinates/GetCoordinates.tsx @@ -152,7 +152,6 @@ const GetCoordinates: React.FC = () => { setResults(null); clearWarnings(); coordsResponse.warnings.forEach((warning) => { - console.log(warning); if (warning.startsWith("Found more than one accession")) { setChromosomeText("Complete ID required"); } else if (warning.startsWith("Unable to get exons for")) { @@ -169,8 +168,6 @@ const GetCoordinates: React.FC = () => { const exonPattern = /Exon (\d*) does not exist on (.*)/; const match = exonPattern.exec(warning); if (match) { - console.log(exonStart); - console.log(match[1]); if (exonStart === match[1]) { setExonStartText("Out of range"); } else if (exonEnd === match[1]) { @@ -255,7 +252,6 @@ const GetCoordinates: React.FC = () => { diff --git a/client/src/components/main/App/App.tsx b/client/src/components/main/App/App.tsx index 586b3ee5..483ec9e4 100644 --- a/client/src/components/main/App/App.tsx +++ b/client/src/components/main/App/App.tsx @@ -43,6 +43,11 @@ import LandingPage from "../Landing/LandingPage"; import AppMenu from "./AppMenu"; import DemoDropdown from "./DemoDropdown"; import { HelpPopover } from "../shared/HelpPopover/HelpPopover"; +import { + initialSettings, + SettingsContext, + SettingsType, +} from "../../../global/contexts/SettingsContext"; type ClientFusion = ClientCategoricalFusion | ClientAssayedFusion; @@ -69,6 +74,15 @@ const App = (): JSX.Element => { null ); const [selectedDemo, setSelectedDemo] = React.useState(""); + const [settings, setSettings] = useState(initialSettings); + + const handleSetSettings = (newSettings: SettingsType) => { + sessionStorage.setItem( + "fusion-builder-settings", + JSON.stringify(newSettings) + ); + setSettings(newSettings); + }; // TODO: implement open/closing of AppMenu. This variable will become a state variable const open = true; @@ -310,8 +324,8 @@ const App = (): JSX.Element => { ); return ( - <> - + +
{ - + {displayTool ? fusionsComponent : } @@ -395,8 +409,8 @@ const App = (): JSX.Element => { - - + + ); }; diff --git a/client/src/components/main/App/AppMenu.tsx b/client/src/components/main/App/AppMenu.tsx index ab729fb5..b1a5a74a 100644 --- a/client/src/components/main/App/AppMenu.tsx +++ b/client/src/components/main/App/AppMenu.tsx @@ -1,6 +1,13 @@ import React, { useEffect, useState } from "react"; import "./App.scss"; -import { Box, Typography, makeStyles, Link, Drawer } from "@material-ui/core"; +import { + Box, + Typography, + makeStyles, + Link, + Drawer, + Switch, +} from "@material-ui/core"; import { ServiceInfoResponse } from "../../../services/ResponseModels"; import { getInfo } from "../../../services/main"; @@ -16,28 +23,44 @@ const useStyles = makeStyles(() => ({ upperSection: { marginLeft: "10px", }, + drawerContainer: { + height: "100%", + display: "flex", + flexDirection: "column", + justifyContent: "space-between", + }, lowerSection: { marginBottom: "10px", display: "flex", - justifyContent: "center", + flexDirection: "column", }, - drawerContainer: { - height: "100%", + optionsContainer: { display: "flex", - flexDirection: "column", + flexDirection: "row", justifyContent: "space-between", + alignItems: "center", + margin: "0px 10px 0px 10px", + }, + optionsText: { fontSize: "0.8rem" }, + versionContainer: { + display: "flex", + justifyContent: "center", + paddingTop: "10px", }, versionText: { fontSize: "0.7rem", }, })); -interface AppMenuProps { - open?: boolean; -} - -export default function AppMenu(props: AppMenuProps): React.ReactElement { +export default function AppMenu({ + open, + settings, + setSettings, +}): React.ReactElement { const [serviceInfo, setServiceInfo] = useState({} as ServiceInfoResponse); + const [tooltipsEnabled, setTooltipsEnabled] = useState( + settings.enableToolTips + ); useEffect(() => { getInfo().then((infoResponse) => { @@ -46,10 +69,16 @@ export default function AppMenu(props: AppMenuProps): React.ReactElement { }, []); const classes = useStyles(); + + const handleTooltipsChange = (event) => { + setTooltipsEnabled(event.target.checked); + setSettings({ ...settings, enableToolTips: event.target.checked }); + }; + return ( @@ -127,9 +156,21 @@ export default function AppMenu(props: AppMenuProps): React.ReactElement { - - v{serviceInfo.curfu_version} - + + + Enable tooltips + + + + + + v{serviceInfo.curfu_version} + + diff --git a/client/src/components/main/shared/ChromosomeField/ChromosomeField.tsx b/client/src/components/main/shared/ChromosomeField/ChromosomeField.tsx index b2b5cb09..26d02456 100644 --- a/client/src/components/main/shared/ChromosomeField/ChromosomeField.tsx +++ b/client/src/components/main/shared/ChromosomeField/ChromosomeField.tsx @@ -4,17 +4,13 @@ import HelpTooltip from "../HelpTooltip/HelpTooltip"; interface Props { fieldValue: string; - valueSetter: CallableFunction; errorText: string; - keyHandler: KeyboardEventHandler | undefined; width?: number | undefined; } const ChromosomeField: React.FC = ({ fieldValue, - valueSetter, errorText, - keyHandler, width, }) => { const useStyles = makeStyles(() => ({ @@ -42,11 +38,10 @@ const ChromosomeField: React.FC = ({ valueSetter(event.target.value)} error={errorText != ""} - onKeyDown={keyHandler} label="Chromosome" helperText={errorText != "" ? errorText : null} + contentEditable={false} className={classes.textField} /> diff --git a/client/src/components/main/shared/GeneAutocomplete/GeneAutocomplete.tsx b/client/src/components/main/shared/GeneAutocomplete/GeneAutocomplete.tsx index 85821a70..fbeb24b4 100644 --- a/client/src/components/main/shared/GeneAutocomplete/GeneAutocomplete.tsx +++ b/client/src/components/main/shared/GeneAutocomplete/GeneAutocomplete.tsx @@ -33,7 +33,7 @@ interface Props { setGene: CallableFunction; geneText: string; setGeneText: CallableFunction; - tooltipDirection: + tooltipDirection?: | "bottom" | "left" | "right" @@ -89,7 +89,8 @@ export const GeneAutocomplete: React.FC = ({ setGene(selection.value); setGeneValue(selection); if (setChromosome) { - setChromosome(selection.chromosome); + // substring is to remove identifier from beginning of chromosome (ex: result in NC_000007.14 instead of NCBI:NC_000007.14) + setChromosome(selection.chromosome?.substring(selection.chromosome.indexOf(":") + 1)); } if (setStrand) { setStrand(selection.strand); diff --git a/client/src/components/main/shared/HelpTooltip/HelpTooltip.tsx b/client/src/components/main/shared/HelpTooltip/HelpTooltip.tsx index 5397e006..32440638 100644 --- a/client/src/components/main/shared/HelpTooltip/HelpTooltip.tsx +++ b/client/src/components/main/shared/HelpTooltip/HelpTooltip.tsx @@ -1,5 +1,6 @@ import { makeStyles, Tooltip } from "@material-ui/core"; -import React from "react"; +import React, { useContext } from "react"; +import { SettingsContext } from "../../../../global/contexts/SettingsContext"; const useStylesBootstrap = makeStyles(() => ({ tooltip: { @@ -14,6 +15,8 @@ const useStylesBootstrap = makeStyles(() => ({ })); const BootstrapTooltip = ({ title, placement, children }) => { + const settings = useContext(SettingsContext); + const classes = useStylesBootstrap(); return ( @@ -22,6 +25,9 @@ const BootstrapTooltip = ({ title, placement, children }) => { placement={placement ? placement : "right"} title={title} classes={classes} + disableFocusListener={!settings.enableToolTips} + disableHoverListener={!settings.enableToolTips} + disableTouchListener={!settings.enableToolTips} > {children} diff --git a/client/src/global/contexts/SettingsContext.tsx b/client/src/global/contexts/SettingsContext.tsx new file mode 100644 index 00000000..00395312 --- /dev/null +++ b/client/src/global/contexts/SettingsContext.tsx @@ -0,0 +1,16 @@ +import { createContext } from "react"; + +export type SettingsType = { + enableToolTips: boolean; +}; + +export const defaultSettings = { + enableToolTips: true, +}; + +export const initialSettings = + sessionStorage.getItem("fusion-builder-settings") !== null + ? JSON.parse(sessionStorage.getItem("fusion-builder-settings") as string) + : defaultSettings; + +export const SettingsContext = createContext(defaultSettings); diff --git a/requirements.txt b/requirements.txt index ba435c4a..d19c649b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -25,7 +25,7 @@ decorator==5.1.1 executing==1.2.0 fake-useragent==1.1.3 fastapi==0.100.0 -fusor==0.0.27 +fusor==0.0.30.dev1 ga4gh.vrs==0.8.4 ga4gh.vrsatile.pydantic==0.0.13 gene-normalizer==0.1.39 diff --git a/server/.flake8 b/server/.flake8 deleted file mode 100644 index 6b13a128..00000000 --- a/server/.flake8 +++ /dev/null @@ -1,20 +0,0 @@ -[flake8] -extend-ignore = D205, D400, I101, ANN101, ANN002, ANN003, W503, D301 -max-line-length = 100 -exclude = - .git - venv - __pycache__ - source - outputs - docs/* - analysis/* -inline-quotes = " -import-order-style = pep8 -application-import-names = - curation - tests -per-file-ignores = - tests/*:ANN001, ANN2 - *__init__.py:F401 -mypy-init-return = true diff --git a/server/curfu/version.py b/server/curfu/version.py deleted file mode 100644 index b53ed92c..00000000 --- a/server/curfu/version.py +++ /dev/null @@ -1,2 +0,0 @@ -"""Provide app version.""" -__version__ = "0.2.0-rc.3" diff --git a/server/pyproject.toml b/server/pyproject.toml new file mode 100644 index 00000000..c9456bda --- /dev/null +++ b/server/pyproject.toml @@ -0,0 +1,110 @@ +[project] +name = "curfu" +authors = [ + {name = "Alex Wagner", email = "alex.wagner@nationwidechildrens.org"}, + {name = "Kori Kuzma", email = "kori.kuzma@nationwidechildrens.org"}, + {name = "James Stevenson", email = "james.stevenson@nationwidechildrens.org"}, + {name = "Katie Stahl", email = "kathryn.stahl@nationwidechildrens.org"}, + {name = "Jeremy Arbesfeld", email = "jeremy.arbesfeld@nationwidechildrens.org"} +] +readme = "README.md" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Science/Research", + "Intended Audience :: Developers", + "Topic :: Scientific/Engineering :: Bio-Informatics", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", +] +requires-python = ">=3.10" +description = "Curation tool for gene fusions" +license = {file = "../LICENSE"} +dependencies = [ + "fastapi >= 0.72.0", + "aiofiles", + "asyncpg", + "fusor ~= 0.0.30-dev1", + "sqlparse >= 0.4.2", + "urllib3 >= 1.26.5", + "click", + "jinja2", + "boto3", +] +dynamic = ["version"] + +[project.optional-dependencies] +tests = [ + "pytest", + "pytest-asyncio >= 0.19.0", + "pytest-cov", + "coverage", + "httpx", +] +dev = [ + "psycopg2-binary", + "ruff", + "black", + "pre-commit>=3.7.1", + "gene-normalizer ~= 0.1.39", + "pydantic-to-typescript", +] + +[project.scripts] +curfu_devtools = "curfu.cli:devtools" +curfu = "curfu.cli:serve" + +[build-system] +requires = ["setuptools>=64", "setuptools_scm>=8"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools_scm] + +[tool.pytest.ini_options] +addopts = "--cov=src --cov-report term-missing" +testpaths = ["tests"] + +[tool.black] +line-length = 88 + +[tool.ruff] +# pycodestyle (E, W) +# Pyflakes (F) +# flake8-annotations (ANN) +# flake8-quotes (Q) +# pydocstyle (D) +# pep8-naming (N) +# isort (I) +select = ["E", "W", "F", "ANN", "Q", "D", "N", "I"] + +fixable = ["I"] + +# D205 - blank-line-after-summary +# D400 - ends-in-period +# D415 - ends-in-punctuation +# ANN101 - missing-type-self +# ANN003 - missing-type-kwargs +# E501 - line-too-long +ignore = ["D205", "D400", "D415", "ANN101", "ANN003", "E501"] + +[tool.ruff.flake8-quotes] +docstring-quotes = "double" + +[tool.ruff.per-file-ignores] +# ANN001 - missing-type-function-argument +# ANN2 - missing-return-type +# ANN102 - missing-type-cls +# N805 - invalid-first-argument-name-for-method +# F821 - undefined-name +# F401 - unused-import +"tests/*" = ["ANN001", "ANN2", "ANN102"] +"setup.py" = ["F821"] +"*__init__.py" = ["F401"] +"curfu/schemas.py" = ["ANN201", "N805", "ANN001"] +"curfu/routers/*" = ["D301"] +"curfu/cli.py" = ["D301"] diff --git a/server/setup.cfg b/server/setup.cfg deleted file mode 100644 index 9127d2bd..00000000 --- a/server/setup.cfg +++ /dev/null @@ -1,51 +0,0 @@ -[metadata] -name = curfu -description = Curation tool for gene fusions -long_description = file:README.md -long_description_content_type = text/markdown -author = Wagner Lab, Nationwide Childrens Hospital -license = MIT - -[options] -packages = find: -python_requires = >=3.8 -zip_safe = False -include_package_data = True - -install_requires = - fastapi >= 0.72.0 - aiofiles - asyncpg - fusor ~= 0.0.27 - sqlparse >= 0.4.2 - urllib3 >= 1.26.5 - click - jinja2 - boto3 - -[options.extras_require] -tests = - pytest - pytest-asyncio >= 0.19.0 - pytest-cov - coverage - httpx - -dev = - psycopg2-binary - flake8 - flake8-docstrings - black - pre-commit - gene-normalizer ~= 0.1.39 - pydantic-to-typescript - - -[options.entry_points] -console_scripts = - curfu_devtools = curfu.cli:devtools - curfu = curfu.cli:serve - -[tool:pytest] -addopts = --disable-warnings --cov-report term-missing --cov-config=.coveragerc --cov=curfu -asyncio_mode = auto diff --git a/server/setup.py b/server/setup.py deleted file mode 100644 index a3fd140d..00000000 --- a/server/setup.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Defines how metakb is packaged and distributed.""" -from setuptools import setup - -exec(open("curfu/version.py").read()) -setup(version=__version__) # noqa: F821 diff --git a/server/curfu/__init__.py b/server/src/curfu/__init__.py similarity index 86% rename from server/curfu/__init__.py rename to server/src/curfu/__init__.py index 523744b0..405d6d56 100644 --- a/server/curfu/__init__.py +++ b/server/src/curfu/__init__.py @@ -1,10 +1,15 @@ """Fusion curation interface.""" -from pathlib import Path -from os import environ import logging +from importlib.metadata import PackageNotFoundError, version +from os import environ +from pathlib import Path -from .version import __version__ - +try: + __version__ = version("curfu") +except PackageNotFoundError: + __version__ = "unknown" +finally: + del version, PackageNotFoundError # provide consistent paths APP_ROOT = Path(__file__).resolve().parents[0] @@ -51,13 +56,9 @@ SEQREPO_DATA_PATH = environ["SEQREPO_DATA_PATH"] -class ServiceWarning(Exception): +class LookupServiceError(Exception): """Custom Exception to use when lookups fail in curation services.""" - def __init__(self, *args, **kwargs): - """Initialize exception.""" - super().__init__(*args, **kwargs) - # define max acceptable matches for autocomplete suggestions MAX_SUGGESTIONS = 50 diff --git a/server/curfu/cli.py b/server/src/curfu/cli.py similarity index 99% rename from server/curfu/cli.py rename to server/src/curfu/cli.py index 15f7a25e..bb8ec26a 100644 --- a/server/curfu/cli.py +++ b/server/src/curfu/cli.py @@ -1,15 +1,15 @@ """Provide command-line interface to application and associated utilities.""" import os -from typing import Optional from pathlib import Path +from typing import Optional import click -from curfu import APP_ROOT +from curfu import APP_ROOT from curfu.devtools import DEFAULT_INTERPRO_TYPES from curfu.devtools.build_client_types import build_client_types -from curfu.devtools.build_interpro import build_gene_domain_maps from curfu.devtools.build_gene_suggest import GeneSuggestionBuilder +from curfu.devtools.build_interpro import build_gene_domain_maps @click.command() @@ -27,9 +27,8 @@ def serve(port: int) -> None: @click.group() -def devtools(): +def devtools() -> None: """Provide setup utilities for constructing data for Fusion Curation app.""" - pass types_help = """ diff --git a/server/curfu/devtools/__init__.py b/server/src/curfu/devtools/__init__.py similarity index 86% rename from server/curfu/devtools/__init__.py rename to server/src/curfu/devtools/__init__.py index d6241a8c..c1ba6c1e 100644 --- a/server/curfu/devtools/__init__.py +++ b/server/src/curfu/devtools/__init__.py @@ -1,10 +1,11 @@ """Utility functions for application setup.""" import ftplib +from typing import Callable from curfu import logger -def ftp_download(domain: str, path: str, fname: str, callback) -> None: +def ftp_download(domain: str, path: str, fname: str, callback: Callable) -> None: """Acquire file via FTP. :param str domain: domain name for remote file host :param str path: path within host to desired file diff --git a/server/curfu/devtools/build_client_types.py b/server/src/curfu/devtools/build_client_types.py similarity index 93% rename from server/curfu/devtools/build_client_types.py rename to server/src/curfu/devtools/build_client_types.py index a41abbca..8e2a940d 100644 --- a/server/curfu/devtools/build_client_types.py +++ b/server/src/curfu/devtools/build_client_types.py @@ -1,9 +1,10 @@ """Provide client type generation tooling.""" from pathlib import Path + from pydantic2ts.cli.script import generate_typescript_defs -def build_client_types(): +def build_client_types() -> None: """Construct type definitions for front-end client.""" client_dir = Path(__file__).resolve().parents[3] / "client" generate_typescript_defs( diff --git a/server/curfu/devtools/build_gene_suggest.py b/server/src/curfu/devtools/build_gene_suggest.py similarity index 97% rename from server/curfu/devtools/build_gene_suggest.py rename to server/src/curfu/devtools/build_gene_suggest.py index 88ec206b..f56ff3ab 100644 --- a/server/curfu/devtools/build_gene_suggest.py +++ b/server/src/curfu/devtools/build_gene_suggest.py @@ -1,14 +1,14 @@ """Provide tools to build backend data relating to gene identification.""" import csv -from typing import Dict, List, Optional -from pathlib import Path from datetime import datetime as dt +from pathlib import Path from timeit import default_timer as timer -from biocommons.seqrepo.seqrepo import SeqRepo +from typing import Dict, List, Optional +import click +from biocommons.seqrepo.seqrepo import SeqRepo from gene.database import create_db from gene.schemas import RecordType -import click from curfu import APP_ROOT, SEQREPO_DATA_PATH, logger @@ -19,7 +19,7 @@ class GeneSuggestionBuilder: Implemented as a class for easier sharing of database resources between methods. """ - def __init__(self): + def __init__(self) -> None: """Initialize class.""" self.gene_db = create_db() self.sr = SeqRepo(SEQREPO_DATA_PATH) @@ -51,6 +51,7 @@ def _make_list_column(values: List[str]) -> str: ensures that only unique, alphabetic values are included in the result. Note: + ---- - The method performs a case-insensitive comparison when filtering unique values. - If the input list contains non-alphabetic values or duplicates, they will be @@ -124,9 +125,8 @@ def _save_suggest_file(self, output_dir: Path) -> None: writer.writerow(row) def build_gene_suggestion_file(self, output_dir: Path = APP_ROOT / "data") -> None: - """ - Build the gene suggestions table file by processing gene records from the gene - database. + """Build the gene suggestions table file by processing gene records from the + gene database. - The gene database should be initialized before calling this method. - The gene suggestions table file will be saved in CSV format. diff --git a/server/curfu/devtools/build_interpro.py b/server/src/curfu/devtools/build_interpro.py similarity index 98% rename from server/curfu/devtools/build_interpro.py rename to server/src/curfu/devtools/build_interpro.py index b1337dbb..112fe277 100644 --- a/server/curfu/devtools/build_interpro.py +++ b/server/src/curfu/devtools/build_interpro.py @@ -1,17 +1,17 @@ """Provide utilities relating to data fetched from InterPro service.""" -import gzip -from typing import Tuple, Dict, Optional, Set -from pathlib import Path import csv -from datetime import datetime -from timeit import default_timer as timer +import gzip import os import shutil -import xml.etree.ElementTree as ET -from gene.database import create_db +import xml.etree.ElementTree as ET # noqa: N817 +from datetime import datetime +from pathlib import Path +from timeit import default_timer as timer +from typing import Dict, Optional, Set, Tuple -from gene.query import QueryHandler import click +from gene.database import create_db +from gene.query import QueryHandler from curfu import APP_ROOT, logger from curfu.devtools import ftp_download @@ -35,7 +35,7 @@ def download_protein2ipr(output_dir: Path) -> None: gz_file_path = output_dir / "protein2ipr.dat.gz" with open(gz_file_path, "w") as fp: - def writefile(data): + def writefile(data): # noqa fp.write(data) ftp_download( @@ -284,7 +284,7 @@ def build_gene_domain_maps( # get relevant Interpro IDs interpro_data_bin = [] - def get_interpro_data(data): + def get_interpro_data(data): # noqa interpro_data_bin.append(data) ftp_download( diff --git a/server/curfu/domain_services.py b/server/src/curfu/domain_services.py similarity index 94% rename from server/curfu/domain_services.py rename to server/src/curfu/domain_services.py index 55511b03..ca1b08af 100644 --- a/server/curfu/domain_services.py +++ b/server/src/curfu/domain_services.py @@ -1,13 +1,15 @@ """Provide lookup services for functional domains. -TODO +Todo: +---- * domains file should be a JSON and pre-pruned to unique pairs * get_possible_domains shouldn't have to force uniqueness + """ -from typing import List, Dict import csv +from typing import Dict, List -from curfu import logger, ServiceWarning +from curfu import LookupServiceError, logger from curfu.utils import get_data_file @@ -56,5 +58,5 @@ def get_possible_domains(self, gene_id: str) -> List[Dict]: domains = self.domains[gene_id.lower()] except KeyError: logger.warning(f"Unable to retrieve associated domains for {gene_id}") - raise ServiceWarning + raise LookupServiceError return domains diff --git a/server/curfu/gene_services.py b/server/src/curfu/gene_services.py similarity index 92% rename from server/curfu/gene_services.py rename to server/src/curfu/gene_services.py index a39c691d..5d43529b 100644 --- a/server/curfu/gene_services.py +++ b/server/src/curfu/gene_services.py @@ -1,15 +1,20 @@ """Wrapper for required Gene Normalization services.""" -from pathlib import Path -from typing import List, Optional, Tuple, Dict, Union import csv +from pathlib import Path +from typing import Dict, List, Optional, Tuple, Union +from ga4gh.vrsatile.pydantic.vrsatile_models import CURIE from gene.query import QueryHandler from gene.schemas import MatchType -from ga4gh.vrsatile.pydantic.vrsatile_models import CURIE -from curfu import logger, ServiceWarning +from curfu import LookupServiceError, logger from curfu.utils import get_data_file +# term -> (normalized ID, normalized label) +Map = Dict[str, Tuple[str, str, str]] + +# term -> (normalized ID, normalized label) +Map = Dict[str, Tuple[str, str, str]] # term, symbol, concept ID, chromosome, strand Suggestion = Tuple[str, str, str, str, str] @@ -17,7 +22,7 @@ class GeneService: """Provide gene ID resolution and term autocorrect suggestions.""" - def __init__(self, suggestions_file: Optional[Path] = None): + def __init__(self, suggestions_file: Optional[Path] = None) -> None: """Initialize gene service provider class. :param suggestions_file: path to existing suggestions file. If not provided, @@ -60,13 +65,13 @@ def get_normalized_gene( if not gd or not gd.gene_id: msg = f"Unexpected null property in normalized response for `{term}`" logger.error(msg) - raise ServiceWarning(msg) + raise LookupServiceError(msg) concept_id = gd.gene_id symbol = gd.label if not symbol: msg = f"Unable to retrieve symbol for gene {concept_id}" logger.error(msg) - raise ServiceWarning(msg) + raise LookupServiceError(msg) term_lower = term.lower() term_cased = None if response.match_type == 100: @@ -109,7 +114,7 @@ def get_normalized_gene( else: warn = f"Lookup of gene term {term} failed." logger.warning(warn) - raise ServiceWarning(warn) + raise LookupServiceError(warn) @staticmethod def _get_completion_results(term: str, lookup: Dict) -> List[Suggestion]: @@ -134,8 +139,6 @@ def suggest_genes(self, query: str) -> Dict[str, List[Suggestion]]: :returns: dict returning list containing any number of suggestion tuples, where each is the correctly-cased term, normalized ID, normalized label, for each item type - :raises ServiceWarning: if number of matching suggestions exceeds - MAX_SUGGESTIONS """ q_upper = query.upper() suggestions = {} @@ -147,5 +150,4 @@ def suggest_genes(self, query: str) -> Dict[str, List[Suggestion]]: q_upper, self.prev_symbols_map ) suggestions["aliases"] = self._get_completion_results(q_upper, self.aliases_map) - return suggestions diff --git a/server/curfu/main.py b/server/src/curfu/main.py similarity index 81% rename from server/curfu/main.py rename to server/src/curfu/main.py index 6010bd68..a14cb86b 100644 --- a/server/curfu/main.py +++ b/server/src/curfu/main.py @@ -3,26 +3,36 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from starlette.templating import _TemplateResponse as TemplateResponse from fusor import FUSOR +from starlette.templating import _TemplateResponse as TemplateResponse from curfu import APP_ROOT -from curfu.version import __version__ as curfu_version -from curfu.gene_services import GeneService +from curfu import __version__ as curfu_version from curfu.domain_services import DomainService +from curfu.gene_services import GeneService from curfu.routers import ( - nomenclature, - utilities, - constructors, - lookup, complete, - validate, + constructors, demo, + lookup, meta, + nomenclature, + utilities, + validate, ) - fastapi_app = FastAPI( + title="Fusion Curation API", + description="Provide data functions to support [VICC Fusion Curation interface](fusion-builder.cancervariants.org/).", + contact={ + "name": "Alex H. Wagner", + "email": "Alex.Wagner@nationwidechildrens.org", + "url": "https://www.nationwidechildrens.org/specialties/institute-for-genomic-medicine/research-labs/wagner-lab", + }, + license={ + "name": "MIT", + "url": "https://github.com/cancervariants/fusion-curation/blob/main/LICENSE", + }, version=curfu_version, swagger_ui_parameters={"tryItOutEnabled": True}, docs_url="/docs", @@ -52,8 +62,7 @@ def serve_react_app(app: FastAPI) -> FastAPI: - """ - Wrap application initialization in Starlette route param converter. + """Wrap application initialization in Starlette route param converter. :param app: FastAPI application instance :return: application with React frontend mounted @@ -65,10 +74,9 @@ def serve_react_app(app: FastAPI) -> FastAPI: ) templates = Jinja2Templates(directory=BUILD_DIR.as_posix()) - @app.get("/{full_path:path}") + @app.get("/{full_path:path}", include_in_schema=False) async def serve_react_app(request: Request, full_path: str) -> TemplateResponse: - """ - Add arbitrary path support to FastAPI service. + """Add arbitrary path support to FastAPI service. React-router provides something akin to client-side routing based out of the Javascript embedded in index.html. However, FastAPI will intercede @@ -117,7 +125,7 @@ def get_domain_services() -> DomainService: @app.on_event("startup") -async def startup(): +async def startup() -> None: """Get FUSOR reference""" app.state.fusor = await start_fusor() app.state.genes = get_gene_services() @@ -125,6 +133,6 @@ async def startup(): @app.on_event("shutdown") -async def shutdown(): +async def shutdown() -> None: """Clean up thread pool.""" await app.state.fusor.cool_seq_tool.uta_db._connection_pool.close() diff --git a/server/curfu/routers/__init__.py b/server/src/curfu/routers/__init__.py similarity index 100% rename from server/curfu/routers/__init__.py rename to server/src/curfu/routers/__init__.py diff --git a/server/curfu/routers/complete.py b/server/src/curfu/routers/complete.py similarity index 87% rename from server/curfu/routers/complete.py rename to server/src/curfu/routers/complete.py index b7039663..5b03bb71 100644 --- a/server/curfu/routers/complete.py +++ b/server/src/curfu/routers/complete.py @@ -1,11 +1,15 @@ """Provide routes for autocomplete/term suggestion methods""" -from typing import Dict, Any +from typing import Any, Dict -from fastapi import Query, APIRouter, Request - -from curfu import MAX_SUGGESTIONS, ServiceWarning -from curfu.schemas import ResponseDict, AssociatedDomainResponse, SuggestGeneResponse +from fastapi import APIRouter, Query, Request +from curfu import MAX_SUGGESTIONS, LookupServiceError +from curfu.schemas import ( + AssociatedDomainResponse, + ResponseDict, + RouteTag, + SuggestGeneResponse, +) router = APIRouter() @@ -15,6 +19,7 @@ operation_id="suggestGene", response_model=SuggestGeneResponse, response_model_exclude_none=True, + tags=[RouteTag.COMPLETION], ) def suggest_gene(request: Request, term: str = Query("")) -> ResponseDict: """Provide completion suggestions for term provided by user. @@ -53,6 +58,7 @@ def suggest_gene(request: Request, term: str = Query("")) -> ResponseDict: operation_id="suggestDomain", response_model=AssociatedDomainResponse, response_model_exclude_none=True, + tags=[RouteTag.COMPLETION], ) def suggest_domain(request: Request, gene_id: str = Query("")) -> ResponseDict: """Provide possible domains associated with a given gene to be selected by a user. @@ -67,6 +73,6 @@ def suggest_domain(request: Request, gene_id: str = Query("")) -> ResponseDict: try: possible_matches = request.app.state.domains.get_possible_domains(gene_id) response["suggestions"] = possible_matches - except ServiceWarning: + except LookupServiceError: response["warnings"] = [f"No associated domains for {gene_id}"] return response diff --git a/server/curfu/routers/constructors.py b/server/src/curfu/routers/constructors.py similarity index 95% rename from server/curfu/routers/constructors.py rename to server/src/curfu/routers/constructors.py index f461a9f4..fab4542e 100644 --- a/server/curfu/routers/constructors.py +++ b/server/src/curfu/routers/constructors.py @@ -1,21 +1,22 @@ """Provide routes for element construction endpoints""" from typing import Optional -from fastapi import Query, Request, APIRouter +from fastapi import APIRouter, Query, Request +from fusor.models import DomainStatus, RegulatoryClass, Strand from pydantic import ValidationError -from fusor.models import RegulatoryClass, Strand, DomainStatus from curfu import logger +from curfu.routers import parse_identifier from curfu.schemas import ( GeneElementResponse, - RegulatoryElementResponse, - TxSegmentElementResponse, - TemplatedSequenceElementResponse, GetDomainResponse, + RegulatoryElementResponse, ResponseDict, + RouteTag, + TemplatedSequenceElementResponse, + TxSegmentElementResponse, ) -from curfu.routers import parse_identifier -from curfu.sequence_services import get_strand, InvalidInputException +from curfu.sequence_services import InvalidInputError, get_strand router = APIRouter() @@ -25,9 +26,11 @@ operation_id="buildGeneElement", response_model=GeneElementResponse, response_model_exclude_none=True, + tags=[RouteTag.CONSTRUCTORS], ) def build_gene_element(request: Request, term: str = Query("")) -> GeneElementResponse: """Construct valid gene element given user-provided term. + \f :param request: the HTTP request context, supplied by FastAPI. Use to access FUSOR and UTA-associated tools. @@ -47,6 +50,7 @@ def build_gene_element(request: Request, term: str = Query("")) -> GeneElementRe operation_id="buildTranscriptSegmentElementECT", response_model=TxSegmentElementResponse, response_model_exclude_none=True, + tags=[RouteTag.CONSTRUCTORS], ) async def build_tx_segment_ect( request: Request, @@ -84,6 +88,7 @@ async def build_tx_segment_ect( operation_id="buildTranscriptSegmentElementGCT", response_model=TxSegmentElementResponse, response_model_exclude_none=True, + tags=[RouteTag.CONSTRUCTORS], ) async def build_tx_segment_gct( request: Request, @@ -108,7 +113,7 @@ async def build_tx_segment_gct( if strand is not None: try: strand_validated = get_strand(strand) - except InvalidInputException: + except InvalidInputError: warning = f"Received invalid strand value: {strand}" logger.warning(warning) return TxSegmentElementResponse(warnings=[warning], element=None) @@ -131,6 +136,7 @@ async def build_tx_segment_gct( operation_id="buildTranscriptSegmentElementGCG", response_model=TxSegmentElementResponse, response_model_exclude_none=True, + tags=[RouteTag.CONSTRUCTORS], ) async def build_tx_segment_gcg( request: Request, @@ -155,7 +161,7 @@ async def build_tx_segment_gcg( if strand is not None: try: strand_validated = get_strand(strand) - except InvalidInputException: + except InvalidInputError: warning = f"Received invalid strand value: {strand}" logger.warning(warning) return TxSegmentElementResponse(warnings=[warning], element=None) @@ -178,6 +184,7 @@ async def build_tx_segment_gcg( operation_id="buildTemplatedSequenceElement", response_model=TemplatedSequenceElementResponse, response_model_exclude_none=True, + tags=[RouteTag.CONSTRUCTORS], ) def build_templated_sequence_element( request: Request, start: int, end: int, sequence_id: str, strand: str @@ -214,6 +221,7 @@ def build_templated_sequence_element( operation_id="getDomain", response_model=GetDomainResponse, response_model_exclude_none=True, + tags=[RouteTag.CONSTRUCTORS], ) def build_domain( request: Request, @@ -259,6 +267,7 @@ def build_domain( operation_id="getRegulatoryElement", response_model=RegulatoryElementResponse, response_model_exclude_none=True, + tags=[RouteTag.CONSTRUCTORS], ) def build_regulatory_element( request: Request, element_class: RegulatoryClass, gene_name: str diff --git a/server/curfu/routers/demo.py b/server/src/curfu/routers/demo.py similarity index 96% rename from server/curfu/routers/demo.py rename to server/src/curfu/routers/demo.py index 702a5e76..434f3e13 100644 --- a/server/curfu/routers/demo.py +++ b/server/src/curfu/routers/demo.py @@ -1,39 +1,40 @@ """Provide routes for accessing demo objects to client.""" -from uuid import uuid4 from typing import Union +from uuid import uuid4 from fastapi import APIRouter, Request -from fusor import examples, FUSOR +from fusor import FUSOR, examples from fusor.models import ( - RegulatoryElement, - StructuralElementType, - CategoricalFusion, AssayedFusion, + CategoricalFusion, FUSORTypes, + RegulatoryElement, + StructuralElementType, ) from fusor.nomenclature import ( - tx_segment_nomenclature, - templated_seq_nomenclature, gene_nomenclature, reg_element_nomenclature, + templated_seq_nomenclature, + tx_segment_nomenclature, ) from curfu.schemas import ( - DemoResponse, - ClientTranscriptSegmentElement, + ClientAssayedFusion, + ClientCategoricalFusion, + ClientGeneElement, ClientLinkerElement, + ClientMultiplePossibleGenesElement, ClientTemplatedSequenceElement, - ClientGeneElement, + ClientTranscriptSegmentElement, ClientUnknownGeneElement, - ClientMultiplePossibleGenesElement, - TranscriptSegmentElement, + DemoResponse, + GeneElement, LinkerElement, + MultiplePossibleGenesElement, + RouteTag, TemplatedSequenceElement, - GeneElement, + TranscriptSegmentElement, UnknownGeneElement, - MultiplePossibleGenesElement, - ClientCategoricalFusion, - ClientAssayedFusion, ) router = APIRouter() @@ -63,8 +64,7 @@ def clientify_structural_element( element: ElementUnion, fusor_instance: FUSOR, ) -> ClientElementUnion: - """ - Add fields required by client to structural element object. + """Add fields required by client to structural element object. \f :param element: a structural element object :param fusor_instance: instantiated FUSOR object, passed down from FastAPI request @@ -112,8 +112,8 @@ def clientify_structural_element( def clientify_fusion(fusion: Fusion, fusor_instance: FUSOR) -> ClientFusion: - """ - Add client-required properties to fusion object. + """Add client-required properties to fusion object. + :param fusion: fusion to append to :param fusor_instance: FUSOR object instance provided by FastAPI request context :return: completed client-ready fusion @@ -157,6 +157,7 @@ def clientify_fusion(fusion: Fusion, fusor_instance: FUSOR) -> ClientFusion: operation_id="alkDemo", response_model=DemoResponse, response_model_exclude_none=True, + tags=[RouteTag.DEMOS], ) def get_alk(request: Request) -> DemoResponse: """Retrieve ALK assayed fusion. @@ -177,6 +178,7 @@ def get_alk(request: Request) -> DemoResponse: operation_id="ewsr1Demo", response_model=DemoResponse, response_model_exclude_none=True, + tags=[RouteTag.DEMOS], ) def get_ewsr1(request: Request) -> DemoResponse: """Retrieve EWSR1 assayed fusion. @@ -197,6 +199,7 @@ def get_ewsr1(request: Request) -> DemoResponse: operation_id="bcrAbl1Demo", response_model=DemoResponse, response_model_exclude_none=True, + tags=[RouteTag.DEMOS], ) def get_bcr_abl1(request: Request) -> DemoResponse: """Retrieve BCR-ABL1 categorical fusion. @@ -217,6 +220,7 @@ def get_bcr_abl1(request: Request) -> DemoResponse: operation_id="tpm3Ntrk1Demo", response_model=DemoResponse, response_model_exclude_none=True, + tags=[RouteTag.DEMOS], ) def get_tpm3_ntrk1(request: Request) -> DemoResponse: """Retrieve TPM3-NTRK1 assayed fusion. @@ -237,6 +241,7 @@ def get_tpm3_ntrk1(request: Request) -> DemoResponse: operation_id="tpm3PdgfrbDemo", response_model=DemoResponse, response_model_exclude_none=True, + tags=[RouteTag.DEMOS], ) def get_tpm3_pdgfrb(request: Request) -> DemoResponse: """Retrieve TPM3-PDGFRB assayed fusion. @@ -257,6 +262,7 @@ def get_tpm3_pdgfrb(request: Request) -> DemoResponse: operation_id="ighMycDemo", response_model=DemoResponse, response_model_exclude_none=True, + tags=[RouteTag.DEMOS], ) def get_igh_myc(request: Request) -> DemoResponse: """Retrieve IGH-MYC assayed fusion. diff --git a/server/curfu/routers/lookup.py b/server/src/curfu/routers/lookup.py similarity index 82% rename from server/curfu/routers/lookup.py rename to server/src/curfu/routers/lookup.py index 348b96e5..4e66948f 100644 --- a/server/curfu/routers/lookup.py +++ b/server/src/curfu/routers/lookup.py @@ -1,9 +1,8 @@ """Provide routes for basic data lookup endpoints""" -from fastapi import Query, Request, APIRouter - -from curfu import ServiceWarning -from curfu.schemas import ResponseDict, NormalizeGeneResponse +from fastapi import APIRouter, Query, Request +from curfu import LookupServiceError +from curfu.schemas import NormalizeGeneResponse, ResponseDict, RouteTag router = APIRouter() @@ -13,6 +12,7 @@ operation_id="normalizeGene", response_model=NormalizeGeneResponse, response_model_exclude_none=True, + tags=[RouteTag.LOOKUP], ) def normalize_gene(request: Request, term: str = Query("")) -> ResponseDict: """Normalize gene term provided by user. @@ -30,6 +30,6 @@ def normalize_gene(request: Request, term: str = Query("")) -> ResponseDict: response["concept_id"] = concept_id response["symbol"] = symbol response["cased"] = cased - except ServiceWarning as e: + except LookupServiceError as e: response["warnings"] = [str(e)] return response diff --git a/server/curfu/routers/meta.py b/server/src/curfu/routers/meta.py similarity index 72% rename from server/curfu/routers/meta.py rename to server/src/curfu/routers/meta.py index db76b24f..85211baf 100644 --- a/server/curfu/routers/meta.py +++ b/server/src/curfu/routers/meta.py @@ -1,17 +1,19 @@ """Provide service meta information""" +from cool_seq_tool.version import __version__ as cool_seq_tool_version from fastapi import APIRouter - from fusor import __version__ as fusor_version -from cool_seq_tool.version import __version__ as cool_seq_tool_version -from curfu.schemas import ServiceInfoResponse -from curfu.version import __version__ as curfu_version +from curfu import __version__ as curfu_version +from curfu.schemas import RouteTag, ServiceInfoResponse router = APIRouter() @router.get( - "/api/service_info", operation_id="serviceInfo", response_model=ServiceInfoResponse + "/api/service_info", + operation_id="serviceInfo", + response_model=ServiceInfoResponse, + tags=[RouteTag.META], ) def get_service_info() -> ServiceInfoResponse: """Return service info.""" diff --git a/server/curfu/routers/nomenclature.py b/server/src/curfu/routers/nomenclature.py similarity index 94% rename from server/curfu/routers/nomenclature.py rename to server/src/curfu/routers/nomenclature.py index 1c38c4c2..710cdc84 100644 --- a/server/curfu/routers/nomenclature.py +++ b/server/src/curfu/routers/nomenclature.py @@ -1,7 +1,8 @@ """Provide routes for nomenclature generation.""" from typing import Dict -from fastapi import Request, APIRouter, Body +from fastapi import APIRouter, Body, Request +from fusor.exceptions import FUSORParametersException from fusor.models import ( GeneElement, RegulatoryElement, @@ -14,12 +15,10 @@ templated_seq_nomenclature, tx_segment_nomenclature, ) -from fusor.exceptions import FUSORParametersException from pydantic import ValidationError from curfu import logger -from curfu.schemas import NomenclatureResponse, ResponseDict - +from curfu.schemas import NomenclatureResponse, ResponseDict, RouteTag router = APIRouter() @@ -29,12 +28,13 @@ operation_id="regulatoryElementNomenclature", response_model=NomenclatureResponse, response_model_exclude_none=True, + tags=[RouteTag.NOMENCLATURE], ) def generate_regulatory_element_nomenclature( request: Request, regulatory_element: Dict = Body() ) -> ResponseDict: - """ - Build regulatory element nomenclature. + """Build regulatory element nomenclature. + \f :param request: the HTTP request context, supplied by FastAPI. Use to access FUSOR and UTA-associated tools. @@ -70,10 +70,11 @@ def generate_regulatory_element_nomenclature( operation_id="txSegmentNomenclature", response_model=NomenclatureResponse, response_model_exclude_none=True, + tags=[RouteTag.NOMENCLATURE], ) def generate_tx_segment_nomenclature(tx_segment: Dict = Body()) -> ResponseDict: - """ - Build transcript segment element nomenclature. + """Build transcript segment element nomenclature. + \f :param request: the HTTP request context, supplied by FastAPI. Use to access FUSOR and UTA-associated tools. @@ -97,6 +98,7 @@ def generate_tx_segment_nomenclature(tx_segment: Dict = Body()) -> ResponseDict: operation_id="templatedSequenceNomenclature", response_model=NomenclatureResponse, response_model_exclude_none=True, + tags=[RouteTag.NOMENCLATURE], ) def generate_templated_seq_nomenclature( request: Request, templated_sequence: Dict = Body() @@ -137,6 +139,7 @@ def generate_templated_seq_nomenclature( operation_id="geneNomenclature", response_model=NomenclatureResponse, response_model_exclude_none=True, + tags=[RouteTag.NOMENCLATURE], ) def generate_gene_nomenclature(gene_element: Dict = Body()) -> ResponseDict: """Build gene element nomenclature. @@ -171,6 +174,7 @@ def generate_gene_nomenclature(gene_element: Dict = Body()) -> ResponseDict: operation_id="fusionNomenclature", response_model=NomenclatureResponse, response_model_exclude_none=True, + tags=[RouteTag.NOMENCLATURE], ) def generate_fusion_nomenclature( request: Request, fusion: Dict = Body() diff --git a/server/curfu/routers/utilities.py b/server/src/curfu/routers/utilities.py similarity index 95% rename from server/curfu/routers/utilities.py rename to server/src/curfu/routers/utilities.py index 89a0f6b0..2ce7799c 100644 --- a/server/curfu/routers/utilities.py +++ b/server/src/curfu/routers/utilities.py @@ -1,22 +1,22 @@ """Provide routes for app utility endpoints""" -from typing import Dict, Optional, List, Any -import tempfile import os +import tempfile from pathlib import Path +from typing import Any, Dict, List, Optional -from fastapi import APIRouter, Request, Query, HTTPException +from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import FileResponse +from gene import schemas as gene_schemas from starlette.background import BackgroundTasks -from gene import schemas as GeneSchemas from curfu import logger from curfu.schemas import ( - GetTranscriptsResponse, CoordsUtilsResponse, + GetTranscriptsResponse, + RouteTag, SequenceIDResponse, ) -from curfu.sequence_services import get_strand, InvalidInputException - +from curfu.sequence_services import InvalidInputError, get_strand router = APIRouter() @@ -26,6 +26,7 @@ operation_id="getMANETranscripts", response_model=GetTranscriptsResponse, response_model_exclude_none=True, + tags=[RouteTag.UTILITIES], ) def get_mane_transcripts(request: Request, term: str) -> Dict: """Get MANE transcripts for gene term. @@ -36,7 +37,7 @@ def get_mane_transcripts(request: Request, term: str) -> Dict: :return: Dict containing transcripts if lookup succeeds, or warnings upon failure """ normalized = request.app.state.fusor.gene_normalizer.normalize(term) - if normalized.match_type == GeneSchemas.MatchType.NO_MATCH: + if normalized.match_type == gene_schemas.MatchType.NO_MATCH: return {"warnings": [f"Normalization error: {term}"]} elif not normalized.gene_descriptor.gene_id.lower().startswith("hgnc"): return {"warnings": [f"No HGNC symbol: {term}"]} @@ -55,6 +56,7 @@ def get_mane_transcripts(request: Request, term: str) -> Dict: operation_id="getGenomicCoords", response_model=CoordsUtilsResponse, response_model_exclude_none=True, + tags=[RouteTag.UTILITIES], ) async def get_genome_coords( request: Request, @@ -127,6 +129,7 @@ async def get_genome_coords( operation_id="getExonCoords", response_model=CoordsUtilsResponse, response_model_exclude_none=True, + tags=[RouteTag.UTILITIES], ) async def get_exon_coords( request: Request, @@ -157,7 +160,7 @@ async def get_exon_coords( if strand is not None: try: strand_validated = get_strand(strand) - except InvalidInputException: + except InvalidInputError: warnings.append(f"Received invalid strand value: {strand}") else: strand_validated = strand @@ -186,6 +189,7 @@ async def get_exon_coords( operation_id="getSequenceId", response_model=SequenceIDResponse, response_model_exclude_none=True, + tags=[RouteTag.UTILITIES], ) async def get_sequence_id(request: Request, sequence: str) -> SequenceIDResponse: """Get GA4GH sequence ID and aliases given sequence sequence ID @@ -235,6 +239,7 @@ async def get_sequence_id(request: Request, sequence: str) -> SequenceIDResponse description="Given a known accession identifier, retrieve sequence data and return" "as a FASTA file", response_class=FileResponse, + tags=[RouteTag.UTILITIES], ) async def get_sequence( request: Request, diff --git a/server/curfu/routers/validate.py b/server/src/curfu/routers/validate.py similarity index 88% rename from server/curfu/routers/validate.py rename to server/src/curfu/routers/validate.py index d4425c2e..a3fc0371 100644 --- a/server/curfu/routers/validate.py +++ b/server/src/curfu/routers/validate.py @@ -1,10 +1,10 @@ """Provide validation endpoint to confirm correctness of fusion object structure.""" from typing import Dict -from fastapi import Body, Request, APIRouter +from fastapi import APIRouter, Body, Request from fusor.exceptions import FUSORParametersException -from curfu.schemas import ResponseDict, ValidateFusionResponse +from curfu.schemas import ResponseDict, RouteTag, ValidateFusionResponse router = APIRouter() @@ -14,6 +14,7 @@ operation_id="validateFusion", response_model=ValidateFusionResponse, response_model_exclude_none=True, + tags=[RouteTag.VALIDATORS], ) def validate_fusion(request: Request, fusion: Dict = Body()) -> ResponseDict: """Validate proposed Fusion object. Return warnings if invalid. diff --git a/server/curfu/schemas.py b/server/src/curfu/schemas.py similarity index 94% rename from server/curfu/schemas.py rename to server/src/curfu/schemas.py index 0f3d1dcb..9cd71c61 100644 --- a/server/curfu/schemas.py +++ b/server/src/curfu/schemas.py @@ -1,24 +1,24 @@ """Provide schemas for FastAPI responses.""" -from typing import List, Optional, Tuple, Union, Literal, Dict +from enum import Enum +from typing import Dict, List, Literal, Optional, Tuple, Union -from pydantic import BaseModel, Field, StrictStr, StrictInt, validator, Extra -from ga4gh.vrsatile.pydantic.vrsatile_models import CURIE +from cool_seq_tool.schemas import GenomicData from fusor.models import ( AssayedFusion, CategoricalFusion, + FunctionalDomain, Fusion, - TranscriptSegmentElement, - LinkerElement, - TemplatedSequenceElement, GeneElement, - UnknownGeneElement, + LinkerElement, MultiplePossibleGenesElement, RegulatoryElement, - FunctionalDomain, Strand, + TemplatedSequenceElement, + TranscriptSegmentElement, + UnknownGeneElement, ) -from cool_seq_tool.schemas import GenomicData - +from ga4gh.vrsatile.pydantic.vrsatile_models import CURIE +from pydantic import BaseModel, Extra, Field, StrictInt, StrictStr, validator ResponseWarnings = Optional[List[StrictStr]] @@ -318,3 +318,17 @@ class DemoResponse(Response): """Response model for demo fusion object retrieval endpoints.""" fusion: Union[ClientAssayedFusion, ClientCategoricalFusion] + + +class RouteTag(str, Enum): + """Define tags for API routes.""" + + UTILITIES = "Utilities" + CONSTRUCTORS = "Constructors" + VALIDATORS = "Validators" + COMPLETION = "Completion" + NOMENCLATURE = "Nomenclature" + DEMOS = "Demos" + META = "Meta" + SERVICE = "Service" + LOOKUP = "Lookup" diff --git a/server/curfu/sequence_services.py b/server/src/curfu/sequence_services.py similarity index 85% rename from server/curfu/sequence_services.py rename to server/src/curfu/sequence_services.py index 996b8a69..b6535b84 100644 --- a/server/curfu/sequence_services.py +++ b/server/src/curfu/sequence_services.py @@ -5,11 +5,9 @@ logger.setLevel(logging.DEBUG) -class InvalidInputException(Exception): +class InvalidInputError(Exception): """Provide exception for input validation.""" - pass - def get_strand(input: str) -> int: """Validate strand arguments received from client. @@ -22,4 +20,4 @@ def get_strand(input: str) -> int: elif input == "-": return -1 else: - raise InvalidInputException + raise InvalidInputError diff --git a/server/curfu/utils.py b/server/src/curfu/utils.py similarity index 97% rename from server/curfu/utils.py rename to server/src/curfu/utils.py index 2b56208e..ec610b52 100644 --- a/server/curfu/utils.py +++ b/server/src/curfu/utils.py @@ -1,15 +1,14 @@ """Miscellaneous helper functions.""" import os from pathlib import Path -from typing import TypeVar, List +from typing import List, TypeVar import boto3 from boto3.exceptions import ResourceLoadException -from botocore.exceptions import ClientError - from botocore.config import Config +from botocore.exceptions import ClientError -from curfu import logger, APP_ROOT +from curfu import APP_ROOT, logger ObjectSummary = TypeVar("ObjectSummary") diff --git a/server/tests/conftest.py b/server/tests/conftest.py index c1d4a340..46c481b8 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -1,11 +1,11 @@ """Provide core fixtures for testing Flask functions.""" +import asyncio from typing import Callable, Dict import pytest -import asyncio from httpx import AsyncClient -from curfu.main import app, start_fusor, get_gene_services, get_domain_services +from curfu.main import app, get_domain_services, get_gene_services, start_fusor @pytest.fixture(scope="session") diff --git a/server/tests/integration/test_complete.py b/server/tests/integration/test_complete.py index 08c51394..0f2e6a6d 100644 --- a/server/tests/integration/test_complete.py +++ b/server/tests/integration/test_complete.py @@ -4,7 +4,7 @@ @pytest.mark.asyncio -async def test_normalize_gene(async_client: AsyncClient): +async def test_complete_gene(async_client: AsyncClient): """Test /complete/gene endpoint""" response = await async_client.get("/api/complete/gene?term=NTRK") assert response.status_code == 200 diff --git a/server/tests/integration/test_main.py b/server/tests/integration/test_main.py index f220fe6b..0a692e2d 100644 --- a/server/tests/integration/test_main.py +++ b/server/tests/integration/test_main.py @@ -15,10 +15,10 @@ async def test_service_info(async_client): assert response.status_code == 200 response_json = response.json() assert response_json["warnings"] == [] - SEMVER_PATTERN = r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" # noqa: E501 - assert re.match(SEMVER_PATTERN, response_json["curfu_version"]) - assert re.match(SEMVER_PATTERN, response_json["fusor_version"]) - assert re.match(SEMVER_PATTERN, response_json["cool_seq_tool_version"]) + semver_pattern = r"^(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$" # noqa: E501 + assert re.match(semver_pattern, response_json["curfu_version"]) + assert re.match(semver_pattern, response_json["fusor_version"]) + assert re.match(semver_pattern, response_json["cool_seq_tool_version"]) # not sure if I want to include vrs-python # also its current version number isn't legal semver # assert re.match( diff --git a/server/tests/integration/test_nomenclature.py b/server/tests/integration/test_nomenclature.py index 5fc39bfe..782d538d 100644 --- a/server/tests/integration/test_nomenclature.py +++ b/server/tests/integration/test_nomenclature.py @@ -2,8 +2,8 @@ from typing import Dict import pytest -from httpx import AsyncClient from fusor.examples import bcr_abl1 +from httpx import AsyncClient @pytest.fixture(scope="module") @@ -159,19 +159,19 @@ async def test_tx_segment_nomenclature( json=ntrk1_tx_element_start, ) assert response.status_code == 200 - assert response.json().get("nomenclature", "") == "refseq:NM_002529.3(NTRK1):e.2+1" + assert response.json().get("nomenclature", "") == "NM_002529.3(NTRK1):e.2+1" response = await async_client.post( "/api/nomenclature/transcript_segment?first=true&last=false", json=epcam_5_prime ) assert response.status_code == 200 - assert response.json().get("nomenclature", "") == "refseq:NM_002354.2(EPCAM):e.5" + assert response.json().get("nomenclature", "") == "NM_002354.2(EPCAM):e.5" response = await async_client.post( "/api/nomenclature/transcript_segment?first=false&last=true", json=epcam_3_prime ) assert response.status_code == 200 - assert response.json().get("nomenclature", "") == "refseq:NM_002354.2(EPCAM):e.5" + assert response.json().get("nomenclature", "") == "NM_002354.2(EPCAM):e.5" response = await async_client.post( "/api/nomenclature/transcript_segment?first=true&last=false", json=epcam_invalid @@ -212,7 +212,7 @@ async def test_templated_sequence_nomenclature( assert response.status_code == 200 assert ( response.json().get("nomenclature", "") - == "refseq:NC_000001.11(chr 1):g.15455_15566(-)" + == "NC_000001.11(chr 1):g.15455_15566(-)" ) response = await async_client.post( @@ -246,5 +246,5 @@ async def test_fusion_nomenclature(async_client: AsyncClient): assert response.status_code == 200 assert ( response.json().get("nomenclature", "") - == "refseq:NM_004327.3(BCR):e.2+182::ACTAAAGCG::refseq:NM_005157.5(ABL1):e.2-173" + == "NM_004327.3(BCR):e.2+182::ACTAAAGCG::NM_005157.5(ABL1):e.2-173" ) diff --git a/server/tests/integration/test_utilities.py b/server/tests/integration/test_utilities.py index 3652b4ba..e0d3d0f6 100644 --- a/server/tests/integration/test_utilities.py +++ b/server/tests/integration/test_utilities.py @@ -1,9 +1,8 @@ """Test end-to-end correctness of utility routes.""" -from typing import Dict, Callable +from typing import Callable, Dict import pytest - response_callback_type = Callable[[Dict, Dict], None] diff --git a/server/tests/integration/test_validate.py b/server/tests/integration/test_validate.py index f1f445d7..b15605d0 100644 --- a/server/tests/integration/test_validate.py +++ b/server/tests/integration/test_validate.py @@ -202,7 +202,8 @@ def wrong_type_fusion(): async def check_validated_fusion_response(client, fixture: Dict, case_name: str): """Run basic checks on fusion validation response. - TODO + Todo: + ---- * FUSOR should provide a "fusion equality" utility function -- incorporate it here when that's done """