diff --git a/src/analysis/clade_cumulus.jl b/src/analysis/clade_cumulus.jl index a702fde2..1ba8f38f 100644 --- a/src/analysis/clade_cumulus.jl +++ b/src/analysis/clade_cumulus.jl @@ -14,9 +14,11 @@ using SHA import ..Epistemic import ..Phylogeny -export PhyloNode, QueryCandidate, QueryPlacement, JplaceDocument, +export PhyloNode, IndexedPhyloNode, PhylogeneticTree, + QueryCandidate, QueryPlacement, JplaceDocument, CladeNode, CladeTreeResult, - parse_newick, midpoint_root, parse_jplace, + parse_newick, parse_phylogenetic_tree, midpoint_root, parse_jplace, + asv_tip_name, clade_counts, unifrac, analyse_phylogeny, faith_pd, faith_pd_table, clade_faith_pd, unweighted_unifrac, weighted_unifrac, unifrac_matrix, branch_unifrac_contributions, @@ -28,7 +30,10 @@ const DEFAULT_TAXONOMIC_RANKS = [ "Class", "Order", "Family", "Genus", "Species", ] -const PLACEMENT_WEIGHT_SUM_TOL = 1e-5 +# jplace writers round `like_weight_ratio` and prune candidates below their LWR +# reporting threshold, so surviving candidates summing within 5% of 1.0 are +# treated as valid placements while larger shortfalls remain `:undecided`. +const PLACEMENT_WEIGHT_SUM_TOL = 5e-2 ## Phylogenetic tree structures & Newick / jplace parsers mutable struct PhyloNode @@ -40,6 +45,342 @@ mutable struct PhyloNode children :: Vector{PhyloNode} end +## Study-tree (indexed) structures used by the cladistics build API. +# These mirror the #200 CladeCumulus schema: a PhylogeneticTree carries a +# `tips` dictionary so cladistics.jl can validate that tree tips exactly +# match the saved ASV columns. +mutable struct IndexedPhyloNode + name :: String + length :: Union{Float64,Nothing} + parent :: Int # 0 only for the root + children :: Vector{Int} # 1-based node ids; parents precede children +end + +struct PhylogeneticTree + nodes :: Vector{IndexedPhyloNode} + root :: Int + tips :: Dict{String,Int} +end + +"""Stable, tool-safe ASV tip name tied to sequence content, not to a row number.""" +function asv_tip_name(sequence::AbstractString) + normalised = uppercase(replace(strip(String(sequence)), r"[\s\-.]" => "")) + isempty(normalised) && throw(ArgumentError("an ASV sequence is empty")) + "ASV_" * bytes2hex(sha256(normalised))[1:20] +end + +## Indexed Newick parser (returns a PhylogeneticTree with a tips index) + +mutable struct _IndexedParser + bytes :: Vector{UInt8} + pos :: Int + nodes :: Vector{IndexedPhyloNode} + tips :: Dict{String,Int} +end + +@inline _ip_done(p::_IndexedParser) = p.pos > length(p.bytes) +@inline _ip_peek(p::_IndexedParser) = _ip_done(p) ? 0x00 : p.bytes[p.pos] +@inline _ip_is_space(b::UInt8) = b in (0x09, 0x0a, 0x0d, 0x20) +@inline _ip_is_delim(b::UInt8) = _ip_is_space(b) || b in ( + UInt8('('), UInt8(')'), UInt8(','), UInt8(':'), UInt8(';'), + UInt8('['), UInt8(']')) + +_ip_fail(p::_IndexedParser, message::AbstractString) = + throw(ArgumentError("Newick byte $(p.pos): $message")) + +function _ip_skip!(p::_IndexedParser) + while !_ip_done(p) + if _ip_is_space(_ip_peek(p)) + p.pos += 1 + elseif _ip_peek(p) == UInt8('[') + depth = 0 + while !_ip_done(p) + b = _ip_peek(p) + p.pos += 1 + b == UInt8('[') && (depth += 1) + if b == UInt8(']') + depth -= 1 + depth == 0 && break + end + end + depth == 0 || _ip_fail(p, "unclosed comment") + else + break + end + end +end + +function _ip_take!(p::_IndexedParser, expected::UInt8) + _ip_skip!(p) + _ip_peek(p) == expected || _ip_fail(p, "expected '$(Char(expected))'") + p.pos += 1 +end + +function _ip_label!(p::_IndexedParser)::String + _ip_skip!(p) + _ip_done(p) && return "" + quote_char = _ip_peek(p) + if quote_char in (UInt8('\''), UInt8('"')) + p.pos += 1 + out = UInt8[] + while !_ip_done(p) + b = _ip_peek(p) + p.pos += 1 + if b == quote_char + if !_ip_done(p) && _ip_peek(p) == quote_char + push!(out, quote_char) + p.pos += 1 + else + return String(out) + end + else + push!(out, b) + end + end + _ip_fail(p, "unclosed quoted label") + end + first = p.pos + while !_ip_done(p) && !_ip_is_delim(_ip_peek(p)) + p.pos += 1 + end + strip(String(SubString(p.bytes, first:p.pos - 1))) +end + +function _ip_length!(p::_IndexedParser)::Float64 + _ip_skip!(p) + first = p.pos + while !_ip_done(p) && !_ip_is_delim(_ip_peek(p)) + p.pos += 1 + end + first < p.pos || _ip_fail(p, "missing branch length after ':'") + token = String(SubString(p.bytes, first:p.pos - 1)) + value = try parse(Float64, token) catch + _ip_fail(p, "invalid branch length '$token'") + end + (isfinite(value) && value >= 0) || + _ip_fail(p, "branch lengths must be finite and non-negative") + value +end + +function _ip_parse_node!(p::_IndexedParser, parent::Int)::Int + _ip_skip!(p) + _ip_done(p) && _ip_fail(p, "expected a node") + push!(p.nodes, IndexedPhyloNode("", nothing, parent, Int[])) + id = length(p.nodes) + leaf = _ip_peek(p) != UInt8('(') + if !leaf + _ip_take!(p, UInt8('(')) + while true + child = _ip_parse_node!(p, id) + push!(p.nodes[id].children, child) + _ip_skip!(p) + if _ip_peek(p) == UInt8(',') + p.pos += 1 + continue + end + _ip_peek(p) == UInt8(')') || _ip_fail(p, "expected ',' or ')'") + p.pos += 1 + break + end + isempty(p.nodes[id].children) && _ip_fail(p, "an internal node needs children") + end + + label = _ip_label!(p) + if leaf + isempty(label) && _ip_fail(p, "a tip needs a non-empty label") + haskey(p.tips, label) && _ip_fail(p, "duplicate tip '$label'") + p.tips[label] = id + end + p.nodes[id].name = label + _ip_skip!(p) + if _ip_peek(p) == UInt8(':') + p.pos += 1 + p.nodes[id].length = _ip_length!(p) + end + id +end + +""" + parse_phylogenetic_tree(text) -> PhylogeneticTree + +Parse one Newick tree into an indexed `PhylogeneticTree` with a `tips` +index, including quoted/Unicode tip labels, bracket comments, internal +support labels and non-binary nodes. Duplicate or empty tip labels and +negative/non-finite branch lengths are rejected. +""" +function parse_phylogenetic_tree(text::AbstractString)::PhylogeneticTree + p = _IndexedParser(Vector{UInt8}(codeunits(text)), 1, IndexedPhyloNode[], Dict{String,Int}()) + root = _ip_parse_node!(p, 0) + _ip_take!(p, UInt8(';')) + _ip_skip!(p) + _ip_done(p) || _ip_fail(p, "unexpected content after ';'") + isempty(p.tips) && throw(ArgumentError("Newick tree has no tips")) + root == 1 || error("internal parser error: root is not the first node") + PhylogeneticTree(p.nodes, root, p.tips) +end + +## Roll-ups and metrics for the indexed tree + +function _check_counts(tree::PhylogeneticTree, counts, tip_names) + ndims(counts) == 2 || throw(DimensionMismatch("counts must be a samples × ASVs matrix")) + size(counts, 2) == length(tip_names) || throw(DimensionMismatch( + "counts has $(size(counts, 2)) columns but $(length(tip_names)) tip names were supplied")) + length(unique(String.(tip_names))) == length(tip_names) || + throw(ArgumentError("tip names must be unique")) + all(v -> isfinite(v) && v >= 0, counts) || throw(ArgumentError("counts must be finite and non-negative")) + for name in tip_names + haskey(tree.tips, String(name)) || throw(ArgumentError( + "count feature '$name' is not a tip in the tree; refusing to discard its reads")) + end + nothing +end + +""" + clade_counts(tree, counts, tip_names) -> Matrix{Float64} + +Return a nodes × samples matrix. Each tip receives its ASV counts, then a +single reverse-node-order pass adds every child's counts to its parent. Thus +every node contains the cumulative abundance of its whole phylogenetic clade. +""" +function clade_counts(tree::PhylogeneticTree, counts::AbstractMatrix{<:Real}, + tip_names::AbstractVector{<:AbstractString})::Matrix{Float64} + _check_counts(tree, counts, tip_names) + samples = size(counts, 1) + cumulative = zeros(Float64, length(tree.nodes), samples) + for (j, name) in enumerate(tip_names), sample in 1:samples + cumulative[tree.tips[String(name)], sample] += Float64(counts[sample, j]) + end + for id in length(tree.nodes):-1:1 + parent = tree.nodes[id].parent + parent == 0 && continue + for sample in 1:samples + cumulative[parent, sample] += cumulative[id, sample] + end + end + cumulative +end + +function _tree_lengths(tree::PhylogeneticTree)::Vector{Float64} + lengths = zeros(Float64, length(tree.nodes)) + for id in eachindex(tree.nodes) + id == tree.root && continue + length = tree.nodes[id].length + isnothing(length) && throw(ArgumentError( + "Faith PD and UniFrac need a length on every non-root branch")) + lengths[id] = something(length, 0.0) + end + lengths +end + +""" + unifrac(tree, cumulative; weighted=false) -> Matrix{Float64} + +Pairwise UniFrac between sample columns of a nodes × samples roll-up. +""" +function unifrac(tree::PhylogeneticTree, cumulative::AbstractMatrix{<:Real}; + weighted::Bool=false)::Matrix{Float64} + sizes = size(cumulative) + sizes[1] == length(tree.nodes) || throw(DimensionMismatch( + "cumulative matrix has the wrong node count")) + all(v -> isfinite(v) && v >= 0, cumulative) || throw(ArgumentError( + "cumulative counts must be finite and non-negative")) + lengths = _tree_lengths(tree) + samples = size(cumulative, 2) + totals = vec(cumulative[tree.root, :]) + out = zeros(Float64, samples, samples) + for a in 1:samples, b in a+1:samples + numerator = 0.0 + denominator = 0.0 + for id in eachindex(tree.nodes) + id == tree.root && continue + av = Float64(cumulative[id, a]) + bv = Float64(cumulative[id, b]) + length = lengths[id] + if weighted + ar = totals[a] > 0 ? av / totals[a] : 0.0 + br = totals[b] > 0 ? bv / totals[b] : 0.0 + numerator += length * abs(ar - br) + denominator += length * (ar + br) + else + ap = av > 0 + bp = bv > 0 + ap != bp && (numerator += length) + (ap || bp) && (denominator += length) + end + end + one_empty = (totals[a] > 0) != (totals[b] > 0) + distance = denominator > 0 ? numerator / denominator : (one_empty ? 1.0 : 0.0) + out[a, b] = out[b, a] = distance + end + out +end + +function _faith_pd_indexed(tree::PhylogeneticTree, cumulative::AbstractMatrix{<:Real})::Vector{Float64} + sizes = size(cumulative) + sizes[1] == length(tree.nodes) || throw(DimensionMismatch( + "cumulative matrix has the wrong node count")) + lengths = _tree_lengths(tree) + samples = size(cumulative, 2) + out = zeros(Float64, samples) + for id in eachindex(tree.nodes) + id == tree.root && continue + for sample in 1:samples + cumulative[id, sample] > 0 && (out[sample] += lengths[id]) + end + end + out +end + +faith_pd(tree::PhylogeneticTree, cumulative::AbstractMatrix{<:Real}) = _faith_pd_indexed(tree, cumulative) +faith_pd(tree::PhylogeneticTree, counts::AbstractMatrix{<:Real}, + tip_names::AbstractVector{<:AbstractString}) = + faith_pd(tree, clade_counts(tree, counts, tip_names)) + +""" + analyse_phylogeny(tree, counts, tip_names, sample_names) -> NamedTuple + +Compute the complete JSON-ready CladeCumulus result: cumulative clade counts +and frequencies, Faith PD, and both UniFrac matrices. +""" +function analyse_phylogeny(tree::PhylogeneticTree, counts::AbstractMatrix{<:Real}, + tip_names::AbstractVector{<:AbstractString}, + sample_names::AbstractVector{<:AbstractString}) + size(counts, 1) == length(sample_names) || throw(DimensionMismatch( + "counts has $(size(counts, 1)) rows but $(length(sample_names)) sample names were supplied")) + length(unique(String.(sample_names))) == length(sample_names) || + throw(ArgumentError("sample names must be unique")) + cumulative = clade_counts(tree, counts, tip_names) + samples = String.(sample_names) + totals = vec(cumulative[tree.root, :]) + grand_total = sum(totals) + + tip_counts = zeros(Int, length(tree.nodes)) + for id in length(tree.nodes):-1:1 + node = tree.nodes[id] + tip_counts[id] = isempty(node.children) ? 1 : sum(tip_counts[c] for c in node.children) + end + tip_ids = Set(values(tree.tips)) + nodes = map(eachindex(tree.nodes)) do id + node = tree.nodes[id] + parent = node.parent == 0 ? nothing : "n$(node.parent)" + count = sum(cumulative[id, :]) + (; id = "n$id", parent_id = parent, + label = isempty(node.name) ? (id in tip_ids ? "Tip $id" : "Clade $id") : node.name, + branch_length = id == tree.root ? nothing : node.length, + is_tip = id in tip_ids, + tip_count = tip_counts[id], + cumulative_count = count, + cumulative_frequency = grand_total > 0 ? count / grand_total : 0.0) + end + + (; nodes, samples, + sample_reads = collect(totals), + faith_pd = _faith_pd_indexed(tree, cumulative), + unweighted_unifrac = unifrac(tree, cumulative), + weighted_unifrac = unifrac(tree, cumulative; weighted=true), + total_branch_length = sum(_tree_lengths(tree))) +end + struct QueryCandidate ordinal :: Int edge_num :: Int @@ -260,6 +601,7 @@ function parse_newick(src::AbstractString; warnings::Vector{String} = String[]): nhx_sup = Ref{Union{Float64,Nothing}}(nothing) edge_num = Ref{Union{Int,Nothing}}(nothing) + skip_ws_and_leading_comments!(capture_nhx = nhx_sup, capture_edge = edge_num) raw_lbl, was_quoted = read_label_and_quoted() skip_ws_and_leading_comments!(capture_nhx = nhx_sup, capture_edge = edge_num) @@ -991,7 +1333,7 @@ function _graft_jplace_queries( for q in jd.queries q.status === :valid && !isempty(q.candidates) || continue - total_w = sum((c.weight for c in q.candidates); init = 0.0) + total_w = sum((c.like_weight for c in q.candidates); init = 0.0) total_w > 0.0 || continue multi = length(q.candidates) > 1 for (qname, mult) in q.names @@ -1000,7 +1342,7 @@ function _graft_jplace_queries( tx = get(asv_deepest_taxon, qname, nothing) tconf = get(asv_tax_conf, qname, 1.0) for (ci, cand) in enumerate(q.candidates) - share = cand.weight / total_w + share = cand.like_weight / total_w tip_id = multi ? string(qname, "@edge", cand.edge_num, "#", ci) : qname if multi asv_reads[tip_id] = get(asv_reads, tip_id, 0.0) + base_cnt * share @@ -1009,9 +1351,9 @@ function _graft_jplace_queries( elseif !haskey(asv_reads, tip_id) asv_reads[tip_id] = base_cnt end - query_lwr[tip_id] = cand.weight + query_lwr[tip_id] = cand.like_weight push!(get!(by_edge, cand.edge_num, _GraftedQuerySpec[]), - _GraftedQuerySpec(tip_id, qname, share, max(0.0, cand.distal_length), max(0.0, cand.pendant_length), cand.weight)) + _GraftedQuerySpec(tip_id, qname, share, max(0.0, cand.distal_len), max(0.0, cand.pendant_len), cand.like_weight)) end end end diff --git a/src/core/doi.jl b/src/core/doi.jl index 6a2f926a..f5b0bd95 100644 --- a/src/core/doi.jl +++ b/src/core/doi.jl @@ -16,6 +16,8 @@ # * Durable write-ahead journal; immutable DOI/provenance receipts # * DOI insertion never rewrites scientific objects +module DOI + using Dates, SHA, HTTP, JSON3, Logging export DOIConfig, Deposition, PublicationReceipt, diff --git a/src/pipeline/phylogeny.jl b/src/pipeline/phylogeny.jl index b394d9dc..b978ef02 100644 --- a/src/pipeline/phylogeny.jl +++ b/src/pipeline/phylogeny.jl @@ -34,6 +34,7 @@ export REFERENCE_STEPS, PLACEMENT_STEPS, Step, placement_settings, run_workflow, using ..PipelineLog using ..Validation using ..Tools: tool_bin, _sq, _run_logged, _safe_optional_args, _num + import ..Tools using ..RemoteExec: run_remote, checked_target import ..Provenance diff --git a/src/server/routes/clade_cumulus.jl b/src/server/routes/clade_cumulus.jl index 45825b61..30c76ca2 100644 --- a/src/server/routes/clade_cumulus.jl +++ b/src/server/routes/clade_cumulus.jl @@ -64,7 +64,9 @@ function _probe_optional_phylo_tool(tool_key::String, fallbacks::Vector{String}) end end -function _load_run_table_df(study::String, run::String, table::String; group::Union{String,Nothing}=nothing) +function _load_run_table_df(study::String, run::String, table::AbstractString; + group::Union{String,Nothing}=nothing) + table = String(table) dir = _require_duckdb(study, run; group) isnothing(dir) && return (nothing, nothing, json_error(404, "no_results", "No results for run '$run' - run the pipeline first")) @@ -92,9 +94,9 @@ function _resolve_tree_inputs_from_request( body, source_digests::OrderedDict{String,String}, ) - tree_source = lowercase(strip(string(get(body, :treeSource, "auto")))) - tree_file = strip(string(get(body, :treeFile, ""))) - inline_newick = strip(string(get(body, :newick, ""))) + tree_source = String(lowercase(strip(string(get(body, :treeSource, "auto"))))) + tree_file = String(strip(string(get(body, :treeFile, "")))) + inline_newick = String(strip(string(get(body, :newick, "")))) if tree_source == "study_tree" || (!isempty(tree_file) && tree_source == "auto") _valid_tree_file(tree_file) || return (nothing, nothing, :auto, @@ -145,19 +147,27 @@ end names_df = DataFrame(DBInterface.execute(con, "SHOW TABLES")) tlist = sort(String[string(r.name) for r in eachrow(names_df)]) for t in tlist - cols = _duckdb_columns(con, t) - scols = _sample_count_columns(con, t) - isempty(scols) && continue - nrows = Int(only(DataFrame(DBInterface.execute(con, "SELECT COUNT(*) AS n FROM \"$t\""))).n) - has_seq = any(c -> c in ("Sequence", "sequence", "seq", "DNA"), cols) - ranks = [r for r in MetaManifold.CladeCumulus.DEFAULT_TAXONOMIC_RANKS if r in cols] - push!(tables_info, OrderedDict{String,Any}( - "table" => t, - "row_count" => nrows, - "sample_count" => length(scols), - "has_sequence" => has_seq, - "ranks" => ranks, - )) + # `SHOW TABLES` includes views; a view backed by a DuckDB + # replacement scan is bound to the connection that registered it + # and cannot be queried from a new connection. + try + cols = _duckdb_columns(con, t) + scols = _sample_count_columns(con, t) + isempty(scols) && continue + nrows = Int(only(DataFrame(DBInterface.execute(con, "SELECT COUNT(*) AS n FROM \"$t\""))).n) + has_seq = any(c -> c in ("Sequence", "sequence", "seq", "DNA"), cols) + ranks = [r for r in MetaManifold.CladeCumulus.DEFAULT_TAXONOMIC_RANKS if r in cols] + push!(tables_info, OrderedDict{String,Any}( + "table" => t, + "row_count" => nrows, + "sample_count" => length(scols), + "has_sequence" => has_seq, + "ranks" => ranks, + )) + catch err + err isa InterruptException && rethrow() + @warn "clade-cumulus sources: skipping unreadable DuckDB object" object=t exception=err + end end finally DBInterface.close!(con) @@ -200,10 +210,10 @@ end return json_error(400, "invalid_json", "Request body must be valid JSON") end - table = strip(string(get(body, :table, "merged"))) + table = String(strip(string(get(body, :table, "merged")))) _valid_name(table) || return json_error(400, "invalid_table", "Invalid table name '$table'") - mode_str = lowercase(strip(string(get(body, :mode, "combined")))) + mode_str = String(lowercase(strip(string(get(body, :mode, "combined"))))) mode_str in ("phylogenetic", "taxonomic", "combined") || return json_error(400, "invalid_mode", "mode must be 'phylogenetic', 'taxonomic', or 'combined'") mode_sym = Symbol(mode_str) @@ -253,6 +263,7 @@ end # Compute Faith's PD + Unweighted & Weighted UniFrac when a phylogenetic tree is present, # including split `.jplace` query placements (`@edge#`). diversity_payload = nothing + try if cc_res.phylo_root !== nothing && !isempty(meta.sample_cols) tip_list = sort!(collect(MetaManifold.CladeCumulus._collect_tree_tips!(Set{String}(), cc_res.phylo_root))) id_col = MetaManifold.CladeCumulus._detect_id_col(df) @@ -268,13 +279,13 @@ end jd_div = MetaManifold.CladeCumulus.parse_jplace(jplace_str) for q in jd_div.queries q.status === :valid && !isempty(q.candidates) || continue - tw = sum((c.weight for c in q.candidates); init = 0.0) + tw = sum((c.like_weight for c in q.candidates); init = 0.0) tw > 0.0 || continue multi = length(q.candidates) > 1 for (qname, _) in q.names for (ci, cand) in enumerate(q.candidates) tid = multi ? string(qname, "@edge", cand.edge_num, "#", ci) : qname - tip_weights[tid] = cand.weight / tw + tip_weights[tid] = cand.like_weight / tw end end end @@ -328,6 +339,9 @@ end "top_branch_contributions" => top_branches, ) end + catch e + @error "clade-cumulus diversity computation failed" exception=(e, catch_backtrace()) + return json_error(500, "clade_cumulus_diversity_failed", sprint(showerror, e)) end receipt = MetaManifold.Epistemic.create_receipt(; @@ -386,15 +400,15 @@ end return json_error(400, "invalid_json", "Request body must be valid JSON") end - source_id = strip(string(get(body, :sourceId, ""))) - target_id = strip(string(get(body, :targetId, ""))) + source_id = String(strip(string(get(body, :sourceId, "")))) + target_id = String(strip(string(get(body, :targetId, "")))) (isempty(source_id) || isempty(target_id)) && return json_error(400, "missing_clade_ids", "Both sourceId and targetId are required") - table = strip(string(get(body, :table, "merged"))) + table = String(strip(string(get(body, :table, "merged")))) _valid_name(table) || return json_error(400, "invalid_table", "Invalid table name '$table'") - mode_str = lowercase(strip(string(get(body, :mode, "combined")))) + mode_str = String(lowercase(strip(string(get(body, :mode, "combined"))))) mode_str in ("phylogenetic", "taxonomic", "combined") || return json_error(400, "invalid_mode", "mode must be 'phylogenetic', 'taxonomic', or 'combined'") mode_sym = Symbol(mode_str) diff --git a/src/server/routes/cladistics.jl b/src/server/routes/cladistics.jl index 16d934ad..14979026 100644 --- a/src/server/routes/cladistics.jl +++ b/src/server/routes/cladistics.jl @@ -205,7 +205,7 @@ function _cc_compute(study::String, id::String) isnothing(doc) && error("The Cladistics source document is unreadable") counts, tips, samples, counts_doc = _cc_read_counts(study, id) tree_text = read(_clad_files(study, id)["reference.treefile"], String) - tree = CC.parse_newick(tree_text) + tree = CC.parse_phylogenetic_tree(tree_text) Set(keys(tree.tips)) == Set(tips) || error("The inferred tree tips do not exactly match the saved ASV count columns") analysed = CC.analyse_phylogeny(tree, counts, tips, samples) diff --git a/src/server/routes/trees.jl b/src/server/routes/trees.jl index f3cc8e07..beec713a 100644 --- a/src/server/routes/trees.jl +++ b/src/server/routes/trees.jl @@ -17,9 +17,9 @@ const _tree_view_lock = ReentrantLock() _trees_dir(study::String) = joinpath(ServerState.projects_dir(), study, "trees") _tree_view_path(study::String, file::String) = joinpath(_trees_dir(study), ".views", file * ".json") -_tree_format(file::String) = endswith(lowercase(file), ".jplace") ? "jplace" : "newick" +_tree_format(file::AbstractString) = endswith(lowercase(file), ".jplace") ? "jplace" : "newick" -function _valid_tree_file(file::String)::Bool +function _valid_tree_file(file::AbstractString)::Bool _valid_name(file) && any(ext -> endswith(lowercase(file), ext), _TREE_EXTENSIONS) end diff --git a/src/server/server.jl b/src/server/server.jl index ebf5abb6..eca368a0 100644 --- a/src/server/server.jl +++ b/src/server/server.jl @@ -118,7 +118,6 @@ module Server include(joinpath(@__DIR__, "routes", "phylogeny.jl")) include(joinpath(@__DIR__, "routes", "cladistics.jl")) include(joinpath(@__DIR__, "routes", "clade_cumulus.jl")) - include(joinpath(@__DIR__, "routes", "cladistics.jl")) ## Bad request middleware # Helpers deep in a route (e.g. `_req_group`) throw this to reject a request. diff --git a/test/unit/test_clade_cumulus.jl b/test/unit/test_clade_cumulus.jl index da09bc18..6e12a0c9 100644 --- a/test/unit/test_clade_cumulus.jl +++ b/test/unit/test_clade_cumulus.jl @@ -252,8 +252,8 @@ done echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_000004:0.08);" > "$OUT" """) chmod(mock_ft, 0o755) - old_ft = get(PH.Tools._tools, "fasttree", nothing) - PH.Tools._tools["fasttree"] = mock_ft + old_ft = get(MetaManifold.Tools._tools, "fasttree", nothing) + MetaManifold.Tools._tools["fasttree"] = mock_ft try s4 = [ "ASV_1" => "ACGTACGTACGTACGTACGT", @@ -267,9 +267,9 @@ echo "(MMTIP_000001:0.05,(MMTIP_000002:0.03,MMTIP_000003:0.04)0.910:0.12,MMTIP_0 r["sha256"] == bytes2hex(open(sha256, mock_ft)), ft_res.tool_records) finally if old_ft === nothing - delete!(PH.Tools._tools, "fasttree") + delete!(MetaManifold.Tools._tools, "fasttree") else - PH.Tools._tools["fasttree"] = old_ft + MetaManifold.Tools._tools["fasttree"] = old_ft end end end