python-intl 0.7.1__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.7.1
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>
@@ -54,6 +54,55 @@ Intl.DateTimeFormatOptions(time_zone_name="short_offset").to_json()
54
54
 
55
55
  ## Available `Intl` classes
56
56
 
57
+ ### `Intl.Locale`
58
+
59
+ #### Example usage
60
+
61
+ ```python
62
+ import datetime as dt
63
+ import python_intl as Intl
64
+
65
+ locale = Intl.Locale("de-DE")
66
+ ```
67
+
68
+ #### Compatibility
69
+
70
+ `Locale` currently does not support any methods or options. It is mainly there to
71
+ allow using the locale class instead of a string with the other classes. There will
72
+ be more later.
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
+
57
106
  ### `Intl.DateTimeFormat`
58
107
 
59
108
  #### Example usage
@@ -121,7 +170,18 @@ Be sure to be able to install `PyICU`, see the installation docs there:
121
170
  https://gitlab.pyicu.org/main/pyicu#installing-pyicu
122
171
 
123
172
  **Hint:** I mainly did run into issues with `pkg-config` not finding the ICU library,
124
- 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.
125
175
 
126
176
  When this is done you should be able to install `python-intl` using any package
127
177
  manager, like `pip install python-intl` or `uv add python-intl`.
178
+
179
+ ## Contributing
180
+
181
+ If you want to contribute to this project, feel free to just fork the project,
182
+ create a dev branch in your fork and then create a pull request (PR). If you
183
+ are unsure about whether your changes really suits the project please create an
184
+ issue first, to talk about this.
185
+
186
+ Please do not contribute AI generated code unless we explicitly talked about
187
+ this and agreed upon doing so first. In general I do not want AI contributions.
@@ -41,6 +41,55 @@ Intl.DateTimeFormatOptions(time_zone_name="short_offset").to_json()
41
41
 
42
42
  ## Available `Intl` classes
43
43
 
44
+ ### `Intl.Locale`
45
+
46
+ #### Example usage
47
+
48
+ ```python
49
+ import datetime as dt
50
+ import python_intl as Intl
51
+
52
+ locale = Intl.Locale("de-DE")
53
+ ```
54
+
55
+ #### Compatibility
56
+
57
+ `Locale` currently does not support any methods or options. It is mainly there to
58
+ allow using the locale class instead of a string with the other classes. There will
59
+ be more later.
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
+
44
93
  ### `Intl.DateTimeFormat`
45
94
 
46
95
  #### Example usage
@@ -108,7 +157,18 @@ Be sure to be able to install `PyICU`, see the installation docs there:
108
157
  https://gitlab.pyicu.org/main/pyicu#installing-pyicu
109
158
 
110
159
  **Hint:** I mainly did run into issues with `pkg-config` not finding the ICU library,
111
- 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.
112
162
 
113
163
  When this is done you should be able to install `python-intl` using any package
114
164
  manager, like `pip install python-intl` or `uv add python-intl`.
165
+
166
+ ## Contributing
167
+
168
+ If you want to contribute to this project, feel free to just fork the project,
169
+ create a dev branch in your fork and then create a pull request (PR). If you
170
+ are unsure about whether your changes really suits the project please create an
171
+ issue first, to talk about this.
172
+
173
+ Please do not contribute AI generated code unless we explicitly talked about
174
+ this and agreed upon doing so first. In general I do not want AI contributions.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-intl"
3
- version = "0.7.1"
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",
@@ -35,59 +38,86 @@ markers = [
35
38
 
36
39
  [tool.ruff]
37
40
  line-length = 115
38
- target-version = "py312"
39
41
  output-format = "grouped"
40
42
 
41
43
  [tool.ruff.lint]
42
44
  select = [
43
- "F",
45
+ "A",
46
+ "ANN",
47
+ "ARG",
48
+ "ASYNC",
49
+ "B",
50
+ "BLE",
51
+ "C4",
52
+ "COM",
53
+ "DTZ",
44
54
  "E",
45
- "W",
46
- "C",
55
+ "EM",
56
+ "EXE",
57
+ "F",
58
+ "FA",
59
+ "FBT",
60
+ "FURB",
47
61
  "I",
62
+ "ICN",
63
+ "ISC",
48
64
  "N",
49
- "UP",
50
- "ANN",
65
+ "PERF",
66
+ "PIE",
67
+ "PL",
68
+ "PT",
69
+ "PTH",
70
+ "Q",
71
+ "RSE",
72
+ "RUF",
51
73
  "S",
52
- "B",
53
- "A",
54
- "COM",
55
- "C4",
74
+ "SIM",
75
+ "SLOT",
76
+ "T10",
56
77
  "T20",
57
- "PT",
58
- "ARG",
78
+ "TC",
59
79
  "TD",
60
- "RUF",
61
- ]
62
- ignore = [
63
- "A001",
64
- "A002",
65
- "A003",
66
- "ANN401",
67
- "C901",
68
- "N8",
69
- "B008",
70
- "F405",
71
- "F821",
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
 
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]
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
120
+
91
121
  [tool.uv.build-backend]
92
122
  module-name = "python_intl"
93
123
  module-root = ""
@@ -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,25 +2,18 @@ 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
- from typing import TYPE_CHECKING, Literal, overload
6
+ from typing import TYPE_CHECKING, overload
8
7
 
9
- import icu # type: ignore[import-untyped]
10
-
11
- if TYPE_CHECKING:
12
- from typing import NotRequired, TypedDict
8
+ import icu
13
9
 
10
+ from .locale import Locale
14
11
 
15
12
  if TYPE_CHECKING:
16
- from ._types import LocaleMatcherT
17
-
18
- # type UsageT = Literal["sort", "search"]
19
- # type CollationT = Literal["emoji", "pinyin", "stroke"]
20
- type CaseFirstT = Literal["upper", "lower", "false"]
21
- type SensitivityT = Literal["base", "accent", "case", "variant"]
13
+ from collections.abc import Callable, Iterable
14
+ from typing import NotRequired, TypedDict
22
15
 
23
- type ComparisonResultT = Literal[-1, 0, 1]
16
+ from ._types import CaseFirstT, ComparisonResultT, LocaleMatcherT, SensitivityT
24
17
 
25
18
  # Important: Must be the same as CollatorOptions
26
19
  # (nothing is required, as this will be used to construct a
@@ -61,23 +54,26 @@ class CollatorOptions:
61
54
  }
62
55
 
63
56
 
64
- _COLLATOR_RESULT_TO_RESULT: dict[icu.UCollationResult, ComparisonResultT] = { # ty: ignore[unresolved-attribute]
65
- icu.UCollationResult.LESS: -1, # ty: ignore[unresolved-attribute]
66
- icu.UCollationResult.EQUAL: 0, # ty: ignore[unresolved-attribute]
67
- 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,
68
61
  }
69
62
 
70
63
 
71
64
  class Collator:
72
- locale: str
65
+ locale: Locale
73
66
  options: CollatorOptions
74
67
 
75
68
  def __init__(
76
69
  self,
77
- locale: str,
70
+ locale: Locale | str,
78
71
  options: CollatorOptions | CollatorOptionsDictT | None = None,
79
72
  ) -> None:
80
- self.locale = locale
73
+ if isinstance(locale, Locale):
74
+ self.locale = locale
75
+ else:
76
+ self.locale = Locale(locale)
81
77
  if options is None:
82
78
  self.options = CollatorOptions()
83
79
  elif isinstance(options, CollatorOptions):
@@ -86,41 +82,37 @@ class Collator:
86
82
  self.options = CollatorOptions(**options)
87
83
 
88
84
  @cached_property
89
- def _icu_locale(self) -> icu.Locale: # ty: ignore[unresolved-attribute]
90
- return icu.Locale(self.locale) # ty: ignore[unresolved-attribute]
91
-
92
- @cached_property
93
- def _icu_collator(self) -> icu.Collator: # ty: ignore[unresolved-attribute]
94
- collator = icu.Collator.createInstance(self._icu_locale) # ty: ignore[unresolved-attribute]
85
+ def _icu_collator(self) -> icu.Collator:
86
+ collator = icu.Collator.createInstance(self.locale._icu_locale)
95
87
 
96
88
  if self.options.numeric:
97
- collator.setAttribute(icu.UCollAttribute.NUMERIC_COLLATION, icu.UCollAttributeValue.ON) # ty: ignore[unresolved-attribute]
89
+ collator.setAttribute(icu.UCollAttribute.NUMERIC_COLLATION, icu.UCollAttributeValue.ON)
98
90
 
99
91
  if (
100
92
  self.options.ignore_punctuation
101
93
  or (
102
94
  self.options.ignore_punctuation is None
103
- and self._icu_locale.getLanguage() == "th"
95
+ and self.locale._icu_locale.getLanguage() == "th"
104
96
  )
105
97
  ):
106
- collator.setAttribute(icu.UCollAttribute.ALTERNATE_HANDLING, icu.UCollAttributeValue.SHIFTED) # ty: ignore[unresolved-attribute]
98
+ collator.setAttribute(icu.UCollAttribute.ALTERNATE_HANDLING, icu.UCollAttributeValue.SHIFTED)
107
99
 
108
100
  match self.options.case_first:
109
101
  case "upper":
110
- 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)
111
103
  case "lower":
112
- 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)
113
105
 
114
- collator.setAttribute(icu.UCollAttribute.NORMALIZATION_MODE, icu.UCollAttributeValue.ON) # ty: ignore[unresolved-attribute]
106
+ collator.setAttribute(icu.UCollAttribute.NORMALIZATION_MODE, icu.UCollAttributeValue.ON)
115
107
  match self.options.sensitivity:
116
108
  case "base":
117
- collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.PRIMARY) # ty: ignore[unresolved-attribute]
109
+ collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.PRIMARY)
118
110
  case "accent":
119
- collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.SECONDARY) # ty: ignore[unresolved-attribute]
111
+ collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.SECONDARY)
120
112
  case "case":
121
- collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.TERTIARY) # ty: ignore[unresolved-attribute]
113
+ collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.TERTIARY)
122
114
  case "variant":
123
- collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.QUATERNARY) # ty: ignore[unresolved-attribute]
115
+ collator.setAttribute(icu.UCollAttribute.STRENGTH, icu.UCollAttributeValue.QUATERNARY)
124
116
 
125
117
  return collator
126
118
 
@@ -131,7 +123,7 @@ class Collator:
131
123
  def sorted(self, items: Iterable[str], /, key: None = None) -> Iterable[str]: ...
132
124
  @overload
133
125
  def sorted[T](self, items: Iterable[T], /, key: Callable[[T], str]) -> Iterable[T]: ...
134
- def sorted(self, items, /, key = None):
126
+ def sorted(self, items, /, key = None): # pyright: ignore[reportInconsistentOverload]
135
127
  return sorted(
136
128
  items,
137
129
  key=functools.cmp_to_key(
@@ -1,56 +1,36 @@
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
8
+
9
+ from .locale import Locale
9
10
 
10
11
  if TYPE_CHECKING:
12
+ import datetime as dt
11
13
  from collections.abc import Iterable
12
14
  from typing import NotRequired, TypedDict
13
15
 
14
-
15
- if TYPE_CHECKING:
16
- from ._types import LocaleMatcherT
17
-
18
- type Hour12T = bool | None
19
- type HourCycleT = Literal["h11", "h12", "h23", "h24"] | None
20
-
21
- type EraFormatT = Literal["long", "short", "narrow"]
22
- type YearFormatT = Literal["numeric", "2-digit"]
23
- type MonthFormatT = Literal["numeric", "2-digit", "long", "short", "narrow"]
24
- type WeekdayFormatT = Literal["long", "short", "narrow"]
25
- type DayFormatT = Literal["numeric", "2-digit"]
26
- type DayPeriodFormatT = Literal["long", "short", "narrow"]
27
- type HourFormatT = Literal["numeric", "2-digit"]
28
- type MinuteFormatT = Literal["numeric", "2-digit"]
29
- type SecondFormatT = Literal["numeric", "2-digit"]
30
- type FractionSecondDigitsFormatT = Literal[1, 2, 3]
31
- type TimezoneNameFormatT = Literal[
32
- "short",
33
- "long",
34
- "short_offset",
35
- "long_offset",
36
- "short_generic",
37
- "long_generic",
38
- ]
39
- type AnyFormatT = (
40
- EraFormatT
41
- | YearFormatT
42
- | MonthFormatT
43
- | WeekdayFormatT
44
- | DayFormatT
45
- | DayPeriodFormatT
46
- | HourFormatT
47
- | MinuteFormatT
48
- | SecondFormatT
49
- | FractionSecondDigitsFormatT
50
- | TimezoneNameFormatT
16
+ from ._types import (
17
+ DayFormatT,
18
+ DayPeriodFormatT,
19
+ EraFormatT,
20
+ FractionSecondDigitsFormatT,
21
+ Hour12T,
22
+ HourCycleT,
23
+ HourFormatT,
24
+ LocaleMatcherT,
25
+ MinuteFormatT,
26
+ MonthFormatT,
27
+ SecondFormatT,
28
+ TimezoneNameFormatT,
29
+ WeekdayFormatT,
30
+ YearFormatT,
51
31
  )
52
32
 
53
- type PatternPartTypeT = Literal[
33
+ type DatetimePatternPartTypeT = Literal[
54
34
  "literal", "unknown",
55
35
  "era", "year", "month", "weekday", "day", "day_period",
56
36
  "hour", "minute", "second", "fraction_second_digits",
@@ -78,7 +58,7 @@ if TYPE_CHECKING:
78
58
  time_zone_name: NotRequired[TimezoneNameFormatT]
79
59
 
80
60
  _PATTERN_SYMBOLS = "GyYuUrQqMLqQdDFgEecabBhHkKmsSAzZOvVxX" # includes unused
81
- _PATTERN_SYMBOL_TO_TYPE: dict[str, PatternPartTypeT] = {
61
+ _PATTERN_SYMBOL_TO_TYPE: dict[str, DatetimePatternPartTypeT] = {
82
62
  "G": "era",
83
63
  "y": "year",
84
64
  "Y": "year",
@@ -92,6 +72,7 @@ _PATTERN_SYMBOL_TO_TYPE: dict[str, PatternPartTypeT] = {
92
72
  "d": "day",
93
73
  "a": "day_period",
94
74
  "b": "day_period",
75
+ "B": "day_period",
95
76
  "C": "day_period",
96
77
  "h": "hour",
97
78
  "H": "hour",
@@ -108,21 +89,27 @@ _PATTERN_SYMBOL_TO_TYPE: dict[str, PatternPartTypeT] = {
108
89
  "x": "time_zone_name",
109
90
  "X": "time_zone_name",
110
91
  }
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]
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
+ # TODO(ddanier): Use this instead once PyICU has those values in the enum:
100
+ # https://gitlab.pyicu.org/main/pyicu/-/work_items/180
101
+ # icu.DateFormat.AM_PM_MIDNIGHT_NOON_FIELD: "day_period",
102
+ # icu.DateFormat.FLEXIBLE_DAY_PERIOD_FIELD: "day_period",
103
+ 35: "day_period",
104
+ 36: "day_period",
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",
126
113
  }
127
114
  _PATTERN_QUOTE = "'"
128
115
 
@@ -146,8 +133,8 @@ _SOURCE_TO_JSON_MAP: dict[str, str] = {
146
133
  @dataclasses.dataclass(frozen=True, kw_only=True, slots=True)
147
134
  class DateTimeFormatOptions:
148
135
  locale_matcher: LocaleMatcherT = "best fit"
149
- hour12: Hour12T = None
150
- hour_cycle: HourCycleT = None
136
+ hour12: Hour12T | None = None
137
+ hour_cycle: HourCycleT | None = None
151
138
 
152
139
  era: EraFormatT | None = None
153
140
  year: YearFormatT | None = None
@@ -165,6 +152,10 @@ class DateTimeFormatOptions:
165
152
  return {
166
153
  k: v
167
154
  for k, v in (
155
+ ("localeMatcher", self.locale_matcher),
156
+ ("hour12", self.hour12),
157
+ ("hourCycle", self.hour_cycle),
158
+
168
159
  ("era", self.era),
169
160
  ("year", self.year),
170
161
  ("month", self.month),
@@ -181,7 +172,7 @@ class DateTimeFormatOptions:
181
172
  }
182
173
 
183
174
 
184
- def _options_to_possible_skeletons(options: DateTimeFormatOptions) -> Iterable[str]:
175
+ def _options_to_possible_skeletons(options: DateTimeFormatOptions) -> Iterable[str]: # noqa: PLR0912, PLR0915
185
176
  skeleton_parts: list[str | tuple[str, ...]] = []
186
177
 
187
178
  # Note: The parts should be ordered from big to small.
@@ -313,17 +304,18 @@ class _MatchedFormatPattern:
313
304
  skeleton: str
314
305
  pattern: str
315
306
 
307
+ @override
316
308
  def __str__(self) -> str:
317
309
  return self.pattern
318
310
 
319
311
 
320
- class FormatPatternNotFoundException(Exception):
312
+ class FormatPatternNotFoundError(Exception):
321
313
  pass
322
314
 
323
315
 
324
316
  @dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
325
317
  class DateTimePatternPart:
326
- type: PatternPartTypeT
318
+ type: DatetimePatternPartTypeT
327
319
  value: str
328
320
  _pattern: str | None = None
329
321
 
@@ -337,7 +329,7 @@ class DateTimePatternPart:
337
329
 
338
330
  @dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
339
331
  class DateTimeIntervalPatternPart:
340
- type: PatternPartTypeT
332
+ type: DatetimePatternPartTypeT
341
333
  value: str
342
334
  source: Literal["start_range", "end_range", "shared"]
343
335
 
@@ -351,12 +343,12 @@ class DateTimeIntervalPatternPart:
351
343
 
352
344
  @cache
353
345
  def _options_to_format_pattern(
354
- locale: icu.Locale, # ty: ignore[unresolved-attribute]
346
+ locale: icu.Locale,
355
347
  options: DateTimeFormatOptions,
356
348
  ) -> _MatchedFormatPattern:
357
349
  possible_skeletons = list(_options_to_possible_skeletons(options))
358
350
 
359
- generator = icu.DateTimePatternGenerator.createInstance(locale) # ty: ignore[unresolved-attribute]
351
+ generator = icu.DateTimePatternGenerator.createInstance(locale)
360
352
 
361
353
  # Try a perfect match
362
354
  for skeleton in possible_skeletons:
@@ -377,7 +369,8 @@ def _options_to_format_pattern(
377
369
  pattern=pattern,
378
370
  )
379
371
 
380
- raise FormatPatternNotFoundException("Didn't find pattern for desired options")
372
+ error = "Didn't find pattern for desired options"
373
+ raise FormatPatternNotFoundError(error)
381
374
 
382
375
 
383
376
  @dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
@@ -390,7 +383,7 @@ class _PartSpan:
390
383
  return cls(start=0, end=0)
391
384
 
392
385
  @classmethod
393
- def from_constrained_fieldposition(cls, position: icu.ConstrainedFieldPosition) -> _PartSpan: # ty: ignore[unresolved-attribute]
386
+ def from_constrained_fieldposition(cls, position: icu.ConstrainedFieldPosition) -> _PartSpan:
394
387
  return cls(start=position.getStart(), end=position.getLimit())
395
388
 
396
389
  def __contains__(self, inner: _PartSpan) -> bool:
@@ -398,15 +391,18 @@ class _PartSpan:
398
391
 
399
392
 
400
393
  class DateTimeFormat:
401
- locale: str
394
+ locale: Locale
402
395
  options: DateTimeFormatOptions
403
396
 
404
397
  def __init__(
405
398
  self,
406
- locale: str,
399
+ locale: Locale | str,
407
400
  options: DateTimeFormatOptions | DateTimeFormatOptionsDictT | None = None,
408
401
  ) -> None:
409
- self.locale = locale
402
+ if isinstance(locale, Locale):
403
+ self.locale = locale
404
+ else:
405
+ self.locale = Locale(locale)
410
406
  if options is None:
411
407
  self.options = DateTimeFormatOptions()
412
408
  elif isinstance(options, DateTimeFormatOptions):
@@ -414,21 +410,17 @@ class DateTimeFormat:
414
410
  else:
415
411
  self.options = DateTimeFormatOptions(**options)
416
412
 
417
- @cached_property
418
- def _icu_locale(self) -> icu.Locale: # ty: ignore[unresolved-attribute]
419
- return icu.Locale(self.locale) # ty: ignore[unresolved-attribute]
420
-
421
413
  @cached_property
422
414
  def _matched_pattern(self) -> _MatchedFormatPattern:
423
- return _options_to_format_pattern(self._icu_locale, self.options)
415
+ return _options_to_format_pattern(self.locale._icu_locale, self.options)
424
416
 
425
417
  @cached_property
426
418
  def _icu_pattern(self) -> str:
427
419
  return self._matched_pattern.pattern
428
420
 
429
421
  @cached_property
430
- def _icu_date_format(self) -> icu.SimpleDateFormat: # ty: ignore[unresolved-attribute]
431
- return icu.SimpleDateFormat(self._icu_pattern, self._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)
432
424
 
433
425
  def format(self, datetime_: dt.datetime, /) -> str:
434
426
  return self._icu_date_format.format(datetime_)
@@ -448,7 +440,7 @@ class DateTimeFormat:
448
440
  if char != prev_char and count > 0:
449
441
  yield DateTimePatternPart(
450
442
  type=_PATTERN_SYMBOL_TO_TYPE.get(prev_char, "unknown"),
451
- value=icu.SimpleDateFormat(prev_char * count, self._icu_locale).format(datetime_), # ty: ignore[unresolved-attribute]
443
+ value=icu.SimpleDateFormat(prev_char * count, self.locale._icu_locale).format(datetime_),
452
444
  _pattern=prev_char * count,
453
445
  )
454
446
  count = 0
@@ -476,7 +468,7 @@ class DateTimeFormat:
476
468
  if count > 0:
477
469
  yield DateTimePatternPart(
478
470
  type=_PATTERN_SYMBOL_TO_TYPE.get(prev_char, "unknown"),
479
- value=icu.SimpleDateFormat(prev_char * count, self._icu_locale).format(datetime_), # ty: ignore[unresolved-attribute]
471
+ value=icu.SimpleDateFormat(prev_char * count, self.locale._icu_locale).format(datetime_),
480
472
  _pattern=prev_char * count,
481
473
  )
482
474
  assert not literal_chars # noqa: S101
@@ -487,16 +479,16 @@ class DateTimeFormat:
487
479
  )
488
480
 
489
481
  @cached_property
490
- def _icu_dateinterval_format(self) -> icu.DateIntervalFormat: # ty: ignore[unresolved-attribute]
482
+ def _icu_dateinterval_format(self) -> icu.DateIntervalFormat:
491
483
  possible_skeletons = list(_options_to_possible_skeletons(self.options))
492
- return icu.DateIntervalFormat.createInstance(possible_skeletons[0], self._icu_locale) # ty: ignore[unresolved-attribute]
484
+ return icu.DateIntervalFormat.createInstance(possible_skeletons[0], self.locale._icu_locale)
493
485
 
494
486
  def format_range(
495
487
  self,
496
488
  start_datetime: dt.datetime,
497
489
  end_datetime: dt.datetime,
498
490
  ) -> str:
499
- icu_date_interval = icu.DateInterval(start_datetime, end_datetime) # ty: ignore[unresolved-attribute]
491
+ icu_date_interval = icu.DateInterval(start_datetime, end_datetime)
500
492
  return self._icu_dateinterval_format.format(icu_date_interval)
501
493
 
502
494
  def format_range_to_parts(
@@ -504,14 +496,14 @@ class DateTimeFormat:
504
496
  start_datetime: dt.datetime,
505
497
  end_datetime: dt.datetime,
506
498
  ) -> Iterable[DateTimeIntervalPatternPart]:
507
- icu_date_interval = icu.DateInterval(start_datetime, end_datetime) # ty: ignore[unresolved-attribute]
499
+ icu_date_interval = icu.DateInterval(start_datetime, end_datetime)
508
500
  formatted = self._icu_dateinterval_format.formatToValue(icu_date_interval)
509
501
 
510
502
  # Find spans of both datetimes (used to determine which parts have which source)
511
503
  span_start = _PartSpan.empty()
512
504
  span_end = _PartSpan.empty()
513
505
  for part in formatted:
514
- if part.getCategory() == icu.UFieldCategory.DATE_INTERVAL_SPAN: # ty: ignore[unresolved-attribute]
506
+ if part.getCategory() == icu.UFieldCategory.DATE_INTERVAL_SPAN:
515
507
  match part.getField():
516
508
  case 0:
517
509
  span_start = _PartSpan.from_constrained_fieldposition(part)
@@ -538,7 +530,7 @@ class DateTimeFormat:
538
530
  source=source_of(_PartSpan(start=last_end, end=span.start)),
539
531
  )
540
532
 
541
- if part.getCategory() == icu.UFieldCategory.DATE: # ty: ignore[unresolved-attribute]
533
+ if part.getCategory() == icu.UFieldCategory.DATE:
542
534
  yield DateTimeIntervalPatternPart(
543
535
  type=_PATTERN_FIELD_TO_TYPE.get(part.getField(), "unknown"),
544
536
  value=result_string[span.start:span.end],
@@ -0,0 +1,16 @@
1
+ from __future__ import annotations
2
+
3
+ from functools import cached_property
4
+
5
+ import icu
6
+
7
+
8
+ class Locale:
9
+ tag: str
10
+
11
+ def __init__(self, tag: str) -> None:
12
+ self.tag = tag
13
+
14
+ @cached_property
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,61 +0,0 @@
1
- [project]
2
- name = "python-intl"
3
- version = "0.7.1"
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
- target-version = "py312"
40
- output-format = "grouped"
41
-
42
- [tool.ruff.lint]
43
- select = ["F","E","W","C","I","N","UP","ANN","S","B","A","COM","C4","T20","PT","ARG","TD","RUF"]
44
- ignore = ["A001","A002","A003","ANN401","C901","N8","B008","F405","F821"]
45
-
46
- [tool.ruff.lint.per-file-ignores]
47
- "__init__.py" = ["F401"]
48
- "conftest.py" = ["S101","ANN","F401"]
49
- "test_*.py" = ["S101","ANN","F401"]
50
-
51
- [tool.ruff.lint.isort]
52
- combine-as-imports = true
53
- force-wrap-aliases = true
54
-
55
- [build-system]
56
- requires = ["uv_build>=0.12.3,<0.13.0"]
57
- build-backend = "uv_build"
58
-
59
- [tool.uv.build-backend]
60
- module-name = "python_intl"
61
- module-root = ""
@@ -1,3 +0,0 @@
1
- from typing import Literal
2
-
3
- type LocaleMatcherT = Literal["best fit", "lookup"]
File without changes