numclassify 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.
numclassify/cli.py ADDED
@@ -0,0 +1,525 @@
1
+ """
2
+ numclassify.cli
3
+ ~~~~~~~~~~~~~~~
4
+ Command-line interface for numclassify.
5
+
6
+ Entry point: ``numclassify`` (configured in pyproject.toml).
7
+
8
+ Commands
9
+ --------
10
+ check <number> [--full] [--json]
11
+ Classify a single number.
12
+
13
+ range <start> <end> [--filter <name>] [--json]
14
+ Inspect a range of integers.
15
+
16
+ find <type_name> [--limit N] [--json]
17
+ Find the first N integers satisfying a named type.
18
+
19
+ info <type_name>
20
+ Show registry metadata for a type.
21
+
22
+ list [--category <cat>]
23
+ List all registered types, optionally filtered by category.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import argparse
29
+ import json
30
+ import sys
31
+ from typing import Any, Dict, List, NoReturn, Optional, Tuple
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Terminal colour / Unicode helpers
36
+ # ---------------------------------------------------------------------------
37
+ # _USE_COLOR and _USE_UNICODE are set to False at import time; main() may
38
+ # flip them to True after reconfiguring stdout.
39
+
40
+ _USE_COLOR: bool = False
41
+ _USE_UNICODE: bool = False
42
+
43
+
44
+ def _c(text: str, *codes: int) -> str:
45
+ """Wrap *text* in ANSI escape codes when stdout is a colour TTY."""
46
+ if not _USE_COLOR:
47
+ return text
48
+ seq = ";".join(str(c) for c in codes)
49
+ return f"\033[{seq}m{text}\033[0m"
50
+
51
+
52
+ def _bold(text: str) -> str:
53
+ return _c(text, 1)
54
+
55
+
56
+ def _green(text: str) -> str:
57
+ return _c(text, 32)
58
+
59
+
60
+ def _red(text: str) -> str:
61
+ return _c(text, 31)
62
+
63
+
64
+ def _yellow(text: str) -> str:
65
+ return _c(text, 33)
66
+
67
+
68
+ def _cyan(text: str) -> str:
69
+ return _c(text, 36)
70
+
71
+
72
+ def _check_mark() -> str:
73
+ """Return ✓ on Unicode-capable TTYs, plain [YES] otherwise."""
74
+ return "✓" if _USE_UNICODE else "[YES]"
75
+
76
+
77
+ def _rule(width: int) -> str:
78
+ """Return a horizontal rule of *width* chars."""
79
+ return ("─" if _USE_UNICODE else "-") * width
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Error handling
84
+ # ---------------------------------------------------------------------------
85
+
86
+ def _die(msg: str, code: int = 1) -> NoReturn:
87
+ """Print *msg* to stderr and exit with *code*."""
88
+ print(_red(f"Error: {msg}"), file=sys.stderr)
89
+ sys.exit(code)
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Lazy imports
94
+ # ---------------------------------------------------------------------------
95
+
96
+ def _lazy_import() -> Tuple[Any, Dict[str, Any], Any]:
97
+ """Import numclassify lazily so CLI starts quickly and errors are clean."""
98
+ try:
99
+ import numclassify as nc
100
+ from numclassify._registry import REGISTRY, find_in_range
101
+ return nc, REGISTRY, find_in_range
102
+ except Exception as exc:
103
+ _die(f"Failed to import numclassify: {exc}")
104
+
105
+
106
+ def _normalize(name: str) -> str:
107
+ """Normalise a type name: lower-case, spaces → underscores."""
108
+ return name.strip().lower().replace(" ", "_").replace("-", "_")
109
+
110
+
111
+ def _resolve_type(name: str, registry: Dict[str, Any]) -> str:
112
+ """Return the registry key for *name* or call _die."""
113
+ key = _normalize(name)
114
+ if key not in registry:
115
+ _die(
116
+ f"Unknown type: '{name}'. "
117
+ f"Use 'numclassify list' to see all types."
118
+ )
119
+ return key
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Computed extras for `check`
124
+ # ---------------------------------------------------------------------------
125
+
126
+ def _computed_extras(n: int) -> Dict[str, int]:
127
+ """Return a small dict of directly computed values for display."""
128
+ try:
129
+ from numclassify.digital import digit_sum, digital_root
130
+ ds = digit_sum(n)
131
+ dr = digital_root(n)
132
+ except Exception:
133
+ ds = sum(int(d) for d in str(abs(n))) if n != 0 else 0
134
+ dr = ds # fallback
135
+ try:
136
+ from numclassify.divisors import count_divisors
137
+ nd = count_divisors(n)
138
+ except Exception:
139
+ nd = sum(1 for i in range(1, abs(n) + 1) if n % i == 0) if n != 0 else 0
140
+ return {"digit_sum": ds, "digital_root": dr, "num_divisors": nd}
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Sub-command handlers
145
+ # ---------------------------------------------------------------------------
146
+
147
+ def cmd_check(args: argparse.Namespace) -> None:
148
+ """Handle: numclassify check <number> [--full] [--json]."""
149
+ nc, registry, _ = _lazy_import()
150
+
151
+ try:
152
+ n = int(args.number)
153
+ except ValueError:
154
+ _die(f"'{args.number}' is not a valid integer.")
155
+
156
+ if args.json:
157
+ true_props = nc.get_true_properties(n)
158
+ false_props = [
159
+ k for k in registry
160
+ if k not in set(_normalize(p) for p in true_props)
161
+ ]
162
+ payload = {
163
+ "number": n,
164
+ "true_properties": sorted(true_props),
165
+ "false_properties": sorted(false_props),
166
+ }
167
+ print(json.dumps(payload, indent=2))
168
+ return
169
+
170
+ if args.full:
171
+ nc.print_properties(n)
172
+ return
173
+
174
+ # Default: clean summary
175
+ true_props = nc.get_true_properties(n)
176
+ header = f"Properties of {n}"
177
+ print(_bold(header))
178
+ print(_bold(_rule(len(header))))
179
+
180
+ if not true_props:
181
+ print(" (no registered properties satisfied)")
182
+ else:
183
+ for name in sorted(true_props):
184
+ print(f" {_green(_check_mark())} {name}")
185
+
186
+ # Computed extras
187
+ extras = _computed_extras(n)
188
+ extra_str = " ".join(f"{k}={v}" for k, v in extras.items())
189
+ print()
190
+ print(_cyan(f"Computed: {extra_str}"))
191
+
192
+
193
+ def cmd_range(args: argparse.Namespace) -> None:
194
+ """Handle: numclassify range <start> <end> [--filter <name>] [--json]."""
195
+ nc, registry, find_in_range = _lazy_import()
196
+
197
+ try:
198
+ start = int(args.start)
199
+ end = int(args.end)
200
+ except ValueError:
201
+ _die("start and end must be integers.")
202
+
203
+ if start > end:
204
+ _die("start must be ≤ end.")
205
+
206
+ span = end - start + 1
207
+ if span > 100_000:
208
+ print(
209
+ _yellow(f"Warning: range has {span} numbers — this may be slow."),
210
+ file=sys.stderr,
211
+ )
212
+
213
+ if args.filter:
214
+ key = _resolve_type(args.filter, registry)
215
+ func = registry[key].func
216
+ results = find_in_range(func, start, end)
217
+
218
+ if args.json:
219
+ print(json.dumps(results))
220
+ return
221
+
222
+ label = registry[key].name if hasattr(registry[key], "name") else args.filter
223
+ if results:
224
+ print(
225
+ _bold(f"{label} in range {start}..{end}:")
226
+ + f" {results}"
227
+ )
228
+ else:
229
+ print(f"No numbers in [{start}, {end}] satisfy '{args.filter}'.")
230
+ else:
231
+ rows: List[Dict[str, Any]] = []
232
+ for n in range(start, end + 1):
233
+ c = nc.count_properties(n)
234
+ rows.append({"number": n, "property_count": c})
235
+
236
+ if args.json:
237
+ print(json.dumps(rows, indent=2))
238
+ return
239
+
240
+ for row in rows:
241
+ n = row["number"]
242
+ c = row["property_count"]
243
+ noun = "property" if c == 1 else "properties"
244
+ print(f" {n}: {c} {noun}")
245
+
246
+
247
+ def cmd_find(args: argparse.Namespace) -> None:
248
+ """Handle: numclassify find <type_name> [--limit N] [--json]."""
249
+ nc, registry, find_in_range = _lazy_import()
250
+
251
+ key = _resolve_type(args.type_name, registry)
252
+ limit = args.limit
253
+ if limit < 1:
254
+ _die("--limit must be a positive integer.")
255
+
256
+ func = registry[key].func
257
+ upper = 100_000
258
+ results = find_in_range(func, 0, upper)[:limit]
259
+
260
+ label = registry[key].name if hasattr(registry[key], "name") else args.type_name
261
+
262
+ if args.json:
263
+ print(json.dumps(results))
264
+ return
265
+
266
+ if results:
267
+ print(_bold(f"First {len(results)} {label} numbers:") + f" {results}")
268
+ else:
269
+ print(f"No '{label}' numbers found up to {upper:,}.")
270
+
271
+
272
+ def cmd_info(args: argparse.Namespace) -> None:
273
+ """Handle: numclassify info <type_name>."""
274
+ _, registry, _ = _lazy_import()
275
+
276
+ key = _resolve_type(args.type_name, registry)
277
+ entry = registry[key]
278
+
279
+ # Gracefully handle registries that use different attribute names
280
+ name = getattr(entry, "name", key)
281
+ category = getattr(entry, "category", "—")
282
+ oeis = getattr(entry, "oeis", None)
283
+ description = getattr(entry, "description", None)
284
+ example = getattr(entry, "example", None)
285
+
286
+ col_w = 14
287
+ def row(label: str, value: Optional[str]) -> None:
288
+ if value:
289
+ print(f" {_bold(label + ':'): <{col_w + 8}} {value}")
290
+
291
+ print()
292
+ row("Name", name)
293
+ row("Category", str(category).capitalize() if category else None)
294
+ row("OEIS", oeis)
295
+ row("Description", description)
296
+ row("Example", str(example) if example else None)
297
+ print()
298
+
299
+
300
+ def cmd_list(args: argparse.Namespace) -> None:
301
+ """Handle: numclassify list [--category <cat>]."""
302
+ _, registry, _ = _lazy_import()
303
+
304
+ # Group entries by category
305
+ from collections import defaultdict
306
+ groups: Dict[str, List[str]] = defaultdict(list)
307
+
308
+ for key, entry in registry.items():
309
+ cat = str(getattr(entry, "category", "other")).upper()
310
+ name = getattr(entry, "name", key)
311
+ groups[cat].append(name)
312
+
313
+ target_cat: Optional[str] = None
314
+ if args.category:
315
+ # Accept any capitalisation; also try appending S so "prime" → "PRIMES"
316
+ needle = args.category.upper().rstrip("S")
317
+ target_cat = next(
318
+ (k for k in groups if k.upper().rstrip("S") == needle),
319
+ None,
320
+ )
321
+ if target_cat is None:
322
+ available = ", ".join(sorted(groups.keys()))
323
+ _die(
324
+ f"Unknown category '{args.category}'. "
325
+ f"Available: {available}"
326
+ )
327
+
328
+ for cat in sorted(groups.keys()):
329
+ if target_cat and cat != target_cat:
330
+ continue
331
+ names = sorted(groups[cat])
332
+ print(_bold(f"\n{cat} ({len(names)} types)"))
333
+ # Print names in a wrapped paragraph style
334
+ line = " "
335
+ for i, n in enumerate(names):
336
+ chunk = n + (", " if i < len(names) - 1 else "")
337
+ if len(line) + len(chunk) > 78:
338
+ print(line.rstrip(", "))
339
+ line = " " + chunk
340
+ else:
341
+ line += chunk
342
+ if line.strip():
343
+ print(line)
344
+
345
+ print()
346
+
347
+
348
+ # ---------------------------------------------------------------------------
349
+ # Argument parser
350
+ # ---------------------------------------------------------------------------
351
+
352
+ def build_parser() -> argparse.ArgumentParser:
353
+ parser = argparse.ArgumentParser(
354
+ prog="numclassify",
355
+ description="Number classification toolkit — 3000+ number types.",
356
+ formatter_class=argparse.RawDescriptionHelpFormatter,
357
+ )
358
+ sub = parser.add_subparsers(dest="command", metavar="COMMAND")
359
+ sub.required = True
360
+
361
+ # -- check ---------------------------------------------------------------
362
+ p_check = sub.add_parser(
363
+ "check",
364
+ help="Classify a single number.",
365
+ description=(
366
+ "Classify a single integer and print all satisfied properties.\n\n"
367
+ "Examples:\n"
368
+ " numclassify check 153\n"
369
+ " numclassify check 153 --full\n"
370
+ " numclassify check 153 --json"
371
+ ),
372
+ formatter_class=argparse.RawDescriptionHelpFormatter,
373
+ )
374
+ p_check.add_argument("number", help="Integer to classify.")
375
+ p_check.add_argument(
376
+ "--full",
377
+ action="store_true",
378
+ help="Print full formatted report (calls print_properties).",
379
+ )
380
+ p_check.add_argument(
381
+ "--json",
382
+ action="store_true",
383
+ help="Output true and false properties as JSON.",
384
+ )
385
+ p_check.set_defaults(func=cmd_check)
386
+
387
+ # -- range ---------------------------------------------------------------
388
+ p_range = sub.add_parser(
389
+ "range",
390
+ help="Inspect a range of integers.",
391
+ description=(
392
+ "Show property counts or filtered matches for a range.\n\n"
393
+ "Examples:\n"
394
+ " numclassify range 1 20\n"
395
+ " numclassify range 1 100 --filter prime\n"
396
+ " numclassify range 1 50 --filter prime --json"
397
+ ),
398
+ formatter_class=argparse.RawDescriptionHelpFormatter,
399
+ )
400
+ p_range.add_argument("start", help="Start of range (inclusive).")
401
+ p_range.add_argument("end", help="End of range (inclusive).")
402
+ p_range.add_argument(
403
+ "--filter",
404
+ metavar="TYPE",
405
+ default=None,
406
+ help="Show only numbers satisfying this type name.",
407
+ )
408
+ p_range.add_argument(
409
+ "--json",
410
+ action="store_true",
411
+ help="Output results as JSON.",
412
+ )
413
+ p_range.set_defaults(func=cmd_range)
414
+
415
+ # -- find ----------------------------------------------------------------
416
+ p_find = sub.add_parser(
417
+ "find",
418
+ help="Find the first N numbers of a given type.",
419
+ description=(
420
+ "Search up to 100 000 for numbers satisfying a named type.\n\n"
421
+ "Examples:\n"
422
+ " numclassify find armstrong\n"
423
+ " numclassify find 'twin prime' --limit 10\n"
424
+ " numclassify find mersenne_prime --json"
425
+ ),
426
+ formatter_class=argparse.RawDescriptionHelpFormatter,
427
+ )
428
+ p_find.add_argument("type_name", help="Registered type name.")
429
+ p_find.add_argument(
430
+ "--limit",
431
+ type=int,
432
+ default=20,
433
+ metavar="N",
434
+ help="How many numbers to find (default: 20).",
435
+ )
436
+ p_find.add_argument(
437
+ "--json",
438
+ action="store_true",
439
+ help="Output results as JSON array.",
440
+ )
441
+ p_find.set_defaults(func=cmd_find)
442
+
443
+ # -- info ----------------------------------------------------------------
444
+ p_info = sub.add_parser(
445
+ "info",
446
+ help="Show metadata for a registered type.",
447
+ description=(
448
+ "Display name, category, OEIS reference, and description.\n\n"
449
+ "Examples:\n"
450
+ " numclassify info armstrong\n"
451
+ " numclassify info 'twin prime'"
452
+ ),
453
+ formatter_class=argparse.RawDescriptionHelpFormatter,
454
+ )
455
+ p_info.add_argument("type_name", help="Registered type name.")
456
+ p_info.set_defaults(func=cmd_info)
457
+
458
+ # -- list ----------------------------------------------------------------
459
+ p_list = sub.add_parser(
460
+ "list",
461
+ help="List all registered types, grouped by category.",
462
+ description=(
463
+ "Print every registered type name, grouped by category.\n\n"
464
+ "Examples:\n"
465
+ " numclassify list\n"
466
+ " numclassify list --category prime"
467
+ ),
468
+ formatter_class=argparse.RawDescriptionHelpFormatter,
469
+ )
470
+ p_list.add_argument(
471
+ "--category",
472
+ metavar="CAT",
473
+ default=None,
474
+ help="Show only this category.",
475
+ )
476
+ p_list.set_defaults(func=cmd_list)
477
+
478
+ return parser
479
+
480
+
481
+ # ---------------------------------------------------------------------------
482
+ # Entry point
483
+ # ---------------------------------------------------------------------------
484
+
485
+ def main() -> None:
486
+ """Entry point for the ``numclassify`` CLI command."""
487
+ import io
488
+
489
+ global _USE_COLOR, _USE_UNICODE # noqa: PLW0603
490
+
491
+ # ------------------------------------------------------------------
492
+ # 1. Reconfigure stdout to UTF-8 so Unicode chars never crash on
493
+ # Windows cp1252 / latin-1 terminals or when output is piped.
494
+ # ------------------------------------------------------------------
495
+ if hasattr(sys.stdout, "reconfigure"):
496
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
497
+ elif hasattr(sys.stdout, "buffer"):
498
+ sys.stdout = io.TextIOWrapper(
499
+ sys.stdout.buffer, encoding="utf-8", errors="replace"
500
+ )
501
+
502
+ # ------------------------------------------------------------------
503
+ # 2. Enable colour + Unicode only when writing to a real terminal.
504
+ # Subprocesses/pipes get plain ASCII — no ✓, no ─, no ANSI codes.
505
+ # ------------------------------------------------------------------
506
+ if sys.stdout.isatty():
507
+ _USE_COLOR = True
508
+ _USE_UNICODE = True
509
+
510
+ parser = build_parser()
511
+ args = parser.parse_args()
512
+
513
+ try:
514
+ args.func(args)
515
+ except SystemExit:
516
+ raise
517
+ except KeyboardInterrupt:
518
+ print() # clean newline
519
+ sys.exit(0)
520
+ except Exception as exc:
521
+ _die(str(exc))
522
+
523
+
524
+ if __name__ == "__main__":
525
+ main()