tango-python 1.1.1__py3-none-any.whl → 1.1.3__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.
- tango/__init__.py +1 -1
- tango/client.py +180 -20
- tango/shapes/factory.py +12 -76
- tango/shapes/generator.py +12 -27
- tango/shapes/parser.py +17 -16
- tango/shapes/schema.py +3 -2
- {tango_python-1.1.1.dist-info → tango_python-1.1.3.dist-info}/METADATA +1 -1
- {tango_python-1.1.1.dist-info → tango_python-1.1.3.dist-info}/RECORD +11 -11
- {tango_python-1.1.1.dist-info → tango_python-1.1.3.dist-info}/WHEEL +1 -1
- {tango_python-1.1.1.dist-info → tango_python-1.1.3.dist-info}/entry_points.txt +0 -0
- {tango_python-1.1.1.dist-info → tango_python-1.1.3.dist-info}/licenses/LICENSE +0 -0
tango/__init__.py
CHANGED
tango/client.py
CHANGED
|
@@ -1923,15 +1923,37 @@ class TangoClient:
|
|
|
1923
1923
|
)
|
|
1924
1924
|
|
|
1925
1925
|
# Business Types endpoints
|
|
1926
|
-
def list_business_types(
|
|
1927
|
-
|
|
1928
|
-
|
|
1926
|
+
def list_business_types(
|
|
1927
|
+
self,
|
|
1928
|
+
page: int = 1,
|
|
1929
|
+
limit: int = 25,
|
|
1930
|
+
shape: str | None = None,
|
|
1931
|
+
flat: bool = False,
|
|
1932
|
+
flat_lists: bool = False,
|
|
1933
|
+
) -> PaginatedResponse:
|
|
1934
|
+
"""List business types.
|
|
1935
|
+
|
|
1936
|
+
When ``shape`` is omitted the API applies its own default
|
|
1937
|
+
(``name,code``) and results are returned as :class:`BusinessType`
|
|
1938
|
+
instances. When ``shape`` is provided, raw dicts are returned so the
|
|
1939
|
+
caller can rely on the exact shape requested.
|
|
1940
|
+
"""
|
|
1941
|
+
params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
|
|
1942
|
+
if shape:
|
|
1943
|
+
params["shape"] = shape
|
|
1944
|
+
if flat:
|
|
1945
|
+
params["flat"] = "true"
|
|
1946
|
+
if flat_lists:
|
|
1947
|
+
params["flat_lists"] = "true"
|
|
1929
1948
|
data = self._get("/api/business_types/", params)
|
|
1949
|
+
results: list[Any] = (
|
|
1950
|
+
list(data["results"]) if shape else [BusinessType(**btype) for btype in data["results"]]
|
|
1951
|
+
)
|
|
1930
1952
|
return PaginatedResponse(
|
|
1931
1953
|
count=data["count"],
|
|
1932
1954
|
next=data.get("next"),
|
|
1933
1955
|
previous=data.get("previous"),
|
|
1934
|
-
results=
|
|
1956
|
+
results=results,
|
|
1935
1957
|
)
|
|
1936
1958
|
|
|
1937
1959
|
def list_naics(
|
|
@@ -1945,8 +1967,17 @@ class TangoClient:
|
|
|
1945
1967
|
revenue_limit_gte: int | None = None,
|
|
1946
1968
|
revenue_limit_lte: int | None = None,
|
|
1947
1969
|
search: str | None = None,
|
|
1970
|
+
shape: str | None = None,
|
|
1971
|
+
flat: bool = False,
|
|
1972
|
+
flat_lists: bool = False,
|
|
1948
1973
|
) -> PaginatedResponse:
|
|
1949
|
-
"""List NAICS codes (`/api/naics/`).
|
|
1974
|
+
"""List NAICS codes (`/api/naics/`).
|
|
1975
|
+
|
|
1976
|
+
When ``shape`` is omitted the API applies its own default. Passing any
|
|
1977
|
+
of the ``revenue_limit*`` / ``employee_limit*`` filters causes the API
|
|
1978
|
+
to widen the default shape to include ``size_standards`` and
|
|
1979
|
+
``federal_obligations``.
|
|
1980
|
+
"""
|
|
1950
1981
|
params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
|
|
1951
1982
|
if employee_limit is not None:
|
|
1952
1983
|
params["employee_limit"] = employee_limit
|
|
@@ -1962,6 +1993,12 @@ class TangoClient:
|
|
|
1962
1993
|
params["revenue_limit_lte"] = revenue_limit_lte
|
|
1963
1994
|
if search is not None:
|
|
1964
1995
|
params["search"] = search
|
|
1996
|
+
if shape:
|
|
1997
|
+
params["shape"] = shape
|
|
1998
|
+
if flat:
|
|
1999
|
+
params["flat"] = "true"
|
|
2000
|
+
if flat_lists:
|
|
2001
|
+
params["flat_lists"] = "true"
|
|
1965
2002
|
data = self._get("/api/naics/", params)
|
|
1966
2003
|
return PaginatedResponse(
|
|
1967
2004
|
count=data.get("count", 0),
|
|
@@ -2081,11 +2118,36 @@ class TangoClient:
|
|
|
2081
2118
|
data = self._get(f"/api/entities/{key}/", params)
|
|
2082
2119
|
return self._parse_response_with_shape(data, shape, Entity, flat, flat_lists)
|
|
2083
2120
|
|
|
2084
|
-
def get_entity_budget_flows(
|
|
2085
|
-
|
|
2121
|
+
def get_entity_budget_flows(
|
|
2122
|
+
self,
|
|
2123
|
+
uei: str,
|
|
2124
|
+
page: int = 1,
|
|
2125
|
+
limit: int = 25,
|
|
2126
|
+
fiscal_year: int | None = None,
|
|
2127
|
+
) -> PaginatedResponse[dict[str, Any]]:
|
|
2128
|
+
"""Get budget flows for an entity (`/api/entities/{uei}/budget-flows/`).
|
|
2129
|
+
|
|
2130
|
+
Standard page/limit pagination (default 25, max 100). Each result row
|
|
2131
|
+
is a hand-built dict from the backend (no shape system).
|
|
2132
|
+
|
|
2133
|
+
Args:
|
|
2134
|
+
uei: Entity UEI. Required.
|
|
2135
|
+
page: Page number.
|
|
2136
|
+
limit: Results per page (max 100).
|
|
2137
|
+
fiscal_year: Optional fiscal year filter.
|
|
2138
|
+
"""
|
|
2086
2139
|
if not uei:
|
|
2087
2140
|
raise TangoValidationError("UEI is required")
|
|
2088
|
-
|
|
2141
|
+
params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
|
|
2142
|
+
if fiscal_year is not None:
|
|
2143
|
+
params["fiscal_year"] = fiscal_year
|
|
2144
|
+
data = self._get(f"/api/entities/{uei}/budget-flows/", params)
|
|
2145
|
+
return PaginatedResponse(
|
|
2146
|
+
count=int(data.get("count", 0)),
|
|
2147
|
+
next=data.get("next"),
|
|
2148
|
+
previous=data.get("previous"),
|
|
2149
|
+
results=list(data.get("results") or []),
|
|
2150
|
+
)
|
|
2089
2151
|
|
|
2090
2152
|
# Forecast endpoints
|
|
2091
2153
|
def list_forecasts(
|
|
@@ -3319,9 +3381,22 @@ class TangoClient:
|
|
|
3319
3381
|
raise TangoValidationError("Department code is required")
|
|
3320
3382
|
return self._get(f"/api/departments/{code}/")
|
|
3321
3383
|
|
|
3322
|
-
def list_psc(
|
|
3384
|
+
def list_psc(
|
|
3385
|
+
self,
|
|
3386
|
+
page: int = 1,
|
|
3387
|
+
limit: int = 25,
|
|
3388
|
+
shape: str | None = None,
|
|
3389
|
+
flat: bool = False,
|
|
3390
|
+
flat_lists: bool = False,
|
|
3391
|
+
) -> PaginatedResponse[dict[str, Any]]:
|
|
3323
3392
|
"""List Product Service Codes (`/api/psc/`)."""
|
|
3324
3393
|
params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
|
|
3394
|
+
if shape:
|
|
3395
|
+
params["shape"] = shape
|
|
3396
|
+
if flat:
|
|
3397
|
+
params["flat"] = "true"
|
|
3398
|
+
if flat_lists:
|
|
3399
|
+
params["flat_lists"] = "true"
|
|
3325
3400
|
data = self._get("/api/psc/", params)
|
|
3326
3401
|
return PaginatedResponse(
|
|
3327
3402
|
count=int(data.get("count", 0)),
|
|
@@ -3330,11 +3405,24 @@ class TangoClient:
|
|
|
3330
3405
|
results=list(data.get("results") or []),
|
|
3331
3406
|
)
|
|
3332
3407
|
|
|
3333
|
-
def get_psc(
|
|
3408
|
+
def get_psc(
|
|
3409
|
+
self,
|
|
3410
|
+
code: str,
|
|
3411
|
+
shape: str | None = None,
|
|
3412
|
+
flat: bool = False,
|
|
3413
|
+
flat_lists: bool = False,
|
|
3414
|
+
) -> dict[str, Any]:
|
|
3334
3415
|
"""Get a Product Service Code by code (`/api/psc/{code}/`)."""
|
|
3335
3416
|
if not code:
|
|
3336
3417
|
raise TangoValidationError("PSC code is required")
|
|
3337
|
-
|
|
3418
|
+
params: dict[str, Any] = {}
|
|
3419
|
+
if shape:
|
|
3420
|
+
params["shape"] = shape
|
|
3421
|
+
if flat:
|
|
3422
|
+
params["flat"] = "true"
|
|
3423
|
+
if flat_lists:
|
|
3424
|
+
params["flat_lists"] = "true"
|
|
3425
|
+
return self._get(f"/api/psc/{code}/", params)
|
|
3338
3426
|
|
|
3339
3427
|
def get_psc_metrics(self, code: str, months: int, period_grouping: str) -> dict[str, Any]:
|
|
3340
3428
|
"""Get rolling PSC metrics (`/api/psc/{code}/metrics/{months}/{period_grouping}/`).
|
|
@@ -3349,11 +3437,24 @@ class TangoClient:
|
|
|
3349
3437
|
raise TangoValidationError("PSC code is required")
|
|
3350
3438
|
return self._get(f"/api/psc/{code}/metrics/{months}/{period_grouping}/")
|
|
3351
3439
|
|
|
3352
|
-
def get_naics(
|
|
3440
|
+
def get_naics(
|
|
3441
|
+
self,
|
|
3442
|
+
code: str,
|
|
3443
|
+
shape: str | None = None,
|
|
3444
|
+
flat: bool = False,
|
|
3445
|
+
flat_lists: bool = False,
|
|
3446
|
+
) -> dict[str, Any]:
|
|
3353
3447
|
"""Get a NAICS code by code (`/api/naics/{code}/`)."""
|
|
3354
3448
|
if not code:
|
|
3355
3449
|
raise TangoValidationError("NAICS code is required")
|
|
3356
|
-
|
|
3450
|
+
params: dict[str, Any] = {}
|
|
3451
|
+
if shape:
|
|
3452
|
+
params["shape"] = shape
|
|
3453
|
+
if flat:
|
|
3454
|
+
params["flat"] = "true"
|
|
3455
|
+
if flat_lists:
|
|
3456
|
+
params["flat_lists"] = "true"
|
|
3457
|
+
return self._get(f"/api/naics/{code}/", params)
|
|
3357
3458
|
|
|
3358
3459
|
def get_naics_metrics(self, code: str, months: int, period_grouping: str) -> dict[str, Any]:
|
|
3359
3460
|
"""Get rolling NAICS metrics (`/api/naics/{code}/metrics/{months}/{period_grouping}/`)."""
|
|
@@ -3361,17 +3462,41 @@ class TangoClient:
|
|
|
3361
3462
|
raise TangoValidationError("NAICS code is required")
|
|
3362
3463
|
return self._get(f"/api/naics/{code}/metrics/{months}/{period_grouping}/")
|
|
3363
3464
|
|
|
3364
|
-
def get_business_type(
|
|
3465
|
+
def get_business_type(
|
|
3466
|
+
self,
|
|
3467
|
+
code: str,
|
|
3468
|
+
shape: str | None = None,
|
|
3469
|
+
flat: bool = False,
|
|
3470
|
+
flat_lists: bool = False,
|
|
3471
|
+
) -> dict[str, Any]:
|
|
3365
3472
|
"""Get a business type by code (`/api/business_types/{code}/`)."""
|
|
3366
3473
|
if not code:
|
|
3367
3474
|
raise TangoValidationError("Business type code is required")
|
|
3368
|
-
|
|
3475
|
+
params: dict[str, Any] = {}
|
|
3476
|
+
if shape:
|
|
3477
|
+
params["shape"] = shape
|
|
3478
|
+
if flat:
|
|
3479
|
+
params["flat"] = "true"
|
|
3480
|
+
if flat_lists:
|
|
3481
|
+
params["flat_lists"] = "true"
|
|
3482
|
+
return self._get(f"/api/business_types/{code}/", params)
|
|
3369
3483
|
|
|
3370
3484
|
def list_assistance_listings(
|
|
3371
|
-
self,
|
|
3485
|
+
self,
|
|
3486
|
+
page: int = 1,
|
|
3487
|
+
limit: int = 25,
|
|
3488
|
+
shape: str | None = None,
|
|
3489
|
+
flat: bool = False,
|
|
3490
|
+
flat_lists: bool = False,
|
|
3372
3491
|
) -> PaginatedResponse[dict[str, Any]]:
|
|
3373
3492
|
"""List Assistance Listings (CFDA programs) (`/api/assistance_listings/`)."""
|
|
3374
3493
|
params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
|
|
3494
|
+
if shape:
|
|
3495
|
+
params["shape"] = shape
|
|
3496
|
+
if flat:
|
|
3497
|
+
params["flat"] = "true"
|
|
3498
|
+
if flat_lists:
|
|
3499
|
+
params["flat_lists"] = "true"
|
|
3375
3500
|
data = self._get("/api/assistance_listings/", params)
|
|
3376
3501
|
return PaginatedResponse(
|
|
3377
3502
|
count=int(data.get("count", 0)),
|
|
@@ -3380,22 +3505,44 @@ class TangoClient:
|
|
|
3380
3505
|
results=list(data.get("results") or []),
|
|
3381
3506
|
)
|
|
3382
3507
|
|
|
3383
|
-
def get_assistance_listing(
|
|
3508
|
+
def get_assistance_listing(
|
|
3509
|
+
self,
|
|
3510
|
+
number: str,
|
|
3511
|
+
shape: str | None = None,
|
|
3512
|
+
flat: bool = False,
|
|
3513
|
+
flat_lists: bool = False,
|
|
3514
|
+
) -> dict[str, Any]:
|
|
3384
3515
|
"""Get an Assistance Listing by CFDA number (`/api/assistance_listings/{number}/`)."""
|
|
3385
3516
|
if not number:
|
|
3386
3517
|
raise TangoValidationError("Assistance listing number is required")
|
|
3387
|
-
|
|
3518
|
+
params: dict[str, Any] = {}
|
|
3519
|
+
if shape:
|
|
3520
|
+
params["shape"] = shape
|
|
3521
|
+
if flat:
|
|
3522
|
+
params["flat"] = "true"
|
|
3523
|
+
if flat_lists:
|
|
3524
|
+
params["flat_lists"] = "true"
|
|
3525
|
+
return self._get(f"/api/assistance_listings/{number}/", params)
|
|
3388
3526
|
|
|
3389
3527
|
def list_mas_sins(
|
|
3390
3528
|
self,
|
|
3391
3529
|
page: int = 1,
|
|
3392
3530
|
limit: int = 25,
|
|
3393
3531
|
search: str | None = None,
|
|
3532
|
+
shape: str | None = None,
|
|
3533
|
+
flat: bool = False,
|
|
3534
|
+
flat_lists: bool = False,
|
|
3394
3535
|
) -> PaginatedResponse[dict[str, Any]]:
|
|
3395
3536
|
"""List GSA MAS SINs (`/api/mas_sins/`)."""
|
|
3396
3537
|
params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
|
|
3397
3538
|
if search is not None:
|
|
3398
3539
|
params["search"] = search
|
|
3540
|
+
if shape:
|
|
3541
|
+
params["shape"] = shape
|
|
3542
|
+
if flat:
|
|
3543
|
+
params["flat"] = "true"
|
|
3544
|
+
if flat_lists:
|
|
3545
|
+
params["flat_lists"] = "true"
|
|
3399
3546
|
data = self._get("/api/mas_sins/", params)
|
|
3400
3547
|
return PaginatedResponse(
|
|
3401
3548
|
count=int(data.get("count", 0)),
|
|
@@ -3404,11 +3551,24 @@ class TangoClient:
|
|
|
3404
3551
|
results=list(data.get("results") or []),
|
|
3405
3552
|
)
|
|
3406
3553
|
|
|
3407
|
-
def get_mas_sin(
|
|
3554
|
+
def get_mas_sin(
|
|
3555
|
+
self,
|
|
3556
|
+
sin: str,
|
|
3557
|
+
shape: str | None = None,
|
|
3558
|
+
flat: bool = False,
|
|
3559
|
+
flat_lists: bool = False,
|
|
3560
|
+
) -> dict[str, Any]:
|
|
3408
3561
|
"""Get a MAS SIN by code (`/api/mas_sins/{sin}/`)."""
|
|
3409
3562
|
if not sin:
|
|
3410
3563
|
raise TangoValidationError("MAS SIN is required")
|
|
3411
|
-
|
|
3564
|
+
params: dict[str, Any] = {}
|
|
3565
|
+
if shape:
|
|
3566
|
+
params["shape"] = shape
|
|
3567
|
+
if flat:
|
|
3568
|
+
params["flat"] = "true"
|
|
3569
|
+
if flat_lists:
|
|
3570
|
+
params["flat_lists"] = "true"
|
|
3571
|
+
return self._get(f"/api/mas_sins/{sin}/", params)
|
|
3412
3572
|
|
|
3413
3573
|
# ============================================================================
|
|
3414
3574
|
# Entity sub-resources
|
tango/shapes/factory.py
CHANGED
|
@@ -23,7 +23,7 @@ import logging
|
|
|
23
23
|
from collections.abc import Callable
|
|
24
24
|
from datetime import date, datetime
|
|
25
25
|
from decimal import Decimal
|
|
26
|
-
from typing import Any
|
|
26
|
+
from typing import Any, cast
|
|
27
27
|
|
|
28
28
|
from tango.exceptions import ModelInstantiationError
|
|
29
29
|
from tango.shapes.generator import TypeGenerator
|
|
@@ -542,38 +542,6 @@ class ModelFactory:
|
|
|
542
542
|
# Value is not a dict - might be a primitive or None
|
|
543
543
|
result[result_field_name] = value
|
|
544
544
|
|
|
545
|
-
elif field_spec.is_wildcard:
|
|
546
|
-
# Wildcard on nested field - use full model type
|
|
547
|
-
# This is handled at the top level, but we need to handle it here too
|
|
548
|
-
# for nested wildcards like recipient(*)
|
|
549
|
-
if field_schema.nested_model:
|
|
550
|
-
if field_schema.is_list:
|
|
551
|
-
if isinstance(value, list):
|
|
552
|
-
nested_instances = []
|
|
553
|
-
for item in value:
|
|
554
|
-
if isinstance(item, dict):
|
|
555
|
-
# Parse all fields from the nested model
|
|
556
|
-
nested_instance = self._parse_nested_wildcard(
|
|
557
|
-
item, field_schema.nested_model
|
|
558
|
-
)
|
|
559
|
-
nested_instances.append(nested_instance)
|
|
560
|
-
else:
|
|
561
|
-
nested_instances.append(item)
|
|
562
|
-
result[result_field_name] = nested_instances
|
|
563
|
-
else:
|
|
564
|
-
result[result_field_name] = value
|
|
565
|
-
else:
|
|
566
|
-
if isinstance(value, dict):
|
|
567
|
-
nested_instance = self._parse_nested_wildcard(
|
|
568
|
-
value, field_schema.nested_model
|
|
569
|
-
)
|
|
570
|
-
result[result_field_name] = nested_instance
|
|
571
|
-
else:
|
|
572
|
-
result[result_field_name] = value
|
|
573
|
-
else:
|
|
574
|
-
# Not a nested model, just use the value
|
|
575
|
-
result[result_field_name] = value
|
|
576
|
-
|
|
577
545
|
else:
|
|
578
546
|
# Simple field - parse using appropriate parser
|
|
579
547
|
parsed_value = self._parse_field(
|
|
@@ -661,7 +629,7 @@ class ModelFactory:
|
|
|
661
629
|
raise ModelInstantiationError(
|
|
662
630
|
f"Could not resolve nested model '{nested_model}'"
|
|
663
631
|
)
|
|
664
|
-
return model_class
|
|
632
|
+
return cast(type, model_class)
|
|
665
633
|
except ImportError as err:
|
|
666
634
|
raise ModelInstantiationError(
|
|
667
635
|
f"Could not import models module to resolve '{nested_model}'"
|
|
@@ -704,41 +672,6 @@ class ModelFactory:
|
|
|
704
672
|
# Recursively create nested instance
|
|
705
673
|
return self.create_instance(data, nested_shape, resolved_model, nested_type)
|
|
706
674
|
|
|
707
|
-
def _parse_nested_wildcard(
|
|
708
|
-
self, data: dict[str, Any], nested_model: type | str
|
|
709
|
-
) -> dict[str, Any]:
|
|
710
|
-
"""Parse nested object with wildcard (all fields)
|
|
711
|
-
|
|
712
|
-
Args:
|
|
713
|
-
data: Nested object data
|
|
714
|
-
nested_model: Model class or string name for the nested object
|
|
715
|
-
|
|
716
|
-
Returns:
|
|
717
|
-
Dictionary with all parsed fields
|
|
718
|
-
"""
|
|
719
|
-
# Resolve nested model if it's a string
|
|
720
|
-
resolved_model = self._resolve_nested_model(nested_model)
|
|
721
|
-
|
|
722
|
-
# Ensure model is registered
|
|
723
|
-
if not self.schema_registry.is_registered(resolved_model):
|
|
724
|
-
self.schema_registry.register(resolved_model)
|
|
725
|
-
|
|
726
|
-
# Get model schema
|
|
727
|
-
model_schema = self.schema_registry.get_schema(resolved_model)
|
|
728
|
-
|
|
729
|
-
# Parse all fields
|
|
730
|
-
result: dict[str, Any] = {}
|
|
731
|
-
for field_name, value in data.items():
|
|
732
|
-
if field_name in model_schema:
|
|
733
|
-
field_schema = model_schema[field_name]
|
|
734
|
-
parsed_value = self._parse_field(field_name, value, field_schema.type, field_schema)
|
|
735
|
-
result[field_name] = parsed_value
|
|
736
|
-
else:
|
|
737
|
-
# Field not in schema, include as-is
|
|
738
|
-
result[field_name] = value
|
|
739
|
-
|
|
740
|
-
return result
|
|
741
|
-
|
|
742
675
|
def _parse_field(self, field_name: str, value: Any, field_type: type, field_schema: Any) -> Any:
|
|
743
676
|
"""Parse a single field value using appropriate parser
|
|
744
677
|
|
|
@@ -778,7 +711,7 @@ class ModelFactory:
|
|
|
778
711
|
return value
|
|
779
712
|
|
|
780
713
|
def validate_data(
|
|
781
|
-
self, data: dict[str, Any], shape_spec: ShapeSpec, base_model: type
|
|
714
|
+
self, data: dict[str, Any], shape_spec: ShapeSpec, base_model: type | str
|
|
782
715
|
) -> list[str]:
|
|
783
716
|
"""Validate that data matches the shape specification
|
|
784
717
|
|
|
@@ -803,11 +736,15 @@ class ModelFactory:
|
|
|
803
736
|
errors: list[str] = []
|
|
804
737
|
|
|
805
738
|
if not isinstance(data, dict):
|
|
806
|
-
errors.append(
|
|
739
|
+
errors.append( # type: ignore[unreachable]
|
|
740
|
+
f"Expected dictionary data, got {type(data).__name__}"
|
|
741
|
+
)
|
|
807
742
|
return errors
|
|
808
743
|
|
|
809
|
-
# Ensure model is registered
|
|
810
|
-
|
|
744
|
+
# Ensure model is registered. String model names are expected to be
|
|
745
|
+
# pre-registered (explicit schemas); only concrete classes can be
|
|
746
|
+
# auto-registered via introspection.
|
|
747
|
+
if isinstance(base_model, type) and not self.schema_registry.is_registered(base_model):
|
|
811
748
|
self.schema_registry.register(base_model)
|
|
812
749
|
|
|
813
750
|
# Get model schema
|
|
@@ -826,9 +763,8 @@ class ModelFactory:
|
|
|
826
763
|
|
|
827
764
|
# Check if field exists in schema
|
|
828
765
|
if field_spec.name not in model_schema:
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
)
|
|
766
|
+
model_name = base_model.__name__ if isinstance(base_model, type) else base_model
|
|
767
|
+
errors.append(f"Field '{field_spec.name}' does not exist in {model_name} schema")
|
|
832
768
|
continue
|
|
833
769
|
|
|
834
770
|
field_schema = model_schema[field_spec.name]
|
tango/shapes/generator.py
CHANGED
|
@@ -20,7 +20,7 @@ Examples:
|
|
|
20
20
|
import logging
|
|
21
21
|
import threading
|
|
22
22
|
from collections import OrderedDict
|
|
23
|
-
from typing import Any, get_args, get_origin, get_type_hints
|
|
23
|
+
from typing import Any, cast, get_args, get_origin, get_type_hints
|
|
24
24
|
|
|
25
25
|
from tango.exceptions import TypeGenerationError
|
|
26
26
|
from tango.shapes.models import ShapeSpec
|
|
@@ -250,7 +250,10 @@ class TypeGenerator:
|
|
|
250
250
|
|
|
251
251
|
field_schema = model_schema[field_spec.name]
|
|
252
252
|
|
|
253
|
-
# Determine field type
|
|
253
|
+
# Determine field type. The value is a heterogeneous mix of type
|
|
254
|
+
# objects, parameterized generics (list[...]), and union objects,
|
|
255
|
+
# so it is intentionally typed as Any.
|
|
256
|
+
field_type: Any
|
|
254
257
|
if field_spec.nested_fields:
|
|
255
258
|
# Generate nested type
|
|
256
259
|
if not field_schema.nested_model:
|
|
@@ -275,25 +278,7 @@ class TypeGenerator:
|
|
|
275
278
|
|
|
276
279
|
# Handle optional types
|
|
277
280
|
if field_schema.is_optional:
|
|
278
|
-
field_type = field_type | None
|
|
279
|
-
|
|
280
|
-
annotations[field_name] = field_type
|
|
281
|
-
|
|
282
|
-
elif field_spec.is_wildcard:
|
|
283
|
-
# Wildcard on nested field - use full model type
|
|
284
|
-
if field_schema.nested_model:
|
|
285
|
-
# Resolve nested model if it's a string
|
|
286
|
-
field_type = self._resolve_nested_model(field_schema.nested_model)
|
|
287
|
-
else:
|
|
288
|
-
field_type = field_schema.type
|
|
289
|
-
|
|
290
|
-
# Handle list types
|
|
291
|
-
if field_schema.is_list:
|
|
292
|
-
field_type = list[field_type] # type: ignore
|
|
293
|
-
|
|
294
|
-
# Handle optional types
|
|
295
|
-
if field_schema.is_optional:
|
|
296
|
-
field_type = field_type | None # type: ignore
|
|
281
|
+
field_type = field_type | None
|
|
297
282
|
|
|
298
283
|
annotations[field_name] = field_type
|
|
299
284
|
|
|
@@ -303,11 +288,11 @@ class TypeGenerator:
|
|
|
303
288
|
|
|
304
289
|
# Handle list types
|
|
305
290
|
if field_schema.is_list:
|
|
306
|
-
field_type = list[field_type]
|
|
291
|
+
field_type = list[field_type]
|
|
307
292
|
|
|
308
293
|
# Handle optional types
|
|
309
294
|
if field_schema.is_optional:
|
|
310
|
-
field_type = field_type | None
|
|
295
|
+
field_type = field_type | None
|
|
311
296
|
|
|
312
297
|
annotations[field_name] = field_type
|
|
313
298
|
|
|
@@ -329,7 +314,7 @@ class TypeGenerator:
|
|
|
329
314
|
field_type = field_schema.type
|
|
330
315
|
# Handle optional types
|
|
331
316
|
if field_schema.is_optional:
|
|
332
|
-
field_type = field_type | None
|
|
317
|
+
field_type = field_type | None
|
|
333
318
|
annotations[auto_field] = field_type
|
|
334
319
|
|
|
335
320
|
# Create TypedDict dynamically
|
|
@@ -414,7 +399,7 @@ class TypeGenerator:
|
|
|
414
399
|
model_class = getattr(models, nested_model, None)
|
|
415
400
|
if model_class is None:
|
|
416
401
|
raise TypeGenerationError(f"Could not resolve nested model '{nested_model}'")
|
|
417
|
-
return model_class
|
|
402
|
+
return cast(type, model_class)
|
|
418
403
|
except ImportError as err:
|
|
419
404
|
raise TypeGenerationError(
|
|
420
405
|
f"Could not import models module to resolve '{nested_model}'"
|
|
@@ -555,7 +540,7 @@ class TypeGenerator:
|
|
|
555
540
|
|
|
556
541
|
# Handle basic types
|
|
557
542
|
if hasattr(type_annotation, "__name__"):
|
|
558
|
-
type_name = type_annotation.__name__
|
|
543
|
+
type_name = str(type_annotation.__name__)
|
|
559
544
|
else:
|
|
560
545
|
type_name = str(type_annotation)
|
|
561
546
|
|
|
@@ -576,7 +561,7 @@ class TypeGenerator:
|
|
|
576
561
|
if args:
|
|
577
562
|
formatted_args = [self._format_type_annotation(arg) for arg in args]
|
|
578
563
|
return f"{origin.__name__}[{', '.join(formatted_args)}]"
|
|
579
|
-
return origin.__name__
|
|
564
|
+
return str(origin.__name__)
|
|
580
565
|
|
|
581
566
|
return type_name
|
|
582
567
|
|
tango/shapes/parser.py
CHANGED
|
@@ -110,7 +110,7 @@ def _suggest_field_correction(invalid_field: str, valid_fields: list[str]) -> st
|
|
|
110
110
|
|
|
111
111
|
# Check for common prefix
|
|
112
112
|
best_match = None
|
|
113
|
-
best_score = 0
|
|
113
|
+
best_score = 0.0
|
|
114
114
|
|
|
115
115
|
for field in valid_fields:
|
|
116
116
|
# Count common prefix length
|
|
@@ -167,6 +167,13 @@ class ShapeParser:
|
|
|
167
167
|
self._schema_registry = schema_registry
|
|
168
168
|
self._schema_registry_initialized = schema_registry is not None
|
|
169
169
|
|
|
170
|
+
def _ensure_registry(self) -> SchemaRegistry:
|
|
171
|
+
"""Return the schema registry, lazily creating it on first use."""
|
|
172
|
+
if self._schema_registry is None:
|
|
173
|
+
self._schema_registry = SchemaRegistry()
|
|
174
|
+
self._schema_registry_initialized = True
|
|
175
|
+
return self._schema_registry
|
|
176
|
+
|
|
170
177
|
def parse(self, shape: str) -> ShapeSpec:
|
|
171
178
|
"""Parse a shape string into a ShapeSpec
|
|
172
179
|
|
|
@@ -544,25 +551,22 @@ class ShapeParser:
|
|
|
544
551
|
>>> spec = parser.parse("invalid_field")
|
|
545
552
|
>>> parser.validate(spec, Contract) # Raises ShapeValidationError
|
|
546
553
|
"""
|
|
547
|
-
|
|
548
|
-
if not self._schema_registry_initialized:
|
|
549
|
-
self._schema_registry = SchemaRegistry()
|
|
550
|
-
self._schema_registry_initialized = True
|
|
554
|
+
registry = self._ensure_registry()
|
|
551
555
|
|
|
552
556
|
# Ensure model is registered
|
|
553
|
-
if not
|
|
554
|
-
|
|
557
|
+
if not registry.is_registered(model_class):
|
|
558
|
+
registry.register(model_class)
|
|
555
559
|
|
|
556
560
|
# Validate each field
|
|
557
561
|
for field_spec in shape_spec.fields:
|
|
558
562
|
self._validate_field_spec(field_spec, model_class)
|
|
559
563
|
|
|
560
|
-
def _validate_field_spec(self, field_spec: FieldSpec, model_class: type) -> None:
|
|
564
|
+
def _validate_field_spec(self, field_spec: FieldSpec, model_class: type | str) -> None:
|
|
561
565
|
"""Validate a single field specification against a model
|
|
562
566
|
|
|
563
567
|
Args:
|
|
564
568
|
field_spec: Field specification to validate
|
|
565
|
-
model_class: Model class to validate against
|
|
569
|
+
model_class: Model class (or registered model name) to validate against
|
|
566
570
|
|
|
567
571
|
Raises:
|
|
568
572
|
ShapeValidationError: If field is invalid
|
|
@@ -571,20 +575,17 @@ class ShapeParser:
|
|
|
571
575
|
if field_spec.is_wildcard:
|
|
572
576
|
return
|
|
573
577
|
|
|
574
|
-
|
|
575
|
-
if not self._schema_registry_initialized:
|
|
576
|
-
self._schema_registry = SchemaRegistry()
|
|
577
|
-
self._schema_registry_initialized = True
|
|
578
|
+
registry = self._ensure_registry()
|
|
578
579
|
|
|
579
580
|
# Validate field exists in model
|
|
580
581
|
try:
|
|
581
|
-
field_schema =
|
|
582
|
+
field_schema = registry.validate_field(model_class, field_spec.name)
|
|
582
583
|
except ShapeValidationError as e:
|
|
583
584
|
# Enhance error message with suggestions
|
|
584
585
|
model_name = (
|
|
585
586
|
model_class.__name__ if hasattr(model_class, "__name__") else str(model_class)
|
|
586
587
|
)
|
|
587
|
-
model_schema =
|
|
588
|
+
model_schema = registry.get_schema(model_class)
|
|
588
589
|
valid_fields = list(model_schema.keys())
|
|
589
590
|
|
|
590
591
|
error_msg = f"Field '{field_spec.name}' does not exist in {model_name}."
|
|
@@ -630,7 +631,7 @@ class ShapeParser:
|
|
|
630
631
|
error_msg += "\n\nNested selections are only valid for object fields like 'recipient', 'agency', 'location', etc."
|
|
631
632
|
|
|
632
633
|
# Find some nested fields as examples
|
|
633
|
-
model_schema =
|
|
634
|
+
model_schema = registry.get_schema(model_class)
|
|
634
635
|
nested_examples = [
|
|
635
636
|
name for name, schema in model_schema.items() if schema.nested_model
|
|
636
637
|
]
|
tango/shapes/schema.py
CHANGED
|
@@ -8,6 +8,7 @@ the dynamic-only model approach. These schemas define field types, nested models
|
|
|
8
8
|
list indicators independently of the dataclass definitions.
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
+
import builtins
|
|
11
12
|
from dataclasses import dataclass
|
|
12
13
|
from typing import Any, get_args, get_origin, get_type_hints
|
|
13
14
|
|
|
@@ -33,10 +34,10 @@ class FieldSchema:
|
|
|
33
34
|
"""
|
|
34
35
|
|
|
35
36
|
name: str
|
|
36
|
-
type: type
|
|
37
|
+
type: builtins.type
|
|
37
38
|
is_optional: bool
|
|
38
39
|
is_list: bool
|
|
39
|
-
nested_model: type | None = None
|
|
40
|
+
nested_model: builtins.type | str | None = None
|
|
40
41
|
|
|
41
42
|
def __repr__(self) -> str:
|
|
42
43
|
"""String representation for debugging"""
|
|
@@ -1,22 +1,22 @@
|
|
|
1
|
-
tango/__init__.py,sha256=
|
|
2
|
-
tango/client.py,sha256=
|
|
1
|
+
tango/__init__.py,sha256=GFajAGv2H7jhE0yuU7wJk8qOqEARV7Mg2bFIelwsodE,1912
|
|
2
|
+
tango/client.py,sha256=3rZO03j-it7X0BP1EDJSpj5MVOV8JXtnC7AL2pijobQ,148758
|
|
3
3
|
tango/exceptions.py,sha256=aRvDm0dUCEtNDfRVYCX7SEDdd1WlIVVY6sN78Tzo-a0,3114
|
|
4
4
|
tango/models.py,sha256=QqUPtO7HJJDUaJDAMUkzvqR7pj1YIr8BetbS4palxc0,34261
|
|
5
5
|
tango/shapes/__init__.py,sha256=7ea1WU74jp4znhNw-gXruag6m6eyPZtbVgbDFmFUWro,1072
|
|
6
6
|
tango/shapes/explicit_schemas.py,sha256=8HgxKpAi2U80JP_MnABnW1JpYJA05aoOZSJsJIcyIWQ,71421
|
|
7
|
-
tango/shapes/factory.py,sha256=
|
|
8
|
-
tango/shapes/generator.py,sha256=
|
|
7
|
+
tango/shapes/factory.py,sha256=uDPhnp6VVzrLBKodxteZy5AcU43juEv-qKVRWth51j0,31550
|
|
8
|
+
tango/shapes/generator.py,sha256=xzakpwTwqTMUr89I06hp54-FIYZziviWHk2Glscg-LM,22775
|
|
9
9
|
tango/shapes/models.py,sha256=h3pIhOqrrdlN953Y6r0oney5HFbKPOD-frRndRWimJ0,3018
|
|
10
|
-
tango/shapes/parser.py,sha256
|
|
11
|
-
tango/shapes/schema.py,sha256=
|
|
10
|
+
tango/shapes/parser.py,sha256=AecNx5oT_hV_pf2AWZnsR8Y2aLmCtjfgS9-znA2Pesc,27429
|
|
11
|
+
tango/shapes/schema.py,sha256=ZdiNuqWCRhmpyLag8xtlQcoyyqvkpDqrr7xu2YbhaOY,14176
|
|
12
12
|
tango/shapes/types.py,sha256=27jrAE0VIdrKaLjR_FK71hfIIGX2Tg3ex7REEBV1TFE,1301
|
|
13
13
|
tango/webhooks/__init__.py,sha256=3bbiiGoB3s5iqqmQceroN0-MCSm-ZOZQx3M6JAknIUo,774
|
|
14
14
|
tango/webhooks/cli.py,sha256=f_vQbJ2AeSQjKnQo7MxHFyUB2SAKcgHYmGQULS0isqQ,13858
|
|
15
15
|
tango/webhooks/receiver.py,sha256=5yhsVhlLyoxmOCGvmbynWAIlDB2OaCPVf1H4GA1SxmU,9279
|
|
16
16
|
tango/webhooks/signing.py,sha256=92Ee-0B6PR7ZkvY3Np3gzl88-mtfKkh-I7lxqCe2lGw,2374
|
|
17
17
|
tango/webhooks/simulate.py,sha256=g2Osa0FYU5mJuon07T2aUCtmkUoTEzsY261tlp76fF0,3165
|
|
18
|
-
tango_python-1.1.
|
|
19
|
-
tango_python-1.1.
|
|
20
|
-
tango_python-1.1.
|
|
21
|
-
tango_python-1.1.
|
|
22
|
-
tango_python-1.1.
|
|
18
|
+
tango_python-1.1.3.dist-info/METADATA,sha256=dvg8fyZFIcwz6xkS1J40HFHjtAxtIAfsolxz0RIM3aw,20599
|
|
19
|
+
tango_python-1.1.3.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
20
|
+
tango_python-1.1.3.dist-info/entry_points.txt,sha256=kGLUbglUjuaAqEFvOZ1QuSW0vWb6VeSpCIFKaOFkKoQ,50
|
|
21
|
+
tango_python-1.1.3.dist-info/licenses/LICENSE,sha256=j2kYVHMwTkoGn3ZNScnrdIueG0k2XzB_LCPFoyBc2wk,1064
|
|
22
|
+
tango_python-1.1.3.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|