Coverage for pyTooling / Filesystem / Docker.py: 71%
139 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-10 22:41 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-10 22:41 +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#
31from pathlib import Path
32from typing import Optional as Nullable, List, Set
34from pyTooling.Decorators import export, readonly
35from pyTooling.MetaClasses import ExtendedType
36from pyTooling.Common import getFullyQualifiedName
37from pyTooling.Filesystem import Root, Element, Directory, Filename, SymbolicLink, FilesystemException
38from pyTooling.Stopwatch import Stopwatch
41@export
42class Layer(metaclass=ExtendedType):
43 _parent: Nullable["LayerCake"] #: Reference to the parent layer cake.
44 _previousLayer: Nullable["Layer"] #: Reference to the previous layer.
45 _nextLayer: Nullable["Layer"] #: Reference to the next layer
47 _files: List[Element[Directory]] #: List of files in this layer.
48 _size: int #: Aggregated size of all contained files for this layer.
50 def __init__(self, parent: Nullable["LayerCake"] = None, previousLayer: Nullable["Layer"] = None) -> None:
51 if parent is not None:
52 parent._layers.append(self)
53 self._parent = parent
54 self._previousLayer = previousLayer
55 self._nextLayer = None
56 if previousLayer is not None:
57 previousLayer._nextLayer = self
59 self._files = []
60 self._size = 0
62 @readonly
63 def Parent(self) -> Nullable["LayerCake"]:
64 return self._parent
66 @readonly
67 def PreviousLayer(self) -> Nullable["Layer"]:
68 return self._previousLayer
70 @readonly
71 def NextLayer(self) -> Nullable["Layer"]:
72 return self._nextLayer
74 @readonly
75 def Files(self) -> List[Element[Directory]]:
76 return self._files
78 @readonly
79 def FileCount(self) -> int:
80 return len(self._files)
82 @readonly
83 def Size(self) -> int:
84 return self._size
86 def AddFile(self, element: Element) -> Set[Filename]:
87 usedFiles = set()
88 if isinstance(element, Filename): 88 ↛ 92line 88 didn't jump to line 92 because the condition on line 88 was always true
89 for filename in element.File.Parents:
90 self._files.append(filename)
91 usedFiles.add(filename)
92 elif isinstance(element, SymbolicLink):
93 self._files.append(element)
94 usedFiles.add(element)
95 else:
96 ex = TypeError(f"Parameter 'element' is not a filename nor symbolic link.")
97 ex.add_note(f"Got type '{getFullyQualifiedName(element)}'.")
98 raise ex
100 self._size += 0 if isinstance(element, SymbolicLink) else element.Size
102 return usedFiles
104 def WriteLayerFile(self, path: Path, relative: bool = True) -> None:
105 rootDirectory = self._parent._root._path
107 if relative:
108 def format(file: Path) -> str:
109 return f"{file.relative_to(rootDirectory).as_posix()}\n"
110 else:
111 def format(file: Path) -> str:
112 return f"{file.as_posix()}\n"
114 with path.open("w", encoding="utf-8") as f:
115 for file in self._files:
116 f.write(format(file.Path))
119@export
120class LayerCake(metaclass=ExtendedType):
121 _root: Nullable[Root] #: Reference to the filesystem root.
122 _layers: List[Layer] #: List of Docker image layers.
123 _emptyDirectories: List[Directory] #: List of empty directories (not covered by layers).
124 _slicingDuration: Nullable[float] #: Duration for sorting files by size and assigning them to Docker image layers.
126 def __init__(self, root: Root) -> None:
127 self._root = root
128 self._layers = []
129 self._emptyDirectories = []
131 @readonly
132 def Root(self) -> Root:
133 return self._root
135 @readonly
136 def Layers(self) -> List[Layer]:
137 return self._layers
139 @readonly
140 def LayerCount(self) -> int:
141 return len(self._layers)
143 @readonly
144 def TotalFileCount(self) -> int:
145 return sum(layer.FileCount for layer in self._layers)
147 @readonly
148 def EmptyDirectories(self) -> List[Directory]:
149 return self._emptyDirectories
151 @readonly
152 def EmptyDirectoryCount(self) -> int:
153 return len(self._emptyDirectories)
155 @readonly
156 def SlicingDuration(self) -> float:
157 """
158 Read-only property to access the time needed to slice the filesystem structure into docker layers.
160 :returns: The slicing duration in seconds.
161 :raises FilesystemException: If the filesystem was not sliced into layers.
162 """
163 if self._slicingDuration is None:
164 raise FilesystemException(f"Filesystem was not sliced, yet.")
166 return self._slicingDuration
168 def CreateDockerLayers(self, minLayerSize: int, maxLayerSize: int, layerSizeGradient: int) -> None:
169 with Stopwatch() as sw:
170 self._SliceFilesystemIntoLayers(minLayerSize, maxLayerSize, layerSizeGradient)
171 self._CollectEmptDirectories()
173 self._slicingDuration = sw.Duration
175 def _SliceFilesystemIntoLayers(self, minLayerSize: int, maxLayerSize: int, layerSizeGradient: int) -> None:
176 # greedy algorithm
177 layer = Layer(self)
179 def sizeOf(file: Element[Directory]) -> int:
180 return 0 if isinstance(file, SymbolicLink) else file.Size
182 collectedFiles = set()
183 targetLayerSize = maxLayerSize
184 iterator = iter(sorted(self._root.IterateFiles(), key=sizeOf, reverse=True))
185 firstFile = next(iterator)
186 collectedFiles |= layer.AddFile(firstFile)
188 for file in iterator:
189 if file in collectedFiles: 189 ↛ 190line 189 didn't jump to line 190 because the condition on line 189 was never true
190 continue
192 if layer._size + sizeOf(file) <= targetLayerSize:
193 collectedFiles |= layer.AddFile(file)
194 else:
195 layer = Layer(self, layer)
196 collectedFiles |= layer.AddFile(file)
198 if (size := targetLayerSize - layerSizeGradient) >= minLayerSize: 198 ↛ 188line 198 didn't jump to line 188 because the condition on line 198 was always true
199 targetLayerSize = size
201 def _CollectEmptDirectories(self) -> None:
202 for directory in self._root.IterateDirectories():
203 if directory.SubdirectoryCount == 0 and directory.FileCount == 0: 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true
204 self._emptyDirectories.append(directory)
206 def WriteLayerFiles(self, directory: Path, fileNamePattern: str = "layer_{layerID}.files", relative: bool = True) -> None:
207 for i, layer in enumerate(self._layers, start=1):
208 layer.WriteLayerFile(directory / fileNamePattern.format(layerID=i), relative)
210 def WriteEmptyDirectoryFile(self, directory: Path, fileNamePattern: str = "empty_directories.files", relative: bool = True) -> None:
211 rootDirectory = self._root._path
213 if relative:
214 def format(file: Path) -> str:
215 return f"{file.relative_to(rootDirectory).as_posix()}\n"
216 else:
217 def format(file: Path) -> str:
218 return f"{file.as_posix()}\n"
220 with (directory / fileNamePattern).open("w", encoding="utf-8") as f:
221 for directory in self._emptyDirectories:
222 f.write(format(directory.Path))