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,537 @@
|
|
|
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
|
+
"""Key-level three-way merge for YAML and env config files.
|
|
16
|
+
|
|
17
|
+
The line merge in :mod:`merge3` gives up as soon as the developer edited a line
|
|
18
|
+
next to one the template changes (``replicaCount: 3`` two lines above
|
|
19
|
+
``runtime: fastapi`` is enough). A runtime or model-provider change must still
|
|
20
|
+
reach the keys the chart and the app read, so this module applies the
|
|
21
|
+
template's change key by key instead:
|
|
22
|
+
|
|
23
|
+
* the template's change is the list of keys whose value differs between the
|
|
24
|
+
old render and the new one (set, added or removed);
|
|
25
|
+
* each such key the developer left as the old render had it is edited in place
|
|
26
|
+
in the developer's text: a scalar value is replaced where it stands, a new
|
|
27
|
+
entry is inserted next to its neighbour in the template (with the comment
|
|
28
|
+
lines directly above it), a removed entry is deleted;
|
|
29
|
+
* a key the developer changed too is left alone and returned as a
|
|
30
|
+
:class:`KeyConflict`, so the caller can say exactly what is left to do.
|
|
31
|
+
|
|
32
|
+
Comments, formatting and every other edit of the developer's file are kept.
|
|
33
|
+
The edited text is parsed again (a repeated key is refused) and must equal the
|
|
34
|
+
developer's document with exactly the applied changes; anything else returns
|
|
35
|
+
``None`` and the caller treats the whole file as not mergeable.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import copy
|
|
41
|
+
import re
|
|
42
|
+
from collections.abc import Callable
|
|
43
|
+
from dataclasses import dataclass, field
|
|
44
|
+
from typing import Any
|
|
45
|
+
|
|
46
|
+
import yaml
|
|
47
|
+
|
|
48
|
+
from .merge3 import _parse_env, _StrictLoader
|
|
49
|
+
|
|
50
|
+
MISSING: Any = type("Missing", (), {"__repr__": lambda self: "<absent>"})()
|
|
51
|
+
|
|
52
|
+
_ENV_LINE = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=")
|
|
53
|
+
_COMMENT_LINE = re.compile(r"^\s*#")
|
|
54
|
+
# Line breaks PyYAML counts that a split on "\n" would not: refused, so that
|
|
55
|
+
# the line numbers of the parsed nodes and of the text always agree.
|
|
56
|
+
_OTHER_BREAKS = re.compile("\r(?!\n)|[\x85\u2028\u2029]")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class KeyConflict:
|
|
61
|
+
"""A key the template changed that the developer changed differently."""
|
|
62
|
+
|
|
63
|
+
path: tuple[Any, ...]
|
|
64
|
+
yours: Any # MISSING: the developer removed it
|
|
65
|
+
template: Any # MISSING: the new settings no longer set it
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def dotted(self) -> str:
|
|
69
|
+
return ".".join(str(part) for part in self.path)
|
|
70
|
+
|
|
71
|
+
def describe(self) -> str:
|
|
72
|
+
if self.yours is MISSING:
|
|
73
|
+
return f"{self.dotted}: you removed it; the new settings set {_show(self.template)}"
|
|
74
|
+
if self.template is MISSING:
|
|
75
|
+
return (
|
|
76
|
+
f"{self.dotted}: you set {_show(self.yours)}; the new settings no longer "
|
|
77
|
+
"set it (remove it if nothing needs it)"
|
|
78
|
+
)
|
|
79
|
+
return (
|
|
80
|
+
f"{self.dotted}: you set {_show(self.yours)}; the new settings set "
|
|
81
|
+
f"{_show(self.template)}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class KeyMergeResult:
|
|
87
|
+
"""The developer's text with every non-conflicting template change applied."""
|
|
88
|
+
|
|
89
|
+
text: str
|
|
90
|
+
applied: list[tuple[Any, ...]] = field(default_factory=list)
|
|
91
|
+
conflicts: list[KeyConflict] = field(default_factory=list)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _show(value: Any) -> str:
|
|
95
|
+
if isinstance(value, dict | list):
|
|
96
|
+
text = yaml.safe_dump(value, default_flow_style=True, width=10_000).strip()
|
|
97
|
+
return text if len(text) <= 80 else text[:77] + "..."
|
|
98
|
+
return repr(value)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class _Change:
|
|
103
|
+
path: tuple[Any, ...]
|
|
104
|
+
old: Any
|
|
105
|
+
new: Any
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _changes(base: dict, theirs: dict, prefix: tuple[Any, ...] = ()) -> list[_Change]:
|
|
109
|
+
"""Keys whose value differs between the two renders (mappings compared key by key)."""
|
|
110
|
+
changes: list[_Change] = []
|
|
111
|
+
for key in [*theirs, *(k for k in base if k not in theirs)]:
|
|
112
|
+
old, new = base.get(key, MISSING), theirs.get(key, MISSING)
|
|
113
|
+
if old is not MISSING and new is not MISSING and old == new:
|
|
114
|
+
continue
|
|
115
|
+
if isinstance(old, dict) and isinstance(new, dict):
|
|
116
|
+
changes.extend(_changes(old, new, (*prefix, key)))
|
|
117
|
+
else:
|
|
118
|
+
changes.append(_Change((*prefix, key), old, new))
|
|
119
|
+
return changes
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _get(data: Any, path: tuple[Any, ...]) -> Any:
|
|
123
|
+
for key in path:
|
|
124
|
+
if not isinstance(data, dict) or key not in data:
|
|
125
|
+
return MISSING
|
|
126
|
+
data = data[key]
|
|
127
|
+
return data
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _set(data: dict, path: tuple[Any, ...], value: Any) -> None:
|
|
131
|
+
for key in path[:-1]:
|
|
132
|
+
data = data[key]
|
|
133
|
+
if value is MISSING:
|
|
134
|
+
data.pop(path[-1], None)
|
|
135
|
+
else:
|
|
136
|
+
data[path[-1]] = copy.deepcopy(value)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _lines(text: str) -> list[str]:
|
|
140
|
+
"""Lines with their ``\\n`` kept (a missing final newline stays missing)."""
|
|
141
|
+
parts = text.split("\n")
|
|
142
|
+
lines = [part + "\n" for part in parts[:-1]]
|
|
143
|
+
if parts[-1]:
|
|
144
|
+
lines.append(parts[-1])
|
|
145
|
+
return lines
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _newline(text: str) -> str:
|
|
149
|
+
return "\r\n" if "\r\n" in text else "\n"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _with_newline(line: str, newline: str) -> str:
|
|
153
|
+
return line.rstrip("\r\n") + newline
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _reindent(lines: list[str], delta: int) -> list[str]:
|
|
157
|
+
if delta == 0:
|
|
158
|
+
return list(lines)
|
|
159
|
+
out: list[str] = []
|
|
160
|
+
for line in lines:
|
|
161
|
+
if not line.strip():
|
|
162
|
+
out.append(line)
|
|
163
|
+
elif delta > 0:
|
|
164
|
+
out.append(" " * delta + line)
|
|
165
|
+
else:
|
|
166
|
+
strip = min(-delta, len(line) - len(line.lstrip(" ")))
|
|
167
|
+
out.append(line[strip:])
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _comment_block_above(lines: list[str], first: int, column: int) -> int:
|
|
172
|
+
"""First line of the comment lines directly above ``first`` at ``column`` or deeper."""
|
|
173
|
+
start = first
|
|
174
|
+
while start > 0:
|
|
175
|
+
above = lines[start - 1]
|
|
176
|
+
if not _COMMENT_LINE.match(above):
|
|
177
|
+
break
|
|
178
|
+
if len(above) - len(above.lstrip(" ")) < column:
|
|
179
|
+
break
|
|
180
|
+
start -= 1
|
|
181
|
+
return start
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _stripped(lines: list[str]) -> set[str]:
|
|
185
|
+
return {line.strip() for line in lines if line.strip()}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _template_comments(
|
|
189
|
+
lines: list[str],
|
|
190
|
+
first: int,
|
|
191
|
+
column: int,
|
|
192
|
+
base_lines: list[str],
|
|
193
|
+
base_first: int,
|
|
194
|
+
base_column: int,
|
|
195
|
+
theirs_lines: list[str],
|
|
196
|
+
) -> list[int]:
|
|
197
|
+
"""Indexes of the comment lines above an entry being removed that go with it.
|
|
198
|
+
|
|
199
|
+
Only the template's own comment block qualifies: the lines directly above
|
|
200
|
+
the entry must read exactly as they do above the same entry in the old
|
|
201
|
+
render, and a line the new render still has (a section heading) stays.
|
|
202
|
+
"""
|
|
203
|
+
attached = _comment_block_above(lines, first, column)
|
|
204
|
+
base_start = _comment_block_above(base_lines, base_first, base_column)
|
|
205
|
+
ours_block = [line.strip() for line in lines[attached:first]]
|
|
206
|
+
if not ours_block or ours_block != [line.strip() for line in base_lines[base_start:base_first]]:
|
|
207
|
+
return []
|
|
208
|
+
keep = _stripped(theirs_lines)
|
|
209
|
+
return [i for i in range(attached, first) if lines[i].strip() not in keep]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _new_entry_block(
|
|
213
|
+
theirs_lines: list[str], first: int, last: int, column: int, ours_lines: list[str]
|
|
214
|
+
) -> list[str]:
|
|
215
|
+
"""The template's lines for an entry being added, with the comments directly above it.
|
|
216
|
+
|
|
217
|
+
A comment line the developer's file already has (a section heading the
|
|
218
|
+
entry sits under in both) is not repeated.
|
|
219
|
+
"""
|
|
220
|
+
start = _comment_block_above(theirs_lines, first, column)
|
|
221
|
+
present = _stripped(ours_lines)
|
|
222
|
+
comments = [line for line in theirs_lines[start:first] if line.strip() not in present]
|
|
223
|
+
return [*comments, *theirs_lines[first : last + 1]]
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _insert(lines: list[str], at: int, block: list[str], newline: str) -> str:
|
|
227
|
+
block = [_with_newline(line, newline) for line in block]
|
|
228
|
+
if at > 0 and not lines[at - 1].endswith("\n"):
|
|
229
|
+
lines = [*lines[: at - 1], lines[at - 1] + newline, *lines[at:]]
|
|
230
|
+
return "".join([*lines[:at], *block, *lines[at:]])
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _remove(lines: list[str], first: int, last: int, comments: list[int]) -> str:
|
|
234
|
+
drop = set(comments) | set(range(first, last + 1))
|
|
235
|
+
return "".join(line for number, line in enumerate(lines) if number not in drop)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
# YAML
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
_MERGE_TAG = "tag:yaml.org,2002:merge"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class _Unsupported(Exception):
|
|
247
|
+
"""The edit cannot be made safely in this text (flow style, missing parent...)."""
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class _YamlDoc:
|
|
251
|
+
"""A composed YAML document: node positions for editing its text."""
|
|
252
|
+
|
|
253
|
+
def __init__(self, text: str) -> None:
|
|
254
|
+
self.text = text
|
|
255
|
+
self.lines = _lines(text)
|
|
256
|
+
root = yaml.compose(text, Loader=yaml.SafeLoader)
|
|
257
|
+
if not isinstance(root, yaml.MappingNode) or root.flow_style:
|
|
258
|
+
raise _Unsupported("not a block mapping")
|
|
259
|
+
self.root = root
|
|
260
|
+
self._loader = yaml.SafeLoader("")
|
|
261
|
+
|
|
262
|
+
def _key(self, node: yaml.Node) -> Any:
|
|
263
|
+
return self._loader.construct_object(node, deep=True)
|
|
264
|
+
|
|
265
|
+
def mapping(self, path: tuple[Any, ...]) -> yaml.MappingNode:
|
|
266
|
+
node: yaml.Node = self.root
|
|
267
|
+
for key in path:
|
|
268
|
+
pair = self._pair(node, key)
|
|
269
|
+
if pair is None:
|
|
270
|
+
raise _Unsupported(f"no {key!r}")
|
|
271
|
+
node = pair[1]
|
|
272
|
+
if not isinstance(node, yaml.MappingNode) or node.flow_style or not node.value:
|
|
273
|
+
raise _Unsupported("not a non-empty block mapping")
|
|
274
|
+
return node
|
|
275
|
+
|
|
276
|
+
@staticmethod
|
|
277
|
+
def _own_pairs(node: yaml.MappingNode) -> list[tuple[yaml.Node, yaml.Node]]:
|
|
278
|
+
"""The entries written in this mapping (a ``<<`` merge key brings others in)."""
|
|
279
|
+
return [
|
|
280
|
+
(key_node, value_node)
|
|
281
|
+
for key_node, value_node in node.value
|
|
282
|
+
if isinstance(key_node, yaml.ScalarNode) and key_node.tag != _MERGE_TAG
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
def _pair(self, node: yaml.Node, key: Any) -> tuple[yaml.Node, yaml.Node] | None:
|
|
286
|
+
if not isinstance(node, yaml.MappingNode):
|
|
287
|
+
return None
|
|
288
|
+
for key_node, value_node in self._own_pairs(node):
|
|
289
|
+
if self._key(key_node) == key:
|
|
290
|
+
return key_node, value_node
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
def keys(self, mapping: yaml.MappingNode) -> list[Any]:
|
|
294
|
+
return [self._key(k) for k, _v in self._own_pairs(mapping)]
|
|
295
|
+
|
|
296
|
+
def entry(self, path: tuple[Any, ...]) -> tuple[yaml.Node, yaml.Node, int, int]:
|
|
297
|
+
"""``(key node, value node, first line, last line)`` of the entry at ``path``."""
|
|
298
|
+
parent = self.mapping(path[:-1])
|
|
299
|
+
pair = self._pair(parent, path[-1])
|
|
300
|
+
if pair is None:
|
|
301
|
+
raise _Unsupported(f"no {path[-1]!r}")
|
|
302
|
+
key_node, value_node = pair
|
|
303
|
+
first = key_node.start_mark.line
|
|
304
|
+
last = max(_last_line(value_node), key_node.end_mark.line)
|
|
305
|
+
return key_node, value_node, first, last
|
|
306
|
+
|
|
307
|
+
def column(self, path: tuple[Any, ...]) -> int:
|
|
308
|
+
"""The key column of the mapping at ``path``."""
|
|
309
|
+
return self.mapping(path).value[0][0].start_mark.column
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _is_flow(node: yaml.Node) -> bool:
|
|
313
|
+
return isinstance(node, yaml.CollectionNode) and bool(node.flow_style)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _last_line(node: yaml.Node) -> int:
|
|
317
|
+
"""The last line holding content of ``node`` (not the blank or comment lines after it)."""
|
|
318
|
+
if isinstance(node, yaml.ScalarNode):
|
|
319
|
+
end = node.end_mark
|
|
320
|
+
if node.style in ("|", ">") and end.column == 0:
|
|
321
|
+
return max(end.line - 1, node.start_mark.line)
|
|
322
|
+
return end.line
|
|
323
|
+
if isinstance(node, yaml.CollectionNode) and (node.flow_style or not node.value):
|
|
324
|
+
return node.end_mark.line
|
|
325
|
+
last = node.value[-1]
|
|
326
|
+
if isinstance(node, yaml.MappingNode):
|
|
327
|
+
return max(_last_line(last[1]), last[0].end_mark.line)
|
|
328
|
+
return _last_line(last)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _yaml_apply(ours: str, theirs: _YamlDoc, base: _YamlDoc, change: _Change) -> str:
|
|
332
|
+
"""``ours`` with one template change applied (``_Unsupported`` when it cannot be)."""
|
|
333
|
+
doc = _YamlDoc(ours)
|
|
334
|
+
lines = doc.lines
|
|
335
|
+
newline = _newline(ours)
|
|
336
|
+
path = change.path
|
|
337
|
+
if change.old is not MISSING and change.new is not MISSING:
|
|
338
|
+
key_node, value_node, first, last = doc.entry(path)
|
|
339
|
+
t_key, t_value, t_first, t_last = theirs.entry(path)
|
|
340
|
+
if (
|
|
341
|
+
isinstance(value_node, yaml.ScalarNode)
|
|
342
|
+
and isinstance(t_value, yaml.ScalarNode)
|
|
343
|
+
and value_node.style not in ("|", ">")
|
|
344
|
+
and t_value.style not in ("|", ">")
|
|
345
|
+
and value_node.start_mark.line == value_node.end_mark.line
|
|
346
|
+
):
|
|
347
|
+
new_value = theirs.text[t_value.start_mark.index : t_value.end_mark.index]
|
|
348
|
+
if value_node.start_mark.index == value_node.end_mark.index:
|
|
349
|
+
# `key:` with nothing after it (null): keep one space before the value.
|
|
350
|
+
new_value = " " + new_value
|
|
351
|
+
return (
|
|
352
|
+
ours[: value_node.start_mark.index] + new_value + ours[value_node.end_mark.index :]
|
|
353
|
+
)
|
|
354
|
+
if _is_flow(value_node) and value_node.start_mark.line != first:
|
|
355
|
+
raise _Unsupported("flow collection on its own line")
|
|
356
|
+
block = _reindent(
|
|
357
|
+
theirs.lines[t_first : t_last + 1], key_node.start_mark.column - t_key.start_mark.column
|
|
358
|
+
)
|
|
359
|
+
block = [_with_newline(line, newline) for line in block]
|
|
360
|
+
if last + 1 >= len(lines) and not lines[last].endswith("\n"):
|
|
361
|
+
block[-1] = block[-1].rstrip("\r\n")
|
|
362
|
+
return "".join([*lines[:first], *block, *lines[last + 1 :]])
|
|
363
|
+
|
|
364
|
+
if change.new is MISSING: # the template removed the entry
|
|
365
|
+
key_node, _value, first, last = doc.entry(path)
|
|
366
|
+
if len(doc.mapping(path[:-1]).value) == 1:
|
|
367
|
+
raise _Unsupported("the last key of its mapping")
|
|
368
|
+
b_key, _bv, b_first, _bl = base.entry(path)
|
|
369
|
+
comments = _template_comments(
|
|
370
|
+
lines,
|
|
371
|
+
first,
|
|
372
|
+
key_node.start_mark.column,
|
|
373
|
+
base.lines,
|
|
374
|
+
b_first,
|
|
375
|
+
b_key.start_mark.column,
|
|
376
|
+
theirs.lines,
|
|
377
|
+
)
|
|
378
|
+
return _remove(lines, first, last, comments)
|
|
379
|
+
|
|
380
|
+
# The template added the entry.
|
|
381
|
+
parent = doc.mapping(path[:-1])
|
|
382
|
+
t_keys = theirs.keys(theirs.mapping(path[:-1]))
|
|
383
|
+
ours_keys = doc.keys(parent)
|
|
384
|
+
index = t_keys.index(path[-1])
|
|
385
|
+
t_key, _tv, t_first, t_last = theirs.entry(path)
|
|
386
|
+
column = parent.value[0][0].start_mark.column
|
|
387
|
+
block = _reindent(
|
|
388
|
+
_new_entry_block(theirs.lines, t_first, t_last, t_key.start_mark.column, lines),
|
|
389
|
+
column - t_key.start_mark.column,
|
|
390
|
+
)
|
|
391
|
+
before = next((k for k in reversed(t_keys[:index]) if k in ours_keys), MISSING)
|
|
392
|
+
after = next((k for k in t_keys[index + 1 :] if k in ours_keys), MISSING)
|
|
393
|
+
if before is not MISSING:
|
|
394
|
+
at = doc.entry((*path[:-1], before))[3] + 1
|
|
395
|
+
elif after is not MISSING:
|
|
396
|
+
k_node, _v, first, _l = doc.entry((*path[:-1], after))
|
|
397
|
+
at = _comment_block_above(lines, first, k_node.start_mark.column)
|
|
398
|
+
else:
|
|
399
|
+
at = doc.entry((*path[:-1], ours_keys[-1]))[3] + 1
|
|
400
|
+
return _insert(lines, at, block, newline)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
# ---------------------------------------------------------------------------
|
|
404
|
+
# env files (NAME=value lines)
|
|
405
|
+
# ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _env_index(lines: list[str]) -> dict[str, int]:
|
|
409
|
+
found: dict[str, int] = {}
|
|
410
|
+
for number, line in enumerate(lines):
|
|
411
|
+
match = _ENV_LINE.match(line)
|
|
412
|
+
if match:
|
|
413
|
+
found[match.group(1)] = number
|
|
414
|
+
return found
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _env_apply(ours: str, theirs: str, base: str, change: _Change) -> str:
|
|
418
|
+
lines = _lines(ours)
|
|
419
|
+
newline = _newline(ours)
|
|
420
|
+
index = _env_index(lines)
|
|
421
|
+
t_lines = _lines(theirs)
|
|
422
|
+
t_index = _env_index(t_lines)
|
|
423
|
+
name = change.path[0]
|
|
424
|
+
if change.old is not MISSING and change.new is not MISSING:
|
|
425
|
+
at = index[name]
|
|
426
|
+
new_line = _with_newline(t_lines[t_index[name]], newline)
|
|
427
|
+
if not lines[at].endswith("\n"):
|
|
428
|
+
new_line = new_line.rstrip("\r\n")
|
|
429
|
+
return "".join([*lines[:at], new_line, *lines[at + 1 :]])
|
|
430
|
+
if change.new is MISSING:
|
|
431
|
+
at = index[name]
|
|
432
|
+
b_lines = _lines(base)
|
|
433
|
+
comments = _template_comments(lines, at, 0, b_lines, _env_index(b_lines)[name], 0, t_lines)
|
|
434
|
+
return _remove(lines, at, at, comments)
|
|
435
|
+
t_at = t_index[name]
|
|
436
|
+
block = _new_entry_block(t_lines, t_at, t_at, 0, lines)
|
|
437
|
+
order = list(t_index)
|
|
438
|
+
position = order.index(name)
|
|
439
|
+
before = next((k for k in reversed(order[:position]) if k in index), None)
|
|
440
|
+
after = next((k for k in order[position + 1 :] if k in index), None)
|
|
441
|
+
if before is not None:
|
|
442
|
+
at = index[before] + 1
|
|
443
|
+
elif after is not None:
|
|
444
|
+
at = _comment_block_above(lines, index[after], 0)
|
|
445
|
+
else:
|
|
446
|
+
at = len(lines)
|
|
447
|
+
return _insert(lines, at, block, newline)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
# ---------------------------------------------------------------------------
|
|
451
|
+
# entry point
|
|
452
|
+
# ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _kind(path: str) -> str | None:
|
|
456
|
+
name = path.replace("\\", "/").rsplit("/", 1)[-1]
|
|
457
|
+
if name.endswith((".yaml", ".yml")):
|
|
458
|
+
return "yaml"
|
|
459
|
+
if name.startswith(".env") or name.endswith(".env"):
|
|
460
|
+
return "env"
|
|
461
|
+
return None
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def supports_key_merge(path: str) -> bool:
|
|
465
|
+
"""True for the file types :func:`merge_keys` understands (YAML, env files)."""
|
|
466
|
+
return _kind(path) is not None
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def merge_keys(base: str, ours: str, theirs: str, path: str) -> KeyMergeResult | None:
|
|
470
|
+
"""Apply the template's change (``base`` -> ``theirs``) to ``ours`` key by key.
|
|
471
|
+
|
|
472
|
+
``None`` when the file type is not supported, a text does not parse as a
|
|
473
|
+
single mapping, or the edited text would not mean exactly the developer's
|
|
474
|
+
document plus the applied changes.
|
|
475
|
+
"""
|
|
476
|
+
kind = _kind(path)
|
|
477
|
+
if kind is None or any(_OTHER_BREAKS.search(text) for text in (base, ours, theirs)):
|
|
478
|
+
return None
|
|
479
|
+
parse: Callable[[str], Any]
|
|
480
|
+
if kind == "yaml":
|
|
481
|
+
|
|
482
|
+
def parse(text: str) -> Any:
|
|
483
|
+
return yaml.load(text, Loader=_StrictLoader)
|
|
484
|
+
|
|
485
|
+
else:
|
|
486
|
+
parse = _parse_env
|
|
487
|
+
try:
|
|
488
|
+
base_data, ours_data, theirs_data = parse(base), parse(ours), parse(theirs)
|
|
489
|
+
except (ValueError, yaml.YAMLError):
|
|
490
|
+
return None
|
|
491
|
+
if not all(isinstance(d, dict) for d in (base_data, ours_data, theirs_data)):
|
|
492
|
+
return None
|
|
493
|
+
try:
|
|
494
|
+
base_doc = _YamlDoc(base) if kind == "yaml" else None
|
|
495
|
+
theirs_doc = _YamlDoc(theirs) if kind == "yaml" else None
|
|
496
|
+
except (_Unsupported, yaml.YAMLError):
|
|
497
|
+
return None
|
|
498
|
+
|
|
499
|
+
text = ours
|
|
500
|
+
expected = copy.deepcopy(ours_data)
|
|
501
|
+
result = KeyMergeResult(text=ours)
|
|
502
|
+
for change in _changes(base_data, theirs_data):
|
|
503
|
+
yours = _get(ours_data, change.path)
|
|
504
|
+
if yours == change.new:
|
|
505
|
+
continue # already what the new settings render (or absent from both)
|
|
506
|
+
if yours is MISSING and change.old is not MISSING:
|
|
507
|
+
# The developer removed a key the template still changes.
|
|
508
|
+
result.conflicts.append(KeyConflict(change.path, MISSING, change.new))
|
|
509
|
+
continue
|
|
510
|
+
if yours is not MISSING and (change.old is MISSING or yours != change.old):
|
|
511
|
+
result.conflicts.append(KeyConflict(change.path, yours, change.new))
|
|
512
|
+
continue
|
|
513
|
+
wanted = copy.deepcopy(expected)
|
|
514
|
+
try:
|
|
515
|
+
_set(wanted, change.path, change.new)
|
|
516
|
+
if kind == "yaml":
|
|
517
|
+
assert theirs_doc is not None and base_doc is not None
|
|
518
|
+
edited = _yaml_apply(text, theirs_doc, base_doc, change)
|
|
519
|
+
else:
|
|
520
|
+
edited = _env_apply(text, theirs, base, change)
|
|
521
|
+
applied = parse(edited) == wanted
|
|
522
|
+
except (_Unsupported, KeyError, ValueError, IndexError, TypeError, yaml.YAMLError):
|
|
523
|
+
applied = False
|
|
524
|
+
if not applied:
|
|
525
|
+
# No safe edit in this text (a flow mapping, a missing parent, ...):
|
|
526
|
+
# the key is left for the developer like one they changed.
|
|
527
|
+
result.conflicts.append(KeyConflict(change.path, yours, change.new))
|
|
528
|
+
continue
|
|
529
|
+
text, expected = edited, wanted
|
|
530
|
+
result.applied.append(change.path)
|
|
531
|
+
try:
|
|
532
|
+
if parse(text) != expected:
|
|
533
|
+
return None
|
|
534
|
+
except (ValueError, yaml.YAMLError):
|
|
535
|
+
return None
|
|
536
|
+
result.text = text
|
|
537
|
+
return result
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC
|
|
2
|
+
# Modifications Copyright 2026 graph-agents-cli contributors
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""Language configuration for CLI commands: Python only.
|
|
17
|
+
|
|
18
|
+
The single-entry table keeps the ``dispatch_language`` shape the command
|
|
19
|
+
modules were written against, so adding a language later is additive.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import pathlib
|
|
24
|
+
import tomllib
|
|
25
|
+
from collections.abc import Callable, Mapping
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import click
|
|
29
|
+
|
|
30
|
+
SUPPORTED_LANGUAGES: tuple[str, ...] = ("python",)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _read_python_version(root: pathlib.Path) -> str:
|
|
34
|
+
"""Return the ``[project].version`` from ``pyproject.toml`` or raise."""
|
|
35
|
+
pyproject_path = root / "pyproject.toml"
|
|
36
|
+
if not pyproject_path.exists():
|
|
37
|
+
raise FileNotFoundError(f"pyproject.toml not found in {root}")
|
|
38
|
+
with open(pyproject_path, "rb") as f:
|
|
39
|
+
data = tomllib.load(f) # PEP 621 [project] section
|
|
40
|
+
version = data.get("project", {}).get("version")
|
|
41
|
+
if version and isinstance(version, str):
|
|
42
|
+
return version
|
|
43
|
+
raise KeyError(f"no [project].version in {pyproject_path}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
LANGUAGE_CONFIGS: dict[str, dict[str, Any]] = {
|
|
47
|
+
"python": {
|
|
48
|
+
"lock_file": "uv.lock",
|
|
49
|
+
"lock_command": ["uv", "lock"],
|
|
50
|
+
"lock_command_name": "uv lock",
|
|
51
|
+
"display_name": "Python",
|
|
52
|
+
"agent_file": "agent.py",
|
|
53
|
+
# `app/agent.py` exports the compiled graph.
|
|
54
|
+
"agent_variable": "graph",
|
|
55
|
+
"agent_in_subdirectory": False,
|
|
56
|
+
"version_reader": _read_python_version,
|
|
57
|
+
"api_base_path": "",
|
|
58
|
+
"a2a_base_path_factory": lambda app_name: f"/a2a/{app_name}",
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_language_config(language: str) -> dict[str, Any]:
|
|
64
|
+
"""Get the configuration dict for a language (Python config as fallback)."""
|
|
65
|
+
return LANGUAGE_CONFIGS.get(language, LANGUAGE_CONFIGS["python"])
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class UnsupportedLanguageError(click.ClickException):
|
|
69
|
+
"""Raised when a command has no handler for the project's language."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def dispatch_language(
|
|
73
|
+
command_name: str,
|
|
74
|
+
handlers: Mapping[str, Callable | None],
|
|
75
|
+
language: str,
|
|
76
|
+
) -> Callable:
|
|
77
|
+
"""Return the handler for ``language``, or raise a clear error."""
|
|
78
|
+
handler = handlers.get(language)
|
|
79
|
+
if handler is None:
|
|
80
|
+
supported = ", ".join(sorted(k for k, v in handlers.items() if v is not None))
|
|
81
|
+
raise UnsupportedLanguageError(
|
|
82
|
+
f"`graph-agents-cli {command_name}` isn't supported for '{language}' "
|
|
83
|
+
f"projects.\n Supported languages: {supported}."
|
|
84
|
+
)
|
|
85
|
+
return handler
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def find_agent_file(
|
|
89
|
+
project_dir: pathlib.Path,
|
|
90
|
+
agent_directory: str,
|
|
91
|
+
language: str = "python",
|
|
92
|
+
) -> pathlib.Path | None:
|
|
93
|
+
"""Return ``{agent_directory}/agent.py`` when it exists, else None."""
|
|
94
|
+
agent_file = project_dir / agent_directory / get_language_config(language)["agent_file"]
|
|
95
|
+
return agent_file if agent_file.is_file() else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def validate_agent_file(
|
|
99
|
+
agent_file: pathlib.Path,
|
|
100
|
+
language: str = "python",
|
|
101
|
+
) -> tuple[bool, str | None]:
|
|
102
|
+
"""Check that the agent file defines the exported variable (``graph``)."""
|
|
103
|
+
required_var = get_language_config(language)["agent_variable"]
|
|
104
|
+
try:
|
|
105
|
+
content = agent_file.read_text(encoding="utf-8")
|
|
106
|
+
except Exception as e:
|
|
107
|
+
return False, f"Could not read {agent_file.name}: {e}"
|
|
108
|
+
if required_var in content:
|
|
109
|
+
return True, None
|
|
110
|
+
return False, f"Missing '{required_var}' variable in {agent_file.name}"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def get_agent_file_hint(dir_path: pathlib.Path, language: str = "python") -> str:
|
|
114
|
+
"""Hint string for directory selection, e.g. ``' (has agent.py)'``."""
|
|
115
|
+
if not dir_path.is_dir():
|
|
116
|
+
return ""
|
|
117
|
+
agent_file = get_language_config(language)["agent_file"]
|
|
118
|
+
if (dir_path / agent_file).exists():
|
|
119
|
+
return f" (has {agent_file})"
|
|
120
|
+
return ""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_project_version(
|
|
124
|
+
project_dir: str | pathlib.Path,
|
|
125
|
+
default_version: str = "0.0.0",
|
|
126
|
+
) -> str:
|
|
127
|
+
"""Extract the project version from ``pyproject.toml``, falling back to ``default_version``."""
|
|
128
|
+
root = pathlib.Path(project_dir)
|
|
129
|
+
try:
|
|
130
|
+
return _read_python_version(root)
|
|
131
|
+
except Exception as e:
|
|
132
|
+
logging.warning(
|
|
133
|
+
"Could not read the project version (%s). Falling back to %s; set "
|
|
134
|
+
"[project].version in pyproject.toml.",
|
|
135
|
+
e,
|
|
136
|
+
default_version,
|
|
137
|
+
)
|
|
138
|
+
return default_version
|