@josephyan/qingflow-cli 0.2.0-beta.55

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 (79) hide show
  1. package/README.md +30 -0
  2. package/docs/local-agent-install.md +235 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +204 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +5 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +547 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +985 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +8243 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +78 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +184 -0
  21. package/src/qingflow_mcp/cli/commands/common.py +47 -0
  22. package/src/qingflow_mcp/cli/commands/imports.py +86 -0
  23. package/src/qingflow_mcp/cli/commands/record.py +202 -0
  24. package/src/qingflow_mcp/cli/commands/task.py +87 -0
  25. package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
  26. package/src/qingflow_mcp/cli/context.py +48 -0
  27. package/src/qingflow_mcp/cli/formatters.py +269 -0
  28. package/src/qingflow_mcp/cli/json_io.py +50 -0
  29. package/src/qingflow_mcp/cli/main.py +147 -0
  30. package/src/qingflow_mcp/config.py +221 -0
  31. package/src/qingflow_mcp/errors.py +66 -0
  32. package/src/qingflow_mcp/import_store.py +121 -0
  33. package/src/qingflow_mcp/json_types.py +18 -0
  34. package/src/qingflow_mcp/list_type_labels.py +76 -0
  35. package/src/qingflow_mcp/server.py +211 -0
  36. package/src/qingflow_mcp/server_app_builder.py +387 -0
  37. package/src/qingflow_mcp/server_app_user.py +317 -0
  38. package/src/qingflow_mcp/session_store.py +289 -0
  39. package/src/qingflow_mcp/solution/__init__.py +6 -0
  40. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  41. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  42. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  43. package/src/qingflow_mcp/solution/compiler/form_compiler.py +466 -0
  44. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  45. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  46. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  47. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  48. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  49. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  50. package/src/qingflow_mcp/solution/design_session.py +222 -0
  51. package/src/qingflow_mcp/solution/design_store.py +100 -0
  52. package/src/qingflow_mcp/solution/executor.py +2339 -0
  53. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  54. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  55. package/src/qingflow_mcp/solution/run_store.py +244 -0
  56. package/src/qingflow_mcp/solution/spec_models.py +853 -0
  57. package/src/qingflow_mcp/tools/__init__.py +1 -0
  58. package/src/qingflow_mcp/tools/ai_builder_tools.py +2063 -0
  59. package/src/qingflow_mcp/tools/app_tools.py +850 -0
  60. package/src/qingflow_mcp/tools/approval_tools.py +833 -0
  61. package/src/qingflow_mcp/tools/auth_tools.py +697 -0
  62. package/src/qingflow_mcp/tools/base.py +81 -0
  63. package/src/qingflow_mcp/tools/code_block_tools.py +679 -0
  64. package/src/qingflow_mcp/tools/directory_tools.py +648 -0
  65. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  66. package/src/qingflow_mcp/tools/file_tools.py +385 -0
  67. package/src/qingflow_mcp/tools/import_tools.py +1971 -0
  68. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  69. package/src/qingflow_mcp/tools/package_tools.py +240 -0
  70. package/src/qingflow_mcp/tools/portal_tools.py +131 -0
  71. package/src/qingflow_mcp/tools/qingbi_report_tools.py +269 -0
  72. package/src/qingflow_mcp/tools/record_tools.py +12739 -0
  73. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  74. package/src/qingflow_mcp/tools/solution_tools.py +3887 -0
  75. package/src/qingflow_mcp/tools/task_context_tools.py +1423 -0
  76. package/src/qingflow_mcp/tools/task_tools.py +843 -0
  77. package/src/qingflow_mcp/tools/view_tools.py +280 -0
  78. package/src/qingflow_mcp/tools/workflow_tools.py +312 -0
  79. package/src/qingflow_mcp/tools/workspace_tools.py +219 -0
@@ -0,0 +1,317 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import date
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from .backend_client import BackendClient
8
+ from .config import DEFAULT_PROFILE
9
+ from .session_store import SessionStore
10
+ from .tools.app_tools import AppTools
11
+ from .tools.auth_tools import AuthTools
12
+ from .tools.code_block_tools import CodeBlockTools
13
+ from .tools.directory_tools import DirectoryTools
14
+ from .tools.feedback_tools import FeedbackTools
15
+ from .tools.file_tools import FileTools
16
+ from .tools.import_tools import ImportTools
17
+ from .tools.task_context_tools import TaskContextTools
18
+ from .tools.workspace_tools import WorkspaceTools
19
+
20
+
21
+ def build_user_server() -> FastMCP:
22
+ today = date.today()
23
+ current_year = today.year
24
+ server = FastMCP(
25
+ "Qingflow App User MCP",
26
+ instructions=f"""Use this server for Qingflow operational workflows. Current date: `{today.isoformat()}`.
27
+
28
+ ## App Discovery
29
+
30
+ If `app_key` is unknown, use `app_list` or `app_search` first.
31
+ If the app is known but the data range is not, use `app_get` first and choose from `accessible_views`.
32
+ If an accessible view has `analysis_supported=false`, do not use it for `record_list` or `record_analyze`. `boardView` and `ganttView` are special UI views, not list/analyze targets.
33
+
34
+ ## Shared Helper
35
+
36
+ `feedback_submit` is always available as a cross-cutting helper.
37
+
38
+ - Use it when the current MCP capability is unsupported, awkward, or still cannot satisfy the user's need after reasonable use.
39
+ - It does not require Qingflow login or workspace selection.
40
+ - Call it only after the user explicitly confirms submission.
41
+
42
+ ## Schema-First Rule
43
+
44
+ Call `record_insert_schema_get` before `record_insert`.
45
+ Call `record_update_schema_get` before `record_update`.
46
+ Call `record_code_block_schema_get` before `record_code_block_run`.
47
+ Call `app_get` first when the data range is unclear, then use `record_browse_schema_get(view_id=...)` before `record_list`, `record_get`, or `record_analyze`.
48
+ Call `record_import_schema_get` when the import field mapping is unclear before template download or verify.
49
+
50
+ - All `field_id` values must come from the schema response.
51
+ - Never guess field names or ids.
52
+
53
+ ## Schema Scope
54
+
55
+ `record_insert_schema_get` returns the current user's insert-ready applicant schema; read `required_fields`, `optional_fields`, `runtime_linked_required_fields`, and `payload_template`.
56
+ Inside `optional_fields`, any field with `may_become_required=true` is still writable, but may become required when linked visibility or option-driven runtime rules activate.
57
+ `record_update_schema_get` returns the current record's overall update-ready writable field set across matched accessible views; read `writable_fields` and `payload_template`.
58
+ `record_browse_schema_get(view_id=...)` returns browse-schema fields for the selected accessible view.
59
+ `record_code_block_schema_get` returns code-block-ready schema for exact code block field selection.
60
+ `record_import_schema_get` returns import-ready column metadata.
61
+
62
+ - Hidden fields are omitted.
63
+ - Missing fields mean the field is not visible in the current permission scope.
64
+ - Read the top-level schema payload directly; do not guess missing writable fields.
65
+
66
+ ## Analytics Path
67
+
68
+ `app_get -> record_browse_schema_get(view_id=...) -> record_analyze`
69
+
70
+ Prefer `view_id` entries from `accessible_views` where `analysis_supported=true`.
71
+
72
+ Use this DSL shape:
73
+
74
+ - `dimensions`: `{{field_id, alias, bucket}}`
75
+ - `metrics`: `{{op, field_id, alias}}`
76
+ - `filters`: `{{field_id, op, value}}`
77
+ - `sort`: `{{by, order}}`
78
+
79
+ Important key rules:
80
+
81
+ - Use `op`
82
+ - Do **not** use `type`
83
+ - Do **not** use `agg`
84
+ - Do **not** use `aggregation`
85
+ - Do **not** use `operator`
86
+
87
+ Analysis answers must include concrete numbers. When applicable, include percentages based on the returned totals.
88
+
89
+ ## Record CRUD Path
90
+
91
+ `app_get -> record_browse_schema_get(view_id=...) -> record_list / record_get`
92
+ `record_insert_schema_get -> record_insert`
93
+ `record_update_schema_get -> record_update`
94
+ `record_list / record_get -> record_delete`
95
+ `record_code_block_schema_get -> record_code_block_run`
96
+
97
+ - Use `columns` as `[{{field_id}}]`
98
+ - Use `where` items as `{{field_id, op, value}}`
99
+ - Use `order_by` items as `{{field_id, direction}}`
100
+ - Legacy forms such as bare integer `field_id`, `fieldId`, `operator`, `values`, or `order` may still parse, but they are compatibility-only and not the canonical DSL
101
+
102
+ - `record_insert` uses an applicant-node `fields` map keyed by field title.
103
+ - `record_update` uses a field-title keyed `fields` map and internally selects the first accessible view that can execute the current payload.
104
+ - For insert, `runtime_linked_required_fields` means required-but-not-directly-writable fields that are usually supplied by runtime linkage or upstream context.
105
+ - For insert, fields marked `may_become_required=true` stay in `optional_fields`; they are still directly writable, but linked visibility or option-driven rules can make them required at runtime.
106
+ - Read field-level `linkage` whenever present on `record_insert_schema_get` or `record_update_schema_get`; it is the static hint for linked visibility, reference-driven auto fill, and formula/default auto-fill behavior.
107
+ - `linkage.sources` lists upstream field titles that influence the current field; `linkage.affects_fields` lists downstream fields that may change when the current field changes.
108
+ - `linkage.kind=logic_visibility` means linked visibility or option-driven rules are involved; `linkage.kind=reference_fill` means reference/default matching logic is involved; `linkage.kind=formula_fill` means formula/default auto-fill logic is involved.
109
+ - `record_update_schema_get` exposes the overall writable field set for the record, but not every field combination is guaranteed; `record_update` still needs one single matched accessible view that can cover the payload.
110
+ - `record_delete` deletes by `record_id` or `record_ids`.
111
+ - When readback shape matters after insert or update, prefer `record_get(..., output_profile="normalized")` or `record_list(..., output_profile="normalized")`.
112
+
113
+ - Read relation targets from `record_insert_schema_get` / `record_update_schema_get` relation metadata before preparing relation writes.
114
+ - Member and department fields may be written with natural strings directly on `record_insert` / `record_update`; only fall back to `record_member_candidates` or `record_department_candidates` when the user wants explicit candidate browsing or the write returns ambiguity that needs confirmation.
115
+ - If explicit candidate browsing is needed for default-all member or department fields, prefer those field candidate tools instead of starting with `directory_*`.
116
+
117
+ ## Code Block Path
118
+
119
+ Use `record_code_block_run` when the user wants to execute a form code-block field against an existing record.
120
+
121
+ - Always resolve the exact code-block field from `record_code_block_schema_get` first.
122
+ - Treat code-block execution as write-capable, not read-only.
123
+ - If the code block is bound to relation outputs, Qingflow may calculate target answers and write them back automatically.
124
+ - In workflow context, pass `role=3` and the exact `workflow_node_id`.
125
+ - After execution, inspect `outputs.alias_results`, `relation.target_fields`, and `writeback.verification` before claiming success.
126
+
127
+ ## Import Path
128
+
129
+ `app_get -> record_import_schema_get -> record_import_template_get -> record_import_verify -> (optional authorized record_import_repair_local) -> record_import_start -> record_import_status_get`
130
+
131
+ - Check `app_get.data.import_capability` before doing import work.
132
+ - If `import_capability.can_import=false`, stop before template download, file repair, or import start.
133
+ - Import must go through `verify -> start`; do not start directly from a raw file path.
134
+ - `record_import_start` requires an explicit `being_enter_auditing` choice. Do not assume a default.
135
+ - Do not modify user-uploaded files unless the user explicitly authorizes repair.
136
+ - If repair is authorized, keep the original file and repair a copy, then run `record_import_verify` again before `record_import_start`.
137
+
138
+ ## Task Workflow Path
139
+
140
+ `task_list -> task_get -> task_action_execute`
141
+
142
+ - Use `task_associated_report_detail_get` for associated view or report details.
143
+ - Use `task_workflow_log_get` for full workflow log history.
144
+ - Task actions operate on `app_key + record_id + workflow_node_id`, not `task_id`.
145
+
146
+ ## Time Handling
147
+
148
+ Normalize relative dates before building DSL.
149
+
150
+ - If the user says `3月` without a year, use the current year: `{current_year}`
151
+ - Convert month-only phrases into explicit legal date ranges
152
+ - Never send impossible dates such as `2026-02-29`
153
+
154
+ ## Environment
155
+
156
+ Default to `prod` unless the user explicitly specifies `test`.
157
+
158
+ ## Constraints
159
+
160
+ Avoid builder-side app or schema changes here.
161
+
162
+ ## Feedback Path
163
+
164
+ If the current MCP capability is unsupported, the workflow is awkward, or the user's need still cannot be satisfied after reasonable use, offer to submit product feedback.
165
+
166
+ - First summarize what is still not working
167
+ - Ask the user whether to submit feedback
168
+ - Call `feedback_submit` only after explicit user confirmation""",
169
+ )
170
+ sessions = SessionStore()
171
+ backend = BackendClient()
172
+ auth = AuthTools(sessions, backend)
173
+ apps = AppTools(sessions, backend)
174
+ workspace = WorkspaceTools(sessions, backend)
175
+ files = FileTools(sessions, backend)
176
+ imports = ImportTools(sessions, backend)
177
+ feedback = FeedbackTools(backend, mcp_side="App User MCP")
178
+
179
+ @server.tool()
180
+ def auth_login(
181
+ profile: str = DEFAULT_PROFILE,
182
+ base_url: str | None = None,
183
+ qf_version: str | None = None,
184
+ email: str = "",
185
+ password: str = "",
186
+ persist: bool = True,
187
+ ) -> dict:
188
+ return auth.auth_login(
189
+ profile=profile,
190
+ base_url=base_url,
191
+ qf_version=qf_version,
192
+ email=email,
193
+ password=password,
194
+ persist=persist,
195
+ )
196
+
197
+ @server.tool()
198
+ def auth_use_token(
199
+ profile: str = DEFAULT_PROFILE,
200
+ base_url: str | None = None,
201
+ qf_version: str | None = None,
202
+ token: str = "",
203
+ ws_id: int | None = None,
204
+ persist: bool = False,
205
+ ) -> dict:
206
+ return auth.auth_use_token(
207
+ profile=profile,
208
+ base_url=base_url,
209
+ qf_version=qf_version,
210
+ token=token,
211
+ ws_id=ws_id,
212
+ persist=persist,
213
+ )
214
+
215
+ @server.tool()
216
+ def auth_whoami(profile: str = DEFAULT_PROFILE) -> dict:
217
+ return auth.auth_whoami(profile=profile)
218
+
219
+ @server.tool()
220
+ def auth_logout(profile: str = DEFAULT_PROFILE, forget_persisted: bool = False) -> dict:
221
+ return auth.auth_logout(profile=profile, forget_persisted=forget_persisted)
222
+
223
+ @server.tool()
224
+ def workspace_list(
225
+ profile: str = DEFAULT_PROFILE,
226
+ page_num: int = 1,
227
+ page_size: int = 20,
228
+ include_external: bool = False,
229
+ ) -> dict:
230
+ return workspace.workspace_list(
231
+ profile=profile,
232
+ page_num=page_num,
233
+ page_size=page_size,
234
+ include_external=include_external,
235
+ )
236
+
237
+ @server.tool()
238
+ def workspace_select(profile: str = DEFAULT_PROFILE, ws_id: int = 0) -> dict:
239
+ return workspace.workspace_select(profile=profile, ws_id=ws_id)
240
+
241
+ @server.tool()
242
+ def app_list(profile: str = DEFAULT_PROFILE) -> dict:
243
+ return apps.app_list(profile=profile)
244
+
245
+ @server.tool()
246
+ def app_search(profile: str = DEFAULT_PROFILE, keyword: str = "", page_num: int = 1, page_size: int = 50) -> dict:
247
+ return apps.app_search(profile=profile, keyword=keyword, page_num=page_num, page_size=page_size)
248
+
249
+ @server.tool()
250
+ def app_get(profile: str = DEFAULT_PROFILE, app_key: str = "") -> dict:
251
+ return apps.app_get(profile=profile, app_key=app_key)
252
+
253
+ @server.tool()
254
+ def file_get_upload_info(
255
+ profile: str = DEFAULT_PROFILE,
256
+ upload_kind: str = "attachment",
257
+ file_name: str = "",
258
+ file_size: int = 0,
259
+ upload_mark: str | None = None,
260
+ content_type: str | None = None,
261
+ bucket_type: str | None = None,
262
+ path_id: int | None = None,
263
+ file_related_url: str | None = None,
264
+ ) -> dict:
265
+ return files.file_get_upload_info(
266
+ profile=profile,
267
+ upload_kind=upload_kind,
268
+ file_name=file_name,
269
+ file_size=file_size,
270
+ upload_mark=upload_mark,
271
+ content_type=content_type,
272
+ bucket_type=bucket_type,
273
+ path_id=path_id,
274
+ file_related_url=file_related_url,
275
+ )
276
+
277
+ @server.tool()
278
+ def file_upload_local(
279
+ profile: str = DEFAULT_PROFILE,
280
+ upload_kind: str = "attachment",
281
+ file_path: str = "",
282
+ upload_mark: str | None = None,
283
+ content_type: str | None = None,
284
+ bucket_type: str | None = None,
285
+ path_id: int | None = None,
286
+ file_related_url: str | None = None,
287
+ ) -> dict:
288
+ return files.file_upload_local(
289
+ profile=profile,
290
+ upload_kind=upload_kind,
291
+ file_path=file_path,
292
+ upload_mark=upload_mark,
293
+ content_type=content_type,
294
+ bucket_type=bucket_type,
295
+ path_id=path_id,
296
+ file_related_url=file_related_url,
297
+ )
298
+
299
+ imports.register(server)
300
+
301
+ feedback.register(server)
302
+ CodeBlockTools(sessions, backend).register(server)
303
+ TaskContextTools(sessions, backend).register(server)
304
+ DirectoryTools(sessions, backend).register(server)
305
+
306
+ return server
307
+
308
+
309
+ mcp = build_user_server()
310
+
311
+
312
+ def main() -> None:
313
+ mcp.run()
314
+
315
+
316
+ if __name__ == "__main__":
317
+ main()
@@ -0,0 +1,289 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ try:
9
+ import keyring
10
+ except ImportError:
11
+ keyring = None
12
+
13
+ from .config import get_profiles_path, normalize_base_url
14
+ from .json_types import JSONObject, KeyringBackend
15
+
16
+
17
+ KEYRING_SERVICE_NAME = "qingflow-mcp"
18
+ _UNSET = object()
19
+
20
+
21
+ def _utcnow() -> str:
22
+ return datetime.now(timezone.utc).isoformat()
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class SessionProfile:
27
+ profile: str
28
+ base_url: str
29
+ qf_version: str | None
30
+ qf_version_source: str | None
31
+ uid: int
32
+ email: str | None
33
+ nick_name: str | None
34
+ selected_ws_id: int | None
35
+ selected_ws_name: str | None
36
+ persisted: bool
37
+ created_at: str
38
+ updated_at: str
39
+
40
+ @classmethod
41
+ def from_dict(cls, value: JSONObject) -> "SessionProfile":
42
+ return cls(
43
+ profile=value["profile"],
44
+ base_url=value["base_url"],
45
+ qf_version=value.get("qf_version"),
46
+ qf_version_source=value.get("qf_version_source"),
47
+ uid=value["uid"],
48
+ email=value.get("email"),
49
+ nick_name=value.get("nick_name"),
50
+ selected_ws_id=value.get("selected_ws_id"),
51
+ selected_ws_name=value.get("selected_ws_name"),
52
+ persisted=bool(value.get("persisted", False)),
53
+ created_at=value.get("created_at", _utcnow()),
54
+ updated_at=value.get("updated_at", _utcnow()),
55
+ )
56
+
57
+
58
+ @dataclass(slots=True)
59
+ class BackendSession:
60
+ token: str
61
+ login_token: str | None
62
+ profile: str
63
+ base_url: str
64
+ qf_version: str | None
65
+ qf_version_source: str | None = None
66
+
67
+
68
+ class SessionStore:
69
+ def __init__(self, base_dir: Path | None = None, keyring_backend: KeyringBackend | None = None) -> None:
70
+ profiles_path = get_profiles_path() if base_dir is None else Path(base_dir) / "profiles.json"
71
+ self._profiles_path = profiles_path
72
+ self._profiles_path.parent.mkdir(parents=True, exist_ok=True)
73
+ self._keyring = keyring_backend if keyring_backend is not None else keyring
74
+ self._memory_sessions: dict[str, BackendSession] = {}
75
+ self._logged_out_profiles: set[str] = set()
76
+
77
+ def save_session(
78
+ self,
79
+ *,
80
+ profile: str,
81
+ base_url: str,
82
+ qf_version: str | None,
83
+ qf_version_source: str | None = None,
84
+ token: str,
85
+ login_token: str | None,
86
+ uid: int,
87
+ email: str | None,
88
+ nick_name: str | None,
89
+ persist: bool,
90
+ ) -> SessionProfile:
91
+ now = _utcnow()
92
+ previous = self.get_profile(profile)
93
+ persisted = False
94
+ if persist:
95
+ persisted = self._set_secret(self._token_key(profile), token)
96
+ if login_token:
97
+ self._set_secret(self._login_token_key(profile), login_token)
98
+ else:
99
+ self._delete_secret(self._login_token_key(profile))
100
+ else:
101
+ self._delete_secret(self._token_key(profile))
102
+ self._delete_secret(self._login_token_key(profile))
103
+ session_profile = SessionProfile(
104
+ profile=profile,
105
+ base_url=normalize_base_url(base_url) or base_url,
106
+ qf_version=(str(qf_version).strip() or None) if qf_version is not None else None,
107
+ qf_version_source=(str(qf_version_source).strip() or None) if qf_version_source is not None else None,
108
+ uid=uid,
109
+ email=email,
110
+ nick_name=nick_name,
111
+ selected_ws_id=None,
112
+ selected_ws_name=None,
113
+ persisted=persisted,
114
+ created_at=previous.created_at if previous else now,
115
+ updated_at=now,
116
+ )
117
+ self._memory_sessions[profile] = BackendSession(
118
+ token=token,
119
+ login_token=login_token,
120
+ profile=profile,
121
+ base_url=session_profile.base_url,
122
+ qf_version=session_profile.qf_version,
123
+ qf_version_source=session_profile.qf_version_source,
124
+ )
125
+ self._logged_out_profiles.discard(profile)
126
+ self._upsert_profile(session_profile)
127
+ return session_profile
128
+
129
+ def get_profile(self, profile: str) -> SessionProfile | None:
130
+ payload = self._load_profiles()
131
+ raw_profile = payload.get("profiles", {}).get(profile)
132
+ if not raw_profile:
133
+ return None
134
+ return SessionProfile.from_dict(raw_profile)
135
+
136
+ def get_backend_session(self, profile: str) -> BackendSession | None:
137
+ if profile in self._logged_out_profiles:
138
+ return None
139
+ memory_session = self._memory_sessions.get(profile)
140
+ session_profile = self.get_profile(profile)
141
+ if memory_session:
142
+ if session_profile is not None:
143
+ memory_session.base_url = session_profile.base_url
144
+ memory_session.qf_version = session_profile.qf_version
145
+ memory_session.qf_version_source = session_profile.qf_version_source
146
+ return memory_session
147
+ if not session_profile or not session_profile.persisted:
148
+ return None
149
+ token = self._get_secret(self._token_key(profile))
150
+ if not token:
151
+ return None
152
+ backend_session = BackendSession(
153
+ token=token,
154
+ login_token=self._get_secret(self._login_token_key(profile)),
155
+ profile=profile,
156
+ base_url=session_profile.base_url,
157
+ qf_version=session_profile.qf_version,
158
+ qf_version_source=session_profile.qf_version_source,
159
+ )
160
+ self._memory_sessions[profile] = backend_session
161
+ return backend_session
162
+
163
+ def select_workspace(self, profile: str, ws_id: int, ws_name: str | None) -> SessionProfile:
164
+ session_profile = self.get_profile(profile)
165
+ if session_profile is None:
166
+ raise KeyError(profile)
167
+ session_profile.selected_ws_id = ws_id
168
+ session_profile.selected_ws_name = ws_name
169
+ session_profile.updated_at = _utcnow()
170
+ self._upsert_profile(session_profile)
171
+ return session_profile
172
+
173
+ def update_route(self, profile: str, *, qf_version: str | None, qf_version_source: str | None) -> SessionProfile:
174
+ session_profile = self.get_profile(profile)
175
+ if session_profile is None:
176
+ raise KeyError(profile)
177
+ session_profile.qf_version = (str(qf_version).strip() or None) if qf_version is not None else None
178
+ session_profile.qf_version_source = (str(qf_version_source).strip() or None) if qf_version_source is not None else None
179
+ session_profile.updated_at = _utcnow()
180
+ self._upsert_profile(session_profile)
181
+ backend_session = self._memory_sessions.get(profile)
182
+ if backend_session is not None:
183
+ backend_session.qf_version = session_profile.qf_version
184
+ backend_session.qf_version_source = session_profile.qf_version_source
185
+ return session_profile
186
+
187
+ def update_profile_metadata(
188
+ self,
189
+ profile: str,
190
+ *,
191
+ uid: int | object = _UNSET,
192
+ email: str | None | object = _UNSET,
193
+ nick_name: str | None | object = _UNSET,
194
+ selected_ws_id: int | None | object = _UNSET,
195
+ selected_ws_name: str | None | object = _UNSET,
196
+ ) -> SessionProfile:
197
+ session_profile = self.get_profile(profile)
198
+ if session_profile is None:
199
+ raise KeyError(profile)
200
+ if uid is not _UNSET:
201
+ session_profile.uid = int(uid)
202
+ if email is not _UNSET:
203
+ session_profile.email = email if isinstance(email, str) or email is None else session_profile.email
204
+ if nick_name is not _UNSET:
205
+ session_profile.nick_name = nick_name if isinstance(nick_name, str) or nick_name is None else session_profile.nick_name
206
+ if selected_ws_id is not _UNSET:
207
+ session_profile.selected_ws_id = (
208
+ int(selected_ws_id)
209
+ if isinstance(selected_ws_id, int) and not isinstance(selected_ws_id, bool)
210
+ else None
211
+ )
212
+ if selected_ws_name is not _UNSET:
213
+ session_profile.selected_ws_name = (
214
+ selected_ws_name
215
+ if isinstance(selected_ws_name, str) or selected_ws_name is None
216
+ else session_profile.selected_ws_name
217
+ )
218
+ session_profile.updated_at = _utcnow()
219
+ self._upsert_profile(session_profile)
220
+ return session_profile
221
+
222
+ def logout(self, profile: str, forget_persisted: bool = False) -> None:
223
+ self._memory_sessions.pop(profile, None)
224
+ if forget_persisted:
225
+ self.invalidate(profile)
226
+ return
227
+ if self.get_profile(profile):
228
+ self._logged_out_profiles.add(profile)
229
+
230
+ def invalidate(self, profile: str) -> None:
231
+ self._memory_sessions.pop(profile, None)
232
+ self._logged_out_profiles.discard(profile)
233
+ self._delete_secret(self._token_key(profile))
234
+ self._delete_secret(self._login_token_key(profile))
235
+ payload = self._load_profiles()
236
+ profiles = payload.get("profiles", {})
237
+ if profile in profiles:
238
+ profiles.pop(profile)
239
+ self._save_profiles(payload)
240
+
241
+ def has_profile(self, profile: str) -> bool:
242
+ return self.get_profile(profile) is not None
243
+
244
+ def _token_key(self, profile: str) -> str:
245
+ return f"{profile}:token"
246
+
247
+ def _login_token_key(self, profile: str) -> str:
248
+ return f"{profile}:login-token"
249
+
250
+ def _upsert_profile(self, profile: SessionProfile) -> None:
251
+ payload = self._load_profiles()
252
+ payload.setdefault("profiles", {})[profile.profile] = asdict(profile)
253
+ self._save_profiles(payload)
254
+
255
+ def _load_profiles(self) -> JSONObject:
256
+ if not self._profiles_path.exists():
257
+ return {"profiles": {}}
258
+ with self._profiles_path.open("r", encoding="utf-8") as handle:
259
+ return json.load(handle)
260
+
261
+ def _save_profiles(self, payload: JSONObject) -> None:
262
+ self._profiles_path.parent.mkdir(parents=True, exist_ok=True)
263
+ with self._profiles_path.open("w", encoding="utf-8") as handle:
264
+ json.dump(payload, handle, ensure_ascii=False, indent=2)
265
+
266
+ def _set_secret(self, key: str, value: str) -> bool:
267
+ if self._keyring is None:
268
+ return False
269
+ try:
270
+ self._keyring.set_password(KEYRING_SERVICE_NAME, key, value)
271
+ return True
272
+ except Exception:
273
+ return False
274
+
275
+ def _get_secret(self, key: str) -> str | None:
276
+ if self._keyring is None:
277
+ return None
278
+ try:
279
+ return self._keyring.get_password(KEYRING_SERVICE_NAME, key)
280
+ except Exception:
281
+ return None
282
+
283
+ def _delete_secret(self, key: str) -> None:
284
+ if self._keyring is None:
285
+ return
286
+ try:
287
+ self._keyring.delete_password(KEYRING_SERVICE_NAME, key)
288
+ except Exception:
289
+ return
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from .normalizer import normalize_solution_spec
4
+ from .spec_models import SolutionSpec
5
+
6
+ __all__ = ["SolutionSpec", "normalize_solution_spec"]