python-intl 0.8.0__tar.gz → 0.9.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.8.0
3
+ Version: 0.9.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>
@@ -62,7 +62,7 @@ Intl.DateTimeFormatOptions(time_zone_name="short_offset").to_json()
62
62
  import datetime as dt
63
63
  import python_intl as Intl
64
64
 
65
- locale = Intl.DateTimeFormat("de-DE")
65
+ locale = Intl.Locale("de-DE")
66
66
  ```
67
67
 
68
68
  #### Compatibility
@@ -71,6 +71,38 @@ locale = Intl.DateTimeFormat("de-DE")
71
71
  allow using the locale class instead of a string with the other classes. There will
72
72
  be more later.
73
73
 
74
+ ### `Intl.NumberFormat`
75
+
76
+ #### Example usage
77
+
78
+ ```python
79
+ import python_intl as Intl
80
+
81
+ # Format liters
82
+ number = 1234.567
83
+ formatter = Intl.NumberFormat("de-DE", {"style": "unit", "unit": "liter"})
84
+ formatter.format(number)
85
+ # Result = "1.234,567 l"
86
+
87
+ # Format euros
88
+ number = 1234.567
89
+ formatter = Intl.NumberFormat("de-DE", {"style": "currency", "currency": "EUR"})
90
+ formatter.format(number)
91
+ # Result = "1.234,57 €"
92
+ # Note: Result will be automatically rounded to two decimal places, as EUR defines.
93
+ ```
94
+
95
+ #### Compatibility
96
+
97
+ | Method | Status | Python name |
98
+ | --------------------------------- | :----: | ------------------------------------ |
99
+ | `NumberFormat.format` | ✅ | |
100
+ | `NumberFormat.formatToParts` | ❌ | `NumberFormat.format_to_parts` |
101
+ | `NumberFormat.supportedLocalesOf` | ❌ | |
102
+ | `NumberFormat.formatRange` | ✅ | `NumberFormat.format_range` |
103
+ | `NumberFormat.formatRangeToParts` | ❌ | `NumberFormat.format_range_to_parts` |
104
+ | `NumberFormat.resolvedOptions` | ❌ | |
105
+
74
106
  ### `Intl.DateTimeFormat`
75
107
 
76
108
  #### Example usage
@@ -138,7 +170,8 @@ Be sure to be able to install `PyICU`, see the installation docs there:
138
170
  https://gitlab.pyicu.org/main/pyicu#installing-pyicu
139
171
 
140
172
  **Hint:** I mainly did run into issues with `pkg-config` not finding the ICU library,
141
- setting `PKG_CONFIG_PATH` accordingly helps most of the time I guess.
173
+ setting `PKG_CONFIG_PATH` accordingly helps most of the time I guess. See [`.envrc`](.envrc)
174
+ for how to set this up correctly on `nix` based systems.
142
175
 
143
176
  When this is done you should be able to install `python-intl` using any package
144
177
  manager, like `pip install python-intl` or `uv add python-intl`.
@@ -49,7 +49,7 @@ Intl.DateTimeFormatOptions(time_zone_name="short_offset").to_json()
49
49
  import datetime as dt
50
50
  import python_intl as Intl
51
51
 
52
- locale = Intl.DateTimeFormat("de-DE")
52
+ locale = Intl.Locale("de-DE")
53
53
  ```
54
54
 
55
55
  #### Compatibility
@@ -58,6 +58,38 @@ locale = Intl.DateTimeFormat("de-DE")
58
58
  allow using the locale class instead of a string with the other classes. There will
59
59
  be more later.
60
60
 
61
+ ### `Intl.NumberFormat`
62
+
63
+ #### Example usage
64
+
65
+ ```python
66
+ import python_intl as Intl
67
+
68
+ # Format liters
69
+ number = 1234.567
70
+ formatter = Intl.NumberFormat("de-DE", {"style": "unit", "unit": "liter"})
71
+ formatter.format(number)
72
+ # Result = "1.234,567 l"
73
+
74
+ # Format euros
75
+ number = 1234.567
76
+ formatter = Intl.NumberFormat("de-DE", {"style": "currency", "currency": "EUR"})
77
+ formatter.format(number)
78
+ # Result = "1.234,57 €"
79
+ # Note: Result will be automatically rounded to two decimal places, as EUR defines.
80
+ ```
81
+
82
+ #### Compatibility
83
+
84
+ | Method | Status | Python name |
85
+ | --------------------------------- | :----: | ------------------------------------ |
86
+ | `NumberFormat.format` | ✅ | |
87
+ | `NumberFormat.formatToParts` | ❌ | `NumberFormat.format_to_parts` |
88
+ | `NumberFormat.supportedLocalesOf` | ❌ | |
89
+ | `NumberFormat.formatRange` | ✅ | `NumberFormat.format_range` |
90
+ | `NumberFormat.formatRangeToParts` | ❌ | `NumberFormat.format_range_to_parts` |
91
+ | `NumberFormat.resolvedOptions` | ❌ | |
92
+
61
93
  ### `Intl.DateTimeFormat`
62
94
 
63
95
  #### Example usage
@@ -125,7 +157,8 @@ Be sure to be able to install `PyICU`, see the installation docs there:
125
157
  https://gitlab.pyicu.org/main/pyicu#installing-pyicu
126
158
 
127
159
  **Hint:** I mainly did run into issues with `pkg-config` not finding the ICU library,
128
- setting `PKG_CONFIG_PATH` accordingly helps most of the time I guess.
160
+ setting `PKG_CONFIG_PATH` accordingly helps most of the time I guess. See [`.envrc`](.envrc)
161
+ for how to set this up correctly on `nix` based systems.
129
162
 
130
163
  When this is done you should be able to install `python-intl` using any package
131
164
  manager, like `pip install python-intl` or `uv add python-intl`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-intl"
3
- version = "0.8.0"
3
+ version = "0.9.0"
4
4
  description = "Python implementation of the Intl JavaScript API"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -17,11 +17,14 @@ Repository = "https://github.com/ddanier/python-intl"
17
17
 
18
18
  [dependency-groups]
19
19
  dev = [
20
+ "basedpyright>=1.40.1",
20
21
  "mypy>=2.3.1",
22
+ "pylsp-mypy>=0.8.0",
21
23
  "pyright>=1.1.411",
22
24
  "pytest>=9.1.1",
23
25
  "pytest-cov>=7.1.0",
24
26
  "pytest-xdist>=3.8.0",
27
+ "python-lsp-server>=1.15.0",
25
28
  "ruff>=0.16.3",
26
29
  "tox>=4.60.0",
27
30
  "ty>=0.0.72",
@@ -39,57 +42,81 @@ output-format = "grouped"
39
42
 
40
43
  [tool.ruff.lint]
41
44
  select = [
42
- "F",
45
+ "A",
46
+ "ANN",
47
+ "ARG",
48
+ "ASYNC",
49
+ "B",
50
+ "BLE",
51
+ "C4",
52
+ "COM",
53
+ "DTZ",
43
54
  "E",
44
- "W",
45
- "C",
55
+ "EM",
56
+ "EXE",
57
+ "F",
58
+ "FA",
59
+ "FBT",
60
+ "FURB",
46
61
  "I",
62
+ "ICN",
63
+ "ISC",
47
64
  "N",
48
- "UP",
49
- "ANN",
65
+ "PERF",
66
+ "PIE",
67
+ "PL",
68
+ "PT",
69
+ "PTH",
70
+ "Q",
71
+ "RSE",
72
+ "RUF",
50
73
  "S",
51
- "B",
52
- "A",
53
- "COM",
54
- "C4",
74
+ "SIM",
75
+ "SLOT",
76
+ "T10",
55
77
  "T20",
56
- "PT",
57
- "ARG",
78
+ "TC",
58
79
  "TD",
59
- "RUF",
60
- ]
61
- ignore = [
62
- "A001",
63
- "A002",
64
- "A003",
65
- "ANN401",
66
- "C901",
67
- "N8",
68
- "B008",
69
- "F405",
70
- "F821",
71
- "TD003",
80
+ "TID",
81
+ "TRY",
82
+ "UP",
83
+ "W",
72
84
  ]
85
+ ignore = ["N812"]
73
86
 
74
87
  [tool.ruff.lint.per-file-ignores]
75
- "__init__.py" = ["F401"]
76
- "conftest.py" = [
88
+ "test_*.py" = [
77
89
  "S101",
78
- "ANN",
79
- "F401",
90
+ "ANN201",
80
91
  ]
81
- "test_*.py" = [
92
+ "conftest.py" = [
82
93
  "S101",
83
- "ANN",
84
- "F401",
94
+ "ANN201",
85
95
  ]
86
96
 
87
97
  [tool.ruff.lint.isort]
88
98
  combine-as-imports = true
89
99
  force-wrap-aliases = true
90
100
 
91
- [tool.pyright]
101
+ [tool.ty.analysis]
102
+ replace-imports-with-any = ["icu.**"]
103
+
104
+ [[tool.mypy.overrides]]
105
+ module = ["icu.*"]
106
+ ignore_missing_imports = true
107
+
108
+ [tool.basedpyright]
92
109
  pythonVersion = "3.12"
110
+ reportAny = false
111
+ reportMatchNotExhaustive = false
112
+ reportUnknownLambdaType = false
113
+ reportPrivateUsage = false
114
+ allowedUntypedLibraries = ["icu"]
115
+ reportAttributeAccessIssue = false
116
+ reportUnknownVariableType = false
117
+ reportUnknownMemberType = false
118
+ reportUnknownParameterType = false
119
+ reportUnknownArgumentType = false
93
120
 
94
121
  [tool.uv.build-backend]
95
122
  module-name = "python_intl"
@@ -0,0 +1,131 @@
1
+ [project]
2
+ name = "python-intl"
3
+ version = "0.9.0"
4
+ description = "Python implementation of the Intl JavaScript API"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "David Danier", email = "david.danier@gmail.com" }
8
+ ]
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ requires-python = ">=3.12"
12
+ dependencies = [
13
+ "pyicu>=2.16.2",
14
+ ]
15
+
16
+ [project.urls]
17
+ Repository = "https://github.com/ddanier/python-intl"
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "basedpyright>=1.40.1",
22
+ "mypy>=2.3.1",
23
+ "pylsp-mypy>=0.8.0",
24
+ "pyright>=1.1.411",
25
+ "pytest>=9.1.1",
26
+ "pytest-cov>=7.1.0",
27
+ "pytest-xdist>=3.8.0",
28
+ "python-lsp-server>=1.15.0",
29
+ "ruff>=0.16.3",
30
+ "tox>=4.60.0",
31
+ "ty>=0.0.72",
32
+ ]
33
+
34
+ [tool.pytest.ini_options]
35
+ markers = [
36
+ "unit: mark a test as a unit test",
37
+ "node: mark tests jun against the node/JS implementation",
38
+ ]
39
+
40
+ [tool.ruff]
41
+ line-length = 115
42
+ output-format = "grouped"
43
+
44
+ [tool.ruff.lint]
45
+ select = [
46
+ "A", # flake8-builtins
47
+ "ANN", # flake8-annotations
48
+ "ARG", # flake8-unused-arguments
49
+ "ASYNC", # flake8-async
50
+ "B", # flake8-bugbear
51
+ "BLE", # flake8-blind-except
52
+ "C4", # flake8-comprehensions
53
+ "COM", # flake8-commas
54
+ "DTZ", # flake8-datetimez
55
+ "E", # pycodestyle errors
56
+ "EM", # flake8-errmsg
57
+ "EXE", # flake8-executable
58
+ "F", # pyflakes
59
+ "FA", # flake8-future-annotations
60
+ "FBT", # flake8-boolean-trap
61
+ "FURB", # refurb
62
+ "I", # isort
63
+ "ICN", # flake8-import-conventions
64
+ "ISC", # flake8-implicit-str-concat
65
+ "N", # pep8-naming
66
+ "PERF", # perflint
67
+ "PIE", # flake8-pie
68
+ "PL", # pylint
69
+ "PT", # flake8-pytest-style
70
+ "PTH", # flake8-use-pathlib
71
+ "Q", # flake8-quotes
72
+ "RSE", # flake8-raise
73
+ "RUF", # ruff-specific rules
74
+ "S", # flake8-bandit
75
+ "SIM", # flake8-simplify
76
+ "SLOT", # flake8-slots
77
+ "T10", # flake8-debugger
78
+ "T20", # flake8-print
79
+ "TC", # flake8-type-checking
80
+ "TD", # flake8-todos
81
+ "TID", # flake8-tidy-imports
82
+ "TRY", # tryceratops
83
+ "UP", # pyupgrade
84
+ "W", # pycodestyle warnings
85
+ ]
86
+ ignore = [
87
+ "N812", # Allow using "import python_intl as Intl"
88
+ ]
89
+
90
+ [tool.ruff.lint.per-file-ignores]
91
+ "test_*.py" = [
92
+ "S101", # Allow usage of assert in tests
93
+ "ANN201", # Don't require to define return types in tests
94
+ ]
95
+ "conftest.py" = [
96
+ "S101", # Allow usage of assert in tests
97
+ "ANN201", # Don't require to define return types in tests
98
+ ]
99
+
100
+ [tool.ruff.lint.isort]
101
+ combine-as-imports = true
102
+ force-wrap-aliases = true
103
+
104
+ [tool.ty.analysis]
105
+ replace-imports-with-any = ["icu.**"]
106
+
107
+ [[tool.mypy.overrides]]
108
+ module = ["icu.*"]
109
+ ignore_missing_imports = true
110
+
111
+ [tool.basedpyright]
112
+ pythonVersion = "3.12"
113
+ reportAny = false
114
+ reportMatchNotExhaustive = false
115
+ reportUnknownLambdaType = false
116
+ reportPrivateUsage = false
117
+ # The following are only necessary cause of untyped icu library
118
+ allowedUntypedLibraries = ["icu"]
119
+ reportAttributeAccessIssue = false
120
+ reportUnknownVariableType = false
121
+ reportUnknownMemberType = false
122
+ reportUnknownParameterType = false
123
+ reportUnknownArgumentType = false
124
+
125
+ [build-system]
126
+ requires = ["uv_build>=0.12.3,<0.13.0"]
127
+ build-backend = "uv_build"
128
+
129
+ [tool.uv.build-backend]
130
+ module-name = "python_intl"
131
+ module-root = ""
@@ -7,5 +7,10 @@ from .datetimeformat import (
7
7
  DateTimeFormatOptions as DateTimeFormatOptions,
8
8
  DateTimeIntervalPatternPart as DateTimeIntervalPatternPart,
9
9
  DateTimePatternPart as DateTimePatternPart,
10
- FormatPatternNotFoundException as FormatPatternNotFoundException,
10
+ FormatPatternNotFoundError as FormatPatternNotFoundError,
11
+ )
12
+ from .numberformat import (
13
+ InvalidNumberFormatOptionError as InvalidNumberFormatOptionError,
14
+ NumberFormat as NumberFormat,
15
+ NumberFormatOptions as NumberFormatOptions,
11
16
  )
@@ -0,0 +1,94 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ if TYPE_CHECKING:
4
+ from typing import Literal
5
+
6
+ type LocaleMatcherT = Literal["best fit", "lookup"]
7
+ type Hour12T = bool
8
+ type HourCycleT = Literal["h11", "h12", "h23", "h24"]
9
+
10
+ # datetime
11
+ type EraFormatT = Literal["long", "short", "narrow"]
12
+ type YearFormatT = Literal["numeric", "2-digit"]
13
+ type MonthFormatT = Literal["numeric", "2-digit", "long", "short", "narrow"]
14
+ type WeekdayFormatT = Literal["long", "short", "narrow"]
15
+ type DayFormatT = Literal["numeric", "2-digit"]
16
+ type DayPeriodFormatT = Literal["long", "short", "narrow"]
17
+ type HourFormatT = Literal["numeric", "2-digit"]
18
+ type MinuteFormatT = Literal["numeric", "2-digit"]
19
+ type SecondFormatT = Literal["numeric", "2-digit"]
20
+ type FractionSecondDigitsFormatT = Literal[1, 2, 3]
21
+ type TimezoneNameFormatT = Literal[
22
+ "short",
23
+ "long",
24
+ "short_offset",
25
+ "long_offset",
26
+ "short_generic",
27
+ "long_generic",
28
+ ]
29
+
30
+ # number
31
+ type StyleT = Literal["decimal", "currency", "percent", "unit"]
32
+ type CurrencyT = str # 3 char ISO code
33
+ type CurrencyDisplayT = Literal["code", "symbol", "narrow_symbol", "name"]
34
+ type CurrencySignT = Literal["standard", "accounting"]
35
+ type UnitT = Literal[
36
+ # see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/supportedValuesOf#supported_unit_identifiers
37
+ # for Intl reference
38
+ # see https://unicode-org.github.io/icu-docs/apidoc/released/icu4c/classicu_1_1MeasureUnit.html
39
+ # for ICU reference
40
+ "acre",
41
+ "bit",
42
+ "byte",
43
+ "celsius",
44
+ "centimeter",
45
+ "day",
46
+ "degree",
47
+ "fahrenheit",
48
+ "fluid-ounce",
49
+ "foot",
50
+ "gallon",
51
+ "gigabit",
52
+ "gigabyte",
53
+ "gram",
54
+ "hectare",
55
+ "hour",
56
+ "inch",
57
+ "kilobit",
58
+ "kilobyte",
59
+ "kilogram",
60
+ "kilometer",
61
+ "liter",
62
+ "megabit",
63
+ "megabyte",
64
+ "meter",
65
+ "microsecond",
66
+ "mile",
67
+ "mile-scandinavian",
68
+ "milliliter",
69
+ "millimeter",
70
+ "millisecond",
71
+ "minute",
72
+ "month",
73
+ "nanosecond",
74
+ "ounce",
75
+ "percent",
76
+ "petabyte",
77
+ "pound",
78
+ "second",
79
+ "stone",
80
+ "terabit",
81
+ "terabyte",
82
+ "week",
83
+ "yard",
84
+ "year",
85
+ ]
86
+ type UnitDisplayT = Literal["short", "narrow", "long"]
87
+
88
+ # collator
89
+ # type UsageT = Literal["sort", "search"]
90
+ # type CollationT = Literal["emoji", "pinyin", "stroke"]
91
+ type CaseFirstT = Literal["upper", "lower", "false"]
92
+ type SensitivityT = Literal["base", "accent", "case", "variant"]
93
+
94
+ type ComparisonResultT = Literal[-1, 0, 1]
@@ -2,15 +2,15 @@ from __future__ import annotations
2
2
 
3
3
  import dataclasses
4
4
  import functools
5
- from collections.abc import Callable, Iterable
6
5
  from functools import cached_property
7
6
  from typing import TYPE_CHECKING, overload
8
7
 
9
- import icu # type: ignore[import-untyped]
8
+ import icu
10
9
 
11
10
  from .locale import Locale
12
11
 
13
12
  if TYPE_CHECKING:
13
+ from collections.abc import Callable, Iterable
14
14
  from typing import NotRequired, TypedDict
15
15
 
16
16
  from ._types import CaseFirstT, ComparisonResultT, LocaleMatcherT, SensitivityT
@@ -54,10 +54,10 @@ class CollatorOptions:
54
54
  }
55
55
 
56
56
 
57
- _COLLATOR_RESULT_TO_RESULT: dict[icu.UCollationResult, ComparisonResultT] = { # ty: ignore[unresolved-attribute]
58
- icu.UCollationResult.LESS: -1, # ty: ignore[unresolved-attribute]
59
- icu.UCollationResult.EQUAL: 0, # ty: ignore[unresolved-attribute]
60
- icu.UCollationResult.GREATER: 1, # ty: ignore[unresolved-attribute]
57
+ _COLLATOR_RESULT_TO_RESULT: dict[icu.UCollationResult, ComparisonResultT] = {
58
+ icu.UCollationResult.LESS: -1,
59
+ icu.UCollationResult.EQUAL: 0,
60
+ icu.UCollationResult.GREATER: 1,
61
61
  }
62
62
 
63
63
 
@@ -82,11 +82,11 @@ class Collator:
82
82
  self.options = CollatorOptions(**options)
83
83
 
84
84
  @cached_property
85
- def _icu_collator(self) -> icu.Collator: # ty: ignore[unresolved-attribute]
86
- collator = icu.Collator.createInstance(self.locale._icu_locale) # ty: ignore[unresolved-attribute]
85
+ def _icu_collator(self) -> icu.Collator:
86
+ collator = icu.Collator.createInstance(self.locale._icu_locale)
87
87
 
88
88
  if self.options.numeric:
89
- collator.setAttribute(icu.UCollAttribute.NUMERIC_COLLATION, icu.UCollAttributeValue.ON) # ty: ignore[unresolved-attribute]
89
+ collator.setAttribute(icu.UCollAttribute.NUMERIC_COLLATION, icu.UCollAttributeValue.ON)
90
90
 
91
91
  if (
92
92
  self.options.ignore_punctuation
@@ -95,24 +95,24 @@ class Collator:
95
95
  and self.locale._icu_locale.getLanguage() == "th"
96
96
  )
97
97
  ):
98
- collator.setAttribute(icu.UCollAttribute.ALTERNATE_HANDLING, icu.UCollAttributeValue.SHIFTED) # ty: ignore[unresolved-attribute]
98
+ collator.setAttribute(icu.UCollAttribute.ALTERNATE_HANDLING, icu.UCollAttributeValue.SHIFTED)
99
99
 
100
100
  match self.options.case_first:
101
101
  case "upper":
102
- collator.setAttribute(icu.UCollAttribute.CASE_FIRST, icu.UCollAttributeValue.UPPER_FIRST) # ty: ignore[unresolved-attribute]
102
+ collator.setAttribute(icu.UCollAttribute.CASE_FIRST, icu.UCollAttributeValue.UPPER_FIRST)
103
103
  case "lower":
104
- collator.setAttribute(icu.UCollAttribute.CASE_FIRST, icu.UCollAttributeValue.LOWER_FIRST) # ty: ignore[unresolved-attribute]
104
+ collator.setAttribute(icu.UCollAttribute.CASE_FIRST, icu.UCollAttributeValue.LOWER_FIRST)
105
105
 
106
- collator.setAttribute(icu.UCollAttribute.NORMALIZATION_MODE, icu.UCollAttributeValue.ON) # ty: ignore[unresolved-attribute]
106
+ collator.setAttribute(icu.UCollAttribute.NORMALIZATION_MODE, icu.UCollAttributeValue.ON)
107
107
  match self.options.sensitivity:
108
108
  case "base":
109
- collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.PRIMARY) # ty: ignore[unresolved-attribute]
109
+ collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.PRIMARY)
110
110
  case "accent":
111
- collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.SECONDARY) # ty: ignore[unresolved-attribute]
111
+ collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.SECONDARY)
112
112
  case "case":
113
- collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.TERTIARY) # ty: ignore[unresolved-attribute]
113
+ collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.TERTIARY)
114
114
  case "variant":
115
- collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.QUATERNARY) # ty: ignore[unresolved-attribute]
115
+ collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.QUATERNARY)
116
116
 
117
117
  return collator
118
118
 
@@ -123,7 +123,7 @@ class Collator:
123
123
  def sorted(self, items: Iterable[str], /, key: None = None) -> Iterable[str]: ...
124
124
  @overload
125
125
  def sorted[T](self, items: Iterable[T], /, key: Callable[[T], str]) -> Iterable[T]: ...
126
- def sorted(self, items, /, key = None):
126
+ def sorted(self, items, /, key = None): # pyright: ignore[reportInconsistentOverload]
127
127
  return sorted(
128
128
  items,
129
129
  key=functools.cmp_to_key(
@@ -1,15 +1,15 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import dataclasses
4
- import datetime as dt
5
4
  from functools import cache, cached_property
6
- from typing import TYPE_CHECKING, Literal
5
+ from typing import TYPE_CHECKING, Literal, override
7
6
 
8
- import icu # type: ignore[import-untyped]
7
+ import icu
9
8
 
10
9
  from .locale import Locale
11
10
 
12
11
  if TYPE_CHECKING:
12
+ import datetime as dt
13
13
  from collections.abc import Iterable
14
14
  from typing import NotRequired, TypedDict
15
15
 
@@ -89,27 +89,27 @@ _PATTERN_SYMBOL_TO_TYPE: dict[str, DatetimePatternPartTypeT] = {
89
89
  "x": "time_zone_name",
90
90
  "X": "time_zone_name",
91
91
  }
92
- _PATTERN_FIELD_TO_TYPE: dict[icu.UDateTimePatternField, DatetimePatternPartTypeT] = { # ty: ignore[unresolved-attribute]
93
- icu.DateFormat.ERA_FIELD: "era", # ty: ignore[unresolved-attribute]
94
- icu.DateFormat.YEAR_FIELD: "year", # ty: ignore[unresolved-attribute]
95
- icu.DateFormat.MONTH_FIELD: "month", # ty: ignore[unresolved-attribute]
96
- icu.DateFormat.DAY_OF_WEEK_FIELD: "weekday", # ty: ignore[unresolved-attribute]
97
- icu.DateFormat.DATE_FIELD: "day", # ty: ignore[unresolved-attribute]
98
- icu.DateFormat.AM_PM_FIELD: "day_period", # ty: ignore[unresolved-attribute]
92
+ _PATTERN_FIELD_TO_TYPE: dict[icu.UDateTimePatternField, DatetimePatternPartTypeT] = {
93
+ icu.DateFormat.ERA_FIELD: "era",
94
+ icu.DateFormat.YEAR_FIELD: "year",
95
+ icu.DateFormat.MONTH_FIELD: "month",
96
+ icu.DateFormat.DAY_OF_WEEK_FIELD: "weekday",
97
+ icu.DateFormat.DATE_FIELD: "day",
98
+ icu.DateFormat.AM_PM_FIELD: "day_period",
99
99
  # TODO(ddanier): Use this instead once PyICU has those values in the enum:
100
100
  # https://gitlab.pyicu.org/main/pyicu/-/work_items/180
101
101
  # icu.DateFormat.AM_PM_MIDNIGHT_NOON_FIELD: "day_period",
102
102
  # icu.DateFormat.FLEXIBLE_DAY_PERIOD_FIELD: "day_period",
103
103
  35: "day_period",
104
104
  36: "day_period",
105
- icu.DateFormat.HOUR0_FIELD: "hour", # ty: ignore[unresolved-attribute]
106
- icu.DateFormat.HOUR1_FIELD: "hour", # ty: ignore[unresolved-attribute]
107
- icu.DateFormat.HOUR_OF_DAY0_FIELD: "hour", # ty: ignore[unresolved-attribute]
108
- icu.DateFormat.HOUR_OF_DAY1_FIELD: "hour", # ty: ignore[unresolved-attribute]
109
- icu.DateFormat.MINUTE_FIELD: "minute", # ty: ignore[unresolved-attribute]
110
- icu.DateFormat.SECOND_FIELD: "second", # ty: ignore[unresolved-attribute]
111
- icu.DateFormat.MILLISECOND_FIELD: "fraction_second_digits", # ty: ignore[unresolved-attribute]
112
- icu.DateFormat.TIMEZONE_FIELD: "time_zone_name", # ty: ignore[unresolved-attribute]
105
+ icu.DateFormat.HOUR0_FIELD: "hour",
106
+ icu.DateFormat.HOUR1_FIELD: "hour",
107
+ icu.DateFormat.HOUR_OF_DAY0_FIELD: "hour",
108
+ icu.DateFormat.HOUR_OF_DAY1_FIELD: "hour",
109
+ icu.DateFormat.MINUTE_FIELD: "minute",
110
+ icu.DateFormat.SECOND_FIELD: "second",
111
+ icu.DateFormat.MILLISECOND_FIELD: "fraction_second_digits",
112
+ icu.DateFormat.TIMEZONE_FIELD: "time_zone_name",
113
113
  }
114
114
  _PATTERN_QUOTE = "'"
115
115
 
@@ -172,7 +172,7 @@ class DateTimeFormatOptions:
172
172
  }
173
173
 
174
174
 
175
- def _options_to_possible_skeletons(options: DateTimeFormatOptions) -> Iterable[str]:
175
+ def _options_to_possible_skeletons(options: DateTimeFormatOptions) -> Iterable[str]: # noqa: PLR0912, PLR0915
176
176
  skeleton_parts: list[str | tuple[str, ...]] = []
177
177
 
178
178
  # Note: The parts should be ordered from big to small.
@@ -304,11 +304,12 @@ class _MatchedFormatPattern:
304
304
  skeleton: str
305
305
  pattern: str
306
306
 
307
+ @override
307
308
  def __str__(self) -> str:
308
309
  return self.pattern
309
310
 
310
311
 
311
- class FormatPatternNotFoundException(Exception):
312
+ class FormatPatternNotFoundError(Exception):
312
313
  pass
313
314
 
314
315
 
@@ -342,12 +343,12 @@ class DateTimeIntervalPatternPart:
342
343
 
343
344
  @cache
344
345
  def _options_to_format_pattern(
345
- locale: icu.Locale, # ty: ignore[unresolved-attribute]
346
+ locale: icu.Locale,
346
347
  options: DateTimeFormatOptions,
347
348
  ) -> _MatchedFormatPattern:
348
349
  possible_skeletons = list(_options_to_possible_skeletons(options))
349
350
 
350
- generator = icu.DateTimePatternGenerator.createInstance(locale) # ty: ignore[unresolved-attribute]
351
+ generator = icu.DateTimePatternGenerator.createInstance(locale)
351
352
 
352
353
  # Try a perfect match
353
354
  for skeleton in possible_skeletons:
@@ -368,7 +369,8 @@ def _options_to_format_pattern(
368
369
  pattern=pattern,
369
370
  )
370
371
 
371
- raise FormatPatternNotFoundException("Didn't find pattern for desired options")
372
+ error = "Didn't find pattern for desired options"
373
+ raise FormatPatternNotFoundError(error)
372
374
 
373
375
 
374
376
  @dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
@@ -381,7 +383,7 @@ class _PartSpan:
381
383
  return cls(start=0, end=0)
382
384
 
383
385
  @classmethod
384
- def from_constrained_fieldposition(cls, position: icu.ConstrainedFieldPosition) -> _PartSpan: # ty: ignore[unresolved-attribute]
386
+ def from_constrained_fieldposition(cls, position: icu.ConstrainedFieldPosition) -> _PartSpan:
385
387
  return cls(start=position.getStart(), end=position.getLimit())
386
388
 
387
389
  def __contains__(self, inner: _PartSpan) -> bool:
@@ -417,8 +419,8 @@ class DateTimeFormat:
417
419
  return self._matched_pattern.pattern
418
420
 
419
421
  @cached_property
420
- def _icu_date_format(self) -> icu.SimpleDateFormat: # ty: ignore[unresolved-attribute]
421
- return icu.SimpleDateFormat(self._icu_pattern, self.locale._icu_locale) # ty: ignore[unresolved-attribute]
422
+ def _icu_date_format(self) -> icu.SimpleDateFormat:
423
+ return icu.SimpleDateFormat(self._icu_pattern, self.locale._icu_locale)
422
424
 
423
425
  def format(self, datetime_: dt.datetime, /) -> str:
424
426
  return self._icu_date_format.format(datetime_)
@@ -438,7 +440,7 @@ class DateTimeFormat:
438
440
  if char != prev_char and count > 0:
439
441
  yield DateTimePatternPart(
440
442
  type=_PATTERN_SYMBOL_TO_TYPE.get(prev_char, "unknown"),
441
- value=icu.SimpleDateFormat(prev_char * count, self.locale._icu_locale).format(datetime_), # ty: ignore[unresolved-attribute]
443
+ value=icu.SimpleDateFormat(prev_char * count, self.locale._icu_locale).format(datetime_),
442
444
  _pattern=prev_char * count,
443
445
  )
444
446
  count = 0
@@ -466,7 +468,7 @@ class DateTimeFormat:
466
468
  if count > 0:
467
469
  yield DateTimePatternPart(
468
470
  type=_PATTERN_SYMBOL_TO_TYPE.get(prev_char, "unknown"),
469
- value=icu.SimpleDateFormat(prev_char * count, self.locale._icu_locale).format(datetime_), # ty: ignore[unresolved-attribute]
471
+ value=icu.SimpleDateFormat(prev_char * count, self.locale._icu_locale).format(datetime_),
470
472
  _pattern=prev_char * count,
471
473
  )
472
474
  assert not literal_chars # noqa: S101
@@ -477,16 +479,16 @@ class DateTimeFormat:
477
479
  )
478
480
 
479
481
  @cached_property
480
- def _icu_dateinterval_format(self) -> icu.DateIntervalFormat: # ty: ignore[unresolved-attribute]
482
+ def _icu_dateinterval_format(self) -> icu.DateIntervalFormat:
481
483
  possible_skeletons = list(_options_to_possible_skeletons(self.options))
482
- return icu.DateIntervalFormat.createInstance(possible_skeletons[0], self.locale._icu_locale) # ty: ignore[unresolved-attribute]
484
+ return icu.DateIntervalFormat.createInstance(possible_skeletons[0], self.locale._icu_locale)
483
485
 
484
486
  def format_range(
485
487
  self,
486
488
  start_datetime: dt.datetime,
487
489
  end_datetime: dt.datetime,
488
490
  ) -> str:
489
- icu_date_interval = icu.DateInterval(start_datetime, end_datetime) # ty: ignore[unresolved-attribute]
491
+ icu_date_interval = icu.DateInterval(start_datetime, end_datetime)
490
492
  return self._icu_dateinterval_format.format(icu_date_interval)
491
493
 
492
494
  def format_range_to_parts(
@@ -494,14 +496,14 @@ class DateTimeFormat:
494
496
  start_datetime: dt.datetime,
495
497
  end_datetime: dt.datetime,
496
498
  ) -> Iterable[DateTimeIntervalPatternPart]:
497
- icu_date_interval = icu.DateInterval(start_datetime, end_datetime) # ty: ignore[unresolved-attribute]
499
+ icu_date_interval = icu.DateInterval(start_datetime, end_datetime)
498
500
  formatted = self._icu_dateinterval_format.formatToValue(icu_date_interval)
499
501
 
500
502
  # Find spans of both datetimes (used to determine which parts have which source)
501
503
  span_start = _PartSpan.empty()
502
504
  span_end = _PartSpan.empty()
503
505
  for part in formatted:
504
- if part.getCategory() == icu.UFieldCategory.DATE_INTERVAL_SPAN: # ty: ignore[unresolved-attribute]
506
+ if part.getCategory() == icu.UFieldCategory.DATE_INTERVAL_SPAN:
505
507
  match part.getField():
506
508
  case 0:
507
509
  span_start = _PartSpan.from_constrained_fieldposition(part)
@@ -528,7 +530,7 @@ class DateTimeFormat:
528
530
  source=source_of(_PartSpan(start=last_end, end=span.start)),
529
531
  )
530
532
 
531
- if part.getCategory() == icu.UFieldCategory.DATE: # ty: ignore[unresolved-attribute]
533
+ if part.getCategory() == icu.UFieldCategory.DATE:
532
534
  yield DateTimeIntervalPatternPart(
533
535
  type=_PATTERN_FIELD_TO_TYPE.get(part.getField(), "unknown"),
534
536
  value=result_string[span.start:span.end],
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from functools import cached_property
4
4
 
5
- import icu # type: ignore[import-untyped]
5
+ import icu
6
6
 
7
7
 
8
8
  class Locale:
@@ -12,5 +12,5 @@ class Locale:
12
12
  self.tag = tag
13
13
 
14
14
  @cached_property
15
- def _icu_locale(self) -> icu.Locale: # ty: ignore[unresolved-attribute]
16
- return icu.Locale(self.tag) # ty: ignore[unresolved-attribute]
15
+ def _icu_locale(self) -> icu.Locale:
16
+ return icu.Locale(self.tag)
@@ -0,0 +1,191 @@
1
+ from __future__ import annotations
2
+
3
+ import dataclasses
4
+ import decimal
5
+ from functools import cached_property
6
+ from typing import TYPE_CHECKING
7
+
8
+ import icu
9
+
10
+ from .locale import Locale
11
+
12
+ if TYPE_CHECKING:
13
+ from typing import NotRequired, TypedDict
14
+
15
+ from ._types import (
16
+ CurrencyDisplayT,
17
+ CurrencySignT,
18
+ CurrencyT,
19
+ LocaleMatcherT,
20
+ StyleT,
21
+ UnitDisplayT,
22
+ UnitT,
23
+ )
24
+
25
+ type NumberT = decimal.Decimal | float | int
26
+
27
+ # Important: Must be the same as DateTimeFormatOptions
28
+ # (nothing is required, as this will be used to construct a
29
+ # DateTimeFormatOptions instance, so default values apply then)
30
+ class NumberFormatOptionsDictT(TypedDict):
31
+ locale_matcher: NotRequired[LocaleMatcherT]
32
+
33
+ style: NotRequired[StyleT]
34
+
35
+ currency: NotRequired[CurrencyT]
36
+ currency_display: NotRequired[CurrencyDisplayT]
37
+ currency_sign: NotRequired[CurrencySignT]
38
+
39
+ unit: NotRequired[UnitT]
40
+ unit_display: NotRequired[UnitDisplayT]
41
+
42
+
43
+ _CURRENCY_DISPLAY_TO_JSON_MAP: dict[str, str] = {
44
+ "narrow_symbol": "narrowSymbol",
45
+ }
46
+
47
+
48
+ class InvalidNumberFormatOptionError(ValueError):
49
+ pass
50
+
51
+
52
+ @dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
53
+ class NumberFormatOptions:
54
+ locale_matcher: LocaleMatcherT = "best fit"
55
+
56
+ style: StyleT = "decimal"
57
+
58
+ currency: CurrencyT | None = None
59
+ currency_display: CurrencyDisplayT = "symbol"
60
+ currency_sign: CurrencySignT = "standard"
61
+
62
+ unit: UnitT | None = None
63
+ unit_display: UnitDisplayT = "short"
64
+
65
+ def __post_init__(self) -> None:
66
+ error = None
67
+ if self.style == "currency" and self.currency is None:
68
+ error = "You need to provide a currency when using the currency style"
69
+ elif self.style == "unit" and self.unit is None:
70
+ error = "You need to provide a unit when using the unit style"
71
+
72
+ if error:
73
+ raise InvalidNumberFormatOptionError(error)
74
+
75
+ def to_json(self) -> dict[str, str | int]:
76
+ return {
77
+ k: v
78
+ for k, v in (
79
+ ("localeMatcher", self.locale_matcher),
80
+
81
+ ("style", self.style),
82
+
83
+ ("currency", self.currency),
84
+ ("currencyDisplay", _CURRENCY_DISPLAY_TO_JSON_MAP.get(
85
+ self.currency_display,
86
+ self.currency_display,
87
+ )),
88
+ ("currencySign", self.currency_sign),
89
+
90
+ ("unit", self.unit),
91
+ ("unitDisplay", self.unit_display),
92
+ )
93
+ if v is not None
94
+ }
95
+
96
+
97
+ def _options_to_skeleton(options: NumberFormatOptions) -> str:
98
+ skeleton_parts: list[str] = []
99
+
100
+ match options.style:
101
+ case "decimal":
102
+ skeleton_parts.append("decimal-auto")
103
+ case "percent":
104
+ skeleton_parts.append("precision-integer")
105
+ skeleton_parts.append("scale/100")
106
+ skeleton_parts.append("percent")
107
+ case "currency":
108
+ skeleton_parts.append(f"currency/{options.currency}")
109
+ skeleton_parts.append("precision-currency-standard")
110
+ if options.currency_sign == "accounting":
111
+ skeleton_parts.append("sign-accounting")
112
+ match options.currency_display:
113
+ case "code":
114
+ skeleton_parts.append("unit-width-iso-code")
115
+ case "symbol":
116
+ skeleton_parts.append("unit-width-short")
117
+ case "narrow_symbol":
118
+ skeleton_parts.append("unit-width-narrow")
119
+ case "name":
120
+ skeleton_parts.append("unit-width-full-name")
121
+ case "unit":
122
+ skeleton_parts.append(f"unit/{options.unit}")
123
+ match options.unit_display:
124
+ case "short":
125
+ skeleton_parts.append("unit-width-short")
126
+ case "narrow":
127
+ skeleton_parts.append("unit-width-narrow")
128
+ case "long":
129
+ skeleton_parts.append("unit-width-full-name")
130
+
131
+ return " ".join(skeleton_parts)
132
+
133
+
134
+ class NumberFormat:
135
+ locale: Locale
136
+ options: NumberFormatOptions
137
+
138
+ def __init__(
139
+ self,
140
+ locale: Locale | str,
141
+ options: NumberFormatOptions | NumberFormatOptionsDictT | None = None,
142
+ ) -> None:
143
+ if isinstance(locale, Locale):
144
+ self.locale = locale
145
+ else:
146
+ self.locale = Locale(locale)
147
+ if options is None:
148
+ self.options = NumberFormatOptions()
149
+ elif isinstance(options, NumberFormatOptions):
150
+ self.options = options
151
+ else:
152
+ self.options = NumberFormatOptions(**options)
153
+
154
+ @cached_property
155
+ def _icu_number_formatter(self) -> icu.LocalizedNumberFormatter:
156
+ skeleton = _options_to_skeleton(self.options)
157
+ return icu.NumberFormatter.forSkeleton(skeleton).locale(self.locale._icu_locale)
158
+
159
+ def format(self, value: NumberT, /) -> str:
160
+ match value:
161
+ case int():
162
+ return self._icu_number_formatter.formatInt(value)
163
+ case float():
164
+ return self._icu_number_formatter.formatDouble(value)
165
+ case decimal.Decimal():
166
+ return self._icu_number_formatter.formatDecimal(str(value).encode("ascii"))
167
+
168
+ @cached_property
169
+ def _icu_number_range_formatter(self) -> icu.LocalizedNumberRangeFormatter:
170
+ skeleton = _options_to_skeleton(self.options)
171
+ return (
172
+ icu.NumberRangeFormatter
173
+ .withLocale(self.locale._icu_locale)
174
+ .numberFormatterBoth(icu.NumberFormatter.forSkeleton(skeleton))
175
+ )
176
+
177
+ def format_range(self, start_value: NumberT, end_value: NumberT, /) -> str:
178
+ match start_value, end_value:
179
+ case int(), int():
180
+ return self._icu_number_range_formatter.formatIntRange(start_value, end_value)
181
+ case float(), float():
182
+ return self._icu_number_range_formatter.formatDoubleRange(start_value, end_value)
183
+ case decimal.Decimal(), decimal.Decimal():
184
+ return self._icu_number_range_formatter.formatDoubleRange(
185
+ # For some reason formatDecimalRange fails, so we fall back to floats
186
+ float(start_value),
187
+ float(end_value),
188
+ )
189
+ case _:
190
+ error = "Both parameters passed to format_range must have the same type"
191
+ raise ValueError(error)
@@ -1,63 +0,0 @@
1
- [project]
2
- name = "python-intl"
3
- version = "0.8.0"
4
- description = "Python implementation of the Intl JavaScript API"
5
- readme = "README.md"
6
- authors = [
7
- { name = "David Danier", email = "david.danier@gmail.com" }
8
- ]
9
- license = "MIT"
10
- license-files = ["LICENSE"]
11
- requires-python = ">=3.12"
12
- dependencies = [
13
- "pyicu>=2.16.2",
14
- ]
15
-
16
- [project.urls]
17
- Repository = "https://github.com/ddanier/python-intl"
18
-
19
- [dependency-groups]
20
- dev = [
21
- "mypy>=2.3.1",
22
- "pyright>=1.1.411",
23
- "pytest>=9.1.1",
24
- "pytest-cov>=7.1.0",
25
- "pytest-xdist>=3.8.0",
26
- "ruff>=0.16.3",
27
- "tox>=4.60.0",
28
- "ty>=0.0.72",
29
- ]
30
-
31
- [tool.pytest.ini_options]
32
- markers = [
33
- "unit: mark a test as a unit test",
34
- "node: mark tests jun against the node/JS implementation",
35
- ]
36
-
37
- [tool.ruff]
38
- line-length = 115
39
- output-format = "grouped"
40
-
41
- [tool.ruff.lint]
42
- select = ["F","E","W","C","I","N","UP","ANN","S","B","A","COM","C4","T20","PT","ARG","TD","RUF"]
43
- ignore = ["A001","A002","A003","ANN401","C901","N8","B008","F405","F821","TD003"]
44
-
45
- [tool.ruff.lint.per-file-ignores]
46
- "__init__.py" = ["F401"]
47
- "conftest.py" = ["S101","ANN","F401"]
48
- "test_*.py" = ["S101","ANN","F401"]
49
-
50
- [tool.ruff.lint.isort]
51
- combine-as-imports = true
52
- force-wrap-aliases = true
53
-
54
- [tool.pyright]
55
- pythonVersion = "3.12"
56
-
57
- [build-system]
58
- requires = ["uv_build>=0.12.3,<0.13.0"]
59
- build-backend = "uv_build"
60
-
61
- [tool.uv.build-backend]
62
- module-name = "python_intl"
63
- module-root = ""
@@ -1,34 +0,0 @@
1
- from typing import TYPE_CHECKING
2
-
3
- if TYPE_CHECKING:
4
- from typing import Literal
5
-
6
- type LocaleMatcherT = Literal["best fit", "lookup"]
7
- type Hour12T = bool
8
- type HourCycleT = Literal["h11", "h12", "h23", "h24"]
9
-
10
- type EraFormatT = Literal["long", "short", "narrow"]
11
- type YearFormatT = Literal["numeric", "2-digit"]
12
- type MonthFormatT = Literal["numeric", "2-digit", "long", "short", "narrow"]
13
- type WeekdayFormatT = Literal["long", "short", "narrow"]
14
- type DayFormatT = Literal["numeric", "2-digit"]
15
- type DayPeriodFormatT = Literal["long", "short", "narrow"]
16
- type HourFormatT = Literal["numeric", "2-digit"]
17
- type MinuteFormatT = Literal["numeric", "2-digit"]
18
- type SecondFormatT = Literal["numeric", "2-digit"]
19
- type FractionSecondDigitsFormatT = Literal[1, 2, 3]
20
- type TimezoneNameFormatT = Literal[
21
- "short",
22
- "long",
23
- "short_offset",
24
- "long_offset",
25
- "short_generic",
26
- "long_generic",
27
- ]
28
-
29
- # type UsageT = Literal["sort", "search"]
30
- # type CollationT = Literal["emoji", "pinyin", "stroke"]
31
- type CaseFirstT = Literal["upper", "lower", "false"]
32
- type SensitivityT = Literal["base", "accent", "case", "variant"]
33
-
34
- type ComparisonResultT = Literal[-1, 0, 1]
File without changes