diff --git a/.github/workflows/CompatHelper.yml b/.github/workflows/CompatHelper.yml new file mode 100644 index 0000000..8765dd6 --- /dev/null +++ b/.github/workflows/CompatHelper.yml @@ -0,0 +1,43 @@ +name: CompatHelper +on: + schedule: + - cron: 0 0 * * * + workflow_dispatch: +permissions: + contents: write + pull-requests: write +jobs: + CompatHelper: + runs-on: ubuntu-latest + steps: + - name: Check if Julia is already available in the PATH + id: julia_in_path + run: which julia + continue-on-error: true + - name: Install Julia, but only if it is not already available in the PATH + uses: julia-actions/setup-julia@v1 + with: + version: '1' + arch: ${{ runner.arch }} + if: steps.julia_in_path.outcome != 'success' + - name: "Add the General registry via Git" + run: | + import Pkg + ENV["JULIA_PKG_SERVER"] = "" + Pkg.Registry.add("General") + shell: julia --color=yes {0} + - name: "Install CompatHelper" + run: | + import Pkg + name = "CompatHelper" + uuid = "aa819f21-2bde-4658-8897-bab36330d9b7" + version = "3" + Pkg.add(; name, uuid, version) + shell: julia --color=yes {0} + - name: "Run CompatHelper" + run: | + import CompatHelper + CompatHelper.main() + shell: julia --color=yes {0} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/TagBot.yml b/.github/workflows/TagBot.yml new file mode 100644 index 0000000..f49313b --- /dev/null +++ b/.github/workflows/TagBot.yml @@ -0,0 +1,15 @@ +name: TagBot +on: + issue_comment: + types: + - created + workflow_dispatch: +jobs: + TagBot: + if: github.event_name == 'workflow_dispatch' || github.actor == 'JuliaTagBot' + runs-on: ubuntu-latest + steps: + - uses: JuliaRegistries/TagBot@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + ssh: ${{ secrets.DOCUMENTER_KEY }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a74714a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + +jobs: + test: + name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + version: + - '1' # This is always the latest stable release in the 1.X series + #- '1.6' # LTS + #- 'nightly' + os: + - ubuntu-latest + #- macOS-latest + #- windows-latest + arch: + - x64 + steps: + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v1 + with: + version: ${{ matrix.version }} + arch: ${{ matrix.arch }} + - uses: julia-actions/cache@v1 + - uses: julia-actions/julia-buildpkg@latest + - run: | + git config --global user.name Tester + git config --global user.email te@st.er + - uses: julia-actions/julia-runtest@latest + continue-on-error: ${{ matrix.version == 'nightly' }} + - uses: julia-actions/julia-processcoverage@v1 + - uses: codecov/codecov-action@v1 + with: + file: lcov.info diff --git a/.github/workflows/pr-format.yml b/.github/workflows/pr-format.yml new file mode 100644 index 0000000..3ff70d1 --- /dev/null +++ b/.github/workflows/pr-format.yml @@ -0,0 +1,62 @@ +on: + pull_request: + issue_comment: + types: [created] + +name: Formatting + +jobs: + formatting: + if: github.event_name == 'pull_request' || (github.event_name == 'issue_comment' && github.event.issue.pull_request && (github.event.comment.author_association == 'MEMBER' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'OWNER' || github.event.issue.user.id == github.event.comment.user.id) && startsWith(github.event.comment.body, '/format') ) + runs-on: ubuntu-latest + steps: + - name: Clone the repository + uses: actions/checkout@v4 + - name: Checkout the pull request code # this checks out the actual branch so that one can commit into it + if: github.event_name == 'issue_comment' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh pr checkout ${{ github.event.issue.number }} + - name: Install JuliaFormatter and format + run: | + julia --color=yes -e 'import Pkg; Pkg.add("JuliaFormatter")' + julia --color=yes -e 'using JuliaFormatter; format(".")' + - name: Remove trailing whitespace + run: | + find -name '*.jl' -or -name '*.md' -or -name '*.toml' -or -name '*.yml' | while read filename ; do + # remove any trailing spaces + sed --in-place -e 's/\s*$//' "$filename" + # add a final newline if missing + if [[ -s "$filename" && $(tail -c 1 "$filename" |wc -l) -eq 0 ]] ; then + echo >> "$filename" + fi + # squash superfluous final newlines + sed -i -e :a -e '/^\n*$/{$d;N;};/\n$/ba' "$filename" + done + - name: Fail on formatting problems + if: github.event_name == 'pull_request' + run: | + if git diff --exit-code --quiet + then echo "Looks OK" + else echo "Formatting fixes required!"; git diff -p ; exit 1 + fi + - name: Commit fixes + if: github.event_name == 'issue_comment' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if [ `git status -s | wc -l` -ne 0 ] ; then + git config --local user.name "$GITHUB_ACTOR" + git config --local user.email "$GITHUB_ACTOR@users.noreply.github.com" + git commit -a -m "automatic formatting" -m "triggered by @$GITHUB_ACTOR on PR #${{ github.event.issue.number }}" + if git push + then gh pr comment ${{ github.event.issue.number }} --body \ + ":heavy_check_mark: Auto-formatting triggered by [this comment](${{ github.event.comment.html_url }}) succeeded, commited as `git rev-parse HEAD`" + else gh pr comment ${{ github.event.issue.number }} --body \ + ":x: Auto-formatting triggered by [this comment](${{ github.event.comment.html_url }}) failed, perhaps someone pushed to the PR in the meantime?" + fi + else + gh pr comment ${{ github.event.issue.number }} --body \ + ":sunny: Auto-formatting triggered by [this comment](${{ github.event.comment.html_url }}) succeeded, but the code was already formatted correctly." + fi diff --git a/.gitignore b/.gitignore index f0a86a3..e4e71a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,10 @@ -/.vscode -/vscode -*.code-workspace +/docs/build/ Manifest.toml -downloaded/* -test/downloaded/* \ No newline at end of file +*.cov +.*.swp + +.vscode +*.code-workspace + +/test/test-models diff --git a/Project.toml b/Project.toml index 9775f33..365dcc3 100644 --- a/Project.toml +++ b/Project.toml @@ -11,8 +11,8 @@ ReadableRegex = "cbbcb084-453d-4c4c-b292-e315607ba6a4" SparseArrays = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" [compat] -AbstractFBCModels = "0.2" -DocStringExtensions = "0.9" +AbstractFBCModels = "0.1, 0.2" +DocStringExtensions = "0.8, 0.9" JSON = "0.21" [extras] diff --git a/README.md b/README.md index 6bb9e08..4574e60 100644 --- a/README.md +++ b/README.md @@ -18,18 +18,19 @@ The primary purpose of this is to provide JSON loading functionality for [FBCModelTests.jl](https://github.com/LCSB-BioCore/FBCModelTests.jl), but is otherwise completely generic and can be used independently of these packages. -You should be able to load JSON models (called `JSONFBCModel`) via the +You should be able to load JSON models (of type `JSONFBCModel`) via the AbstractFBCModels interface: ```julia -import AbstractFBCModels as A +import AbstractFBCModels as M import JSONFBCModels -model = A.load("my_model.json") -A.reactions(model) # accessors defined by the interface (NB: access through the overloads: A.xyz) +model = M.load("my_model.json") ``` -See the docstrings of all the accessors available by entering `A.accessors()` in -the REPL. For example: + +Documentation of +[AbstractFBCModels.jl](https://github.com/COBREXA/AbstractFBCModels.jl) +provides details on the use of the loaded model. #### Acknowledgements diff --git a/src/interface.jl b/src/interface.jl index 6e8bb06..1ac6d0b 100644 --- a/src/interface.jl +++ b/src/interface.jl @@ -47,28 +47,28 @@ JSONFBCModel(json::Dict{String,Any}) = begin JSONFBCModel( json, - Dict(_json_rxn_name(r, i) => i for (i, r) in enumerate(rs)), + Dict(extract_json_reaction_id(r, i) => i for (i, r) in enumerate(rs)), rs, - Dict(_json_met_name(m, i) => i for (i, m) in enumerate(ms)), + Dict(extract_json_metabolite_id(m, i) => i for (i, m) in enumerate(ms)), ms, - Dict(_json_gene_name(g, i) => i for (i, g) in enumerate(gs)), + Dict(extract_json_gene_id(g, i) => i for (i, g) in enumerate(gs)), gs, ) end # model A.reactions(model::JSONFBCModel) = - String[_json_rxn_name(r, i) for (i, r) in enumerate(model.reactions)] + String[extract_json_reaction_id(r, i) for (i, r) in enumerate(model.reactions)] A.n_reactions(model::JSONFBCModel) = length(model.reactions) A.metabolites(model::JSONFBCModel) = - String[_json_met_name(m, i) for (i, m) in enumerate(model.metabolites)] + String[extract_json_metabolite_id(m, i) for (i, m) in enumerate(model.metabolites)] A.n_metabolites(model::JSONFBCModel) = length(model.metabolites) A.genes(model::JSONFBCModel) = - String[_json_gene_name(g, i) for (i, g) in enumerate(model.genes)] + String[extract_json_gene_id(g, i) for (i, g) in enumerate(model.genes)] A.n_genes(model::JSONFBCModel) = length(model.genes) @@ -169,16 +169,12 @@ A.metabolite_name(model::JSONFBCModel, mid::String) = A.gene_name(model::JSONFBCModel, gid::String) = parse_name(get(model.genes[model.gene_index[gid]], "name", nothing)) -A.reaction_stoichiometry(model::JSONFBCModel, rid::String) = +A.reaction_stoichiometry(model::JSONFBCModel, rid::String)::Dict{String,Float64} = model.reactions[model.reaction_index[rid]]["metabolites"] function A.metabolite_compartment(model::JSONFBCModel, mid::String) x = get(model.metabolites[model.metabolite_index[mid]], "compartment", nothing) - if isa(x, String) - return x - else - return nothing - end + return isa(x, String) ? x : nothing end function Base.convert(::Type{JSONFBCModel}, mm::A.AbstractFBCModel) diff --git a/src/utils.jl b/src/utils.jl index db0e160..264d6cd 100644 --- a/src/utils.jl +++ b/src/utils.jl @@ -1,14 +1,14 @@ -_json_rxn_name(r, i) = string(get(r, "id", "rxn$i")) +extract_json_reaction_id(r, i) = string(get(r, "id", "rxn$i")) -_json_met_name(m, i) = string(get(m, "id", "met$i")) +extract_json_metabolite_id(m, i) = string(get(m, "id", "met$i")) -_json_gene_name(g, i) = string(get(g, "id", "gene$i")) +extract_json_gene_id(g, i) = string(get(g, "id", "gene$i")) function parse_grr(str::Maybe{String}) isnothing(str) && return nothing isempty(str) && return nothing - + dnf = A.GeneAssociationDNF() for isozyme in string.(split(str, " or ")) push!( @@ -31,15 +31,17 @@ function parse_formula(x::Maybe{String}) return res end -function parse_charge(x) +function parse_charge(x)::Maybe{Int} if isa(x, Int) - return x + x elseif isa(x, Float64) - return Int(x)::Int + Int(x) elseif isa(x, String) - return parse(Int, x) - else + Int(parse(Float64, x)) + elseif isnothing(x) nothing + else + throw(DomainError(x, "cannot parse charge")) end end @@ -53,7 +55,7 @@ function parse_annotations_or_notes(x) if isa(vs, String) a_or_n[k] = String[vs] else - a_or_n[k] = String[v for v in vs] + a_or_n[k] = String[v for v in vs] end end return a_or_n diff --git a/test/io.jl b/test/io.jl deleted file mode 100644 index 45604a0..0000000 --- a/test/io.jl +++ /dev/null @@ -1,26 +0,0 @@ -@testset "IO" begin - model = A.load(J.JSONFBCModel, iml1515_path) - - @test all( - in.( - keys(model.json), - Ref([ - "metabolites" - "id" - "compartments" - "reactions" - "version" - "genes" - ]), - ), - ) - - @test all(in.(A.filename_extensions(J.JSONFBCModel), Ref([ - "json" - "JSON" - ]))) - - saved_path = joinpath(mktempdir(), "test-model.json") - A.save(model, saved_path) - test_iml1515_details(saved_path) -end diff --git a/test/misc.jl b/test/misc.jl new file mode 100644 index 0000000..0629410 --- /dev/null +++ b/test/misc.jl @@ -0,0 +1,25 @@ + +@testset "Miscellaneous tests" begin + # the path is inherited from the main testset + model = A.load(JSONFBCModel, joinpath(@__DIR__, "test-models", "iML1515.json")) + # test that no superfluous keys are generated + @test all( + key in ["metabolites", "id", "compartments", "reactions", "version", "genes"] for + key in keys(model.json) + ) + + # caps suffix is quite common + @test all(suffix in A.filename_extensions(JSONFBCModel) for suffix in ["json", "JSON"]) +end + +@testset "Corner cases" begin + import JSONFBCModels: parse_charge + + @test parse_charge(1) == 1 + @test parse_charge(2.0) == 2 + @test parse_charge("3") == 3 + @test parse_charge("4.0") == 4 + @test parse_charge(nothing) == nothing + @test_throws ArgumentError parse_charge("totally positive charge") + @test_throws DomainError parse_charge(["very charged"]) +end diff --git a/test/runtests.jl b/test/runtests.jl index be82fd4..206ebe6 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,33 +1,39 @@ -using Test import AbstractFBCModels as A -import JSONFBCModels as J - -include("test_utils.jl") - -isdir("downloaded") || mkdir("downloaded") +import JSONFBCModels: JSONFBCModel -iml1515_path = A.download_data_file( - "http://bigg.ucsd.edu/static/models/iML1515.json", - joinpath("downloaded", "iML1515.json"), - "b0f9199f048779bb08a14dfa6c09ec56d35b8750d2f99681980d0f098355fbf5", -) - -@testset "JSONFBCModels" begin - @testset "Interface implemented" begin - A.run_fbcmodel_type_tests(J.JSONFBCModel) - end - - @testset "IO" begin - include("io.jl") - # TODO test convert +using Test +@testset "JSONFBCModels tests" begin + A.run_fbcmodel_type_tests(JSONFBCModel) + + modeldir = joinpath(@__DIR__, "test-models") + mkpath(modeldir) + + for (name, url, hash, ts) in [ + ( + "e_coli_core", + "http://bigg.ucsd.edu/static/models/e_coli_core.json", + "7bedec10576cfe935b19218dc881f3fb14f890a1871448fc19a9b4ee15b448d8", + true, + ), + ( + "iJO1366", + "http://bigg.ucsd.edu/static/models/iJO1366.json", + "9376a93f62ad430719f23e612154dd94c67e0d7c9545ed9d17a4d0c347672313", + true, + ), + ( + "iML1515", + "http://bigg.ucsd.edu/static/models/iML1515.json", + "b0f9199f048779bb08a14dfa6c09ec56d35b8750d2f99681980d0f098355fbf5", + true, + ), + ] + path = joinpath(modeldir, "$name.json") + A.download_data_file(url, path, hash) + A.run_fbcmodel_file_tests(JSONFBCModel, path; name, test_save = ts) end - @testset "Accessors" begin - A.run_fbcmodel_file_tests(J.JSONFBCModel, iml1515_path; name="iML1515", test_save=true) - - @testset "Specific details of iML1515" begin - test_iml1515_details(iml1515_path) - end - end + include("test_iML1515.jl") + include("misc.jl") end diff --git a/test/test_utils.jl b/test/test_iML1515.jl similarity index 80% rename from test/test_utils.jl rename to test/test_iML1515.jl index 08825c2..4aea939 100644 --- a/test/test_utils.jl +++ b/test/test_iML1515.jl @@ -1,5 +1,8 @@ -function test_iml1515_details(iml1515_path::String) - model = A.load(J.JSONFBCModel, iml1515_path) + +@testset "Test the expected contents of iML1515.json" begin + + # the path is inherited from the main testset + model = A.load(JSONFBCModel, joinpath(@__DIR__, "test-models", "iML1515.json")) @test "SHK3Dr" in A.reactions(model) @test A.n_reactions(model) == 2712 @@ -11,7 +14,9 @@ function test_iml1515_details(iml1515_path::String) @test all(length.(A.bounds(model)) .== 2712) @test all(A.balance(model) .== 0) @test A.objective(model)[2669] == 1 - @test all(in.(A.reaction_gene_association_dnf(model, "FBA"), Ref([["b2925"], ["b2097"]]))) + @test all( + in.(A.reaction_gene_association_dnf(model, "FBA"), Ref([["b2925"], ["b2097"]])), + ) @test A.metabolite_formula(model, "atp_c")["C"] == 10 @test A.metabolite_charge(model, "atp_c") == -4 @test A.metabolite_compartment(model, "atp_c") == "c" diff --git a/test/utils.jl b/test/utils.jl deleted file mode 100644 index 492d588..0000000 --- a/test/utils.jl +++ /dev/null @@ -1,4 +0,0 @@ -str = "(b3739 and (b3731 and b3733 and b3735 and b3734 and b3732) and (b3738 and b3736 and b3737)) or ((b3731 and b3733 and b3735 and b3734 and b3732) and (b3738 and b3736 and b3737))" -str = "b3731 and b3733" -str = "b3731 or b3733" -str = "b4562"