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.
- rosterops/__init__.py +24 -0
- rosterops/__main__.py +10 -0
- rosterops/cli.py +279 -0
- rosterops/config.py +380 -0
- rosterops/domain/__init__.py +1 -0
- rosterops/domain/dates.py +122 -0
- rosterops/domain/intervals.py +162 -0
- rosterops/engine/__init__.py +20 -0
- rosterops/engine/api.py +98 -0
- rosterops/engine/baseline.py +181 -0
- rosterops/engine/context.py +89 -0
- rosterops/engine/lifecycle_rules.py +387 -0
- rosterops/engine/load.py +153 -0
- rosterops/engine/metrics.py +99 -0
- rosterops/engine/orchestrator.py +229 -0
- rosterops/engine/reconciliation.py +119 -0
- rosterops/engine/reconstruction.py +517 -0
- rosterops/engine/result.py +159 -0
- rosterops/engine/roster.py +68 -0
- rosterops/engine/snapshot_rules.py +641 -0
- rosterops/engine/state.py +234 -0
- rosterops/engine/vacancies.py +141 -0
- rosterops/events/__init__.py +37 -0
- rosterops/events/ledger.py +180 -0
- rosterops/events/normalize.py +259 -0
- rosterops/events/pipeline.py +79 -0
- rosterops/events/reconcile.py +240 -0
- rosterops/exceptions.py +24 -0
- rosterops/interop.py +66 -0
- rosterops/io/__init__.py +24 -0
- rosterops/io/ingest.py +118 -0
- rosterops/io/raw.py +68 -0
- rosterops/io/readers.py +211 -0
- rosterops/mapping/__init__.py +7 -0
- rosterops/mapping/canonical.py +523 -0
- rosterops/models.py +291 -0
- rosterops/outputs/__init__.py +12 -0
- rosterops/outputs/writer.py +352 -0
- rosterops/py.typed +0 -0
- rosterops/rules/__init__.py +53 -0
- rosterops/rules/base.py +249 -0
- rosterops/rules/coverage_rules.py +69 -0
- rosterops/rules/employment_rules.py +120 -0
- rosterops/rules/grain_rules.py +52 -0
- rosterops/rules/hierarchy_rules.py +68 -0
- rosterops/rules/identifier_rules.py +107 -0
- rosterops/rules/input_rules.py +110 -0
- rosterops/rules/leave_rules.py +141 -0
- rosterops/rules/promotion_rules.py +78 -0
- rosterops/rules/reconciliation_rules.py +129 -0
- rosterops/rules/settings.py +66 -0
- rosterops/rules/temporal_rules.py +123 -0
- rosterops/rules/territory_rules.py +130 -0
- rosterops/rules/transfer_rules.py +127 -0
- rosterops/version.py +3 -0
- rosterops-0.1.0.dist-info/METADATA +129 -0
- rosterops-0.1.0.dist-info/RECORD +60 -0
- rosterops-0.1.0.dist-info/WHEEL +4 -0
- rosterops-0.1.0.dist-info/entry_points.txt +2 -0
- 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
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."""
|