dh-cli 0.12.4__tar.gz → 0.12.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. {dh_cli-0.12.4 → dh_cli-0.12.6}/PKG-INFO +2 -2
  2. {dh_cli-0.12.4 → dh_cli-0.12.6}/pyproject.toml +1 -1
  3. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/aws_batch.py +44 -0
  4. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/submit.py +5 -0
  5. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/cloud_commands.py +67 -3
  6. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/engines_studios/engine_commands.py +1 -3
  7. dh_cli-0.12.6/tests/batch/test_submit_job_tags.py +157 -0
  8. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/batch/test_submit_merge.py +38 -0
  9. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/engines_studios/test_retry.py +7 -20
  10. dh_cli-0.12.6/tests/test_aws_status.py +184 -0
  11. {dh_cli-0.12.4 → dh_cli-0.12.6}/.gitignore +0 -0
  12. {dh_cli-0.12.4 → dh_cli-0.12.6}/LICENSE +0 -0
  13. {dh_cli-0.12.4 → dh_cli-0.12.6}/README.md +0 -0
  14. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/__init__.py +0 -0
  15. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/_identity.py +0 -0
  16. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/__init__.py +0 -0
  17. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/__init__.py +0 -0
  18. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/boltz.py +0 -0
  19. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/cancel.py +0 -0
  20. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/clean.py +0 -0
  21. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/disco.py +0 -0
  22. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/embed_t5.py +0 -0
  23. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/equilibrator.py +0 -0
  24. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/esmfold2.py +0 -0
  25. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/finalize.py +0 -0
  26. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/list_jobs.py +0 -0
  27. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/local.py +0 -0
  28. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/logs.py +0 -0
  29. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/orca.py +0 -0
  30. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/protmpnn.py +0 -0
  31. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/protmpnn_to_boltz.py +0 -0
  32. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/retry.py +0 -0
  33. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/rfd3.py +0 -0
  34. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/status.py +0 -0
  35. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/commands/wait_for.py +0 -0
  36. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/fasta_utils.py +0 -0
  37. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/h5_utils.py +0 -0
  38. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/job_id.py +0 -0
  39. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/manifest.py +0 -0
  40. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/batch/s3_transport.py +0 -0
  41. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/bedrock/__init__.py +0 -0
  42. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/bedrock/commands.py +0 -0
  43. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/bedrock/cost_report.py +0 -0
  44. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/bedrock/pricing.yaml +0 -0
  45. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/codeartifact.py +0 -0
  46. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/engines_studios/__init__.py +0 -0
  47. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/engines_studios/api_client.py +0 -0
  48. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/engines_studios/auth.py +0 -0
  49. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/engines_studios/progress.py +0 -0
  50. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/engines_studios/retry.py +0 -0
  51. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/engines_studios/ssh_config.py +0 -0
  52. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/engines_studios/studio_commands.py +0 -0
  53. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/github_commands.py +0 -0
  54. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/hz/__init__.py +0 -0
  55. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/hz/deploy.py +0 -0
  56. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/hz/local.py +0 -0
  57. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/hz/test.py +0 -0
  58. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/hz/tf.py +0 -0
  59. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/hz/users.py +0 -0
  60. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/main.py +0 -0
  61. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/sweep.py +0 -0
  62. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/utility_commands.py +0 -0
  63. {dh_cli-0.12.4 → dh_cli-0.12.6}/src/dh_cli/warehouse.py +0 -0
  64. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/batch/__init__.py +0 -0
  65. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/batch/test_aws_batch_resources.py +0 -0
  66. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/batch/test_image_override.py +0 -0
  67. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/batch/test_no_train_command.py +0 -0
  68. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/batch/test_submit_cpu_only.py +0 -0
  69. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/batch/test_submit_image_validation.py +0 -0
  70. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/batch/test_submit_mlflow_env.py +0 -0
  71. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/conftest.py +0 -0
  72. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/fixtures/A_cache_write.json +0 -0
  73. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/fixtures/B_cache_read.json +0 -0
  74. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/fixtures/C_plain.json +0 -0
  75. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/fixtures/D_cursor_user.json +0 -0
  76. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/fixtures/E_service_role.json +0 -0
  77. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/fixtures/F_legacy_shared.json +0 -0
  78. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/fixtures/G_unknown_model.json +0 -0
  79. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_build_report.py +0 -0
  80. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_classify_arn.py +0 -0
  81. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_cli_exit_codes.py +0 -0
  82. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_cost_calc.py +0 -0
  83. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_cost_command.py +0 -0
  84. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_cur_reconciliation.py +0 -0
  85. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_key_command.py +0 -0
  86. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_render_formats.py +0 -0
  87. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_resolve_base_model.py +0 -0
  88. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_rotate.py +0 -0
  89. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/bedrock/test_s3_walker.py +0 -0
  90. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/engines_studios/__init__.py +0 -0
  91. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/engines_studios/test_engine_retry_commands.py +0 -0
  92. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/__init__.py +0 -0
  93. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/conftest.py +0 -0
  94. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/test_engine_role_cannot_read_github_pat.py +0 -0
  95. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/test_identity.py +0 -0
  96. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/test_login.py +0 -0
  97. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/test_login_error_paths.py +0 -0
  98. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/test_login_security.py +0 -0
  99. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/test_logout.py +0 -0
  100. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/test_rotate.py +0 -0
  101. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/github/test_status.py +0 -0
  102. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/hz/test_init.py +0 -0
  103. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/hz/test_suites.py +0 -0
  104. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/hz/test_users.py +0 -0
  105. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_cloud_gcp.py +0 -0
  106. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_disco_command.py +0 -0
  107. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_disco_jobdef.py +0 -0
  108. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_equilibrator_command.py +0 -0
  109. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_esmfold2_cli.py +0 -0
  110. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_finalize_boltz_tar.py +0 -0
  111. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_finalize_disco.py +0 -0
  112. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_finalize_disco_dispatch.py +0 -0
  113. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_finalize_equilibrator.py +0 -0
  114. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_finalize_esmfold2.py +0 -0
  115. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_finalize_protmpnn.py +0 -0
  116. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_finalize_rfd3.py +0 -0
  117. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_rfd3_command.py +0 -0
  118. {dh_cli-0.12.4 → dh_cli-0.12.6}/tests/test_sweep.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: dh-cli
3
- Version: 0.12.4
3
+ Version: 0.12.6
4
4
  Summary: Dayhoff Labs developer CLI
5
5
  Author-email: Dayhoff Labs <dev@dayhofflabs.com>
6
6
  License: # PolyForm Noncommercial License 1.0.0
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dh-cli"
7
- version = "0.12.4"
7
+ version = "0.12.6"
8
8
  description = "Dayhoff Labs developer CLI"
9
9
  requires-python = ">=3.11"
10
10
  readme = "README.md"
@@ -2,6 +2,7 @@
2
2
 
3
3
  import logging
4
4
  import re
5
+ import socket
5
6
  import time
6
7
  from dataclasses import dataclass
7
8
  from typing import Any
@@ -9,6 +10,7 @@ from typing import Any
9
10
  import boto3
10
11
  from botocore.exceptions import ClientError
11
12
 
13
+ from dh_cli.batch.job_id import get_aws_username
12
14
  from dh_cli.batch.manifest import (
13
15
  BATCH_JOBS_BASE,
14
16
  JobStatus,
@@ -29,6 +31,36 @@ class BatchError(Exception):
29
31
  pass
30
32
 
31
33
 
34
+ def _pipeline_from_definition(job_definition: str) -> str:
35
+ """Derive the pipeline name from a job definition name or ARN.
36
+
37
+ 'dayhoff-embed-t5', 'dayhoff-embed-t5:3' and the full ARN all yield
38
+ 'embed-t5'. Falls back to the bare definition name if the 'dayhoff-'
39
+ prefix is absent.
40
+ """
41
+ name = job_definition.rsplit("/", 1)[-1].split(":")[0]
42
+ return name.removeprefix("dayhoff-")
43
+
44
+
45
+ def _tag_user() -> str:
46
+ """Username for the User cost tag.
47
+
48
+ 'root' means get_aws_username() hit its getpass fallback in a container —
49
+ tagging it would collapse many people into one bucket, invisibly. Tag
50
+ unknown-<hostname> instead and say how to fix it.
51
+ """
52
+ username = get_aws_username()
53
+ if username == "root":
54
+ hostname = socket.gethostname()
55
+ logger.warning(
56
+ "Could not resolve your AWS username (got system user 'root'); "
57
+ f"tagging this job User=unknown-{hostname}. "
58
+ "Set AWS_SSO_USER or run 'dh aws login' to fix cost attribution."
59
+ )
60
+ return f"unknown-{hostname}"
61
+ return username
62
+
63
+
32
64
  @dataclass
33
65
  class ArrayJobStatus:
34
66
  """Aggregated status for an array job."""
@@ -90,6 +122,7 @@ class BatchClient:
90
122
  gpus: int | None = None,
91
123
  image_override: str | None = None,
92
124
  command: list[str] | None = None,
125
+ project: str | None = None,
93
126
  ) -> str:
94
127
  """Submit a job to AWS Batch.
95
128
 
@@ -103,6 +136,7 @@ class BatchClient:
103
136
  timeout_seconds: Job timeout in seconds
104
137
  retry_attempts: Number of retry attempts
105
138
  depends_on: Job dependencies
139
+ project: Value for the Project cost tag (defaults to "unspecified")
106
140
 
107
141
  Returns:
108
142
  AWS Batch job ID
@@ -119,6 +153,16 @@ class BatchClient:
119
153
  "jobDefinition": job_definition,
120
154
  "jobQueue": job_queue,
121
155
  "retryStrategy": {"attempts": retry_attempts},
156
+ # Cost attribution: propagated to the ECS task, split per task
157
+ # by SCAD. JobId is the dh job ID (the job name), not the AWS
158
+ # UUID, so humans can correlate with `dh batch status`.
159
+ "tags": {
160
+ "User": _tag_user(),
161
+ "Pipeline": _pipeline_from_definition(job_definition),
162
+ "Project": project or "unspecified",
163
+ "JobId": job_name,
164
+ },
165
+ "propagateTags": True,
122
166
  }
123
167
 
124
168
  if array_size and array_size > 1:
@@ -33,6 +33,7 @@ DEFAULT_QUEUE = "t4-1x-spot"
33
33
  @click.option("--retry", default=3, type=int, help="Retry attempts")
34
34
  @click.option("--timeout", default="6h", help="Job timeout (e.g., 6h, 1d)")
35
35
  @click.option("--image", help="Pre-built image URI")
36
+ @click.option("--project", help="Project cost tag (defaults to 'unspecified')")
36
37
  @click.option("--env", multiple=True, help="Environment variables (KEY=VALUE)")
37
38
  @click.option("--dry-run", is_flag=True, help="Show plan without submitting")
38
39
  @click.option("--base-path", default=BATCH_JOBS_BASE, help="Base path for job data")
@@ -50,6 +51,7 @@ def submit(
50
51
  retry,
51
52
  timeout,
52
53
  image,
54
+ project,
53
55
  env,
54
56
  dry_run,
55
57
  base_path,
@@ -84,6 +86,7 @@ def submit(
84
86
  retry: 3
85
87
  timeout: 6h
86
88
  image: custom-image:tag
89
+ project: my-campaign # Project cost tag (optional)
87
90
  env:
88
91
  MY_VAR: value
89
92
  """
@@ -116,6 +119,7 @@ def submit(
116
119
  job_retry = _pick("retry", retry)
117
120
  job_timeout = _pick("timeout", timeout)
118
121
  job_image = image or config.get("image")
122
+ job_project = project or config.get("project")
119
123
  if job_image and not _ECR_IMAGE_RE.match(job_image):
120
124
  raise click.BadParameter(
121
125
  f"--image must be a fully-qualified ECR URL "
@@ -214,6 +218,7 @@ def submit(
214
218
  depends_on=depends_on,
215
219
  share_identifier=get_aws_username(),
216
220
  image_override=job_image,
221
+ project=job_project,
217
222
  )
218
223
 
219
224
  # Update manifest with Batch job ID
@@ -19,6 +19,7 @@ import re
19
19
  import shutil
20
20
  import subprocess
21
21
  import sys
22
+ import time
22
23
  from pathlib import Path
23
24
  from typing import List, Optional, Tuple
24
25
 
@@ -29,6 +30,8 @@ import typer
29
30
  GCP_PROJECT_ID = "enzyme-discovery"
30
31
  AWS_DEFAULT_PROFILE = "dev-devaccess"
31
32
  AWS_CONFIG_FILE = Path.home() / ".aws" / "config"
33
+ AWS_SSO_CACHE_DIR = Path.home() / ".aws" / "sso" / "cache"
34
+ NETRC_FILE = Path.home() / ".netrc"
32
35
  SHELL_RC_FILES = [
33
36
  Path.home() / ".bashrc",
34
37
  Path.home() / ".bash_profile",
@@ -398,6 +401,55 @@ def _is_aws_profile_authenticated(profile: str) -> bool:
398
401
  return returncode == 0
399
402
 
400
403
 
404
+ def _print_sso_session_status() -> None:
405
+ """Print the cached SSO access token's expiry, and note what it isn't.
406
+
407
+ A cache dir holds both the client-registration file and the access-token
408
+ file, and both carry `expiresAt` - the registration one is weeks out, the
409
+ token's is hours out. They're told apart by content shape (presence of
410
+ `accessToken`), never by glob order, mtime, or filename, since either of
411
+ those picked the registration file on the box this was written for and
412
+ reported six weeks of headroom that didn't exist.
413
+ """
414
+ access_token_expiry = None
415
+ if AWS_SSO_CACHE_DIR.is_dir():
416
+ for cache_file in AWS_SSO_CACHE_DIR.glob("*.json"):
417
+ try:
418
+ data = json.loads(cache_file.read_text())
419
+ except (OSError, ValueError):
420
+ continue
421
+ if isinstance(data, dict) and "accessToken" in data:
422
+ access_token_expiry = data.get("expiresAt")
423
+ break
424
+
425
+ # The token is refreshed silently on ~1h cycles; the 8h portal session it
426
+ # rides on is not visible from inside the container at all, so the note
427
+ # below must stand on its own when no expiry was printed above.
428
+ if access_token_expiry:
429
+ print(f"{BLUE}SSO access token expires:{NC} {access_token_expiry}")
430
+ print(
431
+ f"{YELLOW}Note: the AWS SSO portal session's lifetime is not locally "
432
+ f"observable from here; the access token expiry above is not that window.{NC}"
433
+ )
434
+ else:
435
+ print(f"{YELLOW}Note: the AWS SSO portal session's lifetime is not locally observable from here.{NC}")
436
+
437
+
438
+ def _print_netrc_age() -> None:
439
+ """Print the age of ~/.netrc.
440
+
441
+ The CodeArtifact token it holds carries no expiry, so age is the only
442
+ signal available, and it gates `pixi install` (and therefore `dh` itself).
443
+ """
444
+ if not NETRC_FILE.exists():
445
+ return
446
+ try:
447
+ age_hours = (time.time() - NETRC_FILE.stat().st_mtime) / 3600
448
+ except OSError:
449
+ return
450
+ print(f"{BLUE}~/.netrc age:{NC} {age_hours:.1f} hours")
451
+
452
+
401
453
  def _run_aws_sso_login(profile: str) -> None:
402
454
  """Run the AWS SSO login command for a specific profile."""
403
455
  aws_path = _find_executable("aws")
@@ -590,13 +642,19 @@ def aws_status(
590
642
  profile: Optional[str] = typer.Option(None, "--profile", "-p", help="Check specific profile instead of current."),
591
643
  ):
592
644
  """Show current AWS authentication status."""
593
- target_profile = profile or _get_current_aws_profile()
645
+ try:
646
+ target_profile = profile or _get_current_aws_profile()
647
+ authenticated = _is_aws_profile_authenticated(target_profile)
648
+ aws_path = _find_executable("aws")
649
+ except FileNotFoundError as e:
650
+ print(f"{RED}Could not determine AWS status: {e}{NC}", file=sys.stderr)
651
+ raise typer.Exit(6)
652
+
594
653
  print(f"{BLUE}AWS profile:{NC} {GREEN}{target_profile}{NC}")
595
654
 
596
- if _is_aws_profile_authenticated(target_profile):
655
+ if authenticated:
597
656
  print(f"Credential status: {GREEN}valid{NC}")
598
657
  # Get detailed identity information
599
- aws_path = _find_executable("aws")
600
658
  _run_command(
601
659
  [
602
660
  aws_path,
@@ -612,6 +670,12 @@ def aws_status(
612
670
  print("\nTo authenticate, run:")
613
671
  print(f" {YELLOW}dh aws login{NC}")
614
672
 
673
+ _print_sso_session_status()
674
+ _print_netrc_age()
675
+
676
+ if not authenticated:
677
+ raise typer.Exit(5)
678
+
615
679
 
616
680
  @aws_app.command("login")
617
681
  def aws_login(
@@ -147,9 +147,7 @@ def launch_engine(
147
147
  # Launch the engine, retrying transient AWS capacity errors by default.
148
148
  retry = _make_capacity_retry(engine_type, no_retry, retry_timeout, retry_interval)
149
149
  engine = retry(
150
- lambda: client.launch_engine(
151
- name=name, user=user, engine_type=engine_type, boot_disk_size=boot_disk_size
152
- )
150
+ lambda: client.launch_engine(name=name, user=user, engine_type=engine_type, boot_disk_size=boot_disk_size)
153
151
  )
154
152
 
155
153
  engine_id = engine["instance_id"]
@@ -0,0 +1,157 @@
1
+ """Tests for cost-attribution tags on BatchClient.submit_job.
2
+
3
+ Tags are defaulted inside submit_job — no call site passes them. They pin:
4
+ - the four keys User/Pipeline/Project/JobId and propagateTags=True (AC-1)
5
+ - User from get_aws_username(), JobId = the dh job name (AC-2)
6
+ - Pipeline derived from the job definition name (AC-5)
7
+ - Project defaults to "unspecified", overridable per submission (AC-5)
8
+ - the root-fallback guard tagging User=unknown-<hostname> (AC-6)
9
+ - both submit_array_retry re-entry paths produce tagged jobs (AC-4)
10
+ """
11
+
12
+ from unittest.mock import MagicMock, patch
13
+
14
+
15
+ def _make_client():
16
+ """Construct a BatchClient with mocked boto3 batch/logs clients."""
17
+ with patch("dh_cli.batch.aws_batch.boto3") as mock_boto3:
18
+ mock_batch = MagicMock()
19
+ mock_logs = MagicMock()
20
+ mock_boto3.client.side_effect = [mock_batch, mock_logs]
21
+ mock_batch.submit_job.return_value = {"jobId": "aws-uuid"}
22
+
23
+ from dh_cli.batch.aws_batch import BatchClient
24
+
25
+ client = BatchClient()
26
+ return client, mock_batch
27
+
28
+
29
+ def _submit(client, **overrides):
30
+ kwargs = dict(
31
+ job_name="dma-boltz-20260810-a3f2c9e1",
32
+ job_definition="dayhoff-boltz",
33
+ job_queue="a10g-1x-spot",
34
+ )
35
+ kwargs.update(overrides)
36
+ return client.submit_job(**kwargs)
37
+
38
+
39
+ @patch("dh_cli.batch.aws_batch.get_aws_username", return_value="dma")
40
+ class TestSubmitJobTags:
41
+ def test_no_tag_parameters_in_signature(self, _mock_user):
42
+ """AC-3: tags are defaulted inside submit_job — call sites have no
43
+ tag argument to pass (or forget)."""
44
+ import inspect
45
+
46
+ from dh_cli.batch.aws_batch import BatchClient
47
+
48
+ params = inspect.signature(BatchClient.submit_job).parameters
49
+ assert "tags" not in params
50
+ assert "propagate_tags" not in params
51
+
52
+ def test_sends_exactly_four_tag_keys_and_propagate(self, _mock_user):
53
+ client, mock_batch = _make_client()
54
+ _submit(client)
55
+ submit_args = mock_batch.submit_job.call_args[1]
56
+ assert submit_args["propagateTags"] is True
57
+ assert set(submit_args["tags"]) == {"User", "Pipeline", "Project", "JobId"}
58
+
59
+ def test_user_is_aws_username_and_jobid_is_dh_job_name(self, _mock_user):
60
+ client, mock_batch = _make_client()
61
+ _submit(client)
62
+ tags = mock_batch.submit_job.call_args[1]["tags"]
63
+ assert tags["User"] == "dma"
64
+ assert tags["JobId"] == "dma-boltz-20260810-a3f2c9e1"
65
+
66
+ def test_pipeline_derived_from_job_definition(self, _mock_user):
67
+ client, mock_batch = _make_client()
68
+ for definition, expected in [
69
+ ("dayhoff-embed-t5", "embed-t5"),
70
+ ("dayhoff-boltz", "boltz"),
71
+ ("dayhoff-generic", "generic"),
72
+ # ARN form, as produced by _register_image_override
73
+ (
74
+ "arn:aws:batch:us-east-1:074735440724:job-definition/dayhoff-rfd3:7",
75
+ "rfd3",
76
+ ),
77
+ # Name:revision form
78
+ ("dayhoff-esmfold2:3", "esmfold2"),
79
+ ]:
80
+ _submit(client, job_definition=definition)
81
+ tags = mock_batch.submit_job.call_args[1]["tags"]
82
+ assert tags["Pipeline"] == expected, definition
83
+
84
+ def test_project_defaults_to_unspecified(self, _mock_user):
85
+ client, mock_batch = _make_client()
86
+ _submit(client)
87
+ tags = mock_batch.submit_job.call_args[1]["tags"]
88
+ assert tags["Project"] == "unspecified"
89
+
90
+ def test_project_overridable_per_submission(self, _mock_user):
91
+ client, mock_batch = _make_client()
92
+ _submit(client, project="cost-attribution")
93
+ tags = mock_batch.submit_job.call_args[1]["tags"]
94
+ assert tags["Project"] == "cost-attribution"
95
+
96
+ def test_share_identifier_behaviour_unchanged(self, _mock_user):
97
+ client, mock_batch = _make_client()
98
+ _submit(client, share_identifier="dma")
99
+ submit_args = mock_batch.submit_job.call_args[1]
100
+ assert submit_args["shareIdentifier"] == "dma"
101
+ assert submit_args["schedulingPriorityOverride"] == 0
102
+
103
+
104
+ class TestRootFallbackGuard:
105
+ """AC-6: getpass-fallback 'root' must not become the User tag."""
106
+
107
+ @patch("dh_cli.batch.aws_batch.socket.gethostname", return_value="devbox9")
108
+ @patch("dh_cli.batch.aws_batch.get_aws_username", return_value="root")
109
+ def test_root_user_tagged_unknown_hostname_with_warning(self, _mock_user, _mock_host, caplog):
110
+ client, mock_batch = _make_client()
111
+ import logging
112
+
113
+ with caplog.at_level(logging.WARNING):
114
+ _submit(client)
115
+ tags = mock_batch.submit_job.call_args[1]["tags"]
116
+ assert tags["User"] == "unknown-devbox9"
117
+ assert any("AWS_SSO_USER" in r.message for r in caplog.records)
118
+
119
+ @patch("dh_cli.batch.aws_batch.get_aws_username", return_value="dma")
120
+ def test_non_root_user_emits_no_warning(self, _mock_user, caplog):
121
+ client, mock_batch = _make_client()
122
+ import logging
123
+
124
+ with caplog.at_level(logging.WARNING):
125
+ _submit(client)
126
+ assert not any("AWS_SSO_USER" in r.message for r in caplog.records)
127
+
128
+
129
+ @patch("dh_cli.batch.aws_batch.get_aws_username", return_value="dma")
130
+ class TestArrayRetryReentriesTagged:
131
+ """AC-4: both submit_array_retry re-entry paths produce tagged jobs."""
132
+
133
+ def test_single_index_reentry_tagged(self, _mock_user):
134
+ client, mock_batch = _make_client()
135
+ client.submit_array_job_with_indices(
136
+ job_name="dma-embed-20260810-a3f2c9e1-r1",
137
+ job_definition="dayhoff-embed-t5",
138
+ job_queue="t4-1x-spot",
139
+ indices=[7],
140
+ )
141
+ submit_args = mock_batch.submit_job.call_args[1]
142
+ assert submit_args["propagateTags"] is True
143
+ assert submit_args["tags"]["Pipeline"] == "embed-t5"
144
+ assert submit_args["tags"]["JobId"] == "dma-embed-20260810-a3f2c9e1-r1"
145
+
146
+ def test_multi_index_reentry_tagged(self, _mock_user):
147
+ client, mock_batch = _make_client()
148
+ client.submit_array_job_with_indices(
149
+ job_name="dma-embed-20260810-a3f2c9e1-r1",
150
+ job_definition="dayhoff-embed-t5",
151
+ job_queue="t4-1x-spot",
152
+ indices=[3, 5, 9],
153
+ )
154
+ submit_args = mock_batch.submit_job.call_args[1]
155
+ assert submit_args["propagateTags"] is True
156
+ assert submit_args["tags"]["Pipeline"] == "embed-t5"
157
+ assert submit_args["tags"]["User"] == "dma"
@@ -203,6 +203,44 @@ class TestArrayAndRetryMerge:
203
203
  assert mock_client.submit_job.call_args[1]["retry_attempts"] == 5
204
204
 
205
205
 
206
+ class TestProjectOption:
207
+ """--project / YAML `project` forwards to submit_job's project kwarg
208
+ (the Project cost tag); absent everywhere, None is forwarded and
209
+ submit_job defaults the tag to "unspecified"."""
210
+
211
+ def test_cli_project_forwarded(self, cli_runner, tmp_path):
212
+ result, mock_client = _invoke(
213
+ cli_runner,
214
+ ["--command", "echo hi", "--project", "cost-attribution"],
215
+ tmp_path,
216
+ )
217
+ assert result.exit_code == 0, result.output
218
+ assert mock_client.submit_job.call_args[1]["project"] == "cost-attribution"
219
+
220
+ def test_yaml_project_wins_when_cli_omitted(self, cli_runner, tmp_path):
221
+ config_path = tmp_path / "job.yaml"
222
+ config_path.write_text(yaml.dump({"command": "echo hi", "project": "gauntlet"}))
223
+ result, mock_client = _invoke(cli_runner, ["-f", str(config_path)], tmp_path)
224
+ assert result.exit_code == 0, result.output
225
+ assert mock_client.submit_job.call_args[1]["project"] == "gauntlet"
226
+
227
+ def test_cli_project_beats_yaml(self, cli_runner, tmp_path):
228
+ config_path = tmp_path / "job.yaml"
229
+ config_path.write_text(yaml.dump({"command": "echo hi", "project": "gauntlet"}))
230
+ result, mock_client = _invoke(
231
+ cli_runner,
232
+ ["-f", str(config_path), "--project", "poise"],
233
+ tmp_path,
234
+ )
235
+ assert result.exit_code == 0, result.output
236
+ assert mock_client.submit_job.call_args[1]["project"] == "poise"
237
+
238
+ def test_absent_everywhere_forwards_none(self, cli_runner, tmp_path):
239
+ result, mock_client = _invoke(cli_runner, ["--command", "echo hi"], tmp_path)
240
+ assert result.exit_code == 0, result.output
241
+ assert mock_client.submit_job.call_args[1]["project"] is None
242
+
243
+
206
244
  class TestGpuRegressionFromReport:
207
245
  """The specific surprising case called out in the plan: a caller
208
246
  who does `--gpus 1 -f yaml-with-gpus-zero.yaml` expects a GPU job,
@@ -36,9 +36,7 @@ def test_returns_immediately_on_success():
36
36
  calls.append(1)
37
37
  return "ok"
38
38
 
39
- result = retry_on_capacity(
40
- action, timeout=600, interval=30, monotonic=clock.monotonic, sleep=clock.sleep
41
- )
39
+ result = retry_on_capacity(action, timeout=600, interval=30, monotonic=clock.monotonic, sleep=clock.sleep)
42
40
 
43
41
  assert result == "ok"
44
42
  assert len(calls) == 1
@@ -53,14 +51,11 @@ def test_retries_capacity_error_then_succeeds():
53
51
  attempts["n"] += 1
54
52
  if attempts["n"] < 3:
55
53
  raise RuntimeError(
56
- "Failed to launch instance: An error occurred "
57
- "(InsufficientInstanceCapacity) when calling RunInstances"
54
+ "Failed to launch instance: An error occurred (InsufficientInstanceCapacity) when calling RunInstances"
58
55
  )
59
56
  return "launched"
60
57
 
61
- result = retry_on_capacity(
62
- action, timeout=600, interval=30, monotonic=clock.monotonic, sleep=clock.sleep
63
- )
58
+ result = retry_on_capacity(action, timeout=600, interval=30, monotonic=clock.monotonic, sleep=clock.sleep)
64
59
 
65
60
  assert result == "launched"
66
61
  assert attempts["n"] == 3
@@ -77,9 +72,7 @@ def test_non_capacity_error_propagates_without_retry():
77
72
  raise RuntimeError("VcpuLimitExceeded: vCPU quota exceeded")
78
73
 
79
74
  with pytest.raises(RuntimeError, match="VcpuLimitExceeded"):
80
- retry_on_capacity(
81
- action, timeout=600, interval=30, monotonic=clock.monotonic, sleep=clock.sleep
82
- )
75
+ retry_on_capacity(action, timeout=600, interval=30, monotonic=clock.monotonic, sleep=clock.sleep)
83
76
 
84
77
  assert attempts["n"] == 1
85
78
  assert clock.slept == []
@@ -94,9 +87,7 @@ def test_reraises_last_capacity_error_after_budget_exhausted():
94
87
  raise RuntimeError("InsufficientInstanceCapacity: no capacity")
95
88
 
96
89
  with pytest.raises(RuntimeError, match="InsufficientInstanceCapacity"):
97
- retry_on_capacity(
98
- action, timeout=60, interval=30, monotonic=clock.monotonic, sleep=clock.sleep
99
- )
90
+ retry_on_capacity(action, timeout=60, interval=30, monotonic=clock.monotonic, sleep=clock.sleep)
100
91
 
101
92
  # attempt@t=0 fails; next attempt (t=30) fits in the 60s budget -> sleep 30.
102
93
  # attempt@t=30 fails; next attempt (t=60) would NOT be < 60 -> stop, no sleep.
@@ -113,9 +104,7 @@ def test_timeout_zero_means_single_attempt():
113
104
  raise RuntimeError("InsufficientInstanceCapacity")
114
105
 
115
106
  with pytest.raises(RuntimeError, match="InsufficientInstanceCapacity"):
116
- retry_on_capacity(
117
- action, timeout=0, interval=30, monotonic=clock.monotonic, sleep=clock.sleep
118
- )
107
+ retry_on_capacity(action, timeout=0, interval=30, monotonic=clock.monotonic, sleep=clock.sleep)
119
108
 
120
109
  assert attempts["n"] == 1
121
110
  assert clock.slept == []
@@ -162,9 +151,7 @@ def test_keyboard_interrupt_during_sleep_propagates():
162
151
  raise RuntimeError("InsufficientInstanceCapacity")
163
152
 
164
153
  with pytest.raises(KeyboardInterrupt):
165
- retry_on_capacity(
166
- action, timeout=600, interval=30, monotonic=clock.monotonic, sleep=clock.sleep
167
- )
154
+ retry_on_capacity(action, timeout=600, interval=30, monotonic=clock.monotonic, sleep=clock.sleep)
168
155
 
169
156
 
170
157
  @pytest.mark.parametrize(
@@ -0,0 +1,184 @@
1
+ """Exit-code and reporting contract for `dh aws status`.
2
+
3
+ `dh aws status` is the one honest answer an unattended agent has about its own
4
+ AWS credentials, so it must fail closed and must never overstate the window it
5
+ can see:
6
+
7
+ 0 — authenticated
8
+ 5 — unauthenticated
9
+ 6 — could not tell (e.g. no `aws` binary)
10
+
11
+ `2` stays reserved for typer's UsageError. 5 and 6 are distinct on purpose: a
12
+ caller must never confuse "credentials are gone" with "I could not determine
13
+ it", because the safe response differs.
14
+
15
+ All AWS interaction is mocked at `_run_command`; no credentials required. No
16
+ test asserts on a token value, and every fixture token is a placeholder.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import os
23
+ import re
24
+ import time
25
+ from pathlib import Path
26
+ from unittest.mock import patch
27
+
28
+ from click.testing import CliRunner
29
+ from typer.main import get_command
30
+
31
+ PLACEHOLDER_TOKEN = "PLACEHOLDER-NOT-A-REAL-TOKEN"
32
+
33
+ REGISTRATION_FILE = {
34
+ "clientId": "placeholder-client-id",
35
+ "clientSecret": "PLACEHOLDER-CLIENT-SECRET",
36
+ "expiresAt": "2026-09-14T13:59:33Z",
37
+ "grantTypes": ["refresh_token", "urn:ietf:params:oauth:grant-type:device_code"],
38
+ "scopes": ["sso:account:access"],
39
+ }
40
+
41
+ ACCESS_TOKEN_FILE = {
42
+ "accessToken": PLACEHOLDER_TOKEN,
43
+ "clientId": "placeholder-client-id",
44
+ "clientSecret": "PLACEHOLDER-CLIENT-SECRET",
45
+ "expiresAt": "2026-08-13T04:00:00Z",
46
+ "refreshToken": PLACEHOLDER_TOKEN,
47
+ "region": "us-east-1",
48
+ "registrationExpiresAt": "2026-09-14T13:59:33Z",
49
+ "startUrl": "https://placeholder.awsapps.com/start",
50
+ }
51
+
52
+
53
+ def _invoke(
54
+ cache_dir: Path,
55
+ netrc_file: Path,
56
+ *,
57
+ authenticated: bool = True,
58
+ which_returns_path: bool = True,
59
+ ):
60
+ """Run `aws status` with the AWS CLI boundary and both files stubbed."""
61
+ from dh_cli import cloud_commands
62
+ from dh_cli.cloud_commands import aws_app
63
+
64
+ result_tuple = (0, '{"Arn": "arn:aws:sts::000000000000:assumed-role/placeholder/x"}', "")
65
+ if not authenticated:
66
+ result_tuple = (255, "", "Error loading SSO Token: Token has expired and refresh failed")
67
+
68
+ stack = [
69
+ patch.object(cloud_commands, "AWS_SSO_CACHE_DIR", cache_dir),
70
+ patch.object(cloud_commands, "NETRC_FILE", netrc_file),
71
+ patch.object(cloud_commands, "_run_command", return_value=result_tuple),
72
+ patch.dict(os.environ, {"AWS_PROFILE": "dev-devaccess"}, clear=False),
73
+ ]
74
+ if which_returns_path:
75
+ stack.append(patch.object(cloud_commands.shutil, "which", return_value="/usr/bin/aws"))
76
+ else:
77
+ stack.append(patch.object(cloud_commands.shutil, "which", return_value=None))
78
+
79
+ runner = CliRunner()
80
+ for ctx in stack:
81
+ ctx.start()
82
+ try:
83
+ return runner.invoke(get_command(aws_app), ["status"])
84
+ finally:
85
+ for ctx in reversed(stack):
86
+ ctx.stop()
87
+
88
+
89
+ def _write_cache(tmp_path: Path) -> Path:
90
+ """Write both SSO cache shapes, with the registration file NEWER on disk."""
91
+ cache_dir = tmp_path / "sso" / "cache"
92
+ cache_dir.mkdir(parents=True)
93
+ token_path = cache_dir / "aaa11111.json"
94
+ token_path.write_text(json.dumps(ACCESS_TOKEN_FILE))
95
+ reg_path = cache_dir / "bbb22222.json"
96
+ reg_path.write_text(json.dumps(REGISTRATION_FILE))
97
+ # Registration file is both newer and alphabetically later: a glob taking
98
+ # the newest or the first hit must not be able to pass by accident.
99
+ old = time.time() - 86400
100
+ os.utime(token_path, (old, old))
101
+ return cache_dir
102
+
103
+
104
+ def _netrc(tmp_path: Path, age_hours: float = 5.0) -> Path:
105
+ netrc_file = tmp_path / ".netrc"
106
+ netrc_file.write_text("machine placeholder.d.codeartifact.us-east-1.amazonaws.com\n")
107
+ stamp = time.time() - age_hours * 3600
108
+ os.utime(netrc_file, (stamp, stamp))
109
+ return netrc_file
110
+
111
+
112
+ def test_authenticated_exits_0(tmp_path):
113
+ result = _invoke(_write_cache(tmp_path), _netrc(tmp_path), authenticated=True)
114
+ assert result.exit_code == 0, result.output
115
+
116
+
117
+ def test_unauthenticated_exits_5(tmp_path):
118
+ """The whole point: `dh aws status && long_job` must not run the job."""
119
+ result = _invoke(_write_cache(tmp_path), _netrc(tmp_path), authenticated=False)
120
+ assert result.exit_code == 5, result.output
121
+ assert "not authenticated" in result.output.lower(), result.output
122
+
123
+
124
+ def test_missing_aws_binary_exits_6_without_traceback(tmp_path):
125
+ """`_find_executable` raises FileNotFoundError today; a diagnostic must not
126
+ traceback when the thing it diagnoses is missing."""
127
+ result = _invoke(
128
+ _write_cache(tmp_path),
129
+ _netrc(tmp_path),
130
+ which_returns_path=False,
131
+ )
132
+ assert result.exit_code == 6, result.output
133
+ assert not isinstance(result.exception, FileNotFoundError), result.exception
134
+
135
+
136
+ def test_reports_access_token_expiry_not_registration_expiry(tmp_path):
137
+ """The regression test for the trap.
138
+
139
+ Both cache files carry `expiresAt`. On a real box the registration file
140
+ read 2026-09-14 beside an access token expiring the same afternoon, so a
141
+ glob taking the newest or first hit reports six weeks of headroom that does
142
+ not exist. Selection must be by content shape: the file with `accessToken`.
143
+ """
144
+ result = _invoke(_write_cache(tmp_path), _netrc(tmp_path), authenticated=True)
145
+ assert result.exit_code == 0, result.output
146
+ assert "2026-08-13" in result.output, result.output
147
+ assert "2026-09-14" not in result.output, result.output
148
+
149
+
150
+ def test_missing_cache_directory_claims_no_window(tmp_path):
151
+ """No cache is a reason to say nothing, not to guess."""
152
+ result = _invoke(tmp_path / "absent", tmp_path / "no-netrc", authenticated=True)
153
+ assert result.exit_code == 0, result.output
154
+ assert result.exception is None, result.exception
155
+ assert not re.search(r"\d{4}-\d{2}-\d{2}", result.output), result.output
156
+
157
+
158
+ def test_reports_netrc_age(tmp_path):
159
+ """The CodeArtifact token records no expiry, so age is the only signal —
160
+ and it gates `pixi install`, and therefore `dh` itself."""
161
+ result = _invoke(_write_cache(tmp_path), _netrc(tmp_path, age_hours=5.0))
162
+ netrc_lines = [ln for ln in result.output.splitlines() if "netrc" in ln.lower()]
163
+ assert netrc_lines, result.output
164
+ assert "5" in netrc_lines[0], netrc_lines[0]
165
+
166
+
167
+ def test_states_portal_session_is_not_locally_observable(tmp_path):
168
+ """The access-token expiry is not the window. Nothing inside a devcon can
169
+ see the portal session's lifetime, and the output must say so rather than
170
+ let the token clock be read as the deadline."""
171
+ result = _invoke(_write_cache(tmp_path), _netrc(tmp_path))
172
+ lowered = result.output.lower()
173
+ assert "portal" in lowered, result.output
174
+ assert "not locally observable" in lowered, result.output
175
+
176
+
177
+ def test_no_boto3_import():
178
+ """`dh` installs through CodeArtifact, which needs working AWS auth, so a
179
+ diagnostic that cannot run when auth is broken is useless exactly when it
180
+ is needed."""
181
+ from dh_cli import cloud_commands
182
+
183
+ source = Path(cloud_commands.__file__).read_text()
184
+ assert "boto3" not in source, "cloud_commands.py must stay standard-library-only"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes