python-intl 0.2.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.
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-intl
3
+ Version: 0.4.0
4
+ Summary: Python implementation of the Intl JavaScript API
5
+ Author: David Danier
6
+ Author-email: David Danier <david.danier@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Dist: pyicu>=2.16.2
10
+ Requires-Python: >=3.12
11
+ Project-URL: Repository, https://github.com/ddanier/python-intl
12
+ Description-Content-Type: text/markdown
13
+
14
+ # `python-intl`
15
+
16
+ A small library using [PyICU](https://pypi.org/project/pyicu/) to provide a Python
17
+ API similar to what the [`Intl` JavaScript API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl)
18
+ provides. It is not meant to fully behave the same, but instead be close enough so
19
+ the "same" code works on JavaScript and Python, with similar results.
20
+
21
+ **Status:** This is very much work in progress. Currently only `Intl.DateTimeFormat`
22
+ exists and this also is not complete yet.
23
+
24
+ **Note:** There are a lot of tests running the Python implementation against the
25
+ JavaScript one and comparing the results. In general things should be pretty
26
+ stable there. Still there are some cases with known differences. Also note
27
+ that results depend on the ICU version you have installed on your machine.
28
+
29
+ ## General notes about the Python adaption
30
+
31
+ All names will be using the Python style rules. This means instead of
32
+ `formatToParts` a method will be called `format_to_parts`. Also a dictionary
33
+ key like `dayPeriod` will be named `day_period`. Python uses snake case, let's
34
+ stick to this.
35
+
36
+ Instead of passing around undefined objects like in JavaScript we want to use
37
+ well defined and typed dataclasses. This for example is true for the
38
+ `Intl.DateTimeFormat` format options, you can use `DateTimeFormatOptions` as
39
+ a clean representation of those. Using a dictionary (which behaves the most
40
+ like those JavaScript objects) is still fine and will automatically converted,
41
+ as seen in the examples here.
42
+
43
+ Many objects like for example the `DateTimeFormatOptions` provide methods to
44
+ convert their Python representation to a JavaScript compatible JSON format
45
+ by returning a `dict` using the JavaScript naming. You can use the `to_json`
46
+ method for this.
47
+
48
+ For example:
49
+ ```python
50
+ import python_intl as Intl
51
+
52
+ Intl.DateTimeFormatOptions(time_zone_name="short_offset").to_json()
53
+ # Will return: {'timeZoneName': 'shortOffset'}
54
+ ```
55
+
56
+ ## Available `Intl` classes
57
+
58
+ ### `Intl.DateTimeFormat`
59
+
60
+ #### Example usage
61
+
62
+ ```python
63
+ import datetime as dt
64
+ import python_intl as Intl
65
+
66
+ datetime = dt.datetime(2026, 8, 15)
67
+ formatter = Intl.DateTimeFormat("de-DE", {"year": "numeric", "month": "2-digit", "day": "2-digit"})
68
+ formatter.format(datetime) # Will output the German format: "15.08.2026"
69
+ datetime_till = dt.datetime(2026, 9, 7)
70
+ formatter.format_range(datetime, datetime_till) # Will output the German format: "15.08. – 07.09.2026"
71
+ ```
72
+
73
+ #### Compatibility
74
+
75
+ | Method | Status | Python name |
76
+ | ----------------------------------- | :----: | -------------------------------- |
77
+ | `DateTimeFormat.format` | ✅ | |
78
+ | `DateTimeFormat.formatToParts` | ✅ | `DateTimeFormat.format_to_parts` |
79
+ | `DateTimeFormat.supportedLocalesOf` | ❌ | |
80
+ | `DateTimeFormat.formatRange` | ✅ | `DateTimeFormat.format_range` |
81
+ | `DateTimeFormat.formatRangeToParts` | ❌ | |
82
+ | `DateTimeFormat.resolvedOptions` | ❌ | |
83
+
84
+ ## Installation
85
+
86
+ Be sure to be able to install `PyICU`, see the installation docs there:
87
+ https://gitlab.pyicu.org/main/pyicu#installing-pyicu
88
+
89
+ **Hint:** I mainly did run into issues with `pkg-config` not finding the ICU library,
90
+ setting `PKG_CONFIG_PATH` accordingly helps most of the time I guess.
91
+
92
+ When this is done you should be able to install `python-intl` using any package
93
+ manager, like `pip install python-intl` or `uv add python-intl`.
@@ -0,0 +1,80 @@
1
+ # `python-intl`
2
+
3
+ A small library using [PyICU](https://pypi.org/project/pyicu/) to provide a Python
4
+ API similar to what the [`Intl` JavaScript API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl)
5
+ provides. It is not meant to fully behave the same, but instead be close enough so
6
+ the "same" code works on JavaScript and Python, with similar results.
7
+
8
+ **Status:** This is very much work in progress. Currently only `Intl.DateTimeFormat`
9
+ exists and this also is not complete yet.
10
+
11
+ **Note:** There are a lot of tests running the Python implementation against the
12
+ JavaScript one and comparing the results. In general things should be pretty
13
+ stable there. Still there are some cases with known differences. Also note
14
+ that results depend on the ICU version you have installed on your machine.
15
+
16
+ ## General notes about the Python adaption
17
+
18
+ All names will be using the Python style rules. This means instead of
19
+ `formatToParts` a method will be called `format_to_parts`. Also a dictionary
20
+ key like `dayPeriod` will be named `day_period`. Python uses snake case, let's
21
+ stick to this.
22
+
23
+ Instead of passing around undefined objects like in JavaScript we want to use
24
+ well defined and typed dataclasses. This for example is true for the
25
+ `Intl.DateTimeFormat` format options, you can use `DateTimeFormatOptions` as
26
+ a clean representation of those. Using a dictionary (which behaves the most
27
+ like those JavaScript objects) is still fine and will automatically converted,
28
+ as seen in the examples here.
29
+
30
+ Many objects like for example the `DateTimeFormatOptions` provide methods to
31
+ convert their Python representation to a JavaScript compatible JSON format
32
+ by returning a `dict` using the JavaScript naming. You can use the `to_json`
33
+ method for this.
34
+
35
+ For example:
36
+ ```python
37
+ import python_intl as Intl
38
+
39
+ Intl.DateTimeFormatOptions(time_zone_name="short_offset").to_json()
40
+ # Will return: {'timeZoneName': 'shortOffset'}
41
+ ```
42
+
43
+ ## Available `Intl` classes
44
+
45
+ ### `Intl.DateTimeFormat`
46
+
47
+ #### Example usage
48
+
49
+ ```python
50
+ import datetime as dt
51
+ import python_intl as Intl
52
+
53
+ datetime = dt.datetime(2026, 8, 15)
54
+ formatter = Intl.DateTimeFormat("de-DE", {"year": "numeric", "month": "2-digit", "day": "2-digit"})
55
+ formatter.format(datetime) # Will output the German format: "15.08.2026"
56
+ datetime_till = dt.datetime(2026, 9, 7)
57
+ formatter.format_range(datetime, datetime_till) # Will output the German format: "15.08. – 07.09.2026"
58
+ ```
59
+
60
+ #### Compatibility
61
+
62
+ | Method | Status | Python name |
63
+ | ----------------------------------- | :----: | -------------------------------- |
64
+ | `DateTimeFormat.format` | ✅ | |
65
+ | `DateTimeFormat.formatToParts` | ✅ | `DateTimeFormat.format_to_parts` |
66
+ | `DateTimeFormat.supportedLocalesOf` | ❌ | |
67
+ | `DateTimeFormat.formatRange` | ✅ | `DateTimeFormat.format_range` |
68
+ | `DateTimeFormat.formatRangeToParts` | ❌ | |
69
+ | `DateTimeFormat.resolvedOptions` | ❌ | |
70
+
71
+ ## Installation
72
+
73
+ Be sure to be able to install `PyICU`, see the installation docs there:
74
+ https://gitlab.pyicu.org/main/pyicu#installing-pyicu
75
+
76
+ **Hint:** I mainly did run into issues with `pkg-config` not finding the ICU library,
77
+ setting `PKG_CONFIG_PATH` accordingly helps most of the time I guess.
78
+
79
+ When this is done you should be able to install `python-intl` using any package
80
+ manager, like `pip install python-intl` or `uv add python-intl`.
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "python-intl"
3
- version = "0.2.0"
4
- description = "Add your description here"
3
+ version = "0.4.0"
4
+ description = "Python implementation of the Intl JavaScript API"
5
5
  readme = "README.md"
6
6
  license = "MIT"
7
7
  license-files = ["LICENSE"]
@@ -80,6 +80,10 @@ ignore = [
80
80
  "F401",
81
81
  ]
82
82
 
83
+ [tool.ruff.lint.isort]
84
+ combine-as-imports = true
85
+ force-wrap-aliases = true
86
+
83
87
  [tool.uv.build-backend]
84
88
  module-name = "python_intl"
85
89
  module-root = ""
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "python-intl"
3
- version = "0.2.0"
4
- description = "Add your description here"
3
+ version = "0.4.0"
4
+ description = "Python implementation of the Intl JavaScript API"
5
5
  readme = "README.md"
6
6
  authors = [
7
7
  { name = "David Danier", email = "david.danier@gmail.com" }
@@ -46,6 +46,10 @@ ignore = ["A001","A002","A003","ANN401","C901","N8","B008","F405","F821"]
46
46
  "conftest.py" = ["S101","ANN","F401"]
47
47
  "test_*.py" = ["S101","ANN","F401"]
48
48
 
49
+ [tool.ruff.lint.isort]
50
+ combine-as-imports = true
51
+ force-wrap-aliases = true
52
+
49
53
  [build-system]
50
54
  requires = ["uv_build>=0.12.3,<0.13.0"]
51
55
  build-backend = "uv_build"
@@ -0,0 +1,6 @@
1
+ from .datetimeformat import (
2
+ DateTimeFormat as DateTimeFormat,
3
+ DateTimeFormatOptions as DateTimeFormatOptions,
4
+ DateTimePatternPart as DateTimePatternPart,
5
+ FormatPatternNotFoundException as FormatPatternNotFoundException,
6
+ )
@@ -49,6 +49,13 @@ if TYPE_CHECKING:
49
49
  | TimezoneNameFormatT
50
50
  )
51
51
 
52
+ type PatternPartTypeT = Literal[
53
+ "literal", "unknown",
54
+ "era", "year", "month", "weekday", "day", "day_period",
55
+ "hour", "minute", "second", "fraction_second_digits",
56
+ "time_zone_name",
57
+ ]
58
+
52
59
  # Important: Must be the same as DateTimeFormatOptions
53
60
  # (nothing is required, as this will be used to construct a
54
61
  # DateTimeFormatOptions instance, so default values apply then)
@@ -69,8 +76,45 @@ if TYPE_CHECKING:
69
76
  fraction_second_digits: NotRequired[FractionSecondDigitsFormatT]
70
77
  time_zone_name: NotRequired[TimezoneNameFormatT]
71
78
 
79
+ _PATTERN_SYMBOLS = "GyYuUrQqMLqQdDFgEecabBhHkKmsSAzZOvVxX" # includes unused
80
+ _PATTERN_SYMBOL_TO_TYPE: dict[str, PatternPartTypeT] = {
81
+ "G": "era",
82
+ "y": "year",
83
+ "Y": "year",
84
+ "u": "year",
85
+ "U": "year",
86
+ "r": "year",
87
+ "M": "month",
88
+ "E": "weekday",
89
+ "e": "weekday",
90
+ "c": "weekday",
91
+ "d": "day",
92
+ "a": "day_period",
93
+ "b": "day_period",
94
+ "C": "day_period",
95
+ "h": "hour",
96
+ "H": "hour",
97
+ "k": "hour",
98
+ "K": "hour",
99
+ "m": "minute",
100
+ "s": "second",
101
+ "S": "fraction_second_digits",
102
+ "z": "time_zone_name",
103
+ "Z": "time_zone_name",
104
+ "O": "time_zone_name",
105
+ "v": "time_zone_name",
106
+ "V": "time_zone_name",
107
+ "x": "time_zone_name",
108
+ "X": "time_zone_name",
109
+ }
110
+ _PATTERN_QUOTE = "'"
72
111
 
73
- _TIMEZONE_NAME_JS_MAPPING: dict[str | None, str] = {
112
+ _COMPONENT_TO_JSON_MAP: dict[str, str] = {
113
+ "day_period": "dayPeriod",
114
+ "fraction_second_digits": "fractionalSecondDigits",
115
+ "time_zone_name": "timeZoneName",
116
+ }
117
+ _TIMEZONE_NAME_TO_JSON_MAP: dict[str | None, str] = {
74
118
  "short_offset": "shortOffset",
75
119
  "long_offset": "longOffset",
76
120
  "short_generic": "shortGeneric",
@@ -78,7 +122,7 @@ _TIMEZONE_NAME_JS_MAPPING: dict[str | None, str] = {
78
122
  }
79
123
 
80
124
 
81
- @dataclasses.dataclass(frozen=True, kw_only=True)
125
+ @dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
82
126
  class DateTimeFormatOptions:
83
127
  locale_matcher: LocaleMatcherT = "best fit"
84
128
  hour12: Hour12T = None
@@ -110,7 +154,7 @@ class DateTimeFormatOptions:
110
154
  ("minute", self.minute),
111
155
  ("second", self.second),
112
156
  ("fractionalSecondDigits", self.fraction_second_digits),
113
- ("timeZoneName", _TIMEZONE_NAME_JS_MAPPING.get(self.time_zone_name, self.time_zone_name)),
157
+ ("timeZoneName", _TIMEZONE_NAME_TO_JSON_MAP.get(self.time_zone_name, self.time_zone_name)),
114
158
  )
115
159
  if v is not None
116
160
  }
@@ -243,8 +287,8 @@ def _options_to_possible_skeletons(options: DateTimeFormatOptions) -> Iterable[s
243
287
  yield from generate_skeletons("", skeleton_parts)
244
288
 
245
289
 
246
- @dataclasses.dataclass(frozen=True, kw_only=True)
247
- class MatchedFormatPattern:
290
+ @dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
291
+ class _MatchedFormatPattern:
248
292
  skeleton: str
249
293
  pattern: str
250
294
 
@@ -256,11 +300,25 @@ class FormatPatternNotFoundException(Exception):
256
300
  pass
257
301
 
258
302
 
303
+ @dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
304
+ class DateTimePatternPart:
305
+ type: PatternPartTypeT
306
+ value: str
307
+ _pattern: str | None = None
308
+
309
+ def to_json(self) -> dict[str, str]:
310
+ return {
311
+ "type": _COMPONENT_TO_JSON_MAP.get(self.type, self.type),
312
+ "value": self.value,
313
+ # note: _pattern is internal and not available in JavaScript
314
+ }
315
+
316
+
259
317
  @cache
260
318
  def _options_to_format_pattern(
261
319
  locale: icu.Locale, # ty: ignore[unresolved-attribute]
262
320
  options: DateTimeFormatOptions,
263
- ) -> MatchedFormatPattern:
321
+ ) -> _MatchedFormatPattern:
264
322
  possible_skeletons = list(_options_to_possible_skeletons(options))
265
323
 
266
324
  generator = icu.DateTimePatternGenerator.createInstance(locale) # ty: ignore[unresolved-attribute]
@@ -269,7 +327,7 @@ def _options_to_format_pattern(
269
327
  for skeleton in possible_skeletons:
270
328
  pattern = generator.getPatternForSkeleton(skeleton)
271
329
  if pattern:
272
- return MatchedFormatPattern(
330
+ return _MatchedFormatPattern(
273
331
  skeleton=skeleton,
274
332
  pattern=pattern,
275
333
  )
@@ -279,7 +337,7 @@ def _options_to_format_pattern(
279
337
  for skeleton in possible_skeletons:
280
338
  pattern = generator.getBestPattern(skeleton)
281
339
  if pattern:
282
- return MatchedFormatPattern(
340
+ return _MatchedFormatPattern(
283
341
  skeleton=skeleton,
284
342
  pattern=pattern,
285
343
  )
@@ -303,20 +361,82 @@ class DateTimeFormat:
303
361
  self.options = DateTimeFormatOptions(**options)
304
362
 
305
363
  @cached_property
306
- def icu_locale(self) -> icu.Locale: # ty: ignore[unresolved-attribute]
364
+ def _icu_locale(self) -> icu.Locale: # ty: ignore[unresolved-attribute]
307
365
  return icu.Locale(self.locale) # ty: ignore[unresolved-attribute]
308
366
 
309
367
  @cached_property
310
- def matched_pattern(self) -> MatchedFormatPattern:
311
- return _options_to_format_pattern(self.icu_locale, self.options)
368
+ def _matched_pattern(self) -> _MatchedFormatPattern:
369
+ return _options_to_format_pattern(self._icu_locale, self.options)
312
370
 
313
371
  @cached_property
314
- def icu_pattern(self) -> str:
315
- return self.matched_pattern.pattern
372
+ def _icu_pattern(self) -> str:
373
+ return self._matched_pattern.pattern
316
374
 
317
375
  @cached_property
318
- def icu_date_format(self) -> icu.SimpleDateFormat: # ty: ignore[unresolved-attribute]
319
- return icu.SimpleDateFormat(self.icu_pattern, self.icu_locale) # ty: ignore[unresolved-attribute]
376
+ def _icu_date_format(self) -> icu.SimpleDateFormat: # ty: ignore[unresolved-attribute]
377
+ return icu.SimpleDateFormat(self._icu_pattern, self._icu_locale) # ty: ignore[unresolved-attribute]
320
378
 
321
379
  def format(self, datetime_: dt.datetime, /) -> str:
322
- return self.icu_date_format.format(datetime_)
380
+ return self._icu_date_format.format(datetime_)
381
+
382
+ def format_to_parts(self, datetime_: dt.datetime, /) -> Iterable[DateTimePatternPart]:
383
+ # Based on https://github.com/unicode-org/icu/blob/6fb634d81d10dd4667fb3fbcd1f19d9b9b926e62/icu4c/source/i18n/smpdtfmt.cpp#L1060
384
+ pattern = self._icu_pattern
385
+ in_quote = False
386
+ prev_char = ""
387
+ pattern_length = len(pattern)
388
+ count = 0
389
+ i = 0
390
+ literal_chars: list[str] = []
391
+ while i < pattern_length:
392
+ char = pattern[i]
393
+
394
+ if char != prev_char and count > 0:
395
+ yield DateTimePatternPart(
396
+ type=_PATTERN_SYMBOL_TO_TYPE.get(prev_char, "unknown"),
397
+ value=icu.SimpleDateFormat(prev_char * count, self._icu_locale).format(datetime_), # ty: ignore[unresolved-attribute]
398
+ _pattern=prev_char * count,
399
+ )
400
+ count = 0
401
+
402
+ if char == _PATTERN_QUOTE:
403
+ if (i + 1) < pattern_length and pattern[i + 1] == _PATTERN_QUOTE:
404
+ literal_chars.append(_PATTERN_QUOTE)
405
+ i += 1
406
+ else:
407
+ in_quote = not in_quote
408
+ elif not in_quote and char in _PATTERN_SYMBOLS:
409
+ if literal_chars:
410
+ yield DateTimePatternPart(
411
+ type="literal",
412
+ value="".join(literal_chars),
413
+ )
414
+ literal_chars = []
415
+ prev_char = char
416
+ count += 1
417
+ else:
418
+ literal_chars.append(char)
419
+
420
+ i += 1
421
+
422
+ if count > 0:
423
+ yield DateTimePatternPart(
424
+ type=_PATTERN_SYMBOL_TO_TYPE.get(prev_char, "unknown"),
425
+ value=icu.SimpleDateFormat(prev_char * count, self._icu_locale).format(datetime_), # ty: ignore[unresolved-attribute]
426
+ _pattern=prev_char * count,
427
+ )
428
+ assert not literal_chars # noqa: S101
429
+ elif literal_chars:
430
+ yield DateTimePatternPart(
431
+ type="literal",
432
+ value="".join(literal_chars),
433
+ )
434
+
435
+ @cached_property
436
+ def _icu_dateinterval_format(self) -> icu.DateIntervalFormat: # ty: ignore[unresolved-attribute]
437
+ possible_skeletons = list(_options_to_possible_skeletons(self.options))
438
+ return icu.DateIntervalFormat.createInstance(possible_skeletons[0], self._icu_locale) # ty: ignore[unresolved-attribute]
439
+
440
+ def format_range(self, start_datetime: dt.datetime, end_datetime: dt.datetime) -> str:
441
+ icu_date_interval = icu.DateInterval(start_datetime, end_datetime) # ty: ignore[unresolved-attribute]
442
+ return self._icu_dateinterval_format.format(icu_date_interval)
@@ -1,42 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: python-intl
3
- Version: 0.2.0
4
- Summary: Add your description here
5
- Author: David Danier
6
- Author-email: David Danier <david.danier@gmail.com>
7
- License-Expression: MIT
8
- License-File: LICENSE
9
- Requires-Dist: pyicu>=2.16.2
10
- Requires-Python: >=3.12
11
- Project-URL: Repository, https://github.com/ddanier/python-intl
12
- Description-Content-Type: text/markdown
13
-
14
- # `python-intl`
15
-
16
- A small library using [PyICU](https://pypi.org/project/pyicu/) to provide a Python
17
- API similar to what the [`Intl` JavaScript API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl)
18
- provides.
19
-
20
- **Status:** This is very much work in progress. Currently only `Intl.DateTimeFormat`
21
- exists and this also is not complete yet.
22
-
23
- ## `Intl.DateTimeFormat`
24
-
25
- ### Example usage
26
-
27
- ```python
28
- import datetime
29
- from python_intl import DateTimeFormat
30
-
31
- datetime_ = datetime.datetime(2026, 8, 15)
32
- formatter = DateTimeFormat("de-DE", {"year": "numeric", "month": "2-digit", "day": "2-digit"})
33
- formatter.format(datetime_) # Will output the German format: "15.08.2026"
34
- ```
35
-
36
- ## Installation
37
-
38
- Be sure to be able to install `PyICU`, see the installation docs there:
39
- https://gitlab.pyicu.org/main/pyicu#installing-pyicu
40
-
41
- **Hint:** I mainly did run into issues with `pkg-config` not finding the ICU library,
42
- setting `PKG_CONFIG_PATH` accordingly helps most of the time I guess.
@@ -1,29 +0,0 @@
1
- # `python-intl`
2
-
3
- A small library using [PyICU](https://pypi.org/project/pyicu/) to provide a Python
4
- API similar to what the [`Intl` JavaScript API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl)
5
- provides.
6
-
7
- **Status:** This is very much work in progress. Currently only `Intl.DateTimeFormat`
8
- exists and this also is not complete yet.
9
-
10
- ## `Intl.DateTimeFormat`
11
-
12
- ### Example usage
13
-
14
- ```python
15
- import datetime
16
- from python_intl import DateTimeFormat
17
-
18
- datetime_ = datetime.datetime(2026, 8, 15)
19
- formatter = DateTimeFormat("de-DE", {"year": "numeric", "month": "2-digit", "day": "2-digit"})
20
- formatter.format(datetime_) # Will output the German format: "15.08.2026"
21
- ```
22
-
23
- ## Installation
24
-
25
- Be sure to be able to install `PyICU`, see the installation docs there:
26
- https://gitlab.pyicu.org/main/pyicu#installing-pyicu
27
-
28
- **Hint:** I mainly did run into issues with `pkg-config` not finding the ICU library,
29
- setting `PKG_CONFIG_PATH` accordingly helps most of the time I guess.
@@ -1 +0,0 @@
1
- from .datetimeformat import DateTimeFormat as DateTimeFormat
File without changes