diff --git a/swh/model/git.py b/swh/model/git.py index ad5962f..d46a60b 100644 --- a/swh/model/git.py +++ b/swh/model/git.py @@ -1,587 +1,586 @@ # Copyright (C) 2015-2017 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import os import stat from enum import Enum, IntEnum from swh.model import hashutil, identifiers ROOT_TREE_KEY = b'' class GitType(Enum): BLOB = b'blob' TREE = b'tree' EXEC = b'exec' LINK = b'link' COMM = b'commit' RELE = b'release' REFS = b'ref' class GitPerm(IntEnum): BLOB = 0o100644 TREE = 0o040000 EXEC = 0o100755 LINK = 0o120000 def _compute_directory_git_sha1(hashes): """Compute a directory git sha1 from hashes. Args: - hashes: list of tree entries with keys: + hashes (list): list of tree entries with the following keys: + - sha1_git: the tree entry's sha1 - name: file or subdir's name - perms: the tree entry's sha1 permissions Returns: the binary sha1 of the dictionary's identifier Assumes: Every path exists in hashes. """ directory = { 'entries': [ { 'name': entry['name'], 'perms': entry['perms'], 'target': entry['sha1_git'], 'type': 'dir' if entry['perms'] == GitPerm.TREE else 'file', } for entry in hashes ] } return hashutil.hash_to_bytes(identifiers.directory_identifier(directory)) def compute_directory_git_sha1(dirpath, hashes): """Compute a directory git sha1 for a dirpath. Args: dirpath: the directory's absolute path - hashes: list of tree entries with keys: + hashes (list): list of tree entries with keys: + - sha1_git: the tree entry's sha1 - name: file or subdir's name - perms: the tree entry's sha1 permissions Returns: the binary sha1 of the dictionary's identifier Assumes: Every path exists in hashes. """ return _compute_directory_git_sha1(hashes[dirpath]) def compute_revision_sha1_git(revision): """Compute a revision sha1 git from its dict representation. Args: revision: Additional dictionary information needed to compute a - synthetic - revision. Following keys are expected: + synthetic revision. The following keys are expected: + - author - date - committer - committer_date - message - type - directory: binary form of the tree hash Returns: revision sha1 in bytes # FIXME: beware, bytes output from storage api """ return hashutil.hash_to_bytes(identifiers.revision_identifier(revision)) def compute_release_sha1_git(release): """Compute a release sha1 git from its dict representation. Args: release: Additional dictionary information needed to compute a - synthetic release. Following keys are expected: + synthetic release. Following keys are expected: + - name - message - date - author - revision: binary form of the sha1_git revision targeted by this Returns: release sha1 in bytes """ return hashutil.hash_to_bytes(identifiers.release_identifier(release)) def compute_link_metadata(linkpath): """Given a linkpath, compute the git metadata. Args: linkpath: absolute pathname of the link Returns: - Dictionary of values: + dict: Dictionary of values with the following keys: + - data: link's content - length: link's content length - name: basename of the link - perms: git permission for link - type: git type for link - path: absolute path to the link on filesystem """ data = os.readlink(linkpath) link_metadata = hashutil.hash_data(data) link_metadata.update({ 'data': data, 'length': len(data), 'name': os.path.basename(linkpath), 'perms': GitPerm.LINK, 'type': GitType.BLOB, 'path': linkpath }) return link_metadata def compute_blob_metadata(filepath): """Given a filepath resolving to a regular file, compute the metadata. Other file types (fifo, character or block device, symlink) will be considered empty regular file. To deal properly with symlinks, use swh.model.git.compute_link_metadata. Args: filepath: absolute pathname of the regular file. Returns: - Dictionary of values: + dict: Dictionary of values with the following keys: + - name: basename of the file - length: data length - perms: git permission for file - type: git type for file - path: absolute filepath on filesystem """ mode = os.lstat(filepath).st_mode if not stat.S_ISREG(mode): # special (block or character device, fifo) perms = GitPerm.BLOB blob_metadata = hashutil.hash_data(b'') blob_metadata['length'] = 0 else: perms = GitPerm.EXEC if os.access(filepath, os.X_OK) else GitPerm.BLOB blob_metadata = hashutil.hash_path(filepath) blob_metadata.update({ 'name': os.path.basename(filepath), 'perms': perms, 'type': GitType.BLOB, 'path': filepath }) return blob_metadata def _compute_tree_metadata(dirname, hashes): """Given a dirname, compute the git metadata. Args: dirname: absolute pathname of the directory. - hashes: list of tree dirname's entries with keys: + hashes (list): list of tree dirname's entries with keys: + - sha1_git: the tree entry's sha1 - name: file or subdir's name - perms: the tree entry's sha1 permissions Returns: - Dictionary of values: + dict: Dictionary of values with the following keys: + - sha1_git: tree's sha1 git - name: basename of the directory - perms: git permission for directory - type: git type for directory - path: absolute path to directory on filesystem """ return { 'sha1_git': _compute_directory_git_sha1(hashes), 'name': os.path.basename(dirname), 'perms': GitPerm.TREE, 'type': GitType.TREE, 'path': dirname } def compute_tree_metadata(dirname, ls_hashes): """Given a dirname, compute the git metadata. Args: dirname: absolute pathname of the directory. ls_hashes: dictionary of path, hashes Returns: - Dictionary of values: + dict: Dictionary of values with the following keys: + - sha1_git: tree's sha1 git - name: basename of the directory - perms: git permission for directory - type: git type for directory - path: absolute path to directory on filesystem """ return _compute_tree_metadata(dirname, ls_hashes[dirname]) def default_validation_dir(dirpath): """Default validation function. This is the equivalent of the identity function. Args: dirpath: Path to validate Returns: True """ return True def _walk(rootdir, dir_ok_fn=default_validation_dir, remove_empty_folder=False): """Walk the filesystem and yields a 3 tuples (dirpath, dirnames as set of absolute paths, filenames as set of abslute paths) Ignore files which won't pass the dir_ok_fn validation. If remove_empty_folder is True, remove and ignore any encountered empty folder. Args: - rootdir: starting walk root directory path - - dir_ok_fn: validation function. if folder encountered are - not ok, they are ignored. Default to default_validation_dir - which does nothing. - - remove_empty_folder: Flag to remove and ignore any - encountered empty folders. + - dir_ok_fn: validation function. if folder encountered are not ok, + they are ignored. Default to default_validation_dir which does + nothing. + - remove_empty_folder: Flag to remove and ignore any encountered empty + folders. Yields: 3 tuples dirpath, set of absolute children dirname paths, set of absolute filename paths. """ def basic_gen_dir(rootdir): for dp, dns, fns in os.walk(rootdir, topdown=False): yield (dp, set((os.path.join(dp, dn) for dn in dns)), set((os.path.join(dp, fn) for fn in fns))) if dir_ok_fn == default_validation_dir: if not remove_empty_folder: # os.walk yield from basic_gen_dir(rootdir) else: # os.walk + empty dir cleanup empty_folders = set() for dp, dns, fns in basic_gen_dir(rootdir): if not dns and not fns: empty_folders.add(dp) # need to remove it because folder of empty folder # is an empty folder!!! if os.path.islink(dp): os.remove(dp) else: os.rmdir(dp) parent = os.path.dirname(dp) # edge case about parent containing one empty # folder which become an empty one while not os.listdir(parent): empty_folders.add(parent) if os.path.islink(parent): os.remove(parent) else: os.rmdir(parent) parent = os.path.dirname(parent) continue yield (dp, dns - empty_folders, fns) else: def filtfn(dirnames): return set(filter(dir_ok_fn, dirnames)) gen_dir = ((dp, dns, fns) for dp, dns, fns in basic_gen_dir(rootdir) if dir_ok_fn(dp)) if not remove_empty_folder: # os.walk + filtering for dp, dns, fns in gen_dir: yield (dp, filtfn(dns), fns) else: # os.walk + filtering + empty dir cleanup empty_folders = set() for dp, dns, fns in gen_dir: dps = filtfn(dns) if not dps and not fns: empty_folders.add(dp) # need to remove it because folder of empty folder # is an empty folder!!! if os.path.islink(dp): os.remove(dp) else: os.rmdir(dp) parent = os.path.dirname(dp) # edge case about parent containing one empty # folder which become an empty one while not os.listdir(parent): empty_folders.add(parent) if os.path.islink(parent): os.remove(parent) else: os.rmdir(parent) parent = os.path.dirname(parent) continue yield dp, dps - empty_folders, fns def walk_and_compute_sha1_from_directory(rootdir, dir_ok_fn=default_validation_dir, with_root_tree=True, remove_empty_folder=False): """(Deprecated) TODO migrate the code to compute_hashes_from_directory. Compute git sha1 from directory rootdir. Args: - - rootdir: Root directory from which beginning the git hash computation - - - dir_ok_fn: Filter function to filter directory according to rules - defined in the function. By default, all folders are ok. - Example override: dir_ok_fn = lambda dirpath: b'svn' not in dirpath - - - with_root_tree: Determine if we compute the upper root tree's - checksums. As a default, we want it. One possible use case where this - is not useful is the update (cf. `update_checksums_from`) + rootdir: Root directory from which beginning the git hash computation + dir_ok_fn: Filter function to filter directory according to rules + defined in the function. By default, all folders are ok. Example + override: ``dir_ok_fn = lambda dirpath: b'svn' not in dirpath`` + with_root_tree: Determine if we compute the upper root tree's + checksums. As a default, we want it. One possible use case where + this is not useful is the update (cf. `update_checksums_from`) Returns: - Dictionary of entries with keys and as values a list of - directory entries. - Those are list of dictionary with keys: - - 'perms' - - 'type' - - 'name' - - 'sha1_git' - - and specifically content: 'sha1', 'sha256', ... + dict: Dictionary of entries with keys and as values a list + of directory entries. Those are list of dictionary with keys: + + - perms + - type + - name + - sha1_git + - and specifically for content: sha1, sha256, etc. Note: One special key is ROOT_TREE_KEY to indicate the upper root of the directory (this is the revision's directory). - Raises: - Nothing - If something is raised, this is a programmatic error. - """ ls_hashes = {} all_links = set() if rootdir.endswith(b'/'): rootdir = rootdir.rstrip(b'/') for dirpath, dirnames, filenames in _walk( rootdir, dir_ok_fn, remove_empty_folder): hashes = [] links = (file for file in filenames.union(dirnames) if os.path.islink(file)) for linkpath in links: all_links.add(linkpath) m_hashes = compute_link_metadata(linkpath) hashes.append(m_hashes) for filepath in (file for file in filenames if file not in all_links): m_hashes = compute_blob_metadata(filepath) hashes.append(m_hashes) ls_hashes[dirpath] = hashes dir_hashes = [] for fulldirname in (dir for dir in dirnames if dir not in all_links): tree_hash = _compute_tree_metadata(fulldirname, ls_hashes[fulldirname]) dir_hashes.append(tree_hash) ls_hashes[dirpath].extend(dir_hashes) if with_root_tree: # compute the current directory hashes root_hash = { 'sha1_git': _compute_directory_git_sha1(ls_hashes[rootdir]), 'path': rootdir, 'name': os.path.basename(rootdir), 'perms': GitPerm.TREE, 'type': GitType.TREE } ls_hashes[ROOT_TREE_KEY] = [root_hash] return ls_hashes def compute_hashes_from_directory(rootdir, dir_ok_fn=default_validation_dir, remove_empty_folder=False): """Compute git sha1 from directory rootdir. Args: - - rootdir: Root directory from which beginning the git hash - computation - - - dir_ok_fn: Filter function to filter directory according to rules - defined in the function. By default, all folders are ok. - Example override: dir_ok_fn = lambda dirpath: b'svn' not in dirpath + rootdir: Root directory from which beginning the git hash + computation + dir_ok_fn: Filter function to filter directory according to rules + defined in the function. By default, all folders are ok. Example + override: ``dir_ok_fn = lambda dirpath: b'svn' not in dirpath`` Returns: - Dictionary of entries with keys absolute path name. + dict: Dictionary of entries with keys absolute path name. Path-name can be a file/link or directory. - The associated value is a dictionary with: + The associated value is a dictionary with keys: + - checksums: the dictionary with the hashes for the link/file/dir + Those are list of dictionary with keys: - - 'perms' - - 'type' - - 'name' - - 'sha1_git' - - and specifically content: 'sha1', 'sha256', ... + - 'perms' + - 'type' + - 'name' + - 'sha1_git' + - and specifically for content: sha1, sha256, etc. - children: Only for a directory, the set of children paths Note: - One special key is the / which indicates the upper root of - the directory (this is the revision's directory). - - Raises: - Nothing - If something is raised, this is a programmatic error. + One special key is the / which indicates the upper root of the + directory (this is the revision's directory). """ def _get_dict_from_dirpath(_dict, path): """Retrieve the default associated value for key path. """ return _dict.get(path, dict(children=set(), checksums=None)) def _get_dict_from_filepath(_dict, path): """Retrieve the default associated value for key path. """ return _dict.get(path, dict(checksums=None)) ls_hashes = {} all_links = set() if rootdir.endswith(b'/'): rootdir = rootdir.rstrip(b'/') for dirpath, dirnames, filenames in _walk( rootdir, dir_ok_fn, remove_empty_folder): dir_entry = _get_dict_from_dirpath(ls_hashes, dirpath) children = dir_entry['children'] links = (file for file in filenames.union(dirnames) if os.path.islink(file)) for linkpath in links: all_links.add(linkpath) m_hashes = compute_link_metadata(linkpath) d = _get_dict_from_filepath(ls_hashes, linkpath) d['checksums'] = m_hashes ls_hashes[linkpath] = d children.add(linkpath) for filepath in (file for file in filenames if file not in all_links): m_hashes = compute_blob_metadata(filepath) d = _get_dict_from_filepath(ls_hashes, filepath) d['checksums'] = m_hashes ls_hashes[filepath] = d children.add(filepath) for fulldirname in (dir for dir in dirnames if dir not in all_links): d_hashes = _get_dict_from_dirpath(ls_hashes, fulldirname) tree_hash = _compute_tree_metadata( fulldirname, (ls_hashes[p]['checksums'] for p in d_hashes['children']) ) d = _get_dict_from_dirpath(ls_hashes, fulldirname) d['checksums'] = tree_hash ls_hashes[fulldirname] = d children.add(fulldirname) dir_entry['children'] = children ls_hashes[dirpath] = dir_entry # compute the current directory hashes d_hashes = _get_dict_from_dirpath(ls_hashes, rootdir) root_hash = { 'sha1_git': _compute_directory_git_sha1( (ls_hashes[p]['checksums'] for p in d_hashes['children']) ), 'path': rootdir, 'name': os.path.basename(rootdir), 'perms': GitPerm.TREE, 'type': GitType.TREE } d_hashes['checksums'] = root_hash ls_hashes[rootdir] = d_hashes return ls_hashes def children_hashes(children, objects): """Given a collection of children path, yield the corresponding hashes. Args: - objects: objects hash as returned by git.compute_hashes_from_directory. + objects: objects hash as returned by git.compute_hashes_from_directory children: collection of bytes path Yields: Dictionary hashes """ for p in children: c = objects.get(p) if c: h = c.get('checksums') if h: yield h def objects_per_type(filter_type, objects_per_path): """Given an object dictionary returned by - `swh.model.git.compute_hashes_from_directory`, yields - corresponding element type's hashes + :py:func:`compute_hashes_from_directory`, yields corresponding element + type's hashes Args: filter_type: one of GitType enum objects_per_path: Yields: Elements of type filter_type's hashes """ for path, obj in objects_per_path.items(): o = obj['checksums'] if o['type'] == filter_type: if 'children' in obj: # for trees if obj['children']: o['children'] = children_hashes(obj['children'], objects_per_path) else: o['children'] = [] yield o diff --git a/swh/model/identifiers.py b/swh/model/identifiers.py index d51304e..c7a6ce9 100644 --- a/swh/model/identifiers.py +++ b/swh/model/identifiers.py @@ -1,490 +1,501 @@ # Copyright (C) 2015 The Software Heritage developers # See the AUTHORS file at the top-level directory of this distribution # License: GNU General Public License version 3, or any later version # See top-level LICENSE file for more information import binascii import datetime from functools import lru_cache from .hashutil import hash_data, hash_git_data, DEFAULT_ALGORITHMS @lru_cache() def identifier_to_bytes(identifier): """Convert a text identifier to bytes. Args: identifier: an identifier, either a 40-char hexadecimal string or a - bytes object of length 20 + bytes object of length 20 Returns: The length 20 bytestring corresponding to the given identifier Raises: - ValueError if the identifier is of an unexpected type or length. + ValueError: if the identifier is of an unexpected type or length. """ if isinstance(identifier, bytes): if len(identifier) != 20: raise ValueError( 'Wrong length for bytes identifier %s, expected 20' % len(identifier)) return identifier if isinstance(identifier, str): if len(identifier) != 40: raise ValueError( 'Wrong length for str identifier %s, expected 40' % len(identifier)) return bytes.fromhex(identifier) raise ValueError('Wrong type for identifier %s, expected bytes or str' % identifier.__class__.__name__) @lru_cache() def identifier_to_str(identifier): """Convert an identifier to an hexadecimal string. Args: identifier: an identifier, either a 40-char hexadecimal string or a - bytes object of length 20 + bytes object of length 20 + Returns: The length 40 string corresponding to the given identifier, hex encoded Raises: ValueError if the identifier is of an unexpected type or length. """ if isinstance(identifier, str): if len(identifier) != 40: raise ValueError( 'Wrong length for str identifier %s, expected 40' % len(identifier)) return identifier if isinstance(identifier, bytes): if len(identifier) != 20: raise ValueError( 'Wrong length for bytes identifier %s, expected 20' % len(identifier)) return binascii.hexlify(identifier).decode() raise ValueError('Wrong type for identifier %s, expected bytes or str' % identifier.__class__.__name__) def content_identifier(content): """Return the intrinsic identifier for a content. A content's identifier is the sha1, sha1_git and sha256 checksums of its data. Args: content: a content conforming to the Software Heritage schema Returns: A dictionary with all the hashes for the data Raises: - KeyError if the content doesn't have a data member. + KeyError: if the content doesn't have a data member. """ return hash_data(content['data'], DEFAULT_ALGORITHMS) def _sort_key(entry): """The sorting key for tree entries""" if entry['type'] == 'dir': return entry['name'] + b'/' else: return entry['name'] @lru_cache() def _perms_to_bytes(perms): """Convert the perms value to its bytes representation""" oc = oct(perms)[2:] return oc.encode('ascii') def escape_newlines(snippet): """Escape the newlines present in snippet according to git rules. New lines in git manifests are escaped by indenting the next line by one - space.""" + space. + + """ if b'\n' in snippet: return b'\n '.join(snippet.split(b'\n')) else: return snippet def directory_identifier(directory): """Return the intrinsic identifier for a directory. A directory's identifier is the tree sha1 à la git of a directory listing, using the following algorithm, which is equivalent to the git algorithm for trees: 1. Entries of the directory are sorted using the name (or the name with '/' - appended for directory entries) as key, in bytes order. + appended for directory entries) as key, in bytes order. 2. For each entry of the directory, the following bytes are output: - - the octal representation of the permissions for the entry - (stored in the 'perms' member), which is a representation of the - entry type: - b'100644' (int 33188) for files - b'100755' (int 33261) for executable files - b'120000' (int 40960) for symbolic links - b'40000' (int 16384) for directories - b'160000' (int 57344) for references to revisions - - an ascii space (b'\x20') - - the entry's name (as raw bytes), stored in the 'name' member - - a null byte (b'\x00') - - the 20 byte long identifier of the object pointed at by the entry, - stored in the 'target' member: - for files or executable files: their blob sha1_git - for symbolic links: the blob sha1_git of a file containing the - link destination - for directories: their intrinsic identifier - for revisions: their intrinsic identifier + + - the octal representation of the permissions for the entry (stored in + the 'perms' member), which is a representation of the entry type: + + - b'100644' (int 33188) for files + - b'100755' (int 33261) for executable files + - b'120000' (int 40960) for symbolic links + - b'40000' (int 16384) for directories + - b'160000' (int 57344) for references to revisions + + - an ascii space (b'\x20') + - the entry's name (as raw bytes), stored in the 'name' member + - a null byte (b'\x00') + - the 20 byte long identifier of the object pointed at by the entry, + stored in the 'target' member: + + - for files or executable files: their blob sha1_git + - for symbolic links: the blob sha1_git of a file containing the link + destination + - for directories: their intrinsic identifier + - for revisions: their intrinsic identifier (Note that there is no separator between entries) """ components = [] for entry in sorted(directory['entries'], key=_sort_key): components.extend([ _perms_to_bytes(entry['perms']), b'\x20', entry['name'], b'\x00', identifier_to_bytes(entry['target']), ]) return identifier_to_str(hash_git_data(b''.join(components), 'tree')) def format_date(date): """Convert a date object into an UTC timestamp encoded as ascii bytes. Git stores timestamps as an integer number of seconds since the UNIX epoch. However, Software Heritage stores timestamps as an integer number of microseconds (postgres type "datetime with timezone"). Therefore, we print timestamps with no microseconds as integers, and timestamps with microseconds as floating point values. We elide the trailing zeroes from microsecond values, to "future-proof" our representation if we ever need more precision in timestamps. """ if not isinstance(date, dict): raise ValueError('format_date only supports dicts, %r received' % date) seconds = date.get('seconds', 0) microseconds = date.get('microseconds', 0) if not microseconds: return str(seconds).encode() else: float_value = ('%d.%06d' % (seconds, microseconds)) return float_value.rstrip('0').encode() @lru_cache() def format_offset(offset, negative_utc=None): """Convert an integer number of minutes into an offset representation. The offset representation is [+-]hhmm where: - hh is the number of hours; - mm is the number of minutes. + + - hh is the number of hours; + - mm is the number of minutes. A null offset is represented as +0000. """ if offset < 0 or offset == 0 and negative_utc: sign = '-' else: sign = '+' hours = abs(offset) // 60 minutes = abs(offset) % 60 t = '%s%02d%02d' % (sign, hours, minutes) return t.encode() def normalize_timestamp(time_representation): """Normalize a time representation for processing by Software Heritage This function supports a numeric timestamp (representing a number of - seconds since the UNIX epoch, 1970-01-01 at 00:00 UTC), a datetime.datetime - object (with timezone information), or a normalized Software - Heritage time representation (idempotency). + seconds since the UNIX epoch, 1970-01-01 at 00:00 UTC), a + :obj:`datetime.datetime` object (with timezone information), or a + normalized Software Heritage time representation (idempotency). Args: time_representation: the representation of a timestamp - Returns: a normalized dictionary with three keys + Returns: + dict: a normalized dictionary with three keys: + + - timestamp: a dict with two optional keys: + + - seconds: the integral number of seconds since the UNIX epoch + - microseconds: the integral number of microseconds - - timestamp: a dict with two optional keys: - - seconds: the integral number of seconds since the UNIX epoch - - microseconds: the integral number of microseconds - - offset: the timezone offset as a number of minutes relative to UTC - - negative_utc: a boolean representing whether the offset is -0000 when - offset = 0. + - offset: the timezone offset as a number of minutes relative to + UTC + - negative_utc: a boolean representing whether the offset is -0000 + when offset = 0. """ if time_representation is None: return None negative_utc = False if isinstance(time_representation, dict): ts = time_representation['timestamp'] if isinstance(ts, dict): seconds = ts.get('seconds', 0) microseconds = ts.get('microseconds', 0) elif isinstance(ts, int): seconds = ts microseconds = 0 else: raise ValueError( 'normalize_timestamp received non-integer timestamp member:' ' %r' % ts) offset = time_representation['offset'] if 'negative_utc' in time_representation: negative_utc = time_representation['negative_utc'] elif isinstance(time_representation, datetime.datetime): seconds = int(time_representation.timestamp()) microseconds = time_representation.microsecond utcoffset = time_representation.utcoffset() if utcoffset is None: raise ValueError( 'normalize_timestamp received datetime without timezone: %s' % time_representation) # utcoffset is an integer number of minutes seconds_offset = utcoffset.total_seconds() offset = int(seconds_offset) // 60 elif isinstance(time_representation, int): seconds = time_representation microseconds = 0 offset = 0 else: raise ValueError( 'normalize_timestamp received non-integer timestamp:' ' %r' % time_representation) return { 'timestamp': { 'seconds': seconds, 'microseconds': microseconds, }, 'offset': offset, 'negative_utc': negative_utc, } def format_author(author): """Format the specification of an author. An author is either a byte string (passed unchanged), or a dict with three keys, fullname, name and email. If the fullname exists, return it; if it doesn't, we construct a fullname using the following heuristics: if the name value is None, we return the email in angle brackets, else, we return the name, a space, and the email in angle brackets. """ if isinstance(author, bytes) or author is None: return author if 'fullname' in author: return author['fullname'] ret = [] if author['name'] is not None: ret.append(author['name']) if author['email'] is not None: ret.append(b''.join([b'<', author['email'], b'>'])) return b' '.join(ret) def format_author_line(header, author, date_offset): """Format a an author line according to git standards. An author line has three components: - - a header, describing the type of author (author, committer, tagger) - - a name and email, which is an arbitrary bytestring - - optionally, a timestamp with UTC offset specification - The author line is formatted thus: + - a header, describing the type of author (author, committer, tagger) + - a name and email, which is an arbitrary bytestring + - optionally, a timestamp with UTC offset specification + + The author line is formatted thus:: `header` `name and email`[ `timestamp` `utc_offset`] The timestamp is encoded as a (decimal) number of seconds since the UNIX epoch (1970-01-01 at 00:00 UTC). As an extension to the git format, we support fractional timestamps, using a dot as the separator for the decimal part. The utc offset is a number of minutes encoded as '[+-]HHMM'. Note some tools can pass a negative offset corresponding to the UTC timezone ('-0000'), which is valid and is encoded as such. For convenience, this function returns the whole line with its trailing newline. Args: header: the header of the author line (one of 'author', 'committer', - 'tagger') + 'tagger') author: an author specification (dict with two bytes values: name and - email, or byte value) + email, or byte value) date_offset: a normalized date/time representation as returned by - `normalize_timestamp`. + :func:`normalize_timestamp`. Returns: the newline-terminated byte string containing the author line """ ret = [header.encode(), b' ', escape_newlines(format_author(author))] date_offset = normalize_timestamp(date_offset) if date_offset is not None: date_f = format_date(date_offset['timestamp']) offset_f = format_offset(date_offset['offset'], date_offset['negative_utc']) ret.extend([b' ', date_f, b' ', offset_f]) ret.append(b'\n') return b''.join(ret) def revision_identifier(revision): """Return the intrinsic identifier for a revision. The fields used for the revision identifier computation are: - - directory - - parents - - author - - author_date - - committer - - committer_date - - metadata -> extra_headers - - message + + - directory + - parents + - author + - author_date + - committer + - committer_date + - metadata -> extra_headers + - message A revision's identifier is the 'git'-checksum of a commit manifest - constructed as follows (newlines are a single ASCII newline character): - - ``` - tree - [for each parent in parents] - parent - [end for each parents] - author - committer - [for each key, value in extra_headers] - - [end for each extra_headers] - - - ``` + constructed as follows (newlines are a single ASCII newline character):: + + tree + [for each parent in parents] + parent + [end for each parents] + author + committer + [for each key, value in extra_headers] + + [end for each extra_headers] + + The directory identifier is the ascii representation of its hexadecimal encoding. - Author and committer are formatted with the `format_author` function. - Dates are formatted with the `format_date_offset` function. + Author and committer are formatted with the :func:`format_author` function. + Dates are formatted with the :func:`format_offset` function. Extra headers are an ordered list of [key, value] pairs. Keys are strings and get encoded to utf-8 for identifier computation. Values are either byte strings, unicode strings (that get encoded to utf-8), or integers (that get encoded to their utf-8 decimal representation). Multiline extra header values are escaped by indenting the continuation lines with one ascii space. If the message is None, the manifest ends with the last header. Else, the message is appended to the headers after an empty line. The checksum of the full manifest is computed using the 'commit' git object type. """ components = [ b'tree ', identifier_to_str(revision['directory']).encode(), b'\n', ] for parent in revision['parents']: if parent: components.extend([ b'parent ', identifier_to_str(parent).encode(), b'\n', ]) components.extend([ format_author_line('author', revision['author'], revision['date']), format_author_line('committer', revision['committer'], revision['committer_date']), ]) # Handle extra headers metadata = revision.get('metadata') if not metadata: metadata = {} for key, value in metadata.get('extra_headers', []): # Integer values: decimal representation if isinstance(value, int): value = str(value).encode('utf-8') # Unicode string values: utf-8 encoding if isinstance(value, str): value = value.encode('utf-8') # encode the key to utf-8 components.extend([key.encode('utf-8'), b' ', escape_newlines(value), b'\n']) if revision['message'] is not None: components.extend([b'\n', revision['message']]) commit_raw = b''.join(components) return identifier_to_str(hash_git_data(commit_raw, 'commit')) def target_type_to_git(target_type): """Convert a software heritage target type to a git object type""" return { 'content': b'blob', 'directory': b'tree', 'revision': b'commit', 'release': b'tag', }[target_type] def release_identifier(release): """Return the intrinsic identifier for a release.""" components = [ b'object ', identifier_to_str(release['target']).encode(), b'\n', b'type ', target_type_to_git(release['target_type']), b'\n', b'tag ', release['name'], b'\n', ] if 'author' in release and release['author']: components.append( format_author_line('tagger', release['author'], release['date']) ) if release['message'] is not None: components.extend([b'\n', release['message']]) return identifier_to_str(hash_git_data(b''.join(components), 'tag'))