backlogops 0.1__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.
@@ -0,0 +1,299 @@
1
+ #! /usr/local/bin/python3
2
+ """Backlog and and its related releases."""
3
+
4
+ # Copyright (c) 2026, Tom Björkholm
5
+ # MIT License
6
+
7
+ from dataclasses import dataclass
8
+ from datetime import date, timedelta
9
+ from typing import Optional, TextIO, Sequence
10
+ import sys
11
+ from backlogops.backlog import Backlog, check_backlog_consistency
12
+ from backlogops.backlog_helpers import report_unknown_reference
13
+ from backlogops.releases import Release, Releases, check_releases
14
+ from backlogops.move_keys_first import move_keys_first
15
+ from backlogops.order_by_dependencies import order_by_dependencies, \
16
+ DependencyMode
17
+ from backlogops.estimate_ready_date import estimate_ready_date, \
18
+ set_plan_from_estimate
19
+ from backlogops.available_teams import AvailableTeams
20
+ from backlogops.release_backlog_updates import estimate_release_dates, \
21
+ release_plan_on_estimate, adjust_release_content, ReleaseChanges, \
22
+ ReleaseDateChanges
23
+
24
+
25
+ @dataclass
26
+ class BacklogReleases:
27
+ """A backlog and its related releases.
28
+
29
+ The releases list describes the releases that the backlog items are
30
+ delivered in. A backlog item refers to its release by name through
31
+ its ``release`` field. The releases list may hold releases that no
32
+ backlog item refers to yet, but every release named by a backlog
33
+ item is expected to be present in the releases list.
34
+
35
+ Fields:
36
+ backlog: The backlog of items.
37
+ releases: The releases the backlog items are delivered in.
38
+ """
39
+
40
+ backlog: Backlog
41
+ releases: Releases
42
+
43
+ @staticmethod
44
+ def add_to_releases(backlog: Backlog, releases: Releases) -> Releases:
45
+ """Add all releases mentioned in the backlog to the releases list.
46
+
47
+ For each backlog item that names a release, a release with that
48
+ name is added to the releases list when no release of that name
49
+ is present yet. A release added this way has no planned or
50
+ estimated date, because a backlog item only carries the release
51
+ name. The order of the existing releases is kept and any new
52
+ releases are appended in the order they are first met in the
53
+ backlog.
54
+
55
+ Args:
56
+ backlog: The backlog to take the release names from.
57
+ releases: The releases to add the missing releases to.
58
+ The argument is not modified.
59
+
60
+ Returns:
61
+ The releases list with the added releases. If all releases
62
+ named by the backlog are already present, the argument
63
+ object is returned unchanged. If any new releases are added,
64
+ a new list is returned.
65
+ """
66
+ known = {release.name for release in releases}
67
+ added: Releases = []
68
+ for item in backlog:
69
+ if item.release is not None and item.release not in known:
70
+ known.add(item.release)
71
+ added.append(Release(name=item.release))
72
+ return releases + added if added else releases
73
+
74
+ @staticmethod
75
+ def check_in_releases(backlog: Backlog, releases: Releases,
76
+ stderr_file: TextIO = sys.stderr) -> None:
77
+ """Check that all releases in the backlog are in the releases list.
78
+
79
+ For each backlog item that names a release, the release is
80
+ checked to be present by name in the releases list.
81
+
82
+ Args:
83
+ backlog: The backlog to check.
84
+ releases: The releases to check the backlog against.
85
+ stderr_file: The file to report errors to.
86
+
87
+ Raises:
88
+ KeyError: If a release named by the backlog is not present in
89
+ the releases list.
90
+ """
91
+ known = {release.name for release in releases}
92
+ for item in backlog:
93
+ if item.release is not None and item.release not in known:
94
+ report_unknown_reference('release', item.key, item.release,
95
+ stderr_file)
96
+
97
+ def update_releases(self) -> None:
98
+ """Update the releases list to include all releases in the backlog.
99
+
100
+ For each backlog item that names a release, the release is added
101
+ to the releases list when it is not already present, as
102
+ documented for :meth:`add_to_releases`.
103
+ """
104
+ self.releases = self.add_to_releases(self.backlog, self.releases)
105
+
106
+ def check_release_xref(self, stderr_file: TextIO = sys.stderr) -> None:
107
+ """Check that all releases in the backlog are in the releases list.
108
+
109
+ This is the cross reference check documented for
110
+ :meth:`check_in_releases`, applied to the member backlog and
111
+ releases.
112
+
113
+ Args:
114
+ stderr_file: The file to report errors to.
115
+
116
+ Raises:
117
+ KeyError: If a release named by the backlog is not present in
118
+ the releases list.
119
+ """
120
+ self.check_in_releases(self.backlog, self.releases, stderr_file)
121
+
122
+ def check_consistency(self, stderr_file: TextIO = sys.stderr) -> None:
123
+ """Check the internal consistency of the backlog and releases.
124
+
125
+ The backlog is checked for full consistency as documented for
126
+ :func:`check_backlog_consistency`, the releases are checked for
127
+ internal consistency and unique names as documented for
128
+ :func:`check_releases`, and every release named by the backlog
129
+ is checked to be present in the releases list.
130
+
131
+ Args:
132
+ stderr_file: The file to report errors to.
133
+
134
+ Raises:
135
+ TypeError: If a field has the wrong type.
136
+ ValueError: If a field value violates a constraint, or if
137
+ release names are not unique.
138
+ KeyError: If a key reference is invalid, or if a release
139
+ named by the backlog is not in the releases list.
140
+ """
141
+ check_backlog_consistency(self.backlog, stderr_file)
142
+ check_releases(self.releases, stderr_file)
143
+ self.check_in_releases(self.backlog, self.releases, stderr_file)
144
+
145
+ def move_keys_first(self, keys: Sequence[str],
146
+ stderr_file: TextIO = sys.stderr) -> None:
147
+ """Move the items named by ``keys`` to the front of the backlog.
148
+
149
+ The named items lead the backlog in the order of ``keys``. Each
150
+ named item is preceded by its descendants in post order: a child
151
+ comes right before its own parent, and that parent right before
152
+ the grandparent, up to the named item. Siblings keep their
153
+ original backlog order. A named descendant is placed by its own
154
+ key instead, so it may end up after its named parent. A descendant
155
+ is pulled to the front only when it appears after its named
156
+ ancestor in the backlog, so that no item is moved to a later
157
+ position because of an ancestor's key. The remaining items keep
158
+ their original order after the front block. The behavior is the
159
+ one documented for :func:`backlogops.move_keys_first`.
160
+
161
+ Args:
162
+ keys: The keys to move to the front, in the wanted order. The
163
+ keys must be unique and must exist in the backlog.
164
+ stderr_file: The file to report errors to.
165
+
166
+ Raises:
167
+ KeyError: If a key is not found in the backlog.
168
+ ValueError: If a key is not unique.
169
+ """
170
+ self.backlog = move_keys_first(self.backlog, keys, stderr_file)
171
+
172
+ def order_by_dependencies(self, *, later: bool = False,
173
+ mode: DependencyMode = DependencyMode.KEEP,
174
+ space_around: Optional[str | Sequence[str]]
175
+ = None,
176
+ stderr_file: TextIO = sys.stderr) -> None:
177
+ """Order the member backlog by dependencies.
178
+
179
+ The member backlog is replaced by a backlog ordered so that a
180
+ team can start the items in backlog order without starting an
181
+ item before the items it depends on. The behavior is the one
182
+ documented for :func:`backlogops.order_by_dependencies`.
183
+
184
+ Args:
185
+ later: How a dependency that is not yet satisfied is resolved.
186
+ If False (the default) the prerequisite item is pulled to
187
+ a position just before the dependent item. If True the
188
+ dependent item is pushed to a position just after its
189
+ prerequisites.
190
+ mode: How items that take part in a dependency are placed in
191
+ relation to items that take part in no dependency, as
192
+ documented for :class:`DependencyMode`. The default is
193
+ KEEP.
194
+ space_around: Key or keys of items that should have as many
195
+ other items as possible placed between them and the items
196
+ they depend on, and between them and the items that
197
+ depend on them. It only works well for one or very few
198
+ items. None means no item is treated this way.
199
+ stderr_file: The file to report errors to.
200
+
201
+ Raises:
202
+ TypeError: If space_around is neither None, a string, nor a
203
+ sequence of strings.
204
+ KeyError: If a space_around key is not found in the backlog.
205
+ RuntimeError: If space_around names more keys than allowed:
206
+ more than five, or more than ten percent of a backlog of
207
+ fewer than fifty items.
208
+ """
209
+ self.backlog = order_by_dependencies(self.backlog, later=later,
210
+ mode=mode,
211
+ space_around=space_around,
212
+ stderr_file=stderr_file)
213
+
214
+ def estimate_ready_date(self, available_teams: AvailableTeams,
215
+ start_date: Optional[date] = None,
216
+ stderr_file: TextIO = sys.stderr) \
217
+ -> ReleaseDateChanges:
218
+ """Estimate the ready date of the member backlog items.
219
+
220
+ The member backlog is replaced by a backlog whose items carry the
221
+ estimated ready date. The teams start working on the start date,
222
+ which defaults to today when None is given. The behavior is the
223
+ one documented for :func:`backlogops.estimate_ready_date`.
224
+
225
+ Args:
226
+ available_teams: The available teams used to estimate the
227
+ ready date, including absence, velocity and work
228
+ hours.
229
+ start_date: The day the teams start working, or None for today.
230
+ stderr_file: The file to report warnings to.
231
+ """
232
+ self.backlog = estimate_ready_date(self.backlog, available_teams,
233
+ start_date, stderr_file)
234
+ self.releases, changes = estimate_release_dates(self.releases,
235
+ self.backlog)
236
+ return changes
237
+
238
+ def set_plan_from_estimate(self, stderr_file: TextIO = sys.stderr) -> None:
239
+ """Set the planned ready dates from the estimated ready dates.
240
+
241
+ The member backlog is replaced by a backlog whose items carry the
242
+ planned ready date taken from the estimated ready date, as
243
+ documented for :func:`backlogops.set_plan_from_estimate`.
244
+
245
+ Args:
246
+ stderr_file: The file to report errors to.
247
+ """
248
+ self.backlog = set_plan_from_estimate(self.backlog, stderr_file)
249
+
250
+ def adjust_release_content(self, buffer: timedelta,
251
+ stderr_file: TextIO = sys.stderr) \
252
+ -> ReleaseChanges:
253
+ """Adjust the release content to fit the planned release dates.
254
+
255
+ The member backlog is replaced by a backlog whose items carry the
256
+ adjusted release content. The behavior is the one documented for
257
+ :func:`backlogops.adjust_release_content`.
258
+
259
+ Args:
260
+ buffer: The buffer or slack added to the estimated ready dates
261
+ to gain confidence that an item fits a release. Must
262
+ not be negative.
263
+ stderr_file: The file to report errors to.
264
+
265
+ Returns:
266
+ A record of how the release content was changed.
267
+
268
+ Raises:
269
+ ValueError: If the buffer is negative.
270
+ """
271
+ _ = stderr_file
272
+ self.backlog, changes = adjust_release_content(self.releases,
273
+ self.backlog, buffer)
274
+ return changes
275
+
276
+ def release_plan_on_estimate(self, buffer: timedelta,
277
+ stderr_file: TextIO = sys.stderr) \
278
+ -> ReleaseDateChanges:
279
+ """Set the planned release dates from the estimated release dates.
280
+
281
+ The member releases is replaced by releases whose items carry the
282
+ planned release dates taken from the estimated release dates, as
283
+ documented for :func:`backlogops.release_plan_on_estimate`.
284
+
285
+ Args:
286
+ buffer: The buffer or slack to add to the estimated release dates
287
+ to get the planned release dates. Must not be negative.
288
+ stderr_file: The file to report errors to.
289
+
290
+ Returns:
291
+ A record of how the release dates were changed.
292
+
293
+ Raises:
294
+ ValueError: If the buffer is negative.
295
+ """
296
+ _ = stderr_file
297
+ self.releases, changes = release_plan_on_estimate(self.releases,
298
+ buffer)
299
+ return changes
@@ -0,0 +1,200 @@
1
+ #! /usr/local/bin/python3
2
+ """Read and write a backlog and its releases as tables with TableIO.
3
+
4
+ A backlog and its releases form two tables. They are written to one file
5
+ (and read back) using TableIO, which supports several tables in one sheet
6
+ separated by headings. Reading walks the tables in the file and tells a
7
+ backlog table from a releases table by their columns: a table with a
8
+ ``key`` column is the backlog, a table with a ``name`` column is the
9
+ releases.
10
+
11
+ The internal field names of the data model can differ from the column
12
+ names in the file. An :class:`InputFormatConfig` carries a map from
13
+ external column name to internal field name, and an
14
+ :class:`OutputFormatConfig` carries a map from internal field name to
15
+ external column name. The dependency lists of a backlog item are stored
16
+ as one space separated string per dependency kind, and the extra fields
17
+ of a backlog item become extra columns.
18
+ """
19
+
20
+ # Copyright (c) 2026, Tom Björkholm
21
+ # MIT License
22
+
23
+ import sys
24
+ from typing import Optional, TextIO, TypeVar
25
+ from config_as_json import PathOrStr
26
+ from tableio import CAP_IGNORABLE, Capabilities, DictData, FileAccess, \
27
+ TableIO, Value, ValueFmt, access_capabilities, tio_config_create
28
+ from backlogops.backlog import Backlog
29
+ from backlogops.backlog_releases import BacklogReleases
30
+ from backlogops.io_config import InputFormatConfig, OutputFormatConfig
31
+ from backlogops.levels import Levels
32
+ from backlogops.releases import Releases
33
+ from backlogops.format_rules import FormatRules
34
+ from backlogops.apply_format_rules import format_backlog, format_releases
35
+ from backlogops.table_rows import BACKLOG_FIELDS, RELEASE_FIELDS, \
36
+ row_to_item, row_to_release
37
+
38
+ BACKLOG_HEADING = 'Backlog'
39
+ """Heading written before the backlog table."""
40
+
41
+ RELEASE_HEADING = 'Releases'
42
+ """Heading written before the releases table."""
43
+
44
+ _RenameCell = TypeVar('_RenameCell', Value, ValueFmt)
45
+
46
+
47
+ def _rename(row: dict[str, _RenameCell],
48
+ names: dict[str, str]) -> dict[str, _RenameCell]:
49
+ """Return the row with its keys translated through a name map."""
50
+ return {names.get(key, key): value for key, value in row.items()}
51
+
52
+
53
+ def _is_backlog_table(rows: DictData[Value]) -> bool:
54
+ """Return whether a table of internal-named rows is the backlog."""
55
+ return 'key' in rows[0]
56
+
57
+
58
+ def _is_release_table(rows: DictData[Value]) -> bool:
59
+ """Return whether a table of internal-named rows is the releases."""
60
+ return 'name' in rows[0]
61
+
62
+
63
+ def _collect_tables(config: InputFormatConfig, data_file: PathOrStr,
64
+ stderr_file: TextIO
65
+ ) -> tuple[DictData[Value], DictData[Value]]:
66
+ """Read every table and split it into backlog and release rows."""
67
+ capabilities = access_capabilities(FileAccess.READ, error_file=stderr_file)
68
+ backlog_rows: DictData[Value] = []
69
+ release_rows: DictData[Value] = []
70
+ with tio_config_create(config=config.tableio, file_name=data_file,
71
+ file_access=FileAccess.READ,
72
+ capabilities=capabilities) as tableio:
73
+ while True:
74
+ result = tableio.read_table_dictdata()
75
+ if not result.data:
76
+ break
77
+ rows = [_rename(row, config.to_internal) for row in result.data]
78
+ if _is_backlog_table(rows):
79
+ backlog_rows.extend(rows)
80
+ elif _is_release_table(rows):
81
+ release_rows.extend(rows)
82
+ else:
83
+ raise ValueError('A table has neither a key column (backlog) '
84
+ 'nor a name column (releases).')
85
+ return backlog_rows, release_rows
86
+
87
+
88
+ def read_backlog_releases(data_file: PathOrStr, config: InputFormatConfig,
89
+ levels: Optional[Levels] = None,
90
+ stderr_file: TextIO = sys.stderr) -> BacklogReleases:
91
+ """Read a backlog, releases, or both from one file.
92
+
93
+ Each table in the file is read and classified by its columns. The
94
+ column names are translated to internal field names through the input
95
+ configuration before classification and conversion. Field values are
96
+ converted to their internal types; consistency across items is not
97
+ checked here.
98
+
99
+ Args:
100
+ data_file: The data file to read.
101
+ config: The input configuration (format and column-name map).
102
+ levels: The levels used to resolve a string level, or None for
103
+ the default levels.
104
+ stderr_file: Stream used for user-facing diagnostics.
105
+
106
+ Returns:
107
+ The backlog and releases found in the file. Either may be empty.
108
+
109
+ Raises:
110
+ KeyError: A mandatory field is missing in a row.
111
+ TypeError: A field value has a type that cannot be converted.
112
+ ValueError: A table cannot be classified as backlog or releases.
113
+ """
114
+ backlog_rows, release_rows = _collect_tables(config, data_file,
115
+ stderr_file)
116
+ backlog: Backlog = [row_to_item(row, levels, stderr_file)
117
+ for row in backlog_rows]
118
+ releases: Releases = [row_to_release(row, stderr_file)
119
+ for row in release_rows]
120
+ return BacklogReleases(backlog=backlog, releases=releases)
121
+
122
+
123
+ def _write_capabilities(stderr_file: TextIO) -> Capabilities:
124
+ """Return CREATE capabilities that prefer borders, format and filter.
125
+
126
+ The border, cell formatting, highlight and filter features are
127
+ requested as ignorable, so a backend that supports them (such as an
128
+ Excel backend like XlsxWriter or OpenPyXL) is preferred, while
129
+ formats without them (such as CSV) and backends without them
130
+ (such as pylightxl for Excel) are still allowed.
131
+ """
132
+ base = access_capabilities(FileAccess.CREATE, error_file=stderr_file)
133
+ return base._replace(filtered_data_range=CAP_IGNORABLE,
134
+ can_write_borders=CAP_IGNORABLE,
135
+ can_fmt_row=CAP_IGNORABLE,
136
+ can_fmt_value=CAP_IGNORABLE,
137
+ can_write_highlight=CAP_IGNORABLE)
138
+
139
+
140
+ def _backlog_order(backlog: Backlog) -> list[str]:
141
+ """Return the backlog column order, with extra fields appended."""
142
+ extra = sorted({name for item in backlog for name in item.extra_fields})
143
+ return BACKLOG_FIELDS + extra
144
+
145
+
146
+ def _write_table(tableio: TableIO,
147
+ section: tuple[str, DictData[ValueFmt], list[str]],
148
+ names: dict[str, str], rules: FormatRules) -> None:
149
+ """Write one heading and one formatted, bordered table."""
150
+ heading, rows, column_order = section
151
+ tableio.write_heading(heading)
152
+ external_rows = [_rename(row, names) for row in rows]
153
+ external_order = [names.get(name, name) for name in column_order]
154
+ tableio.write_table_dictdata(external_rows, column_order=external_order,
155
+ missing_ok=True,
156
+ first_row_format=rules.first_row_format,
157
+ filtered_data_range=rules.filtered_data_range,
158
+ border_style=rules.border_style)
159
+
160
+
161
+ def _ordered_sections(data: BacklogReleases, rules: FormatRules
162
+ ) -> list[tuple[str, DictData[ValueFmt], list[str]]]:
163
+ """Return the non-empty tables to write, in the requested order."""
164
+ backlog_rows = format_backlog(data.backlog, rules)
165
+ release_rows = format_releases(data.releases, rules)
166
+ backlog = (BACKLOG_HEADING, backlog_rows, _backlog_order(data.backlog))
167
+ releases = (RELEASE_HEADING, release_rows, RELEASE_FIELDS)
168
+ sections = [backlog, releases] if rules.backlog_first else \
169
+ [releases, backlog]
170
+ return [section for section in sections if section[1]]
171
+
172
+
173
+ def write_backlog_releases(data: BacklogReleases, data_file: PathOrStr,
174
+ config: OutputFormatConfig,
175
+ format_rules: Optional[FormatRules] = None,
176
+ stderr_file: TextIO = sys.stderr) -> None:
177
+ """Write a backlog, releases, or both to one file.
178
+
179
+ Each non-empty table is written with a heading before it, so several
180
+ tables can share one file. Internal field names are translated to
181
+ external column names through the output configuration. The format
182
+ rules decide the table order, the borders, the filter range and the
183
+ cell formatting; when omitted the default :class:`FormatRules` apply.
184
+
185
+ Args:
186
+ data: The backlog and releases to write.
187
+ data_file: The data file to create.
188
+ config: The output configuration (format and column-name map).
189
+ format_rules: How to format the written data, or None for the
190
+ default format rules.
191
+ stderr_file: Stream used for user-facing diagnostics.
192
+ """
193
+ rules = FormatRules() if format_rules is None else format_rules
194
+ capabilities = _write_capabilities(stderr_file)
195
+ sections = _ordered_sections(data, rules)
196
+ with tio_config_create(config=config.tableio, file_name=data_file,
197
+ file_access=FileAccess.CREATE,
198
+ capabilities=capabilities) as tableio:
199
+ for section in sections:
200
+ _write_table(tableio, section, config.to_external, rules)
@@ -0,0 +1,45 @@
1
+ #! /usr/local/bin/python3
2
+ """Console wizard bridge that adds yes/no questions.
3
+
4
+ The workforce wizard asks yes/no questions through a
5
+ :class:`YesNoUiBridge`. This module provides the console implementation of
6
+ that bridge, built on the text-based ``WizardUiBridgeConsole`` of
7
+ ``tableio_cfg_json``, so a command-line program can drive the wizard. A yes
8
+ or no answer is read as free text such as ``y`` or ``no``, and an empty
9
+ answer chooses the default.
10
+ """
11
+
12
+ # Copyright (c) 2026, Tom Björkholm
13
+ # MIT License
14
+
15
+ from typing import Optional
16
+ from tableio_cfg_json import WizardUiBridgeConsole
17
+ from backlogops.available_teams_wizard import YesNoUiBridge
18
+
19
+
20
+ class ConsoleYesNoUiBridge(WizardUiBridgeConsole, YesNoUiBridge):
21
+ """Console wizard bridge that asks yes/no questions as free text."""
22
+
23
+ def ask_yes_no(self, question: str, default: bool) -> bool:
24
+ """Ask a yes/no question, returning ``default`` for an empty answer.
25
+
26
+ Args:
27
+ question: The yes/no question to ask.
28
+ default: The value to use when the user gives an empty answer.
29
+
30
+ Returns:
31
+ The user's choice as a boolean.
32
+ """
33
+ hint = 'Y/n' if default else 'y/N'
34
+ re_ask: Optional[str] = None
35
+ while True:
36
+ answer = self.ask(f'{question} ({hint})', re_ask)
37
+ text = answer if isinstance(answer, str) else str(answer)
38
+ if text == '':
39
+ return default
40
+ lowered = text.strip().lower()
41
+ if lowered in ('y', 'yes'):
42
+ return True
43
+ if lowered in ('n', 'no'):
44
+ return False
45
+ re_ask = "Please answer 'yes' or 'no'."
@@ -0,0 +1,59 @@
1
+ #! /usr/local/bin/python3
2
+ """Helpers for validating inclusive date ranges."""
3
+
4
+ # Copyright (c) 2026, Tom Björkholm
5
+ # MIT License
6
+
7
+ import sys
8
+ from datetime import date
9
+ from typing import TextIO
10
+ from backlogops.backlog_helpers import report_bad_value
11
+
12
+
13
+ def check_date_range(field_name: str, start: date, end: date,
14
+ stderr_file: TextIO = sys.stderr,
15
+ subject: str = 'Backlog item') -> None:
16
+ """Check that an inclusive date range is not empty.
17
+
18
+ The range covers every day from ``start`` to ``end`` inclusive, so
19
+ ``start`` must not be after ``end``.
20
+
21
+ Args:
22
+ field_name: The name of the field that holds the range.
23
+ start: The first day of the range.
24
+ end: The last day of the range.
25
+ stderr_file: The file to report errors to.
26
+ subject: What owns the field, used to start error messages.
27
+
28
+ Raises:
29
+ ValueError: If ``start`` is after ``end``.
30
+ """
31
+ if start > end:
32
+ report_bad_value(field_name, (start, end),
33
+ 'start_date is after end_date', stderr_file, subject)
34
+
35
+
36
+ def check_no_overlap(field_name: str, ranges: list[tuple[date, date]],
37
+ stderr_file: TextIO = sys.stderr,
38
+ subject: str = 'Backlog item') -> None:
39
+ """Check that inclusive date ranges do not share a day.
40
+
41
+ Each range must already be valid (start not after end). The ranges
42
+ are sorted by start day and each is compared with the next, so an
43
+ overlap is found in a single pass.
44
+
45
+ Args:
46
+ field_name: The name of the field that holds the ranges.
47
+ ranges: The inclusive ``(start, end)`` ranges to check.
48
+ stderr_file: The file to report errors to.
49
+ subject: What owns the field, used to start error messages.
50
+
51
+ Raises:
52
+ ValueError: If two ranges share a day.
53
+ """
54
+ ordered = sorted(ranges)
55
+ for earlier, later in zip(ordered, ordered[1:]):
56
+ if later[0] <= earlier[1]:
57
+ report_bad_value(field_name, later,
58
+ 'overlaps an earlier date range', stderr_file,
59
+ subject)