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

85 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-02-22 03:16 +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, # TODO: str | Path 

44 __.tyro.conf.arg( 

45 help = ( 

46 "Source file for mimeogram. " 

47 "Defaults to stdin if '--clip' not specified." ) ), 

48 ] = '-' 

49 clip: __.typx.Annotated[ 

50 __.typx.Optional[ bool ], 

51 __.tyro.conf.arg( 

52 aliases = ( '--clipboard', '--from-clipboard' ), 

53 help = "Read mimeogram from clipboard instead of file or stdin." ), 

54 ] = None 

55 mode: __.typx.Annotated[ 

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

57 __.tyro.conf.arg( 

58 aliases = ( '--review-mode', ), 

59 help = ( 

60 "Controls how changes are reviewed. " 

61 "'silent': Apply without review. " 

62 "'partitive': Review each change interactively. " 

63 "Partitive, if not specified and on a terminal. " 

64 "Silent, if not specified and not on a terminal." ) ), 

65 ] = None 

66 base: __.typx.Annotated[ 

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

68 __.tyro.conf.arg( 

69 aliases = ( '--base-directory', ), 

70 help = ( 

71 "Base directory for relative locations. " 

72 "Defaults to current working directory." ) ), 

73 ] = None 

74 force: __.typx.Annotated[ 

75 __.typx.Optional[ bool ], 

76 __.tyro.conf.arg( 

77 help = 'Override protected path checks.' ), 

78 ] = None 

79 

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

81 ''' Executes command to apply mimeogram. ''' 

82 await apply( auxdata, self ) 

83 

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

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

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

87 if None is not self.clip: 

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

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

90 if None is not self.force: 

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

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

93 value = self.force ) ) 

94 return tuple( edits ) 

95 

96 

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

98 __.typx.Protocol, 

99 metaclass = __.ImmutableStandardProtocolDataclass, 

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

101): 

102 ''' Acquires content for apply command. ''' 

103 

104 @__.abc.abstractmethod 

105 def stdin_is_tty( self ) -> bool: 

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

107 raise NotImplementedError 

108 

109 @__.abc.abstractmethod 

110 async def acquire_clipboard( self ) -> str: 

111 ''' Acquires content from clipboard. ''' 

112 raise NotImplementedError 

113 

114 @__.abc.abstractmethod 

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

116 ''' Acquires content from file. ''' 

117 raise NotImplementedError 

118 

119 @__.abc.abstractmethod 

120 async def acquire_stdin( self ) -> str: 

121 ''' Acquires content from standard input. ''' 

122 raise NotImplementedError 

123 

124 

125class StandardContentAcquirer( 

126 ContentAcquirer, decorators = ( __.standard_dataclass, ) 

127): 

128 ''' Standard implementation of content acquisition. ''' 

129 

130 def stdin_is_tty( self ) -> bool: 

131 return __.sys.stdin.isatty( ) 

132 

133 async def acquire_clipboard( self ) -> str: 

134 from pyperclip import paste 

135 return paste( ) 

136 

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

138 return await __.acquire_text_file_async( path ) 

139 

140 async def acquire_stdin( self ) -> str: 

141 return __.sys.stdin.read( ) 

142 

143 

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

145 auxdata: __.Globals, 

146 command: Command, 

147 *, 

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

149 parser: __.Absential[ 

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

151 ] = __.absent, 

152 updater: __.Absential[ 

153 __.cabc.Callable[ 

154 [ __.Globals, 

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

156 _updaters.ReviewModes ], 

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

158 ] 

159 ] = __.absent, 

160) -> __.typx.Never: 

161 ''' Applies mimeogram. ''' 

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

163 acquirer = StandardContentAcquirer( ) 

164 if __.is_absent( parser ): 

165 from .parsers import parse as parser 

166 if __.is_absent( updater ): 

167 from .updaters import update as updater 

168 review_mode = _determine_review_mode( command, acquirer ) 

169 with __.report_exceptions( 

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

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

172 if not mgtext: 

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

174 raise SystemExit( 1 ) 

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

176 parts = parser( mgtext ) 

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

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

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

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

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

182 _scribe.info( "Successfully applied mimeogram" ) 

183 raise SystemExit( 0 ) 

184 

185 

186async def _acquire( 

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

188) -> str: 

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

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

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

192 content = await acquirer.acquire_clipboard( ) 

193 if not content: 

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

195 raise SystemExit( 1 ) 

196 _scribe.debug( 

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

198 return content 

199 match cmd.source: 

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

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

202 

203 

204def _determine_review_mode( 

205 command: Command, acquirer: ContentAcquirer 

206) -> _updaters.ReviewModes: 

207 on_tty = acquirer.stdin_is_tty( ) 

208 if command.mode is None: 

209 if on_tty: return _updaters.ReviewModes.Partitive 

210 return _updaters.ReviewModes.Silent 

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

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

213 raise SystemExit( 1 ) 

214 return command.mode