pythonic-peano-arithmetic 0.3.0__tar.gz → 0.4.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: pythonic-peano-arithmetic
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Build arithmetic from Peano axioms and trace algebraic real roots
5
5
  Keywords: arithmetic,education,mathematics,peano
6
6
  Author: Yusuke Hayashi
@@ -35,10 +35,20 @@ The implementation is intentionally small and explicit. Its purpose is to make
35
35
  the correspondence between a mathematical definition and executable Python
36
36
  visible—not to compete with Python's built-in numeric types.
37
37
 
38
- The interactive course runs entirely in the browser with Zensical and Pyodide:
39
-
40
- - [English course](https://peano.yusuke-hayashi.com/en/)
41
- - [日本語の教材](https://peano.yusuke-hayashi.com/)
38
+ The interactive course runs entirely in the browser with Zensical and Pyodide.
39
+ It is available in
40
+ [English](https://peano.yusuke-hayashi.com/en/),
41
+ [日本語](https://peano.yusuke-hayashi.com/),
42
+ [简体中文](https://peano.yusuke-hayashi.com/zh/),
43
+ [繁體中文](https://peano.yusuke-hayashi.com/zh-hant/),
44
+ [Español](https://peano.yusuke-hayashi.com/es/),
45
+ [Português (Brasil)](https://peano.yusuke-hayashi.com/pt-br/),
46
+ [Français](https://peano.yusuke-hayashi.com/fr/),
47
+ [Deutsch](https://peano.yusuke-hayashi.com/de/),
48
+ [한국어](https://peano.yusuke-hayashi.com/ko/),
49
+ [Русский](https://peano.yusuke-hayashi.com/ru/),
50
+ [العربية](https://peano.yusuke-hayashi.com/ar/), and
51
+ [हिन्दी](https://peano.yusuke-hayashi.com/hi/).
42
52
 
43
53
  ## What you can observe
44
54
 
@@ -98,7 +108,9 @@ n + 0 = n
98
108
  n + S(m) = S(n + m)
99
109
  ```
100
110
 
101
- Pass `locale="ja"` to `config_log` to show Japanese rule labels.
111
+ Pass a BCP 47 locale to `config_log` to localize rule labels. Supported values
112
+ are `en`, `ja`, `zh-Hans`, `zh-Hant`, `es`, `pt-BR`, `fr`, `de`, `ko`, `ru`,
113
+ `ar`, and `hi`.
102
114
 
103
115
  ### Integers: equality of representatives
104
116
 
@@ -220,10 +232,10 @@ intermediate Peano representations; returned endpoints are still `Rational`.
220
232
  ## Documentation development
221
233
 
222
234
  ```bash
223
- make docs # build Japanese at / and English at /en/
235
+ make docs # build all 12 course languages
224
236
  make docs-serve # preview Japanese
225
237
  make docs-serve-en # preview English
226
- make docs-a11y # run WCAG checks on both languages
238
+ make docs-a11y # run WCAG checks on all 120 localized pages
227
239
  ```
228
240
 
229
241
  The site is deployed from `main` to Cloudflare Workers Static Assets. Deployment
@@ -8,10 +8,20 @@ The implementation is intentionally small and explicit. Its purpose is to make
8
8
  the correspondence between a mathematical definition and executable Python
9
9
  visible—not to compete with Python's built-in numeric types.
10
10
 
11
- The interactive course runs entirely in the browser with Zensical and Pyodide:
12
-
13
- - [English course](https://peano.yusuke-hayashi.com/en/)
14
- - [日本語の教材](https://peano.yusuke-hayashi.com/)
11
+ The interactive course runs entirely in the browser with Zensical and Pyodide.
12
+ It is available in
13
+ [English](https://peano.yusuke-hayashi.com/en/),
14
+ [日本語](https://peano.yusuke-hayashi.com/),
15
+ [简体中文](https://peano.yusuke-hayashi.com/zh/),
16
+ [繁體中文](https://peano.yusuke-hayashi.com/zh-hant/),
17
+ [Español](https://peano.yusuke-hayashi.com/es/),
18
+ [Português (Brasil)](https://peano.yusuke-hayashi.com/pt-br/),
19
+ [Français](https://peano.yusuke-hayashi.com/fr/),
20
+ [Deutsch](https://peano.yusuke-hayashi.com/de/),
21
+ [한국어](https://peano.yusuke-hayashi.com/ko/),
22
+ [Русский](https://peano.yusuke-hayashi.com/ru/),
23
+ [العربية](https://peano.yusuke-hayashi.com/ar/), and
24
+ [हिन्दी](https://peano.yusuke-hayashi.com/hi/).
15
25
 
16
26
  ## What you can observe
17
27
 
@@ -71,7 +81,9 @@ n + 0 = n
71
81
  n + S(m) = S(n + m)
72
82
  ```
73
83
 
74
- Pass `locale="ja"` to `config_log` to show Japanese rule labels.
84
+ Pass a BCP 47 locale to `config_log` to localize rule labels. Supported values
85
+ are `en`, `ja`, `zh-Hans`, `zh-Hant`, `es`, `pt-BR`, `fr`, `de`, `ko`, `ru`,
86
+ `ar`, and `hi`.
75
87
 
76
88
  ### Integers: equality of representatives
77
89
 
@@ -193,10 +205,10 @@ intermediate Peano representations; returned endpoints are still `Rational`.
193
205
  ## Documentation development
194
206
 
195
207
  ```bash
196
- make docs # build Japanese at / and English at /en/
208
+ make docs # build all 12 course languages
197
209
  make docs-serve # preview Japanese
198
210
  make docs-serve-en # preview English
199
- make docs-a11y # run WCAG checks on both languages
211
+ make docs-a11y # run WCAG checks on all 120 localized pages
200
212
  ```
201
213
 
202
214
  The site is deployed from `main` to Cloudflare Workers Static Assets. Deployment
@@ -28,7 +28,7 @@ from .rational import (
28
28
  rational,
29
29
  z2r,
30
30
  )
31
- from .utils import config_log
31
+ from .utils import SUPPORTED_LOCALES, config_log
32
32
 
33
33
  __all__ = [
34
34
  "NaturalNumber",
@@ -63,4 +63,5 @@ __all__ = [
63
63
  "AlgebraicRoot",
64
64
  "algebraic_root",
65
65
  "config_log",
66
+ "SUPPORTED_LOCALES",
66
67
  ]
@@ -8,7 +8,7 @@ from fractions import Fraction
8
8
  from .natural_number import NaturalNumber
9
9
  from .polynomial import Polynomial, count_real_roots
10
10
  from .rational import Rational, rational
11
- from .utils import LogMessage, localized, log
11
+ from .utils import LogMessage, log, translate
12
12
 
13
13
 
14
14
  @dataclass(frozen=True, slots=True)
@@ -132,9 +132,10 @@ def _bisect(
132
132
  result = RationalInterval(midpoint, midpoint)
133
133
  return (
134
134
  result,
135
- lambda: localized(
136
- f"{polynomial_value!r}: midpoint {midpoint!r} is a root",
137
- f"{polynomial_value!r}: 中点 {midpoint!r} は根",
135
+ lambda: translate(
136
+ "midpoint_root",
137
+ polynomial=repr(polynomial_value),
138
+ midpoint=repr(midpoint),
138
139
  ),
139
140
  )
140
141
 
@@ -6,7 +6,7 @@ from dataclasses import dataclass
6
6
  from functools import total_ordering
7
7
  from typing import TYPE_CHECKING, Iterator, cast
8
8
 
9
- from .utils import LogMessage, localized, log
9
+ from .utils import LogMessage, log, translate
10
10
 
11
11
  if TYPE_CHECKING:
12
12
  from .integer import Integer
@@ -67,7 +67,7 @@ class NaturalNumber:
67
67
  return (
68
68
  result,
69
69
  lambda: (
70
- f"{localized('[equality: zero case]', '[等値・0の場合]')} "
70
+ f"{translate('equality.zero')} "
71
71
  f"eq({self.structural_str()}, "
72
72
  f"{other.structural_str()}) -> {result}"
73
73
  ),
@@ -75,7 +75,7 @@ class NaturalNumber:
75
75
  return (
76
76
  left_predecessor == right_predecessor,
77
77
  lambda: (
78
- f"{localized('[equality: successor case]', '[等値・後者の場合]')} "
78
+ f"{translate('equality.successor')} "
79
79
  f"eq({self.structural_str()}, "
80
80
  f"{other.structural_str()}) -> "
81
81
  f"eq({left_predecessor.structural_str()}, "
@@ -128,7 +128,7 @@ class NaturalNumber:
128
128
  return (
129
129
  self,
130
130
  lambda: (
131
- f"{localized('[addition: base]', '[加法・基底]')} "
131
+ f"{translate('addition.base')} "
132
132
  f"add({self.structural_str()}, 0) "
133
133
  f"-> {self.structural_str()}"
134
134
  ),
@@ -136,7 +136,7 @@ class NaturalNumber:
136
136
  return (
137
137
  successor(self + predecessor),
138
138
  lambda: (
139
- f"{localized('[addition: recursive]', '[加法・再帰]')} "
139
+ f"{translate('addition.recursive')} "
140
140
  f"add({self.structural_str()}, "
141
141
  f"{other.structural_str()}) -> "
142
142
  f"S(add({self.structural_str()}, {predecessor.structural_str()}))"
@@ -173,14 +173,14 @@ class NaturalNumber:
173
173
  return (
174
174
  N_ZERO,
175
175
  lambda: (
176
- f"{localized('[multiplication: base]', '[乗法・基底]')} "
176
+ f"{translate('multiplication.base')} "
177
177
  f"mul({self.structural_str()}, 0) -> 0"
178
178
  ),
179
179
  )
180
180
  return (
181
181
  self + self * predecessor,
182
182
  lambda: (
183
- f"{localized('[multiplication: recursive]', '[乗法・再帰]')} "
183
+ f"{translate('multiplication.recursive')} "
184
184
  f"mul({self.structural_str()}, "
185
185
  f"{other.structural_str()}) -> "
186
186
  f"add({self.structural_str()}, "
@@ -0,0 +1,372 @@
1
+ """Lightweight logging support for observing symbolic evaluation."""
2
+
3
+ from copy import copy
4
+ from functools import wraps
5
+ from inspect import signature
6
+ from logging import (
7
+ Filter,
8
+ Formatter,
9
+ Logger,
10
+ LogRecord,
11
+ NullHandler,
12
+ StreamHandler,
13
+ getLogger,
14
+ )
15
+ from typing import Callable, Literal, ParamSpec, TypeVar, get_args, get_origin
16
+
17
+ logger = getLogger(__name__)
18
+ logger.addHandler(NullHandler())
19
+ logger.propagate = False
20
+ logger.disabled = True
21
+
22
+ P = ParamSpec("P")
23
+ T = TypeVar("T")
24
+ LogMessage = str | Callable[[], str]
25
+ Locale = Literal[
26
+ "en",
27
+ "ja",
28
+ "zh-Hans",
29
+ "zh-Hant",
30
+ "es",
31
+ "pt-BR",
32
+ "fr",
33
+ "de",
34
+ "ko",
35
+ "ru",
36
+ "ar",
37
+ "hi",
38
+ ]
39
+ SUPPORTED_LOCALES: tuple[Locale, ...] = (
40
+ "en",
41
+ "ja",
42
+ "zh-Hans",
43
+ "zh-Hant",
44
+ "es",
45
+ "pt-BR",
46
+ "fr",
47
+ "de",
48
+ "ko",
49
+ "ru",
50
+ "ar",
51
+ "hi",
52
+ )
53
+ _PEANO_HANDLER_MARKER = "_peano_handler"
54
+ _LOG_LIMIT_NOTICE = "_peano_log_limit_notice"
55
+ _locale: Locale = "en"
56
+
57
+ _MESSAGES: dict[Locale, dict[str, str]] = {
58
+ "en": {
59
+ "equality.zero": "[equality: zero case]",
60
+ "equality.successor": "[equality: successor case]",
61
+ "addition.base": "[addition: base]",
62
+ "addition.recursive": "[addition: recursive]",
63
+ "multiplication.base": "[multiplication: base]",
64
+ "multiplication.recursive": "[multiplication: recursive]",
65
+ "truncated": (
66
+ "…Log output was truncated after {max_lines} lines. "
67
+ "Use smaller inputs and run the cell again."
68
+ ),
69
+ "midpoint_root": "{polynomial}: midpoint {midpoint} is a root",
70
+ },
71
+ "ja": {
72
+ "equality.zero": "[等値・0の場合]",
73
+ "equality.successor": "[等値・後者の場合]",
74
+ "addition.base": "[加法・基底]",
75
+ "addition.recursive": "[加法・再帰]",
76
+ "multiplication.base": "[乗法・基底]",
77
+ "multiplication.recursive": "[乗法・再帰]",
78
+ "truncated": (
79
+ "…ログは{max_lines}行で省略しました。入力を小さくして再実行してください。"
80
+ ),
81
+ "midpoint_root": "{polynomial}: 中点 {midpoint} は根",
82
+ },
83
+ "zh-Hans": {
84
+ "equality.zero": "[相等:零情形]",
85
+ "equality.successor": "[相等:后继情形]",
86
+ "addition.base": "[加法:基础情形]",
87
+ "addition.recursive": "[加法:递归情形]",
88
+ "multiplication.base": "[乘法:基础情形]",
89
+ "multiplication.recursive": "[乘法:递归情形]",
90
+ "truncated": "…日志在{max_lines}行后截断。请减小输入后重新运行。",
91
+ "midpoint_root": "{polynomial}:中点 {midpoint} 是根",
92
+ },
93
+ "zh-Hant": {
94
+ "equality.zero": "[相等:零情形]",
95
+ "equality.successor": "[相等:後繼情形]",
96
+ "addition.base": "[加法:基礎情形]",
97
+ "addition.recursive": "[加法:遞迴情形]",
98
+ "multiplication.base": "[乘法:基礎情形]",
99
+ "multiplication.recursive": "[乘法:遞迴情形]",
100
+ "truncated": "…記錄在{max_lines}行後截斷。請縮小輸入後重新執行。",
101
+ "midpoint_root": "{polynomial}:中點 {midpoint} 是根",
102
+ },
103
+ "es": {
104
+ "equality.zero": "[igualdad: caso cero]",
105
+ "equality.successor": "[igualdad: caso sucesor]",
106
+ "addition.base": "[suma: caso base]",
107
+ "addition.recursive": "[suma: caso recursivo]",
108
+ "multiplication.base": "[multiplicación: caso base]",
109
+ "multiplication.recursive": "[multiplicación: caso recursivo]",
110
+ "truncated": (
111
+ "…El registro se truncó tras {max_lines} líneas. "
112
+ "Reduce la entrada y vuelve a ejecutar."
113
+ ),
114
+ "midpoint_root": "{polynomial}: el punto medio {midpoint} es una raíz",
115
+ },
116
+ "pt-BR": {
117
+ "equality.zero": "[igualdade: caso zero]",
118
+ "equality.successor": "[igualdade: caso sucessor]",
119
+ "addition.base": "[adição: caso base]",
120
+ "addition.recursive": "[adição: caso recursivo]",
121
+ "multiplication.base": "[multiplicação: caso base]",
122
+ "multiplication.recursive": "[multiplicação: caso recursivo]",
123
+ "truncated": (
124
+ "…O log foi truncado após {max_lines} linhas. "
125
+ "Reduza a entrada e execute novamente."
126
+ ),
127
+ "midpoint_root": "{polynomial}: o ponto médio {midpoint} é uma raiz",
128
+ },
129
+ "fr": {
130
+ "equality.zero": "[égalité : cas zéro]",
131
+ "equality.successor": "[égalité : cas successeur]",
132
+ "addition.base": "[addition : cas de base]",
133
+ "addition.recursive": "[addition : cas récursif]",
134
+ "multiplication.base": "[multiplication : cas de base]",
135
+ "multiplication.recursive": "[multiplication : cas récursif]",
136
+ "truncated": (
137
+ "…Le journal a été tronqué après {max_lines} lignes. "
138
+ "Réduisez les entrées et relancez."
139
+ ),
140
+ "midpoint_root": "{polynomial} : le milieu {midpoint} est une racine",
141
+ },
142
+ "de": {
143
+ "equality.zero": "[Gleichheit: Nullfall]",
144
+ "equality.successor": "[Gleichheit: Nachfolgerfall]",
145
+ "addition.base": "[Addition: Basisfall]",
146
+ "addition.recursive": "[Addition: Rekursionsfall]",
147
+ "multiplication.base": "[Multiplikation: Basisfall]",
148
+ "multiplication.recursive": "[Multiplikation: Rekursionsfall]",
149
+ "truncated": (
150
+ "…Die Protokollausgabe wurde nach {max_lines} Zeilen gekürzt. "
151
+ "Verkleinern Sie die Eingaben und führen Sie die Zelle erneut aus."
152
+ ),
153
+ "midpoint_root": "{polynomial}: Der Mittelpunkt {midpoint} ist eine Nullstelle",
154
+ },
155
+ "ko": {
156
+ "equality.zero": "[같음: 0인 경우]",
157
+ "equality.successor": "[같음: 후속자 경우]",
158
+ "addition.base": "[덧셈: 기저 경우]",
159
+ "addition.recursive": "[덧셈: 재귀 경우]",
160
+ "multiplication.base": "[곱셈: 기저 경우]",
161
+ "multiplication.recursive": "[곱셈: 재귀 경우]",
162
+ "truncated": (
163
+ "…로그를 {max_lines}줄에서 줄였습니다. 입력을 작게 바꾸고 다시 실행하세요."
164
+ ),
165
+ "midpoint_root": "{polynomial}: 중점 {midpoint}은(는) 근입니다",
166
+ },
167
+ "ru": {
168
+ "equality.zero": "[равенство: случай нуля]",
169
+ "equality.successor": "[равенство: случай следующего]",
170
+ "addition.base": "[сложение: базовый случай]",
171
+ "addition.recursive": "[сложение: рекурсивный случай]",
172
+ "multiplication.base": "[умножение: базовый случай]",
173
+ "multiplication.recursive": "[умножение: рекурсивный случай]",
174
+ "truncated": (
175
+ "…Журнал обрезан после {max_lines} строк. "
176
+ "Уменьшите входные данные и запустите ячейку снова."
177
+ ),
178
+ "midpoint_root": "{polynomial}: середина {midpoint} является корнем",
179
+ },
180
+ "ar": {
181
+ "equality.zero": "[المساواة: حالة الصفر]",
182
+ "equality.successor": "[المساواة: حالة الخليفة]",
183
+ "addition.base": "[الجمع: الحالة الأساسية]",
184
+ "addition.recursive": "[الجمع: الحالة العودية]",
185
+ "multiplication.base": "[الضرب: الحالة الأساسية]",
186
+ "multiplication.recursive": "[الضرب: الحالة العودية]",
187
+ "truncated": (
188
+ "…اختُصر السجل بعد {max_lines} سطرًا. صغّر المدخلات ثم شغّل الخلية من جديد."
189
+ ),
190
+ "midpoint_root": "{polynomial}: نقطة المنتصف {midpoint} جذر",
191
+ },
192
+ "hi": {
193
+ "equality.zero": "[समानता: शून्य स्थिति]",
194
+ "equality.successor": "[समानता: उत्तराधिकारी स्थिति]",
195
+ "addition.base": "[जोड़: आधार स्थिति]",
196
+ "addition.recursive": "[जोड़: पुनरावर्ती स्थिति]",
197
+ "multiplication.base": "[गुणा: आधार स्थिति]",
198
+ "multiplication.recursive": "[गुणा: पुनरावर्ती स्थिति]",
199
+ "truncated": (
200
+ "…लॉग {max_lines} पंक्तियों के बाद छोटा कर दिया गया। इनपुट घटाकर सेल फिर चलाएँ।"
201
+ ),
202
+ "midpoint_root": "{polynomial}: मध्यबिंदु {midpoint} एक मूल है",
203
+ },
204
+ }
205
+
206
+
207
+ def translate(key: str, **values: object) -> str:
208
+ """Return a runtime message in the configured locale."""
209
+
210
+ return _MESSAGES[_locale][key].format(**values)
211
+
212
+
213
+ def localized(english: str, japanese: str) -> str:
214
+ """Return an English/Japanese compatibility message.
215
+
216
+ New runtime messages should use :func:`translate`.
217
+ """
218
+ return japanese if _locale == "ja" else english
219
+
220
+
221
+ class _LineLimitFormatter(Formatter):
222
+ """Format a copied record when emitting the truncation notice."""
223
+
224
+ def format(self, record: LogRecord) -> str:
225
+ max_lines = getattr(record, _LOG_LIMIT_NOTICE, None)
226
+ if not isinstance(max_lines, int):
227
+ return super().format(record)
228
+ notice = copy(record)
229
+ notice.msg = translate("truncated", max_lines=max_lines)
230
+ notice.args = ()
231
+ return super().format(notice)
232
+
233
+
234
+ class _LineLimitFilter(Filter):
235
+ """Emit at most ``max_lines`` messages plus one truncation notice."""
236
+
237
+ def __init__(self, max_lines: int | None) -> None:
238
+ super().__init__(__name__)
239
+ self.max_lines = max_lines
240
+ self.emitted_lines = 0
241
+
242
+ def filter(self, record: LogRecord) -> bool:
243
+ if not super().filter(record):
244
+ return False
245
+ if self.max_lines is None:
246
+ return True
247
+ if self.emitted_lines < self.max_lines:
248
+ self.emitted_lines += 1
249
+ return True
250
+ if self.emitted_lines == self.max_lines:
251
+ self.emitted_lines += 1
252
+ setattr(record, _LOG_LIMIT_NOTICE, self.max_lines)
253
+ return True
254
+ return False
255
+
256
+
257
+ def log(
258
+ log_level: int,
259
+ ) -> Callable[[Callable[P, tuple[T, LogMessage]]], Callable[P, T]]:
260
+ """Turn an operation returning a result and message into a public operation.
261
+
262
+ The implementation returns ``(result, formula)``. Callers receive only
263
+ ``result`` while ``formula`` is logged at the requested level. Callable
264
+ messages are evaluated lazily only when logging is enabled.
265
+ """
266
+
267
+ def outer(func: Callable[P, tuple[T, LogMessage]]) -> Callable[P, T]:
268
+ @wraps(func)
269
+ def inner(*args: P.args, **kwargs: P.kwargs) -> T:
270
+ result, message = func(*args, **kwargs)
271
+ if logger.isEnabledFor(log_level):
272
+ logger.log(
273
+ log_level, message if isinstance(message, str) else message()
274
+ )
275
+ return result
276
+
277
+ public_return = _public_return_annotation(
278
+ signature(func, follow_wrapped=False).return_annotation
279
+ )
280
+ inner.__annotations__ = {
281
+ **func.__annotations__,
282
+ "return": public_return,
283
+ }
284
+ setattr(
285
+ inner,
286
+ "__signature__",
287
+ signature(func, follow_wrapped=False).replace(
288
+ return_annotation=public_return
289
+ ),
290
+ )
291
+ return inner
292
+
293
+ return outer
294
+
295
+
296
+ def _public_return_annotation(annotation: object) -> object:
297
+ """Extract the public result type from the internal tuple annotation."""
298
+
299
+ if isinstance(annotation, str):
300
+ prefix = "tuple["
301
+ for suffix in (", str]", ", LogMessage]"):
302
+ if annotation.startswith(prefix) and annotation.endswith(suffix):
303
+ return annotation[len(prefix) : -len(suffix)]
304
+ elif get_origin(annotation) is tuple:
305
+ arguments = get_args(annotation)
306
+ if len(arguments) == 2 and arguments[1] in (str, LogMessage):
307
+ return arguments[0]
308
+ raise TypeError("functions decorated with log must return (result, LogMessage)")
309
+
310
+
311
+ def _remove_peano_handlers(*loggers: Logger) -> None:
312
+ """Remove only handlers installed by this module."""
313
+
314
+ for target in loggers:
315
+ for handler in tuple(target.handlers):
316
+ if getattr(handler, _PEANO_HANDLER_MARKER, False):
317
+ target.removeHandler(handler)
318
+ handler.close()
319
+
320
+
321
+ def config_log(
322
+ log_level: int = 0,
323
+ root: bool = False,
324
+ fmt: str = "%(message)s",
325
+ clear_handlers: bool = True,
326
+ max_lines: int | None = None,
327
+ locale: Locale = "en",
328
+ ) -> None:
329
+ """Write symbolic evaluation logs to standard error.
330
+
331
+ By default, only the library logger is configured. ``root=True`` preserves
332
+ existing root handlers and manages only the handler installed here.
333
+ ``max_lines`` emits one truncation notice after the limit. Pass
334
+ ``"Level %(levelno)s: %(message)s"`` as ``fmt`` to expose internal levels.
335
+ Runtime labels are English by default. ``SUPPORTED_LOCALES`` lists the
336
+ available translations.
337
+ """
338
+
339
+ global _locale
340
+ if max_lines is not None and (
341
+ isinstance(max_lines, bool) or not isinstance(max_lines, int) or max_lines < 1
342
+ ):
343
+ raise ValueError("max_lines must be a positive integer or None")
344
+ if locale not in SUPPORTED_LOCALES:
345
+ supported = ", ".join(SUPPORTED_LOCALES)
346
+ raise ValueError(f"locale must be one of: {supported}")
347
+ _locale = locale
348
+
349
+ root_logger = getLogger()
350
+ if clear_handlers:
351
+ _remove_peano_handlers(logger, root_logger)
352
+
353
+ handler = StreamHandler()
354
+ handler.setFormatter(_LineLimitFormatter(fmt))
355
+ handler.setLevel(log_level)
356
+ handler.addFilter(_LineLimitFilter(max_lines))
357
+ setattr(handler, _PEANO_HANDLER_MARKER, True)
358
+
359
+ logger.setLevel(log_level)
360
+ logger.disabled = False
361
+ if root:
362
+ logger.propagate = True
363
+ root_logger.addHandler(handler)
364
+ else:
365
+ logger.propagate = False
366
+ logger.addHandler(handler)
367
+
368
+
369
+ def explain(value: object) -> str:
370
+ """Render a value consistently inside symbolic log messages."""
371
+
372
+ return repr(value)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pythonic-peano-arithmetic"
3
- version = "0.3.0"
3
+ version = "0.4.0"
4
4
  description = "Build arithmetic from Peano axioms and trace algebraic real roots"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,189 +0,0 @@
1
- """Lightweight logging support for observing symbolic evaluation."""
2
-
3
- from copy import copy
4
- from functools import wraps
5
- from inspect import signature
6
- from logging import (
7
- Filter,
8
- Formatter,
9
- Logger,
10
- LogRecord,
11
- NullHandler,
12
- StreamHandler,
13
- getLogger,
14
- )
15
- from typing import Callable, Literal, ParamSpec, TypeVar, get_args, get_origin
16
-
17
- logger = getLogger(__name__)
18
- logger.addHandler(NullHandler())
19
- logger.propagate = False
20
- logger.disabled = True
21
-
22
- P = ParamSpec("P")
23
- T = TypeVar("T")
24
- LogMessage = str | Callable[[], str]
25
- _PEANO_HANDLER_MARKER = "_peano_handler"
26
- _LOG_LIMIT_NOTICE = "_peano_log_limit_notice"
27
- _locale: Literal["en", "ja"] = "en"
28
-
29
-
30
- def localized(english: str, japanese: str) -> str:
31
- """Return a localized runtime message."""
32
-
33
- return japanese if _locale == "ja" else english
34
-
35
-
36
- class _LineLimitFormatter(Formatter):
37
- """Format a copied record when emitting the truncation notice."""
38
-
39
- def format(self, record: LogRecord) -> str:
40
- max_lines = getattr(record, _LOG_LIMIT_NOTICE, None)
41
- if not isinstance(max_lines, int):
42
- return super().format(record)
43
- notice = copy(record)
44
- notice.msg = localized(
45
- f"…Log output was truncated after {max_lines} lines. "
46
- "Use smaller inputs and run the cell again.",
47
- f"…ログは{max_lines}行で省略しました。入力を小さくして再実行してください。",
48
- )
49
- notice.args = ()
50
- return super().format(notice)
51
-
52
-
53
- class _LineLimitFilter(Filter):
54
- """Emit at most ``max_lines`` messages plus one truncation notice."""
55
-
56
- def __init__(self, max_lines: int | None) -> None:
57
- super().__init__(__name__)
58
- self.max_lines = max_lines
59
- self.emitted_lines = 0
60
-
61
- def filter(self, record: LogRecord) -> bool:
62
- if not super().filter(record):
63
- return False
64
- if self.max_lines is None:
65
- return True
66
- if self.emitted_lines < self.max_lines:
67
- self.emitted_lines += 1
68
- return True
69
- if self.emitted_lines == self.max_lines:
70
- self.emitted_lines += 1
71
- setattr(record, _LOG_LIMIT_NOTICE, self.max_lines)
72
- return True
73
- return False
74
-
75
-
76
- def log(
77
- log_level: int,
78
- ) -> Callable[[Callable[P, tuple[T, LogMessage]]], Callable[P, T]]:
79
- """Turn an operation returning a result and message into a public operation.
80
-
81
- The implementation returns ``(result, formula)``. Callers receive only
82
- ``result`` while ``formula`` is logged at the requested level. Callable
83
- messages are evaluated lazily only when logging is enabled.
84
- """
85
-
86
- def outer(func: Callable[P, tuple[T, LogMessage]]) -> Callable[P, T]:
87
- @wraps(func)
88
- def inner(*args: P.args, **kwargs: P.kwargs) -> T:
89
- result, message = func(*args, **kwargs)
90
- if logger.isEnabledFor(log_level):
91
- logger.log(
92
- log_level, message if isinstance(message, str) else message()
93
- )
94
- return result
95
-
96
- public_return = _public_return_annotation(
97
- signature(func, follow_wrapped=False).return_annotation
98
- )
99
- inner.__annotations__ = {
100
- **func.__annotations__,
101
- "return": public_return,
102
- }
103
- setattr(
104
- inner,
105
- "__signature__",
106
- signature(func, follow_wrapped=False).replace(
107
- return_annotation=public_return
108
- ),
109
- )
110
- return inner
111
-
112
- return outer
113
-
114
-
115
- def _public_return_annotation(annotation: object) -> object:
116
- """Extract the public result type from the internal tuple annotation."""
117
-
118
- if isinstance(annotation, str):
119
- prefix = "tuple["
120
- for suffix in (", str]", ", LogMessage]"):
121
- if annotation.startswith(prefix) and annotation.endswith(suffix):
122
- return annotation[len(prefix) : -len(suffix)]
123
- elif get_origin(annotation) is tuple:
124
- arguments = get_args(annotation)
125
- if len(arguments) == 2 and arguments[1] in (str, LogMessage):
126
- return arguments[0]
127
- raise TypeError("functions decorated with log must return (result, LogMessage)")
128
-
129
-
130
- def _remove_peano_handlers(*loggers: Logger) -> None:
131
- """Remove only handlers installed by this module."""
132
-
133
- for target in loggers:
134
- for handler in tuple(target.handlers):
135
- if getattr(handler, _PEANO_HANDLER_MARKER, False):
136
- target.removeHandler(handler)
137
- handler.close()
138
-
139
-
140
- def config_log(
141
- log_level: int = 0,
142
- root: bool = False,
143
- fmt: str = "%(message)s",
144
- clear_handlers: bool = True,
145
- max_lines: int | None = None,
146
- locale: Literal["en", "ja"] = "en",
147
- ) -> None:
148
- """Write symbolic evaluation logs to standard error.
149
-
150
- By default, only the library logger is configured. ``root=True`` preserves
151
- existing root handlers and manages only the handler installed here.
152
- ``max_lines`` emits one truncation notice after the limit. Pass
153
- ``"Level %(levelno)s: %(message)s"`` as ``fmt`` to expose internal levels.
154
- Runtime labels are English by default; ``locale="ja"`` selects Japanese.
155
- """
156
-
157
- global _locale
158
- if max_lines is not None and (
159
- isinstance(max_lines, bool) or not isinstance(max_lines, int) or max_lines < 1
160
- ):
161
- raise ValueError("max_lines must be a positive integer or None")
162
- if locale not in ("en", "ja"):
163
- raise ValueError("locale must be 'en' or 'ja'")
164
- _locale = locale
165
-
166
- root_logger = getLogger()
167
- if clear_handlers:
168
- _remove_peano_handlers(logger, root_logger)
169
-
170
- handler = StreamHandler()
171
- handler.setFormatter(_LineLimitFormatter(fmt))
172
- handler.setLevel(log_level)
173
- handler.addFilter(_LineLimitFilter(max_lines))
174
- setattr(handler, _PEANO_HANDLER_MARKER, True)
175
-
176
- logger.setLevel(log_level)
177
- logger.disabled = False
178
- if root:
179
- logger.propagate = True
180
- root_logger.addHandler(handler)
181
- else:
182
- logger.propagate = False
183
- logger.addHandler(handler)
184
-
185
-
186
- def explain(value: object) -> str:
187
- """Render a value consistently inside symbolic log messages."""
188
-
189
- return repr(value)