docker-diff 0.0.5__py3-none-any.whl → 0.0.7__py3-none-any.whl

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.4
2
2
  Name: docker-diff
3
- Version: 0.0.5
3
+ Version: 0.0.7
4
4
  Summary: Docker Image Comparison Database Manager
5
5
  License: This is free and unencumbered software released into the public domain.
6
6
 
@@ -30,4 +30,5 @@ License: This is free and unencumbered software released into the public domain.
30
30
  Project-URL: Repository, https://github.com/eapolinario/docker-diff
31
31
  Requires-Python: >=3.10
32
32
  License-File: LICENSE
33
+ Requires-Dist: libsql-client>=0.3
33
34
  Dynamic: license-file
@@ -0,0 +1,10 @@
1
+ docker_diff-0.0.7.dist-info/licenses/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
2
+ docker_diff_pkg/__init__.py,sha256=aG5WUO74Z5ax04A5iwh6thCpHcFXBsdH5v-ph-uUJuI,321
3
+ docker_diff_pkg/_version.py,sha256=AV58KqMkBGaCvmPdbd3g9huyNXfIVxjw8QbCMdaeivU,704
4
+ docker_diff_pkg/cli.py,sha256=C6LYpgFGmiZsBZ8MO4TJKmtKMvg1EqB6qYlpilbq6pI,21549
5
+ docker_diff_pkg/schema.sql,sha256=xI3kOohbUEzwFhmIGQs2_abN_tJn1BBdAya7tVbw6n8,5477
6
+ docker_diff-0.0.7.dist-info/METADATA,sha256=na6gAxdT1d2_cZeuQMqfFEu9k7gqUKhPPm-wGGRBr8c,1688
7
+ docker_diff-0.0.7.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ docker_diff-0.0.7.dist-info/entry_points.txt,sha256=nEDTWZsgB5fRJgeXHJbXJyaHKMmKyN9Qypf3X78tosw,57
9
+ docker_diff-0.0.7.dist-info/top_level.txt,sha256=GB5Qrc3AH1pk0543cQz9SRNr5Fatrj-c0Cdp3rCvTM8,16
10
+ docker_diff-0.0.7.dist-info/RECORD,,
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.0.5'
32
- __version_tuple__ = version_tuple = (0, 0, 5)
31
+ __version__ = version = '0.0.7'
32
+ __version_tuple__ = version_tuple = (0, 0, 7)
33
33
 
34
34
  __commit_id__ = commit_id = None
docker_diff_pkg/cli.py CHANGED
@@ -2,6 +2,7 @@
2
2
  """
3
3
  Docker Image Comparison Database Manager
4
4
  Provides functions to store and query Docker image file comparison results in SQLite
5
+ Supports local SQLite via `sqlite3` and remote Turso via `libsql`.
5
6
  """
6
7
 
7
8
  import sqlite3
@@ -10,72 +11,211 @@ import subprocess
10
11
  import sys
11
12
  from datetime import datetime
12
13
  from pathlib import Path
13
- from typing import List, Dict, Optional, Tuple
14
+ from typing import List, Dict, Optional, Tuple, Any
15
+ import os
16
+
17
+ try:
18
+ # Optional: Turso/libsql client for remote DB (sync wrapper)
19
+ from libsql_client.sync import create_client_sync # type: ignore
20
+ except Exception: # pragma: no cover - optional dependency
21
+ create_client_sync = None # type: ignore
14
22
 
15
23
  class DockerImageDB:
16
24
  def __init__(self, db_path: str = "docker_images.db"):
25
+ """
26
+ Initialize DB access. If environment variables for Turso/libsql are set,
27
+ connect to the remote database. Otherwise, use local SQLite file.
28
+
29
+ Env vars recognized (first found wins):
30
+ - TURSO_DATABASE_URL / LIBSQL_URL
31
+ - TURSO_AUTH_TOKEN / LIBSQL_AUTH_TOKEN
32
+ """
17
33
  self.db_path = db_path
34
+
35
+ # Detect remote libsql configuration via env vars
36
+ self.libsql_url = os.getenv("TURSO_DATABASE_URL") or os.getenv("LIBSQL_URL")
37
+ self.libsql_token = os.getenv("TURSO_AUTH_TOKEN") or os.getenv("LIBSQL_AUTH_TOKEN")
38
+ self.use_libsql = bool(self.libsql_url)
39
+
40
+ self.client = None
41
+ if self.use_libsql:
42
+ if create_client_sync is None:
43
+ raise RuntimeError(
44
+ "libsql-client is not installed but LIBSQL/TURSO env vars are set. "
45
+ "Install dependency or unset the env vars."
46
+ )
47
+ # Create libsql client
48
+ self.client = create_client_sync(url=self.libsql_url, auth_token=self.libsql_token)
49
+
18
50
  self.init_database()
19
51
 
52
+ def close(self) -> None:
53
+ """Close any remote DB client resources."""
54
+ try:
55
+ if self.use_libsql and self.client is not None and hasattr(self.client, "close"):
56
+ # ClientSync.close() is synchronous
57
+ self.client.close() # type: ignore[call-arg]
58
+ except Exception:
59
+ # Best-effort shutdown; avoid masking exit with close errors
60
+ pass
61
+
20
62
  def init_database(self):
21
- """Initialize the database with schema"""
22
- with sqlite3.connect(self.db_path) as conn:
23
- # Read schema using importlib.resources for proper packaging
24
- try:
25
- import importlib.resources as pkg_resources
26
- with pkg_resources.files('docker_diff_pkg').joinpath('schema.sql').open('r') as f:
27
- schema_content = f.read()
28
- except (ImportError, AttributeError):
29
- # Fallback for Python < 3.9
30
- import importlib_resources as pkg_resources
31
- with pkg_resources.files('docker_diff_pkg').joinpath('schema.sql').open('r') as f:
32
- schema_content = f.read()
33
-
34
- conn.executescript(schema_content)
63
+ """Initialize the database with schema for either backend"""
64
+ # Read schema using importlib.resources for proper packaging
65
+ try:
66
+ import importlib.resources as pkg_resources
67
+ with pkg_resources.files('docker_diff_pkg').joinpath('schema.sql').open('r') as f:
68
+ schema_content = f.read()
69
+ except (ImportError, AttributeError):
70
+ # Fallback for Python < 3.9
71
+ import importlib_resources as pkg_resources
72
+ with pkg_resources.files('docker_diff_pkg').joinpath('schema.sql').open('r') as f:
73
+ schema_content = f.read()
74
+
75
+ self._executescript(schema_content)
76
+
77
+ # ---- Internal helpers to abstract DB backend ----
78
+ class _Result:
79
+ def __init__(self, rows: List[Tuple[Any, ...]] | None = None, columns: List[str] | None = None, last_row_id: Optional[int] = None):
80
+ self.rows = rows or []
81
+ self.columns = columns or []
82
+ self.last_row_id = last_row_id
83
+
84
+ def _to_tuples(self, rows: Any) -> List[Tuple[Any, ...]]:
85
+ """Normalize libsql rows (list[list|tuple|dict]) to list of tuples."""
86
+ if rows is None:
87
+ return []
88
+ if not rows:
89
+ return []
90
+ first = rows[0]
91
+ if isinstance(first, dict):
92
+ # Keep insertion order of dict; assume all rows share keys order
93
+ return [tuple(r.values()) for r in rows]
94
+ if isinstance(first, (list, tuple)):
95
+ return [tuple(r) for r in rows]
96
+ # Fallback single column scalar rows
97
+ return [(r,) for r in rows]
98
+
99
+ def _exec(self, sql: str, params: Tuple[Any, ...] | List[Any] | None = None) -> "DockerImageDB._Result":
100
+ if self.use_libsql:
101
+ res = self.client.execute(sql, params or []) # type: ignore[attr-defined]
102
+ # libsql ResultSet rows are Row objects; convert to tuples
103
+ rs_rows = getattr(res, "rows", [])
104
+ rows: List[Tuple[Any, ...]] = []
105
+ for r in rs_rows:
106
+ if hasattr(r, "astuple"):
107
+ rows.append(tuple(r.astuple())) # type: ignore[attr-defined]
108
+ elif isinstance(r, (list, tuple)):
109
+ rows.append(tuple(r))
110
+ else:
111
+ # Sequence fallback
112
+ try:
113
+ rows.append(tuple(r)) # type: ignore[arg-type]
114
+ except Exception:
115
+ rows.append((r,))
116
+ columns = list(getattr(res, "columns", []) or [])
117
+ last_row_id = getattr(res, "last_insert_rowid", None)
118
+ return DockerImageDB._Result(rows, columns, last_row_id)
119
+ else:
120
+ with sqlite3.connect(self.db_path) as conn:
121
+ cur = conn.cursor()
122
+ if params is None:
123
+ cur.execute(sql)
124
+ else:
125
+ cur.execute(sql, params)
126
+ rows = cur.fetchall() if cur.description else []
127
+ columns = [d[0] for d in cur.description] if cur.description else []
128
+ return DockerImageDB._Result(rows, columns, cur.lastrowid)
129
+
130
+ def _executemany(self, sql: str, seq_params: List[Tuple[Any, ...]]):
131
+ if not seq_params:
132
+ return
133
+ if self.use_libsql:
134
+ # Execute sequentially for compatibility
135
+ for p in seq_params:
136
+ self.client.execute(sql, list(p)) # type: ignore[attr-defined]
137
+ else:
138
+ with sqlite3.connect(self.db_path) as conn:
139
+ cur = conn.cursor()
140
+ cur.executemany(sql, seq_params)
141
+
142
+ def _executescript(self, script: str):
143
+ if self.use_libsql:
144
+ # Split naive on ';' and run statements; ignore empty
145
+ statements = [s.strip() for s in script.split(';') if s.strip()]
146
+ for stmt in statements:
147
+ self.client.execute(stmt) # type: ignore[attr-defined]
148
+ else:
149
+ with sqlite3.connect(self.db_path) as conn:
150
+ conn.executescript(script)
35
151
 
36
152
  def add_image(self, name: str, digest: str = None, size_bytes: int = None) -> int:
37
153
  """Add an image to the database, return image_id"""
38
- with sqlite3.connect(self.db_path) as conn:
39
- cursor = conn.cursor()
40
- cursor.execute("""
41
- INSERT OR IGNORE INTO images (name, digest, size_bytes)
42
- VALUES (?, ?, ?)
43
- """, (name, digest, size_bytes))
44
-
45
- # Get the image ID
46
- cursor.execute("SELECT id FROM images WHERE name = ?", (name,))
47
- return cursor.fetchone()[0]
154
+ self._exec(
155
+ """
156
+ INSERT OR IGNORE INTO images (name, digest, size_bytes)
157
+ VALUES (?, ?, ?)
158
+ """,
159
+ (name, digest, size_bytes),
160
+ )
161
+ row = self._exec("SELECT id FROM images WHERE name = ?", (name,)).rows
162
+ return int(row[0][0]) if row else -1
48
163
 
49
164
  def add_files_for_image(self, image_id: int, files: List[Dict]):
50
165
  """Add file listings for an image"""
51
- with sqlite3.connect(self.db_path) as conn:
52
- cursor = conn.cursor()
53
-
54
- # Clear existing files for this image
55
- cursor.execute("DELETE FROM files WHERE image_id = ?", (image_id,))
56
-
57
- # Insert new files
58
- file_data = []
59
- for file_info in files:
60
- file_data.append((
61
- image_id,
62
- file_info['path'],
63
- file_info.get('size', 0),
64
- file_info.get('mode'),
65
- file_info.get('mtime'),
66
- file_info.get('type', 'file'),
67
- file_info.get('checksum')
68
- ))
69
-
70
- cursor.executemany("""
71
- INSERT INTO files
72
- (image_id, file_path, file_size, file_mode, modified_time, file_type, checksum)
73
- VALUES (?, ?, ?, ?, ?, ?, ?)
74
- """, file_data)
166
+ # Clear existing files for this image
167
+ self._exec("DELETE FROM files WHERE image_id = ?", (image_id,))
168
+
169
+ # Insert new files
170
+ file_data: List[Tuple[Any, ...]] = []
171
+ for file_info in files:
172
+ file_data.append((
173
+ image_id,
174
+ file_info['path'],
175
+ file_info.get('size', 0),
176
+ file_info.get('mode'),
177
+ file_info.get('mtime'),
178
+ file_info.get('type', 'file'),
179
+ file_info.get('checksum')
180
+ ))
181
+
182
+ self._executemany(
183
+ """
184
+ INSERT INTO files
185
+ (image_id, file_path, file_size, file_mode, modified_time, file_type, checksum)
186
+ VALUES (?, ?, ?, ?, ?, ?, ?)
187
+ """,
188
+ file_data,
189
+ )
75
190
 
76
- def scan_image(self, image_name: str) -> int:
191
+ def scan_image(self, image_name: str, force: bool = False) -> int:
77
192
  """Scan a Docker image and store its files"""
78
193
  print(f"Scanning {image_name}...")
194
+
195
+ # Helper to parse tag safely: consider last ':' after last '/'
196
+ def _get_tag(name: str) -> str:
197
+ slash_idx = name.rfind('/')
198
+ colon_idx = name.rfind(':')
199
+ if colon_idx > slash_idx:
200
+ return name[colon_idx + 1 :]
201
+ return 'latest'
202
+
203
+ # If image already exists with files and tag is not 'latest', skip re-scan
204
+ tag = _get_tag(image_name).lower() if image_name else 'latest'
205
+ if not force and tag != 'latest':
206
+ rows = self._exec(
207
+ """
208
+ SELECT i.id, COUNT(f.id) as file_count
209
+ FROM images i
210
+ LEFT JOIN files f ON i.id = f.image_id
211
+ WHERE i.name = ?
212
+ GROUP BY i.id
213
+ """,
214
+ (image_name,),
215
+ ).rows
216
+ if rows and rows[0][1] and int(rows[0][1]) > 0:
217
+ print(f" Skipping re-scan for {image_name} (already in DB with files)")
218
+ return int(rows[0][0])
79
219
 
80
220
  # Add image to database
81
221
  image_id = self.add_image(image_name)
@@ -108,25 +248,31 @@ class DockerImageDB:
108
248
 
109
249
  def create_comparison(self, name: str, description: str = None) -> int:
110
250
  """Create a new comparison session"""
111
- with sqlite3.connect(self.db_path) as conn:
112
- cursor = conn.cursor()
113
- cursor.execute("""
114
- INSERT INTO comparisons (name, description)
115
- VALUES (?, ?)
116
- """, (name, description))
117
- return cursor.lastrowid
251
+ res = self._exec(
252
+ """
253
+ INSERT INTO comparisons (name, description)
254
+ VALUES (?, ?)
255
+ """,
256
+ (name, description),
257
+ )
258
+ if self.use_libsql:
259
+ # Fetch id via last row if needed
260
+ row = self._exec("SELECT id FROM comparisons WHERE name = ? ORDER BY id DESC LIMIT 1", (name,)).rows
261
+ return int(row[0][0]) if row else -1
262
+ return int(res.last_row_id or -1)
118
263
 
119
264
  def add_images_to_comparison(self, comparison_id: int, image_ids: List[int]):
120
265
  """Add images to a comparison"""
121
- with sqlite3.connect(self.db_path) as conn:
122
- cursor = conn.cursor()
123
- for image_id in image_ids:
124
- cursor.execute("""
125
- INSERT OR IGNORE INTO comparison_images (comparison_id, image_id)
126
- VALUES (?, ?)
127
- """, (comparison_id, image_id))
266
+ for image_id in image_ids:
267
+ self._exec(
268
+ """
269
+ INSERT OR IGNORE INTO comparison_images (comparison_id, image_id)
270
+ VALUES (?, ?)
271
+ """,
272
+ (comparison_id, image_id),
273
+ )
128
274
 
129
- def compare_images(self, image_names: List[str], comparison_name: str = None) -> int:
275
+ def compare_images(self, image_names: List[str], comparison_name: str = None, *, force: bool = False) -> int:
130
276
  """Compare multiple images and store results"""
131
277
  if not comparison_name:
132
278
  comparison_name = f"Comparison_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
@@ -134,7 +280,7 @@ class DockerImageDB:
134
280
  # Scan all images
135
281
  image_ids = []
136
282
  for image_name in image_names:
137
- image_id = self.scan_image(image_name)
283
+ image_id = self.scan_image(image_name, force=force)
138
284
  image_ids.append(image_id)
139
285
 
140
286
  # Create comparison
@@ -153,86 +299,93 @@ class DockerImageDB:
153
299
 
154
300
  def _generate_file_differences(self, comparison_id: int):
155
301
  """Generate file difference records for a comparison"""
156
- with sqlite3.connect(self.db_path) as conn:
157
- conn.execute("""
158
- INSERT INTO file_differences
159
- (comparison_id, file_path, difference_type, source_image_id, target_image_id, old_size, new_size, size_change)
160
- SELECT
161
- ? as comparison_id,
162
- f1.file_path,
163
- CASE
164
- WHEN f2.file_path IS NULL THEN 'only_in_first'
165
- WHEN f1.file_size != f2.file_size THEN 'changed'
166
- ELSE 'common'
167
- END as difference_type,
168
- f1.image_id as source_image_id,
169
- f2.image_id as target_image_id,
170
- f1.file_size as old_size,
171
- f2.file_size as new_size,
172
- COALESCE(f2.file_size - f1.file_size, -f1.file_size) as size_change
173
- FROM files f1
174
- JOIN comparison_images ci1 ON f1.image_id = ci1.image_id
175
- LEFT JOIN files f2 ON f1.file_path = f2.file_path
176
- AND f2.image_id IN (
177
- SELECT ci2.image_id FROM comparison_images ci2
178
- WHERE ci2.comparison_id = ? AND ci2.image_id != f1.image_id
179
- )
180
- WHERE ci1.comparison_id = ?
181
- """, (comparison_id, comparison_id, comparison_id))
302
+ self._exec(
303
+ """
304
+ INSERT INTO file_differences
305
+ (comparison_id, file_path, difference_type, source_image_id, target_image_id, old_size, new_size, size_change)
306
+ SELECT
307
+ ? as comparison_id,
308
+ f1.file_path,
309
+ CASE
310
+ WHEN f2.file_path IS NULL THEN 'only_in_first'
311
+ WHEN f1.file_size != f2.file_size THEN 'changed'
312
+ ELSE 'common'
313
+ END as difference_type,
314
+ f1.image_id as source_image_id,
315
+ f2.image_id as target_image_id,
316
+ f1.file_size as old_size,
317
+ f2.file_size as new_size,
318
+ COALESCE(f2.file_size - f1.file_size, -f1.file_size) as size_change
319
+ FROM files f1
320
+ JOIN comparison_images ci1 ON f1.image_id = ci1.image_id
321
+ LEFT JOIN files f2 ON f1.file_path = f2.file_path
322
+ AND f2.image_id IN (
323
+ SELECT ci2.image_id FROM comparison_images ci2
324
+ WHERE ci2.comparison_id = ? AND ci2.image_id != f1.image_id
325
+ )
326
+ WHERE ci1.comparison_id = ?
327
+ """,
328
+ (comparison_id, comparison_id, comparison_id),
329
+ )
182
330
 
183
331
  def get_comparison_summary(self, comparison_id: int) -> Dict:
184
332
  """Get summary statistics for a comparison"""
185
- with sqlite3.connect(self.db_path) as conn:
186
- cursor = conn.cursor()
187
-
188
- # Get basic info
189
- cursor.execute("""
190
- SELECT c.name, c.description, c.created_at,
191
- GROUP_CONCAT(i.name, ', ') as images
192
- FROM comparisons c
193
- JOIN comparison_images ci ON c.id = ci.comparison_id
194
- JOIN images i ON ci.image_id = i.id
195
- WHERE c.id = ?
196
- GROUP BY c.id
197
- """, (comparison_id,))
198
-
199
- basic_info = cursor.fetchone()
200
- if not basic_info:
201
- return {}
202
-
203
- # Get difference counts
204
- cursor.execute("""
205
- SELECT difference_type, COUNT(*) as count
206
- FROM file_differences
207
- WHERE comparison_id = ?
208
- GROUP BY difference_type
209
- """, (comparison_id,))
210
-
211
- diff_counts = dict(cursor.fetchall())
212
-
213
- return {
214
- 'name': basic_info[0],
215
- 'description': basic_info[1],
216
- 'created_at': basic_info[2],
217
- 'images': basic_info[3].split(', '),
218
- 'differences': diff_counts,
219
- 'total_differences': sum(diff_counts.values())
220
- }
333
+ # Get basic info
334
+ basic_rows = self._exec(
335
+ """
336
+ SELECT c.name, c.description, c.created_at,
337
+ GROUP_CONCAT(i.name, ', ') as images
338
+ FROM comparisons c
339
+ JOIN comparison_images ci ON c.id = ci.comparison_id
340
+ JOIN images i ON ci.image_id = i.id
341
+ WHERE c.id = ?
342
+ GROUP BY c.id
343
+ """,
344
+ (comparison_id,),
345
+ ).rows
346
+
347
+ if not basic_rows:
348
+ return {}
349
+
350
+ basic_info = basic_rows[0]
351
+
352
+ # Get difference counts
353
+ diff_rows = self._exec(
354
+ """
355
+ SELECT difference_type, COUNT(*) as count
356
+ FROM file_differences
357
+ WHERE comparison_id = ?
358
+ GROUP BY difference_type
359
+ """,
360
+ (comparison_id,),
361
+ ).rows
362
+
363
+ diff_counts = {k: v for (k, v) in diff_rows}
364
+
365
+ return {
366
+ 'name': basic_info[0],
367
+ 'description': basic_info[1],
368
+ 'created_at': basic_info[2],
369
+ 'images': basic_info[3].split(', '),
370
+ 'differences': diff_counts,
371
+ 'total_differences': sum(diff_counts.values()) if diff_counts else 0,
372
+ }
221
373
 
222
374
  def query_unique_files(self, comparison_id: int) -> List[Tuple]:
223
375
  """Get files unique to each image in comparison"""
224
- with sqlite3.connect(self.db_path) as conn:
225
- cursor = conn.cursor()
226
- cursor.execute("""
227
- SELECT i.name as image_name, f.file_path, f.file_size
228
- FROM unique_files uf
229
- JOIN comparisons c ON uf.comparison_name = c.name
230
- JOIN images i ON uf.image_name = i.name
231
- JOIN files f ON i.id = f.image_id AND uf.file_path = f.file_path
232
- WHERE c.id = ?
233
- ORDER BY i.name, f.file_size DESC
234
- """, (comparison_id,))
235
- return cursor.fetchall()
376
+ rows = self._exec(
377
+ """
378
+ SELECT i.name as image_name, f.file_path, f.file_size
379
+ FROM unique_files uf
380
+ JOIN comparisons c ON uf.comparison_name = c.name
381
+ JOIN images i ON uf.image_name = i.name
382
+ JOIN files f ON i.id = f.image_id AND uf.file_path = f.file_path
383
+ WHERE c.id = ?
384
+ ORDER BY i.name, f.file_size DESC
385
+ """,
386
+ (comparison_id,),
387
+ ).rows
388
+ return rows
236
389
 
237
390
 
238
391
  def print_comparison_summary(db: DockerImageDB, comparison_id: int):
@@ -265,67 +418,63 @@ def print_comparison_summary(db: DockerImageDB, comparison_id: int):
265
418
 
266
419
  def list_comparisons(db: DockerImageDB):
267
420
  """List all comparisons in the database"""
268
- with sqlite3.connect(db.db_path) as conn:
269
- cursor = conn.cursor()
270
- cursor.execute("""
271
- SELECT
272
- c.id,
273
- c.name,
274
- c.created_at,
275
- COUNT(DISTINCT ci.image_id) as image_count,
276
- GROUP_CONCAT(i.name, ', ') as images
277
- FROM comparisons c
278
- JOIN comparison_images ci ON c.id = ci.comparison_id
279
- JOIN images i ON ci.image_id = i.id
280
- GROUP BY c.id
281
- ORDER BY c.created_at DESC
282
- """)
283
-
284
- comparisons = cursor.fetchall()
285
-
286
- if not comparisons:
287
- print("No comparisons found in database.")
288
- return
289
-
290
- print(f"\n{'ID':<4} {'Name':<25} {'Images':<8} {'Date':<20} {'Image Names'}")
291
- print("-" * 80)
292
-
293
- for comp_id, name, created, img_count, img_names in comparisons:
294
- # Truncate long image names
295
- if len(img_names) > 35:
296
- img_names = img_names[:32] + "..."
297
- print(f"{comp_id:<4} {name[:24]:<25} {img_count:<8} {created[:19]:<20} {img_names}")
421
+ rows = db._exec(
422
+ """
423
+ SELECT
424
+ c.id,
425
+ c.name,
426
+ c.created_at,
427
+ COUNT(DISTINCT ci.image_id) as image_count,
428
+ GROUP_CONCAT(i.name, ', ') as images
429
+ FROM comparisons c
430
+ JOIN comparison_images ci ON c.id = ci.comparison_id
431
+ JOIN images i ON ci.image_id = i.id
432
+ GROUP BY c.id
433
+ ORDER BY c.created_at DESC
434
+ """,
435
+ ).rows
436
+
437
+ if not rows:
438
+ print("No comparisons found in database.")
439
+ return
440
+
441
+ print(f"\n{'ID':<4} {'Name':<25} {'Images':<8} {'Date':<20} {'Image Names'}")
442
+ print("-" * 80)
443
+
444
+ for comp_id, name, created, img_count, img_names in rows:
445
+ # Truncate long image names
446
+ if img_names and len(img_names) > 35:
447
+ img_names = img_names[:32] + "..."
448
+ print(f"{comp_id:<4} {name[:24]:<25} {img_count:<8} {created[:19]:<20} {img_names}")
298
449
 
299
450
 
300
451
  def list_images(db: DockerImageDB):
301
452
  """List all images in the database"""
302
- with sqlite3.connect(db.db_path) as conn:
303
- cursor = conn.cursor()
304
- cursor.execute("""
305
- SELECT
306
- i.id,
307
- i.name,
308
- i.scanned_at,
309
- COUNT(f.id) as file_count,
310
- COALESCE(SUM(f.file_size), 0) as total_size
311
- FROM images i
312
- LEFT JOIN files f ON i.id = f.image_id
313
- GROUP BY i.id
314
- ORDER BY i.scanned_at DESC
315
- """)
316
-
317
- images = cursor.fetchall()
318
-
319
- if not images:
320
- print("No images found in database.")
321
- return
322
-
323
- print(f"\n{'ID':<4} {'Image Name':<30} {'Files':<8} {'Size (MB)':<12} {'Scanned'}")
324
- print("-" * 80)
325
-
326
- for img_id, name, scanned, file_count, total_size in images:
327
- size_mb = total_size / (1024 * 1024) if total_size else 0
328
- print(f"{img_id:<4} {name[:29]:<30} {file_count:<8} {size_mb:<12.2f} {scanned[:19] if scanned else 'Never'}")
453
+ rows = db._exec(
454
+ """
455
+ SELECT
456
+ i.id,
457
+ i.name,
458
+ i.scanned_at,
459
+ COUNT(f.id) as file_count,
460
+ COALESCE(SUM(f.file_size), 0) as total_size
461
+ FROM images i
462
+ LEFT JOIN files f ON i.id = f.image_id
463
+ GROUP BY i.id
464
+ ORDER BY i.scanned_at DESC
465
+ """,
466
+ ).rows
467
+
468
+ if not rows:
469
+ print("No images found in database.")
470
+ return
471
+
472
+ print(f"\n{'ID':<4} {'Image Name':<30} {'Files':<8} {'Size (MB)':<12} {'Scanned'}")
473
+ print("-" * 80)
474
+
475
+ for img_id, name, scanned, file_count, total_size in rows:
476
+ size_mb = total_size / (1024 * 1024) if total_size else 0
477
+ print(f"{img_id:<4} {name[:29]:<30} {file_count:<8} {size_mb:<12.2f} {scanned[:19] if scanned else 'Never'}")
329
478
 
330
479
 
331
480
  def show_unique_files(db: DockerImageDB, comparison_id: int, limit: int = 20):
@@ -349,10 +498,10 @@ def show_unique_files(db: DockerImageDB, comparison_id: int, limit: int = 20):
349
498
 
350
499
  def _cmd_scan(db: DockerImageDB, args):
351
500
  for image in args.images:
352
- db.scan_image(image)
501
+ db.scan_image(image, force=args.force)
353
502
 
354
503
  def _cmd_compare(db: DockerImageDB, args):
355
- comparison_id = db.compare_images(args.images, args.name)
504
+ comparison_id = db.compare_images(args.images, args.name, force=args.force)
356
505
  print_comparison_summary(db, comparison_id)
357
506
 
358
507
  def _cmd_list_images(db: DockerImageDB, args):
@@ -377,11 +526,13 @@ def main():
377
526
 
378
527
  p_scan = sub.add_parser("scan", help="Scan one or more images and store file listings")
379
528
  p_scan.add_argument("images", nargs="+", help="Docker image names (e.g., ubuntu:22.04)")
529
+ p_scan.add_argument("--force", action="store_true", help="Re-scan even if image exists in DB (overrides skip)")
380
530
  p_scan.set_defaults(func=_cmd_scan)
381
531
 
382
532
  p_compare = sub.add_parser("compare", help="Compare images and store results")
383
533
  p_compare.add_argument("images", nargs="+", help="Docker image names to compare")
384
534
  p_compare.add_argument("--name", help="Optional comparison name")
535
+ p_compare.add_argument("--force", action="store_true", help="Re-scan images even if they exist in DB")
385
536
  p_compare.set_defaults(func=_cmd_compare)
386
537
 
387
538
  p_list = sub.add_parser("list", help="List images or comparisons")
@@ -404,8 +555,11 @@ def main():
404
555
 
405
556
  args = parser.parse_args()
406
557
  db = DockerImageDB()
407
- args.func(db, args)
558
+ try:
559
+ args.func(db, args)
560
+ finally:
561
+ db.close()
408
562
 
409
563
 
410
564
  if __name__ == "__main__":
411
- main()
565
+ main()
@@ -1,10 +0,0 @@
1
- docker_diff-0.0.5.dist-info/licenses/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
2
- docker_diff_pkg/__init__.py,sha256=aG5WUO74Z5ax04A5iwh6thCpHcFXBsdH5v-ph-uUJuI,321
3
- docker_diff_pkg/_version.py,sha256=YRV1ohn6CdKEhsUOmFFMmr5UTjMv4Ydw3WJGxF2BHBs,704
4
- docker_diff_pkg/cli.py,sha256=iXC5GR1jCzhmJpFCSJHSujL3vdAkXQOpeOtBJYGQy_A,15983
5
- docker_diff_pkg/schema.sql,sha256=xI3kOohbUEzwFhmIGQs2_abN_tJn1BBdAya7tVbw6n8,5477
6
- docker_diff-0.0.5.dist-info/METADATA,sha256=MXmyyswAGtc-NNGNjoIwWADT2W_a2XKPseLvz__jHj0,1654
7
- docker_diff-0.0.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
- docker_diff-0.0.5.dist-info/entry_points.txt,sha256=nEDTWZsgB5fRJgeXHJbXJyaHKMmKyN9Qypf3X78tosw,57
9
- docker_diff-0.0.5.dist-info/top_level.txt,sha256=GB5Qrc3AH1pk0543cQz9SRNr5Fatrj-c0Cdp3rCvTM8,16
10
- docker_diff-0.0.5.dist-info/RECORD,,