Coverage for sources/appcore/distribution.py: 100%

55 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-09 19:57 +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''' Information about package distribution. ''' 

22 

23 

24from . import __ 

25from . import exceptions as _exceptions 

26from . import io as _io 

27 

28 

29class Information( __.immut.DataclassObject ): 

30 ''' Information about a package distribution. ''' 

31 

32 name: str 

33 location: __.Path 

34 editable: bool 

35 

36 @classmethod 

37 async def prepare( 

38 selfclass, package: str, exits: __.ctxl.AsyncExitStack, 

39 project_anchor: __.Absential[ __.Path ] = __.absent, 

40 ) -> __.typx.Self: 

41 ''' Acquires information about our package distribution. ''' 

42 import sys 

43 # Detect PyInstaller bundle. 

44 if getattr( sys, 'frozen', False ) and hasattr( sys, '_MEIPASS' ): 

45 project_anchor = __.Path( # pragma: no cover 

46 getattr( sys, '_MEIPASS' ) ) 

47 # TODO: Python 3.12: importlib.metadata 

48 from importlib_metadata import packages_distributions 

49 # https://github.com/pypa/packaging-problems/issues/609 

50 name = packages_distributions( ).get( package ) 

51 if name is None: # Development sources rather than distribution. 

52 editable = True # Implies no use of importlib.resources. 

53 if __.is_absent( project_anchor ): 

54 project_anchor = _discover_invoker_location( ) 

55 location, name = ( 

56 await _acquire_development_information( 

57 project_anchor = project_anchor ) ) 

58 else: 

59 editable = False 

60 name = name[ 0 ] 

61 location = await _acquire_production_location( package, exits ) 

62 return selfclass( 

63 editable = editable, location = location, name = name ) 

64 

65 def provide_data_location( self, *appendages: str ) -> __.Path: 

66 ''' Provides location of distribution data. ''' 

67 base = self.location / 'data' 

68 if appendages: return base.joinpath( *appendages ) 

69 return base 

70 

71 

72async def _acquire_development_information( 

73 project_anchor: __.Path 

74) -> tuple[ __.Path, str ]: 

75 location = _locate_pyproject( project_anchor ) 

76 pyproject = await _io.acquire_text_file_async( 

77 location / 'pyproject.toml', deserializer = __.tomli.loads ) 

78 name = pyproject[ 'project' ][ 'name' ] 

79 return location, name 

80 

81 

82async def _acquire_production_location( 

83 package: str, exits: __.ctxl.AsyncExitStack 

84) -> __.Path: 

85 # TODO: Python 3.12: importlib.resources 

86 from importlib_resources import files, as_file # pyright: ignore 

87 # Extract package contents to temporary directory, if necessary. 

88 return exits.enter_context( 

89 as_file( files( package ) ) ) # pyright: ignore 

90 

91 

92def _discover_invoker_location( ) -> __.Path: 

93 ''' Discovers file path of caller for project root detection. ''' 

94 import inspect 

95 package_location = __.Path( __file__ ).parent.resolve( ) 

96 python_location = __.Path( __.sys.executable ).parent.parent.resolve( ) 

97 frame = inspect.currentframe( ) 

98 if frame is None: return __.Path.cwd( ) 

99 # Walk up the call stack to find frame outside of this package. 

100 while True: 

101 frame = frame.f_back 

102 if frame is None: break # pragma: no cover 

103 location = __.Path( frame.f_code.co_filename).resolve( ) 

104 # Skip frames within this package and Python installation. 

105 if location.is_relative_to( package_location ): # pragma: no cover 

106 continue 

107 if location.is_relative_to( python_location ): # pragma: no cover 

108 continue 

109 return location.parent 

110 # Fallback location is current working directory. 

111 return __.Path.cwd( ) # pragma: no cover 

112 

113 

114def _locate_pyproject( project_anchor: __.Path ) -> __.Path: 

115 ''' Finds project manifest, if it exists. Errors otherwise. ''' 

116 initial = project_anchor.resolve( ) 

117 current = initial if initial.is_dir( ) else initial.parent 

118 limits: set[ __.Path ] = set( ) 

119 for limits_variable in ( 'GIT_CEILING_DIRECTORIES', ): 

120 limits_value = __.os.environ.get( limits_variable ) 

121 if not limits_value: continue # pragma: no cover 

122 limits.update( # pragma: no cover 

123 __.Path( limit ).resolve( ) 

124 for limit in limits_value.split( ':' ) if limit.strip( ) ) 

125 while current != current.parent: # Not at filesystem root 

126 if ( current / 'pyproject.toml' ).exists( ): 

127 return current 

128 if current in limits: 

129 raise _exceptions.FileLocateFailure( # noqa: TRY003 # pragma: no cover 

130 'project root discovery', 'pyproject.toml' ) 

131 current = current.parent 

132 raise _exceptions.FileLocateFailure( # noqa: TRY003 # pragma: no cover 

133 'project root discovery', 'pyproject.toml' )