rosterops 0.1.0__py3-none-any.whl

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 (60) hide show
  1. rosterops/__init__.py +24 -0
  2. rosterops/__main__.py +10 -0
  3. rosterops/cli.py +279 -0
  4. rosterops/config.py +380 -0
  5. rosterops/domain/__init__.py +1 -0
  6. rosterops/domain/dates.py +122 -0
  7. rosterops/domain/intervals.py +162 -0
  8. rosterops/engine/__init__.py +20 -0
  9. rosterops/engine/api.py +98 -0
  10. rosterops/engine/baseline.py +181 -0
  11. rosterops/engine/context.py +89 -0
  12. rosterops/engine/lifecycle_rules.py +387 -0
  13. rosterops/engine/load.py +153 -0
  14. rosterops/engine/metrics.py +99 -0
  15. rosterops/engine/orchestrator.py +229 -0
  16. rosterops/engine/reconciliation.py +119 -0
  17. rosterops/engine/reconstruction.py +517 -0
  18. rosterops/engine/result.py +159 -0
  19. rosterops/engine/roster.py +68 -0
  20. rosterops/engine/snapshot_rules.py +641 -0
  21. rosterops/engine/state.py +234 -0
  22. rosterops/engine/vacancies.py +141 -0
  23. rosterops/events/__init__.py +37 -0
  24. rosterops/events/ledger.py +180 -0
  25. rosterops/events/normalize.py +259 -0
  26. rosterops/events/pipeline.py +79 -0
  27. rosterops/events/reconcile.py +240 -0
  28. rosterops/exceptions.py +24 -0
  29. rosterops/interop.py +66 -0
  30. rosterops/io/__init__.py +24 -0
  31. rosterops/io/ingest.py +118 -0
  32. rosterops/io/raw.py +68 -0
  33. rosterops/io/readers.py +211 -0
  34. rosterops/mapping/__init__.py +7 -0
  35. rosterops/mapping/canonical.py +523 -0
  36. rosterops/models.py +291 -0
  37. rosterops/outputs/__init__.py +12 -0
  38. rosterops/outputs/writer.py +352 -0
  39. rosterops/py.typed +0 -0
  40. rosterops/rules/__init__.py +53 -0
  41. rosterops/rules/base.py +249 -0
  42. rosterops/rules/coverage_rules.py +69 -0
  43. rosterops/rules/employment_rules.py +120 -0
  44. rosterops/rules/grain_rules.py +52 -0
  45. rosterops/rules/hierarchy_rules.py +68 -0
  46. rosterops/rules/identifier_rules.py +107 -0
  47. rosterops/rules/input_rules.py +110 -0
  48. rosterops/rules/leave_rules.py +141 -0
  49. rosterops/rules/promotion_rules.py +78 -0
  50. rosterops/rules/reconciliation_rules.py +129 -0
  51. rosterops/rules/settings.py +66 -0
  52. rosterops/rules/temporal_rules.py +123 -0
  53. rosterops/rules/territory_rules.py +130 -0
  54. rosterops/rules/transfer_rules.py +127 -0
  55. rosterops/version.py +3 -0
  56. rosterops-0.1.0.dist-info/METADATA +129 -0
  57. rosterops-0.1.0.dist-info/RECORD +60 -0
  58. rosterops-0.1.0.dist-info/WHEEL +4 -0
  59. rosterops-0.1.0.dist-info/entry_points.txt +2 -0
  60. rosterops-0.1.0.dist-info/licenses/LICENSE +21 -0
rosterops/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """rosterops — deterministic, configurable pharma roster processing and validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rosterops.config import RosterConfig
6
+ from rosterops.engine import RosterResult
7
+ from rosterops.engine.api import RosterEngine
8
+ from rosterops.exceptions import ConfigError, InputError, ReconstructionError, RosterOpsError
9
+ from rosterops.interop import is_pandas_dataframe, to_pandas, to_polars
10
+ from rosterops.version import __version__
11
+
12
+ __all__ = [
13
+ "ConfigError",
14
+ "InputError",
15
+ "ReconstructionError",
16
+ "RosterConfig",
17
+ "RosterEngine",
18
+ "RosterOpsError",
19
+ "RosterResult",
20
+ "__version__",
21
+ "is_pandas_dataframe",
22
+ "to_pandas",
23
+ "to_polars",
24
+ ]
rosterops/__main__.py ADDED
@@ -0,0 +1,10 @@
1
+ """Allow ``python -m rosterops``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from rosterops.cli import main
8
+
9
+ if __name__ == "__main__":
10
+ sys.exit(main())
rosterops/cli.py ADDED
@@ -0,0 +1,279 @@
1
+ """Command-line interface for rosterops.
2
+
3
+ Exit codes (REQUIREMENTS §13): 0 = success, 1 = completed with error-severity
4
+ findings or reconciliation failures, 2 = the run could not be performed
5
+ (invalid configuration, unreadable required input, impossible reconstruction,
6
+ or a command not yet available).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import sys
13
+ from collections.abc import Sequence
14
+
15
+ from rosterops.config import RosterConfig
16
+ from rosterops.exceptions import ConfigError
17
+ from rosterops.rules import all_rules, get_rule
18
+ from rosterops.version import __version__
19
+
20
+ __all__ = ["build_parser", "main"]
21
+
22
+
23
+ def _configure_std_streams() -> None:
24
+ """Force UTF-8 stdio so rule names (§8) render regardless of console codepage.
25
+
26
+ Windows consoles default to a legacy codepage (cp1252) that cannot encode
27
+ characters such as ``≥``, ``≠``, or the minus sign used verbatim in the rule
28
+ catalogue. Reconfiguring the streams keeps output identical across locales.
29
+ """
30
+ for stream in (sys.stdout, sys.stderr):
31
+ reconfigure = getattr(stream, "reconfigure", None)
32
+ if reconfigure is not None:
33
+ reconfigure(encoding="utf-8")
34
+
35
+
36
+ def build_parser() -> argparse.ArgumentParser:
37
+ """Build the full command-line parser (subcommands per REQUIREMENTS §13)."""
38
+ parser = argparse.ArgumentParser(
39
+ prog="rosterops",
40
+ description=(
41
+ "Deterministic roster processing and validation for pharma commercial operations."
42
+ ),
43
+ )
44
+ parser.add_argument(
45
+ "--version",
46
+ action="version",
47
+ version=f"rosterops {__version__}",
48
+ )
49
+ subparsers = parser.add_subparsers(dest="command")
50
+
51
+ process = subparsers.add_parser(
52
+ "process",
53
+ help="Reconstruct the roster as of a date from a baseline and event files",
54
+ )
55
+ process.add_argument("--baseline", required=True, help="Baseline roster file")
56
+ process.add_argument(
57
+ "--baseline-date", required=True, help="Baseline snapshot date (YYYY-MM-DD)"
58
+ )
59
+ process.add_argument("--as-of", required=True, help="Reconstruction date (YYYY-MM-DD)")
60
+ process.add_argument("--transfers", help="Transfer events file")
61
+ process.add_argument("--promotions", help="Promotion events file")
62
+ process.add_argument("--terminations", help="Termination events file")
63
+ process.add_argument("--leaves", help="Leave-of-absence events file")
64
+ process.add_argument("--returns", help="Return-from-leave events file")
65
+ process.add_argument("--hires", help="New-hire events file")
66
+ process.add_argument("--rehires", help="Rehire events file")
67
+ process.add_argument("--coverage", help="Temporary coverage events file")
68
+ process.add_argument("--territories", help="Territory master file")
69
+ process.add_argument("--managers", help="Manager master file")
70
+ process.add_argument("--config", required=True, help="Configuration YAML file")
71
+ process.add_argument("--output-dir", required=True, help="Directory for reports")
72
+ process.add_argument(
73
+ "--overwrite", action="store_true", help="Allow overwriting the output dir"
74
+ )
75
+ process.add_argument(
76
+ "--fail-on-warning",
77
+ action="store_true",
78
+ help="Exit 1 when any warning-severity finding exists",
79
+ )
80
+
81
+ validate = subparsers.add_parser(
82
+ "validate",
83
+ help="Validate a single consolidated roster snapshot",
84
+ )
85
+ validate.add_argument("--snapshot", required=True, help="Consolidated roster file")
86
+ validate.add_argument("--as-of", required=True, help="Effective date (YYYY-MM-DD)")
87
+ validate.add_argument("--territories", help="Territory master file")
88
+ validate.add_argument("--config", required=True, help="Configuration YAML file")
89
+ validate.add_argument("--output-dir", required=True, help="Directory for reports")
90
+ validate.add_argument(
91
+ "--overwrite", action="store_true", help="Allow overwriting the output dir"
92
+ )
93
+ validate.add_argument(
94
+ "--fail-on-warning",
95
+ action="store_true",
96
+ help="Exit 1 when any warning-severity finding exists",
97
+ )
98
+
99
+ validate_inputs = subparsers.add_parser(
100
+ "validate-inputs",
101
+ help="Validate configuration and inputs without reconstructing",
102
+ )
103
+ validate_inputs.add_argument("--config", required=True, help="Configuration YAML file")
104
+
105
+ rules = subparsers.add_parser("rules", help="Inspect validation rules")
106
+ rules_sub = rules.add_subparsers(dest="rules_command")
107
+ rules_sub.add_parser("list", help="List all rules")
108
+ describe = rules_sub.add_parser("describe", help="Describe one rule")
109
+ describe.add_argument("rule_id", help="Rule ID, e.g. TRF-002")
110
+ return parser
111
+
112
+
113
+ def main(argv: Sequence[str] | None = None) -> int:
114
+ """CLI entry point returning the documented process exit code."""
115
+ _configure_std_streams()
116
+ parser = build_parser()
117
+ args = parser.parse_args(list(argv) if argv is not None else None)
118
+
119
+ if args.command is None:
120
+ parser.print_help()
121
+ return 0
122
+
123
+ if args.command == "rules":
124
+ return _rules_command(args)
125
+ if args.command == "validate-inputs":
126
+ return _validate_inputs_command(args)
127
+ if args.command == "process":
128
+ return _process_command(args)
129
+ if args.command == "validate":
130
+ return _validate_command(args)
131
+ parser.error(f"unknown command: {args.command}")
132
+ return 2
133
+
134
+
135
+ _EVENT_FLAGS = (
136
+ ("transfers", "transfers"),
137
+ ("promotions", "promotions"),
138
+ ("terminations", "terminations"),
139
+ ("leaves", "leaves"),
140
+ ("returns", "returns"),
141
+ ("hires", "hires"),
142
+ ("rehires", "rehires"),
143
+ ("coverage", "coverage"),
144
+ )
145
+
146
+ _MASTER_FLAGS = (("territories", "territories"), ("managers", "managers"))
147
+
148
+
149
+ def _load_config(args: argparse.Namespace) -> RosterConfig | None:
150
+ try:
151
+ return RosterConfig.from_file(args.config)
152
+ except ConfigError as exc:
153
+ print(f"configuration error: {exc}", file=sys.stderr)
154
+ return None
155
+
156
+
157
+ def _process_command(args: argparse.Namespace) -> int:
158
+ from rosterops.engine.api import RosterEngine
159
+ from rosterops.exceptions import RosterOpsError
160
+ from rosterops.outputs import console_report, write_outputs
161
+
162
+ config = _load_config(args)
163
+ if config is None:
164
+ return 2
165
+
166
+ sources: dict[str, str] = {"baseline": args.baseline}
167
+ for attr, role in (*_EVENT_FLAGS, *_MASTER_FLAGS):
168
+ value = getattr(args, attr, None)
169
+ if value:
170
+ sources[role] = value
171
+
172
+ try:
173
+ result = RosterEngine(config).process(
174
+ **sources, # type: ignore[arg-type]
175
+ baseline_date=args.baseline_date,
176
+ as_of=args.as_of,
177
+ )
178
+ except RosterOpsError as exc:
179
+ print(f"run failed: {exc}", file=sys.stderr)
180
+ return 2
181
+
182
+ try:
183
+ report = write_outputs(
184
+ result,
185
+ args.output_dir,
186
+ input_paths=[p for p in sources.values() if isinstance(p, str)],
187
+ overwrite=args.overwrite,
188
+ )
189
+ except RosterOpsError as exc:
190
+ print(f"output error: {exc}", file=sys.stderr)
191
+ return 2
192
+
193
+ print(console_report(result, report))
194
+ return result.exit_code(fail_on_warning=args.fail_on_warning)
195
+
196
+
197
+ def _validate_command(args: argparse.Namespace) -> int:
198
+ from rosterops.engine.api import RosterEngine
199
+ from rosterops.exceptions import RosterOpsError
200
+ from rosterops.outputs import console_report, write_outputs
201
+
202
+ config = _load_config(args)
203
+ if config is None:
204
+ return 2
205
+
206
+ sources: dict[str, str] = {"snapshot": args.snapshot}
207
+ if getattr(args, "territories", None):
208
+ sources["territories"] = args.territories
209
+
210
+ try:
211
+ result = RosterEngine(config).validate_snapshot(
212
+ **sources, # type: ignore[arg-type]
213
+ as_of=args.as_of,
214
+ )
215
+ except RosterOpsError as exc:
216
+ print(f"run failed: {exc}", file=sys.stderr)
217
+ return 2
218
+
219
+ try:
220
+ report = write_outputs(
221
+ result,
222
+ args.output_dir,
223
+ input_paths=[p for p in sources.values() if isinstance(p, str)],
224
+ overwrite=args.overwrite,
225
+ )
226
+ except RosterOpsError as exc:
227
+ print(f"output error: {exc}", file=sys.stderr)
228
+ return 2
229
+
230
+ print(console_report(result, report))
231
+ return result.exit_code(fail_on_warning=args.fail_on_warning)
232
+
233
+
234
+ def _rules_command(args: argparse.Namespace) -> int:
235
+ if args.rules_command == "describe":
236
+ try:
237
+ spec = get_rule(args.rule_id)
238
+ except KeyError as exc:
239
+ print(str(exc), file=sys.stderr)
240
+ return 2
241
+ print(f"{spec.id} — {spec.name}")
242
+ print(f" group: {spec.group}")
243
+ print(f" default severity: {spec.default_severity.label}")
244
+ print(f" modes: {', '.join(spec.mode_labels)}")
245
+ if spec.params:
246
+ declared = ", ".join(f"{k}: {t.__name__}" for k, t in sorted(spec.params.items()))
247
+ print(f" parameters: {declared}")
248
+ else:
249
+ print(" parameters: none")
250
+ print(f" description: {spec.description}")
251
+ return 0
252
+
253
+ # Default: list (also covers `rules` with no subcommand).
254
+ print(f"{'ID':<10} {'GROUP':<15} {'SEVERITY':<9} {'MODES':<18} NAME")
255
+ for spec in all_rules():
256
+ print(
257
+ f"{spec.id:<10} {spec.group:<15} {spec.default_severity.label:<9} "
258
+ f"{','.join(spec.mode_labels):<18} {spec.name}"
259
+ )
260
+ print(f"\n{len(all_rules())} rules")
261
+ return 0
262
+
263
+
264
+ def _validate_inputs_command(args: argparse.Namespace) -> int:
265
+ try:
266
+ config = RosterConfig.from_file(args.config)
267
+ except ConfigError as exc:
268
+ print(f"configuration error: {exc}", file=sys.stderr)
269
+ return 2
270
+ print(f"configuration OK: {args.config}")
271
+ print(f" version: {config.version}")
272
+ print(f" grain: {config.data.grain.value}")
273
+ print(f" inputs: {len(config.inputs)} role(s) configured")
274
+ print(f" rules: {len(config.rules)} override(s)")
275
+ return 0
276
+
277
+
278
+ if __name__ == "__main__":
279
+ sys.exit(main())
rosterops/config.py ADDED
@@ -0,0 +1,380 @@
1
+ """Strict, versioned configuration for rosterops.
2
+
3
+ Separates source mapping from business policy. Parsed with ``yaml.safe_load``
4
+ only — configuration can never execute code, shell commands, or imports.
5
+ Precedence: built-in defaults < config file < CLI overrides (merged as raw
6
+ layers by :meth:`RosterConfig.from_layers` before validation).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Mapping
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from typing import Any, Literal
15
+
16
+ import yaml
17
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
18
+
19
+ from rosterops.exceptions import ConfigError
20
+ from rosterops.models import (
21
+ BASELINE_FIELDS,
22
+ KNOWN_CONFLICT_POLICIES,
23
+ KNOWN_INPUT_ROLES,
24
+ ROLE_FIELDS,
25
+ Grain,
26
+ Severity,
27
+ TerminationDateSemantics,
28
+ )
29
+ from rosterops.rules import all_rule_ids, get_rule
30
+
31
+ __all__ = ["RosterConfig"]
32
+
33
+
34
+ class InputDateSemantics(BaseModel):
35
+ """Per-source date convention overrides."""
36
+
37
+ model_config = ConfigDict(extra="forbid")
38
+
39
+ interval_end: Literal["exclusive", "inclusive"] = "exclusive"
40
+
41
+
42
+ class InputRoleConfig(BaseModel):
43
+ """Mapping of one input role's source file onto canonical fields."""
44
+
45
+ model_config = ConfigDict(extra="forbid")
46
+
47
+ path: str | None = None
48
+ format: Literal["csv", "excel", "parquet"] | None = None
49
+ sheet_name: str | None = None
50
+ header_row: int = Field(default=0, ge=0)
51
+ required: bool = True
52
+ columns: dict[str, str] = Field(default_factory=dict)
53
+ aliases: dict[str, list[str]] = Field(default_factory=dict)
54
+ date_semantics: InputDateSemantics = Field(default_factory=InputDateSemantics)
55
+ null_tokens: list[str] | None = None
56
+ date_formats: dict[str, list[str]] = Field(default_factory=dict)
57
+ value_maps: dict[str, dict[str, str]] = Field(default_factory=dict)
58
+ allowed_values: dict[str, list[str]] = Field(default_factory=dict)
59
+
60
+
61
+ class DataConfig(BaseModel):
62
+ """Declared input grain (decision D1)."""
63
+
64
+ model_config = ConfigDict(extra="forbid")
65
+
66
+ grain: Grain = Grain.EMPLOYEE_TERRITORY
67
+ grain_keys: list[str] = Field(default_factory=list)
68
+
69
+ @model_validator(mode="after")
70
+ def _validate_grain_keys(self) -> DataConfig:
71
+ if self.grain is Grain.CUSTOM:
72
+ if not self.grain_keys:
73
+ raise ValueError(
74
+ "data.grain_keys must list at least one column when grain is 'custom'"
75
+ )
76
+ elif self.grain_keys:
77
+ raise ValueError("data.grain_keys is only allowed when data.grain is 'custom'")
78
+ unknown = sorted(set(self.grain_keys) - BASELINE_FIELDS)
79
+ if unknown:
80
+ raise ValueError(
81
+ f"data.grain_keys contains unknown canonical field(s): {', '.join(unknown)}"
82
+ )
83
+ return self
84
+
85
+
86
+ class DatesConfig(BaseModel):
87
+ """Global effective-date conventions."""
88
+
89
+ model_config = ConfigDict(extra="forbid")
90
+
91
+ interval_convention: Literal["half_open"] = "half_open"
92
+ baseline_includes_effective_date: bool = True
93
+ default_date_format: str = "%Y-%m-%d"
94
+ termination_date_semantics: TerminationDateSemantics = (
95
+ TerminationDateSemantics.LAST_WORKING_DAY_INCLUSIVE
96
+ )
97
+
98
+ @field_validator("default_date_format")
99
+ @classmethod
100
+ def _valid_strftime(cls, value: str) -> str:
101
+ try:
102
+ datetime(2000, 1, 1).strftime(value)
103
+ except (ValueError, TypeError) as exc:
104
+ raise ValueError(f"invalid strftime format {value!r}: {exc}") from exc
105
+ return value
106
+
107
+
108
+ class LeavePolicy(BaseModel):
109
+ model_config = ConfigDict(extra="forbid")
110
+
111
+ retains_employment_status: bool = True
112
+ retains_home_assignment: bool = True
113
+ coverage_required_by_default: bool = False
114
+ allow_overlapping_leave_types: bool = False
115
+
116
+
117
+ class VacancyPolicy(BaseModel):
118
+ model_config = ConfigDict(extra="forbid")
119
+
120
+ derive_only_for_required_owner_territories: bool = True
121
+ count_leave_as_vacancy: bool = False
122
+
123
+
124
+ class EventPolicy(BaseModel):
125
+ model_config = ConfigDict(extra="forbid")
126
+
127
+ reject_events_on_or_before_baseline_date: bool = True
128
+ resolve_conflicting_same_date_events: bool | str = False
129
+
130
+ @field_validator("resolve_conflicting_same_date_events")
131
+ @classmethod
132
+ def _known_policy(cls, value: bool | str) -> bool | str:
133
+ if value is True:
134
+ raise ValueError(
135
+ "resolve_conflicting_same_date_events must be false or an explicit "
136
+ f"policy name; known policies: {', '.join(KNOWN_CONFLICT_POLICIES)}"
137
+ )
138
+ if isinstance(value, str) and value not in KNOWN_CONFLICT_POLICIES:
139
+ raise ValueError(
140
+ f"unknown conflict policy {value!r}; known policies: "
141
+ f"{', '.join(KNOWN_CONFLICT_POLICIES)}"
142
+ )
143
+ return value
144
+
145
+
146
+ class Policies(BaseModel):
147
+ model_config = ConfigDict(extra="forbid")
148
+
149
+ allow_multiple_active_territories: bool = True
150
+ max_primary_territories_per_employee: int = Field(default=1, ge=1)
151
+ require_active_manager: bool = True
152
+ allow_unassigned_active_employees: bool = True
153
+ leave: LeavePolicy = Field(default_factory=LeavePolicy)
154
+ vacancy: VacancyPolicy = Field(default_factory=VacancyPolicy)
155
+ events: EventPolicy = Field(default_factory=EventPolicy)
156
+
157
+
158
+ class RuleOverride(BaseModel):
159
+ """Auditable per-rule policy decision from configuration."""
160
+
161
+ model_config = ConfigDict(extra="forbid")
162
+
163
+ id: str
164
+ enabled: bool = True
165
+ severity: Severity | None = None
166
+ params: dict[str, Any] = Field(default_factory=dict)
167
+
168
+ @field_validator("id")
169
+ @classmethod
170
+ def _known_rule(cls, value: str) -> str:
171
+ if value not in all_rule_ids():
172
+ raise ValueError(f"unknown rule id {value!r} (see 'rosterops rules list')")
173
+ return value
174
+
175
+ @field_validator("severity", mode="before")
176
+ @classmethod
177
+ def _normalize_severity(cls, value: object) -> object:
178
+ if isinstance(value, str):
179
+ return value.lower()
180
+ return value
181
+
182
+
183
+ def _default_formats() -> list[Literal["csv", "parquet", "json", "excel"]]:
184
+ return ["csv", "parquet", "json"]
185
+
186
+
187
+ class OutputsConfig(BaseModel):
188
+ model_config = ConfigDict(extra="forbid")
189
+
190
+ formats: list[Literal["csv", "parquet", "json", "excel"]] = Field(
191
+ default_factory=_default_formats
192
+ )
193
+ excel_row_cap: int = Field(default=50_000, ge=1, le=1_048_576)
194
+ fail_on_warning: bool = False
195
+
196
+ @field_validator("formats")
197
+ @classmethod
198
+ def _unique_formats(cls, value: list[str]) -> list[str]:
199
+ if len(value) != len(set(value)):
200
+ raise ValueError("outputs.formats contains duplicates")
201
+ return value
202
+
203
+
204
+ class MetaConfig(BaseModel):
205
+ model_config = ConfigDict(extra="forbid")
206
+
207
+ allow_unknown_fields: bool = False
208
+
209
+
210
+ class RosterConfig(BaseModel):
211
+ """Root configuration document (``version: 1``)."""
212
+
213
+ model_config = ConfigDict(extra="allow")
214
+
215
+ version: Literal[1] = 1
216
+ data: DataConfig = Field(default_factory=DataConfig)
217
+ dates: DatesConfig = Field(default_factory=DatesConfig)
218
+ policies: Policies = Field(default_factory=Policies)
219
+ rules: list[RuleOverride] = Field(default_factory=list)
220
+ inputs: dict[str, InputRoleConfig] = Field(default_factory=dict)
221
+ outputs: OutputsConfig = Field(default_factory=OutputsConfig)
222
+ meta: MetaConfig = Field(default_factory=MetaConfig)
223
+
224
+ @model_validator(mode="after")
225
+ def _cross_validate(self) -> RosterConfig:
226
+ unknown = set(self.__pydantic_extra__ or {})
227
+ if unknown and not self.meta.allow_unknown_fields:
228
+ raise ValueError(
229
+ f"unknown configuration field(s): {', '.join(sorted(unknown))} "
230
+ "(set meta.allow_unknown_fields: true to permit forward-compatible fields)"
231
+ )
232
+
233
+ for role, cfg in self.inputs.items():
234
+ if role not in KNOWN_INPUT_ROLES:
235
+ raise ValueError(
236
+ f"unknown input role {role!r}; known roles: {', '.join(KNOWN_INPUT_ROLES)}"
237
+ )
238
+ allowed = ROLE_FIELDS[role]
239
+ for label, keys in (
240
+ ("columns", cfg.columns),
241
+ ("aliases", cfg.aliases),
242
+ ("value_maps", cfg.value_maps),
243
+ ("allowed_values", cfg.allowed_values),
244
+ ):
245
+ bad = sorted(set(keys) - allowed)
246
+ if bad:
247
+ raise ValueError(
248
+ f"inputs.{role}.{label}: unknown canonical field(s) for this role: "
249
+ f"{', '.join(bad)}"
250
+ )
251
+
252
+ seen: set[str] = set()
253
+ for override in self.rules:
254
+ if override.id in seen:
255
+ raise ValueError(f"duplicate rule override for {override.id!r}")
256
+ seen.add(override.id)
257
+ _validate_rule_params(override)
258
+ return self
259
+
260
+ @classmethod
261
+ def from_dict(cls, data: Mapping[str, Any]) -> RosterConfig:
262
+ """Build a config from a plain mapping, wrapping validation errors."""
263
+ if not isinstance(data, Mapping):
264
+ raise ConfigError(f"configuration root must be a mapping, got {type(data).__name__}")
265
+ try:
266
+ return cls.model_validate(dict(data))
267
+ except Exception as exc: # pydantic ValidationError and validator ValueErrors
268
+ raise ConfigError(_format_validation_error(exc)) from exc
269
+
270
+ @classmethod
271
+ def from_file(cls, path: str | Path) -> RosterConfig:
272
+ """Load a YAML configuration file with ``yaml.safe_load`` only."""
273
+ file_path = Path(path)
274
+ try:
275
+ text = file_path.read_text(encoding="utf-8")
276
+ except FileNotFoundError:
277
+ raise ConfigError(f"configuration file not found: {file_path}") from None
278
+ except OSError as exc:
279
+ raise ConfigError(f"configuration file not readable: {file_path}: {exc}") from exc
280
+ except UnicodeDecodeError:
281
+ raise ConfigError(f"configuration file is not valid UTF-8: {file_path}") from None
282
+ try:
283
+ loaded = yaml.safe_load(text)
284
+ except yaml.YAMLError as exc:
285
+ raise ConfigError(f"invalid YAML in {file_path}: {exc}") from exc
286
+ if loaded is None:
287
+ raise ConfigError(f"configuration file is empty: {file_path}")
288
+ if not isinstance(loaded, Mapping):
289
+ raise ConfigError(
290
+ f"configuration root must be a mapping, got {type(loaded).__name__}: {file_path}"
291
+ )
292
+ return cls.from_dict(loaded)
293
+
294
+ @classmethod
295
+ def from_layers(cls, *layers: Mapping[str, Any]) -> RosterConfig:
296
+ """Merge raw configuration layers left-to-right (later wins), then validate.
297
+
298
+ Precedence: built-in defaults are applied by the schema itself, so the
299
+ first layer is the config file and subsequent layers are CLI overrides.
300
+ """
301
+ merged: dict[str, Any] = {}
302
+ for layer in layers:
303
+ merged = _merge_dicts(merged, dict(layer))
304
+ return cls.from_dict(merged)
305
+
306
+ def resolved_dict(self) -> dict[str, Any]:
307
+ """JSON-serializable view of the fully resolved configuration."""
308
+ return self.model_dump(mode="json")
309
+
310
+ def rule_overrides(self) -> dict[str, RuleOverride]:
311
+ """Rule overrides indexed by rule ID."""
312
+ return {override.id: override for override in self.rules}
313
+
314
+ def severity_for(self, rule_id: str) -> Severity:
315
+ """Effective severity for a rule (override or default)."""
316
+ override = self.rule_overrides().get(rule_id)
317
+ if override is not None and override.severity is not None:
318
+ return override.severity
319
+ return get_rule(rule_id).default_severity
320
+
321
+ def is_enabled(self, rule_id: str) -> bool:
322
+ """Effective enabled state for a rule (override or default)."""
323
+ override = self.rule_overrides().get(rule_id)
324
+ if override is not None:
325
+ return override.enabled
326
+ return True
327
+
328
+
329
+ def _validate_rule_params(override: RuleOverride) -> None:
330
+ spec = get_rule(override.id)
331
+ for key, value in override.params.items():
332
+ if key not in spec.params:
333
+ raise ValueError(
334
+ f"rule {override.id}: unknown parameter {key!r} "
335
+ f"(declared: {', '.join(sorted(spec.params)) or 'none'})"
336
+ )
337
+ expected = spec.params[key]
338
+ if not _matches_type(value, expected):
339
+ raise ValueError(
340
+ f"rule {override.id}: parameter {key!r} must be "
341
+ f"{getattr(expected, '__name__', expected)}, got {type(value).__name__}"
342
+ )
343
+
344
+
345
+ def _matches_type(value: object, expected: type) -> bool:
346
+ if expected is bool:
347
+ return isinstance(value, bool)
348
+ if expected is int:
349
+ return isinstance(value, int) and not isinstance(value, bool)
350
+ if expected is float:
351
+ return isinstance(value, int | float) and not isinstance(value, bool)
352
+ if expected is str:
353
+ return isinstance(value, str)
354
+ return True
355
+
356
+
357
+ def _merge_dicts(base: dict[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
358
+ """Recursive merge; nested dicts merge, all other values replace."""
359
+ result = dict(base)
360
+ for key, value in override.items():
361
+ existing = result.get(key)
362
+ if isinstance(existing, Mapping) and isinstance(value, Mapping):
363
+ result[key] = _merge_dicts(dict(existing), value)
364
+ else:
365
+ result[key] = value
366
+ return result
367
+
368
+
369
+ def _format_validation_error(exc: Exception) -> str:
370
+ """Compact pydantic errors into actionable single-line messages."""
371
+ errors = getattr(exc, "errors", None)
372
+ if callable(errors):
373
+ parts = []
374
+ for err in errors():
375
+ location = ".".join(str(item) for item in err.get("loc", ()))
376
+ message = err.get("msg", str(exc))
377
+ parts.append(f"{location}: {message}" if location else message)
378
+ if parts:
379
+ return "; ".join(parts)
380
+ return str(exc)
@@ -0,0 +1 @@
1
+ """Domain primitives: intervals, employment episodes, and date semantics."""