tango-python 1.1.0__py3-none-any.whl → 1.1.2__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 CHANGED
@@ -44,7 +44,7 @@ from .webhooks import (
44
44
  )
45
45
  from .webhooks.receiver import Delivery, WebhookReceiver
46
46
 
47
- __version__ = "1.1.0"
47
+ __version__ = "1.1.2"
48
48
  __all__ = [
49
49
  "TangoClient",
50
50
  "TangoAPIError",
tango/client.py CHANGED
@@ -2081,11 +2081,36 @@ class TangoClient:
2081
2081
  data = self._get(f"/api/entities/{key}/", params)
2082
2082
  return self._parse_response_with_shape(data, shape, Entity, flat, flat_lists)
2083
2083
 
2084
- def get_entity_budget_flows(self, uei: str) -> dict[str, Any]:
2085
- """Get budget flows for an entity (`/api/entities/{uei}/budget-flows/`)."""
2084
+ def get_entity_budget_flows(
2085
+ self,
2086
+ uei: str,
2087
+ page: int = 1,
2088
+ limit: int = 25,
2089
+ fiscal_year: int | None = None,
2090
+ ) -> PaginatedResponse[dict[str, Any]]:
2091
+ """Get budget flows for an entity (`/api/entities/{uei}/budget-flows/`).
2092
+
2093
+ Standard page/limit pagination (default 25, max 100). Each result row
2094
+ is a hand-built dict from the backend (no shape system).
2095
+
2096
+ Args:
2097
+ uei: Entity UEI. Required.
2098
+ page: Page number.
2099
+ limit: Results per page (max 100).
2100
+ fiscal_year: Optional fiscal year filter.
2101
+ """
2086
2102
  if not uei:
2087
2103
  raise TangoValidationError("UEI is required")
2088
- return self._get(f"/api/entities/{uei}/budget-flows/")
2104
+ params: dict[str, Any] = {"page": page, "limit": min(limit, 100)}
2105
+ if fiscal_year is not None:
2106
+ params["fiscal_year"] = fiscal_year
2107
+ data = self._get(f"/api/entities/{uei}/budget-flows/", params)
2108
+ return PaginatedResponse(
2109
+ count=int(data.get("count", 0)),
2110
+ next=data.get("next"),
2111
+ previous=data.get("previous"),
2112
+ results=list(data.get("results") or []),
2113
+ )
2089
2114
 
2090
2115
  # Forecast endpoints
2091
2116
  def list_forecasts(
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(f"Expected dictionary data, got {type(data).__name__}")
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
- if not self.schema_registry.is_registered(base_model):
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
- errors.append(
830
- f"Field '{field_spec.name}' does not exist in {base_model.__name__} schema"
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 # type: ignore
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] # type: ignore
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 # type: ignore
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 # type: ignore
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
- # Lazy initialize schema registry
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 self._schema_registry.is_registered(model_class):
554
- self._schema_registry.register(model_class)
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
- # Lazy initialize schema registry if needed
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 = self._schema_registry.validate_field(model_class, field_spec.name)
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 = self._schema_registry.get_schema(model_class)
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 = self._schema_registry.get_schema(model_class)
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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tango-python
3
- Version: 1.1.0
3
+ Version: 1.1.2
4
4
  Summary: Python SDK for the Tango API
5
5
  Project-URL: Homepage, https://github.com/makegov/tango-python
6
6
  Project-URL: Documentation, https://docs.makegov.com/tango-python
@@ -48,9 +48,6 @@ Requires-Dist: pytest>=8.0; extra == 'dev'
48
48
  Requires-Dist: python-dotenv>=1.0.0; extra == 'dev'
49
49
  Requires-Dist: pyyaml>=6.0; extra == 'dev'
50
50
  Requires-Dist: ruff>=0.3.0; extra == 'dev'
51
- Provides-Extra: notebooks
52
- Requires-Dist: ipykernel>=6.25.0; extra == 'notebooks'
53
- Requires-Dist: jupyter>=1.0.0; extra == 'notebooks'
54
51
  Provides-Extra: webhooks
55
52
  Requires-Dist: click>=8.1; extra == 'webhooks'
56
53
  Description-Content-Type: text/markdown
@@ -1,22 +1,22 @@
1
- tango/__init__.py,sha256=izUkwgWLTAsCiBQaJ67N_bmnWnc7uS2DUvSESzmeSFs,1912
2
- tango/client.py,sha256=CAejxkwhyMwisVMGSdVqlKGkSNfNT7G60MtZb6BtEYk,143984
1
+ tango/__init__.py,sha256=gg4U46hFhUJwgvbGtOdP3vgooi8US-fLXvpBZ060_H8,1912
2
+ tango/client.py,sha256=9-IfMZwerx4uRsPlDI38uQDv77TNl2qShCHVtP4t7EU,144839
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=ytpMi5Uw72XZ8MimhuSsLDVXF3zO_Zt3_tAL6NF7LnU,34318
8
- tango/shapes/generator.py,sha256=61V1T3lm8Ps_KSMJAezQJLQVFbNKt1jtoLyhiqNtFTs,23380
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=-2Ap5jgeAvKsKtA-MaXPGE6PRB93GPV8GK99Z0geW_s,27468
11
- tango/shapes/schema.py,sha256=VRPOB1sBdjFyimNchrZKIpTHn83CyX4RfU9077aQtIU,14136
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.0.dist-info/METADATA,sha256=Oun2OfqXz2cZ3i4kOBkiZeMcZJxAwOsjvoHdUHOKjsg,20732
19
- tango_python-1.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
20
- tango_python-1.1.0.dist-info/entry_points.txt,sha256=kGLUbglUjuaAqEFvOZ1QuSW0vWb6VeSpCIFKaOFkKoQ,50
21
- tango_python-1.1.0.dist-info/licenses/LICENSE,sha256=j2kYVHMwTkoGn3ZNScnrdIueG0k2XzB_LCPFoyBc2wk,1064
22
- tango_python-1.1.0.dist-info/RECORD,,
18
+ tango_python-1.1.2.dist-info/METADATA,sha256=-wGAubnmnT1ysNyUThjVuYWx3p9IjDPf-4FQrxy756U,20599
19
+ tango_python-1.1.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
20
+ tango_python-1.1.2.dist-info/entry_points.txt,sha256=kGLUbglUjuaAqEFvOZ1QuSW0vWb6VeSpCIFKaOFkKoQ,50
21
+ tango_python-1.1.2.dist-info/licenses/LICENSE,sha256=j2kYVHMwTkoGn3ZNScnrdIueG0k2XzB_LCPFoyBc2wk,1064
22
+ tango_python-1.1.2.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: hatchling 1.29.0
2
+ Generator: hatchling 1.30.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any