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
« 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 -*-
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#============================================================================#
21''' Information about package distribution. '''
24from . import __
25from . import exceptions as _exceptions
26from . import io as _io
29class Information( __.immut.DataclassObject ):
30 ''' Information about a package distribution. '''
32 name: str
33 location: __.Path
34 editable: bool
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 )
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
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
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
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
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' )