Coverage for pyTooling/Filesystem/Docker.py: 71%
141 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-10-25 12:33 +0000
1# ==================================================================================================================== #
2# _____ _ _ _____ _ _ _ #
3# _ __ _ |_ _|__ ___ | (_)_ __ __ _ | ___(_) | ___ ___ _ _ ___| |_ ___ _ __ ___ #
4# | '_ \| | | || |/ _ \ / _ \| | | '_ \ / _` | | |_ | | |/ _ \/ __| | | / __| __/ _ \ '_ ` _ \ #
5# | |_) | |_| || | (_) | (_) | | | | | | (_| |_| _| | | | __/\__ \ |_| \__ \ || __/ | | | | | #
6# | .__/ \__, ||_|\___/ \___/|_|_|_| |_|\__, (_)_| |_|_|\___||___/\__, |___/\__\___|_| |_| |_| #
7# |_| |___/ |___/ |___/ #
8# ==================================================================================================================== #
9# Authors: #
10# Patrick Lehmann #
11# #
12# License: #
13# ==================================================================================================================== #
14# Copyright 2025-2026 Patrick Lehmann - Bötzingen, Germany #
15# #
16# Licensed under the Apache License, Version 2.0 (the "License"); #
17# you may not use this file except in compliance with the License. #
18# You may obtain a copy of the License at #
19# #
20# http://www.apache.org/licenses/LICENSE-2.0 #
21# #
22# Unless required by applicable law or agreed to in writing, software #
23# distributed under the License is distributed on an "AS IS" BASIS, #
24# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
25# See the License for the specific language governing permissions and #
26# limitations under the License. #
27# #
28# SPDX-License-Identifier: Apache-2.0 #
29# ==================================================================================================================== #
30#
31"""
32Slice a filesystem tree into Docker image layers.
34A :class:`~pyTooling.Filesystem.Docker.LayerCake` distributes the files of a
35:class:`~pyTooling.Filesystem.Root` over layers - largest file first, each layer filled up to a target size that
36shrinks from layer to layer - and writes one file list per layer, ready to be turned into image layers.
37"""
38from __future__ import annotations
40from datetime import timedelta
41from pathlib import Path
42from typing import Optional as Nullable
44from pyTooling.Decorators import export, readonly
45from pyTooling.MetaClasses import ExtendedType
46from pyTooling.Common import getFullyQualifiedName
47from pyTooling.Filesystem import Root, Element, Directory, Filename, SymbolicLink, FilesystemError
48from pyTooling.Stopwatch import Stopwatch
51@export
52class Layer(metaclass=ExtendedType):
53 """
54 One layer of a Docker image: the files assigned to it, and its neighboring layers in the layer cake.
56 A layer knows its aggregated size, so the slicing algorithm can stop filling it when the target size is reached.
57 """
58 _parent: Nullable[LayerCake] #: Reference to the parent layer cake.
59 _previousLayer: Nullable[Layer] #: Reference to the previous layer.
60 _nextLayer: Nullable[Layer] #: Reference to the next layer
62 _files: list[Element[Directory]] #: List of files in this layer.
63 _size: int #: Aggregated size of all contained files for this layer.
65 def __init__(self, parent: Nullable[LayerCake] = None, previousLayer: Nullable[Layer] = None) -> None:
66 """
67 Initialize an empty layer, which appends itself to the layer cake it belongs to.
69 :param parent: Optional, layer cake this layer is part of.
70 :param previousLayer: Optional, layer below this one, which is linked to this layer in both directions.
71 """
72 if parent is not None:
73 parent._layers.append(self)
74 self._parent = parent
75 self._previousLayer = previousLayer
76 self._nextLayer = None
77 if previousLayer is not None:
78 previousLayer._nextLayer = self
80 self._files = []
81 self._size = 0
83 @readonly
84 def Parent(self) -> Nullable[LayerCake]:
85 """
86 Read-only property to access the layer cake this layer belongs to (:attr:`_parent`).
88 :returns: The layer cake this layer belongs to, or ``None`` if the layer isn't part of one.
89 """
90 return self._parent
92 @readonly
93 def PreviousLayer(self) -> Nullable[Layer]:
94 """
95 Read-only property to access the layer below this one (:attr:`_previousLayer`).
97 :returns: The previous layer, or ``None`` if this is the bottom layer.
98 """
99 return self._previousLayer
101 @readonly
102 def NextLayer(self) -> Nullable[Layer]:
103 """
104 Read-only property to access the layer above this one (:attr:`_nextLayer`).
106 :returns: The next layer, or ``None`` if this is the top layer.
107 """
108 return self._nextLayer
110 @readonly
111 def Files(self) -> list[Element[Directory]]:
112 """
113 Read-only property to access the files contributed by this layer (:attr:`_files`).
115 :returns: List of files in this layer.
116 """
117 return self._files
119 @readonly
120 def FileCount(self) -> int:
121 """
122 Read-only property to return the number of files in this layer.
124 :returns: Number of files.
125 """
126 return len(self._files)
128 @readonly
129 def Size(self) -> int:
130 """
131 Read-only property to access the accumulated size of all files in this layer (:attr:`_size`).
133 :returns: Size of the layer in bytes.
134 """
135 return self._size
137 def AddFile(self, element: Element) -> set[Filename]:
138 """
139 Add a filename or symbolic link to this layer.
141 For a filename, every other filename of the same file object is added too, because hardlinks have to end up in
142 the same image layer.
144 :param element: The filename or symbolic link to add.
145 :returns: The set of elements that were added, so the caller can skip them.
146 :raises TypeError: If parameter 'element' is neither a filename nor a symbolic link.
147 """
148 usedFiles = set()
149 if isinstance(element, Filename): 149 ↛ 153line 149 didn't jump to line 153 because the condition on line 149 was always true
150 for filename in element.File.Parents:
151 self._files.append(filename)
152 usedFiles.add(filename)
153 elif isinstance(element, SymbolicLink):
154 self._files.append(element)
155 usedFiles.add(element)
156 else:
157 ex = TypeError("Parameter 'element' is not a filename nor symbolic link.")
158 ex.add_note(f"Got type '{getFullyQualifiedName(element)}'.")
159 raise ex
161 self._size += 0 if isinstance(element, SymbolicLink) else element.Size
163 return usedFiles
165 def WriteLayerFile(self, path: Path, relative: bool = True) -> None:
166 """
167 Write the layer's files as one file list.
169 :param path: Path of the file list to write.
170 :param relative: Optional, if ``True``, the paths are written relative to the filesystem root.
171 """
172 rootDirectory = self._parent._root._path
174 if relative:
175 def format(file: Path) -> str:
176 """
177 Nested function rendering a file's path relative to the filesystem root.
179 :param file: The path to render.
180 :returns: The relative path in POSIX notation, terminated by a newline.
181 """
182 return f"{file.relative_to(rootDirectory).as_posix()}\n"
183 else:
184 def format(file: Path) -> str:
185 """
186 Nested function rendering a file's path as it is.
188 :param file: The path to render.
189 :returns: The absolute path in POSIX notation, terminated by a newline.
190 """
191 return f"{file.as_posix()}\n"
193 with path.open("w", encoding="utf-8") as f:
194 for file in self._files:
195 f.write(format(file.Path))
198@export
199class LayerCake(metaclass=ExtendedType):
200 """
201 A stack of Docker image layers computed from a filesystem tree.
203 :meth:`CreateDockerLayers` distributes the files of a :class:`~pyTooling.Filesystem.Root` over layers - largest file
204 first, each layer filled up to a target size that shrinks by a gradient from layer to layer - and collects the
205 directories no layer covers.
206 """
207 _root: Nullable[Root] #: Reference to the filesystem root.
208 _layers: list[Layer] #: List of Docker image layers.
209 _emptyDirectories: list[Directory] #: List of empty directories (not covered by layers).
210 _slicingDuration: Nullable[timedelta] #: Time to sort files by size and assign them to layers.
212 def __init__(self, root: Root) -> None:
213 """
214 Initialize an empty layer cake for the given filesystem tree.
216 :param root: Root of the filesystem statistics scope to slice into layers.
217 """
218 self._root = root
219 self._layers = []
220 self._emptyDirectories = []
222 @readonly
223 def Root(self) -> Root:
224 """
225 Read-only property to access the root directory of the merged layers (:attr:`_root`).
227 :returns: Root directory of the layer cake.
228 """
229 return self._root
231 @readonly
232 def Layers(self) -> list[Layer]:
233 """
234 Read-only property to access all layers, bottom-most first (:attr:`_layers`).
236 :returns: List of layers.
237 """
238 return self._layers
240 @readonly
241 def LayerCount(self) -> int:
242 """
243 Read-only property to return the number of layers.
245 :returns: Number of layers.
246 """
247 return len(self._layers)
249 @readonly
250 def TotalFileCount(self) -> int:
251 """
252 Read-only property to return the number of files across all layers.
254 :returns: Sum of all layers' file counts.
255 """
256 return sum(layer.FileCount for layer in self._layers)
258 @readonly
259 def EmptyDirectories(self) -> list[Directory]:
260 """
261 Read-only property to access the directories that contain no files (:attr:`_emptyDirectories`).
263 :returns: List of empty directories.
264 """
265 return self._emptyDirectories
267 @readonly
268 def EmptyDirectoryCount(self) -> int:
269 """
270 Read-only property to return the number of empty directories.
272 :returns: Number of empty directories.
273 """
274 return len(self._emptyDirectories)
276 @readonly
277 def SlicingDuration(self) -> timedelta:
278 """
279 Read-only property to access the time needed to slice the filesystem structure into docker layers.
281 :returns: The slicing duration.
282 :raises FilesystemError: If the filesystem was not sliced into layers.
283 """
284 if self._slicingDuration is None:
285 raise FilesystemError("Filesystem was not sliced, yet.")
287 return self._slicingDuration
289 def CreateDockerLayers(self, minLayerSize: int, maxLayerSize: int, layerSizeGradient: int) -> None:
290 """
291 Distribute the filesystem's files over image layers and collect the directories no layer covers.
293 The layers are filled largest file first. Each layer is filled up to a target size, which shrinks by
294 ``layerSizeGradient`` from layer to layer until ``minLayerSize`` is reached.
296 :param minLayerSize: Smallest target size a layer is filled to.
297 :param maxLayerSize: Target size of the first layer.
298 :param layerSizeGradient: Amount the target size shrinks by from layer to layer.
299 """
300 with Stopwatch() as sw:
301 self._SliceFilesystemIntoLayers(minLayerSize, maxLayerSize, layerSizeGradient)
302 self._CollectEmptDirectories()
304 self._slicingDuration = sw.Duration
306 def _SliceFilesystemIntoLayers(self, minLayerSize: int, maxLayerSize: int, layerSizeGradient: int) -> None:
307 """
308 Distribute the filesystem's files over image layers, largest file first.
310 :param minLayerSize: Smallest target size a layer is filled to.
311 :param maxLayerSize: Target size of the first layer.
312 :param layerSizeGradient: Amount the target size shrinks by from layer to layer.
313 """
314 # greedy algorithm
315 layer = Layer(self)
317 def sizeOf(file: Element[Directory]) -> int:
318 """
319 Nested function used as sort key.
321 A symbolic link occupies no space of its own, so it is counted as zero and ends up last.
323 :param file: The filesystem element to measure.
324 :returns: Size of the element in bytes.
325 """
326 return 0 if isinstance(file, SymbolicLink) else file.Size
328 collectedFiles = set()
329 targetLayerSize = maxLayerSize
330 iterator = iter(sorted(self._root.IterateFiles(), key=sizeOf, reverse=True))
331 firstFile = next(iterator)
332 collectedFiles |= layer.AddFile(firstFile)
334 for file in iterator:
335 if file in collectedFiles: 335 ↛ 336line 335 didn't jump to line 336 because the condition on line 335 was never true
336 continue
338 if layer._size + sizeOf(file) <= targetLayerSize:
339 collectedFiles |= layer.AddFile(file)
340 else:
341 layer = Layer(self, layer)
342 collectedFiles |= layer.AddFile(file)
344 if (size := targetLayerSize - layerSizeGradient) >= minLayerSize: 344 ↛ 334line 344 didn't jump to line 334 because the condition on line 344 was always true
345 targetLayerSize = size
347 def _CollectEmptDirectories(self) -> None:
348 """
349 Collect the directories that contain neither files nor subdirectories, so no layer covers them.
350 """
351 for directory in self._root.IterateDirectories():
352 if directory.SubdirectoryCount == 0 and directory.FileCount == 0: 352 ↛ 353line 352 didn't jump to line 353 because the condition on line 352 was never true
353 self._emptyDirectories.append(directory)
355 def WriteLayerFiles(self, directory: Path, fileNamePattern: str = "layer_{layerID}.files", relative: bool = True) -> None:
356 """
357 Write one file list per layer.
359 :param directory: Directory the file lists are written to.
360 :param fileNamePattern: Optional, pattern of the file names, with ``{layerID}`` replaced by the layer's number.
361 :param relative: Optional, if ``True``, the paths are written relative to the filesystem root.
362 """
363 for i, layer in enumerate(self._layers, start=1):
364 layer.WriteLayerFile(directory / fileNamePattern.format(layerID=i), relative)
366 def WriteEmptyDirectoryFile(self, directory: Path, fileNamePattern: str = "empty_directories.files", relative: bool = True) -> None:
367 """
368 Write the empty directories as one file list, so an image build can recreate them.
370 :param directory: Directory the file list is written to.
371 :param fileNamePattern: Optional, name of the file list to write.
372 :param relative: Optional, if ``True``, the paths are written relative to the filesystem root.
373 """
374 rootDirectory = self._root._path
376 if relative:
377 def format(file: Path) -> str:
378 """
379 Nested function rendering a directory's path relative to the filesystem root.
381 :param file: The path to render.
382 :returns: The relative path in POSIX notation, terminated by a newline.
383 """
384 return f"{file.relative_to(rootDirectory).as_posix()}\n"
385 else:
386 def format(file: Path) -> str:
387 """
388 Nested function rendering a directory's path as it is.
390 :param file: The path to render.
391 :returns: The absolute path in POSIX notation, terminated by a newline.
392 """
393 return f"{file.as_posix()}\n"
395 with (directory / fileNamePattern).open("w", encoding="utf-8") as f:
396 for directory in self._emptyDirectories:
397 f.write(format(directory.Path))