nepkit 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.
- nepkit/__init__.py +32 -0
- nepkit/calendar_data.py +202 -0
- nepkit/cli.py +384 -0
- nepkit/convert.py +52 -0
- nepkit/data/DATA.md +52 -0
- nepkit/data/__init__.py +0 -0
- nepkit/data/calendar.json +93 -0
- nepkit/exceptions.py +18 -0
- nepkit/py.typed +0 -0
- nepkit/render.py +159 -0
- nepkit-0.1.0.dist-info/METADATA +350 -0
- nepkit-0.1.0.dist-info/RECORD +15 -0
- nepkit-0.1.0.dist-info/WHEEL +4 -0
- nepkit-0.1.0.dist-info/entry_points.txt +3 -0
- nepkit-0.1.0.dist-info/licenses/LICENSE +21 -0
nepkit/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""nepkit — typed Bikram Sambat ↔ Gregorian date conversion.
|
|
2
|
+
|
|
3
|
+
Everything a caller needs is re-exported here, so the module layout underneath
|
|
4
|
+
stays free to change without breaking imports.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from nepkit.calendar_data import BS_MONTH_NAMES, MAX_BS_YEAR, MIN_BS_YEAR, days_in_month
|
|
8
|
+
from nepkit.convert import MAX_AD_DATE, MIN_AD_DATE, BSDate, ad_to_bs, bs_to_ad
|
|
9
|
+
from nepkit.exceptions import (
|
|
10
|
+
CalendarDataError,
|
|
11
|
+
DateError,
|
|
12
|
+
DateOutOfRangeError,
|
|
13
|
+
InvalidDateError,
|
|
14
|
+
NepkitError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"BS_MONTH_NAMES",
|
|
19
|
+
"MAX_AD_DATE",
|
|
20
|
+
"MAX_BS_YEAR",
|
|
21
|
+
"MIN_AD_DATE",
|
|
22
|
+
"MIN_BS_YEAR",
|
|
23
|
+
"BSDate",
|
|
24
|
+
"CalendarDataError",
|
|
25
|
+
"DateError",
|
|
26
|
+
"DateOutOfRangeError",
|
|
27
|
+
"InvalidDateError",
|
|
28
|
+
"NepkitError",
|
|
29
|
+
"ad_to_bs",
|
|
30
|
+
"bs_to_ad",
|
|
31
|
+
"days_in_month",
|
|
32
|
+
]
|
nepkit/calendar_data.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Bundled Bikram Sambat calendar table: validation, loading, and lookups.
|
|
2
|
+
|
|
3
|
+
See src/nepkit/data/DATA.md for where the underlying numbers came from.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from bisect import bisect_right
|
|
8
|
+
from collections.abc import Mapping
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from datetime import date
|
|
11
|
+
from importlib import resources
|
|
12
|
+
from itertools import pairwise
|
|
13
|
+
from typing import Final
|
|
14
|
+
|
|
15
|
+
from nepkit.exceptions import CalendarDataError, DateOutOfRangeError, InvalidDateError
|
|
16
|
+
|
|
17
|
+
_MONTHS_PER_YEAR: Final[int] = 12
|
|
18
|
+
|
|
19
|
+
# Romanisation varies (Ashoj/Ashwin, Poush/Push, Mangsir/Marga). This set is
|
|
20
|
+
# pinned by a test so it stays stable once anything downstream prints it.
|
|
21
|
+
BS_MONTH_NAMES: Final[tuple[str, ...]] = (
|
|
22
|
+
"Baisakh",
|
|
23
|
+
"Jestha",
|
|
24
|
+
"Ashadh",
|
|
25
|
+
"Shrawan",
|
|
26
|
+
"Bhadra",
|
|
27
|
+
"Ashoj",
|
|
28
|
+
"Kartik",
|
|
29
|
+
"Mangsir",
|
|
30
|
+
"Poush",
|
|
31
|
+
"Magh",
|
|
32
|
+
"Falgun",
|
|
33
|
+
"Chaitra",
|
|
34
|
+
)
|
|
35
|
+
_MIN_DAYS_IN_MONTH: Final[int] = 29
|
|
36
|
+
_MAX_DAYS_IN_MONTH: Final[int] = 32
|
|
37
|
+
_MIN_DAYS_IN_YEAR: Final[int] = 365
|
|
38
|
+
_MAX_DAYS_IN_YEAR: Final[int] = 366
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class BSYearData:
|
|
43
|
+
"""One BS year's month lengths, validated on construction."""
|
|
44
|
+
|
|
45
|
+
year: int
|
|
46
|
+
months: tuple[int, ...]
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
if len(self.months) != _MONTHS_PER_YEAR:
|
|
50
|
+
raise CalendarDataError(
|
|
51
|
+
f"BS {self.year}: expected {_MONTHS_PER_YEAR} months, got {len(self.months)}"
|
|
52
|
+
)
|
|
53
|
+
for month, days in enumerate(self.months, start=1):
|
|
54
|
+
if not (_MIN_DAYS_IN_MONTH <= days <= _MAX_DAYS_IN_MONTH):
|
|
55
|
+
raise CalendarDataError(
|
|
56
|
+
f"BS {self.year} month {month}: {days} days is outside "
|
|
57
|
+
f"[{_MIN_DAYS_IN_MONTH}, {_MAX_DAYS_IN_MONTH}]"
|
|
58
|
+
)
|
|
59
|
+
total = sum(self.months)
|
|
60
|
+
if not (_MIN_DAYS_IN_YEAR <= total <= _MAX_DAYS_IN_YEAR):
|
|
61
|
+
raise CalendarDataError(
|
|
62
|
+
f"BS {self.year}: year totals {total} days, outside "
|
|
63
|
+
f"[{_MIN_DAYS_IN_YEAR}, {_MAX_DAYS_IN_YEAR}]"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _load_years() -> tuple[BSYearData, ...]:
|
|
68
|
+
raw = resources.files("nepkit.data").joinpath("calendar.json").read_text(encoding="utf-8")
|
|
69
|
+
try:
|
|
70
|
+
rows: object = json.loads(raw)
|
|
71
|
+
except json.JSONDecodeError as exc:
|
|
72
|
+
raise CalendarDataError(f"calendar.json is not valid JSON: {exc}") from exc
|
|
73
|
+
|
|
74
|
+
if not isinstance(rows, list) or not rows:
|
|
75
|
+
raise CalendarDataError("calendar.json must contain a non-empty list of year rows")
|
|
76
|
+
|
|
77
|
+
years: list[BSYearData] = []
|
|
78
|
+
for row in rows:
|
|
79
|
+
if not isinstance(row, dict) or "year" not in row or "months" not in row:
|
|
80
|
+
raise CalendarDataError(f"calendar.json row is malformed: {row!r}")
|
|
81
|
+
year, months = row["year"], row["months"]
|
|
82
|
+
if (
|
|
83
|
+
not isinstance(year, int)
|
|
84
|
+
or not isinstance(months, list)
|
|
85
|
+
or not all(isinstance(m, int) for m in months)
|
|
86
|
+
):
|
|
87
|
+
raise CalendarDataError(f"calendar.json row is malformed: {row!r}")
|
|
88
|
+
years.append(BSYearData(year=year, months=tuple(months)))
|
|
89
|
+
|
|
90
|
+
years.sort(key=lambda y: y.year)
|
|
91
|
+
return tuple(years)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _check_contiguous(years: tuple[BSYearData, ...]) -> None:
|
|
95
|
+
for previous, current in pairwise(years):
|
|
96
|
+
if current.year == previous.year:
|
|
97
|
+
raise CalendarDataError(f"calendar.json has a duplicate row for BS {current.year}")
|
|
98
|
+
if current.year != previous.year + 1:
|
|
99
|
+
raise CalendarDataError(
|
|
100
|
+
f"calendar.json has a gap: BS {previous.year} is followed by BS {current.year}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _build_cumulative_offsets(years: tuple[BSYearData, ...]) -> Mapping[int, int]:
|
|
105
|
+
"""Days from the anchor to the start of each BS year, so bs_to_ad never sums a range."""
|
|
106
|
+
offsets: dict[int, int] = {}
|
|
107
|
+
running = 0
|
|
108
|
+
for year_data in years:
|
|
109
|
+
offsets[year_data.year] = running
|
|
110
|
+
running += sum(year_data.months)
|
|
111
|
+
return offsets
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
_YEARS: Final[tuple[BSYearData, ...]] = _load_years()
|
|
115
|
+
_check_contiguous(_YEARS)
|
|
116
|
+
_BY_YEAR: Final[Mapping[int, BSYearData]] = {y.year: y for y in _YEARS}
|
|
117
|
+
_CUMULATIVE_OFFSET: Final[Mapping[int, int]] = _build_cumulative_offsets(_YEARS)
|
|
118
|
+
# The same offsets as a sorted sequence. _CUMULATIVE_OFFSET answers "which offset
|
|
119
|
+
# does this year start at?"; this answers the inverse, "which year contains this
|
|
120
|
+
# offset?", which is a bisect over boundaries and not a dict lookup at all.
|
|
121
|
+
_YEAR_START_OFFSETS: Final[tuple[int, ...]] = tuple(_CUMULATIVE_OFFSET[y.year] for y in _YEARS)
|
|
122
|
+
|
|
123
|
+
MIN_BS_YEAR: Final[int] = _YEARS[0].year
|
|
124
|
+
MAX_BS_YEAR: Final[int] = _YEARS[-1].year
|
|
125
|
+
|
|
126
|
+
# Derived from the table, never written down: the number of days the bundled
|
|
127
|
+
# range covers. Every other bound (the last BS date, the AD window in convert)
|
|
128
|
+
# is computed from this, so extending calendar.json moves them all at once.
|
|
129
|
+
TOTAL_DAYS: Final[int] = sum(sum(y.months) for y in _YEARS)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True, slots=True)
|
|
133
|
+
class Anchor:
|
|
134
|
+
"""The one verified BS↔AD correspondence the whole module hangs on."""
|
|
135
|
+
|
|
136
|
+
bs_year: int
|
|
137
|
+
bs_month: int
|
|
138
|
+
bs_day: int
|
|
139
|
+
ad_date: date
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _check_anchor_is_first_day_of_min_year(anchor: Anchor, min_year: int) -> None:
|
|
143
|
+
"""The cumulative offset table starts at min_year, so the anchor must be its 1/1."""
|
|
144
|
+
if (anchor.bs_year, anchor.bs_month, anchor.bs_day) != (min_year, 1, 1):
|
|
145
|
+
raise CalendarDataError(
|
|
146
|
+
f"ANCHOR {anchor.bs_year}-{anchor.bs_month:02d}-{anchor.bs_day:02d} is not "
|
|
147
|
+
f"the first day of calendar.json's first year (BS {min_year}) — the "
|
|
148
|
+
"cumulative offset table is built starting from that first year, so the "
|
|
149
|
+
"anchor must be its first day or every offset is silently wrong"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# ANCHOR pins one verified BS<->AD correspondence (see data/DATA.md) as a single
|
|
154
|
+
# fact, rather than two literals that could drift apart, because the AD side
|
|
155
|
+
# cannot be derived from calendar.json's month lengths alone.
|
|
156
|
+
ANCHOR: Final[Anchor] = Anchor(bs_year=2000, bs_month=1, bs_day=1, ad_date=date(1943, 4, 14))
|
|
157
|
+
|
|
158
|
+
_check_anchor_is_first_day_of_min_year(ANCHOR, MIN_BS_YEAR)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def days_in_month(year: int, month: int) -> int:
|
|
162
|
+
"""Number of days in the given BS month. Raises on an out-of-range year or bad month."""
|
|
163
|
+
if not (MIN_BS_YEAR <= year <= MAX_BS_YEAR):
|
|
164
|
+
raise DateOutOfRangeError(
|
|
165
|
+
f"BS year {year} is outside the bundled range [{MIN_BS_YEAR}, {MAX_BS_YEAR}]"
|
|
166
|
+
)
|
|
167
|
+
if not (1 <= month <= _MONTHS_PER_YEAR):
|
|
168
|
+
raise InvalidDateError(f"BS month {month} is outside [1, {_MONTHS_PER_YEAR}]")
|
|
169
|
+
return _BY_YEAR[year].months[month - 1]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
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."""
|
|
174
|
+
max_day = days_in_month(year, month) # validates year and month
|
|
175
|
+
if not (1 <= day <= max_day):
|
|
176
|
+
raise InvalidDateError(f"BS {year}-{month:02d}: day {day} is outside [1, {max_day}]")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def days_from_anchor(year: int, month: int, day: int) -> int:
|
|
180
|
+
"""Days from ANCHOR to the given BS date. The one primitive bs_to_ad needs."""
|
|
181
|
+
check_bs_date(year, month, day)
|
|
182
|
+
days_before_month = sum(_BY_YEAR[year].months[: month - 1])
|
|
183
|
+
return _CUMULATIVE_OFFSET[year] + days_before_month + (day - 1)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def bs_from_days(days: int) -> tuple[int, int, int]:
|
|
187
|
+
"""Inverse of days_from_anchor: the BS date that many days after ANCHOR."""
|
|
188
|
+
if not (0 <= days < TOTAL_DAYS):
|
|
189
|
+
raise DateOutOfRangeError(
|
|
190
|
+
f"{days} days from the anchor is outside [0, {TOTAL_DAYS}) — the bundled "
|
|
191
|
+
f"table covers BS {MIN_BS_YEAR} through {MAX_BS_YEAR}"
|
|
192
|
+
)
|
|
193
|
+
index = bisect_right(_YEAR_START_OFFSETS, days) - 1
|
|
194
|
+
year_data = _YEARS[index]
|
|
195
|
+
remainder = days - _YEAR_START_OFFSETS[index]
|
|
196
|
+
for month, length in enumerate(year_data.months, start=1):
|
|
197
|
+
if remainder < length:
|
|
198
|
+
return (year_data.year, month, remainder + 1)
|
|
199
|
+
remainder -= length
|
|
200
|
+
# Unreachable: the bounds check above puts `days` inside the span, and bisect
|
|
201
|
+
# picks the year containing it, so `remainder` is always < that year's total.
|
|
202
|
+
raise AssertionError(f"bs_from_days({days}) escaped BS {year_data.year}'s months")
|
nepkit/cli.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Command-line entry point for nepkit.
|
|
2
|
+
|
|
3
|
+
Deliberately thin. Everything about *what* to print lives in nepkit.render and
|
|
4
|
+
nepkit.convert; this module decides only where output goes and what exit code
|
|
5
|
+
to leave behind.
|
|
6
|
+
|
|
7
|
+
Exit codes:
|
|
8
|
+
|
|
9
|
+
0 success
|
|
10
|
+
2 usage error -- bad flag or unknown command (Typer's own)
|
|
11
|
+
3 InvalidDateError -- not a real date
|
|
12
|
+
4 DateOutOfRangeError -- a real date, but outside the bundled table
|
|
13
|
+
|
|
14
|
+
3 and 4 are separate on purpose. The exception hierarchy exists so a caller can
|
|
15
|
+
distinguish "you typed something that is not a date" from "that date is real
|
|
16
|
+
but I have no data for it"; collapsing both into 1 would throw that away at the
|
|
17
|
+
one boundary where it is most useful.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import shlex
|
|
22
|
+
import sys
|
|
23
|
+
from collections.abc import Generator
|
|
24
|
+
from contextlib import contextmanager
|
|
25
|
+
from datetime import date
|
|
26
|
+
from enum import StrEnum
|
|
27
|
+
from importlib.metadata import version
|
|
28
|
+
from typing import Annotated, Final
|
|
29
|
+
|
|
30
|
+
import typer
|
|
31
|
+
from rich.console import Console
|
|
32
|
+
from rich.panel import Panel
|
|
33
|
+
from typer.main import get_command
|
|
34
|
+
|
|
35
|
+
from nepkit.calendar_data import MAX_BS_YEAR, MIN_BS_YEAR
|
|
36
|
+
from nepkit.convert import MAX_AD_DATE, MIN_AD_DATE, BSDate, ad_to_bs, bs_to_ad
|
|
37
|
+
from nepkit.exceptions import DateOutOfRangeError, InvalidDateError
|
|
38
|
+
from nepkit.render import (
|
|
39
|
+
MonthGrid,
|
|
40
|
+
ad_month_grid,
|
|
41
|
+
block_width,
|
|
42
|
+
bs_month_grid,
|
|
43
|
+
render_body_markup,
|
|
44
|
+
render_plain,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
EXIT_INVALID_DATE: Final[int] = 3
|
|
48
|
+
EXIT_OUT_OF_RANGE: Final[int] = 4
|
|
49
|
+
|
|
50
|
+
_DATE_PARTS: Final[int] = 3
|
|
51
|
+
|
|
52
|
+
app = typer.Typer(
|
|
53
|
+
name="nepkit",
|
|
54
|
+
help="Bikram Sambat (BS) <-> Gregorian (AD) date conversion.",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
# figlet "standard", composed glyph by glyph so the columns actually line up.
|
|
58
|
+
_ASCII_TITLE: Final[str] = r"""
|
|
59
|
+
_ _ _
|
|
60
|
+
_ __ ___ _ __ | | __(_)| |_
|
|
61
|
+
| '_ \ / _ \ | '_ \ | |/ /| || __|
|
|
62
|
+
| | | || __/ | |_) || < | || |_
|
|
63
|
+
|_| |_| \___| | .__/ |_|\_\|_| \__|
|
|
64
|
+
|_|"""
|
|
65
|
+
|
|
66
|
+
_PROMPT: Final[str] = "nepkit> "
|
|
67
|
+
_QUIT_WORDS: Final[frozenset[str]] = frozenset({"quit", "exit", "q"})
|
|
68
|
+
# Prompt-only words. They are not subcommands because they mean nothing outside
|
|
69
|
+
# a session -- `nepkit clear` should stay a usage error, not clear your screen.
|
|
70
|
+
_CLEAR_WORDS: Final[frozenset[str]] = frozenset({"clear", "cls"})
|
|
71
|
+
_HISTORY_LENGTH: Final[int] = 1000
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ColorMode(StrEnum):
|
|
75
|
+
"""When to dress up calendar output. Grids only; conversions are never coloured."""
|
|
76
|
+
|
|
77
|
+
auto = "auto"
|
|
78
|
+
always = "always"
|
|
79
|
+
never = "never"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _today() -> date:
|
|
83
|
+
"""Seam for tests. Patch this rather than the clock itself."""
|
|
84
|
+
return date.today()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _stdin_is_interactive() -> bool:
|
|
88
|
+
"""Seam for tests, and the guard that keeps `nepkit` usable in a pipeline."""
|
|
89
|
+
return sys.stdin.isatty()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _enable_line_editing() -> bool:
|
|
93
|
+
"""Give input() cursor keys and history by importing readline.
|
|
94
|
+
|
|
95
|
+
The import *is* the mechanism: readline hooks itself into input(), so
|
|
96
|
+
Up/Down recall and Ctrl-A/Ctrl-E editing arrive without another line of
|
|
97
|
+
code. It only engages on a real terminal, which is why no test can observe
|
|
98
|
+
the recall itself.
|
|
99
|
+
|
|
100
|
+
Absent on Windows, where the prompt keeps working without editing.
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
import readline
|
|
104
|
+
except ImportError:
|
|
105
|
+
return False
|
|
106
|
+
readline.set_history_length(_HISTORY_LENGTH)
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _dispatch(line: str) -> None:
|
|
111
|
+
"""Run one REPL line through the same command table the shell uses.
|
|
112
|
+
|
|
113
|
+
standalone_mode=False makes Typer return the exit code instead of calling
|
|
114
|
+
sys.exit, so a failing command ends the line rather than the session.
|
|
115
|
+
"""
|
|
116
|
+
try:
|
|
117
|
+
get_command(app).main(shlex.split(line), prog_name="nepkit", standalone_mode=False)
|
|
118
|
+
except Exception as exc:
|
|
119
|
+
# Deliberately broad. A typo must not throw the user out of the session,
|
|
120
|
+
# and Typer's usage errors live in typer._click.exceptions -- a private
|
|
121
|
+
# module this should not be importing to name them precisely.
|
|
122
|
+
typer.echo(f"error: {exc}", err=True)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _today_line() -> str:
|
|
126
|
+
"""Today in both calendars, or a note that it is off the end of the table.
|
|
127
|
+
|
|
128
|
+
Decorating the banner must never stop the session from opening, which it
|
|
129
|
+
would once the clock passes MAX_AD_DATE in 2034.
|
|
130
|
+
"""
|
|
131
|
+
ad = _today()
|
|
132
|
+
if not (MIN_AD_DATE <= ad <= MAX_AD_DATE):
|
|
133
|
+
return f"[dim]Today [/dim] AD {ad.isoformat()} [dim](outside the supported range)[/dim]"
|
|
134
|
+
return f"[dim]Today [/dim] BS [bold]{_format_bs(ad_to_bs(ad))}[/bold] AD {ad.isoformat()}"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _print_banner(*, editing: bool) -> None:
|
|
138
|
+
# A plain Console, not force_terminal: the REPL needs stdin to be a tty but
|
|
139
|
+
# stdout can still be redirected, and then this should come out unstyled.
|
|
140
|
+
console = Console()
|
|
141
|
+
console.print(_ASCII_TITLE, style="bold cyan", markup=False, highlight=False)
|
|
142
|
+
# Flush left, so the info block lines up with the wordmark's left edge.
|
|
143
|
+
console.print(
|
|
144
|
+
f"[bold]nepkit[/bold] [dim]v{version('nepkit')}[/dim] "
|
|
145
|
+
f"[dim]-[/dim] Bikram Sambat (BS) <-> Gregorian (AD) date conversion",
|
|
146
|
+
soft_wrap=True,
|
|
147
|
+
highlight=False,
|
|
148
|
+
)
|
|
149
|
+
console.print(_today_line(), soft_wrap=True, highlight=False)
|
|
150
|
+
hint = " Up/Down recalls history." if editing else ""
|
|
151
|
+
console.print(
|
|
152
|
+
f"\n[dim]Type a command, 'help', 'clear', or 'quit'.{hint}[/dim]",
|
|
153
|
+
soft_wrap=True,
|
|
154
|
+
highlight=False,
|
|
155
|
+
)
|
|
156
|
+
console.print()
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _clear_screen() -> None:
|
|
160
|
+
"""Wipe the terminal.
|
|
161
|
+
|
|
162
|
+
A no-op when stdout is redirected, so a session whose output is being
|
|
163
|
+
captured never has escape sequences written into the capture.
|
|
164
|
+
"""
|
|
165
|
+
Console().clear()
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _run_repl() -> None:
|
|
169
|
+
editing = _enable_line_editing()
|
|
170
|
+
_clear_screen()
|
|
171
|
+
_print_banner(editing=editing)
|
|
172
|
+
while True:
|
|
173
|
+
try:
|
|
174
|
+
line = input(_PROMPT).strip()
|
|
175
|
+
except EOFError: # Ctrl-D
|
|
176
|
+
typer.echo("")
|
|
177
|
+
return
|
|
178
|
+
except KeyboardInterrupt: # Ctrl-C abandons the line, not the session
|
|
179
|
+
typer.echo("")
|
|
180
|
+
continue
|
|
181
|
+
if not line:
|
|
182
|
+
continue
|
|
183
|
+
word = line.lower()
|
|
184
|
+
if word in _QUIT_WORDS:
|
|
185
|
+
return
|
|
186
|
+
if word in _CLEAR_WORDS:
|
|
187
|
+
_clear_screen()
|
|
188
|
+
_print_banner(editing=editing)
|
|
189
|
+
continue
|
|
190
|
+
_dispatch("--help" if word == "help" else line)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@app.callback(invoke_without_command=True)
|
|
194
|
+
def main(ctx: typer.Context) -> None:
|
|
195
|
+
"""Bikram Sambat <-> Gregorian date conversion."""
|
|
196
|
+
if ctx.invoked_subcommand is not None:
|
|
197
|
+
return
|
|
198
|
+
if not _stdin_is_interactive():
|
|
199
|
+
# No terminal means no prompt: printing help and exiting 2 keeps the
|
|
200
|
+
# old behaviour for scripts, which would otherwise block on stdin.
|
|
201
|
+
typer.echo(ctx.get_help())
|
|
202
|
+
raise typer.Exit(2)
|
|
203
|
+
_run_repl()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@contextmanager
|
|
207
|
+
def _reported_as_exit_code() -> Generator[None, None, None]:
|
|
208
|
+
"""Turn nepkit's date errors into stderr messages and distinct exit codes."""
|
|
209
|
+
try:
|
|
210
|
+
yield
|
|
211
|
+
except InvalidDateError as exc:
|
|
212
|
+
typer.echo(str(exc), err=True)
|
|
213
|
+
raise typer.Exit(EXIT_INVALID_DATE) from exc
|
|
214
|
+
except DateOutOfRangeError as exc:
|
|
215
|
+
typer.echo(str(exc), err=True)
|
|
216
|
+
raise typer.Exit(EXIT_OUT_OF_RANGE) from exc
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _parse_ymd(text: str) -> tuple[int, int, int]:
|
|
220
|
+
"""Parse YYYY-MM-DD without regard to which calendar it belongs to.
|
|
221
|
+
|
|
222
|
+
Both directions go through this so that identical garbage produces an
|
|
223
|
+
identical exit code either way.
|
|
224
|
+
"""
|
|
225
|
+
parts = text.split("-")
|
|
226
|
+
if len(parts) != _DATE_PARTS or not all(part.isdigit() for part in parts):
|
|
227
|
+
raise InvalidDateError(f"{text!r} is not a date in YYYY-MM-DD form")
|
|
228
|
+
year, month, day = (int(part) for part in parts)
|
|
229
|
+
return year, month, day
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _parse_bs(text: str) -> BSDate:
|
|
233
|
+
year, month, day = _parse_ymd(text)
|
|
234
|
+
return BSDate(year=year, month=month, day=day) # validates against the table
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _parse_ad(text: str) -> date:
|
|
238
|
+
year, month, day = _parse_ymd(text)
|
|
239
|
+
try:
|
|
240
|
+
return date(year, month, day)
|
|
241
|
+
except ValueError as exc:
|
|
242
|
+
raise InvalidDateError(f"AD {text} is not a real Gregorian date") from exc
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _format_bs(bs: BSDate) -> str:
|
|
246
|
+
return f"{bs.year:04d}-{bs.month:02d}-{bs.day:02d}"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _emit_grid(grid: MonthGrid, kind: str, *, as_json: bool, color: ColorMode) -> None:
|
|
250
|
+
if as_json:
|
|
251
|
+
typer.echo(
|
|
252
|
+
json.dumps(
|
|
253
|
+
{
|
|
254
|
+
"calendar": kind,
|
|
255
|
+
"title": grid.title,
|
|
256
|
+
"subtitle": grid.subtitle,
|
|
257
|
+
"today": grid.today,
|
|
258
|
+
"weeks": [list(week) for week in grid.weeks],
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
)
|
|
262
|
+
return
|
|
263
|
+
|
|
264
|
+
coloured = color is ColorMode.always or (color is ColorMode.auto and Console().is_terminal)
|
|
265
|
+
if not coloured:
|
|
266
|
+
# render_plain, never render_body_markup: piped output stays inert, so a
|
|
267
|
+
# grid does not change shape on the one day a month today falls in it.
|
|
268
|
+
typer.echo(render_plain(grid))
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
# The subtitle goes inside the panel, not in its border: panel furniture is
|
|
272
|
+
# clipped to the body width, and a span like "Ashadh 17 - Shrawan 16, 2081"
|
|
273
|
+
# is wider than the 27-column grid, so the border ate the year.
|
|
274
|
+
body = f"[dim]{grid.subtitle.center(block_width(grid))}[/dim]\n{render_body_markup(grid)}"
|
|
275
|
+
Console(force_terminal=True).print(
|
|
276
|
+
Panel.fit(body, title=f"[bold]{grid.title}[/bold]", border_style="cyan")
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
JsonOption = Annotated[bool, typer.Option("--json", help="Emit machine-readable JSON.")]
|
|
281
|
+
ColorOption = Annotated[ColorMode, typer.Option("--color", help="When to colourise the grid.")]
|
|
282
|
+
YearArg = Annotated[int | None, typer.Argument(help="Year. Defaults to the current one.")]
|
|
283
|
+
MonthArg = Annotated[int | None, typer.Argument(help="Month, 1-12. Defaults to the current one.")]
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@app.command("bs2ad")
|
|
287
|
+
def bs_to_ad_command(
|
|
288
|
+
bs_date: Annotated[str, typer.Argument(metavar="BS_DATE", help="Bikram Sambat YYYY-MM-DD.")],
|
|
289
|
+
as_json: JsonOption = False,
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Convert a Bikram Sambat date to Gregorian."""
|
|
292
|
+
with _reported_as_exit_code():
|
|
293
|
+
bs = _parse_bs(bs_date)
|
|
294
|
+
ad = bs_to_ad(bs)
|
|
295
|
+
if as_json:
|
|
296
|
+
typer.echo(json.dumps({"bs": _format_bs(bs), "ad": ad.isoformat()}))
|
|
297
|
+
else:
|
|
298
|
+
typer.echo(ad.isoformat())
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@app.command("ad2bs")
|
|
302
|
+
def ad_to_bs_command(
|
|
303
|
+
ad_date: Annotated[str, typer.Argument(metavar="AD_DATE", help="Gregorian YYYY-MM-DD.")],
|
|
304
|
+
as_json: JsonOption = False,
|
|
305
|
+
) -> None:
|
|
306
|
+
"""Convert a Gregorian date to Bikram Sambat."""
|
|
307
|
+
with _reported_as_exit_code():
|
|
308
|
+
ad = _parse_ad(ad_date)
|
|
309
|
+
bs = ad_to_bs(ad)
|
|
310
|
+
if as_json:
|
|
311
|
+
typer.echo(json.dumps({"bs": _format_bs(bs), "ad": ad.isoformat()}))
|
|
312
|
+
else:
|
|
313
|
+
typer.echo(_format_bs(bs))
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@app.command("today")
|
|
317
|
+
def today_command(as_json: JsonOption = False) -> None:
|
|
318
|
+
"""Print today's date in both calendars."""
|
|
319
|
+
ad = _today()
|
|
320
|
+
with _reported_as_exit_code():
|
|
321
|
+
bs = ad_to_bs(ad)
|
|
322
|
+
if as_json:
|
|
323
|
+
typer.echo(json.dumps({"bs": _format_bs(bs), "ad": ad.isoformat()}))
|
|
324
|
+
return
|
|
325
|
+
# Two labelled lines, the same shape `range` prints, so the two commands
|
|
326
|
+
# that report a position in both calendars read alike.
|
|
327
|
+
typer.echo(f"BS {_format_bs(bs)}")
|
|
328
|
+
typer.echo(f"AD {ad.isoformat()}")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@app.command("range")
|
|
332
|
+
def range_command(as_json: JsonOption = False) -> None:
|
|
333
|
+
"""Print the date range nepkit has data for."""
|
|
334
|
+
bs_min, bs_max = f"{MIN_BS_YEAR:04d}-01-01", _format_bs(ad_to_bs(MAX_AD_DATE))
|
|
335
|
+
if as_json:
|
|
336
|
+
typer.echo(
|
|
337
|
+
json.dumps(
|
|
338
|
+
{
|
|
339
|
+
"bs": {"min": bs_min, "max": bs_max},
|
|
340
|
+
"ad": {
|
|
341
|
+
"min": MIN_AD_DATE.isoformat(),
|
|
342
|
+
"max": MAX_AD_DATE.isoformat(),
|
|
343
|
+
},
|
|
344
|
+
}
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
return
|
|
348
|
+
typer.echo(f"BS {bs_min} .. {bs_max} (years {MIN_BS_YEAR}-{MAX_BS_YEAR})")
|
|
349
|
+
typer.echo(f"AD {MIN_AD_DATE.isoformat()} .. {MAX_AD_DATE.isoformat()}")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@app.command("calbs")
|
|
353
|
+
def calbs_command(
|
|
354
|
+
year: YearArg = None,
|
|
355
|
+
month: MonthArg = None,
|
|
356
|
+
as_json: JsonOption = False,
|
|
357
|
+
color: ColorOption = ColorMode.auto,
|
|
358
|
+
) -> None:
|
|
359
|
+
"""Display a Bikram Sambat month."""
|
|
360
|
+
with _reported_as_exit_code():
|
|
361
|
+
current = ad_to_bs(_today()) if MIN_AD_DATE <= _today() <= MAX_AD_DATE else None
|
|
362
|
+
if year is None or month is None:
|
|
363
|
+
if current is None:
|
|
364
|
+
raise DateOutOfRangeError(
|
|
365
|
+
f"today ({_today().isoformat()}) is outside the convertible window, "
|
|
366
|
+
"so there is no current BS month to default to"
|
|
367
|
+
)
|
|
368
|
+
year, month = year or current.year, month or current.month
|
|
369
|
+
grid = bs_month_grid(year, month, today=current)
|
|
370
|
+
_emit_grid(grid, "bs", as_json=as_json, color=color)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
@app.command("calad")
|
|
374
|
+
def calad_command(
|
|
375
|
+
year: YearArg = None,
|
|
376
|
+
month: MonthArg = None,
|
|
377
|
+
as_json: JsonOption = False,
|
|
378
|
+
color: ColorOption = ColorMode.auto,
|
|
379
|
+
) -> None:
|
|
380
|
+
"""Display a Gregorian month."""
|
|
381
|
+
with _reported_as_exit_code():
|
|
382
|
+
today = _today()
|
|
383
|
+
grid = ad_month_grid(year or today.year, month or today.month, today=today)
|
|
384
|
+
_emit_grid(grid, "ad", as_json=as_json, color=color)
|
nepkit/convert.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Conversion between Bikram Sambat and Gregorian dates.
|
|
2
|
+
|
|
3
|
+
Both directions collapse to a day count from calendar_data.ANCHOR and expand
|
|
4
|
+
out the other side; the AD-side expander is datetime.date arithmetic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import date, timedelta
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
from nepkit.calendar_data import (
|
|
12
|
+
ANCHOR,
|
|
13
|
+
TOTAL_DAYS,
|
|
14
|
+
bs_from_days,
|
|
15
|
+
check_bs_date,
|
|
16
|
+
days_from_anchor,
|
|
17
|
+
)
|
|
18
|
+
from nepkit.exceptions import DateOutOfRangeError
|
|
19
|
+
|
|
20
|
+
# Computed from ANCHOR plus the table's length, never written down as literals.
|
|
21
|
+
# ANCHOR.ad_date is the only AD fact in the package that cannot be derived (see
|
|
22
|
+
# data/DATA.md); a second AD literal here would be free to drift away from it.
|
|
23
|
+
MIN_AD_DATE: Final[date] = ANCHOR.ad_date
|
|
24
|
+
MAX_AD_DATE: Final[date] = ANCHOR.ad_date + timedelta(days=TOTAL_DAYS - 1)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class BSDate:
|
|
29
|
+
"""A Bikram Sambat date, validated on construction against the bundled table."""
|
|
30
|
+
|
|
31
|
+
year: int
|
|
32
|
+
month: int
|
|
33
|
+
day: int
|
|
34
|
+
|
|
35
|
+
def __post_init__(self) -> None:
|
|
36
|
+
check_bs_date(self.year, self.month, self.day)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def bs_to_ad(bs: BSDate) -> date:
|
|
40
|
+
"""Convert a Bikram Sambat date to its Gregorian equivalent."""
|
|
41
|
+
return ANCHOR.ad_date + timedelta(days=days_from_anchor(bs.year, bs.month, bs.day))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def ad_to_bs(ad: date) -> BSDate:
|
|
45
|
+
"""Convert a Gregorian date to its Bikram Sambat equivalent."""
|
|
46
|
+
if not (MIN_AD_DATE <= ad <= MAX_AD_DATE):
|
|
47
|
+
raise DateOutOfRangeError(
|
|
48
|
+
f"AD {ad.isoformat()} is outside the convertible window "
|
|
49
|
+
f"{MIN_AD_DATE.isoformat()} through {MAX_AD_DATE.isoformat()}"
|
|
50
|
+
)
|
|
51
|
+
year, month, day = bs_from_days((ad - ANCHOR.ad_date).days)
|
|
52
|
+
return BSDate(year=year, month=month, day=day)
|