awspub 0.0.6__tar.gz → 0.0.8__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 (34) hide show
  1. {awspub-0.0.6 → awspub-0.0.8}/PKG-INFO +3 -19
  2. {awspub-0.0.6 → awspub-0.0.8}/awspub/configmodels.py +32 -0
  3. {awspub-0.0.6 → awspub-0.0.8}/awspub/exceptions.py +8 -0
  4. {awspub-0.0.6 → awspub-0.0.8}/awspub/image.py +22 -0
  5. awspub-0.0.8/awspub/sns.py +88 -0
  6. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/fixtures/config1.yaml +29 -0
  7. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/test_api.py +2 -0
  8. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/test_image.py +24 -6
  9. awspub-0.0.8/awspub/tests/test_sns.py +140 -0
  10. {awspub-0.0.6 → awspub-0.0.8}/pyproject.toml +8 -2
  11. {awspub-0.0.6 → awspub-0.0.8}/readme.rst +0 -17
  12. {awspub-0.0.6 → awspub-0.0.8}/LICENSE +0 -0
  13. {awspub-0.0.6 → awspub-0.0.8}/awspub/__init__.py +0 -0
  14. {awspub-0.0.6 → awspub-0.0.8}/awspub/api.py +0 -0
  15. {awspub-0.0.6 → awspub-0.0.8}/awspub/cli/__init__.py +0 -0
  16. {awspub-0.0.6 → awspub-0.0.8}/awspub/common.py +0 -0
  17. {awspub-0.0.6 → awspub-0.0.8}/awspub/context.py +0 -0
  18. {awspub-0.0.6 → awspub-0.0.8}/awspub/image_marketplace.py +0 -0
  19. {awspub-0.0.6 → awspub-0.0.8}/awspub/s3.py +0 -0
  20. {awspub-0.0.6 → awspub-0.0.8}/awspub/snapshot.py +0 -0
  21. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/__init__.py +0 -0
  22. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/fixtures/config-invalid-s3-extra.yaml +0 -0
  23. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/fixtures/config-minimal.yaml +0 -0
  24. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/fixtures/config-valid-nonawspub.yaml +0 -0
  25. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/fixtures/config1.vmdk +0 -0
  26. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/fixtures/config2-mapping.yaml +0 -0
  27. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/fixtures/config2.yaml +0 -0
  28. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/fixtures/config3-duplicate-keys.yaml +0 -0
  29. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/test_cli.py +0 -0
  30. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/test_common.py +0 -0
  31. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/test_context.py +0 -0
  32. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/test_image_marketplace.py +0 -0
  33. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/test_s3.py +0 -0
  34. {awspub-0.0.6 → awspub-0.0.8}/awspub/tests/test_snapshot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: awspub
3
- Version: 0.0.6
3
+ Version: 0.0.8
4
4
  Summary: Publish images to AWS EC2
5
5
  Home-page: https://github.com/canonical/awspub
6
6
  License: GPL-3.0-or-later
@@ -14,9 +14,10 @@ Classifier: Programming Language :: Python :: 3.9
14
14
  Classifier: Programming Language :: Python :: 3.10
15
15
  Classifier: Programming Language :: Python :: 3.11
16
16
  Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
17
18
  Requires-Dist: autodoc-pydantic (>=2.0.1,<3.0.0)
18
19
  Requires-Dist: boto3
19
- Requires-Dist: boto3-stubs[essential,marketplace-catalog,s3,ssm] (>=1.28.83,<2.0.0)
20
+ Requires-Dist: boto3-stubs[essential,marketplace-catalog,s3,sns,ssm,sts] (>=1.28.83,<2.0.0)
20
21
  Requires-Dist: pydantic (>=2,<3)
21
22
  Requires-Dist: ruamel-yaml (>=0.18.6,<0.19.0)
22
23
  Project-URL: Repository, https://github.com/canonical/awspub
@@ -44,20 +45,3 @@ License
44
45
 
45
46
  The project uses `GPL-3.0` as license.
46
47
 
47
- Doing a new release
48
- ===================
49
-
50
- New releases are mostly automated.
51
-
52
- pypi
53
- ----
54
-
55
- For a new release on pypi, create a new tag (following semantic versioning)
56
- with a `v` as prefix (eg. `v0.2.1`).
57
-
58
- snapstore
59
- ---------
60
-
61
- The latest git commit will be automatically build and published to the `latest/edge`
62
- channel. Manually promote from `latest/edge` to `latest/stable`.
63
-
@@ -1,4 +1,5 @@
1
1
  import pathlib
2
+ from enum import Enum
2
3
  from typing import Dict, List, Literal, Optional
3
4
 
4
5
  from pydantic import BaseModel, ConfigDict, Field, field_validator
@@ -94,6 +95,34 @@ class ConfigImageSSMParameterModel(BaseModel):
94
95
  )
95
96
 
96
97
 
98
+ class SNSNotificationProtocol(str, Enum):
99
+ DEFAULT = "default"
100
+ EMAIL = "email"
101
+
102
+
103
+ class ConfigImageSNSNotificationModel(BaseModel):
104
+ """
105
+ Image/AMI SNS Notification specific configuration to notify subscribers about new images availability
106
+ """
107
+
108
+ model_config = ConfigDict(extra="forbid")
109
+
110
+ subject: str = Field(description="The subject of SNS Notification", min_length=1, max_length=99)
111
+ message: Dict[SNSNotificationProtocol, str] = Field(
112
+ description="The body of the message to be sent to subscribers.",
113
+ default={SNSNotificationProtocol.DEFAULT: ""},
114
+ )
115
+
116
+ @field_validator("message")
117
+ def check_message(cls, value):
118
+ # Check message protocols have default key
119
+ # Message should contain at least a top-level JSON key of “default”
120
+ # with a value that is a string
121
+ if SNSNotificationProtocol.DEFAULT not in value:
122
+ raise ValueError(f"{SNSNotificationProtocol.DEFAULT.value} key is required to send SNS notification")
123
+ return value
124
+
125
+
97
126
  class ConfigImageModel(BaseModel):
98
127
  """
99
128
  Image/AMI configuration.
@@ -148,6 +177,9 @@ class ConfigImageModel(BaseModel):
148
177
  )
149
178
  groups: Optional[List[str]] = Field(description="Optional list of groups this image is part of", default=[])
150
179
  tags: Optional[Dict[str, str]] = Field(description="Optional Tags to apply to this image only", default={})
180
+ sns: Optional[List[Dict[str, ConfigImageSNSNotificationModel]]] = Field(
181
+ description="Optional list of SNS Notification related configuration", default=None
182
+ )
151
183
 
152
184
  @field_validator("share")
153
185
  @classmethod
@@ -14,3 +14,11 @@ class BucketDoesNotExistException(Exception):
14
14
  def __init__(self, bucket_name: str, *args, **kwargs):
15
15
  msg = f"The bucket named '{bucket_name}' does not exist. You will need to create the bucket before proceeding."
16
16
  super().__init__(msg, *args, **kwargs)
17
+
18
+
19
+ class AWSNotificationException(Exception):
20
+ pass
21
+
22
+
23
+ class AWSAuthorizationException(Exception):
24
+ pass
@@ -14,6 +14,7 @@ from awspub.context import Context
14
14
  from awspub.image_marketplace import ImageMarketplace
15
15
  from awspub.s3 import S3
16
16
  from awspub.snapshot import Snapshot
17
+ from awspub.sns import SNSNotification
17
18
 
18
19
  logger = logging.getLogger(__name__)
19
20
 
@@ -189,6 +190,10 @@ class Image:
189
190
  """
190
191
  share_list = self._share_list_filtered(share_conf)
191
192
 
193
+ if not share_list:
194
+ logger.info("no valid accounts found for sharing in this partition, skipping")
195
+ return
196
+
192
197
  for region, image_info in images.items():
193
198
  ec2client: EC2Client = boto3.client("ec2", region_name=region)
194
199
  # modify image permissions
@@ -349,6 +354,19 @@ class Image:
349
354
  else:
350
355
  logger.error(f"image {self.image_name} not available in region {region}. can not make public")
351
356
 
357
+ def _sns_publish(self) -> None:
358
+ """
359
+ Publish SNS notifiations about newly available images to subscribers
360
+ """
361
+ for region in self.image_regions:
362
+ ec2client_region: EC2Client = boto3.client("ec2", region_name=region)
363
+ image_info: Optional[_ImageInfo] = self._get(ec2client_region)
364
+
365
+ if not image_info:
366
+ logger.error(f"can not send SNS notification for {self.image_name} because no image found in {region}")
367
+ return
368
+ SNSNotification(self._ctx, self.image_name, region).publish()
369
+
352
370
  def cleanup(self) -> None:
353
371
  """
354
372
  Cleanup/delete the temporary images
@@ -552,6 +570,10 @@ class Image:
552
570
  f"currently using partition {partition}. Ignoring marketplace config."
553
571
  )
554
572
 
573
+ # send ssn notification
574
+ if self.conf["sns"]:
575
+ self._sns_publish()
576
+
555
577
  def _verify(self, region: str) -> List[ImageVerificationErrors]:
556
578
  """
557
579
  Verify (but don't modify or create anything) the image in a single region
@@ -0,0 +1,88 @@
1
+ """
2
+ Methods used to handle notifications for AWS using SNS
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any, Dict, List
8
+
9
+ import boto3
10
+ from botocore.exceptions import ClientError
11
+ from mypy_boto3_sns.client import SNSClient
12
+ from mypy_boto3_sts.client import STSClient
13
+
14
+ from awspub.context import Context
15
+ from awspub.exceptions import AWSAuthorizationException, AWSNotificationException
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class SNSNotification(object):
21
+ """
22
+ A data object that contains validation logic and
23
+ structuring rules for SNS notification JSON
24
+ """
25
+
26
+ def __init__(self, context: Context, image_name: str, region_name: str):
27
+ """
28
+ Construct a message and verify that it is valid
29
+ """
30
+ self._ctx: Context = context
31
+ self._image_name: str = image_name
32
+ self._region_name: str = region_name
33
+
34
+ @property
35
+ def conf(self) -> List[Dict[str, Any]]:
36
+ """
37
+ The sns configuration for the current image (based on "image_name") from context
38
+ """
39
+ return self._ctx.conf["images"][self._image_name]["sns"]
40
+
41
+ def _get_topic_arn(self, topic_name: str) -> str:
42
+ """
43
+ Calculate topic ARN based on partition, region, account and topic name
44
+ :param topic_name: Name of topic
45
+ :type topic_name: str
46
+ :param region_name: name of region
47
+ :type region_name: str
48
+ :return: return topic ARN
49
+ :rtype: str
50
+ """
51
+
52
+ stsclient: STSClient = boto3.client("sts", region_name=self._region_name)
53
+ resp = stsclient.get_caller_identity()
54
+
55
+ account = resp["Account"]
56
+ # resp["Arn"] has string format "arn:partition:iam::accountnumber:user/iam_role"
57
+ partition = resp["Arn"].rsplit(":")[1]
58
+
59
+ return f"arn:{partition}:sns:{self._region_name}:{account}:{topic_name}"
60
+
61
+ def publish(self) -> None:
62
+ """
63
+ send notification to subscribers
64
+ """
65
+
66
+ snsclient: SNSClient = boto3.client("sns", region_name=self._region_name)
67
+
68
+ for topic in self.conf:
69
+ for topic_name, topic_config in topic.items():
70
+ try:
71
+ snsclient.publish(
72
+ TopicArn=self._get_topic_arn(topic_name),
73
+ Subject=topic_config["subject"],
74
+ Message=json.dumps(topic_config["message"]),
75
+ MessageStructure="json",
76
+ )
77
+ except ClientError as e:
78
+ exception_code: str = e.response["Error"]["Code"]
79
+ if exception_code == "AuthorizationError":
80
+ raise AWSAuthorizationException(
81
+ "Profile does not have a permission to send the SNS notification. Please review the policy."
82
+ )
83
+ else:
84
+ raise AWSNotificationException(str(e))
85
+ logger.info(
86
+ f"The SNS notification {topic_config['subject']}"
87
+ f" for the topic {topic_name} in {self._region_name} has been sent."
88
+ )
@@ -118,6 +118,35 @@ awspub:
118
118
  -
119
119
  name: /awspub-test/param2
120
120
  allow_overwrite: true
121
+ "test-image-10":
122
+ boot_mode: "uefi"
123
+ description: |
124
+ A test image without a separate snapshot but single sns configs
125
+ regions:
126
+ - "us-east-1"
127
+ sns:
128
+ - "topic1":
129
+ subject: "topic1-subject"
130
+ message:
131
+ default: "default-message"
132
+ email: "email-message"
133
+ "test-image-11":
134
+ boot_mode: "uefi"
135
+ description: |
136
+ A test image without a separate snapshot but multiple sns configs
137
+ regions:
138
+ - "us-east-1"
139
+ - "eu-central-1"
140
+ sns:
141
+ - "topic1":
142
+ subject: "topic1-subject"
143
+ message:
144
+ default: "default-message"
145
+ email: "email-message"
146
+ - "topic2":
147
+ subject: "topic2-subject"
148
+ message:
149
+ default: "default-message"
121
150
 
122
151
  tags:
123
152
  name: "foobar"
@@ -23,6 +23,8 @@ curdir = pathlib.Path(__file__).parent.resolve()
23
23
  "test-image-7",
24
24
  "test-image-8",
25
25
  "test-image-9",
26
+ "test-image-10",
27
+ "test-image-11",
26
28
  ],
27
29
  ),
28
30
  # with a group that no image as, no image should be processed
@@ -127,16 +127,32 @@ def test_image___get_root_device_snapshot_id(root_device_name, block_device_mapp
127
127
 
128
128
 
129
129
  @pytest.mark.parametrize(
130
- "imagename,partition,called_mod_image,called_mod_snapshot,called_start_change_set,called_put_parameter",
130
+ (
131
+ "imagename",
132
+ "partition",
133
+ "called_mod_image",
134
+ "called_mod_snapshot",
135
+ "called_start_change_set",
136
+ "called_put_parameter",
137
+ "called_sns_publish",
138
+ ),
131
139
  [
132
- ("test-image-6", "aws", True, True, False, False),
133
- ("test-image-7", "aws", False, False, False, False),
134
- ("test-image-8", "aws", True, True, True, True),
135
- ("test-image-8", "aws-cn", True, True, False, True),
140
+ ("test-image-6", "aws", True, True, False, False, False),
141
+ ("test-image-7", "aws", False, False, False, False, False),
142
+ ("test-image-8", "aws", True, True, True, True, False),
143
+ ("test-image-8", "aws-cn", True, True, False, True, False),
144
+ ("test-image-10", "aws", False, False, False, False, True),
145
+ ("test-image-11", "aws", False, False, False, False, True),
136
146
  ],
137
147
  )
138
148
  def test_image_publish(
139
- imagename, partition, called_mod_image, called_mod_snapshot, called_start_change_set, called_put_parameter
149
+ imagename,
150
+ partition,
151
+ called_mod_image,
152
+ called_mod_snapshot,
153
+ called_start_change_set,
154
+ called_put_parameter,
155
+ called_sns_publish,
140
156
  ):
141
157
  """
142
158
  Test the publish() for a given image
@@ -167,6 +183,7 @@ def test_image_publish(
167
183
  "Regions": [{"RegionName": "eu-central-1"}, {"RegionName": "us-east-1"}]
168
184
  }
169
185
  instance.list_buckets.return_value = {"Buckets": [{"Name": "bucket1"}]}
186
+ instance.list_topics.return_value = {"Topics": [{"TopicArn": "arn:aws:sns:topic1"}]}
170
187
  ctx = context.Context(curdir / "fixtures/config1.yaml", None)
171
188
  img = image.Image(ctx, imagename)
172
189
  img.publish()
@@ -174,6 +191,7 @@ def test_image_publish(
174
191
  assert instance.modify_snapshot_attribute.called == called_mod_snapshot
175
192
  assert instance.start_change_set.called == called_start_change_set
176
193
  assert instance.put_parameter.called == called_put_parameter
194
+ assert instance.publish.called == called_sns_publish
177
195
 
178
196
 
179
197
  def test_image__get_zero_images():
@@ -0,0 +1,140 @@
1
+ import pathlib
2
+ from unittest.mock import patch
3
+
4
+ import botocore.exceptions
5
+ import pytest
6
+
7
+ from awspub import context, exceptions, sns
8
+
9
+ curdir = pathlib.Path(__file__).parent.resolve()
10
+
11
+
12
+ @pytest.mark.parametrize(
13
+ "imagename,called_sns_publish, publish_call_count",
14
+ [
15
+ ("test-image-10", True, 1),
16
+ ("test-image-11", True, 4),
17
+ ],
18
+ )
19
+ def test_sns_publish(imagename, called_sns_publish, publish_call_count):
20
+ """
21
+ Test the send_notification logic
22
+ """
23
+ with patch("boto3.client") as bclient_mock:
24
+ instance = bclient_mock.return_value
25
+ ctx = context.Context(curdir / "fixtures/config1.yaml", None)
26
+ image_conf = ctx.conf["images"][imagename]
27
+
28
+ for region in image_conf["regions"]:
29
+ sns.SNSNotification(ctx, imagename, region).publish()
30
+
31
+ assert instance.publish.called == called_sns_publish
32
+ assert instance.publish.call_count == publish_call_count
33
+
34
+
35
+ @pytest.mark.parametrize(
36
+ "imagename",
37
+ [
38
+ ("test-image-10"),
39
+ ("test-image-11"),
40
+ ],
41
+ )
42
+ def test_sns_publish_fail_with_invalid_topic(imagename):
43
+ """
44
+ Test the send_notification logic
45
+ """
46
+ with patch("boto3.client") as bclient_mock:
47
+ instance = bclient_mock.return_value
48
+ ctx = context.Context(curdir / "fixtures/config1.yaml", None)
49
+ image_conf = ctx.conf["images"][imagename]
50
+
51
+ # topic1 is invalid topic
52
+ def side_effect(*args, **kwargs):
53
+ topic_arn = kwargs.get("TopicArn")
54
+ if "topic1" in topic_arn:
55
+ error_reponse = {
56
+ "Error": {
57
+ "Code": "NotFoundException",
58
+ "Message": "An error occurred (NotFound) when calling the Publish operation: "
59
+ "Topic does not exist.",
60
+ }
61
+ }
62
+ raise botocore.exceptions.ClientError(error_reponse, "")
63
+
64
+ instance.publish.side_effect = side_effect
65
+
66
+ for region in image_conf["regions"]:
67
+ with pytest.raises(exceptions.AWSNotificationException):
68
+ sns.SNSNotification(ctx, imagename, region).publish()
69
+
70
+
71
+ @pytest.mark.parametrize(
72
+ "imagename",
73
+ [
74
+ ("test-image-10"),
75
+ ("test-image-11"),
76
+ ],
77
+ )
78
+ def test_sns_publish_fail_with_unauthorized_user(imagename):
79
+ """
80
+ Test the send_notification logic
81
+ """
82
+ with patch("boto3.client") as bclient_mock:
83
+ instance = bclient_mock.return_value
84
+ ctx = context.Context(curdir / "fixtures/config1.yaml", None)
85
+ image_conf = ctx.conf["images"][imagename]
86
+
87
+ error_reponse = {
88
+ "Error": {
89
+ "Code": "AuthorizationError",
90
+ "Message": "User are not authorized perform SNS Notification service",
91
+ }
92
+ }
93
+ instance.publish.side_effect = botocore.exceptions.ClientError(error_reponse, "")
94
+
95
+ for region in image_conf["regions"]:
96
+ with pytest.raises(exceptions.AWSAuthorizationException):
97
+ sns.SNSNotification(ctx, imagename, region).publish()
98
+
99
+
100
+ @pytest.mark.parametrize(
101
+ "imagename, partition, expected",
102
+ [
103
+ (
104
+ "test-image-10",
105
+ "aws-cn",
106
+ [
107
+ "arn:aws-cn:sns:us-east-1:1234:topic1",
108
+ ],
109
+ ),
110
+ (
111
+ "test-image-11",
112
+ "aws",
113
+ [
114
+ "arn:aws:sns:us-east-1:1234:topic1",
115
+ "arn:aws:sns:us-east-1:1234:topic2",
116
+ "arn:aws:sns:eu-central-1:1234:topic1",
117
+ "arn:aws:sns:eu-central-1:1234:topic2",
118
+ ],
119
+ ),
120
+ ],
121
+ )
122
+ def test_sns__get_topic_arn(imagename, partition, expected):
123
+ """
124
+ Test the send_notification logic
125
+ """
126
+ with patch("boto3.client") as bclient_mock:
127
+ instance = bclient_mock.return_value
128
+ ctx = context.Context(curdir / "fixtures/config1.yaml", None)
129
+ image_conf = ctx.conf["images"][imagename]
130
+
131
+ instance.get_caller_identity.return_value = {"Account": "1234", "Arn": f"arn:{partition}:iam::1234:user/test"}
132
+
133
+ topic_arns = []
134
+ for region in image_conf["regions"]:
135
+ for topic in image_conf["sns"]:
136
+ topic_name = next(iter(topic))
137
+ res_arn = sns.SNSNotification(ctx, imagename, region)._get_topic_arn(topic_name)
138
+ topic_arns.append(res_arn)
139
+
140
+ assert topic_arns == expected
@@ -4,7 +4,7 @@ build-backend = "poetry_dynamic_versioning.backend"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "awspub"
7
- version = "0.0.6"
7
+ version = "0.0.8"
8
8
  description = "Publish images to AWS EC2"
9
9
 
10
10
  license = "GPL-3.0-or-later"
@@ -24,7 +24,7 @@ keywords = ["AWS", "EC2", "publication"]
24
24
  python = "^3.8.1"
25
25
  boto3 = "*"
26
26
  pydantic = "^2"
27
- boto3-stubs = {extras = ["essential", "marketplace-catalog", "ssm", "s3"], version = "^1.28.83"}
27
+ boto3-stubs = {extras = ["essential", "marketplace-catalog", "ssm", "s3", "sns", "sts"], version = "^1.28.83"}
28
28
  autodoc-pydantic = "^2.0.1"
29
29
  ruamel-yaml = "^0.18.6"
30
30
 
@@ -61,3 +61,9 @@ awspub = "awspub.cli:main"
61
61
  [tool.poetry-dynamic-versioning]
62
62
  enable = false
63
63
  vcs = 'git'
64
+
65
+ [tool.isort]
66
+ profile = "black"
67
+
68
+ [tool.black]
69
+ line-length = 120
@@ -19,20 +19,3 @@ License
19
19
  =======
20
20
 
21
21
  The project uses `GPL-3.0` as license.
22
-
23
- Doing a new release
24
- ===================
25
-
26
- New releases are mostly automated.
27
-
28
- pypi
29
- ----
30
-
31
- For a new release on pypi, create a new tag (following semantic versioning)
32
- with a `v` as prefix (eg. `v0.2.1`).
33
-
34
- snapstore
35
- ---------
36
-
37
- The latest git commit will be automatically build and published to the `latest/edge`
38
- channel. Manually promote from `latest/edge` to `latest/stable`.
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