Skip to content

Commit 2c857dd

Browse files
albertsolaCopilot
andcommitted
MPT-17888: fix bidirectional field mapping for consecutive uppercase letters
Add _FIELD_NAME_MAPPINGS (MappingProxyType) in model.py for API fields that contain two or more consecutive uppercase letters (PPx1, SPxM, unitLP, totalGT, etc.). The generic camelCase<->snake_case regex cannot round-trip these correctly, so an explicit lookup table is checked first in both to_snake_case and to_camel_case. Affected fields from OpenAPI spec: - PP*/SP*/LP* price columns (PPx1→ppx1, SPxM→spxm, LPxY→lpxy, ...) - unit+acronym fields (unitLP→unit_lp, unitPP→unit_pp, unitSP→unit_sp) - total+acronym fields (totalGT→total_gt, totalPP→total_pp, ...) PriceListItem model annotations updated to use the corrected names (ppx1, spxm, lpx1, etc. instead of p_px1, s_px_m, l_px1, ...). to_dict() round-trip now works correctly for all price columns. Tests: - Merged price_list_item price fixture into main fixture for full round-trip test coverage - Added parametrized tests for to_snake_case and to_camel_case with consecutive-uppercase fields in tests/unit/models/test_model.py Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 5feb3be commit 2c857dd

File tree

5 files changed

+111
-35
lines changed

5 files changed

+111
-35
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,8 @@ cython_debug/
167167
.ruff_cache
168168
.idea
169169
.openapi/
170+
openapi/openapi-dev.json
171+
.github/copilot-instructions.md
170172
# Makefile
171173
make/local.mk
172174

mpt_api_client/models/model.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import re
22
from collections import UserList
33
from collections.abc import Iterable
4+
from types import MappingProxyType
45
from typing import Any, Self, get_args, get_origin, override
56

67
from mpt_api_client.http.types import Response
@@ -12,9 +13,47 @@
1213
_SNAKE_CASE_BOUNDARY = re.compile(r"([a-z0-9])([A-Z])")
1314
_SNAKE_CASE_ACRONYM = re.compile(r"(?<=[A-Z])(?=[A-Z][a-z0-9])")
1415

16+
# Explicit bidirectional mappings for API field names that contain two or more consecutive
17+
# uppercase letters (e.g. PPx1, unitLP). The generic regex cannot round-trip these correctly,
18+
# so we maintain an explicit lookup table that is checked before the regex is applied.
19+
_FIELD_NAME_MAPPINGS: MappingProxyType[str, str] = MappingProxyType({
20+
# PP* price columns
21+
"PPx1": "ppx1",
22+
"PPxM": "ppxm",
23+
"PPxY": "ppxy",
24+
# SP* price columns
25+
"SPx1": "spx1",
26+
"SPxM": "spxm",
27+
"SPxY": "spxy",
28+
# LP* price columns
29+
"LPx1": "lpx1",
30+
"LPxM": "lpxm",
31+
"LPxY": "lpxy",
32+
# unit + 2-letter acronym suffix
33+
"unitLP": "unit_lp",
34+
"unitPP": "unit_pp",
35+
"unitSP": "unit_sp",
36+
# total + 2-letter acronym suffix
37+
"totalGT": "total_gt",
38+
"totalPP": "total_pp",
39+
"totalSP": "total_sp",
40+
"totalST": "total_st",
41+
})
42+
43+
_FIELD_NAME_MAPPINGS_REVERSE: MappingProxyType[str, str] = MappingProxyType({
44+
snake: camel for camel, snake in _FIELD_NAME_MAPPINGS.items()
45+
})
46+
1547

1648
def to_snake_case(key: str) -> str:
17-
"""Converts a camelCase string to snake_case."""
49+
"""Converts a camelCase string to snake_case.
50+
51+
Explicit mappings in ``_FIELD_NAME_MAPPINGS`` take priority over the generic
52+
regex for fields that contain two or more consecutive uppercase letters.
53+
"""
54+
mapped = _FIELD_NAME_MAPPINGS.get(key)
55+
if mapped is not None:
56+
return mapped
1857
if "_" in key and key.islower():
1958
return key
2059
# Common pattern for PascalCase/camelCase conversion
@@ -24,7 +63,14 @@ def to_snake_case(key: str) -> str:
2463

2564

2665
def to_camel_case(key: str) -> str:
27-
"""Converts a snake_case string to camelCase."""
66+
"""Converts a snake_case string to camelCase.
67+
68+
Explicit mappings in ``_FIELD_NAME_MAPPINGS_REVERSE`` take priority over the
69+
generic logic for fields that contain two or more consecutive uppercase letters.
70+
"""
71+
mapped = _FIELD_NAME_MAPPINGS_REVERSE.get(key)
72+
if mapped is not None:
73+
return mapped
2874
parts = key.split("_")
2975
return parts[0] + "".join(x.title() for x in parts[1:]) # noqa: WPS111 WPS221
3076

mpt_api_client/resources/catalog/price_list_items.py

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,15 @@ class PriceListItem(Model):
2323
markup: Markup percentage.
2424
margin: Margin percentage.
2525
unit_sp: Unit sell price.
26-
p_px1: Purchase price for 1-year term.
27-
p_px_m: Purchase price for monthly term.
28-
p_px_y: Purchase price for yearly term.
29-
s_px1: Sell price for 1-year term.
30-
s_px_m: Sell price for monthly term.
31-
s_px_y: Sell price for yearly term.
32-
l_px1: List price for 1-year term.
33-
l_px_m: List price for monthly term.
34-
l_px_y: List price for yearly term.
26+
ppx1: Purchase price for 1-year term.
27+
ppxm: Purchase price for monthly term.
28+
ppxy: Purchase price for yearly term.
29+
spx1: Sell price for 1-year term.
30+
spxm: Sell price for monthly term.
31+
spxy: Sell price for yearly term.
32+
lpx1: List price for 1-year term.
33+
lpxm: List price for monthly term.
34+
lpxy: List price for yearly term.
3535
price_list: Reference to the parent price list.
3636
item: Reference to the associated item.
3737
audit: Audit information (created, updated events).
@@ -45,15 +45,15 @@ class PriceListItem(Model):
4545
markup: float | None
4646
margin: float | None
4747
unit_sp: float | None
48-
p_px1: float | None
49-
p_px_m: float | None
50-
p_px_y: float | None
51-
s_px1: float | None
52-
s_px_m: float | None
53-
s_px_y: float | None
54-
l_px1: float | None
55-
l_px_m: float | None
56-
l_px_y: float | None
48+
ppx1: float | None
49+
ppxm: float | None
50+
ppxy: float | None
51+
spx1: float | None
52+
spxm: float | None
53+
spxy: float | None
54+
lpx1: float | None
55+
lpxm: float | None
56+
lpxy: float | None
5757
price_list: BaseModel | None
5858
item: BaseModel | None
5959
audit: BaseModel | None

tests/unit/models/test_model.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@
22
from httpx import Response
33

44
from mpt_api_client.models import Meta, Model
5-
from mpt_api_client.models.model import BaseModel, ModelList, to_snake_case # noqa: WPS347
5+
from mpt_api_client.models.model import ( # noqa: WPS347
6+
BaseModel,
7+
ModelList,
8+
to_camel_case,
9+
to_snake_case,
10+
)
611

712

813
class AgreementDummy(Model): # noqa: WPS431
@@ -326,3 +331,33 @@ def test_process_value_scalar_list_elements():
326331

327332
assert isinstance(container.tags, ModelList)
328333
assert list(container.tags) == ["a", "b", "c"]
334+
335+
336+
@pytest.mark.parametrize(
337+
("camel", "snake"),
338+
[
339+
("PPx1", "ppx1"),
340+
("SPxM", "spxm"),
341+
("unitLP", "unit_lp"),
342+
("totalGT", "total_gt"),
343+
],
344+
)
345+
def test_to_snake_case_consecutive_uppercase(camel, snake):
346+
result = to_snake_case(camel) # act
347+
348+
assert result == snake
349+
350+
351+
@pytest.mark.parametrize(
352+
("snake", "camel"),
353+
[
354+
("ppx1", "PPx1"),
355+
("spxm", "SPxM"),
356+
("unit_lp", "unitLP"),
357+
("total_gt", "totalGT"),
358+
],
359+
)
360+
def test_to_camel_case_consecutive_uppercase(snake, camel):
361+
result = to_camel_case(snake) # act
362+
363+
assert result == camel

tests/unit/resources/catalog/test_price_list_items.py

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,16 +29,6 @@ def price_list_item_data():
2929
"status": "Active",
3030
"description": "Item description",
3131
"reasonForChange": "Price update",
32-
"priceList": {"id": "PRC-001", "currency": "USD"},
33-
"item": {"id": "ITM-001", "name": "My Item"},
34-
"audit": {"created": {"at": "2024-01-01T00:00:00Z"}},
35-
}
36-
37-
38-
@pytest.fixture
39-
def price_list_item_price_data():
40-
return {
41-
"id": "PLI-002",
4232
"unitLP": 100.0,
4333
"unitPP": 80.0,
4434
"markup": 25.0,
@@ -53,6 +43,9 @@ def price_list_item_price_data():
5343
"LPx1": 100.0,
5444
"LPxM": 9.0,
5545
"LPxY": 108.0,
46+
"priceList": {"id": "PRC-001", "currency": "USD"},
47+
"item": {"id": "ITM-001", "name": "My Item"},
48+
"audit": {"created": {"at": "2024-01-01T00:00:00Z"}},
5649
}
5750

5851

@@ -92,14 +85,14 @@ def test_price_list_item_primitive_fields(price_list_item_data):
9285
assert result.to_dict() == price_list_item_data
9386

9487

95-
def test_price_list_item_price_fields(price_list_item_price_data):
96-
result = PriceListItem(price_list_item_price_data)
88+
def test_price_list_item_price_fields(price_list_item_data):
89+
result = PriceListItem(price_list_item_data)
9790

9891
assert result.unit_lp == pytest.approx(100.0)
9992
assert result.unit_pp == pytest.approx(80.0)
10093
assert result.unit_sp == pytest.approx(90.0)
101-
assert result.markup == pytest.approx(25.0)
102-
assert result.margin == pytest.approx(20.0)
94+
assert result.spxm == pytest.approx(8.0)
95+
assert result.lpx1 == pytest.approx(100.0)
10396

10497

10598
def test_price_list_item_nested_models(price_list_item_data):

0 commit comments

Comments
 (0)