flamapy-rest 2.6.0.dev11__tar.gz → 2.9.0.dev0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flamapy-rest
3
- Version: 2.6.0.dev11
3
+ Version: 2.9.0.dev0
4
4
  Summary: Flamapy feature model is a distribution of the flama framework containing all plugins required to analyze feature models. It also offers a richer API and a complete command line interface and documentation.
5
5
  Author-email: Flamapy <flamapy@us.es>
6
6
  License-Expression: GPL-3.0-or-later
@@ -9,9 +9,7 @@ Requires-Python: >=3.11
9
9
  Description-Content-Type: text/markdown
10
10
  Requires-Dist: Flask
11
11
  Requires-Dist: gunicorn
12
- Requires-Dist: flamapy~=2.6.0.dev11
13
- Requires-Dist: flask-swagger-ui
14
- Requires-Dist: flask-restplus
12
+ Requires-Dist: flamapy~=2.9.0.dev0
15
13
  Requires-Dist: flask_cors
16
14
  Requires-Dist: flasgger
17
15
  Requires-Dist: Flask-Limiter
@@ -153,9 +151,14 @@ python -m flask run --host=0.0.0.0
153
151
 
154
152
  ## Usage
155
153
 
156
- Every public operation of FLAMAPY's `FLAMAFeatureModel` facade is exposed as a `POST` endpoint under
157
- `/api/v1/operations/<operation_name>`. Each call uploads a feature model file (e.g. UVL) as
158
- multipart form data; some operations also accept an optional `feature` or `configuration` parameter.
154
+ The API mirrors FLAMAPY's Python facade (`FLAMAFeatureModel`): every one of its operations is a
155
+ `POST` endpoint under `/api/v1/operations/<operation_name>`, with the same parameters as the Python
156
+ method. Each call uploads a feature model file (e.g. UVL) as multipart form data; the other
157
+ parameters are form fields named after the Python arguments (`configuration`, `feature`, `size`,
158
+ `t`, …), files for the ones that take a path. Structured arguments (`objectives`,
159
+ `configurations`) are sent as JSON text. The two operations that return a feature model
160
+ (`generate_random_feature_model`, which needs no upload, and `generate_random_attribute`) answer
161
+ with a JSON string that holds the model in UVL.
159
162
 
160
163
  ```bash
161
164
  # Example: count the number of valid configurations of a model
@@ -166,6 +169,58 @@ curl -X POST http://localhost:8000/api/v1/operations/configurations_number \
166
169
  The full, interactive list of endpoints — with parameters and "try it out" support — is available
167
170
  in the Swagger UI at <http://localhost:8000/docs/>.
168
171
 
172
+ ### Choosing the backend
173
+
174
+ Operations that more than one solver implements take `backend`, exactly like the Python facade:
175
+ a plugin name (`sat`, `bdd`, `z3`; `sdd`, `sharpsat`, `dnnf` when installed) or `auto`, which
176
+ hands the choice to the query planner. `allow_approximate=true` lets `auto` also consider
177
+ approximate implementations. Setting `FLAMAPY_BACKEND=auto` (and optionally
178
+ `FLAMAPY_ALLOW_APPROXIMATE=1`) in the container's environment makes `auto` the default for
179
+ every call.
180
+
181
+ ```bash
182
+ curl -X POST http://localhost:8000/api/v1/operations/configurations_number \
183
+ -F "model=@resources/models/simple/valid_model.uvl" -F "backend=auto"
184
+ ```
185
+
186
+ ### Provenance
187
+
188
+ The body of a response is the bare value the Python facade returns; the provenance the facade
189
+ keeps in `last_result` travels in response headers:
190
+
191
+ | Header | Meaning |
192
+ |---|---|
193
+ | `X-Flamapy-Plugin` / `X-Flamapy-Backend` | The plugin that produced the result and the model it ran on (`pysat`, `bdd`, `z3`, …) |
194
+ | `X-Flamapy-Exact` | `false` when an approximate implementation produced the result |
195
+ | `X-Flamapy-Planner` | Under `backend=auto`, the planner's choice and its reasons (estimated milliseconds, …) |
196
+ | `X-Flamapy-Wall-Time` | Seconds spent in the operation (on a cached response, `X-Cache: HIT`, the time of the run that computed it) |
197
+ | `X-Flamapy-Model-Hash` | Canonical hash of the uploaded model |
198
+ | `X-Flamapy-Artifact-Cache` | `true` when the backend model was loaded from flamapy's artifact cache (`FLAMAPY_CACHE=1`) instead of being built |
199
+ | `X-Cache` | `HIT` / `MISS` of the result cache |
200
+
201
+ ### Asking the planner without running anything
202
+
203
+ `POST /api/v1/operations/<operation>/plan` (same `model` upload, optional `allow_approximate`)
204
+ returns what `backend=auto` would do for that operation on that model — the facade's `plan()`:
205
+
206
+ ```bash
207
+ curl -X POST http://localhost:8000/api/v1/operations/configurations_number/plan \
208
+ -F "model=@resources/models/simple/valid_model.uvl"
209
+ # {"operation": "configurations_number", "plugin": "bdd_metamodel", "backend": "bdd",
210
+ # "transformation_chain": ["fm", "bdd"], "estimated_cost_ms": 12,
211
+ # "rationale": "bdd: estimated 12 ms (transformation 11, operation 1), historical default backend",
212
+ # "alternatives": ["sdd_metamodel", "pysat_metamodel", "z3_metamodel"]}
213
+ ```
214
+
215
+ ### Errors
216
+
217
+ | Status | Meaning |
218
+ |---|---|
219
+ | `400` | Missing or invalid parameter, or a model that could not be parsed |
220
+ | `404` | Unknown operation, or the operation could not run: an unknown backend, invalid inputs, or (under `backend=auto`) no installed plugin can handle this model. The body says why. This is the 2.6 status; 3.0 will answer these with `422` |
221
+ | `413` / `429` / `504` | Upload too large / rate limit exceeded / operation killed after its time budget |
222
+ | `500` | An unexpected error inside a plugin; the details go to the server log, not the response |
223
+
169
224
  <p align="right">(<a href="#top">back to top</a>)</p>
170
225
 
171
226
  ## Abuse controls & configuration
@@ -184,6 +239,8 @@ result cache, all configurable through environment variables (e.g. `docker run -
184
239
  | `FLAMAPY_CACHE_TTL` | `3600` | Seconds a result stays cached (same model + operation + arguments); `0` disables. Responses carry an `X-Cache: HIT|MISS` header |
185
240
  | `FLAMAPY_CACHE_MAXSIZE` | `128` | Max cached results per worker |
186
241
  | `FLAMAPY_TRUST_PROXY` | `false` | Set to `true` behind a reverse proxy so rate limits see the real client IP (`X-Forwarded-For`) |
242
+ | `FLAMAPY_BACKEND` | unset | `auto` makes the query planner choose the backend of every call that does not name one |
243
+ | `FLAMAPY_ALLOW_APPROXIMATE` | unset | `1` lets `auto` also pick approximate implementations |
187
244
  | `WEB_CONCURRENCY` | `2` | gunicorn worker count (Docker image) |
188
245
  | `GUNICORN_TIMEOUT` | `120` | gunicorn hard worker timeout; keep it above `FLAMAPY_OPERATION_TIMEOUT` |
189
246
 
@@ -216,3 +273,11 @@ pytest # tests
216
273
  ```
217
274
 
218
275
  <p align="right">(<a href="#top">back to top</a>)</p>
276
+
277
+ ## Acknowledgements
278
+
279
+ Special thanks to [José Antonio Zamudio Amaya](https://www.linkedin.com/in/joszamama/) for
280
+ identifying the need for a REST API on top of Flamapy and for providing the first implementation of
281
+ the idea, on which this project was built.
282
+
283
+ <p align="right">(<a href="#top">back to top</a>)</p>
@@ -128,9 +128,14 @@ python -m flask run --host=0.0.0.0
128
128
 
129
129
  ## Usage
130
130
 
131
- Every public operation of FLAMAPY's `FLAMAFeatureModel` facade is exposed as a `POST` endpoint under
132
- `/api/v1/operations/<operation_name>`. Each call uploads a feature model file (e.g. UVL) as
133
- multipart form data; some operations also accept an optional `feature` or `configuration` parameter.
131
+ The API mirrors FLAMAPY's Python facade (`FLAMAFeatureModel`): every one of its operations is a
132
+ `POST` endpoint under `/api/v1/operations/<operation_name>`, with the same parameters as the Python
133
+ method. Each call uploads a feature model file (e.g. UVL) as multipart form data; the other
134
+ parameters are form fields named after the Python arguments (`configuration`, `feature`, `size`,
135
+ `t`, …), files for the ones that take a path. Structured arguments (`objectives`,
136
+ `configurations`) are sent as JSON text. The two operations that return a feature model
137
+ (`generate_random_feature_model`, which needs no upload, and `generate_random_attribute`) answer
138
+ with a JSON string that holds the model in UVL.
134
139
 
135
140
  ```bash
136
141
  # Example: count the number of valid configurations of a model
@@ -141,6 +146,58 @@ curl -X POST http://localhost:8000/api/v1/operations/configurations_number \
141
146
  The full, interactive list of endpoints — with parameters and "try it out" support — is available
142
147
  in the Swagger UI at <http://localhost:8000/docs/>.
143
148
 
149
+ ### Choosing the backend
150
+
151
+ Operations that more than one solver implements take `backend`, exactly like the Python facade:
152
+ a plugin name (`sat`, `bdd`, `z3`; `sdd`, `sharpsat`, `dnnf` when installed) or `auto`, which
153
+ hands the choice to the query planner. `allow_approximate=true` lets `auto` also consider
154
+ approximate implementations. Setting `FLAMAPY_BACKEND=auto` (and optionally
155
+ `FLAMAPY_ALLOW_APPROXIMATE=1`) in the container's environment makes `auto` the default for
156
+ every call.
157
+
158
+ ```bash
159
+ curl -X POST http://localhost:8000/api/v1/operations/configurations_number \
160
+ -F "model=@resources/models/simple/valid_model.uvl" -F "backend=auto"
161
+ ```
162
+
163
+ ### Provenance
164
+
165
+ The body of a response is the bare value the Python facade returns; the provenance the facade
166
+ keeps in `last_result` travels in response headers:
167
+
168
+ | Header | Meaning |
169
+ |---|---|
170
+ | `X-Flamapy-Plugin` / `X-Flamapy-Backend` | The plugin that produced the result and the model it ran on (`pysat`, `bdd`, `z3`, …) |
171
+ | `X-Flamapy-Exact` | `false` when an approximate implementation produced the result |
172
+ | `X-Flamapy-Planner` | Under `backend=auto`, the planner's choice and its reasons (estimated milliseconds, …) |
173
+ | `X-Flamapy-Wall-Time` | Seconds spent in the operation (on a cached response, `X-Cache: HIT`, the time of the run that computed it) |
174
+ | `X-Flamapy-Model-Hash` | Canonical hash of the uploaded model |
175
+ | `X-Flamapy-Artifact-Cache` | `true` when the backend model was loaded from flamapy's artifact cache (`FLAMAPY_CACHE=1`) instead of being built |
176
+ | `X-Cache` | `HIT` / `MISS` of the result cache |
177
+
178
+ ### Asking the planner without running anything
179
+
180
+ `POST /api/v1/operations/<operation>/plan` (same `model` upload, optional `allow_approximate`)
181
+ returns what `backend=auto` would do for that operation on that model — the facade's `plan()`:
182
+
183
+ ```bash
184
+ curl -X POST http://localhost:8000/api/v1/operations/configurations_number/plan \
185
+ -F "model=@resources/models/simple/valid_model.uvl"
186
+ # {"operation": "configurations_number", "plugin": "bdd_metamodel", "backend": "bdd",
187
+ # "transformation_chain": ["fm", "bdd"], "estimated_cost_ms": 12,
188
+ # "rationale": "bdd: estimated 12 ms (transformation 11, operation 1), historical default backend",
189
+ # "alternatives": ["sdd_metamodel", "pysat_metamodel", "z3_metamodel"]}
190
+ ```
191
+
192
+ ### Errors
193
+
194
+ | Status | Meaning |
195
+ |---|---|
196
+ | `400` | Missing or invalid parameter, or a model that could not be parsed |
197
+ | `404` | Unknown operation, or the operation could not run: an unknown backend, invalid inputs, or (under `backend=auto`) no installed plugin can handle this model. The body says why. This is the 2.6 status; 3.0 will answer these with `422` |
198
+ | `413` / `429` / `504` | Upload too large / rate limit exceeded / operation killed after its time budget |
199
+ | `500` | An unexpected error inside a plugin; the details go to the server log, not the response |
200
+
144
201
  <p align="right">(<a href="#top">back to top</a>)</p>
145
202
 
146
203
  ## Abuse controls & configuration
@@ -159,6 +216,8 @@ result cache, all configurable through environment variables (e.g. `docker run -
159
216
  | `FLAMAPY_CACHE_TTL` | `3600` | Seconds a result stays cached (same model + operation + arguments); `0` disables. Responses carry an `X-Cache: HIT|MISS` header |
160
217
  | `FLAMAPY_CACHE_MAXSIZE` | `128` | Max cached results per worker |
161
218
  | `FLAMAPY_TRUST_PROXY` | `false` | Set to `true` behind a reverse proxy so rate limits see the real client IP (`X-Forwarded-For`) |
219
+ | `FLAMAPY_BACKEND` | unset | `auto` makes the query planner choose the backend of every call that does not name one |
220
+ | `FLAMAPY_ALLOW_APPROXIMATE` | unset | `1` lets `auto` also pick approximate implementations |
162
221
  | `WEB_CONCURRENCY` | `2` | gunicorn worker count (Docker image) |
163
222
  | `GUNICORN_TIMEOUT` | `120` | gunicorn hard worker timeout; keep it above `FLAMAPY_OPERATION_TIMEOUT` |
164
223
 
@@ -191,3 +250,11 @@ pytest # tests
191
250
  ```
192
251
 
193
252
  <p align="right">(<a href="#top">back to top</a>)</p>
253
+
254
+ ## Acknowledgements
255
+
256
+ Special thanks to [José Antonio Zamudio Amaya](https://www.linkedin.com/in/joszamama/) for
257
+ identifying the need for a REST API on top of Flamapy and for providing the first implementation of
258
+ the idea, on which this project was built.
259
+
260
+ <p align="right">(<a href="#top">back to top</a>)</p>
@@ -5,13 +5,17 @@ import json
5
5
  import shutil
6
6
  import tempfile
7
7
  import typing
8
- from typing import Any, Callable
8
+ from typing import Any, Callable, Optional
9
9
 
10
10
  from flask import Blueprint, Response, current_app, request, jsonify, abort
11
11
  from werkzeug.datastructures import FileStorage
12
12
  from werkzeug.utils import secure_filename
13
+ from flamapy.core.exceptions import FlamaException
14
+ from flamapy.core.models.language_level import LanguageLevel
13
15
  from flamapy.interfaces.python.flamapy_feature_model import FLAMAFeatureModel, Backend
14
16
  from flamapy.metamodels.configuration_metamodel.models import Configuration
17
+ from flamapy.metamodels.fm_metamodel.models import FeatureModel
18
+ from flamapy.metamodels.fm_metamodel.transformations import UVLWriter
15
19
 
16
20
  from flamapy.interfaces.rest.extensions import (
17
21
  limiter,
@@ -19,7 +23,12 @@ from flamapy.interfaces.rest.extensions import (
19
23
  default_operation_limit,
20
24
  expensive_operation_limit,
21
25
  )
22
- from flamapy.interfaces.rest.runner import ModelParseError, OperationTimeout, run_operation
26
+ from flamapy.interfaces.rest.runner import (
27
+ ModelParseError,
28
+ OperationTimeout,
29
+ run_operation,
30
+ run_plan,
31
+ )
23
32
 
24
33
 
25
34
  operations_bp = Blueprint('operations_bp', __name__, url_prefix='/api/v1/operations')
@@ -27,7 +36,12 @@ operations_bp = Blueprint('operations_bp', __name__, url_prefix='/api/v1/operati
27
36
  # Operations that enumerate or repeatedly invoke a solver; they get the stricter
28
37
  # rate-limit tier because a single call can monopolize a CPU for a long time.
29
38
  _EXPENSIVE_OPERATIONS = {
39
+ 'attribute_optimization',
40
+ 'commonality',
41
+ 'generate_random_feature_model',
30
42
  'configurations',
43
+ 'covering_array',
44
+ 't_wise_sampling',
31
45
  'configurations_number',
32
46
  'configurations_with_n_features',
33
47
  'conflict',
@@ -56,14 +70,44 @@ _ENUM_VALUES = {
56
70
 
57
71
  _SWAGGER_TYPES = {int: 'integer', float: 'number', bool: 'boolean', str: 'string'}
58
72
 
73
+ # The provenance of every result (the facade's ``last_result``: plugin, backend, exact,
74
+ # planner rationale, wall time of the run that computed it, hash of the model, whether the
75
+ # backend model came from the artifact cache) travels in response headers, so the body stays
76
+ # the bare value the Python facade returns.
77
+ PROVENANCE_HEADERS = (
78
+ ('X-Flamapy-Plugin', 'plugin'),
79
+ ('X-Flamapy-Backend', 'backend'),
80
+ ('X-Flamapy-Exact', 'exact'),
81
+ ('X-Flamapy-Planner', 'planner'),
82
+ ('X-Flamapy-Wall-Time', 'wall_time'),
83
+ ('X-Flamapy-Model-Hash', 'model_hash'),
84
+ ('X-Flamapy-Artifact-Cache', 'cache_hit'),
85
+ )
86
+
59
87
 
60
88
  class CustomJSONEncoder(json.JSONEncoder):
89
+ """JSON for the facade's return types: configurations as their fields, feature models
90
+ (``generate_random_feature_model``, ``generate_random_attribute``) as UVL text, language
91
+ levels as their major/minor names."""
92
+
61
93
  def default(self, obj: Any) -> Any:
62
94
  if isinstance(obj, Configuration):
63
95
  return obj.__dict__
96
+ if isinstance(obj, FeatureModel):
97
+ return UVLWriter(path=None, source_model=obj).transform()
98
+ if isinstance(obj, LanguageLevel):
99
+ return {'major': obj.major.name, 'minors': sorted(m.name for m in obj.minors)}
100
+ if isinstance(obj, (set, frozenset)):
101
+ return sorted(obj, key=str)
64
102
  return super().default(obj)
65
103
 
66
104
 
105
+ def _facade_kind(method: Any) -> str:
106
+ """'operation' (runs on an uploaded model), 'transformer' (returns the modified model)
107
+ or 'producer' (builds a model from parameters; no upload)."""
108
+ return str(getattr(method, '_facade_kind', 'operation'))
109
+
110
+
67
111
  def _operation_params(method: Any) -> list[inspect.Parameter]:
68
112
  """Call-relevant parameters of a facade method (excluding ``self``)."""
69
113
  return [p for p in inspect.signature(method).parameters.values() if p.name != 'self']
@@ -101,6 +145,19 @@ def _convert(raw: str, scalar_type: type) -> Any:
101
145
  return raw
102
146
 
103
147
 
148
+ def _is_json_param(param: inspect.Parameter) -> bool:
149
+ """Untyped facade inputs take structured values (``objectives``: an attribute name or
150
+ {attribute: min|max}; ``configurations``: a list of {feature: bool}); over HTTP they
151
+ arrive as JSON text."""
152
+ return param.annotation is typing.Any
153
+
154
+
155
+ def _convert_param(raw: str, param: inspect.Parameter) -> Any:
156
+ if _is_json_param(param) and raw.lstrip()[:1] in ('[', '{'):
157
+ return json.loads(raw)
158
+ return _convert(raw, _scalar_type(param.annotation))
159
+
160
+
104
161
  def _save_upload(uploaded: FileStorage, work_dir: str) -> str:
105
162
  """Save an uploaded file into ``work_dir`` under a sanitized name and return
106
163
  its path. ``secure_filename`` strips any directory components, so a client
@@ -133,7 +190,7 @@ def _resolve_kwargs(operation: Any, work_dir: str) -> dict[str, Any]:
133
190
  if allowed is not None and raw not in allowed:
134
191
  abort(400, f"Invalid value for '{field}'; choose from {allowed}")
135
192
  try:
136
- kwargs[param.name] = _convert(raw, _scalar_type(param.annotation))
193
+ kwargs[param.name] = _convert_param(raw, param)
137
194
  except (TypeError, ValueError):
138
195
  abort(400, f"Invalid value for '{field}'")
139
196
  elif required:
@@ -142,15 +199,16 @@ def _resolve_kwargs(operation: Any, work_dir: str) -> dict[str, Any]:
142
199
 
143
200
 
144
201
  def _cache_key(
145
- operation_name: str, operation: Any, model_path: str, kwargs: dict[str, Any]
202
+ operation_name: str, operation: Any, model_path: Optional[str], kwargs: dict[str, Any]
146
203
  ) -> str:
147
204
  """Digest of everything that determines the result: the operation, the model
148
205
  contents and every argument (file arguments by content, not by temp path)."""
149
206
  file_params = {p.name for p in _operation_params(operation) if _is_file_param(p)}
150
207
  digest = hashlib.sha256()
151
208
  digest.update(operation_name.encode())
152
- with open(model_path, 'rb') as model_file:
153
- digest.update(model_file.read())
209
+ if model_path is not None:
210
+ with open(model_path, 'rb') as model_file:
211
+ digest.update(model_file.read())
154
212
  for name in sorted(kwargs):
155
213
  digest.update(b'\x00' + name.encode() + b'\x00')
156
214
  if name in file_params:
@@ -161,49 +219,100 @@ def _cache_key(
161
219
  return digest.hexdigest()
162
220
 
163
221
 
164
- def _api_call(operation_name: str) -> Any:
222
+ def _header_value(value: Any) -> str:
223
+ if isinstance(value, bool):
224
+ return 'true' if value else 'false'
225
+ return str(value)
226
+
227
+
228
+ def _respond(entry: dict[str, Any], cache_status: str) -> Response:
229
+ """The response for a cached or fresh ``{'value': ..., 'provenance': ...}`` entry: the
230
+ bare value as the body, the provenance in the headers."""
231
+ provenance: dict[str, Any] = entry.get('provenance') or {}
232
+ response: Response = jsonify(entry['value'])
233
+ response.headers['X-Cache'] = cache_status
234
+ for header, field in PROVENANCE_HEADERS:
235
+ if provenance.get(field) is not None:
236
+ response.headers[header] = _header_value(provenance[field])
237
+ return response
238
+
239
+
240
+ def _uploaded_model() -> FileStorage:
165
241
  uploaded_model = request.files.get('model')
166
242
  if uploaded_model is None or not uploaded_model.filename:
167
243
  abort(400, "Missing required file 'model'")
244
+ return uploaded_model
245
+
246
+
247
+ def _api_call(operation_name: str) -> Any:
248
+ operation = getattr(FLAMAFeatureModel, operation_name)
249
+ # Producers build a model from their parameters: nothing to upload.
250
+ uploaded_model = None if _facade_kind(operation) == 'producer' else _uploaded_model()
168
251
 
169
252
  # Each request gets its own directory so concurrent requests cannot collide
170
253
  # on (or delete) each other's uploads; the whole tree is removed afterwards.
171
254
  work_dir = tempfile.mkdtemp(prefix='flamapy_')
172
255
  try:
173
- model_path = _save_upload(uploaded_model, work_dir)
174
- operation = getattr(FLAMAFeatureModel, operation_name)
256
+ model_path = _save_upload(uploaded_model, work_dir) if uploaded_model else None
175
257
  kwargs = _resolve_kwargs(operation, work_dir)
176
258
 
177
259
  key = _cache_key(operation_name, operation, model_path, kwargs)
178
- payload = result_cache.get(key)
179
- if payload is not None:
180
- response: Response = jsonify(payload)
181
- response.headers['X-Cache'] = 'HIT'
182
- return response
260
+ cached = result_cache.get(key)
261
+ if cached is not None:
262
+ return _respond(cached, 'HIT')
183
263
 
184
264
  try:
185
- result = run_operation(
265
+ value, provenance = run_operation(
186
266
  model_path, operation_name, kwargs, current_app.config['OPERATION_TIMEOUT']
187
267
  )
188
268
  except ModelParseError:
189
269
  abort(400, "The uploaded model could not be parsed")
190
270
  except OperationTimeout as exc:
191
271
  abort(504, str(exc))
272
+ except FlamaException as exc:
273
+ # The operation could not run: an unknown backend, invalid inputs, or no
274
+ # installed plugin able to run it on this model (backend=auto). 404 as in 2.6,
275
+ # with the reason; 3.0 moves this to 422.
276
+ abort(404, str(exc))
192
277
  finally:
193
278
  shutil.rmtree(work_dir, ignore_errors=True)
194
279
 
195
- if result is None:
280
+ if value is None:
196
281
  return jsonify(error='Not valid result'), 404
197
- payload = json.loads(json.dumps(result, cls=CustomJSONEncoder))
198
- result_cache.set(key, payload)
199
- response = jsonify(payload)
200
- response.headers['X-Cache'] = 'MISS'
201
- return response
282
+ entry = {
283
+ 'value': json.loads(json.dumps(value, cls=CustomJSONEncoder)),
284
+ 'provenance': provenance,
285
+ }
286
+ result_cache.set(key, entry)
287
+ return _respond(entry, 'MISS')
288
+
289
+
290
+ def _plan_call(operation_name: str) -> Any:
291
+ """Which backend ``backend=auto`` would pick for the operation on the uploaded model."""
292
+ uploaded_model = _uploaded_model()
293
+ raw = request.form.get('allow_approximate')
294
+ allow_approximate = _convert(raw, bool) if raw not in (None, '') else None
295
+ work_dir = tempfile.mkdtemp(prefix='flamapy_')
296
+ try:
297
+ model_path = _save_upload(uploaded_model, work_dir)
298
+ try:
299
+ chosen = run_plan(model_path, operation_name, allow_approximate,
300
+ current_app.config['OPERATION_TIMEOUT'])
301
+ except ModelParseError:
302
+ abort(400, "The uploaded model could not be parsed")
303
+ except OperationTimeout as exc:
304
+ abort(504, str(exc))
305
+ except FlamaException as exc:
306
+ abort(404, str(exc))
307
+ finally:
308
+ shutil.rmtree(work_dir, ignore_errors=True)
309
+ return jsonify(chosen)
202
310
 
203
311
 
204
312
  def extract_docstring_with_swagger_info(method: Any) -> str:
205
313
  """Build the flasgger YAML spec for a facade method by introspecting its
206
- signature: a required ``model`` file plus one field per parameter."""
314
+ signature: a required ``model`` file (except for producers) plus one field per
315
+ parameter."""
207
316
  lines = [
208
317
  " ---",
209
318
  " tags:",
@@ -211,11 +320,14 @@ def extract_docstring_with_swagger_info(method: Any) -> str:
211
320
  " consumes:",
212
321
  " - multipart/form-data",
213
322
  " parameters:",
214
- " - name: model",
215
- " in: formData",
216
- " type: file",
217
- " required: true",
218
323
  ]
324
+ if _facade_kind(method) != 'producer':
325
+ lines += [
326
+ " - name: model",
327
+ " in: formData",
328
+ " type: file",
329
+ " required: true",
330
+ ]
219
331
  for param in _operation_params(method):
220
332
  ptype = 'file' if _is_file_param(param) else _SWAGGER_TYPES.get(
221
333
  _scalar_type(param.annotation), 'string'
@@ -227,18 +339,64 @@ def extract_docstring_with_swagger_info(method: Any) -> str:
227
339
  f" type: {ptype}",
228
340
  f" required: {str(required).lower()}",
229
341
  ]
342
+ if _is_json_param(param):
343
+ lines.append(" description: JSON (an object or a list) or a plain string")
344
+ if param.name == 'allow_approximate':
345
+ lines.append(" description: Only with backend=auto, let the planner choose"
346
+ " approximate implementations")
230
347
  if param.name in _ENUM_VALUES:
231
348
  lines.append(" enum:")
232
349
  lines += [f" - {value}" for value in _ENUM_VALUES[param.name]]
350
+ returns_model = _facade_kind(method) in ('producer', 'transformer')
233
351
  lines += [
234
352
  " responses:",
235
353
  " 200:",
236
- " description: Result of the operation",
354
+ " description: " + (
355
+ "A JSON string holding the resulting feature model in UVL" if returns_model
356
+ else "Result of the operation; its provenance travels in the X-Flamapy-* headers"),
357
+ " headers:",
358
+ ]
359
+ for header, _ in PROVENANCE_HEADERS:
360
+ lines += [f" {header}:", " type: string"]
361
+ lines += [
362
+ " 404:",
363
+ " description: The operation could not run (unknown backend, invalid input,"
364
+ " or no installed plugin can run it on this model); the body says why",
237
365
  ]
238
366
  docstring = (method.__doc__ or "").replace('\n', ' ').strip()
239
367
  return docstring + "\n" + "\n".join(lines) + "\n"
240
368
 
241
369
 
370
+ def _plan_docstring(operation_name: str) -> str:
371
+ """The flasgger spec of an operation's ``/plan`` endpoint."""
372
+ lines = [
373
+ f"Which backend backend=auto would run {operation_name} on the uploaded model, and"
374
+ " why: the plugin, its model extension, the transformation chain, the estimated"
375
+ " cost in milliseconds, the rationale and the alternatives. Nothing is executed.",
376
+ " ---",
377
+ " tags:",
378
+ f" - {operation_name}",
379
+ " consumes:",
380
+ " - multipart/form-data",
381
+ " parameters:",
382
+ " - name: model",
383
+ " in: formData",
384
+ " type: file",
385
+ " required: true",
386
+ " - name: allow_approximate",
387
+ " in: formData",
388
+ " type: boolean",
389
+ " required: false",
390
+ " description: Also consider approximate implementations",
391
+ " responses:",
392
+ " 200:",
393
+ " description: The planner's choice",
394
+ " 404:",
395
+ " description: No installed plugin can run the operation on this model",
396
+ ]
397
+ return "\n".join(lines) + "\n"
398
+
399
+
242
400
  def create_route(operation_name: str, docstring: str) -> Callable[[], Any]:
243
401
  def route_function() -> Any:
244
402
  return _api_call(operation_name)
@@ -248,15 +406,30 @@ def create_route(operation_name: str, docstring: str) -> Callable[[], Any]:
248
406
  return route_function
249
407
 
250
408
 
409
+ def create_plan_route(operation_name: str) -> Callable[[], Any]:
410
+ def route_function() -> Any:
411
+ return _plan_call(operation_name)
412
+
413
+ route_function.__name__ = f'{operation_name}_plan'
414
+ route_function.__doc__ = _plan_docstring(operation_name)
415
+ return route_function
416
+
417
+
418
+ def _selects_backend(method: Any) -> bool:
419
+ return any(param.name == 'backend' for param in _operation_params(method))
420
+
421
+
251
422
  # Introspect FLAMAFeatureModel to expose every public method as a POST route,
252
423
  # generating its Swagger spec from the method signature. Limits are passed as
253
424
  # callables so they read the app config of whichever app the blueprint joins.
254
425
  for name, method in inspect.getmembers(FLAMAFeatureModel, predicate=inspect.isfunction):
255
- if name.startswith('_'):
256
- continue
257
- if getattr(method, '_facade_kind', 'operation') != 'operation':
258
- continue # producers/transformers return models; Python-API only for now
426
+ if name.startswith('_') or getattr(method, '_facade_descriptor', None) is None:
427
+ continue # plan/run_async/iter_configurations are Python-API helpers, not operations
259
428
  docstring = extract_docstring_with_swagger_info(method)
260
429
  limit = expensive_operation_limit if name in _EXPENSIVE_OPERATIONS else default_operation_limit
261
430
  route_view = limiter.limit(limit)(create_route(name, docstring))
262
431
  operations_bp.route(f'/{name}', methods=['POST'])(route_view)
432
+ if _selects_backend(method):
433
+ # Operations with more than one backend also answer which one the planner would pick.
434
+ plan_view = limiter.limit(default_operation_limit)(create_plan_route(name))
435
+ operations_bp.route(f'/{name}/plan', methods=['POST'])(plan_view)