Skip to content

Commit 3b4dee6

Browse files
committed
fix: timestamp format in lock files
1 parent 2043c32 commit 3b4dee6

File tree

6 files changed

+142
-74
lines changed

6 files changed

+142
-74
lines changed

src/ops2deb/lockfile.py

+9-7
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,8 @@ class LockFile(BaseModel):
2525
__root__: list[LockEntry]
2626

2727

28-
def get_iso_utc_datetime() -> str:
29-
return datetime.now(tz=timezone.utc).isoformat()[:-13] + "Z"
28+
def get_iso_utc_datetime() -> datetime:
29+
return datetime.now(tz=timezone.utc).replace(microsecond=0)
3030

3131

3232
class Lock:
@@ -84,18 +84,20 @@ def save(self) -> None:
8484
if not self._entries or self._tainted is False:
8585
return
8686

87-
entries = {k: entry.dict() for k, entry in self._entries.items()}
88-
8987
# make sure all added urls since lock was created have the same timestamp
9088
# and make sure this timestamp is when save() was called
9189
now = get_iso_utc_datetime()
9290
for new_url in self._new_urls:
93-
if (entry := entries.get(new_url, None)) is not None:
94-
entry["timestamp"] = now
91+
if (entry := self._entries.get(new_url, None)) is not None:
92+
entry.timestamp = now
93+
94+
# sort lockfile entries by urls
95+
entries = [entry.dict() for entry in self._entries.values()]
96+
sorted_entries = sorted(entries, key=itemgetter("url"))
9597

9698
with self.lock_file_path.open("w") as output:
9799
yaml.dump(
98-
sorted(entries.values(), key=itemgetter("url")),
100+
sorted_entries,
99101
output,
100102
Dumper=PrettyYAMLDumper,
101103
default_flow_style=False,

src/ops2deb/updater.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,9 @@ async def __call__(self, blueprint: Blueprint) -> str:
146146
if Version.isvalid(version) and Version.isvalid(blueprint.version):
147147
version = str(max(Version.parse(version), Version.parse(blueprint.version)))
148148
if await self._try_version(blueprint, version) is False:
149-
raise Ops2debUpdaterError("Failed to determine latest release URL")
149+
raise Ops2debUpdaterError(
150+
f"Failed to determine latest release URL (latest tag is {tag_name})"
151+
)
150152
return version
151153

152154

src/ops2deb/utils.py

+4-21
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,19 @@
11
import os
22
from contextlib import contextmanager
33
from pathlib import Path
4-
from typing import Iterator, Tuple, TypeVar
4+
from typing import Any, Iterator
55

66
import yaml
77
from ruamel.yaml.emitter import Emitter
88

99
from ops2deb import logger
10-
from ops2deb.exceptions import Ops2debError
1110

1211

1312
def log_and_raise(exception: Exception) -> None:
1413
logger.error(str(exception))
1514
raise exception
1615

1716

18-
T = TypeVar("T")
19-
U = TypeVar("U")
20-
21-
22-
def separate_results_from_errors(
23-
results_and_errors: dict[U, T | Exception]
24-
) -> Tuple[dict[U, T], dict[U, Ops2debError]]:
25-
results: dict[U, T] = {}
26-
errors: dict[U, Ops2debError] = {}
27-
for key, value in results_and_errors.items():
28-
if isinstance(value, Ops2debError):
29-
errors[key] = value
30-
elif isinstance(value, Exception):
31-
raise value
32-
else:
33-
results[key] = value
34-
return results, errors
35-
36-
3717
@contextmanager
3818
def working_directory(path: Path) -> Iterator[None]:
3919
origin = Path().absolute()
@@ -45,6 +25,9 @@ def working_directory(path: Path) -> Iterator[None]:
4525

4626

4727
class PrettyYAMLDumper(yaml.dumper.SafeDumper):
28+
def ignore_aliases(self, data: Any) -> bool:
29+
return True
30+
4831
def expect_block_sequence(self) -> None:
4932
self.increase_indent(flow=False, indentless=False)
5033
self.state = self.expect_first_block_sequence_item

tests/test_lockfile.py

+117-16
Original file line numberDiff line numberDiff line change
@@ -1,74 +1,175 @@
1+
from dataclasses import dataclass
2+
from datetime import datetime, timezone
13
from unittest.mock import patch
24

35
import pytest
46

57
from ops2deb.exceptions import Ops2debLockFileError
6-
from ops2deb.fetcher import FetchResult
78
from ops2deb.lockfile import Lock
89

910

10-
def test__init__should_create_empty_lock_when_lockfile_does_not_exist(lockfile_path):
11+
@dataclass
12+
class UrlAndHash:
13+
url: str
14+
sha256: str
15+
16+
17+
def test_init__creates_an_empty_lock_when_lockfile_does_not_exist(lockfile_path):
18+
# Given
1119
lock = Lock(lockfile_path)
20+
21+
# Then
1222
assert lock._entries == {}
1323

1424

15-
def test__init__should_raise_when_lockfile_path_is_a_directory(tmp_path):
25+
def test_init__raises_when_lockfile_path_is_a_directory(tmp_path):
26+
# When
1627
with pytest.raises(Ops2debLockFileError) as error:
1728
Lock(tmp_path)
29+
30+
# Then
1831
assert error.match("Path points to a directory")
1932

2033

21-
def test__init__should_raise_when_lockfile_path_contains_invalid_yaml(lockfile_path):
34+
def test_init__raises_when_lockfile_path_contains_invalid_yaml(lockfile_path):
35+
# Given
2236
lockfile_path.write_text("@£¢±")
37+
38+
# When
2339
with pytest.raises(Ops2debLockFileError) as error:
2440
Lock(lockfile_path)
41+
42+
# Then
2543
assert error.match("Invalid YAML file")
2644

2745

28-
def test__init__should_raise_when_lockfile_cannot_be_parsed_with_pydantic(lockfile_path):
46+
def test_init__raises_when_lockfile_cannot_be_parsed_with_pydantic(lockfile_path):
47+
# Given
2948
lockfile_path.write_text("1")
49+
50+
# When
3051
with pytest.raises(Ops2debLockFileError) as error:
3152
Lock(lockfile_path)
53+
54+
# Then
3255
assert error.match("Invalid lockfile")
3356

3457

35-
def test_sha256__should_raise_when_url_is_not_in_cache(lockfile_path):
58+
def test_sha256__raises_when_url_is_not_in_cache(lockfile_path):
59+
# Given
3660
url = "http://tests.com/file.tar.gz"
61+
62+
# When
3763
with pytest.raises(Ops2debLockFileError) as error:
3864
Lock(lockfile_path).sha256(url)
65+
66+
# Then
3967
assert error.match(f"Unknown hash for url {url}, please run ops2deb lock")
4068

4169

4270
def test_save__should_not_create_a_file_when_lock_is_empty(lockfile_path):
43-
Lock(lockfile_path).save()
71+
# Given
72+
lock = Lock(lockfile_path)
73+
74+
# When
75+
lock.save()
76+
77+
# Then
4478
assert lockfile_path.exists() is False
4579

4680

47-
def test_save__should_produce_a_lockfile_that_contains_added_entries(
48-
lockfile_path, tmp_path
49-
):
81+
def test_save__produces_a_lockfile_that_contains_added_entries(lockfile_path):
82+
# Given
5083
lock = Lock(lockfile_path)
51-
lock.add([FetchResult("http://tests.com/file.tar.gz", "deadbeef", tmp_path, None)])
84+
lock.add([UrlAndHash("http://tests.com/file.tar.gz", "deadbeef")])
85+
86+
# When
5287
lock.save()
53-
lock = Lock(lockfile_path)
54-
assert lock.sha256("http://tests.com/file.tar.gz") == "deadbeef"
88+
89+
# Then
90+
assert Lock(lockfile_path).sha256("http://tests.com/file.tar.gz") == "deadbeef"
5591

5692

5793
@patch("yaml.dump")
5894
def test_save__should_not_write_file_when_no_entry_have_been_added_nor_removed(
5995
mock_dump, lockfile_path
6096
):
97+
# Given
6198
lock = Lock(lockfile_path)
99+
100+
# When
62101
lock.save()
102+
103+
# Then
63104
mock_dump.assert_not_called()
64105

65106

66-
def test_save__set_the_same_timestamp_to_added_entries(lockfile_path, tmp_path):
107+
def test_save__sets_the_same_timestamp_to_added_entries(lockfile_path):
108+
# Given
67109
lock = Lock(lockfile_path)
68-
lock.add([FetchResult("http://tests.com/file1.tar.gz", "deadbeef", tmp_path, None)])
69-
lock.add([FetchResult("http://tests.com/file2.tar.gz", "deadbeef", tmp_path, None)])
110+
lock.add([UrlAndHash("http://tests.com/file1.tar.gz", "deadbeef")])
111+
lock.add([UrlAndHash("http://tests.com/file2.tar.gz", "deadbeef")])
112+
113+
# When
70114
lock.save()
115+
116+
# Then
71117
lock = Lock(lockfile_path)
72118
timestamp_1 = lock.timestamp("http://tests.com/file1.tar.gz")
73119
timestamp_2 = lock.timestamp("http://tests.com/file2.tar.gz")
74120
assert timestamp_1 == timestamp_2
121+
122+
123+
@patch("ops2deb.lockfile.datetime")
124+
def test_save__should_not_include_microseconds_in_timestamps(
125+
mock_datetime, lockfile_path
126+
):
127+
# Given
128+
mock_datetime.now.return_value = datetime(
129+
2023, 3, 4, 0, 22, 14, 1234, tzinfo=timezone.utc
130+
)
131+
lock = Lock(lockfile_path)
132+
lock.add([UrlAndHash("http://tests.com/file1.tar.gz", "deadbeef")])
133+
134+
# When
135+
lock.save()
136+
137+
# Then
138+
assert "2023-03-04 00:22:14+00:00" in lockfile_path.read_text()
139+
140+
141+
@patch("ops2deb.lockfile.datetime")
142+
def test_save__should_be_idempotent(mock_datetime, lockfile_path):
143+
# Given
144+
mock_datetime.now.return_value = datetime(2023, 3, 4, 0, 22, 14, tzinfo=timezone.utc)
145+
146+
# When
147+
lock = Lock(lockfile_path)
148+
lock.add([UrlAndHash("http://tests.com/file1.tar.gz", "deadbeef")])
149+
lock.add([UrlAndHash("http://tests.com/file2.tar.gz", "deadbeef")])
150+
lock.save()
151+
lockfile_content_0 = lockfile_path.read_text()
152+
Lock(lockfile_path).save()
153+
lockfile_content_1 = lockfile_path.read_text()
154+
print(lockfile_content_0)
155+
156+
# Then
157+
assert lockfile_content_0 == lockfile_content_1
158+
159+
160+
@patch("ops2deb.lockfile.datetime")
161+
def test_save__should_not_use_yaml_anchors_in_timestamps(mock_datetime, lockfile_path):
162+
# happens when you reference an object multiple time in a YAML document and when the
163+
# pyyaml dumper is not configured to "ignore aliases"
164+
165+
# Given
166+
mock_datetime.now.return_value = datetime(2023, 3, 4, 0, 22, 14, tzinfo=timezone.utc)
167+
168+
# When
169+
lock = Lock(lockfile_path)
170+
lock.add([UrlAndHash("http://tests.com/file1.tar.gz", "deadbeef")])
171+
lock.add([UrlAndHash("http://tests.com/file2.tar.gz", "deadbeef")])
172+
lock.save()
173+
174+
# Then
175+
assert "timestamp: &" not in lockfile_path.read_text()

tests/test_ops2deb.py

+6-5
Original file line numberDiff line numberDiff line change
@@ -683,11 +683,12 @@ def test_update__creates_a_summary_of_updated_blueprints_when_called_with_output
683683
)
684684

685685
# Then
686-
summary = (
687-
"Updated great-app from 1.0.0 to 1.1.1\n"
688-
"Updated super-app from 1.0.0 to 1.1.1\n"
689-
)
690-
assert summary_path.read_text() == summary
686+
summary_lines_set = {
687+
"",
688+
"Updated great-app from 1.0.0 to 1.1.1",
689+
"Updated super-app from 1.0.0 to 1.1.1",
690+
}
691+
assert set(summary_path.read_text().split("\n")) == summary_lines_set
691692

692693

693694
def test_update__creates_empty_summary_when_called_with_output_file_and_configuration_is_up_to_date( # noqa: E501

tests/test_utils.py

+3-24
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,17 @@
11
import os
22
from pathlib import Path
33

4-
import pytest
4+
from ops2deb.utils import working_directory
55

6-
from ops2deb.exceptions import Ops2debError
7-
from ops2deb.utils import separate_results_from_errors, working_directory
86

9-
10-
def test_separate_results_from_errors_should_separate_results_from_ops2deb_exceptions():
11-
error = Ops2debError("An error")
12-
success = "success"
13-
test = {0: error, 1: success, 2: error, 4: success}
14-
results, errors = separate_results_from_errors(test)
15-
assert errors == {0: error, 2: error}
16-
assert results == {1: success, 4: success}
17-
18-
19-
def test_separate_results_from_errors_should_raise_when_exception_is_not_an_ops2deb_error(): # noqa: E501
20-
error = RuntimeError("An error")
21-
test = {0: error}
22-
with pytest.raises(RuntimeError):
23-
separate_results_from_errors(test)
24-
25-
26-
def test_working_directory__should_set_current_working_directory_within_context(tmp_path):
7+
def test_working_directory__sets_current_working_directory_within_context(tmp_path):
278
origin = Path().absolute()
289
with working_directory(tmp_path):
2910
assert Path(os.getcwd()) != origin
3011
assert Path(os.getcwd()) == tmp_path
3112

3213

33-
def test_working_directory__should_restore_current_directory_when_context_is_left(
34-
tmp_path,
35-
):
14+
def test_working_directory__restores_current_directory_when_context_is_left(tmp_path):
3615
origin = Path().absolute()
3716
with working_directory(tmp_path):
3817
pass

0 commit comments

Comments
 (0)