From a63da22542a5e77e5ff43c876ca92fd9d318b931 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 8 Oct 2026 16:53:20 +0100 Subject: [PATCH 01/11] feat(schema): derive JsonSchema for multi-network models --- pywr-schema/src/model.rs | 6 +++--- pywr-schema/src/network/mod.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pywr-schema/src/model.rs b/pywr-schema/src/model.rs index 659ebb64..dd947bf6 100644 --- a/pywr-schema/src/model.rs +++ b/pywr-schema/src/model.rs @@ -886,7 +886,7 @@ impl ModelSchema { } #[skip_serializing_none] -#[derive(serde::Deserialize, serde::Serialize, Clone)] +#[derive(serde::Deserialize, serde::Serialize, Clone, JsonSchema)] pub struct MultiNetworkTransfer { pub from_network: String, pub metric: Metric, @@ -894,7 +894,7 @@ pub struct MultiNetworkTransfer { pub initial_value: Option, } -#[derive(serde::Deserialize, serde::Serialize, Clone)] +#[derive(serde::Deserialize, serde::Serialize, Clone, JsonSchema)] pub struct MultiNetworkEntry { pub name: String, pub network: NetworkSchemaRef, @@ -993,7 +993,7 @@ pub enum MultiNetworkModelSchemaBuildError { /// /// #[skip_serializing_none] -#[derive(serde::Deserialize, serde::Serialize, Clone)] +#[derive(serde::Deserialize, serde::Serialize, Clone, JsonSchema)] pub struct MultiNetworkModelSchema { pub metadata: Metadata, pub time: TimeDomain, diff --git a/pywr-schema/src/network/mod.rs b/pywr-schema/src/network/mod.rs index a3fd6ce7..b4c450be 100644 --- a/pywr-schema/src/network/mod.rs +++ b/pywr-schema/src/network/mod.rs @@ -876,7 +876,7 @@ impl NetworkSchema { } } -#[derive(serde::Deserialize, serde::Serialize, Clone, Display, EnumDiscriminants)] +#[derive(serde::Deserialize, serde::Serialize, Clone, JsonSchema, Display, EnumDiscriminants)] #[serde(untagged)] #[strum_discriminants(derive(Display, IntoStaticStr, EnumString, EnumIter))] #[strum_discriminants(name(NetworkSchemaRefType))] From d47cbf250b725cb5c227f48df995a9aece511afc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 8 Oct 2026 17:11:52 +0100 Subject: [PATCH 02/11] feat(cli): export one JSON Schema for v1 and v2 model files export-schema --union combines the Pywr v1 schema, generated by pywr-v1-schema, with the v2 model and multi-network schemas. Each version's definitions are namespaced and the document kind is selected by its identifying property: nodes, models, network or networks. --- pywr-cli/src/main.rs | 13 ++- pywr-schema/src/json_schema.rs | 139 +++++++++++++++++++++++++++++++++ pywr-schema/src/lib.rs | 1 + 3 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 pywr-schema/src/json_schema.rs diff --git a/pywr-cli/src/main.rs b/pywr-cli/src/main.rs index df29f167..d49081e1 100644 --- a/pywr-cli/src/main.rs +++ b/pywr-cli/src/main.rs @@ -134,6 +134,9 @@ enum Commands { ExportSchema { /// Path to save the JSON schema. out: PathBuf, + /// Write one schema that covers both Pywr v1 and v2 model files. + #[arg(long)] + union: bool, }, /// Run the Pywr model runner service over a local socket or standard I/O. RunServer { @@ -243,7 +246,7 @@ fn main() -> Result<()> { num_scenarios, solver, } => run_random(*num_systems, *density, *num_scenarios, solver), - Commands::ExportSchema { out } => export_schema(out)?, + Commands::ExportSchema { out, union } => export_schema(out, *union)?, Commands::RunServer { mode, socket_name } => { run_server(*mode, socket_name)?; } @@ -531,8 +534,12 @@ fn run_random(num_systems: usize, density: usize, num_scenarios: usize, solver: .unwrap(); } -fn export_schema(out_path: &Path) -> Result<()> { - let schema = schema_for!(ModelSchema); +fn export_schema(out_path: &Path, union: bool) -> Result<()> { + let schema = if union { + pywr_schema::json_schema::pywr_model_schema(pywr_v1_schema::json_schema::CustomTypes::Any) + } else { + schema_for!(ModelSchema) + }; std::fs::write( out_path, serde_json::to_string_pretty(&schema).with_context(|| "Failed serialise Pywr schema".to_string())?, diff --git a/pywr-schema/src/json_schema.rs b/pywr-schema/src/json_schema.rs new file mode 100644 index 00000000..7c18ed76 --- /dev/null +++ b/pywr-schema/src/json_schema.rs @@ -0,0 +1,139 @@ +//! A single JSON Schema covering the Pywr v1 and v2 model file formats. + +use pywr_v1_schema::json_schema::{ + CustomTypes, model_schema as v1_model_schema, multi_model_schema as v1_multi_model_schema, +}; +use schemars::{JsonSchema, Schema, schema_for}; +use serde_json::{Map, Value, json}; + +const DEFS_POINTER: &str = "#/$defs/"; + +/// A document kind, identified by the property that only that kind has. +struct Branch { + /// Prefix that namespaces the definitions of every document of one version. + prefix: &'static str, + /// The property that identifies the document kind. + key: &'static str, + schema: Schema, +} + +/// Prefix every `#/$defs/` reference in `value` with `prefix`. +fn prefix_refs(value: &mut Value, prefix: &str) { + match value { + Value::Object(map) => { + for (key, child) in map.iter_mut() { + match child { + Value::String(reference) if key == "$ref" => { + let name = reference + .strip_prefix(DEFS_POINTER) + .unwrap_or_else(|| panic!("unexpected $ref form: {reference}")); + *reference = format!("{DEFS_POINTER}{prefix}{name}"); + } + _ => prefix_refs(child, prefix), + } + } + } + Value::Array(items) => items.iter_mut().for_each(|item| prefix_refs(item, prefix)), + _ => {} + } +} + +/// Move the definitions of `schema` into `defs` under `prefix`, with its root as one more definition. +/// +/// Returns the name of the definition that holds the root. Two documents of one version share +/// definitions, which must then be identical. +fn add_namespaced(defs: &mut Map, schema: Schema, prefix: &str) -> String { + let Value::Object(mut root) = schema.to_value() else { + panic!("a root schema is an object"); + }; + root.remove("$schema"); + let Some(Value::Object(definitions)) = root.remove("$defs") else { + panic!("a root schema has definitions"); + }; + let Some(Value::String(title)) = root.remove("title") else { + panic!("a root schema has a title"); + }; + + let root_name = format!("{prefix}{title}"); + let definitions = definitions + .into_iter() + .map(|(name, definition)| (format!("{prefix}{name}"), definition)) + .chain([(root_name.clone(), Value::Object(root))]); + for (name, mut definition) in definitions { + prefix_refs(&mut definition, prefix); + if let Some(existing) = defs.insert(name.clone(), definition.clone()) { + assert_eq!( + existing, definition, + "definition {name} differs between documents of one version" + ); + } + } + root_name +} + +fn v2_schema() -> Schema { + schema_for!(T) +} + +/// The JSON Schema for any Pywr model file, v1 or v2. +/// +/// A document is one of four kinds, told apart by its identifying property: `nodes` (v1 model), +/// `models` (v1 multi-model), `network` (v2 model) or `networks` (v2 multi-network model). The +/// definitions of each version are namespaced `v1_` or `v2_` so that the two cannot collide, and +/// each kind forbids the other kinds' properties so that a file mixing them is rejected. +/// +/// `v1_custom_types` selects how the v1 schemas treat nodes and parameters that fail to match a +/// core definition; see [`CustomTypes`]. +pub fn pywr_model_schema(v1_custom_types: CustomTypes) -> Schema { + let branches = [ + Branch { + prefix: "v1_", + key: "nodes", + schema: v1_model_schema(v1_custom_types), + }, + Branch { + prefix: "v1_", + key: "models", + schema: v1_multi_model_schema(v1_custom_types), + }, + Branch { + prefix: "v2_", + key: "network", + schema: v2_schema::(), + }, + Branch { + prefix: "v2_", + key: "networks", + schema: v2_schema::(), + }, + ]; + + let mut defs = Map::new(); + let keys: Vec<&str> = branches.iter().map(|branch| branch.key).collect(); + let one_of: Vec = branches + .into_iter() + .map(|branch| { + let root = add_namespaced(&mut defs, branch.schema, branch.prefix); + let others: Map = keys + .iter() + .filter(|key| **key != branch.key) + .map(|key| (key.to_string(), Value::Bool(false))) + .collect(); + json!({ + "title": format!("Pywr {} document with `{}`", &branch.prefix[..2], branch.key), + "required": [branch.key], + "properties": others, + "$ref": format!("{DEFS_POINTER}{root}"), + }) + }) + .collect(); + + let schema = json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Pywr model (v1 or v2)", + "description": "A Pywr model: v1 (`nodes`, or `models` for a multi-model) or v2 (`network`, or `networks` for a multi-network model).", + "oneOf": one_of, + "$defs": defs, + }); + Schema::try_from(schema).expect("the union is a JSON object") +} diff --git a/pywr-schema/src/lib.rs b/pywr-schema/src/lib.rs index 8a5bbe05..46fa4f6c 100644 --- a/pywr-schema/src/lib.rs +++ b/pywr-schema/src/lib.rs @@ -10,6 +10,7 @@ mod digest; pub mod edge; mod error; mod files; +pub mod json_schema; mod mermaid; pub mod meta; pub mod metric; From 5681c0e97a711fccaed00f6c4ae1d8ebedf81fd1 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 8 Oct 2026 17:17:27 +0100 Subject: [PATCH 03/11] test(schema): validate the model test files against the union schema Every v2 model, including the multi-network ones, and every v1 conversion input must validate, and a document that mixes the v1 and v2 identifying properties, or has none, must be rejected. --- Cargo.toml | 3 +- pywr-schema/Cargo.toml | 1 + pywr-schema/src/json_schema.rs | 88 ++++++++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 3e7d8a98..6aa6df19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,7 @@ heck = "0.5" hex = "0.4.3" interprocess = "2.4" jiff = { version = "0.2", features = ["serde"] } +jsonschema = { version = "0.58", default-features = false } libc = "0.2" log = "0.4" md-5 = "0.11" @@ -109,4 +110,4 @@ opt-level = 3 [profile.profiling] inherits = "release" -debug = "line-tables-only" \ No newline at end of file +debug = "line-tables-only" diff --git a/pywr-schema/Cargo.toml b/pywr-schema/Cargo.toml index a3eed9c0..d6e8689e 100644 --- a/pywr-schema/Cargo.toml +++ b/pywr-schema/Cargo.toml @@ -65,4 +65,5 @@ thiserror = { workspace = true } parquet = { workspace = true, optional = true, features = ["zstd"] } [dev-dependencies] +jsonschema = { workspace = true } tempfile = { workspace = true } diff --git a/pywr-schema/src/json_schema.rs b/pywr-schema/src/json_schema.rs index 7c18ed76..7edbe673 100644 --- a/pywr-schema/src/json_schema.rs +++ b/pywr-schema/src/json_schema.rs @@ -137,3 +137,91 @@ pub fn pywr_model_schema(v1_custom_types: CustomTypes) -> Schema { }); Schema::try_from(schema).expect("the union is a JSON object") } + +#[cfg(test)] +mod tests { + use super::pywr_model_schema; + use jsonschema::Validator; + use pywr_v1_schema::json_schema::CustomTypes; + use serde_json::Value; + use std::fs; + use std::path::{Path, PathBuf}; + + fn tests_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests") + } + + fn validator(custom_types: CustomTypes) -> Validator { + let schema = serde_json::to_value(pywr_model_schema(custom_types)).expect("schema serialises"); + jsonschema::validator_for(&schema).expect("the union is a valid JSON Schema") + } + + fn read(path: &Path) -> Value { + let data = fs::read_to_string(path).unwrap_or_else(|e| panic!("read {path:?}: {e}")); + serde_json::from_str(&data).unwrap_or_else(|e| panic!("parse {path:?}: {e}")) + } + + /// The JSON files directly in `dir` that are model documents: those with a `time` property. + fn model_files(dir: &Path) -> Vec { + let mut files: Vec = fs::read_dir(dir) + .unwrap_or_else(|e| panic!("read {dir:?}: {e}")) + .map(|entry| entry.expect("directory entry").path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "json")) + .filter(|path| read(path).get("time").is_some()) + .collect(); + files.sort(); + assert!(!files.is_empty(), "no models in {dir:?}"); + files + } + + #[test] + fn v2_models_validate() { + let validator = validator(CustomTypes::Any); + let mut files = model_files(&tests_dir()); + files.extend(model_files(&tests_dir().join("multi1"))); + files.extend(model_files(&tests_dir().join("multi2"))); + for path in files { + let errors: Vec = validator.iter_errors(&read(&path)).map(|e| e.to_string()).collect(); + assert!(errors.is_empty(), "{path:?}: {errors:?}"); + } + } + + /// The v1 models used to test conversion are v1 documents, so only the v1 branch accepts them. + #[test] + fn v1_models_validate() { + let validator = validator(CustomTypes::Any); + let dir = tests_dir().join("v1"); + let v1_models: Vec = fs::read_dir(&dir) + .expect("v1 test models") + .map(|entry| entry.expect("directory entry").path()) + .filter(|path| read(path).get("nodes").is_some()) + .collect(); + assert!(!v1_models.is_empty()); + for path in v1_models { + let errors: Vec = validator.iter_errors(&read(&path)).map(|e| e.to_string()).collect(); + assert!(errors.is_empty(), "{path:?}: {errors:?}"); + } + } + + #[test] + fn mixed_or_unidentified_documents_are_rejected() { + let validator = validator(CustomTypes::Any); + let v2 = read(&tests_dir().join("simple1.json")); + let v1 = read(&tests_dir().join("v1").join("scenarios.json")); + assert!(validator.is_valid(&v2) && validator.is_valid(&v1)); + + let mut both = v2.clone(); + both["nodes"] = v1["nodes"].clone(); + assert!( + !validator.is_valid(&both), + "`nodes` and `network` together must be rejected" + ); + + let mut neither = v2; + neither.as_object_mut().expect("a model is an object").remove("network"); + assert!( + !validator.is_valid(&neither), + "a document with no identifying property must be rejected" + ); + } +} From d19068d9b97bad70b830803207cee798c17f84ba Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 8 Oct 2026 17:27:16 +0100 Subject: [PATCH 04/11] ci(release): attach the JSON Schemas to published releases A new workflow exports the v2 schema and the combined v1 and v2 schema with pywr-cli and uploads them to the release that was published, so that each version has its schemas at a stable URL. --- .github/workflows/schema.yml | 39 ++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 .github/workflows/schema.yml diff --git a/.github/workflows/schema.yml b/.github/workflows/schema.yml new file mode 100644 index 00000000..331d9379 --- /dev/null +++ b/.github/workflows/schema.yml @@ -0,0 +1,39 @@ +name: JSON Schema + +# Attach the JSON Schemas for the model files to each published release, so that they have a stable, +# versioned URL for editors and other tools. +on: + release: + types: [ published ] + +permissions: + contents: write + +env: + CARGO_INCREMENTAL: 0 + CARGO_TERM_COLOR: always + +jobs: + schema: + name: Export and upload the JSON Schemas + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.release.tag_name }} + submodules: true + - uses: dtolnay/rust-toolchain@stable + - uses: swatinem/rust-cache@v2 + - name: "Set up Python" + uses: actions/setup-python@v7 + with: + python-version-file: "pyproject.toml" + - name: Export the schemas + run: | + cargo run --locked --release -p pywr-cli -- export-schema pywr-v2.schema.json + cargo run --locked --release -p pywr-cli -- export-schema pywr.schema.json --union + - name: Upload the schemas to the release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$TAG" pywr-v2.schema.json pywr.schema.json From 781ba4c7f5e85df228efccaf85e19b47870236bc Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 9 Oct 2026 10:18:05 +0100 Subject: [PATCH 05/11] refactor(schema): build the v1 and v2 union schema without panicking pywr_model_schema returns a Result with a JsonSchemaError instead of panicking when a schema generator produces an unexpected shape, and the CLI reports it with context. Both v2 document types are generated by one SchemaGenerator, so they share a single definition namespace and cannot number a clashing definition name differently. Root definitions are named from JsonSchema::schema_name rather than from the schema's title, which a doc comment heading or a schemars attribute can change. The help text of --union now says what the default export covers and how v1 custom types are treated. --- pywr-cli/src/main.rs | 5 +- pywr-schema/src/json_schema.rs | 188 ++++++++++++++++++++++----------- 2 files changed, 133 insertions(+), 60 deletions(-) diff --git a/pywr-cli/src/main.rs b/pywr-cli/src/main.rs index d49081e1..2e148e56 100644 --- a/pywr-cli/src/main.rs +++ b/pywr-cli/src/main.rs @@ -134,7 +134,9 @@ enum Commands { ExportSchema { /// Path to save the JSON schema. out: PathBuf, - /// Write one schema that covers both Pywr v1 and v2 model files. + /// Write one schema that covers Pywr v1 and v2 model files, single- and multi-network, instead + /// of the schema of a v2 single-network model only. Nodes and parameters of a v1 model that + /// match no core type are accepted as custom types, as the v1 deserialiser accepts them. #[arg(long)] union: bool, }, @@ -537,6 +539,7 @@ fn run_random(num_systems: usize, density: usize, num_scenarios: usize, solver: fn export_schema(out_path: &Path, union: bool) -> Result<()> { let schema = if union { pywr_schema::json_schema::pywr_model_schema(pywr_v1_schema::json_schema::CustomTypes::Any) + .with_context(|| "Failed to build the Pywr schema for v1 and v2 models".to_string())? } else { schema_for!(ModelSchema) }; diff --git a/pywr-schema/src/json_schema.rs b/pywr-schema/src/json_schema.rs index 7edbe673..ce7470fd 100644 --- a/pywr-schema/src/json_schema.rs +++ b/pywr-schema/src/json_schema.rs @@ -1,24 +1,53 @@ //! A single JSON Schema covering the Pywr v1 and v2 model file formats. +//! +//! Only whole model files are covered. A bare network file (a v2 `NetworkSchema` or a v1 network) +//! has no identifying property and so no entry in the union. use pywr_v1_schema::json_schema::{ CustomTypes, model_schema as v1_model_schema, multi_model_schema as v1_multi_model_schema, }; -use schemars::{JsonSchema, Schema, schema_for}; +use pywr_v1_schema::{PywrModel, PywrMultiModel}; +use schemars::{JsonSchema, Schema, SchemaGenerator}; use serde_json::{Map, Value, json}; +use thiserror::Error; const DEFS_POINTER: &str = "#/$defs/"; +/// Errors building the union schema. +/// +/// Each variant means that a schema generator produced a shape this module does not handle, for +/// example after a `schemars` upgrade. +#[derive(Debug, Error)] +pub enum JsonSchemaError { + #[error("the root schema of `{0}` is not a JSON object")] + RootNotAnObject(String), + #[error("the root schema of `{0}` has no definitions")] + RootWithoutDefinitions(String), + #[error("there is no definition for `{0}`")] + MissingDefinition(String), + #[error("unexpected `$ref` form: `{0}`")] + UnexpectedReference(String), + #[error("definition `{0}` differs between documents of one version")] + ConflictingDefinition(String), + #[error("the union is not a JSON object")] + UnionNotAnObject, +} + /// A document kind, identified by the property that only that kind has. struct Branch { - /// Prefix that namespaces the definitions of every document of one version. - prefix: &'static str, + /// The Pywr major version of the documents, as shown in the title. + version: &'static str, /// The property that identifies the document kind. key: &'static str, - schema: Schema, + /// The name of the definition that holds the root schema of the document, with its prefix. + root: String, } /// Prefix every `#/$defs/` reference in `value` with `prefix`. -fn prefix_refs(value: &mut Value, prefix: &str) { +/// +/// Every string under a `$ref` key is taken to be a reference, including one inside a literal such +/// as a `default`; none of the generated schemas has such a literal. +fn prefix_refs(value: &mut Value, prefix: &str) -> Result<(), JsonSchemaError> { match value { Value::Object(map) => { for (key, child) in map.iter_mut() { @@ -26,104 +55,144 @@ fn prefix_refs(value: &mut Value, prefix: &str) { Value::String(reference) if key == "$ref" => { let name = reference .strip_prefix(DEFS_POINTER) - .unwrap_or_else(|| panic!("unexpected $ref form: {reference}")); + .ok_or_else(|| JsonSchemaError::UnexpectedReference(reference.clone()))?; *reference = format!("{DEFS_POINTER}{prefix}{name}"); } - _ => prefix_refs(child, prefix), + _ => prefix_refs(child, prefix)?, } } } - Value::Array(items) => items.iter_mut().for_each(|item| prefix_refs(item, prefix)), + Value::Array(items) => { + for item in items { + prefix_refs(item, prefix)?; + } + } _ => {} } + Ok(()) } -/// Move the definitions of `schema` into `defs` under `prefix`, with its root as one more definition. +/// Move `definitions` into `defs` under `prefix`. /// -/// Returns the name of the definition that holds the root. Two documents of one version share -/// definitions, which must then be identical. -fn add_namespaced(defs: &mut Map, schema: Schema, prefix: &str) -> String { +/// Documents of one version share definitions, which must then be identical. +fn add_definitions( + defs: &mut Map, + definitions: Map, + prefix: &str, +) -> Result<(), JsonSchemaError> { + for (name, mut definition) in definitions { + prefix_refs(&mut definition, prefix)?; + let name = format!("{prefix}{name}"); + if let Some(existing) = defs.insert(name.clone(), definition.clone()) + && existing != definition + { + return Err(JsonSchemaError::ConflictingDefinition(name)); + } + } + Ok(()) +} + +/// Move the definitions of the root schema `schema` of `T` into `defs` under `prefix`, with the +/// root as one more definition, and return the name of that definition. +fn add_root_schema( + defs: &mut Map, + schema: Schema, + prefix: &str, +) -> Result { + let name = T::schema_name().into_owned(); let Value::Object(mut root) = schema.to_value() else { - panic!("a root schema is an object"); + return Err(JsonSchemaError::RootNotAnObject(name)); }; root.remove("$schema"); - let Some(Value::Object(definitions)) = root.remove("$defs") else { - panic!("a root schema has definitions"); - }; - let Some(Value::String(title)) = root.remove("title") else { - panic!("a root schema has a title"); + // `schemars` titles a root schema with the type name, which a definition of the same type + // (as it appears in a multi-model) does not carry; without this the two would differ. + root.remove("title"); + let Some(Value::Object(mut definitions)) = root.remove("$defs") else { + return Err(JsonSchemaError::RootWithoutDefinitions(name)); }; - - let root_name = format!("{prefix}{title}"); - let definitions = definitions - .into_iter() - .map(|(name, definition)| (format!("{prefix}{name}"), definition)) - .chain([(root_name.clone(), Value::Object(root))]); - for (name, mut definition) in definitions { - prefix_refs(&mut definition, prefix); - if let Some(existing) = defs.insert(name.clone(), definition.clone()) { - assert_eq!( - existing, definition, - "definition {name} differs between documents of one version" - ); - } - } - root_name + definitions.insert(name.clone(), Value::Object(root)); + add_definitions(defs, definitions, prefix)?; + Ok(format!("{prefix}{name}")) } -fn v2_schema() -> Schema { - schema_for!(T) +/// Add the v2 document types `ModelSchema` and `MultiNetworkModelSchema` to `defs`. +/// +/// One generator produces both, so they share one definition namespace by construction. +fn add_v2_documents(defs: &mut Map) -> Result<(String, String), JsonSchemaError> { + const PREFIX: &str = "v2_"; + let mut generator = SchemaGenerator::default(); + generator.subschema_for::(); + generator.subschema_for::(); + let definitions = generator.take_definitions(true); + + let roots = [ + crate::ModelSchema::schema_name().into_owned(), + crate::MultiNetworkModelSchema::schema_name().into_owned(), + ]; + if let Some(missing) = roots.iter().find(|name| !definitions.contains_key(*name)) { + return Err(JsonSchemaError::MissingDefinition(missing.clone())); + } + add_definitions(defs, definitions, PREFIX)?; + let [model, multi] = roots; + Ok((format!("{PREFIX}{model}"), format!("{PREFIX}{multi}"))) } /// The JSON Schema for any Pywr model file, v1 or v2. /// /// A document is one of four kinds, told apart by its identifying property: `nodes` (v1 model), -/// `models` (v1 multi-model), `network` (v2 model) or `networks` (v2 multi-network model). The -/// definitions of each version are namespaced `v1_` or `v2_` so that the two cannot collide, and -/// each kind forbids the other kinds' properties so that a file mixing them is rejected. +/// `models` (v1 multi-model), `network` (v2 model) or `networks` (v2 multi-network model). Each kind +/// forbids the other kinds' properties so that a file mixing them is rejected. The definitions of each version are +/// namespaced `v1_` or `v2_` so that the two cannot collide. /// /// `v1_custom_types` selects how the v1 schemas treat nodes and parameters that fail to match a /// core definition; see [`CustomTypes`]. -pub fn pywr_model_schema(v1_custom_types: CustomTypes) -> Schema { +/// +/// # Errors +/// +/// Returns an error if a schema generator produces a shape this function cannot namespace; see +/// [`JsonSchemaError`]. +pub fn pywr_model_schema(v1_custom_types: CustomTypes) -> Result { + let mut defs = Map::new(); + let v1_model = add_root_schema::(&mut defs, v1_model_schema(v1_custom_types), "v1_")?; + let v1_multi = add_root_schema::(&mut defs, v1_multi_model_schema(v1_custom_types), "v1_")?; + let (v2_model, v2_multi) = add_v2_documents(&mut defs)?; + let branches = [ Branch { - prefix: "v1_", + version: "v1", key: "nodes", - schema: v1_model_schema(v1_custom_types), + root: v1_model, }, Branch { - prefix: "v1_", + version: "v1", key: "models", - schema: v1_multi_model_schema(v1_custom_types), + root: v1_multi, }, Branch { - prefix: "v2_", + version: "v2", key: "network", - schema: v2_schema::(), + root: v2_model, }, Branch { - prefix: "v2_", + version: "v2", key: "networks", - schema: v2_schema::(), + root: v2_multi, }, ]; - let mut defs = Map::new(); - let keys: Vec<&str> = branches.iter().map(|branch| branch.key).collect(); let one_of: Vec = branches - .into_iter() + .iter() .map(|branch| { - let root = add_namespaced(&mut defs, branch.schema, branch.prefix); - let others: Map = keys + let others: Map = branches .iter() - .filter(|key| **key != branch.key) - .map(|key| (key.to_string(), Value::Bool(false))) + .filter(|other| other.key != branch.key) + .map(|other| (other.key.to_string(), Value::Bool(false))) .collect(); json!({ - "title": format!("Pywr {} document with `{}`", &branch.prefix[..2], branch.key), + "title": format!("Pywr {} document with `{}`", branch.version, branch.key), "required": [branch.key], "properties": others, - "$ref": format!("{DEFS_POINTER}{root}"), + "$ref": format!("{DEFS_POINTER}{}", branch.root), }) }) .collect(); @@ -135,7 +204,7 @@ pub fn pywr_model_schema(v1_custom_types: CustomTypes) -> Schema { "oneOf": one_of, "$defs": defs, }); - Schema::try_from(schema).expect("the union is a JSON object") + Schema::try_from(schema).map_err(|_| JsonSchemaError::UnionNotAnObject) } #[cfg(test)] @@ -152,7 +221,8 @@ mod tests { } fn validator(custom_types: CustomTypes) -> Validator { - let schema = serde_json::to_value(pywr_model_schema(custom_types)).expect("schema serialises"); + let schema = serde_json::to_value(pywr_model_schema(custom_types).expect("the union is built")) + .expect("schema serialises"); jsonschema::validator_for(&schema).expect("the union is a valid JSON Schema") } From e0d999774046c90b5dd9222a82787d523d8bbefb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 9 Oct 2026 10:21:50 +0100 Subject: [PATCH 06/11] fix(schema): report the errors of the matching kind from the union schema The union selected its document kind with a oneOf over four alternatives, so any invalid document failed with a bare "not valid under any of the given schemas" and the whole document echoed back. The kind is now chosen by an `if`/`then` on its identifying property, with a oneOf of bare `required` clauses enforcing that exactly one of them is present. The set of accepted documents is unchanged, and a document with a missing or mistyped field reports that field. The tests now reject invalid documents: none or two identifying properties, a missing `time` or `timestepper`, mistyped fields, an unknown v2 node type and a bad v1 edge. Each of the schema's parts is needed by one of them. A further test compares the v2 half of the union with the standalone v2 schema. --- pywr-schema/src/json_schema.rs | 248 ++++++++++++++++++++++++++++----- 1 file changed, 215 insertions(+), 33 deletions(-) diff --git a/pywr-schema/src/json_schema.rs b/pywr-schema/src/json_schema.rs index ce7470fd..783f6f41 100644 --- a/pywr-schema/src/json_schema.rs +++ b/pywr-schema/src/json_schema.rs @@ -140,8 +140,10 @@ fn add_v2_documents(defs: &mut Map) -> Result<(String, String), J /// The JSON Schema for any Pywr model file, v1 or v2. /// /// A document is one of four kinds, told apart by its identifying property: `nodes` (v1 model), -/// `models` (v1 multi-model), `network` (v2 model) or `networks` (v2 multi-network model). Each kind -/// forbids the other kinds' properties so that a file mixing them is rejected. The definitions of each version are +/// `models` (v1 multi-model), `network` (v2 model) or `networks` (v2 multi-network model). Exactly +/// one of those properties must be present. The schema of the kind that is present is applied +/// through an `if`/`then`, so that an invalid document reports the errors of its own kind rather +/// than a bare "not valid under any of the given schemas". The definitions of each version are /// namespaced `v1_` or `v2_` so that the two cannot collide. /// /// `v1_custom_types` selects how the v1 schemas treat nodes and parameters that fail to match a @@ -181,6 +183,15 @@ pub fn pywr_model_schema(v1_custom_types: CustomTypes) -> Result = branches + .iter() + .map(|branch| { + json!({ + "title": format!("Pywr {} document with `{}`", branch.version, branch.key), + "required": [branch.key], + }) + }) + .collect(); + let all_of: Vec = branches .iter() .map(|branch| { let others: Map = branches @@ -189,10 +200,11 @@ pub fn pywr_model_schema(v1_custom_types: CustomTypes) -> Result Result Result Value { + let schema = pywr_model_schema(custom_types).expect("the union is built"); + serde_json::to_value(schema).expect("schema serialises") + } + fn validator(custom_types: CustomTypes) -> Validator { - let schema = serde_json::to_value(pywr_model_schema(custom_types).expect("the union is built")) - .expect("schema serialises"); - jsonschema::validator_for(&schema).expect("the union is a valid JSON Schema") + jsonschema::validator_for(&union_schema(custom_types)).expect("the union is a valid JSON Schema") } fn read(path: &Path) -> Value { @@ -231,6 +249,10 @@ mod tests { serde_json::from_str(&data).unwrap_or_else(|e| panic!("parse {path:?}: {e}")) } + fn errors(validator: &Validator, document: &Value) -> Vec { + validator.iter_errors(document).map(|e| e.to_string()).collect() + } + /// The JSON files directly in `dir` that are model documents: those with a `time` property. fn model_files(dir: &Path) -> Vec { let mut files: Vec = fs::read_dir(dir) @@ -244,6 +266,46 @@ mod tests { files } + /// The v1 models used to test conversion: the files in `tests/v1` with a `nodes` property. + fn v1_model_files() -> Vec { + let dir = tests_dir().join("v1"); + let files: Vec = fs::read_dir(&dir) + .expect("v1 test models") + .map(|entry| entry.expect("directory entry").path()) + .filter(|path| read(path).get("nodes").is_some()) + .collect(); + assert!(!files.is_empty()); + files + } + + fn v2_model() -> Value { + read(&tests_dir().join("simple1.json")) + } + + fn v2_multi_network_model() -> Value { + read(&tests_dir().join("multi1").join("model.json")) + } + + fn v1_model() -> Value { + read(&tests_dir().join("v1").join("scenarios.json")) + } + + fn without(mut document: Value, pointer: &str) -> Value { + let (parent, key) = pointer.rsplit_once('/').expect("a pointer with a parent"); + document + .pointer_mut(parent) + .and_then(Value::as_object_mut) + .expect("the parent is an object") + .remove(key) + .expect("the key is present"); + document + } + + fn with(mut document: Value, pointer: &str, value: Value) -> Value { + *document.pointer_mut(pointer).expect("the pointer is present") = value; + document + } + #[test] fn v2_models_validate() { let validator = validator(CustomTypes::Any); @@ -251,47 +313,167 @@ mod tests { files.extend(model_files(&tests_dir().join("multi1"))); files.extend(model_files(&tests_dir().join("multi2"))); for path in files { - let errors: Vec = validator.iter_errors(&read(&path)).map(|e| e.to_string()).collect(); + let errors = errors(&validator, &read(&path)); assert!(errors.is_empty(), "{path:?}: {errors:?}"); } } - /// The v1 models used to test conversion are v1 documents, so only the v1 branch accepts them. #[test] fn v1_models_validate() { let validator = validator(CustomTypes::Any); - let dir = tests_dir().join("v1"); - let v1_models: Vec = fs::read_dir(&dir) - .expect("v1 test models") - .map(|entry| entry.expect("directory entry").path()) - .filter(|path| read(path).get("nodes").is_some()) - .collect(); - assert!(!v1_models.is_empty()); - for path in v1_models { - let errors: Vec = validator.iter_errors(&read(&path)).map(|e| e.to_string()).collect(); + for path in v1_model_files() { + let errors = errors(&validator, &read(&path)); assert!(errors.is_empty(), "{path:?}: {errors:?}"); } } + /// Documents without exactly one identifying property are rejected, whatever else they hold. #[test] - fn mixed_or_unidentified_documents_are_rejected() { + fn documents_without_exactly_one_identifying_property_are_rejected() { let validator = validator(CustomTypes::Any); - let v2 = read(&tests_dir().join("simple1.json")); - let v1 = read(&tests_dir().join("v1").join("scenarios.json")); - assert!(validator.is_valid(&v2) && validator.is_valid(&v1)); + let (v1, v2, multi) = (v1_model(), v2_model(), v2_multi_network_model()); + assert!(validator.is_valid(&v1) && validator.is_valid(&v2) && validator.is_valid(&multi)); + + let mut v2_with_nodes = v2.clone(); + v2_with_nodes["nodes"] = v1["nodes"].clone(); + let mut v1_with_network = v1.clone(); + v1_with_network["network"] = v2["network"].clone(); + let mut v1_with_models = v1.clone(); + v1_with_models["models"] = json!([]); + let mut v2_with_networks = v2.clone(); + v2_with_networks["networks"] = multi["networks"].clone(); + let mut multi_with_network = multi.clone(); + multi_with_network["network"] = v2["network"].clone(); + + let rejected = [ + ("an empty object", json!({})), + ("an array", json!([])), + ("a string", json!("x")), + ("no identifying property", without(v2.clone(), "/network")), + ("`nodes` and `network`", v2_with_nodes.clone()), + ("`network` and `nodes`", v1_with_network), + ("`nodes` and `models`", v1_with_models), + ("`network` and `networks`", v2_with_networks), + ("`networks` and `network`", multi_with_network), + ]; + for (name, document) in rejected { + assert!(!validator.is_valid(&document), "{name} must be rejected"); + } + + // A document with two identifying properties is reported against the one it is not allowed to have. + let mixed = errors(&validator, &v2_with_nodes); + assert!(mixed.iter().any(|e| e.contains("False schema")), "{mixed:?}"); + } - let mut both = v2.clone(); - both["nodes"] = v1["nodes"].clone(); + /// An invalid document is checked against the schema of its own kind, and the errors say what is + /// wrong with it. + #[test] + fn invalid_documents_are_rejected_by_their_own_kind() { + let validator = validator(CustomTypes::Any); + + let v2_without_time = errors(&validator, &without(v2_model(), "/time")); assert!( - !validator.is_valid(&both), - "`nodes` and `network` together must be rejected" + v2_without_time + .iter() + .any(|e| e.contains("\"time\" is a required property")), + "{v2_without_time:?}" ); - let mut neither = v2; - neither.as_object_mut().expect("a model is an object").remove("network"); + let v1_without_timestepper = errors(&validator, &without(v1_model(), "/timestepper")); assert!( - !validator.is_valid(&neither), - "a document with no identifying property must be rejected" + v1_without_timestepper + .iter() + .any(|e| e.contains("\"timestepper\" is a required property")), + "{v1_without_timestepper:?}" ); + + let multi_without_time = errors(&validator, &without(v2_multi_network_model(), "/time")); + assert!( + multi_without_time + .iter() + .any(|e| e.contains("\"time\" is a required property")), + "{multi_without_time:?}" + ); + + let invalid = [ + ( + "v2 model, unknown node type", + with(v2_model(), "/network/nodes/0/type", json!("Bogus")), + ), + ( + "v2 model, `network` is a number", + with(v2_model(), "/network", json!(5)), + ), + ("v2 model, `time` is a number", with(v2_model(), "/time", json!(5))), + ( + "v2 multi-network model, entry `network` is a number", + with(v2_multi_network_model(), "/networks/0/network", json!(5)), + ), + ("v1 model, an edge is a number", with(v1_model(), "/edges/0", json!(5))), + ("v1 model, `nodes` is a string", with(v1_model(), "/nodes", json!("x"))), + ]; + for (name, document) in invalid { + assert!(!validator.is_valid(&document), "{name} must be rejected"); + } + } + + /// A node of an unknown type is a custom node to the v1 deserialisers, so a v1 model with one is valid. + #[test] + fn v1_unknown_node_types_are_custom_nodes() { + let document = with(v1_model(), "/nodes/0/type", json!("Bogus")); + assert!(validator(CustomTypes::Any).is_valid(&document)); + } + + /// The v2 half of the union is the standalone v2 schema, with every definition under the `v2_` prefix. + #[test] + fn v2_definitions_match_the_standalone_schema() { + let union = union_schema(CustomTypes::Any); + let union_defs = union["$defs"].as_object().expect("the union has definitions"); + + for (root, mut standalone) in [ + ( + ModelSchema::schema_name(), + serde_json::to_value(schema_for!(ModelSchema)), + ), + ( + MultiNetworkModelSchema::schema_name(), + serde_json::to_value(schema_for!(MultiNetworkModelSchema)), + ), + ] + .map(|(name, schema)| (name, schema.expect("schema serialises"))) + { + let standalone = standalone.as_object_mut().expect("a root schema is an object"); + standalone.remove("$schema"); + standalone.remove("title"); + let definitions = standalone.remove("$defs").expect("a root schema has definitions"); + let definitions = definitions.as_object().expect("definitions are an object"); + for (name, definition) in definitions { + let unioned = union_defs + .get(&format!("v2_{name}")) + .expect("definition is in the union"); + assert_eq!(&strip_prefix(unioned, "v2_"), definition, "definition {name}"); + } + let unioned_root = union_defs.get(&format!("v2_{root}")).expect("the root is in the union"); + assert_eq!(strip_prefix(unioned_root, "v2_"), Value::Object(standalone.clone())); + } + } + + /// Undo the namespacing of `$ref`s. + fn strip_prefix(value: &Value, prefix: &str) -> Value { + match value { + Value::Object(map) => Value::Object( + map.iter() + .map(|(key, child)| match child { + Value::String(reference) if key == "$ref" => ( + key.clone(), + Value::String(reference.replacen(&format!("#/$defs/{prefix}"), "#/$defs/", 1)), + ), + _ => (key.clone(), strip_prefix(child, prefix)), + }) + .collect(), + ), + Value::Array(items) => Value::Array(items.iter().map(|item| strip_prefix(item, prefix)).collect()), + other => other.clone(), + } } } From 4205750bda006903c628d6187d6adb44ad18bf8d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 9 Oct 2026 10:21:55 +0100 Subject: [PATCH 07/11] chore: keep the end of Cargo.toml unchanged --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 6aa6df19..c966db79 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,4 +110,4 @@ opt-level = 3 [profile.profiling] inherits = "release" -debug = "line-tables-only" +debug = "line-tables-only" \ No newline at end of file From bc32d7f1f2cc49d61c592a6b03919b17c5101c80 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 9 Oct 2026 10:22:00 +0100 Subject: [PATCH 08/11] ci(release): replace existing schema assets when the upload job re-runs gh release upload fails on an asset that already exists, so re-running the job on a release failed. With --clobber the assets are replaced. --- .github/workflows/schema.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/schema.yml b/.github/workflows/schema.yml index 331d9379..658b0924 100644 --- a/.github/workflows/schema.yml +++ b/.github/workflows/schema.yml @@ -1,7 +1,8 @@ name: JSON Schema -# Attach the JSON Schemas for the model files to each published release, so that they have a stable, -# versioned URL for editors and other tools. +# Attach the JSON Schemas for the model files to each published release, so that they have a +# versioned URL for editors and other tools. The upload replaces assets of the same name, so a +# re-run of the job on a release is safe. on: release: types: [ published ] @@ -36,4 +37,4 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} TAG: ${{ github.event.release.tag_name }} - run: gh release upload "$TAG" pywr-v2.schema.json pywr.schema.json + run: gh release upload --clobber "$TAG" pywr-v2.schema.json pywr.schema.json From 022068865cf5d495796043fea5b2b28715a45e2e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 9 Oct 2026 10:22:28 +0100 Subject: [PATCH 09/11] build: lock the jsonschema dev-dependency of pywr-schema CI builds with --locked, which fails when the lock file lacks the entries for a dependency the manifest declares. --- Cargo.lock | 214 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 56ca7afb..b3fc0c50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,6 +23,7 @@ dependencies = [ "const-random", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -60,6 +61,12 @@ dependencies = [ "cc", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -442,6 +449,21 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -463,6 +485,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "brotli" version = "9.0.0" @@ -490,6 +518,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.2" @@ -837,6 +871,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "defmt" version = "1.1.1" @@ -906,6 +946,15 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "enum_primitive" version = "0.1.1" @@ -954,6 +1003,17 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fancy-regex" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -994,6 +1054,33 @@ dependencies = [ "num-traits 0.2.19", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint 0.4.8", +] + [[package]] name = "futures" version = "0.1.31" @@ -1042,9 +1129,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1108,6 +1197,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] [[package]] name = "hdf5-metno" @@ -1377,6 +1471,59 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits 0.2.19", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom 0.3.4", + "num-cmp", + "num-traits 0.2.19", + "serde_json", + "zmij", +] + [[package]] name = "lexical-core" version = "1.0.6" @@ -1535,6 +1682,12 @@ dependencies = [ "web-time", ] +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -1647,6 +1800,12 @@ dependencies = [ "num-traits 0.2.19", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + [[package]] name = "num-complex" version = "0.4.6" @@ -1762,6 +1921,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "page_size" version = "0.6.0" @@ -1832,6 +1997,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -2144,6 +2315,7 @@ dependencies = [ "hdf5-metno", "hex", "jiff", + "jsonschema", "log", "md-5", "ndarray", @@ -2326,6 +2498,23 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "referencing" +version = "0.58.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.13.1" @@ -2602,6 +2791,9 @@ name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] [[package]] name = "strum_macros" @@ -2757,6 +2949,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.26" @@ -2781,6 +2979,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -2793,6 +3001,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" From 5375bc808f8d58aca394c92296400e3cc5dcac6a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 9 Oct 2026 10:35:12 +0100 Subject: [PATCH 10/11] build: depend on the pywr-v1-schema fork until a release carries json_schema Fork-only: pywr/pywr-v1-schema#300 adds the json_schema module this branch uses, so the published crate cannot build it yet. Drop this commit once that release exists. --- Cargo.lock | 7 +++---- Cargo.toml | 5 ++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3fc0c50..8a3d60e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2347,11 +2347,11 @@ dependencies = [ [[package]] name = "pywr-v1-schema" version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537f27adb2c17afb091174b4af4bec3b1ca80cc410ea4b1642e7e9eb647f8988" +source = "git+https://github.com/Nexsys-Analytics/pywr-v1-schema?branch=feat%2Fjson-schema-export#21d2c58ae5097fc3b2788be97b447324efeee447" dependencies = [ "jiff", "pywr-v1-schema-macros", + "schemars", "serde", "serde_json", "strum", @@ -2362,8 +2362,7 @@ dependencies = [ [[package]] name = "pywr-v1-schema-macros" version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69190f1973029b7ae66802982c6e7bb33ce1937be110306b18f728e51c6f777e" +source = "git+https://github.com/Nexsys-Analytics/pywr-v1-schema?branch=feat%2Fjson-schema-export#21d2c58ae5097fc3b2788be97b447324efeee447" dependencies = [ "quote", "syn 3.0.6", diff --git a/Cargo.toml b/Cargo.toml index c966db79..5fd7d658 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,4 +110,7 @@ opt-level = 3 [profile.profiling] inherits = "release" -debug = "line-tables-only" \ No newline at end of file +debug = "line-tables-only" +# Fork-only: build against the unreleased json_schema module of pywr-v1-schema until a release carries it (pywr/pywr-v1-schema#300). +[patch.crates-io] +pywr-v1-schema = { git = "https://github.com/Nexsys-Analytics/pywr-v1-schema", branch = "feat/json-schema-export" } From c5a7f705bf598b95f0d2321c7e141a53178c4fbb Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 9 Oct 2026 10:38:28 +0100 Subject: [PATCH 11/11] build: update the pywr-v1-schema fork pin Fork-only, with the previous commit. --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a3d60e9..9922af49 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2347,7 +2347,7 @@ dependencies = [ [[package]] name = "pywr-v1-schema" version = "0.25.0" -source = "git+https://github.com/Nexsys-Analytics/pywr-v1-schema?branch=feat%2Fjson-schema-export#21d2c58ae5097fc3b2788be97b447324efeee447" +source = "git+https://github.com/Nexsys-Analytics/pywr-v1-schema?branch=feat%2Fjson-schema-export#818f908ae2a369df70574a3c7be55c91c29ab706" dependencies = [ "jiff", "pywr-v1-schema-macros", @@ -2362,7 +2362,7 @@ dependencies = [ [[package]] name = "pywr-v1-schema-macros" version = "0.25.0" -source = "git+https://github.com/Nexsys-Analytics/pywr-v1-schema?branch=feat%2Fjson-schema-export#21d2c58ae5097fc3b2788be97b447324efeee447" +source = "git+https://github.com/Nexsys-Analytics/pywr-v1-schema?branch=feat%2Fjson-schema-export#818f908ae2a369df70574a3c7be55c91c29ab706" dependencies = [ "quote", "syn 3.0.6",