docker-image-cleanup 0.1.2__tar.gz → 0.1.4__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: docker-image-cleanup
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: Clean up old Docker images from local storage with retention controls
5
5
  Keywords: docker,cleanup,images,disk-space
6
6
  Author: Michael Bianco
@@ -14,6 +14,11 @@ Requires-Python: >=3.11
14
14
  Project-URL: Repository, https://github.com/iloveitaly/docker-image-cleanup
15
15
  Description-Content-Type: text/markdown
16
16
 
17
+ [![Release Notes](https://img.shields.io/github/release/iloveitaly/docker-image-cleanup)](https://github.com/iloveitaly/docker-image-cleanup/releases)
18
+ [![Downloads](https://static.pepy.tech/badge/docker-image-cleanup/month)](https://pepy.tech/project/docker-image-cleanup)
19
+ ![GitHub CI Status](https://github.com/iloveitaly/docker-image-cleanup/actions/workflows/build_and_publish.yml/badge.svg)
20
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
21
+
17
22
  # Clean Up Images from Self-Hosted Docker Registries
18
23
 
19
24
  If you're running a self-hosted Docker registry (using tools like [unregistry](https://github.com/psviderski/unregistry)), you'll want to clean up old images to reclaim disk space. This tool connects to your local Docker daemon and removes old images from specified repositories while keeping the ones you actually need.
@@ -123,3 +128,7 @@ The cleanup process is straightforward:
123
128
  Images with multiple tags are handled intelligently. If all tags on an image would be removed, the entire image is deleted. If only some tags would be removed, those tags are untagged but the image remains.
124
129
 
125
130
  # [MIT License](LICENSE.md)
131
+
132
+ ---
133
+
134
+ *This project was created from [iloveitaly/python-package-template](https://github.com/iloveitaly/python-package-template)*
@@ -1,3 +1,8 @@
1
+ [![Release Notes](https://img.shields.io/github/release/iloveitaly/docker-image-cleanup)](https://github.com/iloveitaly/docker-image-cleanup/releases)
2
+ [![Downloads](https://static.pepy.tech/badge/docker-image-cleanup/month)](https://pepy.tech/project/docker-image-cleanup)
3
+ ![GitHub CI Status](https://github.com/iloveitaly/docker-image-cleanup/actions/workflows/build_and_publish.yml/badge.svg)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+
1
6
  # Clean Up Images from Self-Hosted Docker Registries
2
7
 
3
8
  If you're running a self-hosted Docker registry (using tools like [unregistry](https://github.com/psviderski/unregistry)), you'll want to clean up old images to reclaim disk space. This tool connects to your local Docker daemon and removes old images from specified repositories while keeping the ones you actually need.
@@ -107,3 +112,7 @@ The cleanup process is straightforward:
107
112
  Images with multiple tags are handled intelligently. If all tags on an image would be removed, the entire image is deleted. If only some tags would be removed, those tags are untagged but the image remains.
108
113
 
109
114
  # [MIT License](LICENSE.md)
115
+
116
+ ---
117
+
118
+ *This project was created from [iloveitaly/python-package-template](https://github.com/iloveitaly/python-package-template)*
@@ -12,6 +12,8 @@ from pydantic import BaseModel, Field
12
12
  from structlog_config import configure_logger
13
13
  from whenever import Instant
14
14
 
15
+ from .version import __version__ as __version__
16
+
15
17
  log = configure_logger()
16
18
 
17
19
 
@@ -48,9 +50,25 @@ def parse_docker_image(
48
50
  used_image_ids: set[
49
51
  str
50
52
  ], # Use Any due to pyright resolution issues with docker library types
51
- ) -> ImageInfo:
52
- """Parses docker.models.images.Image into our ImageInfo model."""
53
- created_instant = Instant.parse_iso(image_data.attrs["Created"])
53
+ ) -> ImageInfo | None:
54
+ """Parses docker.models.images.Image into our ImageInfo model.
55
+
56
+ Returns None if the creation date cannot be determined.
57
+ """
58
+ created_str = image_data.attrs.get("Created")
59
+ if not created_str:
60
+ metadata = image_data.attrs.get("Metadata") or {}
61
+ created_str = metadata.get("LastTagTime")
62
+
63
+ if not created_str or created_str == "0001-01-01T00:00:00Z":
64
+ log.warning(
65
+ "could not determine image age, skipping",
66
+ image_id=image_data.id,
67
+ tags=image_data.tags,
68
+ )
69
+ return None
70
+
71
+ created_instant = Instant.parse_iso(created_str)
54
72
  image_id = image_data.id
55
73
 
56
74
  return ImageInfo(
@@ -71,7 +89,11 @@ def get_images_to_process(
71
89
  log.info("no images found for repository", repo=repo_name)
72
90
  return []
73
91
 
74
- parsed_images = [parse_docker_image(img, used_image_ids) for img in images]
92
+ parsed_images = [
93
+ parsed
94
+ for img in images
95
+ if (parsed := parse_docker_image(img, used_image_ids)) is not None
96
+ ]
75
97
  return sorted(parsed_images, key=lambda img: img.created, reverse=True)
76
98
 
77
99
 
@@ -91,7 +113,7 @@ def determine_cleanup_actions(
91
113
  if img.created >= min_age_threshold:
92
114
  keep_tags.update(img.tags)
93
115
 
94
- images_to_delete_ids: set[str] = set()
116
+ images_to_delete: dict[str, int] = {}
95
117
  tags_to_remove: list[str] = []
96
118
 
97
119
  images_by_id: dict[str, list[ImageInfo]] = defaultdict(list)
@@ -120,7 +142,7 @@ def determine_cleanup_actions(
120
142
  if set(tags_for_this_image_to_remove) == current_image_repo_tags:
121
143
  img_info = img_list[0]
122
144
  if not img_info.is_in_use:
123
- images_to_delete_ids.add(img_id)
145
+ images_to_delete[img_id] = img_info.size
124
146
  total_size_saved += img_info.size
125
147
  else:
126
148
  log.warning(
@@ -130,7 +152,7 @@ def determine_cleanup_actions(
130
152
  )
131
153
 
132
154
  return {
133
- "images_to_delete": list(images_to_delete_ids),
155
+ "images_to_delete": images_to_delete,
134
156
  "tags_to_remove": list(set(tags_to_remove)),
135
157
  "total_size_saved": total_size_saved,
136
158
  }
@@ -138,7 +160,7 @@ def determine_cleanup_actions(
138
160
 
139
161
  def execute_cleanup(
140
162
  client: docker.DockerClient,
141
- images_to_delete: list[str],
163
+ images_to_delete: dict[str, int],
142
164
  tags_to_remove: list[str],
143
165
  total_size_saved: int,
144
166
  dry_run: bool,
@@ -150,7 +172,7 @@ def execute_cleanup(
150
172
  if tags_to_remove:
151
173
  log.info("would remove tags", tags=tags_to_remove)
152
174
  if images_to_delete:
153
- log.info("would remove images", image_ids=images_to_delete)
175
+ log.info("would remove images", image_ids=list(images_to_delete.keys()))
154
176
 
155
177
  log.info(
156
178
  "total space that would be saved",
@@ -162,19 +184,25 @@ def execute_cleanup(
162
184
  for tag in tags_to_remove:
163
185
  try:
164
186
  client.images.remove(tag, force=False)
165
- log.info("untagged image", tag=tag)
187
+ log.info("removed tag", tag=tag)
166
188
  except docker.errors.APIError as e:
167
- log.warning("skipped untagging", tag=tag, reason=str(e))
189
+ log.warning("skipped tag removal", tag=tag, reason=str(e))
168
190
 
169
- for img_id in images_to_delete:
191
+ # We explicitly remove the Image ID here even after untagging.
192
+ # This is primarily to catch and clean up "dangling" images (images with no tags)
193
+ # that might have been orphaned but wouldn't be caught by the tag loop.
194
+ for img_id, size in images_to_delete.items():
170
195
  try:
171
- img = client.images.get(img_id)
172
- size = img.attrs.get("Size", 0)
173
196
  client.images.remove(img_id, force=False)
174
197
  log.info("removed image", image_id=img_id, size=format_size(size))
175
198
  executed_size_saved += size
176
199
  except docker.errors.ImageNotFound:
177
- log.warning("image already removed", image_id=img_id)
200
+ # If the image is not found here, it means Docker automatically deleted
201
+ # the underlying image data when we removed its last tag in the loop above.
202
+ log.info(
203
+ "image removed via untagging", image_id=img_id, size=format_size(size)
204
+ )
205
+ executed_size_saved += size
178
206
  except docker.errors.APIError as e:
179
207
  log.warning("skipped image removal", image_id=img_id, reason=str(e))
180
208
 
@@ -0,0 +1,34 @@
1
+ """Version handling for docker-image-cleanup."""
2
+
3
+ import importlib.metadata
4
+ from pathlib import Path
5
+
6
+
7
+ def is_local_source_checkout() -> bool:
8
+ """Check if the code is running from a local source checkout."""
9
+ package_dir = Path(__file__).resolve().parent
10
+ # Since this is a flat layout (module-root = ""), repo root is the parent of the package dir
11
+ repo_root = package_dir.parent
12
+
13
+ return (repo_root / ".git").exists() and (repo_root / "pyproject.toml").exists()
14
+
15
+
16
+ def get_version() -> str:
17
+ """Get the version string, appending .dev if running from source."""
18
+ try:
19
+ # Try to get the version of the installed package
20
+ version = importlib.metadata.version("docker-image-cleanup")
21
+ except importlib.metadata.PackageNotFoundError:
22
+ # Fallback for local development if not installed
23
+ version = "0.1.0"
24
+
25
+ if not is_local_source_checkout():
26
+ return version
27
+
28
+ if version.endswith(".dev"):
29
+ return version
30
+
31
+ return f"{version}.dev"
32
+
33
+
34
+ __version__ = get_version()
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "docker-image-cleanup"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  description = "Clean up old Docker images from local storage with retention controls"
5
5
  keywords = ["docker", "cleanup", "images", "disk-space"]
6
6
  readme = "README.md"
@@ -15,12 +15,14 @@ dependencies = [
15
15
  authors = [{ name = "Michael Bianco", email = "mike@mikebian.co" }]
16
16
  urls = { "Repository" = "https://github.com/iloveitaly/docker-image-cleanup" }
17
17
 
18
+
18
19
  # additional packaging information: https://packaging.python.org/en/latest/specifications/core-metadata/#license
19
20
  [project.scripts]
20
21
  docker-image-cleanup = "docker_image_cleanup:main"
21
22
 
23
+
22
24
  [build-system]
23
- requires = ["uv_build>=0.9.0,<0.10.0"]
25
+ requires = ["uv_build>=0.11.0,<0.12"]
24
26
  build-backend = "uv_build"
25
27
 
26
28
  [tool.uv.build-backend]
@@ -28,10 +30,24 @@ build-backend = "uv_build"
28
30
  module-root = ""
29
31
 
30
32
  [dependency-groups]
31
- dev = ["pytest>=8.3.3", "pyright[nodejs]>=1.1.380", "ruff>=0.8.0"]
33
+ dev = [
34
+ "pytest>=8.3.4",
35
+ "pyright[nodejs]>=1.1.408",
36
+ "ruff>=0.8.0",
37
+ "coverage>=7.13.4",
38
+ "pytest-cov>=7.0.0",
39
+ "covdefaults>=2.3.0",
40
+ ]
32
41
 
33
42
  [tool.pyright]
34
43
  exclude = ["examples/", "playground/", "tmp/", ".venv/", "tests/"]
35
44
 
36
- [tool.ruff]
37
- extend-exclude = ["playground.py", "playground/"]
45
+ [tool.pytest.ini_options]
46
+ addopts = "--cov --cov-report=term-missing --cov-report=html:tmp/htmlcov"
47
+
48
+ [tool.coverage.run]
49
+ plugins = ["covdefaults"]
50
+ source = ["docker_image_cleanup"]
51
+
52
+ [tool.coverage.report]
53
+ fail_under = 50