graph-agents-cli 0.3.1__py3-none-any.whl
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.
- graph_agents_cli/__init__.py +26 -0
- graph_agents_cli/_api_policy.py +2145 -0
- graph_agents_cli/_approvals.py +400 -0
- graph_agents_cli/_build.py +186 -0
- graph_agents_cli/_build_info.json +7 -0
- graph_agents_cli/_chat_client.py +462 -0
- graph_agents_cli/_click.py +157 -0
- graph_agents_cli/_defaults.py +139 -0
- graph_agents_cli/_experiments.py +64 -0
- graph_agents_cli/_http.py +192 -0
- graph_agents_cli/_output.py +83 -0
- graph_agents_cli/_project.py +462 -0
- graph_agents_cli/_remote.py +220 -0
- graph_agents_cli/_response_schema.py +264 -0
- graph_agents_cli/_runner.py +319 -0
- graph_agents_cli/_skills_check.py +274 -0
- graph_agents_cli/_tools.py +189 -0
- graph_agents_cli/_trust.py +66 -0
- graph_agents_cli/api/__init__.py +15 -0
- graph_agents_cli/api/_changes.py +506 -0
- graph_agents_cli/api/_files.py +658 -0
- graph_agents_cli/api/cmd_api.py +2480 -0
- graph_agents_cli/deploy/__init__.py +15 -0
- graph_agents_cli/deploy/_config.py +171 -0
- graph_agents_cli/deploy/_image.py +128 -0
- graph_agents_cli/deploy/_kube.py +286 -0
- graph_agents_cli/deploy/_modes.py +234 -0
- graph_agents_cli/deploy/_preflight.py +370 -0
- graph_agents_cli/deploy/_values.py +168 -0
- graph_agents_cli/deploy/cmd_deploy.py +1866 -0
- graph_agents_cli/deploy/gitops.py +562 -0
- graph_agents_cli/deploy/local_load.py +273 -0
- graph_agents_cli/dev/__init__.py +13 -0
- graph_agents_cli/dev/cmd_build.py +131 -0
- graph_agents_cli/dev/cmd_install.py +78 -0
- graph_agents_cli/dev/cmd_lint.py +119 -0
- graph_agents_cli/dev/cmd_playground.py +297 -0
- graph_agents_cli/dev/policy_check.py +1287 -0
- graph_agents_cli/eval/__init__.py +22 -0
- graph_agents_cli/eval/_client.py +670 -0
- graph_agents_cli/eval/_common.py +177 -0
- graph_agents_cli/eval/_judge.py +168 -0
- graph_agents_cli/eval/_judge_runner.py +238 -0
- graph_agents_cli/eval/_paths.py +212 -0
- graph_agents_cli/eval/checks.py +581 -0
- graph_agents_cli/eval/cmd_analyze.py +278 -0
- graph_agents_cli/eval/cmd_compare.py +284 -0
- graph_agents_cli/eval/cmd_eval_group.py +80 -0
- graph_agents_cli/eval/cmd_generate.py +558 -0
- graph_agents_cli/eval/cmd_grade.py +466 -0
- graph_agents_cli/eval/cmd_metric.py +156 -0
- graph_agents_cli/eval/cmd_run.py +370 -0
- graph_agents_cli/eval/cmd_submit.py +400 -0
- graph_agents_cli/eval/config.py +435 -0
- graph_agents_cli/eval/dataset.py +350 -0
- graph_agents_cli/eval/gate.py +420 -0
- graph_agents_cli/eval/transcript.py +192 -0
- graph_agents_cli/extension/__init__.py +13 -0
- graph_agents_cli/extension/_compat.py +86 -0
- graph_agents_cli/extension/_loader.py +293 -0
- graph_agents_cli/extension/_manifest.py +135 -0
- graph_agents_cli/extension/_overrides.py +195 -0
- graph_agents_cli/extension/_paths.py +91 -0
- graph_agents_cli/extension/_refs.py +193 -0
- graph_agents_cli/extension/_resolver.py +453 -0
- graph_agents_cli/extension/_schema.py +106 -0
- graph_agents_cli/extension/_spec.py +253 -0
- graph_agents_cli/extension/_sync.py +102 -0
- graph_agents_cli/extension/_trust.py +58 -0
- graph_agents_cli/extension/cmd_extension_add.py +259 -0
- graph_agents_cli/extension/cmd_extension_group.py +57 -0
- graph_agents_cli/extension/cmd_extension_list.py +56 -0
- graph_agents_cli/extension/cmd_extension_remove.py +61 -0
- graph_agents_cli/extension/cmd_extension_update.py +195 -0
- graph_agents_cli/info/__init__.py +13 -0
- graph_agents_cli/info/cmd_info.py +222 -0
- graph_agents_cli/infra/__init__.py +15 -0
- graph_agents_cli/infra/checks.py +1169 -0
- graph_agents_cli/infra/cmd_infra.py +103 -0
- graph_agents_cli/main.py +591 -0
- graph_agents_cli/peer/__init__.py +15 -0
- graph_agents_cli/peer/_generate.py +254 -0
- graph_agents_cli/peer/cmd_peer.py +1151 -0
- graph_agents_cli/run/__init__.py +13 -0
- graph_agents_cli/run/_local_server.py +1157 -0
- graph_agents_cli/run/_signals.py +141 -0
- graph_agents_cli/run/cmd_approvals.py +530 -0
- graph_agents_cli/run/cmd_run.py +1421 -0
- graph_agents_cli/scaffold/__init__.py +19 -0
- graph_agents_cli/scaffold/agents/README.md +24 -0
- graph_agents_cli/scaffold/agents/empty_py/.template/templateconfig.yaml +22 -0
- graph_agents_cli/scaffold/agents/langgraph/.env.example +292 -0
- graph_agents_cli/scaffold/agents/langgraph/.template/templateconfig.yaml +28 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile +59 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile.langgraph-server +59 -0
- graph_agents_cli/scaffold/agents/langgraph/README.md +571 -0
- graph_agents_cli/scaffold/agents/langgraph/api-policy.yaml +60 -0
- graph_agents_cli/scaffold/agents/langgraph/app/__init__.py +20 -0
- graph_agents_cli/scaffold/agents/langgraph/app/agent.py +174 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/__init__.py +15 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a.py +2162 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a_client.py +1167 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/api_client.py +4220 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/approvals.py +1349 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/auth.py +1986 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/chat.py +2962 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/checkpointer.py +432 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/content.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/db.py +580 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/limits.py +203 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/metrics.py +231 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/middleware.py +361 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/model.py +611 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/playground.py +230 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/run_locks.py +459 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/structured.py +755 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/telemetry.py +681 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/threads.py +493 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/token_exchange.py +959 -0
- graph_agents_cli/scaffold/agents/langgraph/app/fast_api_app.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/__init__.py +55 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/custom.py +97 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/__init__.py +46 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/example_api.py +92 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/weather.py +33 -0
- graph_agents_cli/scaffold/agents/langgraph/langgraph.json +14 -0
- graph_agents_cli/scaffold/agents/langgraph/pyproject.toml +78 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/conftest.py +376 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/datasets/basic-dataset.json +53 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/eval_config.yaml +32 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/approval_graph.py +137 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_issuer.py +216 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_openai.py +357 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_outcomes.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_relay.py +479 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_api_surface.py +812 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals.py +1367 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals_server.py +794 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor.py +497 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor_server.py +247 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_history_repair.py +278 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_model_apis.py +242 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_postgres.py +637 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_resilience_postgres.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_runtime_guardrails.py +854 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_e2e.py +340 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_runtime.py +989 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_answers.py +584 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_server.py +222 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_token_exchange_issuer.py +650 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/.results/.placeholder +0 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/README.md +22 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/conftest.py +21 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/load_test.py +81 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_client.py +824 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_scoping.py +724 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client.py +1214 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client_hardening.py +716 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_policy_rpc.py +767 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_approval_ledger.py +1536 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_fake_model.py +115 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_jwt_policy.py +991 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_limits.py +310 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging.py +148 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging_hardening.py +271 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_policy.py +378 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_resilience.py +610 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_server_auth.py +702 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_structured.py +673 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_telemetry.py +404 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_thread_listing.py +255 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_threads.py +268 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_token_exchange.py +1320 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_untrusted_content.py +393 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-fastapi.lock +2084 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-langgraph-server.lock +2106 -0
- graph_agents_cli/scaffold/agents/langgraph/{{cookiecutter.agent_guidance_filename}} +129 -0
- graph_agents_cli/scaffold/base_templates/_shared/graph-agents-cli-manifest.yaml +36 -0
- graph_agents_cli/scaffold/base_templates/python/.dockerignore +32 -0
- graph_agents_cli/scaffold/base_templates/python/.github/CODEOWNERS +30 -0
- graph_agents_cli/scaffold/base_templates/python/.github/agent.env +7 -0
- graph_agents_cli/scaffold/base_templates/python/.github/workflows/pr_checks.yaml +214 -0
- graph_agents_cli/scaffold/base_templates/python/.gitignore +209 -0
- graph_agents_cli/scaffold/base_templates/python/tests/unit/test_dummy.py +23 -0
- graph_agents_cli/scaffold/base_templates/python/{{cookiecutter.agent_guidance_filename}} +35 -0
- graph_agents_cli/scaffold/cmd_scaffold_group.py +49 -0
- graph_agents_cli/scaffold/commands/__init__.py +13 -0
- graph_agents_cli/scaffold/commands/create.py +1424 -0
- graph_agents_cli/scaffold/commands/enhance.py +1652 -0
- graph_agents_cli/scaffold/commands/upgrade.py +570 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/agent.env +12 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/promote-to-prod.yaml +371 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/staging.yaml +450 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-dev.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-prod.yaml +41 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-staging.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/.helmignore +14 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/Chart.yaml +21 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/examples/networkpolicy.yaml +103 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/NOTES.txt +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/_helpers.tpl +189 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/certificate.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/configmap.yaml +10 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/deployment.yaml +199 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/hpa.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/httproute.yaml +30 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/ingress.yaml +39 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/networkpolicy.yaml +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/pdb.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/postgresql-secret.yaml +37 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/service.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/serviceaccount.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/servicemonitor.yaml +42 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-dev.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-prod.yaml +45 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-staging.yaml +29 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values.yaml +396 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/tests/integration/test_chart.py +269 -0
- graph_agents_cli/scaffold/deployment_targets/none/README.md +5 -0
- graph_agents_cli/scaffold/deployment_targets/none/python/README.md +6 -0
- graph_agents_cli/scaffold/utils/__init__.py +13 -0
- graph_agents_cli/scaffold/utils/backup.py +212 -0
- graph_agents_cli/scaffold/utils/build_record.py +257 -0
- graph_agents_cli/scaffold/utils/cli_options.py +184 -0
- graph_agents_cli/scaffold/utils/fs.py +83 -0
- graph_agents_cli/scaffold/utils/generate_locks.py +214 -0
- graph_agents_cli/scaffold/utils/generation_metadata.py +88 -0
- graph_agents_cli/scaffold/utils/keyedit.py +768 -0
- graph_agents_cli/scaffold/utils/keymerge.py +537 -0
- graph_agents_cli/scaffold/utils/language.py +138 -0
- graph_agents_cli/scaffold/utils/lock_utils.py +94 -0
- graph_agents_cli/scaffold/utils/logging.py +77 -0
- graph_agents_cli/scaffold/utils/manifest.py +292 -0
- graph_agents_cli/scaffold/utils/merge.py +970 -0
- graph_agents_cli/scaffold/utils/merge3.py +216 -0
- graph_agents_cli/scaffold/utils/openapi_seed.py +199 -0
- graph_agents_cli/scaffold/utils/remote_template.py +376 -0
- graph_agents_cli/scaffold/utils/template.py +1352 -0
- graph_agents_cli/scaffold/utils/upgrade.py +894 -0
- graph_agents_cli/scaffold/utils/version.py +438 -0
- graph_agents_cli/secrets/__init__.py +15 -0
- graph_agents_cli/secrets/_apply.py +954 -0
- graph_agents_cli/secrets/_required.py +188 -0
- graph_agents_cli/secrets/cmd_secrets.py +211 -0
- graph_agents_cli/setup/__init__.py +13 -0
- graph_agents_cli/setup/_antigravity.py +221 -0
- graph_agents_cli/setup/cmd_auth.py +1030 -0
- graph_agents_cli/setup/cmd_dev_token.py +513 -0
- graph_agents_cli/setup/cmd_setup.py +428 -0
- graph_agents_cli/setup/cmd_update.py +140 -0
- graph_agents_cli/skills/__init__.py +13 -0
- graph_agents_cli/skills/_bundle.py +65 -0
- graph_agents_cli/skills/data/README.md +19 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/SKILL.md +357 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/github-settings.md +113 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/gitops.md +137 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/kubernetes.md +315 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/secrets.md +160 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/SKILL.md +303 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/dataset_schema.md +282 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/metrics-guide.md +143 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/SKILL.md +659 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langchain-models.md +124 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langgraph.md +235 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/template-contract.md +477 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/SKILL.md +231 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/langsmith.md +46 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/otel.md +59 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/SKILL.md +414 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/references/flags.md +134 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/SKILL.md +478 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/brainstorming.md +118 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/commands.md +419 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/extension.md +156 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/internals.md +67 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/spec-template.md +56 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/terminology.md +119 -0
- graph_agents_cli/system/__init__.py +15 -0
- graph_agents_cli/system/_apply.py +519 -0
- graph_agents_cli/system/_checks.py +1023 -0
- graph_agents_cli/system/_deploy.py +215 -0
- graph_agents_cli/system/_model.py +363 -0
- graph_agents_cli/system/_system.py +664 -0
- graph_agents_cli/system/_views.py +208 -0
- graph_agents_cli/system/cmd_system.py +423 -0
- graph_agents_cli-0.3.1.dist-info/METADATA +162 -0
- graph_agents_cli-0.3.1.dist-info/RECORD +291 -0
- graph_agents_cli-0.3.1.dist-info/WHEEL +4 -0
- graph_agents_cli-0.3.1.dist-info/entry_points.txt +2 -0
- graph_agents_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
- graph_agents_cli-0.3.1.dist-info/licenses/NOTICE +19 -0
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
# Copyright 2026 graph-agents-cli contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Comment-preserving edits of YAML and env files, key by key.
|
|
16
|
+
|
|
17
|
+
``graph-agents-cli api`` changes ``api-policy.yaml``, the manifest, the chart's
|
|
18
|
+
``values.yaml`` and ``.env.example``, all files people edit and review. A
|
|
19
|
+
round trip through a YAML library would reformat them and drop comments, so
|
|
20
|
+
this module edits the text at the positions the parser reports, the way
|
|
21
|
+
:mod:`keymerge` applies a template change:
|
|
22
|
+
|
|
23
|
+
* a scalar or flow value (``[GET, POST]``, ``{connect: 2000}``) is replaced
|
|
24
|
+
where it stands, so the comment after it stays;
|
|
25
|
+
* a new key is inserted after its neighbour, at the indentation of the
|
|
26
|
+
mapping, and a new list item after the last item, at the list's indentation;
|
|
27
|
+
* a removed key or list item takes its own lines and nothing else.
|
|
28
|
+
|
|
29
|
+
Every edit is checked: the new text is parsed again (a repeated key is
|
|
30
|
+
refused) and must equal the old document with exactly that change, at that
|
|
31
|
+
one key. The old document is compared as a copy in which nothing is shared,
|
|
32
|
+
so an edit that would also change another key through a YAML anchor and
|
|
33
|
+
alias (``orders: &o ...`` / ``billing: *o``) or a merge key is refused rather
|
|
34
|
+
than widening that other key silently. When the check fails, or the text uses
|
|
35
|
+
a shape the edit cannot handle safely (a merge key in the edited mapping, a
|
|
36
|
+
multi-line flow value, ...), :class:`EditError` is raised and nothing changes.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import copy
|
|
42
|
+
import re
|
|
43
|
+
from collections.abc import Callable, Mapping
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
import yaml
|
|
47
|
+
|
|
48
|
+
from .keymerge import (
|
|
49
|
+
_OTHER_BREAKS,
|
|
50
|
+
_last_line,
|
|
51
|
+
_lines,
|
|
52
|
+
_newline,
|
|
53
|
+
)
|
|
54
|
+
from .merge3 import _parse_env, _StrictLoader
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class EditError(Exception):
|
|
58
|
+
"""The change cannot be made safely in this text; nothing was changed."""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
Path = tuple[Any, ...]
|
|
62
|
+
|
|
63
|
+
_MERGE_TAG = "tag:yaml.org,2002:merge"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Rendering values
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_collection(value: Any) -> bool:
|
|
72
|
+
return isinstance(value, dict | list)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def inline_ok(value: Any) -> bool:
|
|
76
|
+
"""True when ``value`` is written on its key's line: a scalar, or a flat list or mapping."""
|
|
77
|
+
if isinstance(value, dict):
|
|
78
|
+
return not any(_is_collection(v) for v in value.values())
|
|
79
|
+
if isinstance(value, list):
|
|
80
|
+
return not any(_is_collection(v) for v in value)
|
|
81
|
+
return True
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def inline(value: Any) -> str:
|
|
85
|
+
"""``value`` as YAML on one line (flow style for collections)."""
|
|
86
|
+
text = yaml.safe_dump(
|
|
87
|
+
value, default_flow_style=True, width=1 << 30, sort_keys=False, allow_unicode=True
|
|
88
|
+
)
|
|
89
|
+
if text.endswith("\n...\n"):
|
|
90
|
+
text = text[: -len("\n...\n")]
|
|
91
|
+
return text.strip()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def block_lines(value: Any, column: int, step: int = 2) -> list[str]:
|
|
95
|
+
"""``value`` (a mapping or a list) as block YAML lines starting at ``column``.
|
|
96
|
+
|
|
97
|
+
Flat collections inside it stay on their key's line (``methods: [GET]``);
|
|
98
|
+
list items that are mappings are written as block mappings
|
|
99
|
+
(``- operationId: x`` then `` path: /y``).
|
|
100
|
+
"""
|
|
101
|
+
if isinstance(value, dict):
|
|
102
|
+
lines: list[str] = []
|
|
103
|
+
for key, item in value.items():
|
|
104
|
+
if inline_ok(item):
|
|
105
|
+
lines.append(f"{' ' * column}{inline(key)}: {inline(item)}")
|
|
106
|
+
else:
|
|
107
|
+
lines.append(f"{' ' * column}{inline(key)}:")
|
|
108
|
+
lines.extend(block_lines(item, column + step, step))
|
|
109
|
+
return lines
|
|
110
|
+
if isinstance(value, list):
|
|
111
|
+
lines = []
|
|
112
|
+
for item in value:
|
|
113
|
+
if isinstance(item, dict) and item:
|
|
114
|
+
inner = block_lines(item, column + 2, step)
|
|
115
|
+
lines.append(f"{' ' * column}- {inner[0][column + 2 :]}")
|
|
116
|
+
lines.extend(inner[1:])
|
|
117
|
+
elif _is_collection(item) and not inline_ok(item):
|
|
118
|
+
raise EditError("a list nested directly in a list")
|
|
119
|
+
else:
|
|
120
|
+
lines.append(f"{' ' * column}- {inline(item)}")
|
|
121
|
+
return lines
|
|
122
|
+
raise EditError(f"{value!r} is not a mapping or a list")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# The document
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parse(text: str) -> Any:
|
|
131
|
+
return yaml.load(text, Loader=_StrictLoader)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _unshared(value: Any, _open: frozenset[int] = frozenset()) -> Any:
|
|
135
|
+
"""``value`` rebuilt so that no mapping or list appears twice in it.
|
|
136
|
+
|
|
137
|
+
YAML aliases make the parser hand back one object at every place that
|
|
138
|
+
repeats it, and ``copy.deepcopy`` keeps that sharing: an edit applied to
|
|
139
|
+
such a copy would reach the other places too, and the check would accept
|
|
140
|
+
a text that changes them. A recursive alias cannot be rebuilt: EditError.
|
|
141
|
+
"""
|
|
142
|
+
if not isinstance(value, dict | list):
|
|
143
|
+
return value
|
|
144
|
+
if id(value) in _open:
|
|
145
|
+
raise EditError("a recursive YAML alias")
|
|
146
|
+
inner = _open | {id(value)}
|
|
147
|
+
if isinstance(value, dict):
|
|
148
|
+
return {key: _unshared(item, inner) for key, item in value.items()}
|
|
149
|
+
return [_unshared(item, inner) for item in value]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _uses_aliases(text: str) -> bool:
|
|
153
|
+
"""True when the text repeats a node through an alias (``*name``, merge keys included)."""
|
|
154
|
+
try:
|
|
155
|
+
events = yaml.parse(text, Loader=yaml.SafeLoader)
|
|
156
|
+
return any(isinstance(event, yaml.AliasEvent) for event in events)
|
|
157
|
+
except yaml.YAMLError:
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class _Doc:
|
|
162
|
+
"""One parse of the text: nodes with their positions."""
|
|
163
|
+
|
|
164
|
+
def __init__(self, text: str) -> None:
|
|
165
|
+
self.text = text
|
|
166
|
+
self.lines = _lines(text)
|
|
167
|
+
self.newline = _newline(text)
|
|
168
|
+
try:
|
|
169
|
+
self.root = yaml.compose(text, Loader=yaml.SafeLoader)
|
|
170
|
+
except yaml.YAMLError as exc:
|
|
171
|
+
raise EditError(f"not valid YAML: {exc}") from exc
|
|
172
|
+
self._loader = yaml.SafeLoader("")
|
|
173
|
+
|
|
174
|
+
def key_of(self, node: yaml.Node) -> Any:
|
|
175
|
+
return self._loader.construct_object(node, deep=True)
|
|
176
|
+
|
|
177
|
+
def pairs(self, node: yaml.MappingNode) -> list[tuple[yaml.Node, yaml.Node]]:
|
|
178
|
+
pairs = []
|
|
179
|
+
for key_node, value_node in node.value:
|
|
180
|
+
if key_node.tag == _MERGE_TAG or not isinstance(key_node, yaml.ScalarNode):
|
|
181
|
+
raise EditError("a merge key or a complex key")
|
|
182
|
+
pairs.append((key_node, value_node))
|
|
183
|
+
return pairs
|
|
184
|
+
|
|
185
|
+
def pair(self, node: yaml.MappingNode, key: Any) -> tuple[yaml.Node, yaml.Node] | None:
|
|
186
|
+
for key_node, value_node in self.pairs(node):
|
|
187
|
+
if self.key_of(key_node) == key:
|
|
188
|
+
return key_node, value_node
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
def node(self, path: Path) -> yaml.Node | None:
|
|
192
|
+
node = self.root
|
|
193
|
+
for part in path:
|
|
194
|
+
if isinstance(node, yaml.MappingNode):
|
|
195
|
+
pair = self.pair(node, part)
|
|
196
|
+
if pair is None:
|
|
197
|
+
return None
|
|
198
|
+
node = pair[1]
|
|
199
|
+
elif isinstance(node, yaml.SequenceNode) and isinstance(part, int):
|
|
200
|
+
if not 0 <= part < len(node.value):
|
|
201
|
+
return None
|
|
202
|
+
node = node.value[part]
|
|
203
|
+
else:
|
|
204
|
+
return None
|
|
205
|
+
return node
|
|
206
|
+
|
|
207
|
+
def entry_lines(self, key_node: yaml.Node, value_node: yaml.Node) -> tuple[int, int]:
|
|
208
|
+
"""First and last line of a block mapping entry."""
|
|
209
|
+
return key_node.start_mark.line, max(_last_line(value_node), key_node.end_mark.line)
|
|
210
|
+
|
|
211
|
+
def step(self) -> int:
|
|
212
|
+
"""The indentation step of the first nested block mapping (2 when there is none)."""
|
|
213
|
+
pending = [self.root]
|
|
214
|
+
while pending:
|
|
215
|
+
node = pending.pop(0)
|
|
216
|
+
if not isinstance(node, yaml.MappingNode) or node.flow_style or not node.value:
|
|
217
|
+
continue
|
|
218
|
+
column = node.value[0][0].start_mark.column
|
|
219
|
+
for _key, child in node.value:
|
|
220
|
+
if isinstance(child, yaml.MappingNode) and not child.flow_style and child.value:
|
|
221
|
+
inner = child.value[0][0].start_mark.column
|
|
222
|
+
if inner > column:
|
|
223
|
+
return inner - column
|
|
224
|
+
pending.append(child)
|
|
225
|
+
return 2
|
|
226
|
+
|
|
227
|
+
def splice(self, start: int, end: int, replacement: str) -> str:
|
|
228
|
+
return self.text[:start] + replacement + self.text[end:]
|
|
229
|
+
|
|
230
|
+
def replace_lines(self, first: int, last: int, new: list[str]) -> str:
|
|
231
|
+
"""Lines ``first..last`` (inclusive) replaced by ``new`` (each without a newline)."""
|
|
232
|
+
block = [line + self.newline for line in new]
|
|
233
|
+
tail = self.lines[last + 1 :]
|
|
234
|
+
if not tail and self.lines and not self.lines[last].endswith("\n") and block:
|
|
235
|
+
block[-1] = block[-1].rstrip("\r\n")
|
|
236
|
+
return "".join([*self.lines[:first], *block, *tail])
|
|
237
|
+
|
|
238
|
+
def after_trailing_comments(self, at: int, column: int, *, last: bool = False) -> int:
|
|
239
|
+
"""``at`` moved past the comment lines that close the block above it.
|
|
240
|
+
|
|
241
|
+
Comments indented deeper than ``column`` belong to the entry above; so
|
|
242
|
+
do comments at ``column`` itself when that entry is the last of its
|
|
243
|
+
mapping (``# approval: ...`` at the end of an API). A new entry goes
|
|
244
|
+
after them. A comment at ``column`` before a following entry heads that
|
|
245
|
+
entry, so it stays below the new one.
|
|
246
|
+
"""
|
|
247
|
+
while at < len(self.lines):
|
|
248
|
+
line = self.lines[at]
|
|
249
|
+
indent = len(line) - len(line.lstrip(" "))
|
|
250
|
+
if not line.lstrip(" ").startswith("#"):
|
|
251
|
+
break
|
|
252
|
+
if indent < column or (indent == column and not last):
|
|
253
|
+
break
|
|
254
|
+
at += 1
|
|
255
|
+
return at
|
|
256
|
+
|
|
257
|
+
def splice_value(self, node: yaml.Node, replacement: str) -> str:
|
|
258
|
+
"""``node``'s text replaced, keeping a comment after it at its column when possible."""
|
|
259
|
+
start, end = node.start_mark.index, node.end_mark.index
|
|
260
|
+
rest = self.text[end:]
|
|
261
|
+
match = re.match(r"([ \t]+)#", rest)
|
|
262
|
+
if match:
|
|
263
|
+
gap = len(match.group(1)) + (end - start) - len(replacement)
|
|
264
|
+
spaces = " " * max(gap, 1)
|
|
265
|
+
return self.text[:start] + replacement + spaces + rest[len(match.group(1)) :]
|
|
266
|
+
return self.text[:start] + replacement + rest
|
|
267
|
+
|
|
268
|
+
def insert_lines(self, at: int, new: list[str]) -> str:
|
|
269
|
+
lines = list(self.lines)
|
|
270
|
+
if at > 0 and at == len(lines) and not lines[-1].endswith("\n"):
|
|
271
|
+
lines[-1] = lines[-1] + self.newline
|
|
272
|
+
block = [line + self.newline for line in new]
|
|
273
|
+
return "".join([*lines[:at], *block, *lines[at:]])
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _flow_single_line(node: yaml.Node) -> bool:
|
|
277
|
+
return node.start_mark.line == node.end_mark.line
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _is_flow(node: yaml.Node) -> bool:
|
|
281
|
+
return isinstance(node, yaml.CollectionNode) and bool(node.flow_style)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ---------------------------------------------------------------------------
|
|
285
|
+
# Data-side changes (what the edited text must mean)
|
|
286
|
+
# ---------------------------------------------------------------------------
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _data_parent(data: Any, path: Path) -> Any:
|
|
290
|
+
for part in path[:-1]:
|
|
291
|
+
data = data[part]
|
|
292
|
+
return data
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _data_set(data: Any, path: Path, value: Any) -> None:
|
|
296
|
+
parent = data
|
|
297
|
+
for part in path[:-1]:
|
|
298
|
+
if isinstance(parent, dict) and part not in parent:
|
|
299
|
+
parent[part] = {}
|
|
300
|
+
parent = parent[part]
|
|
301
|
+
parent[path[-1]] = copy.deepcopy(value)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _data_delete(data: Any, path: Path) -> None:
|
|
305
|
+
parent = _data_parent(data, path)
|
|
306
|
+
del parent[path[-1]]
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# ---------------------------------------------------------------------------
|
|
310
|
+
# Edits
|
|
311
|
+
# ---------------------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
class YamlText:
|
|
315
|
+
"""A YAML document's text with checked, comment-preserving edits.
|
|
316
|
+
|
|
317
|
+
Each method edits ``self.text`` in place and raises :class:`EditError`
|
|
318
|
+
(leaving the text unchanged) when the edit cannot be made safely.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
def __init__(self, text: str) -> None:
|
|
322
|
+
if _OTHER_BREAKS.search(text):
|
|
323
|
+
raise EditError("the text uses line breaks other than \\n and \\r\\n")
|
|
324
|
+
try:
|
|
325
|
+
self.data = _parse(text)
|
|
326
|
+
except yaml.YAMLError as exc:
|
|
327
|
+
raise EditError(f"not valid YAML: {exc}") from exc
|
|
328
|
+
if not isinstance(self.data, dict):
|
|
329
|
+
raise EditError("the document is not a mapping")
|
|
330
|
+
_unshared(self.data) # refuses a recursive alias up front
|
|
331
|
+
self.text = text
|
|
332
|
+
|
|
333
|
+
# -- plumbing -----------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
def _commit(self, new_text: str, change: Callable[[Any], None]) -> None:
|
|
336
|
+
# Unshared: the change lands at its one path, so a text in which it also
|
|
337
|
+
# reaches another key through an alias does not match.
|
|
338
|
+
expected = _unshared(self.data)
|
|
339
|
+
change(expected)
|
|
340
|
+
try:
|
|
341
|
+
parsed = _parse(new_text)
|
|
342
|
+
except yaml.YAMLError as exc:
|
|
343
|
+
raise EditError(f"the edited text is not valid YAML: {exc}") from exc
|
|
344
|
+
if _unshared(parsed) != expected:
|
|
345
|
+
if _uses_aliases(self.text):
|
|
346
|
+
raise EditError(
|
|
347
|
+
"the edit would also change another key that repeats the edited part "
|
|
348
|
+
"through a YAML alias (*name) or merge key (<<)"
|
|
349
|
+
)
|
|
350
|
+
raise EditError("the edited text would not mean exactly the intended change")
|
|
351
|
+
self.text, self.data = new_text, parsed
|
|
352
|
+
|
|
353
|
+
def get(self, path: Path, default: Any = None) -> Any:
|
|
354
|
+
data = self.data
|
|
355
|
+
for part in path:
|
|
356
|
+
if isinstance(data, dict) and part in data:
|
|
357
|
+
data = data[part]
|
|
358
|
+
elif isinstance(data, list) and isinstance(part, int) and 0 <= part < len(data):
|
|
359
|
+
data = data[part]
|
|
360
|
+
else:
|
|
361
|
+
return default
|
|
362
|
+
return data
|
|
363
|
+
|
|
364
|
+
# -- set ----------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
def set(
|
|
367
|
+
self,
|
|
368
|
+
path: Path,
|
|
369
|
+
value: Any,
|
|
370
|
+
*,
|
|
371
|
+
after: Any = None,
|
|
372
|
+
comment: str | None = None,
|
|
373
|
+
block: bool = False,
|
|
374
|
+
) -> None:
|
|
375
|
+
"""Set the key at ``path`` to ``value`` (missing parent mappings are created).
|
|
376
|
+
|
|
377
|
+
A new key goes after the key ``after`` of its mapping when that exists,
|
|
378
|
+
else after the last key; ``comment`` (without ``#``) is written on the
|
|
379
|
+
line above a new key, and ``block`` writes a new mapping or list value
|
|
380
|
+
in block style even when it would fit on the key's line.
|
|
381
|
+
"""
|
|
382
|
+
if self.get(path, _ABSENT) == value:
|
|
383
|
+
return
|
|
384
|
+
doc = _Doc(self.text)
|
|
385
|
+
parent_path, key = path[:-1], path[-1]
|
|
386
|
+
parent = doc.node(parent_path)
|
|
387
|
+
if parent is None or (isinstance(parent, yaml.ScalarNode) and parent.tag.endswith(":null")):
|
|
388
|
+
if not parent_path:
|
|
389
|
+
raise EditError("the document is empty")
|
|
390
|
+
# Create the missing parent with the key inside it.
|
|
391
|
+
self.set(parent_path, {key: value}, comment=comment)
|
|
392
|
+
return
|
|
393
|
+
if not isinstance(parent, yaml.MappingNode):
|
|
394
|
+
raise EditError(f"{'.'.join(map(str, parent_path))} is not a mapping")
|
|
395
|
+
if parent.flow_style:
|
|
396
|
+
if not _flow_single_line(parent):
|
|
397
|
+
raise EditError("a flow mapping written over several lines")
|
|
398
|
+
new_parent = dict(self.get(parent_path))
|
|
399
|
+
new_parent[key] = value
|
|
400
|
+
text = doc.splice(parent.start_mark.index, parent.end_mark.index, inline(new_parent))
|
|
401
|
+
self._commit(text, lambda d: _data_set(d, path, value))
|
|
402
|
+
return
|
|
403
|
+
pair = doc.pair(parent, key)
|
|
404
|
+
if pair is None:
|
|
405
|
+
text = self._insert_key(
|
|
406
|
+
doc, parent, key, value, after=after, comment=comment, block=block
|
|
407
|
+
)
|
|
408
|
+
else:
|
|
409
|
+
text = self._replace_value(doc, pair[0], pair[1], value)
|
|
410
|
+
self._commit(text, lambda d: _data_set(d, path, value))
|
|
411
|
+
|
|
412
|
+
def _insert_key(
|
|
413
|
+
self,
|
|
414
|
+
doc: _Doc,
|
|
415
|
+
parent: yaml.MappingNode,
|
|
416
|
+
key: Any,
|
|
417
|
+
value: Any,
|
|
418
|
+
*,
|
|
419
|
+
after: Any,
|
|
420
|
+
comment: str | None,
|
|
421
|
+
block: bool = False,
|
|
422
|
+
) -> str:
|
|
423
|
+
pairs = doc.pairs(parent)
|
|
424
|
+
if not pairs:
|
|
425
|
+
raise EditError("an empty block mapping")
|
|
426
|
+
column = pairs[0][0].start_mark.column
|
|
427
|
+
anchor = pairs[-1]
|
|
428
|
+
if after is not None:
|
|
429
|
+
anchor = doc.pair(parent, after) or anchor
|
|
430
|
+
at = doc.after_trailing_comments(
|
|
431
|
+
doc.entry_lines(*anchor)[1] + 1, column, last=anchor[0] is pairs[-1][0]
|
|
432
|
+
)
|
|
433
|
+
lines = [f"{' ' * column}# {comment}"] if comment else []
|
|
434
|
+
if inline_ok(value) and not (block and _is_collection(value) and value):
|
|
435
|
+
lines.append(f"{' ' * column}{inline(key)}: {inline(value)}")
|
|
436
|
+
else:
|
|
437
|
+
lines.append(f"{' ' * column}{inline(key)}:")
|
|
438
|
+
lines.extend(block_lines(value, column + doc.step(), doc.step()))
|
|
439
|
+
return doc.insert_lines(at, lines)
|
|
440
|
+
|
|
441
|
+
def _replace_value(
|
|
442
|
+
self, doc: _Doc, key_node: yaml.Node, value_node: yaml.Node, value: Any
|
|
443
|
+
) -> str:
|
|
444
|
+
column = key_node.start_mark.column
|
|
445
|
+
scalar = isinstance(value_node, yaml.ScalarNode) and value_node.style not in ("|", ">")
|
|
446
|
+
if scalar or _is_flow(value_node):
|
|
447
|
+
if not _flow_single_line(value_node):
|
|
448
|
+
raise EditError("a value written over several lines")
|
|
449
|
+
empty_flow = _is_flow(value_node) and not value_node.value
|
|
450
|
+
if inline_ok(value) or not (scalar or empty_flow):
|
|
451
|
+
text = inline(value)
|
|
452
|
+
if value_node.start_mark.index == value_node.end_mark.index:
|
|
453
|
+
text = " " + text # `key:` with nothing after it
|
|
454
|
+
return doc.splice_value(value_node, text)
|
|
455
|
+
# `key: []` (or a scalar) becomes a block collection under the key;
|
|
456
|
+
# a comment after the value stays on the key's line, at its column.
|
|
457
|
+
line_no = key_node.start_mark.line
|
|
458
|
+
if value_node.start_mark.line != line_no:
|
|
459
|
+
raise EditError("a value on the line after its key")
|
|
460
|
+
line = doc.lines[line_no].rstrip("\r\n")
|
|
461
|
+
start = value_node.start_mark.column
|
|
462
|
+
end = value_node.end_mark.column
|
|
463
|
+
head, tail = line[:start].rstrip(), line[end:]
|
|
464
|
+
if tail.strip():
|
|
465
|
+
head = head + " " * (len(line[:end]) - len(head.rstrip()))
|
|
466
|
+
new_line = head + tail
|
|
467
|
+
else:
|
|
468
|
+
new_line = head
|
|
469
|
+
return doc.replace_lines(
|
|
470
|
+
line_no,
|
|
471
|
+
line_no,
|
|
472
|
+
[new_line, *block_lines(value, column + doc.step(), doc.step())],
|
|
473
|
+
)
|
|
474
|
+
# A block collection: rewrite its lines below the key.
|
|
475
|
+
first = value_node.start_mark.line
|
|
476
|
+
last = _last_line(value_node)
|
|
477
|
+
if first <= key_node.start_mark.line:
|
|
478
|
+
raise EditError("a block collection on its key's line")
|
|
479
|
+
if _is_collection(value) and not value:
|
|
480
|
+
# Empty: `key: []` / `key: {}` on the key's line.
|
|
481
|
+
line_no = key_node.start_mark.line
|
|
482
|
+
line = doc.lines[line_no].rstrip("\r\n")
|
|
483
|
+
colon = line.index(":", key_node.end_mark.column)
|
|
484
|
+
inserted = " " + inline(value)
|
|
485
|
+
rest = line[colon + 1 :]
|
|
486
|
+
match = re.match(r"( +)#", rest)
|
|
487
|
+
if match and len(match.group(1)) > len(inserted):
|
|
488
|
+
rest = rest[len(inserted) :] # the comment keeps its column
|
|
489
|
+
new_line = line[: colon + 1] + inserted + rest
|
|
490
|
+
return doc.replace_lines(line_no, last, [new_line])
|
|
491
|
+
if not _is_collection(value):
|
|
492
|
+
raise EditError("a block collection replaced by a scalar")
|
|
493
|
+
if isinstance(value_node, yaml.SequenceNode):
|
|
494
|
+
kept = _scalar_list_lines(doc, value_node, value)
|
|
495
|
+
if kept is not None:
|
|
496
|
+
return doc.replace_lines(first, last, kept)
|
|
497
|
+
inner_column = value_node.start_mark.column
|
|
498
|
+
else:
|
|
499
|
+
inner_column = value_node.value[0][0].start_mark.column
|
|
500
|
+
return doc.replace_lines(first, last, block_lines(value, inner_column, doc.step()))
|
|
501
|
+
|
|
502
|
+
# -- delete -------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
def delete(self, path: Path) -> None:
|
|
505
|
+
"""Remove the key at ``path`` (and its lines); its mapping must keep another key."""
|
|
506
|
+
if self.get(path, _ABSENT) is _ABSENT:
|
|
507
|
+
return
|
|
508
|
+
doc = _Doc(self.text)
|
|
509
|
+
parent = doc.node(path[:-1])
|
|
510
|
+
if not isinstance(parent, yaml.MappingNode):
|
|
511
|
+
raise EditError("the parent is not a mapping")
|
|
512
|
+
if len(parent.value) < 2:
|
|
513
|
+
raise EditError(f"{path[-1]} is the only key of its mapping")
|
|
514
|
+
if parent.flow_style:
|
|
515
|
+
if not _flow_single_line(parent):
|
|
516
|
+
raise EditError("a flow mapping written over several lines")
|
|
517
|
+
new_parent = {k: v for k, v in self.get(path[:-1]).items() if k != path[-1]}
|
|
518
|
+
text = doc.splice(parent.start_mark.index, parent.end_mark.index, inline(new_parent))
|
|
519
|
+
else:
|
|
520
|
+
pair = doc.pair(parent, path[-1])
|
|
521
|
+
assert pair is not None
|
|
522
|
+
first, last = doc.entry_lines(*pair)
|
|
523
|
+
line = doc.lines[first]
|
|
524
|
+
if pair[0].start_mark.column != len(line) - len(line.lstrip(" ")):
|
|
525
|
+
# `- key: value`: the line also opens a list item.
|
|
526
|
+
raise EditError("the key shares its line with a list item's dash")
|
|
527
|
+
text = doc.replace_lines(first, last, [])
|
|
528
|
+
self._commit(text, lambda d: _data_delete(d, path))
|
|
529
|
+
|
|
530
|
+
# -- lists --------------------------------------------------------------
|
|
531
|
+
|
|
532
|
+
def append(self, path: Path, item: Any, *, after: Any = None) -> None:
|
|
533
|
+
"""Append ``item`` to the list at ``path`` (created after the key ``after`` when absent)."""
|
|
534
|
+
current = self.get(path, _ABSENT)
|
|
535
|
+
if current is _ABSENT:
|
|
536
|
+
self.set(path, [item], after=after)
|
|
537
|
+
return
|
|
538
|
+
if not isinstance(current, list):
|
|
539
|
+
raise EditError(f"{'.'.join(map(str, path))} is not a list")
|
|
540
|
+
doc = _Doc(self.text)
|
|
541
|
+
node = doc.node(path)
|
|
542
|
+
assert node is not None
|
|
543
|
+
new_list = [*current, item]
|
|
544
|
+
if _is_flow(node) or not current:
|
|
545
|
+
self.set(path, new_list)
|
|
546
|
+
return
|
|
547
|
+
dash = node.start_mark.column
|
|
548
|
+
gap = node.value[0].start_mark.column - dash # "- " is 2; "- " is 4
|
|
549
|
+
lines = block_lines([item], dash, doc.step())
|
|
550
|
+
if gap > 2:
|
|
551
|
+
extra = " " * (gap - 2)
|
|
552
|
+
lines = [lines[0][: dash + 1] + extra + lines[0][dash + 1 :]] + [
|
|
553
|
+
extra + line for line in lines[1:]
|
|
554
|
+
]
|
|
555
|
+
text = doc.insert_lines(doc.after_trailing_comments(_last_line(node) + 1, dash + 1), lines)
|
|
556
|
+
self._commit(text, lambda d: _data_set(d, path, new_list))
|
|
557
|
+
|
|
558
|
+
def remove_item(self, path: Path, index: int) -> None:
|
|
559
|
+
"""Remove item ``index`` of the list at ``path`` (an emptied list becomes ``[]``)."""
|
|
560
|
+
current = self.get(path, _ABSENT)
|
|
561
|
+
if not isinstance(current, list) or not 0 <= index < len(current):
|
|
562
|
+
raise EditError(f"{'.'.join(map(str, path))} has no item {index}")
|
|
563
|
+
new_list = [item for i, item in enumerate(current) if i != index]
|
|
564
|
+
doc = _Doc(self.text)
|
|
565
|
+
node = doc.node(path)
|
|
566
|
+
assert isinstance(node, yaml.SequenceNode)
|
|
567
|
+
if _is_flow(node) or not new_list:
|
|
568
|
+
self.set(path, new_list)
|
|
569
|
+
return
|
|
570
|
+
item_node = node.value[index]
|
|
571
|
+
first = item_node.start_mark.line
|
|
572
|
+
last = _last_line(item_node)
|
|
573
|
+
# Items share no lines in a block list; the dash sits on the item's first line.
|
|
574
|
+
text = doc.replace_lines(first, last, [])
|
|
575
|
+
self._commit(text, lambda d: _data_set(d, path, new_list))
|
|
576
|
+
|
|
577
|
+
def wrap_in_list(self, path: Path) -> None:
|
|
578
|
+
"""Make the mapping at ``path`` the only item of a list, keeping its lines and comments.
|
|
579
|
+
|
|
580
|
+
A block mapping is indented under a dash where it stands (``approval:``
|
|
581
|
+
then `` required_for: ...`` becomes ``approval:`` then
|
|
582
|
+
`` - required_for: ...``, every line of it two columns deeper, comment
|
|
583
|
+
lines inside it included); a one-line flow mapping moves to its own
|
|
584
|
+
item line below the key, and a comment after it stays on the key's line.
|
|
585
|
+
"""
|
|
586
|
+
current = self.get(path, _ABSENT)
|
|
587
|
+
if not isinstance(current, dict) or not current or not path:
|
|
588
|
+
raise EditError(f"{'.'.join(map(str, path))} is not a non-empty mapping")
|
|
589
|
+
doc = _Doc(self.text)
|
|
590
|
+
parent = doc.node(path[:-1])
|
|
591
|
+
if not isinstance(parent, yaml.MappingNode) or parent.flow_style:
|
|
592
|
+
raise EditError("the mapping's parent is not a block mapping")
|
|
593
|
+
pair = doc.pair(parent, path[-1])
|
|
594
|
+
assert pair is not None
|
|
595
|
+
key_node, value_node = pair
|
|
596
|
+
line_no = key_node.start_mark.line
|
|
597
|
+
if _is_flow(value_node):
|
|
598
|
+
if not _flow_single_line(value_node) or value_node.start_mark.line != line_no:
|
|
599
|
+
raise EditError("a flow mapping written over several lines, or below its key")
|
|
600
|
+
line = doc.lines[line_no].rstrip("\r\n")
|
|
601
|
+
flow = doc.text[value_node.start_mark.index : value_node.end_mark.index]
|
|
602
|
+
end = value_node.end_mark.column
|
|
603
|
+
head, tail = line[: value_node.start_mark.column].rstrip(), line[end:]
|
|
604
|
+
# A comment after the value keeps its column on the key's line.
|
|
605
|
+
key_line = head + " " * (end - len(head)) + tail if tail.strip() else head
|
|
606
|
+
column = key_node.start_mark.column + doc.step()
|
|
607
|
+
text = doc.replace_lines(line_no, line_no, [key_line, f"{' ' * column}- {flow}"])
|
|
608
|
+
else:
|
|
609
|
+
if not isinstance(value_node, yaml.MappingNode) or not value_node.value:
|
|
610
|
+
raise EditError("not a block mapping")
|
|
611
|
+
first = value_node.start_mark.line
|
|
612
|
+
last = _last_line(value_node)
|
|
613
|
+
if first <= line_no:
|
|
614
|
+
raise EditError("a block mapping on its key's line")
|
|
615
|
+
column = value_node.value[0][0].start_mark.column
|
|
616
|
+
lines: list[str] = []
|
|
617
|
+
for number in range(first, last + 1):
|
|
618
|
+
line = doc.lines[number].rstrip("\r\n")
|
|
619
|
+
if number == first:
|
|
620
|
+
if line[:column].strip():
|
|
621
|
+
raise EditError("the mapping shares its first line")
|
|
622
|
+
lines.append(f"{line[:column]}- {line[column:]}")
|
|
623
|
+
else:
|
|
624
|
+
lines.append(f" {line}" if line.strip() else line)
|
|
625
|
+
text = doc.replace_lines(first, last, lines)
|
|
626
|
+
self._commit(text, lambda d: _data_set(d, path, [copy.deepcopy(current)]))
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
_ABSENT: Any = type("Absent", (), {"__repr__": lambda self: "<absent>"})()
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _scalar_list_lines(doc: _Doc, node: yaml.SequenceNode, value: Any) -> list[str] | None:
|
|
633
|
+
"""A block list of scalars rewritten item by item, or None when it is another shape.
|
|
634
|
+
|
|
635
|
+
An item that stays keeps its own line, with the comment after it and the
|
|
636
|
+
comment lines above it (``- GET # reads``); a new item gets a line like
|
|
637
|
+
the first one's; an item that goes takes its comment lines with it.
|
|
638
|
+
"""
|
|
639
|
+
if not isinstance(value, list) or not value or any(_is_collection(v) for v in value):
|
|
640
|
+
return None
|
|
641
|
+
items = node.value
|
|
642
|
+
for item in items:
|
|
643
|
+
if not isinstance(item, yaml.ScalarNode) or item.style in ("|", ">"):
|
|
644
|
+
return None
|
|
645
|
+
line = doc.lines[item.start_mark.line]
|
|
646
|
+
if (
|
|
647
|
+
item.end_mark.line != item.start_mark.line
|
|
648
|
+
or line[: item.start_mark.column].strip() != "-"
|
|
649
|
+
):
|
|
650
|
+
return None # the item is not on its dash's line, or spans several lines
|
|
651
|
+
dash = node.start_mark.column
|
|
652
|
+
gap = items[0].start_mark.column - dash
|
|
653
|
+
groups: dict[Any, list[list[str]]] = {}
|
|
654
|
+
start = node.start_mark.line
|
|
655
|
+
for item in items:
|
|
656
|
+
group = [line.rstrip("\r\n") for line in doc.lines[start : item.end_mark.line + 1]]
|
|
657
|
+
groups.setdefault(doc.key_of(item), []).append(group)
|
|
658
|
+
start = item.end_mark.line + 1
|
|
659
|
+
lines: list[str] = []
|
|
660
|
+
for item in value:
|
|
661
|
+
reusable = groups.get(item)
|
|
662
|
+
if reusable:
|
|
663
|
+
lines.extend(reusable.pop(0))
|
|
664
|
+
else:
|
|
665
|
+
lines.append(f"{' ' * dash}-{' ' * (gap - 1)}{inline(item)}")
|
|
666
|
+
return lines
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# ---------------------------------------------------------------------------
|
|
670
|
+
# env files
|
|
671
|
+
# ---------------------------------------------------------------------------
|
|
672
|
+
|
|
673
|
+
_ENV_NAME = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=")
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def env_names(text: str) -> set[str]:
|
|
677
|
+
"""The variables an env file assigns (``NAME=value`` lines)."""
|
|
678
|
+
return set(_parse_env(text))
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def env_insert(
|
|
682
|
+
text: str,
|
|
683
|
+
lines: list[str],
|
|
684
|
+
*,
|
|
685
|
+
section: re.Pattern[str] | None = None,
|
|
686
|
+
before: re.Pattern[str] | None = None,
|
|
687
|
+
drop: list[str] | None = None,
|
|
688
|
+
) -> str:
|
|
689
|
+
"""``text`` with ``lines`` inserted; :class:`EditError` when that is not safe.
|
|
690
|
+
|
|
691
|
+
The lines go at the end of the section whose header line matches
|
|
692
|
+
``section`` (the section ends at the next ``# ---`` header), before the
|
|
693
|
+
first line of that section matching ``before``; without the section, at
|
|
694
|
+
the end of the file. ``drop`` lines (exact, stripped) inside the section
|
|
695
|
+
are removed (a note that no longer applies). The result must assign
|
|
696
|
+
exactly the old variables plus the new ones.
|
|
697
|
+
"""
|
|
698
|
+
if _OTHER_BREAKS.search(text):
|
|
699
|
+
raise EditError("the text uses line breaks other than \\n and \\r\\n")
|
|
700
|
+
old = _parse_env(text)
|
|
701
|
+
added = _parse_env("\n".join(lines))
|
|
702
|
+
if set(added) & set(old):
|
|
703
|
+
raise EditError(f"already set: {', '.join(sorted(set(added) & set(old)))}")
|
|
704
|
+
newline = _newline(text)
|
|
705
|
+
current = _lines(text)
|
|
706
|
+
start = next(
|
|
707
|
+
(i for i, line in enumerate(current) if section is not None and section.match(line)), None
|
|
708
|
+
)
|
|
709
|
+
if start is None:
|
|
710
|
+
at = len(current)
|
|
711
|
+
block = [*lines]
|
|
712
|
+
if current and current[-1].strip():
|
|
713
|
+
block.insert(0, "")
|
|
714
|
+
dropped: set[int] = set()
|
|
715
|
+
else:
|
|
716
|
+
end = next(
|
|
717
|
+
(i for i in range(start + 1, len(current)) if current[i].startswith("# ---")),
|
|
718
|
+
len(current),
|
|
719
|
+
)
|
|
720
|
+
at = end
|
|
721
|
+
while at > start + 1 and not current[at - 1].strip():
|
|
722
|
+
at -= 1 # before the blank lines that close the section
|
|
723
|
+
if before is not None:
|
|
724
|
+
at = next((i for i in range(start + 1, end) if before.match(current[i])), at)
|
|
725
|
+
wanted = {line.strip() for line in drop or []}
|
|
726
|
+
dropped = {i for i in range(start + 1, end) if current[i].strip() in wanted}
|
|
727
|
+
block = list(lines)
|
|
728
|
+
if at == len(current) and current and not current[-1].endswith("\n"):
|
|
729
|
+
current[-1] += newline
|
|
730
|
+
out = [line for i, line in enumerate(current[:at]) if i not in dropped]
|
|
731
|
+
out += [line + newline for line in block]
|
|
732
|
+
out += [line for i, line in enumerate(current[at:], start=at) if i not in dropped]
|
|
733
|
+
new_text = "".join(out)
|
|
734
|
+
if _parse_env(new_text) != {**old, **added}:
|
|
735
|
+
raise EditError("the edited env file would not mean exactly the intended change")
|
|
736
|
+
return new_text
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def env_remove(text: str, names: list[str], *, comments: list[str] | None = None) -> str:
|
|
740
|
+
"""``text`` without the lines assigning ``names`` (and the ``comments`` lines right above).
|
|
741
|
+
|
|
742
|
+
``comments`` are exact lines (stripped) removed only when they sit
|
|
743
|
+
directly above a removed assignment.
|
|
744
|
+
"""
|
|
745
|
+
if _OTHER_BREAKS.search(text):
|
|
746
|
+
raise EditError("the text uses line breaks other than \\n and \\r\\n")
|
|
747
|
+
old = _parse_env(text)
|
|
748
|
+
current = _lines(text)
|
|
749
|
+
remove: set[int] = set()
|
|
750
|
+
wanted = {line.strip() for line in comments or []}
|
|
751
|
+
for i, line in enumerate(current):
|
|
752
|
+
match = _ENV_NAME.match(line)
|
|
753
|
+
if match and match.group(1) in names:
|
|
754
|
+
remove.add(i)
|
|
755
|
+
j = i - 1
|
|
756
|
+
while j >= 0 and current[j].strip() in wanted:
|
|
757
|
+
remove.add(j)
|
|
758
|
+
j -= 1
|
|
759
|
+
new_text = "".join(line for i, line in enumerate(current) if i not in remove)
|
|
760
|
+
expected = {k: v for k, v in old.items() if k not in names}
|
|
761
|
+
if _parse_env(new_text) != expected:
|
|
762
|
+
raise EditError("the edited env file would not mean exactly the intended change")
|
|
763
|
+
return new_text
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def mapping_keys(value: Any) -> list[Any]:
|
|
767
|
+
"""The keys of a mapping value (empty for anything else)."""
|
|
768
|
+
return list(value) if isinstance(value, Mapping) else []
|