From script to LabRegistry
2026-04-24
You write a useful script. Someone asks for it.
/home/nicolas/scripts/kmer_utils.jl # v1
/home/nicolas/scripts/kmer_utils2.jl # "fixed"
/home/nicolas/scripts/kmer_utils_FINAL.jl
/home/nicolas/scripts/kmer_utils_FINAL2.jl
A Julia package gives you:
Project.toml + Manifest.toml)Before we start run this in your Julia REPL:
Verify it worked:
Once this is done, any lab package installs like any other:
This is the main take-away of the workshop everything else is context for how we got here.
| Block | Topic | Time |
|---|---|---|
| 1 | Package anatomy & Pkg.generate |
15 min |
| 2 | Modules, exports, docstrings | 20 min |
| 3 | Testing with Test.jl |
15 min |
| 4 | Versioning & Project.toml |
10 min |
| 5 | Local registries & LabRegistry | 20 min |
| 6 | Live exercise | 10 min |
This creates:
MyTool/
├── Project.toml
└── src/
└── MyTool.jl
That’s the whole skeleton. Everything else is convention.
Project.toml : the identity carduuid: never edit by hand, generated onceversion: follows SemVer : we’ll come back to this[deps]: only direct dependencies go hereNever manually edit [deps] : let Pkg handle the UUIDs.
Manifest.toml is the full lockfile (exact versions of the entire dep tree). Commit it for scripts, .gitignore it for libraries.
src/ directoryMyTool/
├── Project.toml
├── Manifest.toml
└── src/
├── MyTool.jl # entry point : must match package name
└── submodule.jl # any split you want
MyTool.jl is the only file Julia loads automatically. Everything else must be included from it.
using vs import:
using Foo brings all of Foo’s exports into scopeimport Foo: bar gives explicit, surgical access : prefer this in library code to avoid name collisionsexport controls your public APIAnything not exported is still accessible with MyTool._hash_kmer(). Exports define the intended interface.
"""
kmer_count(seq, k) -> Dict{String, Int}
Count all k-mers of length `k` in amino acid sequence `seq`.
# Arguments
- `seq::AbstractString`: single-letter AA sequence
- `k::Int`: k-mer length, must satisfy `1 ≤ k ≤ length(seq)`
# Examples
julia> kmer_count("ACDEFG", 2)
Dict("AC" => 1, "CD" => 1, "DE" => 1, "EF" => 1, "FG" => 1)
"""
function kmer_count(seq::AbstractString, k::Int)
counts = Dict{String, Int}()
for i in 1:(length(seq) - k + 1)
mer = seq[i:i+k-1]
counts[mer] = get(counts, mer, 0) + 1
end
return counts
end?kmer_count in the REPL renders this. Documenter.jl can build HTML docs from it.
Load it:
test/runtests.jlMyTool/
├── Project.toml
├── src/
│ └── MyTool.jl
└── test/
└── runtests.jl
Output:
Test Summary: | Pass Total
kmer_count | 3 3
edge cases | 2 2
Tests are also run automatically by CI if you set up GitHub Actions (out of scope today, but PkgTemplates.jl wires this up for you).
Test deps go in [extras] + [targets], not [deps]:
This keeps Test.jl out of your users’ dependency graph.
@testset nesting : keep it organizedNesting gives granular failure reports. Outer @testset never short-circuits on inner failure.
MAJOR.MINOR.PATCH : Julia’s Pkg enforces this strictly.
| Change type | Bump |
|---|---|
| Breaking API change | MAJOR (1.x.x → 2.0.0) |
| New exported function | MINOR (1.1.x → 1.2.0) |
| Bug fix, internal refactor | PATCH (1.1.0 → 1.1.1) |
Special rule for 0.x.y: MINOR bumps are breaking. This is intentional : it gives you room to stabilize an API before committing to 1.0.
Breaking:
Not breaking:
When in doubt: bump MINOR, document in CHANGELOG.md.
Pkg what versions you’ve tested againstPkg will warn and LabRegistry will reject the release^ semantics: "1" means >=1.0, <2.0Run Pkg.compat() interactively to set these from the REPL.
Julia’s general registry (General) is the public one at github.com/JuliaRegistries/General. A local registry works the same way but is hosted privately.
LabRegistry lives at: https://github.com/lemieux-lab/LabRegistry
It indexes package name → git URL + versions, so Pkg can resolve Pkg.add("NMacros") without the package being on General.
After this, any package registered there installs like any other:
Do this once per machine/cluster node. On the HPC, add it to your startup.jl.
LocalRegistry.jlYour package must be:
Project.toml with name, uuid, version, [compat]This:
Project.tomlRegistry.toml entry + version file in LabRegistryFor subsequent versions: bump version in Project.toml, tag the commit, then register() again : no repo arg needed.
[ ] Package in git repo, clean working tree
[ ] version bumped in Project.toml
[ ] [compat] entries filled
[ ] Pkg.test() passes
[ ] git tag v0.2.0 (match Project.toml)
[ ] git push --tags
[ ] register("MyTool", registry="/path/to/LabRegistry")
[ ] cd ~/LabRegistry && git push
Anyone in the lab can then:
LabRegistry/
├── Registry.toml # index of all packages
└── packages/
└── M/
└── MyTool/
├── Package.toml # name, uuid, repo URL
└── Versions.toml # version → git tree SHA
LocalRegistry.jl manages all of this. You never edit these files by hand.
PkgTemplates.jl : skip the boilerplateGenerates the full structure + CI + auto-compat PRs. Worth using for anything non-trivial.
Goal: register a minimal package in LabRegistry.
Steps:
Pkg.generate("Lab$(yourname)")@testset, make it pass[compat] for juliaregister("Lab$(yourname)", registry="/path/to/LabRegistry")Stretch: add a dependency (Pkg.add), use it in your function, verify the dep appears in Project.toml.
| Error | Cause | Fix |
|---|---|---|
uuid mismatch |
Edited Project.toml by hand |
Let Pkg manage it |
Package not found |
LabRegistry not added | Pkg.Registry.add(...) |
not a git repo |
Forgot git init |
git init && git add . && git commit |
[compat] missing |
Registry rejects unbound deps | Fill [compat] for all deps |
| Dirty working tree | Uncommitted changes | git commit before register() |
Project.toml, src/, test/export, import, scoping?@testset, edge cases, test-only depsLocalRegistry.jl workflow, one-line install for lab membersRule of thumb: if you’ve included a file more than once across different projects, it belongs in a package.
github.com/lemieux-lab/LabRegistry