vaultkit 0.3.0__tar.gz → 0.4.0__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 (41) hide show
  1. {vaultkit-0.3.0 → vaultkit-0.4.0}/PKG-INFO +19 -1
  2. {vaultkit-0.3.0 → vaultkit-0.4.0}/README.md +18 -0
  3. {vaultkit-0.3.0 → vaultkit-0.4.0}/pyproject.toml +1 -1
  4. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/__init__.py +1 -1
  5. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/client.py +10 -3
  6. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/core/http.py +1 -0
  7. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/errors/exceptions.py +6 -1
  8. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/models/fetch_result.py +12 -1
  9. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/tools/definitions.py +34 -4
  10. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/tools/executor.py +11 -6
  11. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/utils/validation.py +47 -2
  12. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit.egg-info/PKG-INFO +19 -1
  13. {vaultkit-0.3.0 → vaultkit-0.4.0}/setup.cfg +0 -0
  14. {vaultkit-0.3.0 → vaultkit-0.4.0}/tests/test_fetch.py +0 -0
  15. {vaultkit-0.3.0 → vaultkit-0.4.0}/tests/test_query.py +0 -0
  16. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/core/__init__.py +0 -0
  17. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/core/polling.py +0 -0
  18. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/errors/__init__.py +0 -0
  19. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/errors/base.py +0 -0
  20. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/mcp/__init__.py +0 -0
  21. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/mcp/__main__.py +0 -0
  22. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/mcp/identity.py +0 -0
  23. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/mcp/server.py +0 -0
  24. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/models/__init__.py +0 -0
  25. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/models/dataset_info.py +0 -0
  26. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/models/dataset_schema.py +0 -0
  27. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/models/query_result.py +0 -0
  28. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/tools/__init__.py +0 -0
  29. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/tools/adapters/__init__.py +0 -0
  30. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/tools/adapters/anthropic.py +0 -0
  31. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/tools/adapters/mcp.py +0 -0
  32. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/tools/adapters/openai.py +0 -0
  33. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/tools/builder.py +0 -0
  34. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/tools/schemas.py +0 -0
  35. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/utils/__init__.py +0 -0
  36. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit/utils/retry.py +0 -0
  37. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit.egg-info/SOURCES.txt +0 -0
  38. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit.egg-info/dependency_links.txt +0 -0
  39. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit.egg-info/entry_points.txt +0 -0
  40. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit.egg-info/requires.txt +0 -0
  41. {vaultkit-0.3.0 → vaultkit-0.4.0}/vaultkit.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vaultkit
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: VaultKit Python SDK for policy-driven, runtime governed data access
5
5
  Author-email: VaultKit <founders@vaultkit.io>
6
6
  License: MIT
@@ -93,6 +93,24 @@ result = client.execute(
93
93
 
94
94
  - `aggregates`: `func` is one of `count`, `sum`, `avg`, `min`, `max`; use `field="*"` with `count`.
95
95
  - `group_by`: optional. Defaults to `fields`; if given, it must include every entry in `fields`.
96
+ An entry can also be a time bucket of a timestamp or date field, which groups by period
97
+ (`day`, `week`, `month`, `quarter` or `year`; `alias` and `timezone` are optional, and
98
+ the time zone defaults to UTC). Use one bucketed query for a trend instead of one query
99
+ per period:
100
+
101
+ ```python
102
+ client.execute(
103
+ dataset="payments",
104
+ aggregates=[{"func": "count", "field": "*", "alias": "failures"}],
105
+ filters=[{"field": "status", "operator": "eq", "value": "failed"}],
106
+ group_by=[{"field": "created_at", "bucket": "quarter", "alias": "quarter"}],
107
+ order_by={"column": "quarter"},
108
+ purpose="Failed payments per quarter",
109
+ )
110
+ ```
111
+
112
+ Bucketing a fully or partially masked field is denied, like grouping by it. Needs a
113
+ control plane with time buckets (vaultkit#106); older ones reject the request.
96
114
  - `having`: needs an aggregate expression such as `SUM(amount)`, not an alias.
97
115
  - `order_by`: a field, an aggregate alias or an aggregate expression, `asc` (default) or `desc`.
98
116
  - `fields` can be omitted for a single total, e.g. `aggregates=[{"func": "count", "field": "*"}]`.
@@ -67,6 +67,24 @@ result = client.execute(
67
67
 
68
68
  - `aggregates`: `func` is one of `count`, `sum`, `avg`, `min`, `max`; use `field="*"` with `count`.
69
69
  - `group_by`: optional. Defaults to `fields`; if given, it must include every entry in `fields`.
70
+ An entry can also be a time bucket of a timestamp or date field, which groups by period
71
+ (`day`, `week`, `month`, `quarter` or `year`; `alias` and `timezone` are optional, and
72
+ the time zone defaults to UTC). Use one bucketed query for a trend instead of one query
73
+ per period:
74
+
75
+ ```python
76
+ client.execute(
77
+ dataset="payments",
78
+ aggregates=[{"func": "count", "field": "*", "alias": "failures"}],
79
+ filters=[{"field": "status", "operator": "eq", "value": "failed"}],
80
+ group_by=[{"field": "created_at", "bucket": "quarter", "alias": "quarter"}],
81
+ order_by={"column": "quarter"},
82
+ purpose="Failed payments per quarter",
83
+ )
84
+ ```
85
+
86
+ Bucketing a fully or partially masked field is denied, like grouping by it. Needs a
87
+ control plane with time buckets (vaultkit#106); older ones reject the request.
70
88
  - `having`: needs an aggregate expression such as `SUM(amount)`, not an alias.
71
89
  - `order_by`: a field, an aggregate alias or an aggregate expression, `asc` (default) or `desc`.
72
90
  - `fields` can be omitted for a single total, e.g. `aggregates=[{"func": "count", "field": "*"}]`.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "vaultkit"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "VaultKit Python SDK for policy-driven, runtime governed data access"
9
9
  authors = [
10
10
  { name = "VaultKit", email = "founders@vaultkit.io" }
@@ -53,7 +53,7 @@ from .models import (
53
53
  # Optional (nice DX improvement)
54
54
  from .tools import ToolBuilder, ToolExecutor
55
55
 
56
- __version__ = "0.3.0"
56
+ __version__ = "0.4.0"
57
57
 
58
58
  __all__ = [
59
59
  "VaultKitClient",
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import logging
4
4
  from dataclasses import dataclass, field
5
- from typing import Any, Dict, List, Optional
5
+ from typing import Any, Dict, List, Optional, Union
6
6
 
7
7
  from vaultkit.core.http import HttpClient, HttpConfig
8
8
  from vaultkit.core.polling import PollConfig, poll_until_done
@@ -103,7 +103,7 @@ class VaultKitClient:
103
103
  filters: Optional[List[Dict[str, Any]]] = None,
104
104
  limit: Optional[int] = None,
105
105
  aggregates: Optional[List[Dict[str, Any]]] = None,
106
- group_by: Optional[List[str]] = None,
106
+ group_by: Optional[List[Union[str, Dict[str, str]]]] = None,
107
107
  having: Optional[List[Dict[str, Any]]] = None,
108
108
  order_by: Optional[Dict[str, Any]] = None,
109
109
  purpose: Optional[str] = None,
@@ -118,6 +118,11 @@ class VaultKitClient:
118
118
  aggregates [{"func": "sum", "field": "amount", "alias": "total"}]
119
119
  func is one of count, sum, avg, min, max; "*" only with count.
120
120
  group_by ["region"] — every field in `fields` must be listed when given.
121
+ A time bucket of a timestamp or date field groups by period:
122
+ {"field": "created_at", "bucket": "quarter", "alias": "quarter",
123
+ "timezone": "America/Toronto"}; bucket is one of day, week,
124
+ month, quarter, year; alias and timezone (default UTC) are
125
+ optional. One bucketed query replaces one query per period.
121
126
  having [{"field": "SUM(amount)", "operator": "gt", "value": 1000}]
122
127
  field must be an aggregate expression, not an alias.
123
128
  order_by {"column": "total", "direction": "desc"}
@@ -190,6 +195,7 @@ class VaultKitClient:
190
195
  raise DeniedError(
191
196
  result.reason or "Request denied by policy",
192
197
  policy_id=result.policy_id,
198
+ reason=result.reason,
193
199
  )
194
200
 
195
201
  # Case 1: data already returned (reuse path)
@@ -239,7 +245,7 @@ class VaultKitClient:
239
245
  filters: Optional[List[Dict[str, Any]]] = None,
240
246
  limit: Optional[int] = None,
241
247
  aggregates: Optional[List[Dict[str, Any]]] = None,
242
- group_by: Optional[List[str]] = None,
248
+ group_by: Optional[List[Union[str, Dict[str, str]]]] = None,
243
249
  having: Optional[List[Dict[str, Any]]] = None,
244
250
  order_by: Optional[Dict[str, Any]] = None,
245
251
  purpose: Optional[str] = None,
@@ -315,6 +321,7 @@ class VaultKitClient:
315
321
  raise DeniedError(
316
322
  result.reason or "Request denied",
317
323
  policy_id=result.policy_id,
324
+ reason=result.reason,
318
325
  )
319
326
  if result.is_pending and not poll:
320
327
  raise QueuedError(
@@ -166,6 +166,7 @@ class HttpClient:
166
166
  raw.get("error")
167
167
  or raw.get("message")
168
168
  or raw.get("detail")
169
+ or raw.get("reason")
169
170
  or msg
170
171
  )
171
172
  except Exception:
@@ -62,10 +62,12 @@ class DeniedError(VaultKitError):
62
62
  message: str,
63
63
  *,
64
64
  policy_id: Optional[str] = None,
65
+ reason: Optional[str] = None,
65
66
  context: Optional[ErrorContext] = None,
66
67
  ) -> None:
67
68
  super().__init__(message, context=context)
68
69
  self.policy_id = policy_id
70
+ self.reason = reason or message
69
71
 
70
72
 
71
73
  class QueuedError(VaultKitError):
@@ -185,10 +187,13 @@ def map_http_error(status_code: int, message: str, *, context: ErrorContext) ->
185
187
  return AuthError(message, context=context)
186
188
 
187
189
  if status_code == 403:
188
- if error_type == "policy_denied":
190
+ # The intent API answers a denial with {"status": "denied", "policy_id", "reason"};
191
+ # older backends sent {"type": "policy_denied"}.
192
+ if error_type == "policy_denied" or raw.get("status") == "denied":
189
193
  return DeniedError(
190
194
  message,
191
195
  policy_id=raw.get("policy_id"),
196
+ reason=raw.get("reason"),
192
197
  context=context,
193
198
  )
194
199
  return ForbiddenError(message, context=context)
@@ -42,12 +42,23 @@ class FetchResult:
42
42
  @property
43
43
  def masked_fields(self) -> List[str]:
44
44
  """
45
- Masked field names from meta, if your API returns them there.
45
+ Masked field names from meta.
46
+
47
+ funl sends meta.masked_fields as a map of field -> mask method
48
+ ({"email": "partial"}); a plain list of names is also accepted.
46
49
  Returns [] safely if meta is absent or has no masked_fields key.
47
50
  """
48
51
  value = (self.meta or {}).get("masked_fields")
52
+ if isinstance(value, dict):
53
+ return list(value.keys())
49
54
  return value if isinstance(value, list) else []
50
55
 
56
+ @property
57
+ def masking_methods(self) -> Dict[str, str]:
58
+ """Mask method per masked field, e.g. {"email": "partial"}. Empty if the API sent a list."""
59
+ value = (self.meta or {}).get("masked_fields")
60
+ return dict(value) if isinstance(value, dict) else {}
61
+
51
62
  @property
52
63
  def is_empty(self) -> bool:
53
64
  return not self.rows
@@ -2,6 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Any, Dict, List, Optional
4
4
 
5
+ from vaultkit.utils.validation import TIME_BUCKETS
6
+
5
7
 
6
8
  def discover_tool_def(
7
9
  *,
@@ -110,6 +112,25 @@ def query_tool_def(
110
112
  "required": ["logic", "conditions"],
111
113
  }
112
114
 
115
+ time_bucket = {
116
+ "type": "object",
117
+ "properties": {
118
+ "field": {"type": "string", "description": "A timestamp or date field of the dataset."},
119
+ "bucket": {"type": "string", "enum": list(TIME_BUCKETS)},
120
+ "alias": {
121
+ "type": "string",
122
+ "pattern": "^[A-Za-z_][A-Za-z0-9_]*$",
123
+ "description": "Name of the result column (default <field>_<bucket>). Use it in order_by.",
124
+ },
125
+ "timezone": {
126
+ "type": "string",
127
+ "description": "IANA time zone the periods start in, e.g. 'America/Toronto'. Default UTC.",
128
+ },
129
+ },
130
+ "required": ["field", "bucket"],
131
+ "additionalProperties": False,
132
+ }
133
+
113
134
  return {
114
135
  "name": "vaultkit_query",
115
136
  "description": (
@@ -117,6 +138,8 @@ def query_tool_def(
117
138
  "sensitive fields may be masked and some datasets require approval before data is returned. "
118
139
  "Supports aggregation (count/sum/avg/min/max), grouping, having and sorting on a single dataset; "
119
140
  "joins are not supported. "
141
+ "For a trend over time (per day, week, month, quarter or year), send ONE query that groups by a "
142
+ "time bucket of a timestamp field, not one query per period. "
120
143
  "Returns data or a pending status if approval is required."
121
144
  ),
122
145
  "input_schema": {
@@ -173,10 +196,17 @@ def query_tool_def(
173
196
  },
174
197
  "group_by": {
175
198
  "type": "array",
176
- "items": {"type": "string"},
199
+ "items": {"oneOf": [{"type": "string"}, time_bucket]},
177
200
  "description": (
178
- "Fields to group by. Optional: defaults to 'fields'. If given, it must include "
179
- "every entry in 'fields'."
201
+ "Fields to group by, or time buckets of a timestamp or date field. Optional: "
202
+ "defaults to 'fields'. If given, it must include every entry in 'fields'; a "
203
+ "bucketed field is not listed in 'fields'.\n\n"
204
+ "Example, failed payments per quarter in one query: "
205
+ "fields: [], aggregates: [{func: 'count', field: '*', alias: 'failures'}], "
206
+ "filters: [{field: 'status', operator: 'eq', value: 'failed'}], "
207
+ "group_by: [{field: 'created_at', bucket: 'quarter', alias: 'quarter'}], "
208
+ "order_by: {column: 'quarter'}. Each row has the quarter's start date and its count. "
209
+ "Mix with plain fields: group_by: ['status', {field: 'created_at', bucket: 'month'}]."
180
210
  ),
181
211
  },
182
212
  "having": {
@@ -203,7 +233,7 @@ def query_tool_def(
203
233
  "properties": {
204
234
  "column": {
205
235
  "type": "string",
206
- "description": "A field, an aggregate alias, or an aggregate expression.",
236
+ "description": "A field, an aggregate or time bucket alias, or an aggregate expression.",
207
237
  },
208
238
  "direction": {"type": "string", "enum": ["asc", "desc"], "default": "asc"},
209
239
  },
@@ -86,7 +86,6 @@ class ToolExecutor:
86
86
  "name": d.dataset,
87
87
  "datasource": d.datasource,
88
88
  "visibility": visibility_map.get(getattr(d, "visibility", None), getattr(d, "visibility", None)),
89
- "field_count": len(getattr(d, "fields", []) or []),
90
89
  }
91
90
  for d in datasets
92
91
  ],
@@ -121,6 +120,7 @@ class ToolExecutor:
121
120
  }
122
121
 
123
122
  if result.masked_fields:
123
+ response["masked_fields"] = result.masked_fields
124
124
  response["note"] = f"Fields {result.masked_fields} were masked per data policy."
125
125
 
126
126
  return response
@@ -158,12 +158,17 @@ class ToolExecutor:
158
158
 
159
159
  if isinstance(exc, DeniedError):
160
160
  policy_id = getattr(exc, "policy_id", None)
161
+ reason = getattr(exc, "reason", None) or exc.message
161
162
  extra = f" (policy_id={policy_id})" if policy_id else ""
162
- return self._error(
163
- f"Access denied by VaultKit policy{extra}. Reason: {msg}. "
164
- "Do not retry — this denial is deterministic.",
165
- code="denied",
166
- )
163
+ return {
164
+ "status": "denied",
165
+ "reason": reason,
166
+ "policy_id": policy_id,
167
+ "error": (
168
+ f"Access denied by VaultKit policy{extra}. Reason: {reason}. "
169
+ "Do not retry — this denial is deterministic."
170
+ ),
171
+ }
167
172
 
168
173
  if isinstance(exc, ApprovalRequiredError):
169
174
  return {
@@ -131,19 +131,64 @@ def validate_aggregates(value: Optional[Any]) -> Optional[list]:
131
131
  return value
132
132
 
133
133
 
134
+ # Units a group_by time bucket can use. The control plane checks the field is
135
+ # a timestamp or date column and that the time zone exists.
136
+ TIME_BUCKETS = ("day", "week", "month", "quarter", "year")
137
+ TIME_BUCKET_KEYS = {"field", "bucket", "alias", "timezone"}
138
+
139
+
134
140
  def validate_group_by(value: Optional[Any]) -> Optional[list]:
135
141
  if value is None:
136
142
  return None
137
143
 
138
- if not isinstance(value, list) or not all(isinstance(v, str) and v.strip() for v in value):
144
+ if not isinstance(value, list):
139
145
  raise ValidationError(
140
- "group_by must be a list of field names",
146
+ "group_by must be a list of field names or time buckets",
141
147
  context=ErrorContext(raw=value),
142
148
  )
143
149
 
150
+ for entry in value:
151
+ if isinstance(entry, dict):
152
+ _validate_time_bucket(entry)
153
+ elif not (isinstance(entry, str) and entry.strip()):
154
+ raise ValidationError(
155
+ "group_by must be a list of field names or time buckets",
156
+ context=ErrorContext(raw=value),
157
+ )
158
+
144
159
  return value
145
160
 
146
161
 
162
+ def _validate_time_bucket(entry: Dict[str, Any]) -> None:
163
+ unknown = set(entry) - TIME_BUCKET_KEYS
164
+ if unknown:
165
+ raise ValidationError(
166
+ f"group_by time bucket has unknown keys: {', '.join(sorted(unknown))}",
167
+ context=ErrorContext(raw=entry),
168
+ )
169
+
170
+ field = entry.get("field")
171
+ if not isinstance(field, str) or not field.strip():
172
+ raise ValidationError(
173
+ "group_by time bucket needs a 'field'",
174
+ context=ErrorContext(raw=entry),
175
+ )
176
+
177
+ bucket = entry.get("bucket")
178
+ if not isinstance(bucket, str) or bucket.lower() not in TIME_BUCKETS:
179
+ raise ValidationError(
180
+ f"group_by time bucket must be one of {', '.join(TIME_BUCKETS)}",
181
+ context=ErrorContext(raw=entry),
182
+ )
183
+
184
+ for key in ("alias", "timezone"):
185
+ if key in entry and not (isinstance(entry[key], str) and entry[key].strip()):
186
+ raise ValidationError(
187
+ f"group_by time bucket '{key}' must be a non-empty string",
188
+ context=ErrorContext(raw=entry),
189
+ )
190
+
191
+
147
192
  def validate_having(value: Optional[Any]) -> Optional[list]:
148
193
  return _require_list_of_dicts("having", value)
149
194
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vaultkit
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: VaultKit Python SDK for policy-driven, runtime governed data access
5
5
  Author-email: VaultKit <founders@vaultkit.io>
6
6
  License: MIT
@@ -93,6 +93,24 @@ result = client.execute(
93
93
 
94
94
  - `aggregates`: `func` is one of `count`, `sum`, `avg`, `min`, `max`; use `field="*"` with `count`.
95
95
  - `group_by`: optional. Defaults to `fields`; if given, it must include every entry in `fields`.
96
+ An entry can also be a time bucket of a timestamp or date field, which groups by period
97
+ (`day`, `week`, `month`, `quarter` or `year`; `alias` and `timezone` are optional, and
98
+ the time zone defaults to UTC). Use one bucketed query for a trend instead of one query
99
+ per period:
100
+
101
+ ```python
102
+ client.execute(
103
+ dataset="payments",
104
+ aggregates=[{"func": "count", "field": "*", "alias": "failures"}],
105
+ filters=[{"field": "status", "operator": "eq", "value": "failed"}],
106
+ group_by=[{"field": "created_at", "bucket": "quarter", "alias": "quarter"}],
107
+ order_by={"column": "quarter"},
108
+ purpose="Failed payments per quarter",
109
+ )
110
+ ```
111
+
112
+ Bucketing a fully or partially masked field is denied, like grouping by it. Needs a
113
+ control plane with time buckets (vaultkit#106); older ones reject the request.
96
114
  - `having`: needs an aggregate expression such as `SUM(amount)`, not an alias.
97
115
  - `order_by`: a field, an aggregate alias or an aggregate expression, `asc` (default) or `desc`.
98
116
  - `fields` can be omitted for a single total, e.g. `aggregates=[{"func": "count", "field": "*"}]`.
File without changes
File without changes
File without changes