ofd-validator 0.5.0__tar.gz → 0.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/Cargo.lock +4 -3
  2. ofd_validator-0.5.2/Cargo.toml +3 -0
  3. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/PKG-INFO +1 -1
  4. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/Cargo.toml +2 -1
  5. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/orchestrator.rs +251 -0
  6. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/util.rs +7 -0
  7. ofd_validator-0.5.2/crates/ofd-validator-core/src/validators/duplicate_link.rs +138 -0
  8. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/mod.rs +8 -0
  9. ofd_validator-0.5.2/crates/ofd-validator-core/src/validators/store_host.rs +138 -0
  10. ofd_validator-0.5.2/crates/ofd-validator-core/src/validators/url_tracker.rs +229 -0
  11. ofd_validator-0.5.2/crates/ofd-validator-core/src/validators/variant_material.rs +120 -0
  12. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-python/Cargo.toml +1 -1
  13. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-python/src/lib.rs +3 -1
  14. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-python/src/orchestrator.rs +35 -0
  15. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/pyproject.toml +1 -1
  16. ofd_validator-0.5.0/Cargo.toml +0 -3
  17. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/README.md +0 -0
  18. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/lib.rs +0 -0
  19. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/schema_cache.rs +0 -0
  20. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/types.rs +0 -0
  21. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/folder_name.rs +0 -0
  22. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/gtin.rs +0 -0
  23. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/json_validator.rs +0 -0
  24. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/logo_validator.rs +0 -0
  25. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/missing_files.rs +0 -0
  26. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-core/src/validators/store_id.rs +0 -0
  27. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-python/src/types.rs +0 -0
  28. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-python/src/util.rs +0 -0
  29. {ofd_validator-0.5.0 → ofd_validator-0.5.2}/crates/ofd-validator-python/src/validators.rs +0 -0
@@ -805,7 +805,7 @@ dependencies = [
805
805
 
806
806
  [[package]]
807
807
  name = "ofd-validator-core"
808
- version = "0.5.0"
808
+ version = "0.5.2"
809
809
  dependencies = [
810
810
  "image",
811
811
  "jsonschema",
@@ -813,12 +813,13 @@ dependencies = [
813
813
  "regex",
814
814
  "serde",
815
815
  "serde_json",
816
+ "url",
816
817
  "walkdir",
817
818
  ]
818
819
 
819
820
  [[package]]
820
821
  name = "ofd-validator-js"
821
- version = "0.5.0"
822
+ version = "0.5.2"
822
823
  dependencies = [
823
824
  "napi",
824
825
  "napi-build",
@@ -831,7 +832,7 @@ dependencies = [
831
832
 
832
833
  [[package]]
833
834
  name = "ofd-validator-python"
834
- version = "0.5.0"
835
+ version = "0.5.2"
835
836
  dependencies = [
836
837
  "ofd-validator-core",
837
838
  "pyo3",
@@ -0,0 +1,3 @@
1
+ [workspace]
2
+ members = ["crates/ofd-validator-core", "crates/ofd-validator-python"]
3
+ resolver = "2"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ofd-validator
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: License :: OSI Approved :: MIT License
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ofd-validator-core"
3
- version = "0.5.0"
3
+ version = "0.5.2"
4
4
  edition = "2021"
5
5
  authors = ["Open Filament Collective"]
6
6
  license = "MIT"
@@ -11,6 +11,7 @@ serde = { version = "1", features = ["derive"] }
11
11
  serde_json = "1"
12
12
  jsonschema = "0.28"
13
13
  regex = "1"
14
+ url = "2"
14
15
  image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
15
16
  rayon = "1"
16
17
  walkdir = { version = "2", optional = true }
@@ -1,4 +1,6 @@
1
1
  use std::collections::HashSet;
2
+ use std::path::Path;
3
+ use serde::Deserialize;
2
4
  use serde_json::Value;
3
5
  use rayon::prelude::*;
4
6
 
@@ -26,6 +28,220 @@ pub struct DataSet {
26
28
  pub schema_cache: SchemaCache,
27
29
  }
28
30
 
31
+ // --- Types for applying pending changes from the WebUI ---
32
+
33
+ #[derive(Deserialize)]
34
+ struct ChangesPayload {
35
+ changes: Vec<ChangeEntry>,
36
+ }
37
+
38
+ #[derive(Deserialize)]
39
+ struct ChangeEntry {
40
+ entity: EntityRef,
41
+ operation: String,
42
+ data: Option<Value>,
43
+ }
44
+
45
+ #[derive(Deserialize)]
46
+ struct EntityRef {
47
+ path: String,
48
+ #[serde(rename = "type")]
49
+ entity_type: String,
50
+ }
51
+
52
+ /// Internal fields added by the WebUI that should be stripped before validation.
53
+ const STRIP_FIELDS: &[&str] = &[
54
+ "brandId", "brand_id", "materialType", "filamentDir", "filament_id", "slug",
55
+ ];
56
+
57
+ /// Info needed to overlay a change onto the DataSet.
58
+ struct EntityFsInfo {
59
+ json_path: String,
60
+ folder_path: String,
61
+ folder_name: String,
62
+ schema_name: String,
63
+ folder_json_key: String,
64
+ }
65
+
66
+ /// Map a WebUI entity path to filesystem paths and metadata.
67
+ fn entity_path_to_fs_info(
68
+ entity_path: &str,
69
+ entity_type: &str,
70
+ data_dir: &Path,
71
+ stores_dir: &Path,
72
+ ) -> Option<EntityFsInfo> {
73
+ let parts: Vec<&str> = entity_path.split('/').collect();
74
+
75
+ let (json_path, folder_path, folder_name, folder_json_key) = match parts.as_slice() {
76
+ ["stores", slug] => (
77
+ stores_dir.join(slug).join("store.json"),
78
+ stores_dir.join(slug),
79
+ slug.to_string(),
80
+ "id".to_string(),
81
+ ),
82
+ ["brands", slug] => (
83
+ data_dir.join(slug).join("brand.json"),
84
+ data_dir.join(slug),
85
+ slug.to_string(),
86
+ "id".to_string(),
87
+ ),
88
+ ["brands", slug, "materials", mat_type] => (
89
+ data_dir.join(slug).join(mat_type).join("material.json"),
90
+ data_dir.join(slug).join(mat_type),
91
+ mat_type.to_string(),
92
+ "material".to_string(),
93
+ ),
94
+ ["brands", slug, "materials", mat_type, "filaments", name] => (
95
+ data_dir.join(slug).join(mat_type).join(name).join("filament.json"),
96
+ data_dir.join(slug).join(mat_type).join(name),
97
+ name.to_string(),
98
+ "id".to_string(),
99
+ ),
100
+ ["brands", slug, "materials", mat_type, "filaments", name, "variants", variant] => (
101
+ data_dir.join(slug).join(mat_type).join(name).join(variant).join("variant.json"),
102
+ data_dir.join(slug).join(mat_type).join(name).join(variant),
103
+ variant.to_string(),
104
+ "id".to_string(),
105
+ ),
106
+ _ => return None,
107
+ };
108
+
109
+ Some(EntityFsInfo {
110
+ json_path: json_path.to_string_lossy().to_string(),
111
+ folder_path: folder_path.to_string_lossy().to_string(),
112
+ folder_name,
113
+ schema_name: entity_type.to_string(),
114
+ folder_json_key,
115
+ })
116
+ }
117
+
118
+ /// Strip internal WebUI tracking fields and empty strings from entity data.
119
+ fn clean_change_data(data: &Value) -> Value {
120
+ if let Value::Object(map) = data {
121
+ let mut clean = serde_json::Map::new();
122
+ for (key, value) in map {
123
+ if STRIP_FIELDS.contains(&key.as_str()) {
124
+ continue;
125
+ }
126
+ if value == &Value::String(String::new()) {
127
+ continue;
128
+ }
129
+ clean.insert(key.clone(), value.clone());
130
+ }
131
+ Value::Object(clean)
132
+ } else {
133
+ data.clone()
134
+ }
135
+ }
136
+
137
+ impl DataSet {
138
+ /// Apply pending changes from the WebUI onto the in-memory dataset.
139
+ ///
140
+ /// `changes_json` should be a JSON string with the format:
141
+ /// `{ "changes": [{ "entity": { "path": "...", "type": "..." }, "operation": "create"|"update"|"delete", "data": {...} }] }`
142
+ pub fn apply_changes(&mut self, changes_json: &str, data_dir: &Path, stores_dir: &Path) {
143
+ let payload: ChangesPayload = match serde_json::from_str(changes_json) {
144
+ Ok(p) => p,
145
+ Err(e) => {
146
+ eprintln!("Warning: failed to parse changes JSON: {}", e);
147
+ return;
148
+ }
149
+ };
150
+
151
+ for change in &payload.changes {
152
+ let info = match entity_path_to_fs_info(
153
+ &change.entity.path,
154
+ &change.entity.entity_type,
155
+ data_dir,
156
+ stores_dir,
157
+ ) {
158
+ Some(i) => i,
159
+ None => continue,
160
+ };
161
+
162
+ match change.operation.as_str() {
163
+ "create" | "update" => {
164
+ let data = match &change.data {
165
+ Some(d) => clean_change_data(d),
166
+ None => continue,
167
+ };
168
+
169
+ // Update or add json_entries
170
+ if let Some(existing) = self.json_entries.iter_mut().find(|(p, _, _)| *p == info.json_path) {
171
+ existing.2 = data.clone();
172
+ } else {
173
+ self.json_entries.push((
174
+ info.json_path.clone(),
175
+ info.schema_name.clone(),
176
+ data.clone(),
177
+ ));
178
+ }
179
+
180
+ // Update or add folder_entries
181
+ if let Some(existing) = self.folder_entries.iter_mut().find(|(p, _, _, _)| *p == info.folder_path) {
182
+ existing.2 = data.clone();
183
+ } else {
184
+ self.folder_entries.push((
185
+ info.folder_path.clone(),
186
+ info.folder_name.clone(),
187
+ data.clone(),
188
+ info.folder_json_key.clone(),
189
+ ));
190
+ }
191
+
192
+ // For stores: update valid_store_ids
193
+ if info.schema_name == "store" {
194
+ if let Some(id) = data.get("id").and_then(|v| v.as_str()) {
195
+ self.valid_store_ids.insert(id.to_string());
196
+ }
197
+ }
198
+
199
+ // For variants with sizes: update sizes_entries
200
+ if info.schema_name == "variant" {
201
+ if let Some(sizes) = data.get("sizes") {
202
+ let sizes_path = Path::new(&info.folder_path)
203
+ .join("sizes.json")
204
+ .to_string_lossy()
205
+ .to_string();
206
+ if let Some(existing) = self.sizes_entries.iter_mut().find(|(p, _)| *p == sizes_path) {
207
+ existing.1 = sizes.clone();
208
+ } else {
209
+ self.sizes_entries.push((sizes_path, sizes.clone()));
210
+ }
211
+ }
212
+ }
213
+ }
214
+ "delete" => {
215
+ // Remove from json_entries
216
+ self.json_entries.retain(|(p, _, _)| *p != info.json_path);
217
+ // Remove from folder_entries
218
+ self.folder_entries.retain(|(p, _, _, _)| *p != info.folder_path);
219
+ // Remove logo entries for this folder
220
+ self.logo_entries.retain(|(p, _, _, _)| !p.starts_with(&info.folder_path));
221
+ // Remove sizes entries under this folder
222
+ self.sizes_entries.retain(|(p, _)| !p.starts_with(&info.folder_path));
223
+
224
+ // For stores: remove from valid_store_ids
225
+ if info.schema_name == "store" {
226
+ // Extract the ID from the path (last component)
227
+ if let Some(slug) = change.entity.path.split('/').last() {
228
+ self.valid_store_ids.remove(slug);
229
+ }
230
+ }
231
+
232
+ // Also remove any child entries (e.g., deleting a brand removes its materials/filaments/variants)
233
+ let prefix = format!("{}/", info.folder_path);
234
+ self.json_entries.retain(|(p, _, _)| !p.starts_with(&prefix));
235
+ self.folder_entries.retain(|(p, _, _, _)| !p.starts_with(&prefix));
236
+ self.logo_entries.retain(|(p, _, _, _)| !p.starts_with(&prefix));
237
+ self.sizes_entries.retain(|(p, _)| !p.starts_with(&prefix));
238
+ }
239
+ _ => {}
240
+ }
241
+ }
242
+ }
243
+ }
244
+
29
245
  #[cfg(feature = "filesystem")]
30
246
  impl DataSet {
31
247
  /// Build a DataSet by walking the filesystem.
@@ -356,5 +572,40 @@ pub fn validate_dataset(dataset: &DataSet) -> ValidationResult {
356
572
  // 6. GTIN/EAN validation
357
573
  result.merge_from(&validators::validate_gtin_ean(&sizes_refs));
358
574
 
575
+ // 7. URL tracking parameters in purchase links (warnings)
576
+ result.merge_from(&validators::validate_url_tracking(&sizes_refs));
577
+
578
+ // 8. Variant colour-name / folder-id material-type redundancy (warnings)
579
+ let material_types: HashSet<String> = dataset
580
+ .schema_cache
581
+ .get("material_types")
582
+ .and_then(|s| s.get("enum"))
583
+ .and_then(|e| e.as_array())
584
+ .map(|arr| {
585
+ arr.iter()
586
+ .filter_map(|v| v.as_str().map(|s| s.to_ascii_uppercase()))
587
+ .collect()
588
+ })
589
+ .unwrap_or_else(|| {
590
+ validators::variant_material::MATERIAL_TYPES_FALLBACK
591
+ .iter()
592
+ .map(|s| s.to_string())
593
+ .collect()
594
+ });
595
+ let variant_refs: Vec<(&str, &Value)> = dataset
596
+ .json_entries
597
+ .iter()
598
+ .filter(|(_, schema_name, _)| schema_name == "variant")
599
+ .map(|(p, _, v)| (p.as_str(), v))
600
+ .collect();
601
+ result.merge_from(&validators::validate_variant_material(&variant_refs, &material_types));
602
+
603
+ // 9. Purchase-link host consistency vs the store's storefront (warnings)
604
+ let store_hosts = validators::store_host::build_store_hosts(&dataset.json_entries);
605
+ result.merge_from(&validators::validate_store_host(&store_hosts, &sizes_refs));
606
+
607
+ // 10. Generic purchase link reused across many variants of a filament (warnings)
608
+ result.merge_from(&validators::validate_duplicate_links(&sizes_refs));
609
+
359
610
  result
360
611
  }
@@ -16,6 +16,13 @@ pub fn cleanse_folder_name(name: &str) -> String {
16
16
  name.replace('/', " ").trim().to_string()
17
17
  }
18
18
 
19
+ /// Extract the host (e.g. `shop.polymaker.com`) from an absolute URL, if parseable.
20
+ pub fn url_host(url: &str) -> Option<String> {
21
+ url::Url::parse(url)
22
+ .ok()
23
+ .and_then(|u| u.host_str().map(|h| h.to_string()))
24
+ }
25
+
19
26
  #[cfg(feature = "filesystem")]
20
27
  pub fn load_json(path: &std::path::Path) -> Option<Value> {
21
28
  let content = std::fs::read_to_string(path).ok()?;
@@ -0,0 +1,138 @@
1
+ use serde_json::Value;
2
+ use std::collections::{BTreeSet, HashMap};
3
+
4
+ use crate::types::{ValidationError, ValidationResult};
5
+
6
+ /// Minimum number of distinct variants sharing one URL before we warn.
7
+ const MIN_VARIANTS: usize = 3;
8
+
9
+ /// From a sizes.json path label `.../<brand>/<MAT>/<filament>/<variant>/sizes.json`,
10
+ /// return `(filament_key, variant_folder)` = (`.../<brand>/<MAT>/<filament>`, `<variant>`).
11
+ fn filament_key(path: &str) -> Option<(String, String)> {
12
+ let norm = path.replace('\\', "/");
13
+ let parts: Vec<&str> = norm.split('/').collect();
14
+ if parts.len() < 3 {
15
+ return None;
16
+ }
17
+ let variant = parts[parts.len() - 2].to_string();
18
+ let key = parts[..parts.len() - 2].join("/");
19
+ Some((key, variant))
20
+ }
21
+
22
+ /// Warn when the same purchase URL is reused across many variants of one filament (a generic
23
+ /// link copied to every colour). Non-blocking; one warning per (filament, url).
24
+ pub fn validate_duplicate_links(sizes_entries: &[(&str, &Value)]) -> ValidationResult {
25
+ let mut result = ValidationResult::default();
26
+
27
+ // filament_key -> url -> set of variant folder names
28
+ let mut map: HashMap<String, HashMap<String, BTreeSet<String>>> = HashMap::new();
29
+
30
+ for (path_str, sizes_data) in sizes_entries {
31
+ let (fkey, variant) = match filament_key(path_str) {
32
+ Some(v) => v,
33
+ None => continue,
34
+ };
35
+ let sizes_arr = match sizes_data.as_array() {
36
+ Some(a) => a,
37
+ None => continue,
38
+ };
39
+ for size in sizes_arr {
40
+ let purchase_links = match size.get("purchase_links").and_then(|v| v.as_array()) {
41
+ Some(l) => l,
42
+ None => continue,
43
+ };
44
+ for link in purchase_links {
45
+ if let Some(url) = link.get("url").and_then(|v| v.as_str()) {
46
+ map.entry(fkey.clone())
47
+ .or_default()
48
+ .entry(url.to_string())
49
+ .or_default()
50
+ .insert(variant.clone());
51
+ }
52
+ }
53
+ }
54
+ }
55
+
56
+ // Deterministic output ordering.
57
+ let mut filaments: Vec<&String> = map.keys().collect();
58
+ filaments.sort();
59
+ for fkey in filaments {
60
+ let urls = &map[fkey];
61
+ let mut url_keys: Vec<&String> = urls.keys().collect();
62
+ url_keys.sort();
63
+ for url in url_keys {
64
+ let variants = &urls[url];
65
+ if variants.len() >= MIN_VARIANTS {
66
+ result.add(ValidationError::warning(
67
+ "DuplicateLink",
68
+ format!(
69
+ "The same purchase url is reused across {} variants of this filament: {}",
70
+ variants.len(),
71
+ url
72
+ ),
73
+ Some(format!("{}/*/sizes.json", fkey)),
74
+ ));
75
+ }
76
+ }
77
+ }
78
+
79
+ result
80
+ }
81
+
82
+ #[cfg(test)]
83
+ mod tests {
84
+ use super::*;
85
+ use serde_json::json;
86
+
87
+ fn sized(url: &str) -> Value {
88
+ json!([{
89
+ "filament_weight": 1000, "diameter": 1.75,
90
+ "purchase_links": [{ "store_id": "s", "url": url }]
91
+ }])
92
+ }
93
+
94
+ #[test]
95
+ fn flags_generic_link_reused_across_variants() {
96
+ let base = "data/b/PLA/snapspeed_pla";
97
+ let a = sized("https://a.com/generic");
98
+ let b = sized("https://a.com/generic");
99
+ let c = sized("https://a.com/generic");
100
+ let entries = vec![
101
+ (format!("{base}/red/sizes.json"), &a),
102
+ (format!("{base}/blue/sizes.json"), &b),
103
+ (format!("{base}/green/sizes.json"), &c),
104
+ ];
105
+ let refs: Vec<(&str, &Value)> = entries.iter().map(|(p, v)| (p.as_str(), *v)).collect();
106
+ let res = validate_duplicate_links(&refs);
107
+ assert_eq!(res.warning_count(), 1);
108
+ assert!(res.is_valid());
109
+ }
110
+
111
+ #[test]
112
+ fn distinct_links_pass() {
113
+ let base = "data/b/PLA/f";
114
+ let a = sized("https://a.com/1");
115
+ let b = sized("https://a.com/2");
116
+ let c = sized("https://a.com/3");
117
+ let entries = vec![
118
+ (format!("{base}/red/sizes.json"), &a),
119
+ (format!("{base}/blue/sizes.json"), &b),
120
+ (format!("{base}/green/sizes.json"), &c),
121
+ ];
122
+ let refs: Vec<(&str, &Value)> = entries.iter().map(|(p, v)| (p.as_str(), *v)).collect();
123
+ assert_eq!(validate_duplicate_links(&refs).warning_count(), 0);
124
+ }
125
+
126
+ #[test]
127
+ fn same_url_across_two_variants_below_threshold() {
128
+ let base = "data/b/PLA/f";
129
+ let a = sized("https://a.com/x");
130
+ let b = sized("https://a.com/x");
131
+ let entries = vec![
132
+ (format!("{base}/red/sizes.json"), &a),
133
+ (format!("{base}/blue/sizes.json"), &b),
134
+ ];
135
+ let refs: Vec<(&str, &Value)> = entries.iter().map(|(p, v)| (p.as_str(), *v)).collect();
136
+ assert_eq!(validate_duplicate_links(&refs).warning_count(), 0);
137
+ }
138
+ }
@@ -1,13 +1,21 @@
1
+ mod duplicate_link;
1
2
  mod folder_name;
2
3
  mod gtin;
3
4
  mod json_validator;
4
5
  mod logo_validator;
5
6
  pub mod missing_files;
7
+ pub mod store_host;
6
8
  mod store_id;
9
+ mod url_tracker;
10
+ pub mod variant_material;
7
11
 
12
+ pub use duplicate_link::validate_duplicate_links;
8
13
  pub use folder_name::validate_folder_name;
9
14
  pub use gtin::validate_gtin_ean;
10
15
  pub use json_validator::validate_json;
11
16
  pub use logo_validator::validate_logo;
12
17
  pub use missing_files::validate_required_files;
18
+ pub use store_host::validate_store_host;
13
19
  pub use store_id::validate_store_ids;
20
+ pub use url_tracker::validate_url_tracking;
21
+ pub use variant_material::validate_variant_material;
@@ -0,0 +1,138 @@
1
+ use serde_json::Value;
2
+ use std::collections::HashMap;
3
+
4
+ use crate::types::{ValidationError, ValidationResult};
5
+ use crate::util::url_host;
6
+
7
+ /// Build a map of `store_id -> canonical host` from store.json entries.
8
+ /// `json_entries` are (path_label, schema_name, Value).
9
+ pub fn build_store_hosts(json_entries: &[(String, String, Value)]) -> HashMap<String, String> {
10
+ let mut map = HashMap::new();
11
+ for (_, schema_name, data) in json_entries {
12
+ if schema_name != "store" {
13
+ continue;
14
+ }
15
+ let id = data.get("id").and_then(|v| v.as_str());
16
+ let host = data
17
+ .get("storefront_url")
18
+ .and_then(|v| v.as_str())
19
+ .and_then(url_host);
20
+ if let (Some(id), Some(host)) = (id, host) {
21
+ map.insert(id.to_string(), host);
22
+ }
23
+ }
24
+ map
25
+ }
26
+
27
+ /// The registrable-ish base domain: the last two dot-separated labels.
28
+ /// `shop.polymaker.com` and `us.polymaker.com` both -> `polymaker.com`.
29
+ fn base_domain(host: &str) -> String {
30
+ let labels: Vec<&str> = host.split('.').collect();
31
+ let n = labels.len();
32
+ if n >= 2 {
33
+ labels[n - 2..].join(".")
34
+ } else {
35
+ host.to_string()
36
+ }
37
+ }
38
+
39
+ /// Warn when a purchase-link URL's host differs from the store's storefront host but shares
40
+ /// the same base domain (an inconsistent subdomain, e.g. `shop.` vs `us.`). Cross-domain
41
+ /// links are left alone (they may be intentional resellers). Non-blocking warnings.
42
+ pub fn validate_store_host(
43
+ store_hosts: &HashMap<String, String>,
44
+ sizes_entries: &[(&str, &Value)],
45
+ ) -> ValidationResult {
46
+ let mut result = ValidationResult::default();
47
+
48
+ for (path_str, sizes_data) in sizes_entries {
49
+ let sizes_arr = match sizes_data.as_array() {
50
+ Some(a) => a,
51
+ None => continue,
52
+ };
53
+ for (size_idx, size) in sizes_arr.iter().enumerate() {
54
+ let purchase_links = match size.get("purchase_links").and_then(|v| v.as_array()) {
55
+ Some(l) => l,
56
+ None => continue,
57
+ };
58
+ for (link_idx, link) in purchase_links.iter().enumerate() {
59
+ let store_id = match link.get("store_id").and_then(|v| v.as_str()) {
60
+ Some(s) => s,
61
+ None => continue,
62
+ };
63
+ let canonical = match store_hosts.get(store_id) {
64
+ Some(h) => h,
65
+ None => continue,
66
+ };
67
+ let link_host = match link.get("url").and_then(|v| v.as_str()).and_then(url_host) {
68
+ Some(h) => h,
69
+ None => continue,
70
+ };
71
+ if &link_host != canonical && base_domain(&link_host) == base_domain(canonical) {
72
+ result.add(ValidationError::warning(
73
+ "StoreHost",
74
+ format!(
75
+ "url host '{}' at $[{}].purchase_links[{}] differs from store '{}' storefront host '{}'",
76
+ link_host, size_idx, link_idx, store_id, canonical
77
+ ),
78
+ Some(path_str.to_string()),
79
+ ));
80
+ }
81
+ }
82
+ }
83
+ }
84
+
85
+ result
86
+ }
87
+
88
+ #[cfg(test)]
89
+ mod tests {
90
+ use super::*;
91
+ use serde_json::json;
92
+
93
+ fn hosts() -> HashMap<String, String> {
94
+ let mut m = HashMap::new();
95
+ m.insert("polymaker".to_string(), "us.polymaker.com".to_string());
96
+ m
97
+ }
98
+
99
+ #[test]
100
+ fn flags_subdomain_drift() {
101
+ let sizes = json!([{
102
+ "filament_weight": 1000, "diameter": 1.75,
103
+ "purchase_links": [{ "store_id": "polymaker", "url": "https://shop.polymaker.com/products/x" }]
104
+ }]);
105
+ let entries = vec![("data/b/PLA/f/red/sizes.json", &sizes)];
106
+ let res = validate_store_host(&hosts(), &entries);
107
+ assert_eq!(res.warning_count(), 1);
108
+ assert!(res.is_valid());
109
+ }
110
+
111
+ #[test]
112
+ fn same_host_passes() {
113
+ let sizes = json!([{
114
+ "filament_weight": 1000, "diameter": 1.75,
115
+ "purchase_links": [{ "store_id": "polymaker", "url": "https://us.polymaker.com/products/x" }]
116
+ }]);
117
+ let entries = vec![("p", &sizes)];
118
+ assert_eq!(validate_store_host(&hosts(), &entries).warning_count(), 0);
119
+ }
120
+
121
+ #[test]
122
+ fn cross_domain_is_left_alone() {
123
+ let sizes = json!([{
124
+ "filament_weight": 1000, "diameter": 1.75,
125
+ "purchase_links": [{ "store_id": "polymaker", "url": "https://www.amazon.com/dp/x" }]
126
+ }]);
127
+ let entries = vec![("p", &sizes)];
128
+ assert_eq!(validate_store_host(&hosts(), &entries).warning_count(), 0);
129
+ }
130
+
131
+ #[test]
132
+ fn builds_hosts_from_store_entries() {
133
+ let store = json!({ "id": "polymaker", "storefront_url": "https://us.polymaker.com/" });
134
+ let entries = vec![("stores/polymaker/store.json".to_string(), "store".to_string(), store)];
135
+ let map = build_store_hosts(&entries);
136
+ assert_eq!(map.get("polymaker").map(|s| s.as_str()), Some("us.polymaker.com"));
137
+ }
138
+ }
@@ -0,0 +1,229 @@
1
+ use serde_json::Value;
2
+ use std::collections::HashSet;
3
+ use std::sync::LazyLock;
4
+
5
+ use crate::types::{ValidationError, ValidationResult};
6
+
7
+ /// Known tracking query-parameter keys (compared case-insensitively). Prefix families
8
+ /// (`utm_`, `mc_`, `pk_`) are handled separately in `is_tracking_key`.
9
+ ///
10
+ /// Note: Shopify product selectors `variant` and `id` are intentionally NOT included —
11
+ /// they can point at a specific colour/product, so we treat them as meaningful.
12
+ static TRACKING_PARAMS: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
13
+ [
14
+ "tag", "linkcode", "psc", "th", "ref", "ref_", "fbclid", "gclid", "gclsrc", "dclid",
15
+ "msclkid", "yclid", "twclid", "ttclid", "igshid", "si", "mc_cid", "mc_eid", "_pos", "_psq",
16
+ "_ss", "_v", "_sid", "spm", "scm", "aff", "affid", "srsltid", "gad_source", "epik",
17
+ "pk_campaign", "pk_kwd",
18
+ // Amazon / marketplace search-result context (identity lives in the /dp/<ASIN> path)
19
+ "dib", "dib_tag", "keywords", "qid", "sr", "sprefix", "crid", "ascsubtag", "smid",
20
+ "content-id", "qsid", "rnid", "refinements", "_encoding", "pldnsite",
21
+ ]
22
+ .into_iter()
23
+ .collect()
24
+ });
25
+
26
+ fn is_tracking_key(key: &str) -> bool {
27
+ let k = key.trim().to_ascii_lowercase();
28
+ k.starts_with("utm_")
29
+ || k.starts_with("mc_")
30
+ || k.starts_with("pk_")
31
+ || k.starts_with("pd_rd_")
32
+ || k.starts_with("pf_rd_")
33
+ || TRACKING_PARAMS.contains(k.as_str())
34
+ }
35
+
36
+ /// Hosts where the product identity is entirely in the path, so every query param is
37
+ /// search/tracking context and can be dropped wholesale (e.g. Amazon `/dp/<ASIN>`).
38
+ fn has_disposable_query(base: &str) -> bool {
39
+ let host = match crate::util::url_host(base) {
40
+ Some(h) => h.to_ascii_lowercase(),
41
+ None => return false,
42
+ };
43
+ if host == "a.co" || host == "amzn.to" || host == "amzn.eu" {
44
+ return true;
45
+ }
46
+ ["amazon", "ebay"]
47
+ .iter()
48
+ .any(|b| host.starts_with(&format!("{b}.")) || host.contains(&format!(".{b}.")))
49
+ }
50
+
51
+ /// Whether a URL fragment looks like tracking data (e.g. `#utm_source=x`) rather than a
52
+ /// content anchor (e.g. `#productinfo`, which we keep).
53
+ fn is_tracking_fragment(fragment: &str) -> bool {
54
+ if !fragment.contains('=') {
55
+ return false;
56
+ }
57
+ let first_key = fragment.split(['&', '=']).next().unwrap_or("");
58
+ is_tracking_key(first_key)
59
+ }
60
+
61
+ /// Remove known tracking parameters (plus an empty `?` and tracking-only fragments) from a
62
+ /// URL, preserving the original ordering/encoding of everything kept. Idempotent.
63
+ pub fn strip_tracking(url: &str) -> String {
64
+ let (before_frag, fragment) = match url.split_once('#') {
65
+ Some((b, f)) => (b, Some(f)),
66
+ None => (url, None),
67
+ };
68
+ let (base, query) = match before_frag.split_once('?') {
69
+ Some((b, q)) => (b, Some(q)),
70
+ None => (before_frag, None),
71
+ };
72
+
73
+ let mut out = String::from(base);
74
+
75
+ if let Some(q) = query {
76
+ if !has_disposable_query(base) {
77
+ let kept: Vec<&str> = q
78
+ .split('&')
79
+ .filter(|pair| {
80
+ if pair.is_empty() {
81
+ return false;
82
+ }
83
+ let key = pair.split('=').next().unwrap_or("");
84
+ !is_tracking_key(key)
85
+ })
86
+ .collect();
87
+ if !kept.is_empty() {
88
+ out.push('?');
89
+ out.push_str(&kept.join("&"));
90
+ }
91
+ }
92
+ }
93
+
94
+ if let Some(frag) = fragment {
95
+ if !frag.is_empty() && !is_tracking_fragment(frag) {
96
+ out.push('#');
97
+ out.push_str(frag);
98
+ }
99
+ }
100
+
101
+ out
102
+ }
103
+
104
+ /// True if stripping tracking data would change the URL. Mirrors the webui `hasTracking`
105
+ /// helper; retained as a public API for consumers even though the rule uses `strip_tracking`
106
+ /// directly.
107
+ #[allow(dead_code)]
108
+ pub fn has_tracking(url: &str) -> bool {
109
+ strip_tracking(url) != url
110
+ }
111
+
112
+ /// Warn about tracking parameters in `purchase_links[].url` inside sizes.json entries.
113
+ /// Each entry is (path_label, parsed sizes.json Value). Emits non-blocking warnings that
114
+ /// suggest the cleaned URL in the message.
115
+ pub fn validate_url_tracking(sizes_entries: &[(&str, &Value)]) -> ValidationResult {
116
+ let mut result = ValidationResult::default();
117
+
118
+ for (path_str, sizes_data) in sizes_entries {
119
+ let sizes_arr = match sizes_data.as_array() {
120
+ Some(a) => a,
121
+ None => continue,
122
+ };
123
+
124
+ for (size_idx, size) in sizes_arr.iter().enumerate() {
125
+ let purchase_links = match size.get("purchase_links").and_then(|v| v.as_array()) {
126
+ Some(links) => links,
127
+ None => continue,
128
+ };
129
+
130
+ for (link_idx, link) in purchase_links.iter().enumerate() {
131
+ if let Some(url) = link.get("url").and_then(|v| v.as_str()) {
132
+ let cleaned = strip_tracking(url);
133
+ if cleaned != url {
134
+ result.add(ValidationError::warning(
135
+ "URLTracking",
136
+ format!(
137
+ "Tracking parameters in url at $[{}].purchase_links[{}]; suggested: {}",
138
+ size_idx, link_idx, cleaned
139
+ ),
140
+ Some(path_str.to_string()),
141
+ ));
142
+ }
143
+ }
144
+ }
145
+ }
146
+ }
147
+
148
+ result
149
+ }
150
+
151
+ #[cfg(test)]
152
+ mod tests {
153
+ use super::*;
154
+ use serde_json::json;
155
+
156
+ #[test]
157
+ fn strips_amazon_affiliate() {
158
+ assert_eq!(
159
+ strip_tracking("https://www.amazon.eg/dp/B0GVSM88NP/?tag=3dfil-20&linkCode=ogi&psc=1&th=1"),
160
+ "https://www.amazon.eg/dp/B0GVSM88NP/"
161
+ );
162
+ }
163
+
164
+ #[test]
165
+ fn strips_amazon_search_query_wholesale() {
166
+ let dirty = "https://www.amazon.com/AmeLeis-Printer-Filament-Bundle-1-75mm/dp/B0GYNWLN2F?dib=eyJ2IjoiMSJ9.abc&dib_tag=se&keywords=AmeLeis&qid=1784042998&sr=8-1";
167
+ assert_eq!(
168
+ strip_tracking(dirty),
169
+ "https://www.amazon.com/AmeLeis-Printer-Filament-Bundle-1-75mm/dp/B0GYNWLN2F"
170
+ );
171
+ assert!(has_tracking(dirty));
172
+ }
173
+
174
+ #[test]
175
+ fn keeps_shopify_variant_selector() {
176
+ let u = "https://shop.polymaker.com/products/panchroma-galaxy?variant=44933539987513";
177
+ assert_eq!(strip_tracking(u), u);
178
+ }
179
+
180
+ #[test]
181
+ fn keeps_variant_and_content_fragment() {
182
+ let u = "https://shop.polymaker.com/products/panchroma-pla?variant=45411122413625#productinfo";
183
+ assert_eq!(strip_tracking(u), u);
184
+ }
185
+
186
+ #[test]
187
+ fn removes_trailing_empty_query() {
188
+ assert_eq!(
189
+ strip_tracking("https://us.polymaker.com/products/panchroma-glow?"),
190
+ "https://us.polymaker.com/products/panchroma-glow"
191
+ );
192
+ }
193
+
194
+ #[test]
195
+ fn removes_trackers_but_keeps_meaningful() {
196
+ assert_eq!(
197
+ strip_tracking("https://x.com/p?utm_source=a&variant=5&gclid=z"),
198
+ "https://x.com/p?variant=5"
199
+ );
200
+ }
201
+
202
+ #[test]
203
+ fn strips_tracking_fragment_keeps_anchor() {
204
+ assert_eq!(strip_tracking("https://x.com/p#utm_source=a"), "https://x.com/p");
205
+ assert_eq!(strip_tracking("https://x.com/p#section-2"), "https://x.com/p#section-2");
206
+ }
207
+
208
+ #[test]
209
+ fn is_idempotent() {
210
+ let dirty = "https://www.amazon.eg/dp/B0GVSM88NP/?tag=3dfil-20&th=1";
211
+ let once = strip_tracking(dirty);
212
+ assert_eq!(strip_tracking(&once), once);
213
+ assert!(has_tracking(dirty));
214
+ assert!(!has_tracking(&once));
215
+ }
216
+
217
+ #[test]
218
+ fn rule_emits_warning_not_error() {
219
+ let sizes = json!([{
220
+ "filament_weight": 1000, "diameter": 1.75,
221
+ "purchase_links": [{ "store_id": "amazon", "url": "https://a.com/x?tag=aff-20" }]
222
+ }]);
223
+ let entries = vec![("data/b/PLA/f/red/sizes.json", &sizes)];
224
+ let res = validate_url_tracking(&entries);
225
+ assert_eq!(res.warning_count(), 1);
226
+ assert_eq!(res.error_count(), 0);
227
+ assert!(res.is_valid()); // warnings never invalidate
228
+ }
229
+ }
@@ -0,0 +1,120 @@
1
+ use serde_json::Value;
2
+ use std::collections::HashSet;
3
+
4
+ use crate::types::{ValidationError, ValidationResult};
5
+
6
+ /// Canonical material-type tokens (mirrors `schemas/material_types_schema.json`).
7
+ /// Used as a fallback; `validate_dataset` prefers the live schema enum.
8
+ pub const MATERIAL_TYPES_FALLBACK: &[&str] = &[
9
+ "PLA", "PETG", "TPU", "ABS", "ASA", "PC", "PCTG", "PP", "PA6", "PA11", "PA12", "PA66", "CPE",
10
+ "TPE", "HIPS", "PHA", "PET", "PEI", "PBT", "PVB", "PVA", "PEKK", "PEEK", "BVOH", "TPC", "PPS",
11
+ "PPSU", "PVC", "PEBA", "PVDF", "PPA", "PCL", "PES", "PMMA", "POM", "PPE", "PS", "PSU", "TPI",
12
+ "SBS", "OBC", "EVA",
13
+ ];
14
+
15
+ /// Split a display name / id into upper-cased alphanumeric tokens.
16
+ fn tokens(s: &str) -> Vec<String> {
17
+ s.split(|c: char| !c.is_ascii_alphanumeric())
18
+ .filter(|t| !t.is_empty())
19
+ .map(|t| t.to_ascii_uppercase())
20
+ .collect()
21
+ }
22
+
23
+ /// Return the first material token embedded in `name`, if any (whole-token match).
24
+ fn find_material_token(name: &str, material_types: &HashSet<String>) -> Option<String> {
25
+ tokens(name).into_iter().find(|t| material_types.contains(t))
26
+ }
27
+
28
+ /// Extract the variant folder name from a `.../<variant>/variant.json` path label.
29
+ fn variant_folder(path: &str) -> Option<String> {
30
+ let norm = path.replace('\\', "/");
31
+ let mut parts = norm.rsplit('/');
32
+ let _file = parts.next()?; // variant.json
33
+ parts.next().map(|s| s.to_string())
34
+ }
35
+
36
+ /// Warn when a variant's colour name (or its folder id) embeds a material-type token,
37
+ /// which is redundant with the parent MATERIAL directory. Non-blocking warnings;
38
+ /// at most one per variant. `variant_entries` are (path_label, parsed variant.json Value).
39
+ pub fn validate_variant_material(
40
+ variant_entries: &[(&str, &Value)],
41
+ material_types: &HashSet<String>,
42
+ ) -> ValidationResult {
43
+ let mut result = ValidationResult::default();
44
+
45
+ for (path_str, data) in variant_entries {
46
+ if let Some(name) = data.get("name").and_then(|v| v.as_str()) {
47
+ if let Some(tok) = find_material_token(name, material_types) {
48
+ result.add(ValidationError::warning(
49
+ "VariantMaterial",
50
+ format!(
51
+ "Colour name '{}' contains material type '{}', which is redundant with the material folder",
52
+ name, tok
53
+ ),
54
+ Some(path_str.to_string()),
55
+ ));
56
+ continue;
57
+ }
58
+ }
59
+
60
+ if let Some(folder) = variant_folder(path_str) {
61
+ if let Some(tok) = find_material_token(&folder, material_types) {
62
+ result.add(ValidationError::warning(
63
+ "VariantMaterial",
64
+ format!(
65
+ "Variant folder id '{}' contains material type '{}', which is redundant with the material folder",
66
+ folder, tok
67
+ ),
68
+ Some(path_str.to_string()),
69
+ ));
70
+ }
71
+ }
72
+ }
73
+
74
+ result
75
+ }
76
+
77
+ #[cfg(test)]
78
+ mod tests {
79
+ use super::*;
80
+ use serde_json::json;
81
+
82
+ fn material_set() -> HashSet<String> {
83
+ MATERIAL_TYPES_FALLBACK.iter().map(|s| s.to_string()).collect()
84
+ }
85
+
86
+ #[test]
87
+ fn flags_material_in_colour_name() {
88
+ let v = json!({ "name": "Galaxy PETG", "color_hex": "#112233" });
89
+ let entries = vec![("data/b/PETG/f/galaxy_petg/variant.json", &v)];
90
+ let res = validate_variant_material(&entries, &material_set());
91
+ assert_eq!(res.warning_count(), 1);
92
+ assert_eq!(res.error_count(), 0);
93
+ assert!(res.is_valid());
94
+ }
95
+
96
+ #[test]
97
+ fn flags_material_in_folder_id_when_name_clean() {
98
+ let v = json!({ "name": "Sakura Pink", "color_hex": "#ffaabb" });
99
+ let entries = vec![("data/sunlu/PETG/petg/sakura_pink_petg/variant.json", &v)];
100
+ let res = validate_variant_material(&entries, &material_set());
101
+ assert_eq!(res.warning_count(), 1);
102
+ }
103
+
104
+ #[test]
105
+ fn clean_name_and_folder_pass() {
106
+ let v = json!({ "name": "Sakura Pink", "color_hex": "#ffaabb" });
107
+ let entries = vec![("data/sunlu/PETG/petg/sakura_pink/variant.json", &v)];
108
+ let res = validate_variant_material(&entries, &material_set());
109
+ assert_eq!(res.warning_count(), 0);
110
+ }
111
+
112
+ #[test]
113
+ fn whole_token_match_avoids_substrings() {
114
+ // "PETAL" must not match "PET"; "Space" must not match "PA"/"AS".
115
+ let v = json!({ "name": "Petal Space Grey", "color_hex": "#777777" });
116
+ let entries = vec![("data/b/PLA/f/petal/variant.json", &v)];
117
+ let res = validate_variant_material(&entries, &material_set());
118
+ assert_eq!(res.warning_count(), 0);
119
+ }
120
+ }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ofd-validator-python"
3
- version = "0.5.0"
3
+ version = "0.5.2"
4
4
  edition = "2021"
5
5
  authors = ["Open Filament Collective"]
6
6
  license = "MIT"
@@ -6,7 +6,8 @@ mod util;
6
6
  mod validators;
7
7
 
8
8
  use orchestrator::{
9
- validate_all, validate_folder_names, validate_json_files, validate_logo_files,
9
+ validate_all, validate_all_with_changes, validate_folder_names, validate_json_files,
10
+ validate_logo_files,
10
11
  };
11
12
  use types::{ValidationError, ValidationLevel, ValidationResult};
12
13
  use validators::{
@@ -22,6 +23,7 @@ fn ofd_validator(m: &Bound<'_, PyModule>) -> PyResult<()> {
22
23
 
23
24
  // Orchestrated batch validators (internally parallel with rayon)
24
25
  m.add_function(wrap_pyfunction!(validate_all, m)?)?;
26
+ m.add_function(wrap_pyfunction!(validate_all_with_changes, m)?)?;
25
27
  m.add_function(wrap_pyfunction!(validate_json_files, m)?)?;
26
28
  m.add_function(wrap_pyfunction!(validate_logo_files, m)?)?;
27
29
  m.add_function(wrap_pyfunction!(validate_folder_names, m)?)?;
@@ -57,6 +57,41 @@ pub fn validate_all(
57
57
  })
58
58
  }
59
59
 
60
+ #[pyfunction]
61
+ #[pyo3(signature = (data_dir, stores_dir, changes_json, schemas_dir=None, max_workers=None))]
62
+ pub fn validate_all_with_changes(
63
+ py: Python<'_>,
64
+ data_dir: &str,
65
+ stores_dir: &str,
66
+ changes_json: &str,
67
+ schemas_dir: Option<&str>,
68
+ max_workers: Option<usize>,
69
+ ) -> ValidationResult {
70
+ let data_dir = PathBuf::from(data_dir);
71
+ let stores_dir = PathBuf::from(stores_dir);
72
+ let schemas_dir = PathBuf::from(schemas_dir.unwrap_or("schemas"));
73
+ let changes_json = changes_json.to_string();
74
+
75
+ py.allow_threads(|| {
76
+ with_thread_pool(max_workers, || {
77
+ log_step("Loading dataset", None);
78
+ let mut dataset = core::DataSet::from_directories(&data_dir, &stores_dir, &schemas_dir);
79
+
80
+ log_step("Applying pending changes", None);
81
+ dataset.apply_changes(&changes_json, &data_dir, &stores_dir);
82
+
83
+ log_step("Checking required files", None);
84
+ log_step("Validating JSON schemas", Some(dataset.json_entries.len()));
85
+ log_step("Validating logos", Some(dataset.logo_entries.len()));
86
+ log_step("Validating folder names", Some(dataset.folder_entries.len()));
87
+ log_step("Validating store IDs", None);
88
+ log_step("Validating GTIN/EAN codes", None);
89
+
90
+ core::validate_dataset(&dataset).into()
91
+ })
92
+ })
93
+ }
94
+
60
95
  #[pyfunction]
61
96
  #[pyo3(signature = (data_dir, stores_dir, schemas_dir=None, max_workers=None))]
62
97
  pub fn validate_json_files(
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "ofd-validator"
7
- version = "0.5.0"
7
+ version = "0.5.2"
8
8
  description = "High-performance validation library for the Open Filament Database"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,3 +0,0 @@
1
- [workspace]
2
- members = ["crates/ofd-validator-python"]
3
- resolver = "2"
File without changes