ucon 0.5.2__py3-none-any.whl → 0.6.0__py3-none-any.whl

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.
ucon/__init__.py CHANGED
@@ -53,6 +53,7 @@ from ucon.core import (
53
53
  Ratio,
54
54
  )
55
55
  from ucon.graph import get_default_graph, using_graph
56
+ from ucon.units import UnknownUnitError, get_unit_by_name
56
57
 
57
58
 
58
59
  __all__ = [
@@ -69,7 +70,9 @@ __all__ = [
69
70
  'UnitFactor',
70
71
  'UnitProduct',
71
72
  'UnitSystem',
73
+ 'UnknownUnitError',
72
74
  'get_default_graph',
73
- 'using_graph',
75
+ 'get_unit_by_name',
74
76
  'units',
77
+ 'using_graph',
75
78
  ]
ucon/core.py CHANGED
@@ -1108,16 +1108,21 @@ class UnitProduct:
1108
1108
  part = getattr(unit, "shorthand", "") or getattr(unit, "name", "") or ""
1109
1109
  if not part:
1110
1110
  return
1111
+
1112
+ def fmt_exp(p: float) -> str:
1113
+ """Format exponent, using int when possible to avoid '2.0' → '²·⁰'."""
1114
+ return str(int(p) if p == int(p) else p).translate(cls._SUPERSCRIPTS)
1115
+
1111
1116
  if power > 0:
1112
1117
  if power == 1:
1113
1118
  num.append(part)
1114
1119
  else:
1115
- num.append(part + str(power).translate(cls._SUPERSCRIPTS))
1120
+ num.append(part + fmt_exp(power))
1116
1121
  elif power < 0:
1117
1122
  if power == -1:
1118
1123
  den.append(part)
1119
1124
  else:
1120
- den.append(part + str(-power).translate(cls._SUPERSCRIPTS))
1125
+ den.append(part + fmt_exp(-power))
1121
1126
 
1122
1127
  @property
1123
1128
  def shorthand(self) -> str:
ucon/mcp/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ # ucon MCP server
2
+ #
3
+ # Install: pip install ucon[mcp]
4
+ # Run: ucon-mcp
5
+
6
+ from ucon.mcp.server import main
7
+
8
+ __all__ = ["main"]
ucon/mcp/server.py ADDED
@@ -0,0 +1,250 @@
1
+ # ucon MCP Server
2
+ #
3
+ # Provides unit conversion and dimensional analysis tools for AI agents.
4
+ #
5
+ # Usage:
6
+ # ucon-mcp # Run via entry point
7
+ # python -m ucon.mcp # Run as module
8
+
9
+ from mcp.server.fastmcp import FastMCP
10
+ from pydantic import BaseModel
11
+
12
+ from ucon import Dimension, get_unit_by_name
13
+ from ucon.core import Number, Scale, Unit, UnitProduct
14
+ from ucon.units import UnknownUnitError
15
+
16
+
17
+ mcp = FastMCP("ucon")
18
+
19
+
20
+ # -----------------------------------------------------------------------------
21
+ # Response Models
22
+ # -----------------------------------------------------------------------------
23
+
24
+
25
+ class ConversionResult(BaseModel):
26
+ """Result of a unit conversion."""
27
+
28
+ quantity: float
29
+ unit: str | None
30
+ dimension: str
31
+ uncertainty: float | None = None
32
+
33
+
34
+ class UnitInfo(BaseModel):
35
+ """Information about an available unit."""
36
+
37
+ name: str
38
+ shorthand: str
39
+ aliases: list[str]
40
+ dimension: str
41
+ scalable: bool
42
+
43
+
44
+ class ScaleInfo(BaseModel):
45
+ """Information about a scale prefix."""
46
+
47
+ name: str
48
+ prefix: str
49
+ factor: float
50
+
51
+
52
+ class DimensionCheck(BaseModel):
53
+ """Result of a dimensional compatibility check."""
54
+
55
+ compatible: bool
56
+ dimension_a: str
57
+ dimension_b: str
58
+
59
+
60
+ # -----------------------------------------------------------------------------
61
+ # Tools
62
+ # -----------------------------------------------------------------------------
63
+
64
+
65
+ @mcp.tool()
66
+ def convert(value: float, from_unit: str, to_unit: str) -> ConversionResult:
67
+ """
68
+ Convert a numeric value from one unit to another.
69
+
70
+ Units can be specified as:
71
+ - Base units: "meter", "m", "second", "s", "gram", "g"
72
+ - Scaled units: "km", "mL", "kg", "MHz" (use list_scales for prefixes)
73
+ - Composite units: "m/s", "kg*m/s^2", "N*m"
74
+ - Exponents: "m^2", "s^-1" (ASCII) or "m²", "s⁻¹" (Unicode)
75
+
76
+ Args:
77
+ value: The numeric quantity to convert.
78
+ from_unit: Source unit string.
79
+ to_unit: Target unit string.
80
+
81
+ Returns:
82
+ ConversionResult with converted quantity, unit, and dimension.
83
+
84
+ Raises:
85
+ UnknownUnitError: If a unit string cannot be parsed.
86
+ DimensionMismatch: If units have incompatible dimensions.
87
+ """
88
+ src = get_unit_by_name(from_unit)
89
+ dst = get_unit_by_name(to_unit)
90
+
91
+ num = Number(quantity=value, unit=src)
92
+ result = num.to(dst)
93
+
94
+ unit_str = None
95
+ dim_name = "none"
96
+
97
+ if result.unit:
98
+ if isinstance(result.unit, UnitProduct):
99
+ unit_str = result.unit.shorthand
100
+ dim_name = result.unit.dimension.name
101
+ elif isinstance(result.unit, Unit):
102
+ unit_str = result.unit.shorthand
103
+ dim_name = result.unit.dimension.name
104
+
105
+ return ConversionResult(
106
+ quantity=result.quantity,
107
+ unit=unit_str,
108
+ dimension=dim_name,
109
+ uncertainty=result.uncertainty,
110
+ )
111
+
112
+
113
+ @mcp.tool()
114
+ def list_units(dimension: str | None = None) -> list[UnitInfo]:
115
+ """
116
+ List available units, optionally filtered by dimension.
117
+
118
+ Returns base units only. Use scale prefixes (from list_scales) to form
119
+ scaled variants. For example, "meter" with prefix "k" becomes "km".
120
+
121
+ Args:
122
+ dimension: Optional filter by dimension name (e.g., "length", "mass", "time").
123
+ Use list_dimensions() to see available dimensions.
124
+
125
+ Returns:
126
+ List of UnitInfo objects describing available units.
127
+ """
128
+ import ucon.units as units_module
129
+
130
+ # Units that accept SI scale prefixes
131
+ SCALABLE_UNITS = {
132
+ "meter", "gram", "second", "ampere", "kelvin", "mole", "candela",
133
+ "hertz", "newton", "pascal", "joule", "watt", "coulomb", "volt",
134
+ "farad", "ohm", "siemens", "weber", "tesla", "henry", "lumen",
135
+ "lux", "becquerel", "gray", "sievert", "katal",
136
+ "liter", "byte",
137
+ }
138
+
139
+ result = []
140
+ seen_names = set()
141
+
142
+ for name in dir(units_module):
143
+ obj = getattr(units_module, name)
144
+ if isinstance(obj, Unit) and obj.name and obj.name not in seen_names:
145
+ seen_names.add(obj.name)
146
+
147
+ if dimension and obj.dimension.name != dimension:
148
+ continue
149
+
150
+ result.append(
151
+ UnitInfo(
152
+ name=obj.name,
153
+ shorthand=obj.shorthand,
154
+ aliases=list(obj.aliases) if obj.aliases else [],
155
+ dimension=obj.dimension.name,
156
+ scalable=obj.name in SCALABLE_UNITS,
157
+ )
158
+ )
159
+
160
+ return sorted(result, key=lambda u: (u.dimension, u.name))
161
+
162
+
163
+ @mcp.tool()
164
+ def list_scales() -> list[ScaleInfo]:
165
+ """
166
+ List available scale prefixes for units.
167
+
168
+ These prefixes can be combined with scalable units (see list_units).
169
+ For example, prefix "k" (kilo) with unit "m" (meter) forms "km".
170
+
171
+ Includes both SI decimal prefixes (kilo, mega, milli, micro, etc.)
172
+ and binary prefixes (kibi, mebi, gibi) for information units.
173
+
174
+ Note on bytes:
175
+ - SI prefixes: kB = 1000 B, MB = 1,000,000 B (decimal)
176
+ - Binary prefixes: KiB = 1024 B, MiB = 1,048,576 B (powers of 2)
177
+
178
+ Returns:
179
+ List of ScaleInfo objects with name, prefix symbol, and numeric factor.
180
+ """
181
+ result = []
182
+ for scale in Scale:
183
+ if scale == Scale.one:
184
+ continue # Skip the identity scale
185
+ result.append(
186
+ ScaleInfo(
187
+ name=scale.name,
188
+ prefix=scale.shorthand,
189
+ factor=scale.descriptor.evaluated,
190
+ )
191
+ )
192
+ return sorted(result, key=lambda s: -s.factor)
193
+
194
+
195
+ @mcp.tool()
196
+ def check_dimensions(unit_a: str, unit_b: str) -> DimensionCheck:
197
+ """
198
+ Check if two units have compatible dimensions.
199
+
200
+ Units with the same dimension can be converted between each other.
201
+ Units with different dimensions cannot be added or directly compared.
202
+
203
+ Args:
204
+ unit_a: First unit string.
205
+ unit_b: Second unit string.
206
+
207
+ Returns:
208
+ DimensionCheck indicating compatibility and the dimension of each unit.
209
+ """
210
+ a = get_unit_by_name(unit_a)
211
+ b = get_unit_by_name(unit_b)
212
+
213
+ dim_a = a.dimension if isinstance(a, Unit) else a.dimension
214
+ dim_b = b.dimension if isinstance(b, Unit) else b.dimension
215
+
216
+ return DimensionCheck(
217
+ compatible=(dim_a == dim_b),
218
+ dimension_a=dim_a.name,
219
+ dimension_b=dim_b.name,
220
+ )
221
+
222
+
223
+ @mcp.tool()
224
+ def list_dimensions() -> list[str]:
225
+ """
226
+ List available physical dimensions.
227
+
228
+ Dimensions represent fundamental physical quantities (length, mass, time, etc.)
229
+ and derived quantities (velocity, force, energy, etc.).
230
+
231
+ Use these dimension names to filter list_units().
232
+
233
+ Returns:
234
+ List of dimension names.
235
+ """
236
+ return sorted([d.name for d in Dimension])
237
+
238
+
239
+ # -----------------------------------------------------------------------------
240
+ # Entry Point
241
+ # -----------------------------------------------------------------------------
242
+
243
+
244
+ def main():
245
+ """Run the ucon MCP server."""
246
+ mcp.run()
247
+
248
+
249
+ if __name__ == "__main__":
250
+ main()
ucon/pydantic.py ADDED
@@ -0,0 +1,199 @@
1
+ # © 2025 The Radiativity Company
2
+ # Licensed under the Apache License, Version 2.0
3
+ # See the LICENSE file for details.
4
+
5
+ """
6
+ ucon.pydantic
7
+ =============
8
+
9
+ Pydantic v2 integration for ucon.
10
+
11
+ Provides type-annotated wrappers for use in Pydantic models with full
12
+ JSON serialization support.
13
+
14
+ Usage
15
+ -----
16
+ >>> from pydantic import BaseModel
17
+ >>> from ucon.pydantic import Number
18
+ >>>
19
+ >>> class Measurement(BaseModel):
20
+ ... value: Number
21
+ ...
22
+ >>> m = Measurement(value={"quantity": 5, "unit": "km"})
23
+ >>> print(m.value)
24
+ <5 km>
25
+ >>> print(m.model_dump_json())
26
+ {"value": {"quantity": 5.0, "unit": "km", "uncertainty": null}}
27
+
28
+ Installation
29
+ ------------
30
+ Requires Pydantic v2. Install with::
31
+
32
+ pip install ucon[pydantic]
33
+
34
+ """
35
+
36
+ from typing import Annotated, Any
37
+
38
+ try:
39
+ from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler
40
+ from pydantic.json_schema import JsonSchemaValue
41
+ from pydantic_core import CoreSchema, core_schema
42
+ except ImportError as e:
43
+ raise ImportError(
44
+ "Pydantic v2 is required for ucon.pydantic. "
45
+ "Install with: pip install ucon[pydantic]"
46
+ ) from e
47
+
48
+ from ucon.core import Number as _Number
49
+ from ucon.units import UnknownUnitError, get_unit_by_name
50
+
51
+
52
+ def _validate_number(v: Any) -> _Number:
53
+ """
54
+ Validate and convert input to Number.
55
+
56
+ Accepts:
57
+ - Number instance (passthrough)
58
+ - dict with 'quantity' and optional 'unit', 'uncertainty'
59
+
60
+ Raises:
61
+ ValueError: If input cannot be converted to Number.
62
+ """
63
+ if isinstance(v, _Number):
64
+ return v
65
+
66
+ if isinstance(v, dict):
67
+ quantity = v.get("quantity")
68
+ if quantity is None:
69
+ raise ValueError("Number dict must have 'quantity' field")
70
+
71
+ unit_str = v.get("unit")
72
+ uncertainty = v.get("uncertainty")
73
+
74
+ # Parse unit if provided
75
+ if unit_str:
76
+ try:
77
+ unit = get_unit_by_name(unit_str)
78
+ except UnknownUnitError as e:
79
+ raise ValueError(f"Unknown unit: {unit_str!r}") from e
80
+ else:
81
+ unit = None
82
+
83
+ return _Number(
84
+ quantity=quantity,
85
+ unit=unit,
86
+ uncertainty=uncertainty,
87
+ )
88
+
89
+ raise ValueError(
90
+ f"Cannot parse Number from {type(v).__name__}. "
91
+ "Expected Number instance or dict with 'quantity' field."
92
+ )
93
+
94
+
95
+ def _serialize_number(n: _Number) -> dict:
96
+ """
97
+ Serialize Number to JSON-compatible dict.
98
+
99
+ Output format::
100
+
101
+ {
102
+ "quantity": <float>,
103
+ "unit": <str | null>,
104
+ "uncertainty": <float | null>
105
+ }
106
+ """
107
+ # Get unit shorthand
108
+ if n.unit is None:
109
+ unit_str = None
110
+ elif hasattr(n.unit, 'shorthand'):
111
+ unit_str = n.unit.shorthand
112
+ # Empty shorthand means dimensionless
113
+ if unit_str == "":
114
+ unit_str = None
115
+ else:
116
+ unit_str = None
117
+
118
+ return {
119
+ "quantity": float(n.quantity),
120
+ "unit": unit_str,
121
+ "uncertainty": float(n.uncertainty) if n.uncertainty is not None else None,
122
+ }
123
+
124
+
125
+ class _NumberPydanticAnnotation:
126
+ """
127
+ Pydantic annotation helper for ucon Number type.
128
+
129
+ This class provides the schema generation hooks that Pydantic v2 needs
130
+ to properly validate and serialize Number instances without introspecting
131
+ the internal Unit/UnitProduct types.
132
+ """
133
+
134
+ @classmethod
135
+ def __get_pydantic_core_schema__(
136
+ cls,
137
+ _source_type: Any,
138
+ _handler: GetCoreSchemaHandler,
139
+ ) -> CoreSchema:
140
+ """
141
+ Generate Pydantic core schema for Number validation/serialization.
142
+
143
+ Uses no_info_plain_validator_function to bypass Pydantic's default
144
+ introspection of the Number class fields.
145
+ """
146
+ return core_schema.no_info_plain_validator_function(
147
+ _validate_number,
148
+ serialization=core_schema.plain_serializer_function_ser_schema(
149
+ _serialize_number,
150
+ info_arg=False,
151
+ return_schema=core_schema.dict_schema(),
152
+ ),
153
+ )
154
+
155
+ @classmethod
156
+ def __get_pydantic_json_schema__(
157
+ cls,
158
+ _core_schema: CoreSchema,
159
+ handler: GetJsonSchemaHandler,
160
+ ) -> JsonSchemaValue:
161
+ """Generate JSON schema for OpenAPI documentation."""
162
+ return {
163
+ "type": "object",
164
+ "properties": {
165
+ "quantity": {"type": "number"},
166
+ "unit": {"type": "string", "nullable": True},
167
+ "uncertainty": {"type": "number", "nullable": True},
168
+ },
169
+ "required": ["quantity"],
170
+ }
171
+
172
+
173
+ Number = Annotated[_Number, _NumberPydanticAnnotation]
174
+ """
175
+ Pydantic-compatible Number type.
176
+
177
+ Use this as a type hint in Pydantic models to enable automatic validation
178
+ and JSON serialization of ucon Number instances.
179
+
180
+ Example::
181
+
182
+ from pydantic import BaseModel
183
+ from ucon.pydantic import Number
184
+
185
+ class Measurement(BaseModel):
186
+ value: Number
187
+
188
+ # From dict
189
+ m = Measurement(value={"quantity": 5, "unit": "m"})
190
+
191
+ # From Number instance
192
+ from ucon import units
193
+ m2 = Measurement(value=units.meter(10))
194
+
195
+ # Serialize to JSON
196
+ print(m.model_dump_json())
197
+ """
198
+
199
+ __all__ = ["Number"]