nextmv 0.34.0.dev0__tar.gz → 0.34.0.dev2__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 (88) hide show
  1. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/PKG-INFO +1 -1
  2. nextmv-0.34.0.dev2/nextmv/__about__.py +1 -0
  3. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/__init__.py +1 -0
  4. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/local/application.py +65 -46
  5. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/local/executor.py +43 -1
  6. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/manifest.py +35 -5
  7. nextmv-0.34.0.dev0/nextmv/__about__.py +0 -1
  8. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/.gitignore +0 -0
  9. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/LICENSE +0 -0
  10. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/README.md +0 -0
  11. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/__entrypoint__.py +0 -0
  12. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/_serialization.py +0 -0
  13. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/base_model.py +0 -0
  14. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/cloud/__init__.py +0 -0
  15. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/cloud/acceptance_test.py +0 -0
  16. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/cloud/account.py +0 -0
  17. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/cloud/application.py +0 -0
  18. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/cloud/batch_experiment.py +0 -0
  19. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/cloud/client.py +0 -0
  20. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/cloud/ensemble.py +0 -0
  21. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/cloud/input_set.py +0 -0
  22. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/cloud/instance.py +0 -0
  23. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/cloud/package.py +0 -0
  24. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/cloud/scenario.py +0 -0
  25. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/cloud/secrets.py +0 -0
  26. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/cloud/url.py +0 -0
  27. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/cloud/version.py +0 -0
  28. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/default_app/.gitignore +0 -0
  29. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/default_app/README.md +0 -0
  30. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/default_app/app.yaml +0 -0
  31. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/default_app/input.json +0 -0
  32. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/default_app/main.py +0 -0
  33. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/default_app/requirements.txt +0 -0
  34. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/default_app/src/__init__.py +0 -0
  35. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/default_app/src/main.py +0 -0
  36. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/default_app/src/visuals.py +0 -0
  37. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/deprecated.py +0 -0
  38. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/input.py +0 -0
  39. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/local/__init__.py +0 -0
  40. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/local/geojson_handler.py +0 -0
  41. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/local/local.py +0 -0
  42. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/local/plotly_handler.py +0 -0
  43. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/local/runner.py +0 -0
  44. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/logger.py +0 -0
  45. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/model.py +0 -0
  46. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/options.py +0 -0
  47. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/output.py +0 -0
  48. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/polling.py +0 -0
  49. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/run.py +0 -0
  50. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/safe.py +0 -0
  51. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/nextmv/status.py +0 -0
  52. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/pyproject.toml +0 -0
  53. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/__init__.py +0 -0
  54. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/cloud/__init__.py +0 -0
  55. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/cloud/app.yaml +0 -0
  56. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/cloud/test_client.py +0 -0
  57. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/cloud/test_package.py +0 -0
  58. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/cloud/test_scenario.py +0 -0
  59. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/local/__init__.py +0 -0
  60. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/local/test_application.py +0 -0
  61. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/local/test_executor.py +0 -0
  62. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/local/test_runner.py +0 -0
  63. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/scripts/__init__.py +0 -0
  64. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/scripts/options1.py +0 -0
  65. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/scripts/options2.py +0 -0
  66. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/scripts/options3.py +0 -0
  67. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/scripts/options4.py +0 -0
  68. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/scripts/options5.py +0 -0
  69. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/scripts/options6.py +0 -0
  70. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/scripts/options7.py +0 -0
  71. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/scripts/options_deprecated.py +0 -0
  72. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_base_model.py +0 -0
  73. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_entrypoint/__init__.py +0 -0
  74. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_entrypoint/test_entrypoint.py +0 -0
  75. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_input.py +0 -0
  76. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_inputs/test_data.csv +0 -0
  77. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_inputs/test_data.json +0 -0
  78. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_inputs/test_data.txt +0 -0
  79. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_logger.py +0 -0
  80. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_manifest.py +0 -0
  81. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_model.py +0 -0
  82. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_options.py +0 -0
  83. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_output.py +0 -0
  84. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_polling.py +0 -0
  85. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_run.py +0 -0
  86. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_safe.py +0 -0
  87. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_serialization.py +0 -0
  88. {nextmv-0.34.0.dev0 → nextmv-0.34.0.dev2}/tests/test_version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nextmv
3
- Version: 0.34.0.dev0
3
+ Version: 0.34.0.dev2
4
4
  Summary: The all-purpose Python SDK for Nextmv
5
5
  Project-URL: Homepage, https://www.nextmv.io
6
6
  Project-URL: Documentation, https://nextmv-py.docs.nextmv.io/en/latest/nextmv/
@@ -0,0 +1 @@
1
+ __version__ = "v0.34.0.dev2"
@@ -25,6 +25,7 @@ from .manifest import ManifestPythonArch as ManifestPythonArch
25
25
  from .manifest import ManifestPythonModel as ManifestPythonModel
26
26
  from .manifest import ManifestRuntime as ManifestRuntime
27
27
  from .manifest import ManifestType as ManifestType
28
+ from .manifest import default_python_manifest as default_python_manifest
28
29
  from .model import Model as Model
29
30
  from .model import ModelConfiguration as ModelConfiguration
30
31
  from .options import Option as Option
@@ -33,13 +33,14 @@ from nextmv.local.local import (
33
33
  )
34
34
  from nextmv.local.runner import run
35
35
  from nextmv.logger import log
36
- from nextmv.manifest import Manifest
36
+ from nextmv.manifest import Manifest, default_python_manifest
37
37
  from nextmv.options import Options
38
38
  from nextmv.output import ASSETS_KEY, OUTPUTS_KEY, SOLUTIONS_KEY, STATISTICS_KEY, OutputFormat
39
39
  from nextmv.polling import DEFAULT_POLLING_OPTIONS, PollingOptions, poll
40
40
  from nextmv.run import (
41
41
  ErrorLog,
42
42
  Format,
43
+ FormatInput,
43
44
  Run,
44
45
  RunConfiguration,
45
46
  RunInformation,
@@ -93,6 +94,39 @@ class Application:
93
94
 
94
95
  description: Optional[str] = None
95
96
  """Description of the application."""
97
+ manifest: Optional[Manifest] = None
98
+ """
99
+ Manifest of the application. A manifest is a file named `app.yaml` that
100
+ must be present at the root of the application's `src` directory. If the
101
+ app is initialized, and a manifest is not present, a default Python
102
+ manifest will be created, using the `nextmv.default_python_manifest`
103
+ function. If you specify this argument, and a manifest file is already
104
+ present in the `src` directory, the provided manifest will override the
105
+ existing one.
106
+ """
107
+
108
+ def __post_init__(self):
109
+ """
110
+ Validate the presence of the manifest in the application.
111
+ """
112
+
113
+ if self.manifest is not None:
114
+ self.manifest.to_yaml(self.src)
115
+
116
+ return
117
+
118
+ try:
119
+ manifest = Manifest.from_yaml(self.src)
120
+ self.manifest = manifest
121
+
122
+ return
123
+
124
+ except Exception:
125
+ manifest = default_python_manifest()
126
+ self.manifest = manifest
127
+ manifest.to_yaml(self.src)
128
+
129
+ return
96
130
 
97
131
  @classmethod
98
132
  def initialize(
@@ -293,7 +327,7 @@ class Application:
293
327
  >>> print(f"Local run completed with ID: {run_id}")
294
328
  """
295
329
 
296
- self.__validate_input_dir_path_and_configuration(input_dir_path, configuration)
330
+ configuration = self.__validate_input_dir_path_and_configuration(input_dir_path, configuration)
297
331
 
298
332
  if self.src is None:
299
333
  raise ValueError("`src` property for the `Application` must be specified to run the application locally")
@@ -853,7 +887,7 @@ class Application:
853
887
  self,
854
888
  input_dir_path: Optional[str],
855
889
  configuration: Optional[Union[RunConfiguration, dict[str, Any]]],
856
- ) -> None:
890
+ ) -> RunConfiguration:
857
891
  """
858
892
  Auxiliary function to validate the directory path and configuration.
859
893
  """
@@ -862,60 +896,45 @@ class Application:
862
896
  return
863
897
 
864
898
  if configuration is None:
865
- raise ValueError(
866
- "If dir_path is provided, a RunConfiguration must also be provided.",
867
- )
899
+ if self.manifest.configuration is not None and self.manifest.configuration.content is not None:
900
+ configuration = RunConfiguration(
901
+ format=Format(
902
+ format_input=FormatInput(
903
+ input_type=self.manifest.configuration.content.format,
904
+ ),
905
+ ),
906
+ )
907
+ else:
908
+ raise ValueError(
909
+ "If `dir_path` is provided, either a `RunConfiguration` must also be provided or "
910
+ "the application's manifest (app.yaml) must include the format under "
911
+ "`configuration.content.format`.",
912
+ )
868
913
 
869
- config_format = self.__extract_config_format(configuration)
914
+ # Forcefully turn the configuration into a RunConfiguration object to
915
+ # make it easier to deal with in the other functions.
916
+ if isinstance(configuration, dict):
917
+ configuration = RunConfiguration.from_dict(configuration)
870
918
 
919
+ config_format = configuration.format
871
920
  if config_format is None:
872
921
  raise ValueError(
873
- "If dir_path is provided, RunConfiguration.format must also be provided.",
922
+ "If `dir_path` is provided, `RunConfiguration.format` must also be provided.",
874
923
  )
875
924
 
876
- input_type = self.__extract_input_type(config_format)
877
-
878
- if input_type is None or input_type in (InputFormat.JSON, InputFormat.TEXT):
925
+ input_type = config_format.format_input
926
+ if input_type is None:
879
927
  raise ValueError(
880
- "If dir_path is provided, RunConfiguration.format.format_input.input_type must be set to a valid type. "
881
- f"Valid types are: {[InputFormat.CSV_ARCHIVE, InputFormat.MULTI_FILE]}",
928
+ "If `dir_path` is provided, `RunConfiguration.format.format_input` must also be provided.",
882
929
  )
883
930
 
884
- def __extract_config_format(self, configuration: Union[RunConfiguration, dict[str, Any]]) -> Any:
885
- """Extract format from configuration, handling both RunConfiguration objects and dicts."""
886
- if isinstance(configuration, RunConfiguration):
887
- return configuration.format
888
-
889
- if isinstance(configuration, dict):
890
- config_format = configuration.get("format")
891
- if config_format is not None and isinstance(config_format, dict):
892
- return Format.from_dict(config_format) if hasattr(Format, "from_dict") else config_format
893
-
894
- return config_format
895
-
896
- raise ValueError("Configuration must be a RunConfiguration object or a dict.")
897
-
898
- def __extract_input_type(self, config_format: Any) -> Any:
899
- """Extract input type from config format."""
900
- if isinstance(config_format, dict):
901
- format_input = config_format.get("format_input") or config_format.get("input")
902
- if format_input is None:
903
- raise ValueError(
904
- "If dir_path is provided, RunConfiguration.format.format_input must also be provided.",
905
- )
906
-
907
- if isinstance(format_input, dict):
908
- return format_input.get("input_type") or format_input.get("type")
909
-
910
- return getattr(format_input, "input_type", None)
911
-
912
- # Handle Format object
913
- if config_format.format_input is None:
931
+ if input_type is None or input_type in (InputFormat.JSON, InputFormat.TEXT):
914
932
  raise ValueError(
915
- "If dir_path is provided, RunConfiguration.format.format_input must also be provided.",
933
+ "If `dir_path` is provided, `RunConfiguration.format.format_input.input_type` must be set to "
934
+ f"a valid type. Valid types are: {[InputFormat.CSV_ARCHIVE, InputFormat.MULTI_FILE]}",
916
935
  )
917
936
 
918
- return config_format.format_input.input_type
937
+ return configuration
919
938
 
920
939
  def __extract_input_data(
921
940
  self,
@@ -1026,7 +1045,7 @@ class Application:
1026
1045
  # Read the logs of the run and place each line as an element in a list
1027
1046
  run_dir = os.path.join(runs_dir, run_id)
1028
1047
  with open(os.path.join(run_dir, LOGS_KEY, LOGS_FILE)) as f:
1029
- stderr_logs = [line.rstrip("\n") for line in f.readlines()]
1048
+ stderr_logs = f.read()
1030
1049
 
1031
1050
  # Create the tracked run object and start configuring it.
1032
1051
  tracked_run = TrackedRun(
@@ -907,6 +907,9 @@ def _copy_new_or_modified_files(
907
907
  3. Files that are NOT present in the original source directory (if provided)
908
908
  4. Files that are NOT present in any of the exclusion directories (if provided)
909
909
 
910
+ Empty directories are not created or are removed after copying to avoid
911
+ cluttering the output with empty folders.
912
+
910
913
  Parameters
911
914
  ----------
912
915
  src_dir : str
@@ -927,11 +930,12 @@ def _copy_new_or_modified_files(
927
930
  if exclusion_dirs is not None:
928
931
  exclusion_directories.extend(exclusion_dirs)
929
932
 
933
+ files_copied = False
930
934
  for root, _dirs, files in os.walk(src_dir):
931
935
  rel_root = os.path.relpath(root, src_dir)
932
936
  dst_root = dst_dir if rel_root == "." else os.path.join(dst_dir, rel_root)
933
- os.makedirs(dst_root, exist_ok=True)
934
937
 
938
+ files_to_copy = []
935
939
  for file in files:
936
940
  # Skip if file exists in any exclusion directory
937
941
  if exclusion_directories and _file_exists_in_exclusion_dirs(file, rel_root, exclusion_directories):
@@ -941,7 +945,45 @@ def _copy_new_or_modified_files(
941
945
  dst_file = os.path.join(dst_root, file)
942
946
 
943
947
  if _should_copy_file(src_file, dst_file):
948
+ files_to_copy.append((src_file, dst_file))
949
+
950
+ # Only create directory if there are files to copy
951
+ if files_to_copy:
952
+ os.makedirs(dst_root, exist_ok=True)
953
+ for src_file, dst_file in files_to_copy:
944
954
  shutil.copy2(src_file, dst_file)
955
+ files_copied = True
956
+
957
+ # Clean up empty directories after copying
958
+ if files_copied:
959
+ _remove_empty_directories(dst_dir)
960
+
961
+
962
+ def _remove_empty_directories(directory: str) -> None:
963
+ """
964
+ Recursively remove empty directories starting from the given directory.
965
+
966
+ This function walks the directory tree bottom-up and removes any directories
967
+ that are empty after all files have been processed. It preserves the root
968
+ directory even if it's empty.
969
+
970
+ Parameters
971
+ ----------
972
+ directory : str
973
+ The root directory path to start cleaning from.
974
+ """
975
+ for root, dirs, files in os.walk(directory, topdown=False):
976
+ # Skip the root directory itself
977
+ if root == directory:
978
+ continue
979
+
980
+ # If directory is empty (no files and no subdirectories), remove it
981
+ if not files and not dirs:
982
+ try:
983
+ os.rmdir(root)
984
+ except OSError:
985
+ # Directory might not be empty due to hidden files or permissions
986
+ pass
945
987
 
946
988
 
947
989
  def _should_copy_file(src_file: str, dst_file: str) -> bool:
@@ -357,19 +357,24 @@ class ManifestPython(BaseModel):
357
357
  validation_alias=AliasChoices("pip-requirements", "pip_requirements"),
358
358
  default=None,
359
359
  )
360
- """Path to a requirements.txt file.
360
+ """
361
+ Path to a requirements.txt file.
361
362
 
362
363
  Contains (additional) Python dependencies that will be bundled with the
363
364
  app.
364
365
  """
365
366
  arch: Optional[ManifestPythonArch] = None
366
- """The architecture this model is meant to run on. One of "arm64" or "amd64". Uses
367
- "arm64" if not specified."""
367
+ """
368
+ The architecture this model is meant to run on. One of "arm64" or "amd64". Uses
369
+ "arm64" if not specified.
370
+ """
368
371
  version: Optional[Union[str, float]] = None
369
- """The Python version this model is meant to run with. Uses "3.11" if not specified.
372
+ """
373
+ The Python version this model is meant to run with. Uses "3.11" if not specified.
370
374
  """
371
375
  model: Optional[ManifestPythonModel] = None
372
- """Information about an encoded decision model.
376
+ """
377
+ Information about an encoded decision model.
373
378
 
374
379
  As handled via mlflow. This information is used to load the decision model
375
380
  from the app bundle.
@@ -1352,3 +1357,28 @@ class Manifest(BaseModel):
1352
1357
  )
1353
1358
 
1354
1359
  return manifest
1360
+
1361
+
1362
+ def default_python_manifest() -> Manifest:
1363
+ """
1364
+ Creates a default Python manifest as a starting point for applications
1365
+ being executed on the Nextmv Platform.
1366
+
1367
+ You can import the `default_python_manifest` function directly from `nextmv`:
1368
+
1369
+ ```python
1370
+ from nextmv import default_python_manifest
1371
+ ```
1372
+
1373
+ Returns
1374
+ -------
1375
+ Manifest
1376
+ A default Python manifest with common settings.
1377
+ """
1378
+
1379
+ return Manifest(
1380
+ files=["main.py"],
1381
+ runtime=ManifestRuntime.PYTHON,
1382
+ type=ManifestType.PYTHON,
1383
+ python=ManifestPython(pip_requirements="requirements.txt"),
1384
+ )
@@ -1 +0,0 @@
1
- __version__ = "v0.34.0.dev0"
File without changes
File without changes
File without changes
File without changes