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.
- {flamapy_rest-2.6.0.dev11 → flamapy_rest-2.9.0.dev0}/PKG-INFO +72 -7
- {flamapy_rest-2.6.0.dev11 → flamapy_rest-2.9.0.dev0}/README.md +70 -3
- {flamapy_rest-2.6.0.dev11 → flamapy_rest-2.9.0.dev0}/flamapy/interfaces/rest/operations_routes.py +204 -31
- flamapy_rest-2.9.0.dev0/flamapy/interfaces/rest/runner.py +140 -0
- {flamapy_rest-2.6.0.dev11 → flamapy_rest-2.9.0.dev0}/flamapy_rest.egg-info/PKG-INFO +72 -7
- {flamapy_rest-2.6.0.dev11 → flamapy_rest-2.9.0.dev0}/flamapy_rest.egg-info/requires.txt +1 -3
- {flamapy_rest-2.6.0.dev11 → flamapy_rest-2.9.0.dev0}/pyproject.toml +2 -4
- flamapy_rest-2.9.0.dev0/tests/test_api.py +295 -0
- {flamapy_rest-2.6.0.dev11 → flamapy_rest-2.9.0.dev0}/tests/test_basic_units.py +2 -1
- flamapy_rest-2.6.0.dev11/flamapy/interfaces/rest/runner.py +0 -70
- flamapy_rest-2.6.0.dev11/tests/test_api.py +0 -117
- {flamapy_rest-2.6.0.dev11 → flamapy_rest-2.9.0.dev0}/flamapy/interfaces/rest/config.py +0 -0
- {flamapy_rest-2.6.0.dev11 → flamapy_rest-2.9.0.dev0}/flamapy/interfaces/rest/extensions.py +0 -0
- {flamapy_rest-2.6.0.dev11 → flamapy_rest-2.9.0.dev0}/flamapy_rest.egg-info/SOURCES.txt +0 -0
- {flamapy_rest-2.6.0.dev11 → flamapy_rest-2.9.0.dev0}/flamapy_rest.egg-info/dependency_links.txt +0 -0
- {flamapy_rest-2.6.0.dev11 → flamapy_rest-2.9.0.dev0}/flamapy_rest.egg-info/top_level.txt +0 -0
- {flamapy_rest-2.6.0.dev11 → flamapy_rest-2.9.0.dev0}/setup.cfg +0 -0
- {flamapy_rest-2.6.0.dev11 → flamapy_rest-2.9.0.dev0}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: flamapy-rest
|
|
3
|
-
Version: 2.
|
|
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.
|
|
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
|
-
|
|
157
|
-
`/api/v1/operations/<operation_name
|
|
158
|
-
|
|
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
|
-
|
|
132
|
-
`/api/v1/operations/<operation_name
|
|
133
|
-
|
|
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>
|
{flamapy_rest-2.6.0.dev11 → flamapy_rest-2.9.0.dev0}/flamapy/interfaces/rest/operations_routes.py
RENAMED
|
@@ -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
|
|
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] =
|
|
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
|
-
|
|
153
|
-
|
|
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
|
|
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
|
-
|
|
179
|
-
if
|
|
180
|
-
|
|
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
|
-
|
|
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
|
|
280
|
+
if value is None:
|
|
196
281
|
return jsonify(error='Not valid result'), 404
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
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:
|
|
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)
|