python-intl 0.4.0__tar.gz → 0.6.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: python-intl
3
- Version: 0.4.0
3
+ Version: 0.6.0
4
4
  Summary: Python implementation of the Intl JavaScript API
5
5
  Author: David Danier
6
6
  Author-email: David Danier <david.danier@gmail.com>
@@ -18,8 +18,7 @@ API similar to what the [`Intl` JavaScript API](https://developer.mozilla.org/en
18
18
  provides. It is not meant to fully behave the same, but instead be close enough so
19
19
  the "same" code works on JavaScript and Python, with similar results.
20
20
 
21
- **Status:** This is very much work in progress. Currently only `Intl.DateTimeFormat`
22
- exists and this also is not complete yet.
21
+ **Status:** This is very much work in progress.
23
22
 
24
23
  **Note:** There are a lot of tests running the Python implementation against the
25
24
  JavaScript one and comparing the results. In general things should be pretty
@@ -63,23 +62,38 @@ Intl.DateTimeFormatOptions(time_zone_name="short_offset").to_json()
63
62
  import datetime as dt
64
63
  import python_intl as Intl
65
64
 
65
+ # Format a datetime
66
66
  datetime = dt.datetime(2026, 8, 15)
67
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"
68
+ formatter.format(datetime)
69
+ # Result = "15.08.2026"
70
+
71
+ # Format a datetime range
69
72
  datetime_till = dt.datetime(2026, 9, 7)
70
- formatter.format_range(datetime, datetime_till) # Will output the German format: "15.08. – 07.09.2026"
73
+ formatter.format_range(datetime, datetime_till)
74
+ # Result = "15.08. – 07.09.2026"
71
75
  ```
72
76
 
73
77
  #### Compatibility
74
78
 
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` | ❌ | |
79
+ | Method | Status | Python name |
80
+ | ----------------------------------- | :----: | -------------------------------------- |
81
+ | `DateTimeFormat.format` | ✅ | |
82
+ | `DateTimeFormat.formatToParts` | ✅ | `DateTimeFormat.format_to_parts` |
83
+ | `DateTimeFormat.supportedLocalesOf` | ❌ | |
84
+ | `DateTimeFormat.formatRange` | ✅ | `DateTimeFormat.format_range` |
85
+ | `DateTimeFormat.formatRangeToParts` | | `DateTimeFormat.format_range_to_parts` |
86
+ | `DateTimeFormat.resolvedOptions` | ❌ | |
87
+
88
+ ### `Intl.Collator`
89
+
90
+ #### Compatibility
91
+
92
+ | Method | Status | Python name |
93
+ | ------------------ | :----: | ----------- |
94
+ | `Collator.compare` | ✅ | |
95
+
96
+ **Note:** Not all options are currently supported.
83
97
 
84
98
  ## Installation
85
99
 
@@ -5,8 +5,7 @@ API similar to what the [`Intl` JavaScript API](https://developer.mozilla.org/en
5
5
  provides. It is not meant to fully behave the same, but instead be close enough so
6
6
  the "same" code works on JavaScript and Python, with similar results.
7
7
 
8
- **Status:** This is very much work in progress. Currently only `Intl.DateTimeFormat`
9
- exists and this also is not complete yet.
8
+ **Status:** This is very much work in progress.
10
9
 
11
10
  **Note:** There are a lot of tests running the Python implementation against the
12
11
  JavaScript one and comparing the results. In general things should be pretty
@@ -50,23 +49,38 @@ Intl.DateTimeFormatOptions(time_zone_name="short_offset").to_json()
50
49
  import datetime as dt
51
50
  import python_intl as Intl
52
51
 
52
+ # Format a datetime
53
53
  datetime = dt.datetime(2026, 8, 15)
54
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"
55
+ formatter.format(datetime)
56
+ # Result = "15.08.2026"
57
+
58
+ # Format a datetime range
56
59
  datetime_till = dt.datetime(2026, 9, 7)
57
- formatter.format_range(datetime, datetime_till) # Will output the German format: "15.08. – 07.09.2026"
60
+ formatter.format_range(datetime, datetime_till)
61
+ # Result = "15.08. – 07.09.2026"
58
62
  ```
59
63
 
60
64
  #### Compatibility
61
65
 
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` | ❌ | |
66
+ | Method | Status | Python name |
67
+ | ----------------------------------- | :----: | -------------------------------------- |
68
+ | `DateTimeFormat.format` | ✅ | |
69
+ | `DateTimeFormat.formatToParts` | ✅ | `DateTimeFormat.format_to_parts` |
70
+ | `DateTimeFormat.supportedLocalesOf` | ❌ | |
71
+ | `DateTimeFormat.formatRange` | ✅ | `DateTimeFormat.format_range` |
72
+ | `DateTimeFormat.formatRangeToParts` | | `DateTimeFormat.format_range_to_parts` |
73
+ | `DateTimeFormat.resolvedOptions` | ❌ | |
74
+
75
+ ### `Intl.Collator`
76
+
77
+ #### Compatibility
78
+
79
+ | Method | Status | Python name |
80
+ | ------------------ | :----: | ----------- |
81
+ | `Collator.compare` | ✅ | |
82
+
83
+ **Note:** Not all options are currently supported.
70
84
 
71
85
  ## Installation
72
86
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-intl"
3
- version = "0.4.0"
3
+ version = "0.6.0"
4
4
  description = "Python implementation of the Intl JavaScript API"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,13 +21,17 @@ dev = [
21
21
  "pyright>=1.1.411",
22
22
  "pytest>=9.1.1",
23
23
  "pytest-cov>=7.1.0",
24
+ "pytest-xdist>=3.8.0",
24
25
  "ruff>=0.16.3",
25
26
  "tox>=4.60.0",
26
27
  "ty>=0.0.72",
27
28
  ]
28
29
 
29
30
  [tool.pytest.ini_options]
30
- markers = ["unit: mark a test as a unit test"]
31
+ markers = [
32
+ "unit: mark a test as a unit test",
33
+ "node: mark tests jun against the node/JS implementation",
34
+ ]
31
35
 
32
36
  [tool.ruff]
33
37
  line-length = 115
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-intl"
3
- version = "0.4.0"
3
+ version = "0.6.0"
4
4
  description = "Python implementation of the Intl JavaScript API"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -22,6 +22,7 @@ dev = [
22
22
  "pyright>=1.1.411",
23
23
  "pytest>=9.1.1",
24
24
  "pytest-cov>=7.1.0",
25
+ "pytest-xdist>=3.8.0",
25
26
  "ruff>=0.16.3",
26
27
  "tox>=4.60.0",
27
28
  "ty>=0.0.72",
@@ -30,6 +31,7 @@ dev = [
30
31
  [tool.pytest.ini_options]
31
32
  markers = [
32
33
  "unit: mark a test as a unit test",
34
+ "node: mark tests jun against the node/JS implementation",
33
35
  ]
34
36
 
35
37
  [tool.ruff]
@@ -1,6 +1,11 @@
1
+ from .collator import (
2
+ Collator as Collator,
3
+ CollatorOptions as CollatorOptions,
4
+ )
1
5
  from .datetimeformat import (
2
6
  DateTimeFormat as DateTimeFormat,
3
7
  DateTimeFormatOptions as DateTimeFormatOptions,
8
+ DateTimeIntervalPatternPart as DateTimeIntervalPatternPart,
4
9
  DateTimePatternPart as DateTimePatternPart,
5
10
  FormatPatternNotFoundException as FormatPatternNotFoundException,
6
11
  )
@@ -0,0 +1,3 @@
1
+ from typing import Literal
2
+
3
+ type LocaleMatcherT = Literal["best fit", "lookup"]
@@ -0,0 +1,126 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ from functools import cached_property
5
+ from typing import TYPE_CHECKING, Literal
6
+
7
+ import icu # type: ignore[import-untyped]
8
+
9
+ if TYPE_CHECKING:
10
+ from typing import NotRequired, TypedDict
11
+
12
+
13
+ if TYPE_CHECKING:
14
+ from ._types import LocaleMatcherT
15
+
16
+ # type UsageT = Literal["sort", "search"]
17
+ # type CollationT = Literal["emoji", "pinyin", "stroke"]
18
+ type CaseFirstT = Literal["upper", "lower", "false"]
19
+ type SensitivityT = Literal["base", "accent", "case", "variant"]
20
+
21
+ type ComparisonResultT = Literal[-1, 0, 1]
22
+
23
+ # Important: Must be the same as CollatorOptions
24
+ # (nothing is required, as this will be used to construct a
25
+ # CollatorOptions instance, so default values apply then)
26
+ class CollatorOptionsDictT(TypedDict):
27
+ locale_matcher: NotRequired[LocaleMatcherT]
28
+ # usage: NotRequired[UsageT | None]
29
+ # collation: NotRequired[CollationT | None]
30
+ numeric: NotRequired[bool]
31
+ case_first: NotRequired[CaseFirstT]
32
+ sensitivity: NotRequired[SensitivityT]
33
+ ignore_punctuation: NotRequired[bool | None]
34
+
35
+
36
+ @dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
37
+ class CollatorOptions:
38
+ locale_matcher: LocaleMatcherT = "best fit"
39
+ # usage: UsageT | None = None
40
+ # collation: CollationT | None = None
41
+ numeric: bool = False
42
+ case_first: CaseFirstT = "false"
43
+ sensitivity: SensitivityT = "variant"
44
+ ignore_punctuation: bool | None = None
45
+
46
+ def to_json(self) -> dict[str, str | int]:
47
+ return {
48
+ k: v
49
+ for k, v in (
50
+ ("localeMatcher", self.locale_matcher),
51
+ # ("usage", self.usage),
52
+ # ("collation", self.collation),
53
+ ("numeric", self.numeric),
54
+ ("caseFirst", self.case_first),
55
+ ("sensitivity", self.sensitivity),
56
+ ("ignorePunctuation", self.ignore_punctuation),
57
+ )
58
+ if v is not None
59
+ }
60
+
61
+
62
+ _COLLATOR_RESULT_TO_RESULT: dict[icu.UCollationResult, ComparisonResultT] = { # ty: ignore[unresolved-attribute]
63
+ icu.UCollationResult.LESS: -1, # ty: ignore[unresolved-attribute]
64
+ icu.UCollationResult.EQUAL: 0, # ty: ignore[unresolved-attribute]
65
+ icu.UCollationResult.GREATER: 1, # ty: ignore[unresolved-attribute]
66
+ }
67
+
68
+
69
+ class Collator:
70
+ locale: str
71
+ options: CollatorOptions
72
+
73
+ def __init__(
74
+ self,
75
+ locale: str,
76
+ options: CollatorOptions | CollatorOptionsDictT | None = None,
77
+ ) -> None:
78
+ self.locale = locale
79
+ if options is None:
80
+ self.options = CollatorOptions()
81
+ elif isinstance(options, CollatorOptions):
82
+ self.options = options
83
+ else:
84
+ self.options = CollatorOptions(**options)
85
+
86
+ @cached_property
87
+ def _icu_locale(self) -> icu.Locale: # ty: ignore[unresolved-attribute]
88
+ return icu.Locale(self.locale) # ty: ignore[unresolved-attribute]
89
+
90
+ @cached_property
91
+ def _icu_collator(self) -> icu.Collator: # ty: ignore[unresolved-attribute]
92
+ collator = icu.Collator.createInstance(self._icu_locale) # ty: ignore[unresolved-attribute]
93
+
94
+ if self.options.numeric:
95
+ collator.setAttribute(icu.UCollAttribute.NUMERIC_COLLATION, icu.UCollAttributeValue.ON) # ty: ignore[unresolved-attribute]
96
+
97
+ if (
98
+ self.options.ignore_punctuation
99
+ or (
100
+ self.options.ignore_punctuation is None
101
+ and self._icu_locale.getLanguage() == "th"
102
+ )
103
+ ):
104
+ collator.setAttribute(icu.UCollAttribute.ALTERNATE_HANDLING, icu.UCollAttributeValue.SHIFTED) # ty: ignore[unresolved-attribute]
105
+
106
+ match self.options.case_first:
107
+ case "upper":
108
+ collator.setAttribute(icu.UCollAttribute.CASE_FIRST, icu.UCollAttributeValue.UPPER_FIRST) # ty: ignore[unresolved-attribute]
109
+ case "lower":
110
+ collator.setAttribute(icu.UCollAttribute.CASE_FIRST, icu.UCollAttributeValue.LOWER_FIRST) # ty: ignore[unresolved-attribute]
111
+
112
+ collator.setAttribute(icu.UCollAttribute.NORMALIZATION_MODE, icu.UCollAttributeValue.ON) # ty: ignore[unresolved-attribute]
113
+ match self.options.sensitivity:
114
+ case "base":
115
+ collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.PRIMARY) # ty: ignore[unresolved-attribute]
116
+ case "accent":
117
+ collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.SECONDARY) # ty: ignore[unresolved-attribute]
118
+ case "case":
119
+ collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.TERTIARY) # ty: ignore[unresolved-attribute]
120
+ case "variant":
121
+ collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.QUATERNARY) # ty: ignore[unresolved-attribute]
122
+
123
+ return collator
124
+
125
+ def compare(self, string_a: str, string_b: str) -> ComparisonResultT:
126
+ return _COLLATOR_RESULT_TO_RESULT[self._icu_collator.compare(string_a, string_b)]
@@ -13,7 +13,8 @@ if TYPE_CHECKING:
13
13
 
14
14
 
15
15
  if TYPE_CHECKING:
16
- type LocaleMatcherT = Literal["best fit", "lookup"]
16
+ from ._types import LocaleMatcherT
17
+
17
18
  type Hour12T = bool | None
18
19
  type HourCycleT = Literal["h11", "h12", "h23", "h24"] | None
19
20
 
@@ -107,6 +108,22 @@ _PATTERN_SYMBOL_TO_TYPE: dict[str, PatternPartTypeT] = {
107
108
  "x": "time_zone_name",
108
109
  "X": "time_zone_name",
109
110
  }
111
+ _PATTERN_FIELD_TO_TYPE: dict[icu.UDateTimePatternField, PatternPartTypeT] = { # ty: ignore[unresolved-attribute]
112
+ icu.DateFormat.ERA_FIELD: "era", # ty: ignore[unresolved-attribute]
113
+ icu.DateFormat.YEAR_FIELD: "year", # ty: ignore[unresolved-attribute]
114
+ icu.DateFormat.MONTH_FIELD: "month", # ty: ignore[unresolved-attribute]
115
+ icu.DateFormat.DAY_OF_WEEK_FIELD: "weekday", # ty: ignore[unresolved-attribute]
116
+ icu.DateFormat.DATE_FIELD: "day", # ty: ignore[unresolved-attribute]
117
+ icu.DateFormat.AM_PM_FIELD: "day_period", # ty: ignore[unresolved-attribute]
118
+ icu.DateFormat.HOUR0_FIELD: "hour", # ty: ignore[unresolved-attribute]
119
+ icu.DateFormat.HOUR1_FIELD: "hour", # ty: ignore[unresolved-attribute]
120
+ icu.DateFormat.HOUR_OF_DAY0_FIELD: "hour", # ty: ignore[unresolved-attribute]
121
+ icu.DateFormat.HOUR_OF_DAY1_FIELD: "hour", # ty: ignore[unresolved-attribute]
122
+ icu.DateFormat.MINUTE_FIELD: "minute", # ty: ignore[unresolved-attribute]
123
+ icu.DateFormat.SECOND_FIELD: "second", # ty: ignore[unresolved-attribute]
124
+ icu.DateFormat.MILLISECOND_FIELD: "fraction_second_digits", # ty: ignore[unresolved-attribute]
125
+ icu.DateFormat.TIMEZONE_FIELD: "time_zone_name", # ty: ignore[unresolved-attribute]
126
+ }
110
127
  _PATTERN_QUOTE = "'"
111
128
 
112
129
  _COMPONENT_TO_JSON_MAP: dict[str, str] = {
@@ -120,6 +137,10 @@ _TIMEZONE_NAME_TO_JSON_MAP: dict[str | None, str] = {
120
137
  "short_generic": "shortGeneric",
121
138
  "long_generic": "longGeneric",
122
139
  }
140
+ _SOURCE_TO_JSON_MAP: dict[str, str] = {
141
+ "start_range": "startRange",
142
+ "end_range": "endRange",
143
+ }
123
144
 
124
145
 
125
146
  @dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
@@ -314,6 +335,20 @@ class DateTimePatternPart:
314
335
  }
315
336
 
316
337
 
338
+ @dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
339
+ class DateTimeIntervalPatternPart:
340
+ type: PatternPartTypeT
341
+ value: str
342
+ source: Literal["start_range", "end_range", "shared"]
343
+
344
+ def to_json(self) -> dict[str, str]:
345
+ return {
346
+ "type": _COMPONENT_TO_JSON_MAP.get(self.type, self.type),
347
+ "value": self.value,
348
+ "source": _SOURCE_TO_JSON_MAP.get(self.source, self.source),
349
+ }
350
+
351
+
317
352
  @cache
318
353
  def _options_to_format_pattern(
319
354
  locale: icu.Locale, # ty: ignore[unresolved-attribute]
@@ -345,6 +380,23 @@ def _options_to_format_pattern(
345
380
  raise FormatPatternNotFoundException("Didn't find pattern for desired options")
346
381
 
347
382
 
383
+ @dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
384
+ class _PartSpan:
385
+ start: int
386
+ end: int
387
+
388
+ @classmethod
389
+ def empty(cls) -> _PartSpan:
390
+ return cls(start=0, end=0)
391
+
392
+ @classmethod
393
+ def from_constrained_fieldposition(cls, position: icu.ConstrainedFieldPosition) -> _PartSpan: # ty: ignore[unresolved-attribute]
394
+ return cls(start=position.getStart(), end=position.getLimit())
395
+
396
+ def __contains__(self, inner: _PartSpan) -> bool:
397
+ return inner.start >= self.start and inner.end <= self.end
398
+
399
+
348
400
  class DateTimeFormat:
349
401
  locale: str
350
402
  options: DateTimeFormatOptions
@@ -352,10 +404,12 @@ class DateTimeFormat:
352
404
  def __init__(
353
405
  self,
354
406
  locale: str,
355
- options: DateTimeFormatOptions | DateTimeFormatOptionsDictT,
407
+ options: DateTimeFormatOptions | DateTimeFormatOptionsDictT | None = None,
356
408
  ) -> None:
357
409
  self.locale = locale
358
- if isinstance(options, DateTimeFormatOptions):
410
+ if options is None:
411
+ self.options = DateTimeFormatOptions()
412
+ elif isinstance(options, DateTimeFormatOptions):
359
413
  self.options = options
360
414
  else:
361
415
  self.options = DateTimeFormatOptions(**options)
@@ -437,6 +491,66 @@ class DateTimeFormat:
437
491
  possible_skeletons = list(_options_to_possible_skeletons(self.options))
438
492
  return icu.DateIntervalFormat.createInstance(possible_skeletons[0], self._icu_locale) # ty: ignore[unresolved-attribute]
439
493
 
440
- def format_range(self, start_datetime: dt.datetime, end_datetime: dt.datetime) -> str:
494
+ def format_range(
495
+ self,
496
+ start_datetime: dt.datetime,
497
+ end_datetime: dt.datetime,
498
+ ) -> str:
441
499
  icu_date_interval = icu.DateInterval(start_datetime, end_datetime) # ty: ignore[unresolved-attribute]
442
500
  return self._icu_dateinterval_format.format(icu_date_interval)
501
+
502
+ def format_range_to_parts(
503
+ self,
504
+ start_datetime: dt.datetime,
505
+ end_datetime: dt.datetime,
506
+ ) -> Iterable[DateTimeIntervalPatternPart]:
507
+ icu_date_interval = icu.DateInterval(start_datetime, end_datetime) # ty: ignore[unresolved-attribute]
508
+ formatted = self._icu_dateinterval_format.formatToValue(icu_date_interval)
509
+
510
+ # Find spans of both datetimes (used to determine which parts have which source)
511
+ span_start = _PartSpan.empty()
512
+ span_end = _PartSpan.empty()
513
+ for part in formatted:
514
+ if part.getCategory() == icu.UFieldCategory.DATE_INTERVAL_SPAN: # ty: ignore[unresolved-attribute]
515
+ match part.getField():
516
+ case 0:
517
+ span_start = _PartSpan.from_constrained_fieldposition(part)
518
+ case 1:
519
+ span_end = _PartSpan.from_constrained_fieldposition(part)
520
+
521
+ def source_of(span: _PartSpan) -> Literal["start_range", "end_range", "shared"]:
522
+ if span in span_start:
523
+ return "start_range"
524
+ elif span in span_end:
525
+ return "end_range"
526
+ else:
527
+ return "shared"
528
+
529
+ # Break result string into parts
530
+ result_string = str(formatted)
531
+ last_end = 0
532
+ for part in formatted:
533
+ span = _PartSpan.from_constrained_fieldposition(part)
534
+ if span.start > last_end:
535
+ yield DateTimeIntervalPatternPart(
536
+ type="literal",
537
+ value=result_string[last_end:span.start],
538
+ source=source_of(_PartSpan(start=last_end, end=span.start)),
539
+ )
540
+
541
+ if part.getCategory() == icu.UFieldCategory.DATE: # ty: ignore[unresolved-attribute]
542
+ yield DateTimeIntervalPatternPart(
543
+ type=_PATTERN_FIELD_TO_TYPE.get(part.getField(), "unknown"),
544
+ value=result_string[span.start:span.end],
545
+ source=source_of(span),
546
+ )
547
+
548
+ last_end = span.end
549
+
550
+ # Ensure we didn't miss anything at the end
551
+ if last_end < len(result_string):
552
+ yield DateTimeIntervalPatternPart(
553
+ type="literal",
554
+ value=result_string[last_end:],
555
+ source=source_of(_PartSpan(start=last_end, end=len(result_string))),
556
+ )
File without changes