python-intl 0.3.1__tar.gz → 0.5.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.3.1
3
+ Version: 0.5.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>
@@ -21,6 +21,11 @@ the "same" code works on JavaScript and Python, with similar results.
21
21
  **Status:** This is very much work in progress. Currently only `Intl.DateTimeFormat`
22
22
  exists and this also is not complete yet.
23
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
+
24
29
  ## General notes about the Python adaption
25
30
 
26
31
  All names will be using the Python style rules. This means instead of
@@ -55,24 +60,31 @@ Intl.DateTimeFormatOptions(time_zone_name="short_offset").to_json()
55
60
  #### Example usage
56
61
 
57
62
  ```python
58
- import datetime
63
+ import datetime as dt
59
64
  import python_intl as Intl
60
65
 
61
- datetime_ = datetime.datetime(2026, 8, 15)
66
+ # Format a datetime
67
+ datetime = dt.datetime(2026, 8, 15)
62
68
  formatter = Intl.DateTimeFormat("de-DE", {"year": "numeric", "month": "2-digit", "day": "2-digit"})
63
- formatter.format(datetime_) # Will output the German format: "15.08.2026"
69
+ formatter.format(datetime)
70
+ # Result = "15.08.2026"
71
+
72
+ # Format a datetime range
73
+ datetime_till = dt.datetime(2026, 9, 7)
74
+ formatter.format_range(datetime, datetime_till)
75
+ # Result = "15.08. – 07.09.2026"
64
76
  ```
65
77
 
66
78
  #### Compatibility
67
79
 
68
- | Method | Status | Python name |
69
- | ----------------------------------- | :----: | -------------------------------- |
70
- | `DateTimeFormat.format` | ✅ | |
71
- | `DateTimeFormat.formatToParts` | ✅ | `DateTimeFormat.format_to_parts` |
72
- | `DateTimeFormat.supportedLocalesOf` | ❌ | |
73
- | `DateTimeFormat.formatRange` | | |
74
- | `DateTimeFormat.formatRangeToParts` | | |
75
- | `DateTimeFormat.resolvedOptions` | ❌ | |
80
+ | Method | Status | Python name |
81
+ | ----------------------------------- | :----: | -------------------------------------- |
82
+ | `DateTimeFormat.format` | ✅ | |
83
+ | `DateTimeFormat.formatToParts` | ✅ | `DateTimeFormat.format_to_parts` |
84
+ | `DateTimeFormat.supportedLocalesOf` | ❌ | |
85
+ | `DateTimeFormat.formatRange` | | `DateTimeFormat.format_range` |
86
+ | `DateTimeFormat.formatRangeToParts` | | `DateTimeFormat.format_range_to_parts` |
87
+ | `DateTimeFormat.resolvedOptions` | ❌ | |
76
88
 
77
89
  ## Installation
78
90
 
@@ -8,6 +8,11 @@ the "same" code works on JavaScript and Python, with similar results.
8
8
  **Status:** This is very much work in progress. Currently only `Intl.DateTimeFormat`
9
9
  exists and this also is not complete yet.
10
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
+
11
16
  ## General notes about the Python adaption
12
17
 
13
18
  All names will be using the Python style rules. This means instead of
@@ -42,24 +47,31 @@ Intl.DateTimeFormatOptions(time_zone_name="short_offset").to_json()
42
47
  #### Example usage
43
48
 
44
49
  ```python
45
- import datetime
50
+ import datetime as dt
46
51
  import python_intl as Intl
47
52
 
48
- datetime_ = datetime.datetime(2026, 8, 15)
53
+ # Format a datetime
54
+ datetime = dt.datetime(2026, 8, 15)
49
55
  formatter = Intl.DateTimeFormat("de-DE", {"year": "numeric", "month": "2-digit", "day": "2-digit"})
50
- formatter.format(datetime_) # Will output the German format: "15.08.2026"
56
+ formatter.format(datetime)
57
+ # Result = "15.08.2026"
58
+
59
+ # Format a datetime range
60
+ datetime_till = dt.datetime(2026, 9, 7)
61
+ formatter.format_range(datetime, datetime_till)
62
+ # Result = "15.08. – 07.09.2026"
51
63
  ```
52
64
 
53
65
  #### Compatibility
54
66
 
55
- | Method | Status | Python name |
56
- | ----------------------------------- | :----: | -------------------------------- |
57
- | `DateTimeFormat.format` | ✅ | |
58
- | `DateTimeFormat.formatToParts` | ✅ | `DateTimeFormat.format_to_parts` |
59
- | `DateTimeFormat.supportedLocalesOf` | ❌ | |
60
- | `DateTimeFormat.formatRange` | | |
61
- | `DateTimeFormat.formatRangeToParts` | | |
62
- | `DateTimeFormat.resolvedOptions` | ❌ | |
67
+ | Method | Status | Python name |
68
+ | ----------------------------------- | :----: | -------------------------------------- |
69
+ | `DateTimeFormat.format` | ✅ | |
70
+ | `DateTimeFormat.formatToParts` | ✅ | `DateTimeFormat.format_to_parts` |
71
+ | `DateTimeFormat.supportedLocalesOf` | ❌ | |
72
+ | `DateTimeFormat.formatRange` | | `DateTimeFormat.format_range` |
73
+ | `DateTimeFormat.formatRangeToParts` | | `DateTimeFormat.format_range_to_parts` |
74
+ | `DateTimeFormat.resolvedOptions` | ❌ | |
63
75
 
64
76
  ## Installation
65
77
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-intl"
3
- version = "0.3.1"
3
+ version = "0.5.0"
4
4
  description = "Python implementation of the Intl JavaScript API"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-intl"
3
- version = "0.3.1"
3
+ version = "0.5.0"
4
4
  description = "Python implementation of the Intl JavaScript API"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,6 +1,7 @@
1
1
  from .datetimeformat import (
2
2
  DateTimeFormat as DateTimeFormat,
3
3
  DateTimeFormatOptions as DateTimeFormatOptions,
4
+ DateTimeIntervalPatternPart as DateTimeIntervalPatternPart,
4
5
  DateTimePatternPart as DateTimePatternPart,
5
6
  FormatPatternNotFoundException as FormatPatternNotFoundException,
6
7
  )
@@ -107,6 +107,22 @@ _PATTERN_SYMBOL_TO_TYPE: dict[str, PatternPartTypeT] = {
107
107
  "x": "time_zone_name",
108
108
  "X": "time_zone_name",
109
109
  }
110
+ _PATTERN_FIELD_TO_TYPE: dict[icu.UDateTimePatternField, PatternPartTypeT] = { # ty: ignore[unresolved-attribute]
111
+ icu.DateFormat.ERA_FIELD: "era", # ty: ignore[unresolved-attribute]
112
+ icu.DateFormat.YEAR_FIELD: "year", # ty: ignore[unresolved-attribute]
113
+ icu.DateFormat.MONTH_FIELD: "month", # ty: ignore[unresolved-attribute]
114
+ icu.DateFormat.DAY_OF_WEEK_FIELD: "weekday", # ty: ignore[unresolved-attribute]
115
+ icu.DateFormat.DATE_FIELD: "day", # ty: ignore[unresolved-attribute]
116
+ icu.DateFormat.AM_PM_FIELD: "day_period", # ty: ignore[unresolved-attribute]
117
+ icu.DateFormat.HOUR0_FIELD: "hour", # ty: ignore[unresolved-attribute]
118
+ icu.DateFormat.HOUR1_FIELD: "hour", # ty: ignore[unresolved-attribute]
119
+ icu.DateFormat.HOUR_OF_DAY0_FIELD: "hour", # ty: ignore[unresolved-attribute]
120
+ icu.DateFormat.HOUR_OF_DAY1_FIELD: "hour", # ty: ignore[unresolved-attribute]
121
+ icu.DateFormat.MINUTE_FIELD: "minute", # ty: ignore[unresolved-attribute]
122
+ icu.DateFormat.SECOND_FIELD: "second", # ty: ignore[unresolved-attribute]
123
+ icu.DateFormat.MILLISECOND_FIELD: "fraction_second_digits", # ty: ignore[unresolved-attribute]
124
+ icu.DateFormat.TIMEZONE_FIELD: "time_zone_name", # ty: ignore[unresolved-attribute]
125
+ }
110
126
  _PATTERN_QUOTE = "'"
111
127
 
112
128
  _COMPONENT_TO_JSON_MAP: dict[str, str] = {
@@ -314,6 +330,23 @@ class DateTimePatternPart:
314
330
  }
315
331
 
316
332
 
333
+ @dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
334
+ class DateTimeIntervalPatternPart:
335
+ type: PatternPartTypeT
336
+ value: str
337
+ source: Literal["start_range", "end_range", "shared"]
338
+
339
+ def to_json(self) -> dict[str, str]:
340
+ return {
341
+ "type": _COMPONENT_TO_JSON_MAP.get(self.type, self.type),
342
+ "value": self.value,
343
+ "source": {
344
+ "start_range": "startRange",
345
+ "end_range": "endRange",
346
+ }.get(self.source, self.source),
347
+ }
348
+
349
+
317
350
  @cache
318
351
  def _options_to_format_pattern(
319
352
  locale: icu.Locale, # ty: ignore[unresolved-attribute]
@@ -345,6 +378,23 @@ def _options_to_format_pattern(
345
378
  raise FormatPatternNotFoundException("Didn't find pattern for desired options")
346
379
 
347
380
 
381
+ @dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
382
+ class _PartSpan:
383
+ start: int
384
+ end: int
385
+
386
+ @classmethod
387
+ def empty(cls) -> _PartSpan:
388
+ return cls(start=0, end=0)
389
+
390
+ @classmethod
391
+ def from_constrained_fieldposition(cls, position: icu.ConstrainedFieldPosition) -> _PartSpan: # ty: ignore[unresolved-attribute]
392
+ return cls(start=position.getStart(), end=position.getLimit())
393
+
394
+ def __contains__(self, inner: _PartSpan) -> bool:
395
+ return inner.start >= self.start and inner.end <= self.end
396
+
397
+
348
398
  class DateTimeFormat:
349
399
  locale: str
350
400
  options: DateTimeFormatOptions
@@ -431,3 +481,72 @@ class DateTimeFormat:
431
481
  type="literal",
432
482
  value="".join(literal_chars),
433
483
  )
484
+
485
+ @cached_property
486
+ def _icu_dateinterval_format(self) -> icu.DateIntervalFormat: # ty: ignore[unresolved-attribute]
487
+ possible_skeletons = list(_options_to_possible_skeletons(self.options))
488
+ return icu.DateIntervalFormat.createInstance(possible_skeletons[0], self._icu_locale) # ty: ignore[unresolved-attribute]
489
+
490
+ def format_range(
491
+ self,
492
+ start_datetime: dt.datetime,
493
+ end_datetime: dt.datetime,
494
+ ) -> str:
495
+ icu_date_interval = icu.DateInterval(start_datetime, end_datetime) # ty: ignore[unresolved-attribute]
496
+ return self._icu_dateinterval_format.format(icu_date_interval)
497
+
498
+ def format_range_to_parts(
499
+ self,
500
+ start_datetime: dt.datetime,
501
+ end_datetime: dt.datetime,
502
+ ) -> Iterable[DateTimeIntervalPatternPart]:
503
+ icu_date_interval = icu.DateInterval(start_datetime, end_datetime) # ty: ignore[unresolved-attribute]
504
+ formatted = self._icu_dateinterval_format.formatToValue(icu_date_interval)
505
+
506
+ # Find spans of both datetimes (used to determine which parts have which source)
507
+ span_start = _PartSpan.empty()
508
+ span_end = _PartSpan.empty()
509
+ for part in formatted:
510
+ if part.getCategory() == icu.UFieldCategory.DATE_INTERVAL_SPAN: # ty: ignore[unresolved-attribute]
511
+ match part.getField():
512
+ case 0:
513
+ span_start = _PartSpan.from_constrained_fieldposition(part)
514
+ case 1:
515
+ span_end = _PartSpan.from_constrained_fieldposition(part)
516
+
517
+ def source_of(span: _PartSpan) -> Literal["start_range", "end_range", "shared"]:
518
+ if span in span_start:
519
+ return "start_range"
520
+ elif span in span_end:
521
+ return "end_range"
522
+ else:
523
+ return "shared"
524
+
525
+ # Break result string into parts
526
+ result_string = str(formatted)
527
+ last_end = 0
528
+ for part in formatted:
529
+ span = _PartSpan.from_constrained_fieldposition(part)
530
+ if span.start > last_end:
531
+ yield DateTimeIntervalPatternPart(
532
+ type="literal",
533
+ value=result_string[last_end:span.start],
534
+ source=source_of(_PartSpan(start=last_end, end=span.start)),
535
+ )
536
+
537
+ if part.getCategory() == icu.UFieldCategory.DATE: # ty: ignore[unresolved-attribute]
538
+ yield DateTimeIntervalPatternPart(
539
+ type=_PATTERN_FIELD_TO_TYPE.get(part.getField(), "unknown"),
540
+ value=result_string[span.start:span.end],
541
+ source=source_of(span),
542
+ )
543
+
544
+ last_end = span.end
545
+
546
+ # Ensure we didn't miss anything at the end
547
+ if last_end < len(result_string):
548
+ yield DateTimeIntervalPatternPart(
549
+ type="literal",
550
+ value=result_string[last_end:],
551
+ source=source_of(_PartSpan(start=last_end, end=len(result_string))),
552
+ )
File without changes