focus-validator 2.0.1__tar.gz → 2.0.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 (35) hide show
  1. {focus_validator-2.0.1 → focus_validator-2.0.2}/PKG-INFO +1 -1
  2. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/config_objects/json_loader.py +3 -3
  3. focus_validator-2.0.1/focus_validator/rules/model-1.2.json → focus_validator-2.0.2/focus_validator/rules/model-1.2.0.1.json +3 -3
  4. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/rules/spec_rules.py +203 -39
  5. {focus_validator-2.0.1 → focus_validator-2.0.2}/pyproject.toml +1 -1
  6. {focus_validator-2.0.1 → focus_validator-2.0.2}/LICENSE +0 -0
  7. {focus_validator-2.0.1 → focus_validator-2.0.2}/README.md +0 -0
  8. {focus_validator-2.0.1 → focus_validator-2.0.2}/build.py +0 -0
  9. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/__init__.py +0 -0
  10. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/config/logging.yaml +0 -0
  11. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/config_objects/__init__.py +0 -0
  12. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/config_objects/common.py +0 -0
  13. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/config_objects/focus_to_duckdb_converter.py +0 -0
  14. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/config_objects/plan_builder.py +0 -0
  15. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/config_objects/rule.py +0 -0
  16. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/config_objects/rule_dependency_resolver.py +0 -0
  17. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/data_loaders/__init__.py +0 -0
  18. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/data_loaders/csv_data_loader.py +0 -0
  19. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/data_loaders/csv_data_loader_pandas_backup.py +0 -0
  20. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/data_loaders/data_loader.py +0 -0
  21. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/data_loaders/parquet_data_loader.py +0 -0
  22. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/exceptions.py +0 -0
  23. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/main.py +0 -0
  24. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/outputter/__init__.py +0 -0
  25. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/outputter/outputter.py +0 -0
  26. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/outputter/outputter_console.py +0 -0
  27. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/outputter/outputter_unittest.py +0 -0
  28. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/outputter/outputter_validation_graph.py +0 -0
  29. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/outputter/outputter_web.py +0 -0
  30. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/rules/__init__.py +0 -0
  31. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/rules/currency_codes.csv +0 -0
  32. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/utils/__init__.py +0 -0
  33. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/utils/download_currency_codes.py +0 -0
  34. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/utils/performance_logging.py +0 -0
  35. {focus_validator-2.0.1 → focus_validator-2.0.2}/focus_validator/validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: focus_validator
3
- Version: 2.0.1
3
+ Version: 2.0.2
4
4
  Summary: FOCUS spec validator.
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.12,<4.0
@@ -129,14 +129,14 @@ class JsonLoader:
129
129
  focus_dataset: Optional[str] = "",
130
130
  filter_rules: Optional[str] = None,
131
131
  applicability_criteria_list: Optional[List[str]] = None,
132
- ) -> Tuple[ValidationPlan, Dict[str, str]]:
132
+ ) -> Tuple[ValidationPlan, Dict[str, str], Dict[str, Any]]:
133
133
  """
134
134
  Load CR JSON, build the dependency graph with RuleDependencyResolver,
135
135
  select relevant rules, and return both an execution-ready ValidationPlan
136
136
  (parents preserved, topo-ordered nodes + layers) and a column type mapping.
137
137
 
138
138
  Returns:
139
- Tuple of (ValidationPlan, Dict[column_name, pandas_dtype])
139
+ Tuple of (ValidationPlan, Dict[column_name, pandas_dtype], Dict[model_data])
140
140
  """
141
141
  model_data = JsonLoader.load_json_rules(json_rule_file)
142
142
 
@@ -227,4 +227,4 @@ class JsonLoader:
227
227
  exec_ctx=None, # supply a runtime context later if you want gated edges
228
228
  )
229
229
 
230
- return val_plan, column_types
230
+ return val_plan, column_types, model_data
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "Details": {
3
- "ModelVersion": "1.2",
3
+ "ModelVersion": "1.2.0.1",
4
4
  "FOCUSVersion": "1.2"
5
5
  },
6
6
  "ApplicabilityCriteria": {
@@ -9713,7 +9713,7 @@
9713
9713
  },
9714
9714
  "Condition": {
9715
9715
  "CheckFunction": "CheckNotValue",
9716
- "CheckCondition": "ResourceId",
9716
+ "ColumnName": "ResourceId",
9717
9717
  "Value": null
9718
9718
  },
9719
9719
  "Dependencies": [
@@ -12674,7 +12674,7 @@
12674
12674
  "MustSatisfy": "CommitmentDiscountStatus MUST be null when CommitmentDiscountId is null.",
12675
12675
  "Keyword": "MUST",
12676
12676
  "Requirement": {
12677
- "CheckFunction": "CheckNotValue",
12677
+ "CheckFunction": "CheckValue",
12678
12678
  "ColumnName": "CommitmentDiscountStatus",
12679
12679
  "Value": null
12680
12680
  },
@@ -55,8 +55,12 @@ class SpecRules:
55
55
  ):
56
56
  self.rule_set_path = rule_set_path
57
57
  self.rules_file_prefix = rules_file_prefix
58
- self.rules_version = rules_version
59
- self.model_version = "Unknown" # Will be loaded from JSON file
58
+ self.rules_version = (
59
+ rules_version # Will be overridden by FOCUSVersion from JSON Details
60
+ )
61
+ self.model_version = (
62
+ "Unknown" # Will be loaded from ModelVersion in JSON Details
63
+ )
60
64
  self.rules_file_suffix = rules_file_suffix
61
65
  self.focus_dataset = focus_dataset
62
66
  self.filter_rules = filter_rules
@@ -82,9 +86,18 @@ class SpecRules:
82
86
  self.local_supported_versions,
83
87
  )
84
88
  self.remote_versions = {}
85
- if self.rules_block_remote_download and (
86
- self.rules_version not in self.local_supported_versions
87
- ):
89
+
90
+ # Build dict of local versions for semantic matching
91
+ local_versions_dict = {
92
+ v: {"source": "local"} for v in self.local_supported_versions
93
+ }
94
+
95
+ # Try semantic version matching for local versions first
96
+ matched_version = self._find_best_version_match(
97
+ self.rules_version, local_versions_dict
98
+ )
99
+
100
+ if self.rules_block_remote_download and matched_version is None:
88
101
  self.log.error(
89
102
  "Version %s not found in local versions and remote download blocked",
90
103
  self.rules_version,
@@ -92,14 +105,11 @@ class SpecRules:
92
105
  raise UnsupportedVersion(
93
106
  f"FOCUS version {self.rules_version} not supported. Supported versions: local {self.local_supported_versions}"
94
107
  )
95
- elif (
96
- self.rules_force_remote_download
97
- or self.rules_version not in self.local_supported_versions
98
- ):
108
+ elif self.rules_force_remote_download or matched_version is None:
99
109
  self.log.info(
100
110
  "Remote rule download needed (force: %s, version available locally: %s)",
101
111
  self.rules_force_remote_download,
102
- self.rules_version in self.local_supported_versions,
112
+ matched_version is not None,
103
113
  )
104
114
 
105
115
  self.log.debug("Fetching remote supported versions...")
@@ -110,7 +120,12 @@ class SpecRules:
110
120
  self.remote_supported_versions,
111
121
  )
112
122
 
113
- if self.rules_version not in self.remote_supported_versions:
123
+ # Try semantic version matching for remote versions
124
+ matched_version = self._find_best_version_match(
125
+ self.rules_version, self.remote_versions
126
+ )
127
+
128
+ if matched_version is None:
114
129
  self.log.error(
115
130
  "Version %s not found in remote versions", self.rules_version
116
131
  )
@@ -119,13 +134,19 @@ class SpecRules:
119
134
  )
120
135
  else:
121
136
  self.log.info(
122
- "Downloading remote rules for version %s...", self.rules_version
137
+ "Matched requested version %s to %s from remote",
138
+ self.rules_version,
139
+ matched_version,
123
140
  )
124
- download_url = self.remote_versions[self.rules_version][
141
+ download_url = self.remote_versions[matched_version][
125
142
  "asset_browser_download_url"
126
143
  ]
144
+ filename = self.remote_versions[matched_version]["filename"]
127
145
  self.log.debug("Download URL: %s", download_url)
128
146
 
147
+ # Update json_rule_file path to use matched version filename
148
+ self.json_rule_file = os.path.join(self.rule_set_path, filename)
149
+
129
150
  if not self.download_remote_version(
130
151
  remote_url=download_url, save_path=self.json_rule_file
131
152
  ):
@@ -135,6 +156,18 @@ class SpecRules:
135
156
  )
136
157
  else:
137
158
  self.log.info("Remote rules downloaded successfully")
159
+ else:
160
+ # Using local version
161
+ self.log.info(
162
+ "Matched requested version %s to %s from local files",
163
+ self.rules_version,
164
+ matched_version,
165
+ )
166
+ # Update json_rule_file path to use matched version
167
+ self.json_rule_file = os.path.join(
168
+ self.rule_set_path,
169
+ f"{self.rules_file_prefix}{matched_version}{self.rules_file_suffix}",
170
+ )
138
171
  self.rules = {}
139
172
  self.column_namespace = column_namespace
140
173
  self.json_rules = {}
@@ -143,7 +176,12 @@ class SpecRules:
143
176
  self.column_types = {}
144
177
 
145
178
  def supported_local_versions(self) -> List[str]:
146
- """Return list of versions from files in rule_set_path."""
179
+ """Return list of highest versions from files in rule_set_path.
180
+
181
+ Only returns the highest semantic version for each major.minor prefix.
182
+ For example, if both model-1.2.json and model-1.2.0.1.json exist,
183
+ only 1.2.0.1 will be returned.
184
+ """
147
185
  versions = []
148
186
  for filename in os.listdir(self.rule_set_path):
149
187
  if filename.startswith(self.rules_file_prefix) and filename.endswith(
@@ -154,7 +192,96 @@ class SpecRules:
154
192
  len(self.rules_file_prefix) : -len(self.rules_file_suffix)
155
193
  ]
156
194
  versions.append(version)
157
- return versions
195
+ return self._filter_to_highest_versions(versions)
196
+
197
+ def _parse_version_from_filename(self, filename: str) -> Optional[str]:
198
+ """Extract version from filename like 'model-1.2.0.1.json' -> '1.2.0.1'."""
199
+ if not filename.startswith(self.rules_file_prefix) or not filename.endswith(
200
+ self.rules_file_suffix
201
+ ):
202
+ return None
203
+ version = filename[len(self.rules_file_prefix) : -len(self.rules_file_suffix)]
204
+ return version if version else None
205
+
206
+ def _parse_version_tuple(self, version: str) -> Tuple[int, ...]:
207
+ """Convert version string '1.2.0.1' to tuple (1, 2, 0, 1) for comparison."""
208
+ try:
209
+ return tuple(int(x) for x in version.split("."))
210
+ except (ValueError, AttributeError):
211
+ self.log.warning(
212
+ "Malformed version string '%s' - cannot parse as semantic version. "
213
+ "Will sort to bottom. Check for corrupted model filenames.",
214
+ version,
215
+ )
216
+ return (0,) # Fallback for invalid versions
217
+
218
+ def _find_best_version_match(
219
+ self, requested: str, available: Dict[str, Dict[str, Any]]
220
+ ) -> Optional[str]:
221
+ """
222
+ Find the best (highest) semantic version matching the requested prefix.
223
+
224
+ Args:
225
+ requested: Version prefix like '1.2' or '1.3'
226
+ available: Dict of available versions with metadata
227
+
228
+ Returns:
229
+ Best matching version string, or None if no match found
230
+ """
231
+ # Filter versions that match the requested prefix
232
+ matching = [
233
+ v
234
+ for v in available.keys()
235
+ if v.startswith(requested + ".") or v == requested
236
+ ]
237
+
238
+ if not matching:
239
+ return None
240
+
241
+ # Sort by semantic version (highest first)
242
+ matching.sort(key=self._parse_version_tuple, reverse=True)
243
+ return matching[0]
244
+
245
+ def _filter_to_highest_versions(self, versions: List[str]) -> List[str]:
246
+ """
247
+ Filter version list to only include the highest version for each major.minor prefix.
248
+
249
+ For example, given ['1.2', '1.2.0', '1.2.0.1', '1.3', '1.3.0.1']:
250
+ Returns: ['1.2.0.1', '1.3.0.1']
251
+
252
+ This ensures the supported versions list only shows versions that would
253
+ actually be used (since semantic matching always picks the highest).
254
+
255
+ Args:
256
+ versions: List of version strings
257
+
258
+ Returns:
259
+ Filtered list with only highest version per major.minor
260
+ """
261
+ # Group versions by major.minor prefix
262
+ prefix_groups: Dict[str, List[str]] = {}
263
+ for v in versions:
264
+ # Extract major.minor (first two components)
265
+ parts = v.split(".")
266
+ if len(parts) >= 2:
267
+ prefix = f"{parts[0]}.{parts[1]}"
268
+ else:
269
+ prefix = v
270
+
271
+ if prefix not in prefix_groups:
272
+ prefix_groups[prefix] = []
273
+ prefix_groups[prefix].append(v)
274
+
275
+ # For each prefix, keep only the highest version
276
+ highest_versions = []
277
+ for prefix, group_versions in prefix_groups.items():
278
+ # Sort by semantic version and take the highest
279
+ group_versions.sort(key=self._parse_version_tuple, reverse=True)
280
+ highest_versions.append(group_versions[0])
281
+
282
+ # Return sorted by semantic version
283
+ highest_versions.sort(key=self._parse_version_tuple)
284
+ return highest_versions
158
285
 
159
286
  def find_release_assets(
160
287
  self,
@@ -164,22 +291,29 @@ class SpecRules:
164
291
  timeout: float = 15.0,
165
292
  ) -> Dict[str, Dict[str, Any]]:
166
293
  """
167
- Search GitHub releases for assets whose names start with
168
- self.rules_files_prefix and end with self.rules_files_suffix.
294
+ Search GitHub releases for ALL model files across all releases.
169
295
 
170
- Returns a dict of dicts:
296
+ Returns a dict keyed by model VERSION (not release tag):
171
297
  {
172
- "version": {
173
- "release_tag": "v1.2",
174
- "asset_browser_download_url": "<asset_browser_download_url>"
298
+ "1.2.0.1": {
299
+ "release_tag": "v1.3",
300
+ "filename": "model-1.2.0.1.json",
301
+ "asset_browser_download_url": "<url>"
302
+ },
303
+ "1.3": {
304
+ "release_tag": "v1.3",
305
+ "filename": "model-1.3.json",
306
+ "asset_browser_download_url": "<url>"
175
307
  }
176
308
  }
309
+
310
+ When multiple releases contain the same model version, the first (newest)
311
+ release wins, since GitHub API returns releases in reverse chronological order.
177
312
  """
178
313
  session = requests.Session()
179
314
  headers = {
180
315
  "Accept": "application/vnd.github+json",
181
316
  "X-GitHub-Api-Version": "2022-11-28",
182
- # Optional but helps with routing
183
317
  "User-Agent": "focus-validator/asset-scan",
184
318
  }
185
319
 
@@ -194,7 +328,6 @@ class SpecRules:
194
328
  if resp.status_code == 401:
195
329
  raise PermissionError("Unauthorized (bad or missing token)")
196
330
  if resp.status_code == 403:
197
- # Could be secondary rate limiting or scope issue
198
331
  raise RuntimeError(f"Forbidden / rate limited: {resp.text}")
199
332
  resp.raise_for_status()
200
333
 
@@ -209,24 +342,41 @@ class SpecRules:
209
342
  if not self.allow_prerelease_releases and rel.get("prerelease"):
210
343
  continue
211
344
 
345
+ release_tag = rel.get("tag_name", "")
212
346
  assets = rel.get("assets", []) or []
347
+
348
+ # Scan ALL model files in this release
213
349
  for asset in assets:
214
- name = asset.get("name", "")
215
- if name.startswith(self.rules_file_prefix) and name.endswith(
216
- self.rules_file_suffix
217
- ):
218
- results[rel.get("tag_name", "").removeprefix("v")] = {
219
- "release_tag": rel.get("tag_name"),
350
+ filename = asset.get("name", "")
351
+ model_version = self._parse_version_from_filename(filename)
352
+
353
+ if model_version and model_version not in results:
354
+ # First match wins = newest release, since GitHub API
355
+ # returns releases newest-first
356
+ results[model_version] = {
357
+ "release_tag": release_tag,
358
+ "filename": filename,
220
359
  "asset_browser_download_url": asset.get(
221
360
  "browser_download_url"
222
361
  ),
223
362
  }
363
+
224
364
  page += 1
225
365
 
366
+ self.log.debug(
367
+ "Found %d model versions across releases: %s",
368
+ len(results),
369
+ list(results.keys()),
370
+ )
226
371
  return results
227
372
 
228
373
  def supported_remote_versions(self) -> List[str]:
229
- """Return list of versions from remote source."""
374
+ """Return list of highest versions from remote source.
375
+
376
+ Only returns the highest semantic version for each major.minor prefix.
377
+ For example, if both 1.2 and 1.2.0.1 are available remotely,
378
+ only 1.2.0.1 will be returned since that's what semantic matching would use.
379
+ """
230
380
  # Respect block download setting
231
381
  if self.rules_block_remote_download:
232
382
  self.log.debug(
@@ -236,7 +386,8 @@ class SpecRules:
236
386
 
237
387
  # Implement logic to fetch supported remote versions
238
388
  self.remote_versions = self.find_release_assets()
239
- return [v for v in self.remote_versions.keys()]
389
+ all_versions = list(self.remote_versions.keys())
390
+ return self._filter_to_highest_versions(all_versions)
240
391
 
241
392
  def download_remote_version(self, remote_url: str, save_path: str) -> bool:
242
393
  """Download the file from remote_url and save it to save_path.
@@ -259,21 +410,34 @@ class SpecRules:
259
410
  self.load_rules()
260
411
 
261
412
  def load_rules(self) -> ValidationPlan:
262
- val_plan, column_types = JsonLoader.load_json_rules_with_dependencies_and_types(
263
- json_rule_file=self.json_rule_file,
264
- focus_dataset=self.focus_dataset,
265
- filter_rules=self.filter_rules,
266
- applicability_criteria_list=self.applicability_criteria_list,
413
+ # Load rules and parse JSON once
414
+ val_plan, column_types, model_data = (
415
+ JsonLoader.load_json_rules_with_dependencies_and_types(
416
+ json_rule_file=self.json_rule_file,
417
+ focus_dataset=self.focus_dataset,
418
+ filter_rules=self.filter_rules,
419
+ applicability_criteria_list=self.applicability_criteria_list,
420
+ )
267
421
  )
268
422
 
269
- # Load model version from the JSON file Details section
423
+ # Extract FOCUS version and model version from Details (already parsed above)
270
424
  try:
271
- model_data = JsonLoader.load_json_rules(self.json_rule_file)
272
425
  details = model_data.get("Details", {})
426
+ # Override rules_version with FOCUSVersion from model file
427
+ focus_version = details.get("FOCUSVersion", None)
428
+ if focus_version:
429
+ self.rules_version = focus_version
430
+ self.log.debug("Loaded FOCUS version: %s", self.rules_version)
431
+ else:
432
+ self.log.warning(
433
+ "FOCUSVersion not found in Details, using requested version: %s",
434
+ self.rules_version,
435
+ )
436
+ # Load model version
273
437
  self.model_version = details.get("ModelVersion", "Unknown")
274
438
  self.log.debug("Loaded model version: %s", self.model_version)
275
439
  except Exception as e:
276
- self.log.warning("Failed to load model version: %s", e)
440
+ self.log.warning("Failed to load FOCUS/model version: %s", e)
277
441
  self.model_version = "Unknown"
278
442
 
279
443
  self.plan = val_plan
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "focus_validator"
3
- version = "2.0.1"
3
+ version = "2.0.2"
4
4
  description = "FOCUS spec validator."
5
5
  authors = []
6
6
  readme = "README.md"
File without changes