Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
.PHONY: test test-r test-python test-typescript test-julia test-rust \
docs docs-r docs-python docs-typescript docs-julia docs-rust

# ── Tests ──────────────────────────────────────────────────────────────────────

test-r:
@echo "==> R"
cd r && Rscript -e "devtools::test(stop_on_failure = TRUE)"

test-python:
@echo "==> Python"
cd python/rtemis_a3 && uv run pytest

test-typescript:
@echo "==> TypeScript"
cd typescript && pnpm test

test-julia:
@echo "==> Julia"
cd julia/RtemisA3 && julia --project=. -e "using Pkg; Pkg.test()"

test-rust:
@echo "==> Rust"
cd rust && cargo test

test:
@r=0; p=0; ts=0; jl=0; rs=0; \
$(MAKE) test-r || r=1; \
$(MAKE) test-python || p=1; \
$(MAKE) test-julia || jl=1; \
$(MAKE) test-typescript || ts=1; \
$(MAKE) test-rust || rs=1; \
echo ""; \
echo "── Test Summary ──────────────────────────────────────"; \
[ $$r -eq 0 ] && echo " R: passed" || echo " R: FAILED"; \
[ $$p -eq 0 ] && echo " Python: passed" || echo " Python: FAILED"; \
[ $$jl -eq 0 ] && echo " Julia: passed" || echo " Julia: FAILED"; \
[ $$ts -eq 0 ] && echo " TypeScript: passed" || echo " TypeScript: FAILED"; \
[ $$rs -eq 0 ] && echo " Rust: passed" || echo " Rust: FAILED"; \
echo "─────────────────────────────────────────────────────"; \
[ $$((r+p+ts+jl+rs)) -eq 0 ]

# ── Docs ───────────────────────────────────────────────────────────────────────

docs-r:
@echo "==> R"
cd r && Rscript -e "devtools::document()"

docs-python:
@echo "==> Python"
cd python && bash build-docs.sh

docs-julia:
@echo "==> Julia"
cd julia && bash build-docs.sh

docs-typescript:
@echo "==> TypeScript"
cd typescript && pnpm build

docs-rust:
@echo "==> Rust"
cd rust && bash build-docs.sh

docs:
@r=0; p=0; ts=0; jl=0; rs=0; \
$(MAKE) docs-r || r=1; \
$(MAKE) docs-python || p=1; \
$(MAKE) docs-julia || jl=1; \
$(MAKE) docs-typescript || ts=1; \
$(MAKE) docs-rust || rs=1; \
echo ""; \
echo "── Docs Summary ──────────────────────────────────────"; \
[ $$r -eq 0 ] && echo " R: done" || echo " R: FAILED"; \
[ $$p -eq 0 ] && echo " Python: done" || echo " Python: FAILED"; \
[ $$jl -eq 0 ] && echo " Julia: done" || echo " Julia: FAILED"; \
[ $$ts -eq 0 ] && echo " TypeScript: done" || echo " TypeScript: FAILED"; \
[ $$rs -eq 0 ] && echo " Rust: done" || echo " Rust: FAILED"; \
echo "─────────────────────────────────────────────────────"; \
[ $$((r+p+ts+jl+rs)) -eq 0 ]
2 changes: 2 additions & 0 deletions examples/mapt.a3.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{
"$schema": "https://schema.rtemis.org/a3/v1/schema.json",
"a3_version": "1.0.0",
"sequence": "MAEPRQEFEVMEDHAGTYGLGDRKDQGGYTMHQDQEGDTDAGLKESPLQTPTEDGSEEPGSETSDAKSTPTAEDVTAPLVDEGAPGKQAAAQPHTEIPEGTTAEEAGIGDTPSLEDEAAGHVTQARMVSKSKDGTGSDDKKAKGADGKTKIATPRGAAPPGQKGQANATRIPAKTPPAPKTPPSSGEPPKSGDRSGYSSPGSPGTPGSRSRTPSLPTPPTREPKKVAVVRTPPKSPSSAKSRLQTAPVPMPDLKNVKSKIGSTENLKHQPGGGKVQIINKKLDLSNVQSKCGSKDNIKHVPGGGSVQIVYKPVDLSKVTSKCGSLGNIHHKPGGGQVEVKSEKLDFKDRVQSKIGSLDNITHVPGGGNKKIETHKLTFRENAKAKTDHGAEIVYKSPVVSGDTSPRHLSNVSSTGSIDMVDSPQLATLADEVSASLAKQGL",
"annotations": {
"site": {
Expand Down
7 changes: 6 additions & 1 deletion julia/RtemisA3/src/api.jl
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ function create_a3(
processing !== nothing && (annot["processing"] = processing)
variant !== nothing && (annot["variant"] = variant)

raw = Dict{String,Any}("sequence" => sequence, "annotations" => annot)
raw = Dict{String,Any}(
"\$schema" => _A3_SCHEMA_URI,
"a3_version" => _A3_VERSION,
"sequence" => sequence,
"annotations" => annot,
)
metadata !== nothing && (raw["metadata"] = metadata)
A3(raw)
end
Expand Down
2 changes: 2 additions & 0 deletions julia/RtemisA3/src/io.jl
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ end

function to_dict(a3::A3)::Dict{String,Any}
Dict{String,Any}(
"\$schema" => _A3_SCHEMA_URI,
"a3_version" => _A3_VERSION,
"sequence" => a3.sequence,
"annotations" => Dict{String,Any}(
"site" => Dict(k => _to_dict(v) for (k, v) in a3.annotations.site),
Expand Down
21 changes: 19 additions & 2 deletions julia/RtemisA3/src/validate.jl
Original file line number Diff line number Diff line change
Expand Up @@ -211,11 +211,28 @@ end

# ─── A3 outer constructor ─────────────────────────────────────────────────────

const _A3_SCHEMA_URI = "https://schema.rtemis.org/a3/v1/schema.json"
const _A3_VERSION = "1.0.0"
const _A3_ENVELOPE = ("\$schema", "a3_version")
const _A3_DATA_KEYS = ("sequence", "annotations", "metadata")

function A3(raw::AbstractDict)
schema_val = get(raw, "\$schema", nothing)
schema_val !== nothing ||
throw(A3ValidationError("missing required field '\$schema'"))
schema_val == _A3_SCHEMA_URI ||
throw(A3ValidationError("'\$schema' must be '$_A3_SCHEMA_URI', got '$schema_val'"))

version_val = get(raw, "a3_version", nothing)
version_val !== nothing ||
throw(A3ValidationError("missing required field 'a3_version'"))
version_val == _A3_VERSION ||
throw(A3ValidationError("'a3_version' must be '$_A3_VERSION', got '$version_val'"))

for k in keys(raw)
k in ("sequence", "annotations", "metadata") ||
k in _A3_DATA_KEYS || k in _A3_ENVELOPE ||
throw(A3ValidationError("unknown top-level field '$k' " *
"(must be one of: sequence, annotations, metadata)"))
"(must be one of: \$schema, a3_version, sequence, annotations, metadata)"))
end
haskey(raw, "sequence") ||
throw(A3ValidationError("missing required field 'sequence'"))
Expand Down
2 changes: 1 addition & 1 deletion julia/RtemisA3/test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ end
# ─── Unknown keys rejected ────────────────────────────────────────────────────

@testset "unknown keys" begin
val_err(() -> A3(Dict("sequence" => "MAEPRQ", "extra" => "bad")))
val_err(() -> A3(Dict("\$schema" => "https://schema.rtemis.org/a3/v1/schema.json", "a3_version" => "1.0.0", "sequence" => "MAEPRQ", "extra" => "bad")))
val_err(() -> create_a3("MAEPRQ";
site = Dict("x" => Dict("index" => [1], "type" => "", "extra" => "bad"))
))
Expand Down
30 changes: 29 additions & 1 deletion python/rtemis_a3/src/rtemis/a3/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
from ._models import A3, VariantRecord
from .errors import A3ParseError, A3ValidationError

_A3_SCHEMA_URI = "https://schema.rtemis.org/a3/v1/schema.json"
_A3_VERSION = "1.0.0"
_ENVELOPE_KEYS = frozenset({"$schema", "a3_version"})


def create_a3(
sequence: str,
Expand Down Expand Up @@ -104,6 +108,26 @@ def a3_from_json(text: str) -> A3:
except (json.JSONDecodeError, TypeError) as exc:
raise A3ParseError(f"invalid JSON: {exc}") from exc

if not isinstance(data, dict):
raise A3ParseError("JSON root must be an object")
schema_val = data.get("$schema")
if schema_val is None:
raise A3ParseError("missing required field '$schema'")
if schema_val != _A3_SCHEMA_URI:
raise A3ParseError(
f"'$schema' must be '{_A3_SCHEMA_URI}', got '{schema_val}'"
)
version_val = data.get("a3_version")
if version_val is None:
raise A3ParseError("missing required field 'a3_version'")
if version_val != _A3_VERSION:
raise A3ParseError(
f"'a3_version' must be '{_A3_VERSION}', got '{version_val}'"
)
# Strip envelope keys before passing to the data model
for key in _ENVELOPE_KEYS:
data.pop(key, None)

try:
return A3.model_validate(data)
except ValidationError as exc:
Expand All @@ -125,7 +149,11 @@ def a3_to_json(a3: A3, *, indent: int | None = None) -> str:
str
JSON string.
"""
data = a3.model_dump(mode="json")
data: dict[str, Any] = {
"$schema": _A3_SCHEMA_URI,
"a3_version": _A3_VERSION,
**a3.model_dump(mode="json"),
}
return json.dumps(data, indent=indent, ensure_ascii=False)


Expand Down
16 changes: 14 additions & 2 deletions python/rtemis_a3/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ def test_bounds_error(self):
# a3_from_json / a3_to_json
# ---------------------------------------------------------------------------

MINIMAL_JSON = '{"sequence": "MAEPRQ"}'
MINIMAL_JSON = '{"$schema": "https://schema.rtemis.org/a3/v1/schema.json", "a3_version": "1.0.0", "sequence": "MAEPRQ"}'

FULL_JSON = """{
"$schema": "https://schema.rtemis.org/a3/v1/schema.json",
"a3_version": "1.0.0",
"sequence": "MAEPRQFV",
"annotations": {
"site": {
Expand Down Expand Up @@ -101,7 +103,17 @@ def test_invalid_json(self):

def test_valid_json_invalid_a3(self):
with pytest.raises(A3ValidationError):
a3_from_json('{"sequence": "M"}') # too short
a3_from_json(
'{"$schema": "https://schema.rtemis.org/a3/v1/schema.json", "a3_version": "1.0.0", "sequence": "M"}'
) # too short

def test_missing_schema_field(self):
with pytest.raises(A3ParseError, match=r"\$schema"):
a3_from_json('{"a3_version": "1.0.0", "sequence": "MAEPRQ"}')

def test_missing_version_field(self):
with pytest.raises(A3ParseError, match="a3_version"):
a3_from_json('{"$schema": "https://schema.rtemis.org/a3/v1/schema.json", "sequence": "MAEPRQ"}')


class TestA3ToJson:
Expand Down
25 changes: 25 additions & 0 deletions r/R/a3.R
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
# └── organism: character(1)
# 2026- EDG rtemis.org

.A3_SCHEMA_URI <- "https://schema.rtemis.org/a3/v1/schema.json"
.A3_VERSION <- "1.0.0"

# %% Level 1: A3Sequence ----
A3Sequence <- new_class(
"A3Sequence",
Expand Down Expand Up @@ -884,6 +887,8 @@ method(to_json, A3) <- function(x, pretty = TRUE, ...) {
}

lst <- list(
`$schema` = jsonlite::unbox(.A3_SCHEMA_URI),
a3_version = jsonlite::unbox(.A3_VERSION),
sequence = jsonlite::unbox(x@sequence@data),
annotations = list(
site = force_named(lapply(x@annotations@site, feature_to_list)),
Expand Down Expand Up @@ -931,6 +936,26 @@ A3from_json <- function(x, ...) {
)
}

# Validate required envelope fields
schema_field <- x[["$schema"]]
if (is.null(schema_field)) {
cli::cli_abort("JSON input missing required field {.field $schema}.")
}
if (schema_field != .A3_SCHEMA_URI) {
cli::cli_abort(
"Field {.field $schema} must be {.val {.A3_SCHEMA_URI}}, got {.val {schema_field}}."
)
}
version_field <- x[["a3_version"]]
if (is.null(version_field)) {
cli::cli_abort("JSON input missing required field {.field a3_version}.")
}
if (version_field != .A3_VERSION) {
cli::cli_abort(
"Field {.field a3_version} must be {.val {.A3_VERSION}}, got {.val {version_field}}."
)
}

sequence <- x[["sequence"]]
annotations <- x[["annotations"]]

Expand Down
6 changes: 6 additions & 0 deletions r/tests/testthat/test_A3.R
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,8 @@ test_that("A3from_json round-trips to_json with zero loss", {

test_that("A3from_json rejects legacy bare-array format", {
legacy_json <- '{
"$schema": "https://schema.rtemis.org/a3/v1/schema.json",
"a3_version": "1.0.0",
"sequence": "MKTAYIAKQRQISFVK",
"annotations": {
"site": {
Expand All @@ -597,6 +599,8 @@ test_that("A3from_json rejects legacy bare-array format", {

test_that("A3from_json handles missing metadata gracefully", {
json <- '{
"$schema": "https://schema.rtemis.org/a3/v1/schema.json",
"a3_version": "1.0.0",
"sequence": "MKTAYIAKQRQISFVK",
"annotations": {
"site": {},
Expand All @@ -614,6 +618,8 @@ test_that("A3from_json handles missing metadata gracefully", {

test_that("A3from_json accepts pre-parsed list", {
lst <- list(
`$schema` = "https://schema.rtemis.org/a3/v1/schema.json",
a3_version = "1.0.0",
sequence = "MKTAYIAKQRQISFVK",
annotations = list(
site = list(
Expand Down
26 changes: 24 additions & 2 deletions rust/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
//! use rtemis_a3::{a3_from_json, a3_to_json};
//!
//! let json = r#"{
//! "$schema": "https://schema.rtemis.org/a3/v1/schema.json",
//! "a3_version": "1.0.0",
//! "sequence": "MAEPRQ",
//! "annotations": { "site": {}, "region": {}, "ptm": {}, "processing": {}, "variant": [] },
//! "metadata": { "uniprot_id": "", "description": "", "reference": "", "organism": "" }
Expand Down Expand Up @@ -170,6 +172,8 @@ mod tests {

// The minimal JSON the spec requires all five families to be present.
const MINIMAL_JSON: &str = r#"{
"$schema": "https://schema.rtemis.org/a3/v1/schema.json",
"a3_version": "1.0.0",
"sequence": "MAEPRQ",
"annotations": {
"site": {},
Expand Down Expand Up @@ -216,15 +220,33 @@ mod tests {
assert_eq!(residue_at(&a3, 99), None);
}

#[test]
fn rejects_missing_schema() {
let json = r#"{"a3_version":"1.0.0","sequence":"MAEPRQ","annotations":{"site":{},"region":{},"ptm":{},"processing":{},"variant":[]},"metadata":{}}"#;
assert!(a3_from_json(json).is_err());
}

#[test]
fn rejects_wrong_schema_uri() {
let json = r#"{"$schema":"https://example.com/wrong","a3_version":"1.0.0","sequence":"MAEPRQ","annotations":{"site":{},"region":{},"ptm":{},"processing":{},"variant":[]},"metadata":{}}"#;
assert!(a3_from_json(json).is_err());
}

#[test]
fn rejects_missing_version() {
let json = r#"{"$schema":"https://schema.rtemis.org/a3/v1/schema.json","sequence":"MAEPRQ","annotations":{"site":{},"region":{},"ptm":{},"processing":{},"variant":[]},"metadata":{}}"#;
assert!(a3_from_json(json).is_err());
}

#[test]
fn rejects_unknown_top_level_key() {
let json = r#"{"sequence":"MAEPRQ","foo":"bar"}"#;
let json = r#"{"$schema":"https://schema.rtemis.org/a3/v1/schema.json","a3_version":"1.0.0","sequence":"MAEPRQ","foo":"bar"}"#;
assert!(a3_from_json(json).is_err());
}

#[test]
fn rejects_unknown_metadata_key() {
let json = r#"{"sequence":"MAEPRQ","metadata":{"gene":"MAPT"}}"#;
let json = r#"{"$schema":"https://schema.rtemis.org/a3/v1/schema.json","a3_version":"1.0.0","sequence":"MAEPRQ","metadata":{"gene":"MAPT"}}"#;
assert!(a3_from_json(json).is_err());
}

Expand Down
Loading
Loading