Coverage for sources/mimeogram/apply.py: 89%

85 statements  

« prev     ^ index     » next       coverage.py v7.7.1, created at 2025-03-29 23:11 +0000

1# vim: set filetype=python fileencoding=utf-8: 

2# -*- coding: utf-8 -*- 

3 

4#============================================================================# 

5# # 

6# Licensed under the Apache License, Version 2.0 (the "License"); # 

7# you may not use this file except in compliance with the License. # 

8# You may obtain a copy of the License at # 

9# # 

10# http://www.apache.org/licenses/LICENSE-2.0 # 

11# # 

12# Unless required by applicable law or agreed to in writing, software # 

13# distributed under the License is distributed on an "AS IS" BASIS, # 

14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # 

15# See the License for the specific language governing permissions and # 

16# limitations under the License. # 

17# # 

18#============================================================================# 

19 

20 

21''' Application of mimeograms. ''' 

22# TODO? Use BSD sysexits. 

23 

24 

25from __future__ import annotations 

26 

27from . import __ 

28from . import interfaces as _interfaces 

29from . import parts as _parts 

30from . import updaters as _updaters 

31 

32 

33_scribe = __.produce_scribe( __name__ ) 

34 

35 

36class Command( 

37 _interfaces.CliCommand, 

38 decorators = ( __.standard_dataclass, __.standard_tyro_class ), 

39): 

40 ''' Applies mimeogram to filesystem locations. ''' 

41 

42 source: __.typx.Annotated[ 

43 str, 

44 __.typx.Doc( 

45 ''' Source file for mimeogram. 

46 

47 Defaults to stdin if '--clip' not specified. 

48 ''' ), 

49 ] = '-' 

50 clip: __.typx.Annotated[ 

51 __.typx.Optional[ bool ], 

52 __.typx.Doc( 

53 ''' Read mimeogram from clipboard instead of file or stdin. ''' ), 

54 __.tyro.conf.arg( aliases = ( '--clipboard', '--from-clipboard' ) ), 

55 ] = None 

56 mode: __.typx.Annotated[ 

57 __.typx.Optional[ _updaters.ReviewModes ], 

58 __.typx.Doc( 

59 ''' Controls how changes are reviewed. 

60 

61 'silent': Apply without review. 

62 'partitive': Review each change interactively. 

63 

64 Partitive, if not specified and on a terminal. 

65 Silent, if not specified and not on a terminal. 

66 ''' ), 

67 __.tyro.conf.arg( aliases = ( '--review-mode', ) ), 

68 ] = None 

69 base: __.typx.Annotated[ 

70 __.typx.Optional[ __.Path ], 

71 __.typx.Doc( 

72 ''' Base directory for relative locations. 

73 

74 Defaults to current working directory. 

75 ''' ), 

76 __.tyro.conf.arg( aliases = ( '--base-directory', ) ), 

77 ] = None 

78 force: __.typx.Annotated[ 

79 __.typx.Optional[ bool ], 

80 __.typx.Doc( '''Override protected path checks.''' ), 

81 ] = None 

82 

83 async def __call__( self, auxdata: __.Globals ) -> None: 

84 ''' Executes command to apply mimeogram. ''' 

85 await apply( auxdata, self ) 

86 

87 def provide_configuration_edits( self ) -> __.DictionaryEdits: 

88 ''' Provides edits against configuration from options. ''' 

89 edits: list[ __.DictionaryEdit ] = [ ] 

90 if None is not self.clip: 

91 edits.append( __.SimpleDictionaryEdit( # pyright: ignore 

92 address = ( 'apply', 'from-clipboard' ), value = self.clip ) ) 

93 if None is not self.force: 

94 edits.append( __.SimpleDictionaryEdit( # pyright: ignore 

95 address = ( 'update-parts', 'disable-protections' ), 

96 value = self.force ) ) 

97 return tuple( edits ) 

98 

99 

100class ContentAcquirer( # pylint: disable=invalid-metaclass 

101 __.typx.Protocol, 

102 metaclass = __.ImmutableStandardProtocolDataclass, 

103 decorators = ( __.standard_dataclass, __.typx.runtime_checkable ), 

104): 

105 ''' Acquires content for apply command. ''' 

106 

107 @__.abc.abstractmethod 

108 def stdin_is_tty( self ) -> bool: 

109 ''' Checks if input is from a terminal. ''' 

110 raise NotImplementedError 

111 

112 @__.abc.abstractmethod 

113 async def acquire_clipboard( self ) -> str: 

114 ''' Acquires content from clipboard. ''' 

115 raise NotImplementedError 

116 

117 @__.abc.abstractmethod 

118 async def acquire_file( self, path: str | __.Path ) -> str: 

119 ''' Acquires content from file. ''' 

120 raise NotImplementedError 

121 

122 @__.abc.abstractmethod 

123 async def acquire_stdin( self ) -> str: 

124 ''' Acquires content from standard input. ''' 

125 raise NotImplementedError 

126 

127 

128class StandardContentAcquirer( 

129 ContentAcquirer, decorators = ( __.standard_dataclass, ) 

130): 

131 ''' Standard implementation of content acquisition. ''' 

132 

133 def stdin_is_tty( self ) -> bool: 

134 return __.sys.stdin.isatty( ) 

135 

136 async def acquire_clipboard( self ) -> str: 

137 from pyperclip import paste 

138 return paste( ) 

139 

140 async def acquire_file( self, path: str | __.Path ) -> str: 

141 return await __.acquire_text_file_async( path ) 

142 

143 async def acquire_stdin( self ) -> str: 

144 return __.sys.stdin.read( ) 

145 

146 

147async def apply( # pylint: disable=too-complex 

148 auxdata: __.Globals, 

149 command: Command, 

150 *, 

151 acquirer: __.Absential[ ContentAcquirer ] = __.absent, 

152 parser: __.Absential[ 

153 __.cabc.Callable[ [ str ], __.cabc.Sequence[ _parts.Part ] ] 

154 ] = __.absent, 

155 updater: __.Absential[ 

156 __.cabc.Callable[ 

157 [ __.Globals, 

158 __.cabc.Sequence[ _parts.Part ], 

159 _updaters.ReviewModes ], 

160 __.cabc.Coroutine[ None, None, None ] 

161 ] 

162 ] = __.absent, 

163) -> __.typx.Never: 

164 ''' Applies mimeogram. ''' 

165 if __.is_absent( acquirer ): 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true

166 acquirer = StandardContentAcquirer( ) 

167 if __.is_absent( parser ): 

168 from .parsers import parse as parser 

169 if __.is_absent( updater ): 

170 from .updaters import update as updater 

171 review_mode = _determine_review_mode( command, acquirer ) 

172 with __.report_exceptions( 

173 _scribe, "Could not acquire mimeogram to apply." 

174 ): mgtext = await _acquire( auxdata, command, acquirer ) 

175 if not mgtext: 

176 _scribe.error( "Cannot apply empty mimeogram." ) 

177 raise SystemExit( 1 ) 

178 with __.report_exceptions( _scribe, "Could not parse mimeogram." ): 

179 parts = parser( mgtext ) 

180 nomargs: dict[ str, __.typx.Any ] = { } 

181 if command.base: nomargs[ 'base' ] = command.base 

182 with __.report_exceptions( _scribe, "Could not apply mimeogram." ): 

183 await updater( auxdata, parts, review_mode, **nomargs ) 

184 # TODO: If all parts ignored or inapplicable, then do not mention success. 

185 _scribe.info( "Successfully applied mimeogram" ) 

186 raise SystemExit( 0 ) 

187 

188 

189async def _acquire( 

190 auxdata: __.Globals, cmd: Command, acquirer: ContentAcquirer 

191) -> str: 

192 ''' Acquires content to parse from clipboard, file, or stdin. ''' 

193 options = auxdata.configuration.get( 'apply', { } ) 

194 if options.get( 'from-clipboard', False ): 

195 content = await acquirer.acquire_clipboard( ) 

196 if not content: 

197 _scribe.error( "Clipboard is empty." ) 

198 raise SystemExit( 1 ) 

199 _scribe.debug( 

200 "Read {} characters from clipboard.".format( len( content ) ) ) 

201 return content 

202 match cmd.source: 

203 case '-': return await acquirer.acquire_stdin( ) 

204 case _: return await acquirer.acquire_file( cmd.source ) 

205 

206 

207def _determine_review_mode( 

208 command: Command, acquirer: ContentAcquirer 

209) -> _updaters.ReviewModes: 

210 on_tty = acquirer.stdin_is_tty( ) 

211 if command.mode is None: 

212 if on_tty: return _updaters.ReviewModes.Partitive 

213 return _updaters.ReviewModes.Silent 

214 if not on_tty and command.mode is not _updaters.ReviewModes.Silent: 

215 _scribe.error( "Cannot use an interactive mode without terminal." ) 

216 raise SystemExit( 1 ) 

217 return command.mode