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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nepkit
3
- Version: 0.2.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
  [![Python](https://img.shields.io/pypi/pyversions/nepkit)](https://pypi.org/project/nepkit/)
44
44
  [![License](https://img.shields.io/pypi/l/nepkit)](https://github.com/akakritagya/nepkit/blob/main/LICENSE)
45
45
 
46
- > **Status:** first release. The library and CLI both work and are tested; the
47
- > API may still change before 1.0.
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
  [![Python](https://img.shields.io/pypi/pyversions/nepkit)](https://pypi.org/project/nepkit/)
18
18
  [![License](https://img.shields.io/pypi/l/nepkit)](https://github.com/akakritagya/nepkit/blob/main/LICENSE)
19
19
 
20
- > **Status:** first release. The library and CLI both work and are tested; the
21
- > API may still change before 1.0.
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.2.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.2.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
- """Days from the anchor to the start of each BS year, so bs_to_ad never sums a range."""
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↔AD correspondence the whole module hangs on."""
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
- """The cumulative offset table starts at min_year, so the anchor must be its 1/1."""
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
- """Number of days in the given BS month. Raises on an out-of-range year or bad month."""
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
- """Days from ANCHOR to the given BS date. The one primitive bs_to_ad needs."""
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
- """Inverse of days_from_anchor: the BS date that many days after ANCHOR."""
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
- """Seam for tests. Patch this rather than the clock itself."""
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
- """Seam for tests, and the guard that keeps `nepkit` usable in a pipeline."""
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
- """Today in both calendars, or a note that it is off the end of the table.
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
- """Python weeks start Monday; Nepali (and `cal`) calendars start Sunday."""
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
- """How wide the rendered block is: the grid, unless a heading is wider."""
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
- """Left pad that centres the week columns under a wider heading.
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
- """The weekday header and week rows, as plain text with no markup at all."""
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
- """Same grid, with today's cell wrapped in rich markup.
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
- """The whole grid as plain text, every part centred on the same block."""
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