nepkit 0.2.0__tar.gz → 0.3.0__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.
- {nepkit-0.2.0 → nepkit-0.3.0}/PKG-INFO +5 -3
- {nepkit-0.2.0 → nepkit-0.3.0}/README.md +4 -2
- {nepkit-0.2.0 → nepkit-0.3.0}/pyproject.toml +8 -1
- {nepkit-0.2.0 → nepkit-0.3.0}/pyproject.toml.orig +9 -2
- {nepkit-0.2.0 → nepkit-0.3.0}/src/nepkit/__init__.py +7 -0
- {nepkit-0.2.0 → nepkit-0.3.0}/src/nepkit/calendar_data.py +169 -8
- {nepkit-0.2.0 → nepkit-0.3.0}/src/nepkit/cli.py +258 -19
- {nepkit-0.2.0 → nepkit-0.3.0}/src/nepkit/convert.py +48 -3
- nepkit-0.3.0/src/nepkit/data/__init__.py +1 -0
- nepkit-0.3.0/src/nepkit/exceptions.py +34 -0
- {nepkit-0.2.0 → nepkit-0.3.0}/src/nepkit/render.py +181 -9
- nepkit-0.2.0/src/nepkit/data/__init__.py +0 -0
- nepkit-0.2.0/src/nepkit/exceptions.py +0 -18
- {nepkit-0.2.0 → nepkit-0.3.0}/LICENSE +0 -0
- {nepkit-0.2.0 → nepkit-0.3.0}/src/nepkit/data/DATA.md +0 -0
- {nepkit-0.2.0 → nepkit-0.3.0}/src/nepkit/data/calendar.json +0 -0
- {nepkit-0.2.0 → nepkit-0.3.0}/src/nepkit/py.typed +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nepkit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Typed Bikram Sambat ↔ Gregorian date conversion, as a library and a CLI.
|
|
5
5
|
Keywords: nepal,bikram-sambat,nepali-date,calendar,cli
|
|
6
6
|
Author: Kritagya
|
|
@@ -43,8 +43,10 @@ command-line tool.
|
|
|
43
43
|
[](https://pypi.org/project/nepkit/)
|
|
44
44
|
[](https://github.com/akakritagya/nepkit/blob/main/LICENSE)
|
|
45
45
|
|
|
46
|
-
> **Status:**
|
|
47
|
-
> API may still change
|
|
46
|
+
> **Status:** published, pre-1.0. The library and CLI both work and are tested,
|
|
47
|
+
> but the API and the CLI's output shapes may still change — 0.2.0 appended the
|
|
48
|
+
> weekday to `bs2ad`, `ad2bs`, and `today`. Pin a version if you script against
|
|
49
|
+
> stdout, or read the first field: the date still starts the line.
|
|
48
50
|
|
|
49
51
|
[**DEMO.md**](https://github.com/akakritagya/nepkit/blob/main/DEMO.md) walks
|
|
50
52
|
through every command, option, and failure mode with real captured output.
|
|
@@ -17,8 +17,10 @@ command-line tool.
|
|
|
17
17
|
[](https://pypi.org/project/nepkit/)
|
|
18
18
|
[](https://github.com/akakritagya/nepkit/blob/main/LICENSE)
|
|
19
19
|
|
|
20
|
-
> **Status:**
|
|
21
|
-
> API may still change
|
|
20
|
+
> **Status:** published, pre-1.0. The library and CLI both work and are tested,
|
|
21
|
+
> but the API and the CLI's output shapes may still change — 0.2.0 appended the
|
|
22
|
+
> weekday to `bs2ad`, `ad2bs`, and `today`. Pin a version if you script against
|
|
23
|
+
> stdout, or read the first field: the date still starts the line.
|
|
22
24
|
|
|
23
25
|
[**DEMO.md**](https://github.com/akakritagya/nepkit/blob/main/DEMO.md) walks
|
|
24
26
|
through every command, option, and failure mode with real captured output.
|
|
@@ -28,7 +28,7 @@ license-files = ["LICENSE"]
|
|
|
28
28
|
name = "nepkit"
|
|
29
29
|
readme = "README.md"
|
|
30
30
|
requires-python = ">=3.12"
|
|
31
|
-
version = "0.
|
|
31
|
+
version = "0.3.0"
|
|
32
32
|
|
|
33
33
|
[[project.authors]]
|
|
34
34
|
name = "Kritagya"
|
|
@@ -69,8 +69,15 @@ select = [
|
|
|
69
69
|
"SIM",
|
|
70
70
|
"ANN",
|
|
71
71
|
"RUF",
|
|
72
|
+
"D",
|
|
72
73
|
]
|
|
73
74
|
|
|
75
|
+
[tool.ruff.lint.pydocstyle]
|
|
76
|
+
convention = "numpy"
|
|
77
|
+
|
|
78
|
+
[tool.ruff.lint.per-file-ignores]
|
|
79
|
+
"tests/*" = ["D"]
|
|
80
|
+
|
|
74
81
|
[tool.ruff.format]
|
|
75
82
|
docstring-code-format = true
|
|
76
83
|
line-ending = "lf"
|
|
@@ -27,7 +27,7 @@ license-files = ["LICENSE"]
|
|
|
27
27
|
name = "nepkit"
|
|
28
28
|
readme = "README.md"
|
|
29
29
|
requires-python = ">=3.12"
|
|
30
|
-
version = "0.
|
|
30
|
+
version = "0.3.0"
|
|
31
31
|
|
|
32
32
|
[project.urls]
|
|
33
33
|
Homepage = "https://github.com/akakritagya/nepkit"
|
|
@@ -56,7 +56,14 @@ line-length = 100
|
|
|
56
56
|
target-version = "py312"
|
|
57
57
|
|
|
58
58
|
[tool.ruff.lint]
|
|
59
|
-
select = ["E", "F", "I", "UP", "B", "SIM", "ANN", "RUF"]
|
|
59
|
+
select = ["E", "F", "I", "UP", "B", "SIM", "ANN", "RUF", "D"]
|
|
60
|
+
|
|
61
|
+
[tool.ruff.lint.pydocstyle]
|
|
62
|
+
convention = "numpy"
|
|
63
|
+
|
|
64
|
+
[tool.ruff.lint.per-file-ignores]
|
|
65
|
+
# Tests document intent through names and assertions, not docstrings.
|
|
66
|
+
"tests/*" = ["D"]
|
|
60
67
|
|
|
61
68
|
[tool.ruff.format]
|
|
62
69
|
docstring-code-format = true
|
|
@@ -4,6 +4,8 @@ Everything a caller needs is re-exported here, so the module layout underneath
|
|
|
4
4
|
stays free to change without breaking imports.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
from importlib.metadata import version
|
|
8
|
+
|
|
7
9
|
from nepkit.calendar_data import BS_MONTH_NAMES, MAX_BS_YEAR, MIN_BS_YEAR, days_in_month
|
|
8
10
|
from nepkit.convert import MAX_AD_DATE, MIN_AD_DATE, BSDate, ad_to_bs, bs_to_ad
|
|
9
11
|
from nepkit.exceptions import (
|
|
@@ -14,6 +16,10 @@ from nepkit.exceptions import (
|
|
|
14
16
|
NepkitError,
|
|
15
17
|
)
|
|
16
18
|
|
|
19
|
+
# Read from the installed package's metadata rather than duplicated here, so
|
|
20
|
+
# pyproject.toml's `version` stays the one place it can drift out of sync.
|
|
21
|
+
__version__: str = version("nepkit")
|
|
22
|
+
|
|
17
23
|
__all__ = [
|
|
18
24
|
"BS_MONTH_NAMES",
|
|
19
25
|
"MAX_AD_DATE",
|
|
@@ -26,6 +32,7 @@ __all__ = [
|
|
|
26
32
|
"DateOutOfRangeError",
|
|
27
33
|
"InvalidDateError",
|
|
28
34
|
"NepkitError",
|
|
35
|
+
"__version__",
|
|
29
36
|
"ad_to_bs",
|
|
30
37
|
"bs_to_ad",
|
|
31
38
|
"days_in_month",
|
|
@@ -40,12 +40,28 @@ _MAX_DAYS_IN_YEAR: Final[int] = 366
|
|
|
40
40
|
|
|
41
41
|
@dataclass(frozen=True, slots=True)
|
|
42
42
|
class BSYearData:
|
|
43
|
-
"""One BS year's month lengths, validated on construction.
|
|
43
|
+
"""One BS year's month lengths, validated on construction.
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
year : int
|
|
48
|
+
The Bikram Sambat year this row describes.
|
|
49
|
+
months : tuple of int
|
|
50
|
+
The number of days in each of the year's 12 months, in order.
|
|
51
|
+
|
|
52
|
+
Raises
|
|
53
|
+
------
|
|
54
|
+
CalendarDataError
|
|
55
|
+
If the row does not have exactly 12 months, if any month's day
|
|
56
|
+
count is outside `[29, 32]`, or if the months do not sum to a
|
|
57
|
+
plausible year length.
|
|
58
|
+
"""
|
|
44
59
|
|
|
45
60
|
year: int
|
|
46
61
|
months: tuple[int, ...]
|
|
47
62
|
|
|
48
63
|
def __post_init__(self) -> None:
|
|
64
|
+
"""Validate the month count, each month's length, and the year total."""
|
|
49
65
|
if len(self.months) != _MONTHS_PER_YEAR:
|
|
50
66
|
raise CalendarDataError(
|
|
51
67
|
f"BS {self.year}: expected {_MONTHS_PER_YEAR} months, got {len(self.months)}"
|
|
@@ -65,6 +81,19 @@ class BSYearData:
|
|
|
65
81
|
|
|
66
82
|
|
|
67
83
|
def _load_years() -> tuple[BSYearData, ...]:
|
|
84
|
+
"""Load and validate calendar.json into a sorted tuple of year rows.
|
|
85
|
+
|
|
86
|
+
Returns
|
|
87
|
+
-------
|
|
88
|
+
tuple of BSYearData
|
|
89
|
+
Every year row in calendar.json, sorted by year.
|
|
90
|
+
|
|
91
|
+
Raises
|
|
92
|
+
------
|
|
93
|
+
CalendarDataError
|
|
94
|
+
If calendar.json is missing, not valid JSON, empty, or contains a
|
|
95
|
+
malformed row.
|
|
96
|
+
"""
|
|
68
97
|
raw = resources.files("nepkit.data").joinpath("calendar.json").read_text(encoding="utf-8")
|
|
69
98
|
try:
|
|
70
99
|
rows: object = json.loads(raw)
|
|
@@ -92,6 +121,18 @@ def _load_years() -> tuple[BSYearData, ...]:
|
|
|
92
121
|
|
|
93
122
|
|
|
94
123
|
def _check_contiguous(years: tuple[BSYearData, ...]) -> None:
|
|
124
|
+
"""Raise unless `years` covers a run of consecutive BS years.
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
years : tuple of BSYearData
|
|
129
|
+
Year rows, assumed sorted by year.
|
|
130
|
+
|
|
131
|
+
Raises
|
|
132
|
+
------
|
|
133
|
+
CalendarDataError
|
|
134
|
+
If two rows share a year, or a year is skipped between rows.
|
|
135
|
+
"""
|
|
95
136
|
for previous, current in pairwise(years):
|
|
96
137
|
if current.year == previous.year:
|
|
97
138
|
raise CalendarDataError(f"calendar.json has a duplicate row for BS {current.year}")
|
|
@@ -102,7 +143,22 @@ def _check_contiguous(years: tuple[BSYearData, ...]) -> None:
|
|
|
102
143
|
|
|
103
144
|
|
|
104
145
|
def _build_cumulative_offsets(years: tuple[BSYearData, ...]) -> Mapping[int, int]:
|
|
105
|
-
"""
|
|
146
|
+
"""Compute the day offset from the anchor to the start of each BS year.
|
|
147
|
+
|
|
148
|
+
Days from the anchor to the start of each BS year, so bs_to_ad never
|
|
149
|
+
sums a range at call time.
|
|
150
|
+
|
|
151
|
+
Parameters
|
|
152
|
+
----------
|
|
153
|
+
years : tuple of BSYearData
|
|
154
|
+
Year rows, assumed sorted by year and contiguous.
|
|
155
|
+
|
|
156
|
+
Returns
|
|
157
|
+
-------
|
|
158
|
+
Mapping of int to int
|
|
159
|
+
Each year mapped to the number of days between the first year's
|
|
160
|
+
start and that year's start.
|
|
161
|
+
"""
|
|
106
162
|
offsets: dict[int, int] = {}
|
|
107
163
|
running = 0
|
|
108
164
|
for year_data in years:
|
|
@@ -131,7 +187,19 @@ TOTAL_DAYS: Final[int] = sum(sum(y.months) for y in _YEARS)
|
|
|
131
187
|
|
|
132
188
|
@dataclass(frozen=True, slots=True)
|
|
133
189
|
class Anchor:
|
|
134
|
-
"""The one verified BS
|
|
190
|
+
"""The one verified BS<->AD correspondence the whole module hangs on.
|
|
191
|
+
|
|
192
|
+
Parameters
|
|
193
|
+
----------
|
|
194
|
+
bs_year : int
|
|
195
|
+
The anchor's Bikram Sambat year.
|
|
196
|
+
bs_month : int
|
|
197
|
+
The anchor's Bikram Sambat month.
|
|
198
|
+
bs_day : int
|
|
199
|
+
The anchor's Bikram Sambat day.
|
|
200
|
+
ad_date : date
|
|
201
|
+
The Gregorian date equivalent to `(bs_year, bs_month, bs_day)`.
|
|
202
|
+
"""
|
|
135
203
|
|
|
136
204
|
bs_year: int
|
|
137
205
|
bs_month: int
|
|
@@ -140,7 +208,23 @@ class Anchor:
|
|
|
140
208
|
|
|
141
209
|
|
|
142
210
|
def _check_anchor_is_first_day_of_min_year(anchor: Anchor, min_year: int) -> None:
|
|
143
|
-
"""
|
|
211
|
+
"""Raise unless `anchor` is the first day of `min_year`.
|
|
212
|
+
|
|
213
|
+
The cumulative offset table starts at min_year, so the anchor must be
|
|
214
|
+
its 1/1.
|
|
215
|
+
|
|
216
|
+
Parameters
|
|
217
|
+
----------
|
|
218
|
+
anchor : Anchor
|
|
219
|
+
The anchor to check.
|
|
220
|
+
min_year : int
|
|
221
|
+
The bundled table's first BS year.
|
|
222
|
+
|
|
223
|
+
Raises
|
|
224
|
+
------
|
|
225
|
+
CalendarDataError
|
|
226
|
+
If `anchor` is not BS `min_year`-01-01.
|
|
227
|
+
"""
|
|
144
228
|
if (anchor.bs_year, anchor.bs_month, anchor.bs_day) != (min_year, 1, 1):
|
|
145
229
|
raise CalendarDataError(
|
|
146
230
|
f"ANCHOR {anchor.bs_year}-{anchor.bs_month:02d}-{anchor.bs_day:02d} is not "
|
|
@@ -159,7 +243,27 @@ _check_anchor_is_first_day_of_min_year(ANCHOR, MIN_BS_YEAR)
|
|
|
159
243
|
|
|
160
244
|
|
|
161
245
|
def days_in_month(year: int, month: int) -> int:
|
|
162
|
-
"""
|
|
246
|
+
"""Look up the number of days in a Bikram Sambat month.
|
|
247
|
+
|
|
248
|
+
Parameters
|
|
249
|
+
----------
|
|
250
|
+
year : int
|
|
251
|
+
The Bikram Sambat year.
|
|
252
|
+
month : int
|
|
253
|
+
The Bikram Sambat month, 1-12.
|
|
254
|
+
|
|
255
|
+
Returns
|
|
256
|
+
-------
|
|
257
|
+
int
|
|
258
|
+
The number of days in that month.
|
|
259
|
+
|
|
260
|
+
Raises
|
|
261
|
+
------
|
|
262
|
+
DateOutOfRangeError
|
|
263
|
+
If `year` is outside the bundled table's range.
|
|
264
|
+
InvalidDateError
|
|
265
|
+
If `month` is outside `[1, 12]`.
|
|
266
|
+
"""
|
|
163
267
|
if not (MIN_BS_YEAR <= year <= MAX_BS_YEAR):
|
|
164
268
|
raise DateOutOfRangeError(
|
|
165
269
|
f"BS year {year} is outside the bundled range [{MIN_BS_YEAR}, {MAX_BS_YEAR}]"
|
|
@@ -170,21 +274,78 @@ def days_in_month(year: int, month: int) -> int:
|
|
|
170
274
|
|
|
171
275
|
|
|
172
276
|
def check_bs_date(year: int, month: int, day: int) -> None:
|
|
173
|
-
"""Raise unless (year, month, day) is a real BS date inside the bundled range.
|
|
277
|
+
"""Raise unless `(year, month, day)` is a real BS date inside the bundled range.
|
|
278
|
+
|
|
279
|
+
Parameters
|
|
280
|
+
----------
|
|
281
|
+
year : int
|
|
282
|
+
The Bikram Sambat year.
|
|
283
|
+
month : int
|
|
284
|
+
The Bikram Sambat month, 1-12.
|
|
285
|
+
day : int
|
|
286
|
+
The day of the month.
|
|
287
|
+
|
|
288
|
+
Raises
|
|
289
|
+
------
|
|
290
|
+
DateOutOfRangeError
|
|
291
|
+
If `year` is outside the bundled table's range.
|
|
292
|
+
InvalidDateError
|
|
293
|
+
If `month` or `day` is not a real month or day for that year.
|
|
294
|
+
"""
|
|
174
295
|
max_day = days_in_month(year, month) # validates year and month
|
|
175
296
|
if not (1 <= day <= max_day):
|
|
176
297
|
raise InvalidDateError(f"BS {year}-{month:02d}: day {day} is outside [1, {max_day}]")
|
|
177
298
|
|
|
178
299
|
|
|
179
300
|
def days_from_anchor(year: int, month: int, day: int) -> int:
|
|
180
|
-
"""
|
|
301
|
+
"""Count the days from ANCHOR to the given BS date.
|
|
302
|
+
|
|
303
|
+
The one primitive bs_to_ad needs.
|
|
304
|
+
|
|
305
|
+
Parameters
|
|
306
|
+
----------
|
|
307
|
+
year : int
|
|
308
|
+
The Bikram Sambat year.
|
|
309
|
+
month : int
|
|
310
|
+
The Bikram Sambat month, 1-12.
|
|
311
|
+
day : int
|
|
312
|
+
The day of the month.
|
|
313
|
+
|
|
314
|
+
Returns
|
|
315
|
+
-------
|
|
316
|
+
int
|
|
317
|
+
The number of days between ANCHOR and `(year, month, day)`.
|
|
318
|
+
|
|
319
|
+
Raises
|
|
320
|
+
------
|
|
321
|
+
DateOutOfRangeError
|
|
322
|
+
If `year` is outside the bundled table's range.
|
|
323
|
+
InvalidDateError
|
|
324
|
+
If `(year, month, day)` is not a real BS date.
|
|
325
|
+
"""
|
|
181
326
|
check_bs_date(year, month, day)
|
|
182
327
|
days_before_month = sum(_BY_YEAR[year].months[: month - 1])
|
|
183
328
|
return _CUMULATIVE_OFFSET[year] + days_before_month + (day - 1)
|
|
184
329
|
|
|
185
330
|
|
|
186
331
|
def bs_from_days(days: int) -> tuple[int, int, int]:
|
|
187
|
-
"""
|
|
332
|
+
"""Invert days_from_anchor: find the BS date that many days after ANCHOR.
|
|
333
|
+
|
|
334
|
+
Parameters
|
|
335
|
+
----------
|
|
336
|
+
days : int
|
|
337
|
+
Days since ANCHOR.
|
|
338
|
+
|
|
339
|
+
Returns
|
|
340
|
+
-------
|
|
341
|
+
tuple of (int, int, int)
|
|
342
|
+
The `(year, month, day)` that many days after ANCHOR.
|
|
343
|
+
|
|
344
|
+
Raises
|
|
345
|
+
------
|
|
346
|
+
DateOutOfRangeError
|
|
347
|
+
If `days` is outside `[0, TOTAL_DAYS)`.
|
|
348
|
+
"""
|
|
188
349
|
if not (0 <= days < TOTAL_DAYS):
|
|
189
350
|
raise DateOutOfRangeError(
|
|
190
351
|
f"{days} days from the anchor is outside [0, {TOTAL_DAYS}) — the bundled "
|
|
@@ -4,6 +4,8 @@ Deliberately thin. Everything about *what* to print lives in nepkit.render and
|
|
|
4
4
|
nepkit.convert; this module decides only where output goes and what exit code
|
|
5
5
|
to leave behind.
|
|
6
6
|
|
|
7
|
+
Notes
|
|
8
|
+
-----
|
|
7
9
|
Exit codes:
|
|
8
10
|
|
|
9
11
|
0 success
|
|
@@ -74,7 +76,17 @@ _HISTORY_LENGTH: Final[int] = 1000
|
|
|
74
76
|
|
|
75
77
|
|
|
76
78
|
class ColorMode(StrEnum):
|
|
77
|
-
"""When to dress up calendar output. Grids only; conversions are never coloured.
|
|
79
|
+
"""When to dress up calendar output. Grids only; conversions are never coloured.
|
|
80
|
+
|
|
81
|
+
Attributes
|
|
82
|
+
----------
|
|
83
|
+
auto
|
|
84
|
+
Colour only when stdout is a terminal.
|
|
85
|
+
always
|
|
86
|
+
Always colour, even when redirected.
|
|
87
|
+
never
|
|
88
|
+
Never colour.
|
|
89
|
+
"""
|
|
78
90
|
|
|
79
91
|
auto = "auto"
|
|
80
92
|
always = "always"
|
|
@@ -82,12 +94,28 @@ class ColorMode(StrEnum):
|
|
|
82
94
|
|
|
83
95
|
|
|
84
96
|
def _today() -> date:
|
|
85
|
-
"""
|
|
97
|
+
"""Return today's date.
|
|
98
|
+
|
|
99
|
+
Seam for tests. Patch this rather than the clock itself.
|
|
100
|
+
|
|
101
|
+
Returns
|
|
102
|
+
-------
|
|
103
|
+
date
|
|
104
|
+
Today's Gregorian date.
|
|
105
|
+
"""
|
|
86
106
|
return date.today()
|
|
87
107
|
|
|
88
108
|
|
|
89
109
|
def _stdin_is_interactive() -> bool:
|
|
90
|
-
"""
|
|
110
|
+
"""Report whether stdin is a live terminal.
|
|
111
|
+
|
|
112
|
+
Seam for tests, and the guard that keeps `nepkit` usable in a pipeline.
|
|
113
|
+
|
|
114
|
+
Returns
|
|
115
|
+
-------
|
|
116
|
+
bool
|
|
117
|
+
True if stdin is a tty.
|
|
118
|
+
"""
|
|
91
119
|
return sys.stdin.isatty()
|
|
92
120
|
|
|
93
121
|
|
|
@@ -104,6 +132,11 @@ def _enable_line_editing() -> bool:
|
|
|
104
132
|
The except clause still matters: pyreadline3 drives the Win32 console API
|
|
105
133
|
and can fail where Python has no real console, and the prompt has to keep
|
|
106
134
|
working when it does.
|
|
135
|
+
|
|
136
|
+
Returns
|
|
137
|
+
-------
|
|
138
|
+
bool
|
|
139
|
+
True if line editing was successfully enabled.
|
|
107
140
|
"""
|
|
108
141
|
try:
|
|
109
142
|
import readline
|
|
@@ -123,6 +156,12 @@ def _dispatch(line: str) -> None:
|
|
|
123
156
|
|
|
124
157
|
standalone_mode=False makes Typer return the exit code instead of calling
|
|
125
158
|
sys.exit, so a failing command ends the line rather than the session.
|
|
159
|
+
|
|
160
|
+
Parameters
|
|
161
|
+
----------
|
|
162
|
+
line : str
|
|
163
|
+
One line of input, shell-split and dispatched as a `nepkit`
|
|
164
|
+
invocation.
|
|
126
165
|
"""
|
|
127
166
|
try:
|
|
128
167
|
get_command(app).main(shlex.split(line), prog_name="nepkit", standalone_mode=False)
|
|
@@ -134,10 +173,15 @@ def _dispatch(line: str) -> None:
|
|
|
134
173
|
|
|
135
174
|
|
|
136
175
|
def _today_line() -> str:
|
|
137
|
-
"""
|
|
176
|
+
"""Format today in both calendars, or a note that it is off the end of the table.
|
|
138
177
|
|
|
139
178
|
Decorating the banner must never stop the session from opening, which it
|
|
140
179
|
would once the clock passes MAX_AD_DATE in 2034.
|
|
180
|
+
|
|
181
|
+
Returns
|
|
182
|
+
-------
|
|
183
|
+
str
|
|
184
|
+
A rich-markup line for the REPL banner.
|
|
141
185
|
"""
|
|
142
186
|
ad = _today()
|
|
143
187
|
day = weekday_name(ad)
|
|
@@ -151,6 +195,14 @@ def _today_line() -> str:
|
|
|
151
195
|
|
|
152
196
|
|
|
153
197
|
def _print_banner(*, editing: bool) -> None:
|
|
198
|
+
"""Print the ASCII wordmark, version, today's date, and usage hint.
|
|
199
|
+
|
|
200
|
+
Parameters
|
|
201
|
+
----------
|
|
202
|
+
editing : bool
|
|
203
|
+
Whether line editing is active, to decide if the Up/Down hint is
|
|
204
|
+
shown.
|
|
205
|
+
"""
|
|
154
206
|
# A plain Console, not force_terminal: the REPL needs stdin to be a tty but
|
|
155
207
|
# stdout can still be redirected, and then this should come out unstyled.
|
|
156
208
|
console = Console()
|
|
@@ -182,6 +234,7 @@ def _clear_screen() -> None:
|
|
|
182
234
|
|
|
183
235
|
|
|
184
236
|
def _run_repl() -> None:
|
|
237
|
+
"""Run the interactive REPL until the user quits or sends EOF."""
|
|
185
238
|
editing = _enable_line_editing()
|
|
186
239
|
_clear_screen()
|
|
187
240
|
_print_banner(editing=editing)
|
|
@@ -206,9 +259,53 @@ def _run_repl() -> None:
|
|
|
206
259
|
_dispatch("--help" if word == "help" else line)
|
|
207
260
|
|
|
208
261
|
|
|
262
|
+
def _version_callback(show_version: bool) -> None:
|
|
263
|
+
"""Print the installed version and exit, if `show_version` is set.
|
|
264
|
+
|
|
265
|
+
is_eager on the option that drives this means it fires before Typer
|
|
266
|
+
parses anything else, so `nepkit --version` works even though no
|
|
267
|
+
subcommand was given.
|
|
268
|
+
|
|
269
|
+
Parameters
|
|
270
|
+
----------
|
|
271
|
+
show_version : bool
|
|
272
|
+
Whether `--version` was passed.
|
|
273
|
+
|
|
274
|
+
Raises
|
|
275
|
+
------
|
|
276
|
+
typer.Exit
|
|
277
|
+
Always, when `show_version` is True -- printing the version is the
|
|
278
|
+
entire command.
|
|
279
|
+
"""
|
|
280
|
+
if show_version:
|
|
281
|
+
typer.echo(f"nepkit {version('nepkit')}")
|
|
282
|
+
raise typer.Exit()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
VersionOption = Annotated[
|
|
286
|
+
bool,
|
|
287
|
+
typer.Option(
|
|
288
|
+
"--version",
|
|
289
|
+
callback=_version_callback,
|
|
290
|
+
is_eager=True,
|
|
291
|
+
help="Show the version and exit.",
|
|
292
|
+
),
|
|
293
|
+
]
|
|
294
|
+
|
|
295
|
+
|
|
209
296
|
@app.callback(invoke_without_command=True)
|
|
210
|
-
def main(ctx: typer.Context) -> None:
|
|
211
|
-
"""Bikram Sambat <-> Gregorian date conversion.
|
|
297
|
+
def main(ctx: typer.Context, show_version: VersionOption = False) -> None:
|
|
298
|
+
"""Bikram Sambat <-> Gregorian date conversion.
|
|
299
|
+
|
|
300
|
+
Parameters
|
|
301
|
+
----------
|
|
302
|
+
ctx : typer.Context
|
|
303
|
+
Typer's invocation context, used to detect whether a subcommand was
|
|
304
|
+
given.
|
|
305
|
+
show_version : bool, optional
|
|
306
|
+
Whether `--version` was passed. Handled entirely by
|
|
307
|
+
`_version_callback`; unused here beyond declaring the option.
|
|
308
|
+
"""
|
|
212
309
|
if ctx.invoked_subcommand is not None:
|
|
213
310
|
return
|
|
214
311
|
if not _stdin_is_interactive():
|
|
@@ -221,7 +318,19 @@ def main(ctx: typer.Context) -> None:
|
|
|
221
318
|
|
|
222
319
|
@contextmanager
|
|
223
320
|
def _reported_as_exit_code() -> Generator[None, None, None]:
|
|
224
|
-
"""Turn nepkit's date errors into stderr messages and distinct exit codes.
|
|
321
|
+
"""Turn nepkit's date errors into stderr messages and distinct exit codes.
|
|
322
|
+
|
|
323
|
+
Yields
|
|
324
|
+
------
|
|
325
|
+
None
|
|
326
|
+
Nothing; used only for its exception handling.
|
|
327
|
+
|
|
328
|
+
Raises
|
|
329
|
+
------
|
|
330
|
+
typer.Exit
|
|
331
|
+
With `EXIT_INVALID_DATE` or `EXIT_OUT_OF_RANGE`, after printing the
|
|
332
|
+
original error to stderr.
|
|
333
|
+
"""
|
|
225
334
|
try:
|
|
226
335
|
yield
|
|
227
336
|
except InvalidDateError as exc:
|
|
@@ -237,6 +346,21 @@ def _parse_ymd(text: str) -> tuple[int, int, int]:
|
|
|
237
346
|
|
|
238
347
|
Both directions go through this so that identical garbage produces an
|
|
239
348
|
identical exit code either way.
|
|
349
|
+
|
|
350
|
+
Parameters
|
|
351
|
+
----------
|
|
352
|
+
text : str
|
|
353
|
+
The date string to parse.
|
|
354
|
+
|
|
355
|
+
Returns
|
|
356
|
+
-------
|
|
357
|
+
tuple of (int, int, int)
|
|
358
|
+
The `(year, month, day)` parsed from `text`.
|
|
359
|
+
|
|
360
|
+
Raises
|
|
361
|
+
------
|
|
362
|
+
InvalidDateError
|
|
363
|
+
If `text` is not in YYYY-MM-DD form.
|
|
240
364
|
"""
|
|
241
365
|
parts = text.split("-")
|
|
242
366
|
if len(parts) != _DATE_PARTS or not all(part.isdigit() for part in parts):
|
|
@@ -246,11 +370,47 @@ def _parse_ymd(text: str) -> tuple[int, int, int]:
|
|
|
246
370
|
|
|
247
371
|
|
|
248
372
|
def _parse_bs(text: str) -> BSDate:
|
|
373
|
+
"""Parse a Bikram Sambat date string.
|
|
374
|
+
|
|
375
|
+
Parameters
|
|
376
|
+
----------
|
|
377
|
+
text : str
|
|
378
|
+
The date string, YYYY-MM-DD.
|
|
379
|
+
|
|
380
|
+
Returns
|
|
381
|
+
-------
|
|
382
|
+
BSDate
|
|
383
|
+
The parsed and validated BS date.
|
|
384
|
+
|
|
385
|
+
Raises
|
|
386
|
+
------
|
|
387
|
+
InvalidDateError
|
|
388
|
+
If `text` is not a real BS date.
|
|
389
|
+
DateOutOfRangeError
|
|
390
|
+
If the year is outside the bundled table's range.
|
|
391
|
+
"""
|
|
249
392
|
year, month, day = _parse_ymd(text)
|
|
250
393
|
return BSDate(year=year, month=month, day=day) # validates against the table
|
|
251
394
|
|
|
252
395
|
|
|
253
396
|
def _parse_ad(text: str) -> date:
|
|
397
|
+
"""Parse a Gregorian date string.
|
|
398
|
+
|
|
399
|
+
Parameters
|
|
400
|
+
----------
|
|
401
|
+
text : str
|
|
402
|
+
The date string, YYYY-MM-DD.
|
|
403
|
+
|
|
404
|
+
Returns
|
|
405
|
+
-------
|
|
406
|
+
date
|
|
407
|
+
The parsed Gregorian date.
|
|
408
|
+
|
|
409
|
+
Raises
|
|
410
|
+
------
|
|
411
|
+
InvalidDateError
|
|
412
|
+
If `text` is not a real Gregorian date.
|
|
413
|
+
"""
|
|
254
414
|
year, month, day = _parse_ymd(text)
|
|
255
415
|
try:
|
|
256
416
|
return date(year, month, day)
|
|
@@ -259,10 +419,35 @@ def _parse_ad(text: str) -> date:
|
|
|
259
419
|
|
|
260
420
|
|
|
261
421
|
def _format_bs(bs: BSDate) -> str:
|
|
422
|
+
"""Format a BSDate as zero-padded YYYY-MM-DD.
|
|
423
|
+
|
|
424
|
+
Parameters
|
|
425
|
+
----------
|
|
426
|
+
bs : BSDate
|
|
427
|
+
The date to format.
|
|
428
|
+
|
|
429
|
+
Returns
|
|
430
|
+
-------
|
|
431
|
+
str
|
|
432
|
+
The formatted date.
|
|
433
|
+
"""
|
|
262
434
|
return f"{bs.year:04d}-{bs.month:02d}-{bs.day:02d}"
|
|
263
435
|
|
|
264
436
|
|
|
265
437
|
def _emit_grid(grid: MonthGrid, kind: str, *, as_json: bool, color: ColorMode) -> None:
|
|
438
|
+
"""Print a month grid as JSON, plain text, or a coloured panel.
|
|
439
|
+
|
|
440
|
+
Parameters
|
|
441
|
+
----------
|
|
442
|
+
grid : MonthGrid
|
|
443
|
+
The grid to print.
|
|
444
|
+
kind : str
|
|
445
|
+
"bs" or "ad", included in the JSON output to identify the calendar.
|
|
446
|
+
as_json : bool
|
|
447
|
+
Whether to emit machine-readable JSON instead of a rendered grid.
|
|
448
|
+
color : ColorMode
|
|
449
|
+
When to colourise the grid; ignored when `as_json` is True.
|
|
450
|
+
"""
|
|
266
451
|
if as_json:
|
|
267
452
|
typer.echo(
|
|
268
453
|
json.dumps(
|
|
@@ -299,12 +484,20 @@ YearArg = Annotated[int | None, typer.Argument(help="Year. Defaults to the curre
|
|
|
299
484
|
MonthArg = Annotated[int | None, typer.Argument(help="Month, 1-12. Defaults to the current one.")]
|
|
300
485
|
|
|
301
486
|
|
|
302
|
-
@app.command("bs2ad")
|
|
487
|
+
@app.command("bs2ad", help="Convert a Bikram Sambat date to Gregorian.")
|
|
303
488
|
def bs_to_ad_command(
|
|
304
489
|
bs_date: Annotated[str, typer.Argument(metavar="BS_DATE", help="Bikram Sambat YYYY-MM-DD.")],
|
|
305
490
|
as_json: JsonOption = False,
|
|
306
491
|
) -> None:
|
|
307
|
-
"""Convert a Bikram Sambat date to Gregorian.
|
|
492
|
+
"""Convert a Bikram Sambat date to Gregorian.
|
|
493
|
+
|
|
494
|
+
Parameters
|
|
495
|
+
----------
|
|
496
|
+
bs_date : str
|
|
497
|
+
The Bikram Sambat date, YYYY-MM-DD.
|
|
498
|
+
as_json : bool, optional
|
|
499
|
+
Emit machine-readable JSON instead of plain text. Default is False.
|
|
500
|
+
"""
|
|
308
501
|
with _reported_as_exit_code():
|
|
309
502
|
bs = _parse_bs(bs_date)
|
|
310
503
|
ad = bs_to_ad(bs)
|
|
@@ -316,12 +509,20 @@ def bs_to_ad_command(
|
|
|
316
509
|
typer.echo(f"{ad.isoformat()} {weekday_name(ad)}")
|
|
317
510
|
|
|
318
511
|
|
|
319
|
-
@app.command("ad2bs")
|
|
512
|
+
@app.command("ad2bs", help="Convert a Gregorian date to Bikram Sambat.")
|
|
320
513
|
def ad_to_bs_command(
|
|
321
514
|
ad_date: Annotated[str, typer.Argument(metavar="AD_DATE", help="Gregorian YYYY-MM-DD.")],
|
|
322
515
|
as_json: JsonOption = False,
|
|
323
516
|
) -> None:
|
|
324
|
-
"""Convert a Gregorian date to Bikram Sambat.
|
|
517
|
+
"""Convert a Gregorian date to Bikram Sambat.
|
|
518
|
+
|
|
519
|
+
Parameters
|
|
520
|
+
----------
|
|
521
|
+
ad_date : str
|
|
522
|
+
The Gregorian date, YYYY-MM-DD.
|
|
523
|
+
as_json : bool, optional
|
|
524
|
+
Emit machine-readable JSON instead of plain text. Default is False.
|
|
525
|
+
"""
|
|
325
526
|
with _reported_as_exit_code():
|
|
326
527
|
ad = _parse_ad(ad_date)
|
|
327
528
|
bs = ad_to_bs(ad)
|
|
@@ -333,9 +534,15 @@ def ad_to_bs_command(
|
|
|
333
534
|
typer.echo(f"{_format_bs(bs)} {weekday_name(ad)}")
|
|
334
535
|
|
|
335
536
|
|
|
336
|
-
@app.command("today")
|
|
537
|
+
@app.command("today", help="Print today's date in both calendars.")
|
|
337
538
|
def today_command(as_json: JsonOption = False) -> None:
|
|
338
|
-
"""Print today's date in both calendars.
|
|
539
|
+
"""Print today's date in both calendars.
|
|
540
|
+
|
|
541
|
+
Parameters
|
|
542
|
+
----------
|
|
543
|
+
as_json : bool, optional
|
|
544
|
+
Emit machine-readable JSON instead of plain text. Default is False.
|
|
545
|
+
"""
|
|
339
546
|
ad = _today()
|
|
340
547
|
with _reported_as_exit_code():
|
|
341
548
|
bs = ad_to_bs(ad)
|
|
@@ -351,9 +558,15 @@ def today_command(as_json: JsonOption = False) -> None:
|
|
|
351
558
|
typer.echo(f"AD {ad.isoformat()} {day}")
|
|
352
559
|
|
|
353
560
|
|
|
354
|
-
@app.command("range")
|
|
561
|
+
@app.command("range", help="Print the date range nepkit has data for.")
|
|
355
562
|
def range_command(as_json: JsonOption = False) -> None:
|
|
356
|
-
"""Print the date range nepkit has data for.
|
|
563
|
+
"""Print the date range nepkit has data for.
|
|
564
|
+
|
|
565
|
+
Parameters
|
|
566
|
+
----------
|
|
567
|
+
as_json : bool, optional
|
|
568
|
+
Emit machine-readable JSON instead of plain text. Default is False.
|
|
569
|
+
"""
|
|
357
570
|
bs_min, bs_max = f"{MIN_BS_YEAR:04d}-01-01", _format_bs(ad_to_bs(MAX_AD_DATE))
|
|
358
571
|
if as_json:
|
|
359
572
|
typer.echo(
|
|
@@ -372,14 +585,27 @@ def range_command(as_json: JsonOption = False) -> None:
|
|
|
372
585
|
typer.echo(f"AD {MIN_AD_DATE.isoformat()} .. {MAX_AD_DATE.isoformat()}")
|
|
373
586
|
|
|
374
587
|
|
|
375
|
-
@app.command("calbs")
|
|
588
|
+
@app.command("calbs", help="Display a Bikram Sambat month.")
|
|
376
589
|
def calbs_command(
|
|
377
590
|
year: YearArg = None,
|
|
378
591
|
month: MonthArg = None,
|
|
379
592
|
as_json: JsonOption = False,
|
|
380
593
|
color: ColorOption = ColorMode.auto,
|
|
381
594
|
) -> None:
|
|
382
|
-
"""Display a Bikram Sambat month.
|
|
595
|
+
"""Display a Bikram Sambat month.
|
|
596
|
+
|
|
597
|
+
Parameters
|
|
598
|
+
----------
|
|
599
|
+
year : int or None, optional
|
|
600
|
+
The BS year. Defaults to the current one.
|
|
601
|
+
month : int or None, optional
|
|
602
|
+
The BS month, 1-12. Defaults to the current one.
|
|
603
|
+
as_json : bool, optional
|
|
604
|
+
Emit machine-readable JSON instead of a rendered grid. Default is
|
|
605
|
+
False.
|
|
606
|
+
color : ColorMode, optional
|
|
607
|
+
When to colourise the grid. Default is `ColorMode.auto`.
|
|
608
|
+
"""
|
|
383
609
|
with _reported_as_exit_code():
|
|
384
610
|
current = ad_to_bs(_today()) if MIN_AD_DATE <= _today() <= MAX_AD_DATE else None
|
|
385
611
|
if year is None or month is None:
|
|
@@ -393,14 +619,27 @@ def calbs_command(
|
|
|
393
619
|
_emit_grid(grid, "bs", as_json=as_json, color=color)
|
|
394
620
|
|
|
395
621
|
|
|
396
|
-
@app.command("calad")
|
|
622
|
+
@app.command("calad", help="Display a Gregorian month.")
|
|
397
623
|
def calad_command(
|
|
398
624
|
year: YearArg = None,
|
|
399
625
|
month: MonthArg = None,
|
|
400
626
|
as_json: JsonOption = False,
|
|
401
627
|
color: ColorOption = ColorMode.auto,
|
|
402
628
|
) -> None:
|
|
403
|
-
"""Display a Gregorian month.
|
|
629
|
+
"""Display a Gregorian month.
|
|
630
|
+
|
|
631
|
+
Parameters
|
|
632
|
+
----------
|
|
633
|
+
year : int or None, optional
|
|
634
|
+
The Gregorian year. Defaults to the current one.
|
|
635
|
+
month : int or None, optional
|
|
636
|
+
The Gregorian month, 1-12. Defaults to the current one.
|
|
637
|
+
as_json : bool, optional
|
|
638
|
+
Emit machine-readable JSON instead of a rendered grid. Default is
|
|
639
|
+
False.
|
|
640
|
+
color : ColorMode, optional
|
|
641
|
+
When to colourise the grid. Default is `ColorMode.auto`.
|
|
642
|
+
"""
|
|
404
643
|
with _reported_as_exit_code():
|
|
405
644
|
today = _today()
|
|
406
645
|
grid = ad_month_grid(year or today.year, month or today.month, today=today)
|
|
@@ -26,23 +26,68 @@ MAX_AD_DATE: Final[date] = ANCHOR.ad_date + timedelta(days=TOTAL_DAYS - 1)
|
|
|
26
26
|
|
|
27
27
|
@dataclass(frozen=True, slots=True)
|
|
28
28
|
class BSDate:
|
|
29
|
-
"""A Bikram Sambat date, validated on construction against the bundled table.
|
|
29
|
+
"""A Bikram Sambat date, validated on construction against the bundled table.
|
|
30
|
+
|
|
31
|
+
Parameters
|
|
32
|
+
----------
|
|
33
|
+
year : int
|
|
34
|
+
The Bikram Sambat year.
|
|
35
|
+
month : int
|
|
36
|
+
The Bikram Sambat month, 1-12.
|
|
37
|
+
day : int
|
|
38
|
+
The day of the month.
|
|
39
|
+
|
|
40
|
+
Raises
|
|
41
|
+
------
|
|
42
|
+
InvalidDateError
|
|
43
|
+
If `(year, month, day)` is not a real BS date.
|
|
44
|
+
DateOutOfRangeError
|
|
45
|
+
If `year` is outside the bundled table's range.
|
|
46
|
+
"""
|
|
30
47
|
|
|
31
48
|
year: int
|
|
32
49
|
month: int
|
|
33
50
|
day: int
|
|
34
51
|
|
|
35
52
|
def __post_init__(self) -> None:
|
|
53
|
+
"""Validate the date against the bundled table."""
|
|
36
54
|
check_bs_date(self.year, self.month, self.day)
|
|
37
55
|
|
|
38
56
|
|
|
39
57
|
def bs_to_ad(bs: BSDate) -> date:
|
|
40
|
-
"""Convert a Bikram Sambat date to its Gregorian equivalent.
|
|
58
|
+
"""Convert a Bikram Sambat date to its Gregorian equivalent.
|
|
59
|
+
|
|
60
|
+
Parameters
|
|
61
|
+
----------
|
|
62
|
+
bs : BSDate
|
|
63
|
+
The Bikram Sambat date to convert.
|
|
64
|
+
|
|
65
|
+
Returns
|
|
66
|
+
-------
|
|
67
|
+
date
|
|
68
|
+
The equivalent Gregorian date.
|
|
69
|
+
"""
|
|
41
70
|
return ANCHOR.ad_date + timedelta(days=days_from_anchor(bs.year, bs.month, bs.day))
|
|
42
71
|
|
|
43
72
|
|
|
44
73
|
def ad_to_bs(ad: date) -> BSDate:
|
|
45
|
-
"""Convert a Gregorian date to its Bikram Sambat equivalent.
|
|
74
|
+
"""Convert a Gregorian date to its Bikram Sambat equivalent.
|
|
75
|
+
|
|
76
|
+
Parameters
|
|
77
|
+
----------
|
|
78
|
+
ad : date
|
|
79
|
+
The Gregorian date to convert.
|
|
80
|
+
|
|
81
|
+
Returns
|
|
82
|
+
-------
|
|
83
|
+
BSDate
|
|
84
|
+
The equivalent Bikram Sambat date.
|
|
85
|
+
|
|
86
|
+
Raises
|
|
87
|
+
------
|
|
88
|
+
DateOutOfRangeError
|
|
89
|
+
If `ad` is outside `[MIN_AD_DATE, MAX_AD_DATE]`.
|
|
90
|
+
"""
|
|
46
91
|
if not (MIN_AD_DATE <= ad <= MAX_AD_DATE):
|
|
47
92
|
raise DateOutOfRangeError(
|
|
48
93
|
f"AD {ad.isoformat()} is outside the convertible window "
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Bundled calendar data package: calendar.json and its provenance notes in DATA.md."""
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Nepkit's exception hierarchy.
|
|
2
|
+
|
|
3
|
+
Every error nepkit raises descends from `NepkitError`, split into two
|
|
4
|
+
branches: a malformed bundled calendar table (`CalendarDataError`, raised at
|
|
5
|
+
import) and a bad date input (`DateError`, raised at call time), further
|
|
6
|
+
split into `InvalidDateError` and `DateOutOfRangeError`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NepkitError(Exception):
|
|
11
|
+
"""Base class for every error nepkit raises."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CalendarDataError(NepkitError):
|
|
15
|
+
"""Raised when the bundled calendar table is malformed.
|
|
16
|
+
|
|
17
|
+
Raised at import time, since the table is loaded and validated as soon
|
|
18
|
+
as `nepkit.calendar_data` is imported.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DateError(NepkitError):
|
|
23
|
+
"""Base class for date-input problems."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InvalidDateError(DateError):
|
|
27
|
+
"""Raised when a date is syntactically parseable but not a real BS date.
|
|
28
|
+
|
|
29
|
+
For example, BS month 13, or day 33 in a 32-day month.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DateOutOfRangeError(DateError):
|
|
34
|
+
"""Raised when a date is real but outside the bundled table's range."""
|
|
@@ -37,22 +37,48 @@ TODAY_STYLE: Final[str] = f"bold bright_{ACCENT}"
|
|
|
37
37
|
|
|
38
38
|
@dataclass(frozen=True, slots=True)
|
|
39
39
|
class MonthGrid:
|
|
40
|
-
"""One month laid out as Sunday-first weeks, plus its two heading lines.
|
|
40
|
+
"""One month laid out as Sunday-first weeks, plus its two heading lines.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
title : str
|
|
45
|
+
The month and year, e.g. "Shrawan 2081".
|
|
46
|
+
subtitle : str
|
|
47
|
+
The span this month covers in the other calendar.
|
|
48
|
+
weeks : tuple of tuple of (int or None)
|
|
49
|
+
Each week as 7 cells, Sunday first. A cell is None where the month
|
|
50
|
+
has no day, either before day 1 or after the last day.
|
|
51
|
+
today : int or None, optional
|
|
52
|
+
Day of this month to highlight, or None when today falls outside it.
|
|
53
|
+
Default is None.
|
|
54
|
+
"""
|
|
41
55
|
|
|
42
56
|
title: str
|
|
43
57
|
subtitle: str
|
|
44
58
|
weeks: tuple[tuple[int | None, ...], ...]
|
|
45
59
|
today: int | None = None
|
|
46
|
-
"""Day of this month to highlight, or None when today falls outside it."""
|
|
47
60
|
|
|
48
61
|
|
|
49
62
|
def _sunday_first_index(day: date) -> int:
|
|
50
|
-
"""
|
|
63
|
+
"""Map a Gregorian date to its Sunday-first weekday index.
|
|
64
|
+
|
|
65
|
+
Python weeks start Monday; Nepali (and `cal`) calendars start Sunday.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
day : date
|
|
70
|
+
The date to index.
|
|
71
|
+
|
|
72
|
+
Returns
|
|
73
|
+
-------
|
|
74
|
+
int
|
|
75
|
+
0 for Sunday through 6 for Saturday.
|
|
76
|
+
"""
|
|
51
77
|
return (day.weekday() + 1) % _DAYS_PER_WEEK
|
|
52
78
|
|
|
53
79
|
|
|
54
80
|
def weekday_name(day: date) -> str:
|
|
55
|
-
"""Sunday-first weekday abbreviation for a Gregorian date.
|
|
81
|
+
"""Look up the Sunday-first weekday abbreviation for a Gregorian date.
|
|
56
82
|
|
|
57
83
|
Deliberately not strftime("%a"), which is locale-dependent: under
|
|
58
84
|
LC_TIME=fr_FR that yields "mer." while the grid header still says "Wed".
|
|
@@ -60,11 +86,35 @@ def weekday_name(day: date) -> str:
|
|
|
60
86
|
it sits under can never disagree, and the output is byte-identical on every
|
|
61
87
|
machine -- which DEMO.md's captured blocks rely on, and CI now checks on
|
|
62
88
|
three platforms.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
day : date
|
|
93
|
+
The date to name.
|
|
94
|
+
|
|
95
|
+
Returns
|
|
96
|
+
-------
|
|
97
|
+
str
|
|
98
|
+
A three-letter abbreviation, e.g. "Wed".
|
|
63
99
|
"""
|
|
64
100
|
return WEEKDAY_ABBREVIATIONS[_sunday_first_index(day)]
|
|
65
101
|
|
|
66
102
|
|
|
67
103
|
def _build_weeks(lead_blanks: int, total_days: int) -> tuple[tuple[int | None, ...], ...]:
|
|
104
|
+
"""Lay `total_days` numbered cells into Sunday-first weeks.
|
|
105
|
+
|
|
106
|
+
Parameters
|
|
107
|
+
----------
|
|
108
|
+
lead_blanks : int
|
|
109
|
+
Empty cells before day 1, i.e. the Sunday-first index of day 1.
|
|
110
|
+
total_days : int
|
|
111
|
+
The number of days in the month.
|
|
112
|
+
|
|
113
|
+
Returns
|
|
114
|
+
-------
|
|
115
|
+
tuple of tuple of (int or None)
|
|
116
|
+
Each week as 7 cells; None marks a cell outside the month.
|
|
117
|
+
"""
|
|
68
118
|
cells: list[int | None] = [None] * lead_blanks + list(range(1, total_days + 1))
|
|
69
119
|
while len(cells) % _DAYS_PER_WEEK:
|
|
70
120
|
cells.append(None)
|
|
@@ -79,6 +129,28 @@ def bs_month_grid(year: int, month: int, *, today: BSDate | None = None) -> Mont
|
|
|
79
129
|
|
|
80
130
|
`today` is passed in rather than read from the clock so this stays pure and
|
|
81
131
|
the caller keeps one seam for the current date.
|
|
132
|
+
|
|
133
|
+
Parameters
|
|
134
|
+
----------
|
|
135
|
+
year : int
|
|
136
|
+
The Bikram Sambat year.
|
|
137
|
+
month : int
|
|
138
|
+
The Bikram Sambat month, 1-12.
|
|
139
|
+
today : BSDate or None, optional
|
|
140
|
+
The current BS date, used to mark today's cell if it falls inside
|
|
141
|
+
this month. Default is None.
|
|
142
|
+
|
|
143
|
+
Returns
|
|
144
|
+
-------
|
|
145
|
+
MonthGrid
|
|
146
|
+
The laid-out month.
|
|
147
|
+
|
|
148
|
+
Raises
|
|
149
|
+
------
|
|
150
|
+
InvalidDateError
|
|
151
|
+
If `month` is not `1-12`.
|
|
152
|
+
DateOutOfRangeError
|
|
153
|
+
If `year` is outside the bundled table's range.
|
|
82
154
|
"""
|
|
83
155
|
first_bs = BSDate(year=year, month=month, day=1) # validates year and month
|
|
84
156
|
total_days = days_in_month(year, month)
|
|
@@ -100,6 +172,27 @@ def ad_month_grid(year: int, month: int, *, today: date | None = None) -> MonthG
|
|
|
100
172
|
|
|
101
173
|
A Gregorian month never lines up with a BS month, so the subtitle names
|
|
102
174
|
both ends rather than pretending there is a single corresponding month.
|
|
175
|
+
|
|
176
|
+
Parameters
|
|
177
|
+
----------
|
|
178
|
+
year : int
|
|
179
|
+
The Gregorian year.
|
|
180
|
+
month : int
|
|
181
|
+
The Gregorian month, 1-12.
|
|
182
|
+
today : date or None, optional
|
|
183
|
+
The current Gregorian date, used to mark today's cell if it falls
|
|
184
|
+
inside this month. Default is None.
|
|
185
|
+
|
|
186
|
+
Returns
|
|
187
|
+
-------
|
|
188
|
+
MonthGrid
|
|
189
|
+
The laid-out month.
|
|
190
|
+
|
|
191
|
+
Raises
|
|
192
|
+
------
|
|
193
|
+
DateOutOfRangeError
|
|
194
|
+
If `month` is not `1-12`, or if the month is not fully inside the
|
|
195
|
+
convertible AD window.
|
|
103
196
|
"""
|
|
104
197
|
if not (1 <= month <= 12):
|
|
105
198
|
raise DateOutOfRangeError(f"AD month {month} is outside [1, 12]")
|
|
@@ -131,24 +224,71 @@ def ad_month_grid(year: int, month: int, *, today: date | None = None) -> MonthG
|
|
|
131
224
|
|
|
132
225
|
|
|
133
226
|
def _cell(day: int | None) -> str:
|
|
227
|
+
"""Format one grid cell.
|
|
228
|
+
|
|
229
|
+
Parameters
|
|
230
|
+
----------
|
|
231
|
+
day : int or None
|
|
232
|
+
The day number, or None for a blank cell.
|
|
233
|
+
|
|
234
|
+
Returns
|
|
235
|
+
-------
|
|
236
|
+
str
|
|
237
|
+
The day right-aligned to `_CELL_WIDTH`, or that many spaces.
|
|
238
|
+
"""
|
|
134
239
|
return f"{day:>{_CELL_WIDTH}}" if day is not None else " " * _CELL_WIDTH
|
|
135
240
|
|
|
136
241
|
|
|
137
242
|
def block_width(grid: MonthGrid) -> int:
|
|
138
|
-
"""
|
|
243
|
+
"""Compute how wide the rendered block is.
|
|
244
|
+
|
|
245
|
+
Parameters
|
|
246
|
+
----------
|
|
247
|
+
grid : MonthGrid
|
|
248
|
+
The grid to measure.
|
|
249
|
+
|
|
250
|
+
Returns
|
|
251
|
+
-------
|
|
252
|
+
int
|
|
253
|
+
The grid's width, unless a heading is wider.
|
|
254
|
+
"""
|
|
139
255
|
return max(len(WEEKDAY_HEADER), len(grid.title), len(grid.subtitle))
|
|
140
256
|
|
|
141
257
|
|
|
142
258
|
def _indent(grid: MonthGrid) -> str:
|
|
143
|
-
"""
|
|
259
|
+
"""Compute the left pad that centres the week columns under a wider heading.
|
|
144
260
|
|
|
145
261
|
Applied identically to every row, including the weekday header, so the
|
|
146
262
|
columns stay in step however far the block has to shift.
|
|
263
|
+
|
|
264
|
+
Parameters
|
|
265
|
+
----------
|
|
266
|
+
grid : MonthGrid
|
|
267
|
+
The grid being rendered.
|
|
268
|
+
|
|
269
|
+
Returns
|
|
270
|
+
-------
|
|
271
|
+
str
|
|
272
|
+
The left-padding spaces.
|
|
147
273
|
"""
|
|
148
274
|
return " " * ((block_width(grid) - len(WEEKDAY_HEADER)) // 2)
|
|
149
275
|
|
|
150
276
|
|
|
151
277
|
def _rows(grid: MonthGrid, *, mark_today: bool) -> list[str]:
|
|
278
|
+
"""Render each week of `grid` as one padded, space-joined line.
|
|
279
|
+
|
|
280
|
+
Parameters
|
|
281
|
+
----------
|
|
282
|
+
grid : MonthGrid
|
|
283
|
+
The grid to render.
|
|
284
|
+
mark_today : bool
|
|
285
|
+
Whether to wrap today's cell in rich markup.
|
|
286
|
+
|
|
287
|
+
Returns
|
|
288
|
+
-------
|
|
289
|
+
list of str
|
|
290
|
+
One line per week.
|
|
291
|
+
"""
|
|
152
292
|
pad = _indent(grid)
|
|
153
293
|
rows: list[str] = []
|
|
154
294
|
for week in grid.weeks:
|
|
@@ -163,21 +303,53 @@ def _rows(grid: MonthGrid, *, mark_today: bool) -> list[str]:
|
|
|
163
303
|
|
|
164
304
|
|
|
165
305
|
def render_body(grid: MonthGrid) -> str:
|
|
166
|
-
"""
|
|
306
|
+
"""Render the weekday header and week rows as plain text with no markup at all.
|
|
307
|
+
|
|
308
|
+
Parameters
|
|
309
|
+
----------
|
|
310
|
+
grid : MonthGrid
|
|
311
|
+
The grid to render.
|
|
312
|
+
|
|
313
|
+
Returns
|
|
314
|
+
-------
|
|
315
|
+
str
|
|
316
|
+
The header and week rows, newline-joined.
|
|
317
|
+
"""
|
|
167
318
|
return "\n".join([_indent(grid) + WEEKDAY_HEADER, *_rows(grid, mark_today=False)])
|
|
168
319
|
|
|
169
320
|
|
|
170
321
|
def render_body_markup(grid: MonthGrid) -> str:
|
|
171
|
-
"""
|
|
322
|
+
"""Render the same grid as render_body, with today's cell wrapped in rich markup.
|
|
172
323
|
|
|
173
324
|
Kept separate from render_body so the plain path cannot accidentally grow
|
|
174
325
|
escape sequences: anything piping stdout depends on it staying inert.
|
|
326
|
+
|
|
327
|
+
Parameters
|
|
328
|
+
----------
|
|
329
|
+
grid : MonthGrid
|
|
330
|
+
The grid to render.
|
|
331
|
+
|
|
332
|
+
Returns
|
|
333
|
+
-------
|
|
334
|
+
str
|
|
335
|
+
The header and week rows, with today's cell marked up.
|
|
175
336
|
"""
|
|
176
337
|
return "\n".join([_indent(grid) + WEEKDAY_HEADER, *_rows(grid, mark_today=True)])
|
|
177
338
|
|
|
178
339
|
|
|
179
340
|
def render_plain(grid: MonthGrid) -> str:
|
|
180
|
-
"""
|
|
341
|
+
"""Render the whole grid as plain text, every part centred on the same block.
|
|
342
|
+
|
|
343
|
+
Parameters
|
|
344
|
+
----------
|
|
345
|
+
grid : MonthGrid
|
|
346
|
+
The grid to render.
|
|
347
|
+
|
|
348
|
+
Returns
|
|
349
|
+
-------
|
|
350
|
+
str
|
|
351
|
+
The title, subtitle, and body, newline-joined.
|
|
352
|
+
"""
|
|
181
353
|
width = block_width(grid)
|
|
182
354
|
return "\n".join(
|
|
183
355
|
[grid.title.center(width).rstrip(), grid.subtitle.center(width).rstrip(), render_body(grid)]
|
|
File without changes
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
class NepkitError(Exception):
|
|
2
|
-
"""Base for every error nepkit raises."""
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
class CalendarDataError(NepkitError):
|
|
6
|
-
"""The bundled calendar table is malformed. Raised at import."""
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
class DateError(NepkitError):
|
|
10
|
-
"""Base for date-input problems."""
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class InvalidDateError(DateError):
|
|
14
|
-
"""Syntactically parseable, but not a real BS date."""
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
class DateOutOfRangeError(DateError):
|
|
18
|
-
"""Outside the range the bundled table covers."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|