sliderule-cli 5.6.2__tar.gz → 5.6.3__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 (25) hide show
  1. {sliderule_cli-5.6.2/src/sliderule_cli.egg-info → sliderule_cli-5.6.3}/PKG-INFO +1 -1
  2. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3/src/sliderule_cli.egg-info}/PKG-INFO +1 -1
  3. sliderule_cli-5.6.3/src/sliderule_cli.egg-info/scm_version.json +8 -0
  4. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/src/sliderule_cluster/main.py +16 -15
  5. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/src/sliderule_provisioner/main.py +16 -4
  6. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/src/sliderule_runner/main.py +61 -43
  7. sliderule_cli-5.6.2/src/sliderule_cli.egg-info/scm_version.json +0 -8
  8. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/.gitignore +0 -0
  9. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/LICENSE +0 -0
  10. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/README.md +0 -0
  11. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/pyproject.toml +0 -0
  12. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/setup.cfg +0 -0
  13. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/src/sliderule_cli.egg-info/SOURCES.txt +0 -0
  14. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/src/sliderule_cli.egg-info/dependency_links.txt +0 -0
  15. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/src/sliderule_cli.egg-info/entry_points.txt +0 -0
  16. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/src/sliderule_cli.egg-info/requires.txt +0 -0
  17. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/src/sliderule_cli.egg-info/scm_file_list.json +0 -0
  18. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/src/sliderule_cli.egg-info/top_level.txt +0 -0
  19. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/src/sliderule_cluster/__init__.py +0 -0
  20. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/src/sliderule_provisioner/__init__.py +0 -0
  21. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/src/sliderule_recorder/__init__.py +0 -0
  22. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/src/sliderule_recorder/environment.yml +0 -0
  23. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/src/sliderule_recorder/main.py +0 -0
  24. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/src/sliderule_runner/__init__.py +0 -0
  25. {sliderule_cli-5.6.2 → sliderule_cli-5.6.3}/src/sliderule_runner/database.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sliderule-cli
3
- Version: 5.6.2
3
+ Version: 5.6.3
4
4
  Summary: SlideRule Command Line Tool
5
5
  License-Expression: BSD-3-Clause
6
6
  Requires-Python: >=3.9
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sliderule-cli
3
- Version: 5.6.2
3
+ Version: 5.6.3
4
4
  Summary: SlideRule Command Line Tool
5
5
  License-Expression: BSD-3-Clause
6
6
  Requires-Python: >=3.9
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "5.6.3",
3
+ "distance": 0,
4
+ "node": "ge620b5509bb5a993af03131885c3a6f23922cb24",
5
+ "dirty": false,
6
+ "branch": "main",
7
+ "node_date": "2026-09-30"
8
+ }
@@ -1,3 +1,4 @@
1
+ import sys
1
2
  import json
2
3
  import uuid
3
4
  import tempfile
@@ -42,10 +43,9 @@ class Tool:
42
43
 
43
44
  # whoami
44
45
  def whoami(self):
45
- rsps = self.session.source("whoami", parm={}, path="/discovery", retries=0, rethrow=True)
46
- result = json.dumps(rsps)
47
- self.__display_result(result)
48
- return result
46
+ result = self.session.source("whoami", parm={}, path="/discovery", retries=0, rethrow=True)
47
+ self.__display_result(result, reformat=True)
48
+ return json.dumps(result)
49
49
 
50
50
  # status
51
51
  def status(self):
@@ -53,22 +53,21 @@ class Tool:
53
53
  parm = {"service":self.session.service}
54
54
  else:
55
55
  parm = {}
56
- rsps = self.session.source("status", parm=parm, path="/discovery", retries=0, rethrow=True)
57
- result = json.dumps(rsps)
58
- self.__display_result(result)
59
- return result
56
+ result = self.session.source("status", parm=parm, path="/discovery", retries=0, rethrow=True)
57
+ self.__display_result(result, reformat=True)
58
+ return json.dumps(result)
60
59
 
61
60
  # version
62
61
  def version(self):
63
62
  result = sliderule.get_version(session=self.session)
64
63
  self.__display_result(result, reformat=True)
65
- return result
64
+ return json.dumps(result)
66
65
 
67
66
  # defaults
68
67
  def defaults(self):
69
68
  result = sliderule.source("defaults", session=self.session)
70
69
  self.__display_result(result, reformat=True)
71
- return result
70
+ return json.dumps(result)
72
71
 
73
72
  # earthdata
74
73
  def earthdata(self):
@@ -84,13 +83,13 @@ class Tool:
84
83
  }.items() if v is not None })
85
84
  result = sliderule.source("earthdata", parms, rethrow=True, session=self.session)
86
85
  self.__display_result(result, reformat=True)
87
- return result
86
+ return json.dumps(result)
88
87
 
89
88
  # run
90
89
  def run(self):
91
90
  # get request parameters
92
- if self.args.parms.startswith('"') or self.args.parms.startswith('{'):
93
- parms = self.args.parms
91
+ if self.args.parms.startswith('{'):
92
+ parms = json.loads(self.args.parms)
94
93
  else:
95
94
  parms = self.__get_parms()
96
95
  # build output parameters (if not supplied in request)
@@ -117,7 +116,7 @@ class Tool:
117
116
  break
118
117
  # set and return results
119
118
  result = output_path
120
- self.__display_result(result, reformat=True)
119
+ self.__display_result(result, reformat=False)
121
120
  return result
122
121
 
123
122
  #########################################
@@ -157,7 +156,7 @@ def main():
157
156
  # earthdata
158
157
  earthdata = subparsers.add_parser("earthdata", parents=[common], help="earthdata search")
159
158
  earthdata.add_argument('--asset', type=str, default="icesat2", help="SlideRule asset name")
160
- earthdata.add_argument('--poly', type=float, nargs='+', default=None, help="closed counter-clockwise list of latitude longitude pairs defining the area of interest; e.g. lon1 lat1 lon2 lat2 lon3 lat3 ... lon1 lat1")
159
+ earthdata.add_argument('--poly', type=float, nargs='+', default=None, help="closed counter-clockwise list of longitude/latitude pairs defining the area of interest; e.g. lon1 lat1 lon2 lat2 lon3 lat3 ... lon1 lat1")
161
160
  earthdata.add_argument('--geojson', type=str, default=None, help="geojson filename defining the area of interest")
162
161
  earthdata.add_argument('--bbox', type=float, nargs='+', default=None, help="bounding box defining the area of interest; e.g. lon_ll lat_ll lon_ur lat_ur")
163
162
  earthdata.add_argument('--t0', type=str, default=None, help="start time as an ISO datetime string YYYY-MM-DDTHH:MM:SSZ") # datetime.fromisoformat(args.t0)
@@ -166,6 +165,7 @@ def main():
166
165
  earthdata.add_argument('--short_name', type=str, default=None, help="CMR dataset name")
167
166
  earthdata.add_argument('--with_meta', action='store_true', default=False, help="return metadata along with query results")
168
167
  earthdata.add_argument('--name_filter', type=str, default=None, help="regular expression to evaluate against resource names returned by query")
168
+ earthdata.add_argument('--parms', type=str, default=None, help="string or filename containing json parameters to use in request")
169
169
  earthdata.set_defaults(func=Tool.earthdata)
170
170
 
171
171
  # run
@@ -189,6 +189,7 @@ def main():
189
189
  except Exception as e:
190
190
  if args.verbose: raise
191
191
  print(f"Unhandled error: {e}")
192
+ sys.exit(1) # returns error back to system
192
193
 
193
194
  # running via direct invocation
194
195
  if __name__ == "__main__": main()
@@ -69,6 +69,14 @@ class Tool:
69
69
  def info(self):
70
70
  return self.session.provisioner.info()
71
71
 
72
+ # S3 Access
73
+ def s3access(self):
74
+ credentials = self.session.provisioner.s3access()
75
+ print(f'export AWS_ACCESS_KEY_ID={credentials["access_key_id"]}')
76
+ print(f'export AWS_SECRET_ACCESS_KEY={credentials["secret_access_key"]}')
77
+ print(f'export AWS_SESSION_TOKEN={credentials["session_token"]}')
78
+ print(f'export AWS_CREDENTIAL_EXPIRATION={credentials["expiration"]}')
79
+
72
80
  # Authenticate
73
81
  def authenticate(self):
74
82
  return self.session.authenticate(force_login=True)
@@ -81,7 +89,7 @@ class Tool:
81
89
  def main():
82
90
 
83
91
  # command line arguments
84
- parser = argparse.ArgumentParser(prog="sliderule-runner", description="""SlideRule Runner""")
92
+ parser = argparse.ArgumentParser(prog="sliderule-provisioner", description="""SlideRule Provisioner""")
85
93
  subparsers = parser.add_subparsers(dest="command", required=True)
86
94
 
87
95
  # options shared by every subcommand; parents= lets them appear after the command name
@@ -89,7 +97,6 @@ def main():
89
97
  common.add_argument('--domain', type=str, default="slideruleearth.io")
90
98
  common.add_argument('--cluster', type=str, default="developers")
91
99
  common.add_argument('--user_service', action='store_true', default=False)
92
- common.add_argument('--concise', action='store_true', default=False)
93
100
  common.add_argument('--verbose', action='store_true', default=False)
94
101
  common.add_argument('--timezone', type=str, default="America/New_York")
95
102
 
@@ -120,7 +127,7 @@ def main():
120
127
 
121
128
  # report
122
129
  report = subparsers.add_parser("report", parents=[common], help="report metadata of active clusters")
123
- report.add_argument('--kind', type=str, default="clusters", help="'cluster' for cluster report, 'test' for test report")
130
+ report.add_argument('--kind', type=str, default="cluster", help="'cluster' for cluster report, 'test' for test report")
124
131
  report.set_defaults(func=Tool.report)
125
132
 
126
133
  # test
@@ -132,6 +139,10 @@ def main():
132
139
  info = subparsers.add_parser("info", parents=[common], help="display information about provisioner")
133
140
  info.set_defaults(func=Tool.info)
134
141
 
142
+ # s3access
143
+ s3access = subparsers.add_parser("s3access", parents=[common], help="sets AWS credentials by outputting shell commands to 'eval' into current environment")
144
+ s3access.set_defaults(func=Tool.s3access)
145
+
135
146
  # authenticate
136
147
  authenticate = subparsers.add_parser("authenticate", parents=[common], help="force re-authentication of user account")
137
148
  authenticate.set_defaults(func=Tool.authenticate)
@@ -145,7 +156,8 @@ def main():
145
156
  # route command
146
157
  try:
147
158
  result = args.func(tool)
148
- print(f'{json.dumps(result, indent=2)}')
159
+ if result:
160
+ print(f'{json.dumps(result, indent=2)}')
149
161
  except Exception as e:
150
162
  if args.verbose: raise
151
163
  print(f"Unhandled error: {e}")
@@ -4,6 +4,7 @@ import boto3
4
4
  import random
5
5
  import string
6
6
  import argparse
7
+ import traceback
7
8
  from sliderule import sliderule
8
9
  from .database import Database, JobState, QueuePriority, JobStatus
9
10
  from pathlib import Path
@@ -27,7 +28,7 @@ class Tool:
27
28
  # open database
28
29
  self.database = Database(args.database)
29
30
  # create sliderule session
30
- self.session = sliderule.create_session(verbose=args.verbose)
31
+ self.session = sliderule.create_session(domain=args.domain, verbose=args.verbose)
31
32
  self.session.authenticate() # gives privileges to access SlideRule Runner
32
33
  # aws clients
33
34
  self.s3 = boto3.client("s3", region_name="us-west-2")
@@ -36,7 +37,10 @@ class Tool:
36
37
  def __load_remote_file(self, bucket, key):
37
38
  obj = self.s3.get_object(Bucket=bucket, Key=key)
38
39
  contents = obj["Body"].read().decode("utf-8")
39
- return json.loads(contents)
40
+ try:
41
+ return json.loads(contents)
42
+ except Exception:
43
+ return contents
40
44
 
41
45
  # Get Results for a Job Run
42
46
  # output of job must include the following fields for this to work:
@@ -51,15 +55,22 @@ class Tool:
51
55
  bucket = run_url.split("s3://")[-1].split("/")[0]
52
56
  prefix = "/".join(run_url.split("s3://")[-1].split("/")[1:])
53
57
  receipt = self.__load_remote_file(bucket, f"{prefix}/receipt.json") # {"name": ..., "username": ... "args": <path to arg file>, "environment": ...}
54
- args_list = self.__load_remote_file(bucket, receipt["args"])
58
+ args_bucket = receipt["args"].split("s3://")[-1].split("/")[0]
59
+ args_file = "/".join(receipt["args"].split("s3://")[-1].split("/")[1:])
60
+ args_content = self.__load_remote_file(args_bucket, args_file)
61
+ if isinstance(args_content, list):
62
+ args_list = args_content
63
+ elif isinstance(args_content, dict) or isinstance(args_content, str):
64
+ args_list = [args_content]
55
65
  for i in tqdm(range(len(args_list)), total=len(args_list), desc=f"{run_url}", unit="granule"):
66
+ result_file = f"{prefix}/result{len(args_list) > 1 and str(i) or ''}.json"
56
67
  try:
57
68
  result = {
58
- "file": f"{prefix}/result{i}.json",
69
+ "file": f"s3://{bucket}/{result_file}",
59
70
  "environment": receipt["environment"],
60
71
  "arg": args_list[i]
61
72
  }
62
- rsps = self.__load_remote_file(bucket, f"{prefix}/result{i}.json")
73
+ rsps = self.__load_remote_file(bucket, result_file)
63
74
  try:
64
75
  result |= {
65
76
  "status": rsps["status"] and JobStatus.SUCCESS or JobStatus.FAILURE,
@@ -89,7 +100,8 @@ class Tool:
89
100
  # pull out arguments
90
101
  name = self.args.name
91
102
  script_file = self.args.script
92
- arguments_file = self.args.arguments
103
+ arguments_input = self.args.arguments
104
+ arg_as_str = self.args.arg_as_str
93
105
  batch_size = self.args.batch_size
94
106
  vcpus = self.args.vcpus
95
107
  memory = self.args.memory
@@ -99,9 +111,12 @@ class Tool:
99
111
  with open(script_file, "r") as file:
100
112
  script = file.read()
101
113
  # read arguments
102
- with open(arguments_file, "r") as file:
103
- arguments = [line.strip() for line in file.readlines()]
104
- # process job in batches
114
+ if arg_as_str:
115
+ arguments = [str(arguments_input)]
116
+ else:
117
+ with open(arguments_input, "r") as file:
118
+ arguments = [line.strip() for line in file.readlines()]
119
+ # process job in batches
105
120
  for i in range(0, len(arguments), batch_size):
106
121
  # build and check name
107
122
  job_name = f"{name}_{i}"
@@ -111,12 +126,12 @@ class Tool:
111
126
  # submit & save job
112
127
  args_list = arguments[i:i+batch_size]
113
128
  rsps = self.session.runner.submit(name=job_name, script=script, args=args_list, optional_args={"vcpus":vcpus, "memory":memory, "image":image, "queue":queue})
114
- self.database.submissions[job_name] = rsps | {"complete": False}
129
+ self.database.submissions[job_name] = rsps | {"complete": False, "job_size": len(args_list)}
115
130
  print(f"Submitted job {job_name} using script {script_file} with {len(args_list)} entries: {rsps}")
116
131
 
117
132
  # Scrape Submissions
118
133
  def scrape_submissions(self):
119
- arg_list = []
134
+ output_list = []
120
135
  for name,submission in self.database.submissions.items():
121
136
  if self.args.name and self.args.name != name: continue
122
137
  try:
@@ -125,16 +140,20 @@ class Tool:
125
140
  for i in range(len(submission["results"])):
126
141
  result = submission["results"][i]
127
142
  if result["status"] in self.args.status:
128
- print(f'{i:-5d}: {result["arg"]}')
129
- arg_list.append(result["arg"])
143
+ if self.args.verbose:
144
+ print(f'{i:-5d}: {result}\n')
145
+ output_list.append(json.dumps(result))
146
+ else:
147
+ print(f'{i:-5d}: {result["arg"]}')
148
+ output_list.append(result["arg"])
130
149
  else:
131
150
  print(f"Skipping {name} because it is still pending")
132
151
  except Exception as e:
133
152
  print(f"Failed to scrape {name}: {e}")
134
153
  if self.args.output:
135
154
  with open(self.args.output, "w") as file:
136
- for arg in arg_list:
137
- file.write(f"{arg}\n")
155
+ for output in output_list:
156
+ file.write(f"{output}\n")
138
157
 
139
158
  # Get Status
140
159
  def get_status(self):
@@ -143,12 +162,14 @@ class Tool:
143
162
  complete = job["complete"]
144
163
  print(f"Statusing {name} ...")
145
164
  if not complete:
146
- queue_status = self.session.runner.queue(job_id=job["job_id"], queue=queue, verbose=True)
147
- # jobs
148
- child_jobs = queue_status["jobs"]
149
- self.database.submissions[name]["jobs"] = {}
150
- for child_job in child_jobs:
151
- self.database.submissions[name]["jobs"][int(child_job["index"])] = child_job
165
+ if job["job_size"] > 1: # array processing, use parent job id
166
+ queue_status = self.session.runner.queue(job_id=job["job_id"], queue=queue, verbose=True)
167
+ child_jobs = queue_status["jobs"]
168
+ self.database.submissions[name]["jobs"] = {}
169
+ for child_job in child_jobs:
170
+ self.database.submissions[name]["jobs"][int(child_job["index"])] = child_job
171
+ else: # single job, use name
172
+ queue_status = self.session.runner.queue(name=name, queue=queue, verbose=False)
152
173
  # report
153
174
  report = queue_status["report"]
154
175
  self.database.submissions[name]["status"] = report
@@ -172,30 +193,21 @@ class Tool:
172
193
 
173
194
  # Generate Report
174
195
  def generate_report(self):
175
- stats = {status.value: 0 for status in JobStatus}
176
- duration = {"avg": 0.0, "total": 0.0}
177
- processed = 0
178
- pending = 0
179
- errors = 0
196
+ print(",".join([f"{c:>30}" for c in ["name"]] + [f"{c:>12}" for c in ["processed"]] + [f"{c:>12}" for c in list(JobStatus)] + [f"{c:>12}" for c in ["duration"]]))
180
197
  for name,submission in self.database.submissions.items():
181
198
  if self.args.name and self.args.name != name: continue
199
+ stats = {status.value: 0 for status in JobStatus}
200
+ duration = {"avg": 0.0, "total": 0.0}
201
+ processed = 0
182
202
  if submission["complete"]:
183
203
  for result in submission["results"]:
184
- try:
185
- stats[result["status"]] += 1
204
+ stats[result["status"]] += 1
205
+ if "duration" in result:
186
206
  duration["total"] += result["duration"]
187
207
  processed += 1
188
- except Exception as e:
189
- errors += 1
190
- else:
191
- pending += 1
192
- if processed > 0:
193
- duration["avg"] = duration["total"] / processed
194
- print("Processed:", processed)
195
- print("Pending:", pending)
196
- print("Errors:", errors)
197
- print("Status:", json.dumps(stats, indent=2))
198
- print("Duration:", json.dumps(duration, indent=2))
208
+ if processed > 0:
209
+ duration["avg"] = duration["total"] / processed
210
+ print(",".join([f"{c:>30}" for c in [name]] + [f"{c:>12}" for c in [processed]] + [f"{c:>12}" for c in [stats[status] for status in list(JobStatus)]] + [f"{c:>12.3}" for c in [duration["avg"]]]))
199
211
 
200
212
  # Cancel Job
201
213
  def cancel_job(self):
@@ -209,10 +221,13 @@ class Tool:
209
221
  def get_logs(self):
210
222
  name = self.args.name
211
223
  index = self.args.index
212
- job_id = self.database.submissions[name]["jobs"][str(index)]["job_id"]
224
+ if index is not None:
225
+ job_id = self.database.submissions[name]["jobs"][str(index)]["job_id"]
226
+ else:
227
+ job_id = self.database.submissions[name]["job_id"]
213
228
  events = self.session.runner.logs(job_id=job_id)
214
229
  for event in events:
215
- print(event)
230
+ print("->", event)
216
231
 
217
232
  # Finish
218
233
  def finish(self):
@@ -232,6 +247,7 @@ def main():
232
247
 
233
248
  # options shared by every subcommand; parents= lets them appear after the command name
234
249
  common = argparse.ArgumentParser(add_help=False)
250
+ common.add_argument('--domain', type=str, default="slideruleearth.io")
235
251
  common.add_argument('--database', type=Path, default=Path.home() / ".cache" / "sliderule" / "runner_database.json")
236
252
  common.add_argument('--queue', type=QueuePriority, default=QueuePriority.DEFAULT, choices=list(QueuePriority))
237
253
  common.add_argument('--verbose', action='store_true', default=False)
@@ -247,6 +263,7 @@ def main():
247
263
  submit.add_argument('name', metavar="<name>")
248
264
  submit.add_argument('script', metavar="<script.lua>", type=Path)
249
265
  submit.add_argument('arguments', metavar="<arguments.txt>", type=Path)
266
+ submit.add_argument('--arg_as_str', action='store_true', default=False) # changes arguments to be just a string instead of a file
250
267
  submit.add_argument('--vcpus', type=int, default=4)
251
268
  submit.add_argument('--memory', type=int, default=16000)
252
269
  submit.add_argument('--batch_size', type=int, default=10000)
@@ -265,7 +282,7 @@ def main():
265
282
  status.set_defaults(func=Tool.get_status)
266
283
 
267
284
  # report
268
- report = subparsers.add_parser("report", parents=[common], help="generate report of submitted jobs")
285
+ report = subparsers.add_parser("report", parents=[common], help="generate report of completed jobs")
269
286
  report.add_argument('--name', type=str, default=None) # name of submission
270
287
  report.set_defaults(func=Tool.generate_report)
271
288
 
@@ -277,7 +294,7 @@ def main():
277
294
  # logs
278
295
  logs = subparsers.add_parser("logs", parents=[common], help="get log messages for a child job")
279
296
  logs.add_argument('--name', type=str, required=True) # name of submission
280
- logs.add_argument('--index', type=int, required=True) # job index
297
+ logs.add_argument('--index', type=int, default=None) # job index
281
298
  logs.set_defaults(func=Tool.get_logs)
282
299
 
283
300
  # parse command line
@@ -293,6 +310,7 @@ def main():
293
310
  except Exception as e:
294
311
  if args.verbose: raise
295
312
  print(f"Unhandled error: {e}")
313
+ traceback.print_exc()
296
314
 
297
315
  # running via direct invocation
298
316
  if __name__ == "__main__": main()
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "5.6.2",
3
- "distance": 0,
4
- "node": "g76fb3116a5b77c6fbeb8f8df0a2cdffe60a1418b",
5
- "dirty": false,
6
- "branch": "main",
7
- "node_date": "2026-09-23"
8
- }
File without changes
File without changes
File without changes
File without changes