data-filter-mcp 0.2.6__tar.gz → 0.2.7__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.
Files changed (32) hide show
  1. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/PKG-INFO +1 -1
  2. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp/_version.py +3 -3
  3. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp/validator.py +266 -18
  4. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp.egg-info/PKG-INFO +1 -1
  5. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/tests/test_validator.py +128 -0
  6. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/.github/workflows/ci.yml +0 -0
  7. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/.github/workflows/release.yml +0 -0
  8. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/.gitignore +0 -0
  9. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/LICENSE +0 -0
  10. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/README.md +0 -0
  11. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp/__init__.py +0 -0
  12. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp/loaders/__init__.py +0 -0
  13. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp/loaders/base.py +0 -0
  14. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp/loaders/factory.py +0 -0
  15. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp/loaders/json_loader.py +0 -0
  16. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp/loaders/txt_loader.py +0 -0
  17. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp/loaders/yaml_loader.py +0 -0
  18. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp/models.py +0 -0
  19. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp/registry.py +0 -0
  20. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp/server.py +0 -0
  21. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp.egg-info/SOURCES.txt +0 -0
  22. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp.egg-info/dependency_links.txt +0 -0
  23. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp.egg-info/entry_points.txt +0 -0
  24. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp.egg-info/requires.txt +0 -0
  25. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/data_filter_mcp.egg-info/top_level.txt +0 -0
  26. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/pyproject.toml +0 -0
  27. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/server.py +0 -0
  28. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/setup.cfg +0 -0
  29. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/tests/test_loaders.py +0 -0
  30. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/tests/test_mcp_tools.py +0 -0
  31. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/tests/test_registry.py +0 -0
  32. {data_filter_mcp-0.2.6 → data_filter_mcp-0.2.7}/tests/test_server_flow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: data-filter-mcp
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: Local MCP server for running restricted Python text filters over files
5
5
  Author: Aleksei Arakelian
6
6
  License-Expression: MIT
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.6'
22
- __version_tuple__ = version_tuple = (0, 2, 6)
21
+ __version__ = version = '0.2.7'
22
+ __version_tuple__ = version_tuple = (0, 2, 7)
23
23
 
24
- __commit_id__ = commit_id = 'ga0ff2aab6'
24
+ __commit_id__ = commit_id = 'g7eb330227'
@@ -60,6 +60,39 @@ SAFE_DUNDER_ATTRIBUTES = {
60
60
  "__name__",
61
61
  }
62
62
 
63
+ SAFE_PROPERTY_READS = {
64
+ "broadcast_address",
65
+ "compressed",
66
+ "exploded",
67
+ "hostmask",
68
+ "ip",
69
+ "ipv4_mapped",
70
+ "ipv6_mapped",
71
+ "is_global",
72
+ "is_link_local",
73
+ "is_loopback",
74
+ "is_multicast",
75
+ "is_private",
76
+ "is_reserved",
77
+ "is_site_local",
78
+ "is_unspecified",
79
+ "max_prefixlen",
80
+ "netmask",
81
+ "network",
82
+ "network_address",
83
+ "num_addresses",
84
+ "packed",
85
+ "prefixlen",
86
+ "reverse_pointer",
87
+ "scope_id",
88
+ "sixtofour",
89
+ "teredo",
90
+ "version",
91
+ "with_hostmask",
92
+ "with_netmask",
93
+ "with_prefixlen",
94
+ }
95
+
63
96
  SAFE_BUILTINS: dict[str, Any] = {
64
97
  "all": all,
65
98
  "any": any,
@@ -95,6 +128,7 @@ SAFE_METHODS = {
95
128
  "SequenceMatcher",
96
129
  "append",
97
130
  "appendleft",
131
+ "address_exclude",
98
132
  "abs",
99
133
  "accumulate",
100
134
  "add",
@@ -116,6 +150,7 @@ SAFE_METHODS = {
116
150
  "chain",
117
151
  "ChainMap",
118
152
  "cmp_to_key",
153
+ "collapse_addresses",
119
154
  "comb",
120
155
  "combinations",
121
156
  "combinations_with_replacement",
@@ -169,6 +204,7 @@ SAFE_METHODS = {
169
204
  "gcd",
170
205
  "ge",
171
206
  "geometric_mean",
207
+ "get_mixed_type_key",
172
208
  "get",
173
209
  "get_close_matches",
174
210
  "get_matching_blocks",
@@ -303,6 +339,7 @@ SAFE_METHODS = {
303
339
  "subnets",
304
340
  "subn",
305
341
  "subtract",
342
+ "summarize_address_range",
306
343
  "supernet",
307
344
  "supernet_of",
308
345
  "swapcase",
@@ -328,6 +365,8 @@ SAFE_METHODS = {
328
365
  "utcnow",
329
366
  "values",
330
367
  "variance",
368
+ "v4_int_to_packed",
369
+ "v6_int_to_packed",
331
370
  "weekday",
332
371
  "wrap",
333
372
  "wraps",
@@ -412,9 +451,66 @@ ALLOWED_NODE_TYPES = {
412
451
  }
413
452
 
414
453
 
454
+ def _source_fragment(node: ast.AST | None, max_length: int = 200) -> str | None:
455
+ if node is None:
456
+ return None
457
+ try:
458
+ fragment = ast.unparse(node)
459
+ except Exception:
460
+ return None
461
+ if len(fragment) > max_length:
462
+ return f"{fragment[: max_length - 3]}..."
463
+ return fragment
464
+
465
+
466
+ def _node_line(node: ast.AST | None) -> int | None:
467
+ return getattr(node, "lineno", None)
468
+
469
+
470
+ def _node_column(node: ast.AST | None) -> int | None:
471
+ return getattr(node, "col_offset", None)
472
+
473
+
415
474
  class FilterValidationError(ValueError):
416
475
  """Raised when submitted filter code breaks the policy."""
417
476
 
477
+ def __init__(
478
+ self,
479
+ message: str,
480
+ *,
481
+ blocked_kind: str | None = None,
482
+ blocked_field: str | None = None,
483
+ blocked_value: str | None = None,
484
+ source_fragment: str | None = None,
485
+ source_line: int | None = None,
486
+ source_column: int | None = None,
487
+ ) -> None:
488
+ self.blocked_kind = blocked_kind
489
+ self.blocked_field = blocked_field
490
+ self.blocked_value = blocked_value
491
+ self.source_fragment = source_fragment
492
+ self.source_line = source_line
493
+ self.source_column = source_column
494
+
495
+ details = []
496
+ if blocked_kind is not None:
497
+ details.append(f"blocked_kind={blocked_kind!r}")
498
+ if blocked_field is not None:
499
+ details.append(f"blocked_field={blocked_field!r}")
500
+ if blocked_value is not None:
501
+ details.append(f"blocked_value={blocked_value!r}")
502
+ if source_fragment is not None:
503
+ details.append(f"source_fragment={source_fragment!r}")
504
+ if source_line is not None:
505
+ details.append(f"source_line={source_line}")
506
+ if source_column is not None:
507
+ details.append(f"source_column={source_column}")
508
+
509
+ if details:
510
+ message = f"{message} ({', '.join(details)})"
511
+
512
+ super().__init__(message)
513
+
418
514
 
419
515
  class FilterValidator(ast.NodeVisitor):
420
516
  def __init__(self) -> None:
@@ -433,27 +529,66 @@ class FilterValidator(ast.NodeVisitor):
433
529
  def generic_visit(self, node: ast.AST) -> None:
434
530
  if type(node) not in ALLOWED_NODE_TYPES:
435
531
  raise FilterValidationError(
436
- f"{type(node).__name__} is not allowed in filter code"
532
+ f"{type(node).__name__} is not allowed in filter code",
533
+ blocked_kind="disallowed_node_type",
534
+ blocked_field="node_type",
535
+ blocked_value=type(node).__name__,
536
+ source_fragment=_source_fragment(node),
537
+ source_line=_node_line(node),
538
+ source_column=_node_column(node),
437
539
  )
438
540
  super().generic_visit(node)
439
541
 
440
542
  def visit_Module(self, node: ast.Module) -> None:
441
543
  if len(node.body) != 1 or not isinstance(node.body[0], ast.FunctionDef):
442
544
  raise FilterValidationError(
443
- "Filter code must contain exactly one top-level function definition"
545
+ "Filter code must contain exactly one top-level function definition",
546
+ blocked_kind="invalid_module_body",
547
+ blocked_field="top_level_nodes",
548
+ blocked_value=str(len(node.body)),
549
+ source_fragment=_source_fragment(node),
444
550
  )
445
551
  self.generic_visit(node)
446
552
 
447
553
  def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
448
554
  if node.decorator_list:
449
- raise FilterValidationError("Decorators are not allowed")
555
+ raise FilterValidationError(
556
+ "Decorators are not allowed",
557
+ blocked_kind="disallowed_decorator",
558
+ blocked_field="decorator",
559
+ blocked_value=", ".join(
560
+ fragment
561
+ for fragment in (
562
+ _source_fragment(decorator)
563
+ for decorator in node.decorator_list
564
+ )
565
+ if fragment is not None
566
+ ),
567
+ source_fragment=_source_fragment(node),
568
+ source_line=_node_line(node),
569
+ source_column=_node_column(node),
570
+ )
450
571
  if node.returns is not None:
451
- raise FilterValidationError("Return annotations are not allowed")
572
+ raise FilterValidationError(
573
+ "Return annotations are not allowed",
574
+ blocked_kind="disallowed_return_annotation",
575
+ blocked_field="return_annotation",
576
+ blocked_value=_source_fragment(node.returns),
577
+ source_fragment=_source_fragment(node),
578
+ source_line=_node_line(node),
579
+ source_column=_node_column(node),
580
+ )
452
581
 
453
582
  if isinstance(self._parents.get(id(node)), ast.Module):
454
583
  if node.name != "filter_item":
455
584
  raise FilterValidationError(
456
- "Filter function must be named filter_item"
585
+ "Filter function must be named filter_item",
586
+ blocked_kind="invalid_function_name",
587
+ blocked_field="function_name",
588
+ blocked_value=node.name,
589
+ source_fragment=_source_fragment(node),
590
+ source_line=_node_line(node),
591
+ source_column=_node_column(node),
457
592
  )
458
593
 
459
594
  args = node.args
@@ -467,25 +602,53 @@ class FilterValidator(ast.NodeVisitor):
467
602
  or args.kw_defaults
468
603
  ):
469
604
  raise FilterValidationError(
470
- "filter_item must have exactly one parameter named data"
605
+ "filter_item must have exactly one parameter named data",
606
+ blocked_kind="invalid_function_signature",
607
+ blocked_field="parameters",
608
+ blocked_value=self._signature_summary(args),
609
+ source_fragment=_source_fragment(node.args),
610
+ source_line=_node_line(node),
611
+ source_column=_node_column(node),
471
612
  )
472
613
  else:
473
614
  if node.name.startswith("__"):
474
615
  raise FilterValidationError(
475
- f"Nested function name is not allowed: {node.name}"
616
+ f"Nested function name is not allowed: {node.name}",
617
+ blocked_kind="disallowed_nested_function_name",
618
+ blocked_field="function_name",
619
+ blocked_value=node.name,
620
+ source_fragment=_source_fragment(node),
621
+ source_line=_node_line(node),
622
+ source_column=_node_column(node),
476
623
  )
477
624
 
478
625
  self.generic_visit(node)
479
626
 
480
627
  def visit_Name(self, node: ast.Name) -> None:
481
628
  if node.id in DISALLOWED_NAMES or node.id.startswith("__"):
482
- raise FilterValidationError(f"Name is not allowed: {node.id}")
629
+ raise FilterValidationError(
630
+ f"Name is not allowed: {node.id}",
631
+ blocked_kind="disallowed_name",
632
+ blocked_field="name",
633
+ blocked_value=node.id,
634
+ source_fragment=_source_fragment(node),
635
+ source_line=_node_line(node),
636
+ source_column=_node_column(node),
637
+ )
483
638
  self.generic_visit(node)
484
639
 
485
640
  def visit_Attribute(self, node: ast.Attribute) -> None:
486
641
  is_safe_dunder = node.attr in SAFE_DUNDER_ATTRIBUTES
487
642
  if node.attr.startswith("_") and not is_safe_dunder:
488
- raise FilterValidationError(f"Attribute access is not allowed: {node.attr}")
643
+ raise FilterValidationError(
644
+ f"Attribute access is not allowed: {node.attr}",
645
+ blocked_kind="disallowed_attribute",
646
+ blocked_field="attribute",
647
+ blocked_value=node.attr,
648
+ source_fragment=_source_fragment(node),
649
+ source_line=_node_line(node),
650
+ source_column=_node_column(node),
651
+ )
489
652
 
490
653
  parent = self._parents.get(id(node))
491
654
  if self._is_safe_attribute_read(node):
@@ -498,11 +661,28 @@ class FilterValidator(ast.NodeVisitor):
498
661
  return
499
662
 
500
663
  if not is_call_target:
664
+ if node.attr in SAFE_PROPERTY_READS:
665
+ self.visit(node.value)
666
+ return
501
667
  raise FilterValidationError(
502
- "Attribute access is restricted to approved method calls"
668
+ "Attribute access is restricted to approved method calls",
669
+ blocked_kind="disallowed_attribute_read",
670
+ blocked_field="attribute",
671
+ blocked_value=node.attr,
672
+ source_fragment=_source_fragment(node),
673
+ source_line=_node_line(node),
674
+ source_column=_node_column(node),
503
675
  )
504
676
  if node.attr not in SAFE_METHODS:
505
- raise FilterValidationError(f"Method is not allowed: {node.attr}")
677
+ raise FilterValidationError(
678
+ f"Method is not allowed: {node.attr}",
679
+ blocked_kind="disallowed_method",
680
+ blocked_field="method",
681
+ blocked_value=node.attr,
682
+ source_fragment=_source_fragment(node),
683
+ source_line=_node_line(node),
684
+ source_column=_node_column(node),
685
+ )
506
686
 
507
687
  self.visit(node.value)
508
688
 
@@ -512,32 +692,87 @@ class FilterValidator(ast.NodeVisitor):
512
692
  node.func.id not in SAFE_BUILTINS
513
693
  and node.func.id not in self._defined_function_names
514
694
  ):
695
+ blocked_kind = (
696
+ "disallowed_name"
697
+ if node.func.id in DISALLOWED_NAMES
698
+ or node.func.id.startswith("__")
699
+ else "disallowed_function"
700
+ )
701
+ blocked_node = node.func if blocked_kind == "disallowed_name" else node
515
702
  raise FilterValidationError(
516
- f"Calling this function is not allowed: {node.func.id}"
703
+ f"Calling this function is not allowed: {node.func.id}",
704
+ blocked_kind=blocked_kind,
705
+ blocked_field=(
706
+ "name" if blocked_kind == "disallowed_name" else "function"
707
+ ),
708
+ blocked_value=node.func.id,
709
+ source_fragment=_source_fragment(blocked_node),
710
+ source_line=_node_line(blocked_node),
711
+ source_column=_node_column(blocked_node),
517
712
  )
518
713
  self.visit(node.func)
519
714
  elif isinstance(node.func, ast.Attribute):
520
715
  self.visit(node.func)
521
716
  else:
522
- raise FilterValidationError("Only whitelisted function calls are allowed")
717
+ raise FilterValidationError(
718
+ "Only whitelisted function calls are allowed",
719
+ blocked_kind="disallowed_call_target",
720
+ blocked_field="function",
721
+ blocked_value=_source_fragment(node.func) or type(node.func).__name__,
722
+ source_fragment=_source_fragment(node),
723
+ source_line=_node_line(node),
724
+ source_column=_node_column(node),
725
+ )
523
726
 
524
727
  for arg in node.args:
525
728
  if isinstance(arg, ast.Starred):
526
- raise FilterValidationError("Starred arguments are not allowed")
729
+ raise FilterValidationError(
730
+ "Starred arguments are not allowed",
731
+ blocked_kind="disallowed_argument",
732
+ blocked_field="argument",
733
+ blocked_value=_source_fragment(arg),
734
+ source_fragment=_source_fragment(node),
735
+ source_line=_node_line(arg),
736
+ source_column=_node_column(arg),
737
+ )
527
738
  self.visit(arg)
528
739
 
529
740
  for keyword in node.keywords:
530
741
  if keyword.arg is None:
531
742
  raise FilterValidationError(
532
- "Double-star keyword arguments are not allowed"
743
+ "Double-star keyword arguments are not allowed",
744
+ blocked_kind="disallowed_keyword_argument",
745
+ blocked_field="keyword",
746
+ blocked_value="**",
747
+ source_fragment=_source_fragment(node),
748
+ source_line=_node_line(keyword.value),
749
+ source_column=_node_column(keyword.value),
533
750
  )
534
751
  self.visit(keyword)
535
752
 
536
753
  def visit_comprehension(self, node: ast.comprehension) -> None:
537
754
  if node.is_async:
538
- raise FilterValidationError("Async comprehensions are not allowed")
755
+ raise FilterValidationError(
756
+ "Async comprehensions are not allowed",
757
+ blocked_kind="disallowed_async_comprehension",
758
+ blocked_field="is_async",
759
+ blocked_value=str(node.is_async),
760
+ source_fragment=_source_fragment(node),
761
+ )
539
762
  self.generic_visit(node)
540
763
 
764
+ @staticmethod
765
+ def _signature_summary(args: ast.arguments) -> str:
766
+ positional = [arg.arg for arg in args.args]
767
+ keyword_only = [arg.arg for arg in args.kwonlyargs]
768
+ vararg = args.vararg.arg if args.vararg is not None else None
769
+ kwarg = args.kwarg.arg if args.kwarg is not None else None
770
+ return (
771
+ f"args={positional!r}, vararg={vararg!r}, "
772
+ f"kwonlyargs={keyword_only!r}, kwarg={kwarg!r}, "
773
+ f"defaults={len(args.defaults)}, kw_defaults={len(args.kw_defaults)}"
774
+ )
775
+
541
776
  @staticmethod
542
777
  def _build_parent_map(tree: ast.AST) -> dict[int, ast.AST]:
543
778
  parents: dict[int, ast.AST] = {}
@@ -570,7 +805,15 @@ def _parse_filter(source_code: str) -> ast.Module:
570
805
  try:
571
806
  return ast.parse(source_code, mode="exec")
572
807
  except SyntaxError as exc:
573
- raise FilterValidationError(f"Invalid Python syntax: {exc.msg}") from exc
808
+ raise FilterValidationError(
809
+ f"Invalid Python syntax: {exc.msg}",
810
+ blocked_kind="invalid_syntax",
811
+ blocked_field="syntax",
812
+ blocked_value=exc.msg,
813
+ source_fragment=(exc.text or "").strip() or None,
814
+ source_line=exc.lineno,
815
+ source_column=exc.offset,
816
+ ) from exc
574
817
 
575
818
 
576
819
  def _compile_tree(tree: ast.Module) -> CodeType:
@@ -590,6 +833,11 @@ def compile_filter(source_code: str):
590
833
 
591
834
  filter_fn = execution_globals.get("filter_item")
592
835
  if not callable(filter_fn):
593
- raise FilterValidationError("filter_item was not created successfully")
836
+ raise FilterValidationError(
837
+ "filter_item was not created successfully",
838
+ blocked_kind="missing_filter_function",
839
+ blocked_field="function_name",
840
+ blocked_value="filter_item",
841
+ )
594
842
 
595
843
  return filter_fn
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: data-filter-mcp
3
- Version: 0.2.6
3
+ Version: 0.2.7
4
4
  Summary: Local MCP server for running restricted Python text filters over files
5
5
  Author: Aleksei Arakelian
6
6
  License-Expression: MIT
@@ -556,6 +556,61 @@ def test_compile_filter_allows_re_compile_chain() -> None:
556
556
  "10.0.0.0/24",
557
557
  "10.0.0.0/23",
558
558
  ),
559
+ (
560
+ """
561
+ def filter_item(data):
562
+ addr = ipaddress.ip_address(data)
563
+ return str(addr.is_private)
564
+ """,
565
+ "192.168.0.1",
566
+ "True",
567
+ ),
568
+ (
569
+ """
570
+ def filter_item(data):
571
+ net = ipaddress.ip_network(data)
572
+ return str(net.network_address)
573
+ """,
574
+ "10.0.0.0/24",
575
+ "10.0.0.0",
576
+ ),
577
+ (
578
+ """
579
+ def filter_item(data):
580
+ net = ipaddress.ip_network(data)
581
+ return net.with_prefixlen
582
+ """,
583
+ "10.0.0.0/24",
584
+ "10.0.0.0/24",
585
+ ),
586
+ (
587
+ """
588
+ def filter_item(data):
589
+ outer = ipaddress.ip_network(data)
590
+ inner = ipaddress.ip_network("10.0.0.0/25")
591
+ return str(list(outer.address_exclude(inner))[0])
592
+ """,
593
+ "10.0.0.0/24",
594
+ "10.0.0.128/25",
595
+ ),
596
+ (
597
+ """
598
+ def filter_item(data):
599
+ first = ipaddress.ip_network("10.0.0.0/25")
600
+ second = ipaddress.ip_network("10.0.0.128/25")
601
+ return str(list(ipaddress.collapse_addresses([first, second]))[0])
602
+ """,
603
+ None,
604
+ "10.0.0.0/24",
605
+ ),
606
+ (
607
+ """
608
+ def filter_item(data):
609
+ return str(ipaddress.ip_address(data).version)
610
+ """,
611
+ "::1",
612
+ "6",
613
+ ),
559
614
  (
560
615
  """
561
616
  def filter_item(data):
@@ -666,3 +721,76 @@ def test_compile_filter_rejects_methodcaller_in_json_object_hook() -> None:
666
721
  FilterValidationError, match="Method is not allowed: methodcaller"
667
722
  ):
668
723
  compile_filter(code)
724
+
725
+
726
+ def test_validation_error_reports_blocked_name_details() -> None:
727
+ code = textwrap.dedent(
728
+ """
729
+ def filter_item(data):
730
+ return eval(data)
731
+ """
732
+ )
733
+
734
+ with pytest.raises(FilterValidationError, match="blocked_value='eval'") as exc_info:
735
+ compile_filter(code)
736
+
737
+ exc = exc_info.value
738
+ assert exc.blocked_kind == "disallowed_name"
739
+ assert exc.blocked_field == "name"
740
+ assert exc.blocked_value == "eval"
741
+ assert exc.source_fragment == "eval"
742
+ assert exc.source_line == 3
743
+
744
+
745
+ def test_validation_error_reports_blocked_attribute_read_details() -> None:
746
+ code = textwrap.dedent(
747
+ """
748
+ def filter_item(data):
749
+ return str(data.secret)
750
+ """
751
+ )
752
+
753
+ with pytest.raises(FilterValidationError, match="blocked_value='secret'") as exc_info:
754
+ compile_filter(code)
755
+
756
+ exc = exc_info.value
757
+ assert exc.blocked_kind == "disallowed_attribute_read"
758
+ assert exc.blocked_field == "attribute"
759
+ assert exc.blocked_value == "secret"
760
+ assert exc.source_fragment == "data.secret"
761
+ assert exc.source_line == 3
762
+
763
+
764
+ def test_validation_error_reports_signature_details() -> None:
765
+ code = textwrap.dedent(
766
+ """
767
+ def filter_item(item, extra):
768
+ return "bad"
769
+ """
770
+ )
771
+
772
+ with pytest.raises(FilterValidationError, match="blocked_field='parameters'") as exc_info:
773
+ compile_filter(code)
774
+
775
+ exc = exc_info.value
776
+ assert exc.blocked_kind == "invalid_function_signature"
777
+ assert exc.blocked_field == "parameters"
778
+ assert "item" in (exc.blocked_value or "")
779
+ assert "extra" in (exc.blocked_value or "")
780
+
781
+
782
+ def test_validation_error_reports_double_star_keyword_details() -> None:
783
+ code = textwrap.dedent(
784
+ """
785
+ def filter_item(data):
786
+ return dict(**data)
787
+ """
788
+ )
789
+
790
+ with pytest.raises(FilterValidationError, match=r"blocked_value='\*\*'") as exc_info:
791
+ compile_filter(code)
792
+
793
+ exc = exc_info.value
794
+ assert exc.blocked_kind == "disallowed_keyword_argument"
795
+ assert exc.blocked_field == "keyword"
796
+ assert exc.blocked_value == "**"
File without changes