opa-golib-python-bindings 0.1.2__tar.gz → 0.2.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 (17) hide show
  1. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.2.0}/PKG-INFO +15 -1
  2. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.2.0}/README.md +14 -0
  3. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.2.0}/go/bridge.go +32 -6
  4. opa_golib_python_bindings-0.2.0/go/trace.go +78 -0
  5. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.2.0}/pyproject.toml +1 -1
  6. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.2.0}/src/opa_bindings/_native.py +14 -2
  7. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.2.0}/src/opa_bindings/engine.py +52 -6
  8. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.2.0}/.gitignore +0 -0
  9. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.2.0}/LICENSE +0 -0
  10. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.2.0}/Makefile +0 -0
  11. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.2.0}/go/bridge_call.c +0 -0
  12. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.2.0}/go/builtins.go +0 -0
  13. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.2.0}/go/go.mod +0 -0
  14. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.2.0}/go/go.sum +0 -0
  15. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.2.0}/go/merge.go +0 -0
  16. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.2.0}/hatch_build.py +0 -0
  17. {opa_golib_python_bindings-0.1.2 → opa_golib_python_bindings-0.2.0}/src/opa_bindings/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: opa-golib-python-bindings
3
- Version: 0.1.2
3
+ Version: 0.2.0
4
4
  Summary: Python bindings for the OPA (Open Policy Agent) Rego engine via a Go c-shared library
5
5
  Project-URL: Homepage, https://github.com/phi1010/opa-golib-python-bindings
6
6
  Project-URL: Repository, https://github.com/phi1010/opa-golib-python-bindings
@@ -68,6 +68,20 @@ Notes:
68
68
  - Builtin arguments and return values are JSON-compatible objects. A callback exception
69
69
  becomes an evaluation error; returning is fine.
70
70
  - An undefined document raises `OpaUndefinedError`.
71
+ - Pass `coverage=True` to `eval_document` / `eval_query` to capture a coverage
72
+ report (OPA's `cover` tracer) in `engine.last_coverage`: per-file `covered` /
73
+ `not_covered` line ranges plus line counts and a coverage percentage over all
74
+ added policies. Evaluating without `coverage=True` resets it to `None`.
75
+ - Pass `trace=True` to capture the full evaluation trace in `engine.last_trace`:
76
+ a list of event dicts (`op`, `location`, `node`, `locals`, ...) in evaluation
77
+ order. `locals` holds the plugged variable bindings live at each step, so the
78
+ value a statement produced is visible (e.g. `{"x": 6}` after `x := input.n * 2`);
79
+ a false condition appears as a `Fail` event at its location. An undefined
80
+ document still carries its trace — the main way to see which condition failed. Note that `node`
81
+ shows the compiler-rewritten expression (temporaries like `__local0__`), a
82
+ statement may appear multiple times (`Redo` on backtracking), and tracing
83
+ slows evaluation, so keep it opt-in per call. Coverage only records *which*
84
+ statements were evaluated; traces are how to see their results.
71
85
  - Rego `print(...)` output is captured per evaluation: set `engine.print_handler`
72
86
  to a `callable(message, location)` to receive it (default: written to stderr);
73
87
  `engine.last_prints` holds the `(message, location)` pairs of the last eval.
@@ -43,6 +43,20 @@ Notes:
43
43
  - Builtin arguments and return values are JSON-compatible objects. A callback exception
44
44
  becomes an evaluation error; returning is fine.
45
45
  - An undefined document raises `OpaUndefinedError`.
46
+ - Pass `coverage=True` to `eval_document` / `eval_query` to capture a coverage
47
+ report (OPA's `cover` tracer) in `engine.last_coverage`: per-file `covered` /
48
+ `not_covered` line ranges plus line counts and a coverage percentage over all
49
+ added policies. Evaluating without `coverage=True` resets it to `None`.
50
+ - Pass `trace=True` to capture the full evaluation trace in `engine.last_trace`:
51
+ a list of event dicts (`op`, `location`, `node`, `locals`, ...) in evaluation
52
+ order. `locals` holds the plugged variable bindings live at each step, so the
53
+ value a statement produced is visible (e.g. `{"x": 6}` after `x := input.n * 2`);
54
+ a false condition appears as a `Fail` event at its location. An undefined
55
+ document still carries its trace — the main way to see which condition failed. Note that `node`
56
+ shows the compiler-rewritten expression (temporaries like `__local0__`), a
57
+ statement may appear multiple times (`Redo` on backtracking), and tracing
58
+ slows evaluation, so keep it opt-in per call. Coverage only records *which*
59
+ statements were evaluated; traces are how to see their results.
46
60
  - Rego `print(...)` output is captured per evaluation: set `engine.print_handler`
47
61
  to a `callable(message, location)` to receive it (default: written to stderr);
48
62
  `engine.last_prints` holds the `(message, location)` pairs of the last eval.
@@ -21,6 +21,7 @@ import (
21
21
  "unsafe"
22
22
 
23
23
  "github.com/open-policy-agent/opa/v1/ast"
24
+ "github.com/open-policy-agent/opa/v1/cover"
24
25
  "github.com/open-policy-agent/opa/v1/rego"
25
26
  "github.com/open-policy-agent/opa/v1/storage/inmem"
26
27
  "github.com/open-policy-agent/opa/v1/topdown/print"
@@ -187,21 +188,21 @@ func OpaRegisterBuiltin(h C.ulonglong, name *C.char, arity C.int, cb C.opa_callb
187
188
  }
188
189
 
189
190
  //export OpaEvalQuery
190
- func OpaEvalQuery(h C.ulonglong, query, inputJson *C.char) *C.char {
191
- return evalCommon(h, C.GoString(query), inputJson)
191
+ func OpaEvalQuery(h C.ulonglong, query, inputJson *C.char, coverage, trace C.int) *C.char {
192
+ return evalCommon(h, C.GoString(query), inputJson, coverage != 0, trace != 0)
192
193
  }
193
194
 
194
195
  //export OpaEvalDocument
195
- func OpaEvalDocument(h C.ulonglong, docPath, inputJson *C.char) *C.char {
196
+ func OpaEvalDocument(h C.ulonglong, docPath, inputJson *C.char, coverage, trace C.int) *C.char {
196
197
  p := C.GoString(docPath)
197
198
  q := "data"
198
199
  if p != "" {
199
200
  q = "data." + p
200
201
  }
201
- return evalCommon(h, q, inputJson)
202
+ return evalCommon(h, q, inputJson, coverage != 0, trace != 0)
202
203
  }
203
204
 
204
- func evalCommon(h C.ulonglong, query string, inputJson *C.char) *C.char {
205
+ func evalCommon(h C.ulonglong, query string, inputJson *C.char, coverage, trace bool) *C.char {
205
206
  e, err := getEngine(h)
206
207
  if err != nil {
207
208
  return errorJSON("invalid_handle", err.Error())
@@ -243,11 +244,36 @@ func evalCommon(h C.ulonglong, query string, inputJson *C.char) *C.char {
243
244
  }
244
245
  collector := &printCollector{}
245
246
  evalOpts = append(evalOpts, rego.EvalPrintHook(collector))
247
+ var cov *cover.Cover
248
+ if coverage {
249
+ cov = cover.New()
250
+ evalOpts = append(evalOpts, rego.EvalQueryTracer(cov))
251
+ }
252
+ var tracer *traceCollector
253
+ if trace {
254
+ tracer = &traceCollector{}
255
+ evalOpts = append(evalOpts, rego.EvalQueryTracer(tracer))
256
+ }
246
257
  rs, err := pq.Eval(context.Background(), evalOpts...)
247
258
  if err != nil {
248
259
  return errorJSON("eval_error", err.Error())
249
260
  }
250
- b, err := json.Marshal(map[string]any{"result": rs, "prints": collector.msgs})
261
+ envelope := map[string]any{"result": rs, "prints": collector.msgs}
262
+ if cov != nil {
263
+ e.mu.Lock()
264
+ parsed := map[string]*ast.Module{}
265
+ for path, src := range e.modules {
266
+ if m, perr := ast.ParseModule(path, src); perr == nil {
267
+ parsed[path] = m
268
+ }
269
+ }
270
+ e.mu.Unlock()
271
+ envelope["coverage"] = cov.Report(parsed)
272
+ }
273
+ if tracer != nil {
274
+ envelope["trace"] = tracer.events
275
+ }
276
+ b, err := json.Marshal(envelope)
251
277
  if err != nil {
252
278
  return errorJSON("internal", err.Error())
253
279
  }
@@ -0,0 +1,78 @@
1
+ package main
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+ "sync"
7
+
8
+ "github.com/open-policy-agent/opa/v1/ast"
9
+ "github.com/open-policy-agent/opa/v1/topdown"
10
+ )
11
+
12
+ type traceEvent struct {
13
+ Op string `json:"op"`
14
+ QueryID uint64 `json:"query_id"`
15
+ ParentID uint64 `json:"parent_id"`
16
+ Location string `json:"location,omitempty"`
17
+ Node string `json:"node,omitempty"`
18
+ Locals map[string]any `json:"locals,omitempty"`
19
+ Message string `json:"message,omitempty"`
20
+ }
21
+
22
+ // traceCollector implements topdown.QueryTracer, capturing every evaluation
23
+ // event together with the local variable bindings live at that point.
24
+ type traceCollector struct {
25
+ mu sync.Mutex
26
+ events []traceEvent
27
+ }
28
+
29
+ func (t *traceCollector) Enabled() bool { return true }
30
+
31
+ func (t *traceCollector) Config() topdown.TraceConfig {
32
+ return topdown.TraceConfig{PlugLocalVars: true}
33
+ }
34
+
35
+ func (t *traceCollector) TraceEvent(evt topdown.Event) {
36
+ te := traceEvent{
37
+ Op: string(evt.Op),
38
+ QueryID: evt.QueryID,
39
+ ParentID: evt.ParentID,
40
+ Message: evt.Message,
41
+ }
42
+ if evt.Location != nil {
43
+ te.Location = evt.Location.String()
44
+ }
45
+ if evt.Node != nil {
46
+ te.Node = fmt.Sprintf("%v", evt.Node)
47
+ }
48
+ if evt.Locals != nil {
49
+ locals := map[string]any{}
50
+ evt.Locals.Iter(func(k, v ast.Value) bool {
51
+ kv, ok := k.(ast.Var)
52
+ if !ok {
53
+ return false
54
+ }
55
+ name := string(kv)
56
+ // Compiler-generated vars carry the user-facing name in the
57
+ // event metadata; drop them if no such name exists.
58
+ if md, found := evt.LocalMetadata[kv]; found {
59
+ name = string(md.Name)
60
+ }
61
+ if strings.HasPrefix(name, "__local") || strings.HasPrefix(name, "$") {
62
+ return false // compiler temporaries and wildcards
63
+ }
64
+ if j, err := ast.JSON(v); err == nil {
65
+ locals[name] = j
66
+ } else {
67
+ locals[name] = v.String()
68
+ }
69
+ return false
70
+ })
71
+ if len(locals) > 0 {
72
+ te.Locals = locals
73
+ }
74
+ }
75
+ t.mu.Lock()
76
+ t.events = append(t.events, te)
77
+ t.mu.Unlock()
78
+ }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "opa-golib-python-bindings"
3
- version = "0.1.2"
3
+ version = "0.2.0"
4
4
  description = "Python bindings for the OPA (Open Policy Agent) Rego engine via a Go c-shared library"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -43,9 +43,21 @@ def load():
43
43
  ]
44
44
 
45
45
  lib.OpaEvalQuery.restype = ctypes.c_void_p
46
- lib.OpaEvalQuery.argtypes = [ctypes.c_uint64, ctypes.c_char_p, ctypes.c_char_p]
46
+ lib.OpaEvalQuery.argtypes = [
47
+ ctypes.c_uint64,
48
+ ctypes.c_char_p,
49
+ ctypes.c_char_p,
50
+ ctypes.c_int, # coverage
51
+ ctypes.c_int, # trace
52
+ ]
47
53
 
48
54
  lib.OpaEvalDocument.restype = ctypes.c_void_p
49
- lib.OpaEvalDocument.argtypes = [ctypes.c_uint64, ctypes.c_char_p, ctypes.c_char_p]
55
+ lib.OpaEvalDocument.argtypes = [
56
+ ctypes.c_uint64,
57
+ ctypes.c_char_p,
58
+ ctypes.c_char_p,
59
+ ctypes.c_int, # coverage
60
+ ctypes.c_int, # trace
61
+ ]
50
62
 
51
63
  return lib
@@ -50,6 +50,17 @@ class OpaEngine:
50
50
  self.print_handler = None
51
51
  #: Prints captured by the most recent eval, as (message, location).
52
52
  self.last_prints = []
53
+ #: Coverage report of the most recent eval with ``coverage=True``:
54
+ #: {"files": {path: {"covered": [...], "not_covered": [...], ...}},
55
+ #: "covered_lines": int, "not_covered_lines": int, "coverage": float}.
56
+ #: None if the last eval did not capture coverage.
57
+ self.last_coverage = None
58
+ #: Evaluation trace of the most recent eval with ``trace=True``: a
59
+ #: list of event dicts {"op", "query_id", "parent_id", "location",
60
+ #: "node", "locals", "message"} in evaluation order, where "locals"
61
+ #: holds the variable bindings live at that point. None if the last
62
+ #: eval did not capture a trace.
63
+ self.last_trace = None
53
64
 
54
65
  # -- lifecycle -----------------------------------------------------
55
66
 
@@ -90,7 +101,16 @@ class OpaEngine:
90
101
  raise OpaError(err.get("code", "unknown"), err.get("message", ""))
91
102
  return envelope
92
103
 
104
+ def _eval_reset(self):
105
+ # Clear per-eval state up front so a failed eval never leaves stale
106
+ # results from a previous evaluation behind.
107
+ self.last_coverage = None
108
+ self.last_trace = None
109
+ self.last_prints = []
110
+
93
111
  def _eval_result(self, envelope):
112
+ self.last_coverage = envelope.get("coverage")
113
+ self.last_trace = envelope.get("trace")
94
114
  self.last_prints = [
95
115
  (p["message"], p["location"]) for p in envelope.get("prints") or []
96
116
  ]
@@ -166,24 +186,50 @@ class OpaEngine:
166
186
  del self._functions[name]
167
187
  raise
168
188
 
169
- def eval_query(self, query: str, input=None):
170
- """Evaluate a Rego query; returns a list of binding dicts."""
189
+ def eval_query(
190
+ self, query: str, input=None, *, coverage: bool = False, trace: bool = False
191
+ ):
192
+ """Evaluate a Rego query; returns a list of binding dicts.
193
+
194
+ With ``coverage=True`` the evaluation is traced and a coverage report
195
+ over all added policies is stored in ``self.last_coverage``. With
196
+ ``trace=True`` the full event trace, including the variable bindings
197
+ at each step, is stored in ``self.last_trace``.
198
+ """
171
199
  self._check_open()
200
+ self._eval_reset()
172
201
  rs = self._eval_result(
173
- self._call(self._lib.OpaEvalQuery, query.encode(), _encode_input(input))
202
+ self._call(
203
+ self._lib.OpaEvalQuery,
204
+ query.encode(),
205
+ _encode_input(input),
206
+ int(coverage),
207
+ int(trace),
208
+ )
174
209
  )
175
210
  if not rs:
176
211
  return []
177
212
  return [r.get("bindings", {}) for r in rs]
178
213
 
179
- def eval_document(self, path: str, input=None):
214
+ def eval_document(
215
+ self, path: str, input=None, *, coverage: bool = False, trace: bool = False
216
+ ):
180
217
  """Evaluate the document at ``data.<path>`` and return its value.
181
218
 
182
- Raises OpaUndefinedError if the document is undefined.
219
+ Raises OpaUndefinedError if the document is undefined. With
220
+ ``coverage=True`` a coverage report is stored in ``self.last_coverage``;
221
+ with ``trace=True`` the event trace is stored in ``self.last_trace``.
183
222
  """
184
223
  self._check_open()
224
+ self._eval_reset()
185
225
  rs = self._eval_result(
186
- self._call(self._lib.OpaEvalDocument, path.encode(), _encode_input(input))
226
+ self._call(
227
+ self._lib.OpaEvalDocument,
228
+ path.encode(),
229
+ _encode_input(input),
230
+ int(coverage),
231
+ int(trace),
232
+ )
187
233
  )
188
234
  if not rs or not rs[0].get("expressions"):
189
235
  raise OpaUndefinedError(f"data.{path}" if path else "data")