Coverage for lobster/tools/core/online_report/path_to_url_converter.py: 37%

53 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-19 07:49 +0000

1# LOBSTER - Lightweight Open BMW Software Traceability Evidence Report 

2# Copyright (C) 2025 Bayerische Motoren Werke Aktiengesellschaft (BMW AG) 

3# 

4# This program is free software: you can redistribute it and/or modify 

5# it under the terms of the GNU Affero General Public License as 

6# published by the Free Software Foundation, either version 3 of the 

7# License, or (at your option) any later version. 

8# 

9# This program is distributed in the hope that it will be useful, but 

10# WITHOUT ANY WARRANTY; without even the implied warranty of 

11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU 

12# Affero General Public License for more details. 

13# 

14# You should have received a copy of the GNU Affero General Public 

15# License along with this program. If not, see 

16# <https://www.gnu.org/licenses/>. 

17 

18from dataclasses import dataclass 

19import logging 

20import os 

21from pathlib import Path 

22from typing import Tuple 

23from urllib.parse import quote 

24from git import Repo, Submodule 

25 

26 

27@dataclass 

28class UrlParts: 

29 url_start: str 

30 commit_hash: str 

31 path_html: str 

32 

33 

34class NotInsideRepositoryException(Exception): 

35 """Exception raised when a path is not inside a Git repository.""" 

36 

37 

38class PathToUrlConverter: 

39 

40 def __init__(self, repo_path, url_base: str): 

41 self._logger = logging.getLogger(self.__class__.__name__) 

42 self._repo = Repo(repo_path) 

43 self._url_base = url_base 

44 for submodule in self._repo.submodules: 

45 self._logger.info("Name: %s, Path: %s, URL: %s", 

46 submodule.name, submodule.path, submodule.url) 

47 

48 def close(self) -> None: 

49 """Terminate Git helper processes, which keep the repo directory locked 

50 otherwise.""" 

51 self._repo.close() 

52 

53 @staticmethod 

54 def _path_to_html_format(path: Path) -> str: 

55 """Convert a file path to a URL format suitable for HTML links.""" 

56 return quote(path.as_posix()) 

57 

58 def _get_submodule_full_url(self, submodule_url: str) -> str: 

59 """Get the full URL for a submodule.""" 

60 return f"{self._url_base.rstrip('/')}/{submodule_url.lstrip('/')}" 

61 

62 def path_to_url(self, path: Path, commit_id: str) -> UrlParts: 

63 """Convert a path to a URL based on the submodule configuration. 

64 

65 The path must be nested inside a submodule of the repository. 

66 """ 

67 path = path.resolve() 

68 try: 

69 submodule, relative_path = self._get_submodule_and_relative_path(path) 

70 url_start = self._get_submodule_full_url(submodule.url) 

71 commit_hash = submodule.hexsha 

72 except KeyError as e: 

73 self._logger.error("Path '%s' is not inside a submodule: %s", path, e) 

74 # Path is not inside a submodule — use main repo 

75 commit_hash = commit_id 

76 try: 

77 relative_path = path.resolve().relative_to(self._repo.working_tree_dir) 

78 except ValueError as value_error: 

79 raise NotInsideRepositoryException( 

80 f"Path '{path}' is not inside the repository '" 

81 f"{self._repo.working_tree_dir}'!", 

82 ) from value_error 

83 

84 url_start = self._url_base.rstrip("/") 

85 

86 path_html = self._path_to_html_format(relative_path) 

87 return UrlParts(url_start=url_start, commit_hash=commit_hash, 

88 path_html=path_html) 

89 

90 def _get_submodule_and_relative_path(self, path: Path) -> Tuple[Submodule, Path]: 

91 """Get the submodule and the relative path (relative to the submodule folder, 

92 not to repo root) for a given path. 

93 

94 Example: 

95 path = "/home/<user>/git/swh/repo/domains/driving/folder1/folder2/file.cpp" 

96 submodule.path = "domains/driving" 

97 

98 return Submodule instance of "domains/driving", Path of "folder1/folder2/ 

99 file.cpp" 

100 """ 

101 path = path.resolve() 

102 for submodule in self._repo.submodules: 

103 submodule_path = (Path(str(self._repo.working_tree_dir)) / 

104 str(submodule.path).replace("/", os.sep)) 

105 try: 

106 rel_path = path.relative_to(submodule_path) 

107 return submodule, rel_path 

108 except ValueError: 

109 continue 

110 

111 raise KeyError(f"No submodule found for path: {path}")