Coverage for src/dependenpy/_internal/cli.py: 83.48%

85 statements  

« prev     ^ index     » next       coverage.py v7.10.5, created at 2025-08-24 18:36 +0200

1# Why does this file exist, and why not put this in `__main__`? 

2# 

3# You might be tempted to import things from `__main__` later, 

4# but that will cause problems: the code will get executed twice: 

5# 

6# - When you run `python -m dependenpy` python will execute 

7# `__main__.py` as a script. That means there won't be any 

8# `dependenpy.__main__` in `sys.modules`. 

9# - When you import `__main__` it will get executed again (as a module) because 

10# there's no `dependenpy.__main__` in `sys.modules`. 

11 

12from __future__ import annotations 

13 

14import argparse 

15import sys 

16from contextlib import contextmanager 

17from typing import TYPE_CHECKING, Any, TextIO 

18 

19from colorama import init 

20 

21from dependenpy._internal import debug 

22from dependenpy._internal.dsm import DSM 

23from dependenpy._internal.helpers import CSV, FORMAT, JSON, guess_depth 

24 

25if TYPE_CHECKING: 

26 from collections.abc import Iterator, Sequence 

27 

28 

29class _DebugInfo(argparse.Action): 

30 def __init__(self, nargs: int | str | None = 0, **kwargs: Any) -> None: 

31 super().__init__(nargs=nargs, **kwargs) 

32 

33 def __call__(self, *args: Any, **kwargs: Any) -> None: # noqa: ARG002 

34 debug._print_debug_info() 

35 sys.exit(0) 

36 

37 

38def get_parser() -> argparse.ArgumentParser: 

39 """Return the CLI argument parser. 

40 

41 Returns: 

42 An argparse parser. 

43 """ 

44 parser = argparse.ArgumentParser( 

45 prog="dependenpy", 

46 add_help=False, 

47 description="Command line tool for dependenpy Python package.", 

48 ) 

49 mxg = parser.add_mutually_exclusive_group(required=False) 

50 

51 parser.add_argument( 

52 "packages", 

53 metavar="PACKAGES", 

54 nargs=argparse.ONE_OR_MORE, 

55 help="The package list. Can be a comma-separated list. Each package " 

56 "must be either a valid path or a package in PYTHONPATH.", 

57 ) 

58 parser.add_argument( 

59 "-d", 

60 "--depth", 

61 default=None, 

62 type=int, 

63 dest="depth", 

64 help="Specify matrix or graph depth. Default: best guess.", 

65 ) 

66 parser.add_argument( 

67 "-f", 

68 "--format", 

69 choices=FORMAT, 

70 default="text", 

71 dest="format", 

72 help="Output format. Default: text.", 

73 ) 

74 mxg.add_argument( 

75 "-g", 

76 "--show-graph", 

77 action="store_true", 

78 dest="graph", 

79 default=False, 

80 help="Show the graph (no text format). Default: false.", 

81 ) 

82 parser.add_argument( 

83 "-G", 

84 "--greedy", 

85 action="store_true", 

86 dest="greedy", 

87 default=False, 

88 help="Explore subdirectories even if they do not contain an " 

89 "__init__.py file. Can make execution slower. Default: false.", 

90 ) 

91 parser.add_argument( 

92 "-h", 

93 "--help", 

94 action="help", 

95 default=argparse.SUPPRESS, 

96 help="Show this help message and exit.", 

97 ) 

98 parser.add_argument( 

99 "-i", 

100 "--indent", 

101 default=None, 

102 type=int, 

103 dest="indent", 

104 help="Specify output indentation. CSV will never be indented. " 

105 "Text will always have new-lines. JSON can be minified with " 

106 "a negative value. Default: best guess.", 

107 ) 

108 mxg.add_argument( 

109 "-l", 

110 "--show-dependencies-list", 

111 action="store_true", 

112 dest="dependencies", 

113 default=False, 

114 help="Show the dependencies list. Default: false.", 

115 ) 

116 mxg.add_argument( 

117 "-m", 

118 "--show-matrix", 

119 action="store_true", 

120 dest="matrix", 

121 default=False, 

122 help="Show the matrix. Default: true unless -g, -l or -t.", 

123 ) 

124 parser.add_argument( 

125 "-o", 

126 "--output", 

127 action="store", 

128 dest="output", 

129 default=sys.stdout, 

130 help="Output to given file. Default: stdout.", 

131 ) 

132 mxg.add_argument( 

133 "-t", 

134 "--show-treemap", 

135 action="store_true", 

136 dest="treemap", 

137 default=False, 

138 help="Show the treemap (work in progress). Default: false.", 

139 ) 

140 parser.add_argument( 

141 "-v", 

142 "--version", 

143 action="version", 

144 version=f"dependenpy {debug._get_version()}", 

145 help="Show the current version of the program and exit.", 

146 ) 

147 parser.add_argument( 

148 "-z", 

149 "--zero", 

150 dest="zero", 

151 default="0", 

152 help="Character to use for cells with value=0 (text matrix display only).", 

153 ) 

154 

155 parser.add_argument("--debug-info", action=_DebugInfo, help="Print debug information.") 

156 return parser 

157 

158 

159@contextmanager 

160def _open_if_str(output: str | TextIO) -> Iterator[TextIO]: 

161 if isinstance(output, str): 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true

162 with open(output, "w") as fd: 

163 yield fd 

164 else: 

165 yield output 

166 

167 

168def _get_indent(opts: argparse.Namespace) -> int | None: 

169 if opts.indent is None: 169 ↛ 173line 169 didn't jump to line 173 because the condition on line 169 was always true

170 if opts.format == CSV: 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true

171 return 0 

172 return 2 

173 if opts.indent < 0 and opts.format == JSON: 

174 # special case for json.dumps indent argument 

175 return None 

176 return opts.indent 

177 

178 

179def _get_depth(opts: argparse.Namespace, packages: Sequence[str]) -> int: 

180 return opts.depth or guess_depth(packages) 

181 

182 

183def _get_packages(opts: argparse.Namespace) -> list[str]: 

184 packages = [] 

185 for arg in opts.packages: 

186 if "," in arg: 

187 for package in arg.split(","): 

188 if package not in packages: 

189 packages.append(package) 

190 elif arg not in packages: 190 ↛ 185line 190 didn't jump to line 185 because the condition on line 190 was always true

191 packages.append(arg) 

192 return packages 

193 

194 

195def _run(opts: argparse.Namespace, dsm: DSM) -> None: 

196 indent = _get_indent(opts) 

197 depth = _get_depth(opts, packages=dsm.base_packages) 

198 with _open_if_str(opts.output) as output: 

199 if opts.dependencies: 

200 dsm.print(format=opts.format, output=output, indent=indent) 

201 elif opts.matrix: 

202 dsm.print_matrix(format=opts.format, output=output, depth=depth, indent=indent, zero=opts.zero) 

203 elif opts.treemap: 203 ↛ 205line 203 didn't jump to line 205 because the condition on line 203 was always true

204 dsm.print_treemap(format=opts.format, output=output) 

205 elif opts.graph: 

206 dsm.print_graph(format=opts.format, output=output, depth=depth, indent=indent) 

207 

208 

209def main(args: list[str] | None = None) -> int: 

210 """Run the main program. 

211 

212 This function is executed when you type `dependenpy` or `python -m dependenpy`. 

213 

214 Parameters: 

215 args: Arguments passed from the command line. 

216 

217 Returns: 

218 An exit code. 0 (OK), 1 (dsm empty) or 2 (error). 

219 """ 

220 parser = get_parser() 

221 opts = parser.parse_args(args=args) 

222 if not (opts.matrix or opts.dependencies or opts.treemap or opts.graph): 

223 opts.matrix = True 

224 

225 dsm = DSM(*_get_packages(opts), build_tree=True, build_dependencies=True, enforce_init=not opts.greedy) 

226 if dsm.empty: 

227 return 1 

228 

229 # init colorama 

230 init() 

231 

232 try: 

233 _run(opts, dsm) 

234 except BrokenPipeError: 

235 # avoid traceback 

236 return 2 

237 

238 return 0