File: source_builder.py

package info (click to toggle)
git-ubuntu 1.1-2
  • links: PTS, VCS
  • area: main
  • in suites: sid
  • size: 1,688 kB
  • sloc: python: 13,378; sh: 480; makefile: 2
file content (341 lines) | stat: -rw-r--r-- 10,913 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
import subprocess
import tarfile
import tempfile

# pylint bug: https://github.com/PyCQA/pylint/issues/640
import py.path  # pylint: disable=no-name-in-module


"""Build test Debian source packages as data structures

Represent a Debian source package as a data structure. For testing purposes,
often all that is needed is an available source package which is the most basic
source package possible; sometimes with just a simple tweak to adjust some
attribute under test.

Create an abstract specification of what attributes the source package should
have with SourceSpec(). Attributes such as the package version string, or
whether the package is native, can be adjusted using keyword arguments to the
constructor.

Instantiate the source package on disk using Source() or Source(spec). This
object can be used as a context manager (returning a path to the dsc file), or
the write() and cleanup() methods can be called directly.

Example:

    with source_builder.Source() as dsc_path:
        # use dsc_path here for some test
"""



CONTROL_TEXT = """Source: source-builder-package
Maintainer: ubuntu-distributed-devel@lists.ubuntu.com

Package: source-builder-package
Architecture: all
Description: test source-builder-package
 An autogenerated test package.
"""


RULES_TEXT = """#!/usr/bin/make -f
clean:	true
"""


CHANGELOG_TEMPLATE = """source-builder-package ({version}) unstable; urgency=low
    * Test build from source_builder.

 -- git ubuntu <ubuntu-distributed-devel@lists.ubuntu.com>  {date}
"""


DEFAULT_CHANGELOG_DATE = 'Thu, 1 Jan 1970 00:00:00 +0000'


NEW_FILE_PATCH_TEMPLATE = """
--- /dev/null
+++ b/{new_path}
@@ -0,0 +1 @@
+{new_line}
"""


class SourceSpec:
    """A high level abstraction of the attributes of a test source package"""
    version = '1-1'
    native = False
    has_patches = False
    changelog_versions = None
    file_contents = None
    mutate = False
    changelog_date = DEFAULT_CHANGELOG_DATE
    reserved_files = [
        'debian/changelog',
        'debian/control',
        'debian/rules',
    ]

    def __init__(self, **kwargs):
        """Instantiate a new SourceSpec class instance

        :param str version: the Debian package version string
        :param bool native: True for a native package, False for non-native
        :param bool has_patches: if this package should have quilt patches
        :param list(str) changelog_versions: if specified, version is ignored,
            and the changelog is generated with this list of versions instead.
            changelog_versions[0] is taken to be the version string of the
            package itself.
        :param dict(str -> str) file_contents: a dictionary of string
            relative file path names to string contents. The source
            package should contain the specified file names with the
            specified contents.  This is used to control the tree hashes
            of generated source packages.
        :param mutate: if bool(mutate) is True, then this will add a file
            called debian/mutate containing the data str(mutate).

        Keyword arguments to the constructor map directly to class instances
        properties. Properties may be manipulated after construction.
        """
        for k, v in kwargs.items():
            setattr(self, k, v)

        if self.changelog_versions:
            self.version = self.changelog_versions[0]

            if self.native and any(
                '-' in version for version in self.changelog_versions
            ):
                raise ValueError("Version must not have a '-' in a native package")

            if not self.native and any(
                '-' not in version for version in self.changelog_versions
            ):
                raise ValueError("Version must have a '-' in a non-native package")
        else:
            self.changelog_versions = []

            # If the version was not explicitly set, toggle the default for
            # non-native packages
            if 'version' not in kwargs and self.native:
                self.version = '1'

            if self.native and '-' in self.version:
                raise ValueError("Version must not have a '-' in a native package")

            if not self.native and '-' not in self.version:
                raise ValueError("Version must have a '-' in a non-native package")

        if self.file_contents is None:
            self.file_contents = {}

        reserved_files_specified = [
            f for f in self.file_contents
            if f in self.reserved_files
        ]
        if reserved_files_specified:
            raise ValueError(
                "The file names %s are reserved" %
                ", ".join(reserved_files_specified)
            )

        absolute_path_files_specified = [
            f for f in self.file_contents
            if f.startswith('/')
        ]
        if absolute_path_files_specified:
            raise ValueError(
                "File names %s must be relative" %
                ", ".join(absolute_path_files_specified)
            )

class SourceFiles:
    """Representation of a Debian source package in terms of its files

    Instantiated with a SourceSpec, this class presents a set of attributes
    which map to the files in a Debian source package. It thus maps a
    SourceSpec to the files that should be present in a Debian source package
    that meets that spec.

    Where the property types are str, it is expected that the caller will
    encode them to UTF-8 as necessary.
    """
    def __init__(self, spec):
        """Instantiate a new SourceFiles instance

        :param SourceSpec spec: the specification the files will meet
        """
        self.spec = spec

    @property
    def control(self):
        """The contents of debian/control

        :rtype: str
        """
        return CONTROL_TEXT

    @property
    def rules(self):
        """The contents of debian/rules

        :rtype: str
        """
        return RULES_TEXT

    @property
    def changelog(self):
        """The contents of debian/changelog

        :rtype: str
        """
        versions = self.spec.changelog_versions or [self.spec.version]

        return "\n".join(
            CHANGELOG_TEMPLATE.format(
                version=version,
                date=self.spec.changelog_date,
            )
            for version in versions
        )

    @property
    def spec_files(self):
        """The contents of any files explicitly specified in self.spec

        :returns: a mapping of filename to content
        :rtype: dict(str, str)
        """
        result = dict(self.spec.file_contents)
        if self.spec.mutate:
            result['debian/mutate'] = str(self.spec.mutate)
        return result

    @property
    def source_format(self):
        """The contents of debian/source/format

        :rtype: str
        """
        if self.spec.native:
            return "3.0 (native)\n"
        else:
            return "3.0 (quilt)\n"

    @property
    def patches(self):
        """The contents of debian/patches/

        :returns: a mapping of filename to content
        :rtype: dict(str, str)

        The filenames are basenames only (without a path).
        """
        if self.spec.has_patches:
            a = NEW_FILE_PATCH_TEMPLATE.format(new_path='a', new_line='a')
            b = NEW_FILE_PATCH_TEMPLATE.format(new_path='b', new_line='b')
            return {
                'series': b"a\nb\n",
                'a': a,
                'b': b,
            }
        else:
            return None


class Source:
    """A Pythonic representation of a test Debian source package

    An instance is a context manager that, when invoked, creates the source
    package in a temporary directory and returns the full filesystem path to
    the dsc file, cleaning up on exit. Alternatively, use the write() and
    cleanup() methods directly."""
    def __init__(self, spec=None):
        """Construct a Source instance

        :param SourceSpec spec: what the package should contain
        """
        self.spec = spec or SourceSpec()
        self.files = SourceFiles(self.spec)

        self.tmpdir = None
        self.dsc_path = self.changes_path = None

    def cleanup(self):
        """Remove temporary directory and source package files after write()

        This method is idempotent and does nothing if write() has never been
        called or if a write() has already had cleanup() called on it.
        """
        if self.tmpdir is not None:
            self.tmpdir.cleanup()
            self.tmpdir = None
            self.dsc_path = self.changes_path = None

    def __del__(self):
        self.cleanup()

    def __enter__(self):
        return self.write()

    def __exit__(self, exc_type, exc_value, tb):
        self.cleanup()

    def write(self):
        """Write the source package to a temporary directory

        :rtype: str
        :returns: full filesystem path to a dsc file

        Once this method is called, it must not be called again unless
        cleanup() has been called first.
        """
        assert self.tmpdir is None
        self.tmpdir = tempfile.TemporaryDirectory()
        # pylint bug: https://github.com/PyCQA/pylint/issues/640
        top = py.path.local(self.tmpdir.name)  # pylint: disable=no-member
        tree = top.join('srcpkg')
        debian = tree.join('debian')
        for filename in ['control', 'rules', 'changelog']:
            debian.join(filename).write_text(
                getattr(self.files, filename),
                encoding='utf-8',
                ensure=True,
            )
        for (filename, contents) in self.files.spec_files.items():
            tree.join(filename).write_text(
                contents,
                encoding='utf-8',
                ensure=True,
            )
        debian.join('source/format').write_text(
            self.files.source_format,
            encoding='utf-8',
            ensure=True,
        )
        debian.join('rules').chmod(0o755)
        patches = self.files.patches
        if patches is not None:
            patch_path = debian.join('patches')
            patch_path.ensure_dir()
            for basename, content in patches.items():
                patch_path.join(basename).write(content)
        if not self.spec.native:
            orig_version, _ = self.spec.version.rsplit('-', maxsplit=1)
            orig_basename = (
                'source-builder-package_%s.orig.tar.gz' % orig_version
            )
            tarfile.open(str(top.join(orig_basename)), 'w:gz').close()
        subprocess.check_call(
            ['dpkg-source', '--build', tree.basename],
            cwd=str(top),
        )

        for entry in top.listdir():
            if entry.basename.endswith('.dsc'):
                self.dsc_path = str(entry)

        assert self.dsc_path is not None
        return self.dsc_path