omdev 0.0.0.dev112__py3-none-any.whl → 0.0.0.dev113__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of omdev might be problematic. Click here for more details.

@@ -99,109 +99,6 @@ UnparsedVersionVar = ta.TypeVar('UnparsedVersionVar', bound=UnparsedVersion)
99
99
  CallableVersionOperator = ta.Callable[['Version', str], bool]
100
100
 
101
101
 
102
- ########################################
103
- # ../../git.py
104
-
105
-
106
- def git_clone_subtree(
107
- *,
108
- base_dir: str,
109
- repo_url: str,
110
- repo_dir: str,
111
- branch: ta.Optional[str] = None,
112
- rev: ta.Optional[str] = None,
113
- repo_subtrees: ta.Sequence[str],
114
- ) -> None:
115
- if not bool(branch) ^ bool(rev):
116
- raise ValueError('must set branch or rev')
117
-
118
- if isinstance(repo_subtrees, str):
119
- raise TypeError(repo_subtrees)
120
-
121
- git_opts = [
122
- '-c', 'advice.detachedHead=false',
123
- ]
124
-
125
- subprocess.check_call(
126
- [
127
- 'git',
128
- *git_opts,
129
- 'clone',
130
- '-n',
131
- '--depth=1',
132
- '--filter=tree:0',
133
- *(['-b', branch] if branch else []),
134
- '--single-branch',
135
- repo_url,
136
- repo_dir,
137
- ],
138
- cwd=base_dir,
139
- )
140
-
141
- rd = os.path.join(base_dir, repo_dir)
142
- subprocess.check_call(
143
- [
144
- 'git',
145
- *git_opts,
146
- 'sparse-checkout',
147
- 'set',
148
- '--no-cone',
149
- *repo_subtrees,
150
- ],
151
- cwd=rd,
152
- )
153
-
154
- subprocess.check_call(
155
- [
156
- 'git',
157
- *git_opts,
158
- 'checkout',
159
- *([rev] if rev else []),
160
- ],
161
- cwd=rd,
162
- )
163
-
164
-
165
- def get_git_revision(
166
- *,
167
- cwd: ta.Optional[str] = None,
168
- ) -> ta.Optional[str]:
169
- subprocess.check_output(['git', '--version'])
170
-
171
- if cwd is None:
172
- cwd = os.getcwd()
173
-
174
- if subprocess.run( # noqa
175
- [
176
- 'git',
177
- 'rev-parse',
178
- '--is-inside-work-tree',
179
- ],
180
- stdout=subprocess.PIPE,
181
- stderr=subprocess.PIPE,
182
- ).returncode:
183
- return None
184
-
185
- has_untracked = bool(subprocess.check_output([
186
- 'git',
187
- 'ls-files',
188
- '.',
189
- '--exclude-standard',
190
- '--others',
191
- ], cwd=cwd).decode().strip())
192
-
193
- dirty_rev = subprocess.check_output([
194
- 'git',
195
- 'describe',
196
- '--match=NeVeRmAtCh',
197
- '--always',
198
- '--abbrev=40',
199
- '--dirty',
200
- ], cwd=cwd).decode().strip()
201
-
202
- return dirty_rev + ('-untracked' if has_untracked else '')
203
-
204
-
205
102
  ########################################
206
103
  # ../../magic/magic.py
207
104
 
@@ -3735,126 +3632,6 @@ class RequirementsRewriter:
3735
3632
  return in_req
3736
3633
 
3737
3634
 
3738
- ########################################
3739
- # ../../revisions.py
3740
- """
3741
- TODO:
3742
- - omlish-lite, move to pyproject/
3743
- - vendor-lite wheel.wheelfile
3744
- """
3745
-
3746
-
3747
- ##
3748
-
3749
-
3750
- class GitRevisionAdder:
3751
- def __init__(
3752
- self,
3753
- revision: ta.Optional[str] = None,
3754
- output_suffix: ta.Optional[str] = None,
3755
- ) -> None:
3756
- super().__init__()
3757
- self._given_revision = revision
3758
- self._output_suffix = output_suffix
3759
-
3760
- @cached_nullary
3761
- def revision(self) -> str:
3762
- if self._given_revision is not None:
3763
- return self._given_revision
3764
- return check_non_empty_str(get_git_revision())
3765
-
3766
- REVISION_ATTR = '__revision__'
3767
-
3768
- def add_to_contents(self, dct: ta.Dict[str, bytes]) -> bool:
3769
- changed = False
3770
- for n in dct:
3771
- if not n.endswith('__about__.py'):
3772
- continue
3773
- src = dct[n].decode('utf-8')
3774
- lines = src.splitlines(keepends=True)
3775
- for i, l in enumerate(lines):
3776
- if l != f'{self.REVISION_ATTR} = None\n':
3777
- continue
3778
- lines[i] = f"{self.REVISION_ATTR} = '{self.revision()}'\n"
3779
- changed = True
3780
- dct[n] = ''.join(lines).encode('utf-8')
3781
- return changed
3782
-
3783
- def add_to_wheel(self, f: str) -> None:
3784
- if not f.endswith('.whl'):
3785
- raise Exception(f)
3786
- log.info('Scanning wheel %s', f)
3787
-
3788
- zis: ta.Dict[str, zipfile.ZipInfo] = {}
3789
- dct: ta.Dict[str, bytes] = {}
3790
- with WheelFile(f) as wf:
3791
- for zi in wf.filelist:
3792
- if zi.filename == wf.record_path:
3793
- continue
3794
- zis[zi.filename] = zi
3795
- dct[zi.filename] = wf.read(zi.filename)
3796
-
3797
- if self.add_to_contents(dct):
3798
- of = f[:-4] + (self._output_suffix or '') + '.whl'
3799
- log.info('Repacking wheel %s', of)
3800
- with WheelFile(of, 'w') as wf:
3801
- for n, d in dct.items():
3802
- log.info('Adding zipinfo %s', n)
3803
- wf.writestr(zis[n], d)
3804
-
3805
- def add_to_tgz(self, f: str) -> None:
3806
- if not f.endswith('.tar.gz'):
3807
- raise Exception(f)
3808
- log.info('Scanning tgz %s', f)
3809
-
3810
- tis: ta.Dict[str, tarfile.TarInfo] = {}
3811
- dct: ta.Dict[str, bytes] = {}
3812
- with tarfile.open(f, 'r:gz') as tf:
3813
- for ti in tf:
3814
- tis[ti.name] = ti
3815
- if ti.type == tarfile.REGTYPE:
3816
- with tf.extractfile(ti.name) as tif: # type: ignore
3817
- dct[ti.name] = tif.read()
3818
-
3819
- if self.add_to_contents(dct):
3820
- of = f[:-7] + (self._output_suffix or '') + '.tar.gz'
3821
- log.info('Repacking tgz %s', of)
3822
- with tarfile.open(of, 'w:gz') as tf:
3823
- for n, ti in tis.items():
3824
- log.info('Adding tarinfo %s', n)
3825
- if n in dct:
3826
- data = dct[n]
3827
- ti.size = len(data)
3828
- fo = io.BytesIO(data)
3829
- else:
3830
- fo = None
3831
- tf.addfile(ti, fileobj=fo)
3832
-
3833
- EXTS = ('.tar.gz', '.whl')
3834
-
3835
- def add_to_file(self, f: str) -> None:
3836
- if f.endswith('.whl'):
3837
- self.add_to_wheel(f)
3838
-
3839
- elif f.endswith('.tar.gz'):
3840
- self.add_to_tgz(f)
3841
-
3842
- def add_to(self, tgt: str) -> None:
3843
- log.info('Using revision %s', self.revision())
3844
-
3845
- if os.path.isfile(tgt):
3846
- self.add_to_file(tgt)
3847
-
3848
- elif os.path.isdir(tgt):
3849
- for dp, dns, fns in os.walk(tgt): # noqa
3850
- for f in fns:
3851
- if any(f.endswith(ext) for ext in self.EXTS):
3852
- self.add_to_file(os.path.join(dp, f))
3853
-
3854
-
3855
- #
3856
-
3857
-
3858
3635
  ########################################
3859
3636
  # ../../../omlish/lite/subprocesses.py
3860
3637
 
@@ -3980,39 +3757,436 @@ def subprocess_close(
3980
3757
 
3981
3758
 
3982
3759
  ########################################
3983
- # ../../interp/inspect.py
3984
-
3760
+ # ../../git.py
3761
+ """
3762
+ git status
3763
+ --porcelain=v1
3764
+ --ignore-submodules
3765
+ 2>/dev/null
3766
+ """
3985
3767
 
3986
- @dc.dataclass(frozen=True)
3987
- class InterpInspection:
3988
- exe: str
3989
- version: Version
3990
3768
 
3991
- version_str: str
3992
- config_vars: ta.Mapping[str, str]
3993
- prefix: str
3994
- base_prefix: str
3769
+ ##
3995
3770
 
3996
- @property
3997
- def opts(self) -> InterpOpts:
3998
- return InterpOpts(
3999
- threaded=bool(self.config_vars.get('Py_GIL_DISABLED')),
4000
- debug=bool(self.config_vars.get('Py_DEBUG')),
4001
- )
4002
3771
 
4003
- @property
4004
- def iv(self) -> InterpVersion:
4005
- return InterpVersion(
4006
- version=self.version,
4007
- opts=self.opts,
4008
- )
3772
+ def git_clone_subtree(
3773
+ *,
3774
+ base_dir: str,
3775
+ repo_url: str,
3776
+ repo_dir: str,
3777
+ branch: ta.Optional[str] = None,
3778
+ rev: ta.Optional[str] = None,
3779
+ repo_subtrees: ta.Sequence[str],
3780
+ ) -> None:
3781
+ if not bool(branch) ^ bool(rev):
3782
+ raise ValueError('must set branch or rev')
4009
3783
 
4010
- @property
4011
- def is_venv(self) -> bool:
4012
- return self.prefix != self.base_prefix
3784
+ if isinstance(repo_subtrees, str):
3785
+ raise TypeError(repo_subtrees)
4013
3786
 
3787
+ git_opts = [
3788
+ '-c', 'advice.detachedHead=false',
3789
+ ]
4014
3790
 
4015
- class InterpInspector:
3791
+ subprocess.check_call(
3792
+ subprocess_maybe_shell_wrap_exec(
3793
+ 'git',
3794
+ *git_opts,
3795
+ 'clone',
3796
+ '-n',
3797
+ '--depth=1',
3798
+ '--filter=tree:0',
3799
+ *(['-b', branch] if branch else []),
3800
+ '--single-branch',
3801
+ repo_url,
3802
+ repo_dir,
3803
+ ),
3804
+ cwd=base_dir,
3805
+ )
3806
+
3807
+ rd = os.path.join(base_dir, repo_dir)
3808
+ subprocess.check_call(
3809
+ subprocess_maybe_shell_wrap_exec(
3810
+ 'git',
3811
+ *git_opts,
3812
+ 'sparse-checkout',
3813
+ 'set',
3814
+ '--no-cone',
3815
+ *repo_subtrees,
3816
+ ),
3817
+ cwd=rd,
3818
+ )
3819
+
3820
+ subprocess.check_call(
3821
+ subprocess_maybe_shell_wrap_exec(
3822
+ 'git',
3823
+ *git_opts,
3824
+ 'checkout',
3825
+ *([rev] if rev else []),
3826
+ ),
3827
+ cwd=rd,
3828
+ )
3829
+
3830
+
3831
+ def get_git_revision(
3832
+ *,
3833
+ cwd: ta.Optional[str] = None,
3834
+ ) -> ta.Optional[str]:
3835
+ subprocess.check_output(subprocess_maybe_shell_wrap_exec('git', '--version'))
3836
+
3837
+ if cwd is None:
3838
+ cwd = os.getcwd()
3839
+
3840
+ if subprocess.run( # noqa
3841
+ subprocess_maybe_shell_wrap_exec(
3842
+ 'git',
3843
+ 'rev-parse',
3844
+ '--is-inside-work-tree',
3845
+ ),
3846
+ stdout=subprocess.PIPE,
3847
+ stderr=subprocess.PIPE,
3848
+ ).returncode:
3849
+ return None
3850
+
3851
+ has_untracked = bool(subprocess.check_output(subprocess_maybe_shell_wrap_exec(
3852
+ 'git',
3853
+ 'ls-files',
3854
+ '.',
3855
+ '--exclude-standard',
3856
+ '--others',
3857
+ ), cwd=cwd).decode().strip())
3858
+
3859
+ dirty_rev = subprocess.check_output(subprocess_maybe_shell_wrap_exec(
3860
+ 'git',
3861
+ 'describe',
3862
+ '--match=NeVeRmAtCh',
3863
+ '--always',
3864
+ '--abbrev=40',
3865
+ '--dirty',
3866
+ ), cwd=cwd).decode().strip()
3867
+
3868
+ return dirty_rev + ('-untracked' if has_untracked else '')
3869
+
3870
+
3871
+ ##
3872
+
3873
+
3874
+ _GIT_STATUS_LINE_ESCAPE_CODES: ta.Mapping[str, str] = {
3875
+ '\\': '\\',
3876
+ '"': '"',
3877
+ 'n': '\n',
3878
+ 't': '\t',
3879
+ }
3880
+
3881
+
3882
+ def yield_git_status_line_fields(l: str) -> ta.Iterator[str]:
3883
+ def find_any(chars: str, start: int = 0) -> int:
3884
+ ret = -1
3885
+ for c in chars:
3886
+ if (found := l.find(c, start)) >= 0 and (ret < 0 or ret > found):
3887
+ ret = found
3888
+ return ret
3889
+
3890
+ p = 0
3891
+ while True:
3892
+ if l[p] == '"':
3893
+ p += 1
3894
+ s = []
3895
+ while (n := find_any('\\"', p)) > 0:
3896
+ if (c := l[n]) == '\\':
3897
+ s.append(l[p:n])
3898
+ s.append(_GIT_STATUS_LINE_ESCAPE_CODES[l[n + 1]])
3899
+ p = n + 2
3900
+ elif c == '"':
3901
+ s.append(l[p:n])
3902
+ p = n
3903
+ break
3904
+ else:
3905
+ raise ValueError(l)
3906
+
3907
+ if l[p] != '"':
3908
+ raise ValueError(l)
3909
+
3910
+ yield ''.join(s)
3911
+
3912
+ p += 1
3913
+ if p == len(l):
3914
+ return
3915
+ elif l[p] != ' ':
3916
+ raise ValueError(l)
3917
+
3918
+ p += 1
3919
+
3920
+ else:
3921
+ if (e := l.find(' ', p)) < 0:
3922
+ yield l[p:]
3923
+ return
3924
+
3925
+ yield l[p:e]
3926
+ p = e + 1
3927
+
3928
+
3929
+ """
3930
+ When merge is occurring and was successful, or outside of a merge situation, X shows the status of the index and Y shows
3931
+ the status of the working tree:
3932
+ -------------------------------------------------
3933
+ X Y Meaning
3934
+ -------------------------------------------------
3935
+ [AMD] not updated
3936
+ M [ MTD] updated in index
3937
+ T [ MTD] type changed in index
3938
+ A [ MTD] added to index
3939
+ D deleted from index
3940
+ R [ MTD] renamed in index
3941
+ C [ MTD] copied in index
3942
+ [MTARC] index and work tree matches
3943
+ [ MTARC] M work tree changed since index
3944
+ [ MTARC] T type changed in work tree since index
3945
+ [ MTARC] D deleted in work tree
3946
+ R renamed in work tree
3947
+ C copied in work tree
3948
+
3949
+ When merge conflict has occurred and has not yet been resolved, X and Y show the state introduced by each head of the
3950
+ merge, relative to the common ancestor:
3951
+ -------------------------------------------------
3952
+ X Y Meaning
3953
+ -------------------------------------------------
3954
+ D D unmerged, both deleted
3955
+ A U unmerged, added by us
3956
+ U D unmerged, deleted by them
3957
+ U A unmerged, added by them
3958
+ D U unmerged, deleted by us
3959
+ A A unmerged, both added
3960
+ U U unmerged, both modified
3961
+
3962
+ When path is untracked, X and Y are always the same, since they are unknown to the index:
3963
+ -------------------------------------------------
3964
+ X Y Meaning
3965
+ -------------------------------------------------
3966
+ ? ? untracked
3967
+ ! ! ignored
3968
+
3969
+ Submodules have more state and instead report
3970
+
3971
+ - M = the submodule has a different HEAD than recorded in the index
3972
+ - m = the submodule has modified content
3973
+ - ? = the submodule has untracked files
3974
+
3975
+ This is since modified content or untracked files in a submodule cannot be added via git add in the superproject to
3976
+ prepare a commit. m and ? are applied recursively. For example if a nested submodule in a submodule contains an
3977
+ untracked file, this is reported as ? as well.
3978
+ """ # noqa
3979
+
3980
+
3981
+ class GitStatusLineState(enum.Enum):
3982
+ UNMODIFIED = ' '
3983
+ MODIFIED = 'M'
3984
+ FILE_TYPE_CHANGED = 'T'
3985
+ ADDED = 'A'
3986
+ DELETED = 'D'
3987
+ RENAMED = 'R'
3988
+ COPIED = 'C'
3989
+ UPDATED_BUT_UNMERGED = 'U'
3990
+ UNTRACKED = '?'
3991
+ IGNORED = '!'
3992
+ SUBMODULE_MODIFIED_CONTENT = 'm'
3993
+
3994
+
3995
+ _EXTRA_UNMERGED_GIT_STATUS_LINE_STATES: ta.FrozenSet[ta.Tuple[GitStatusLineState, GitStatusLineState]] = frozenset([
3996
+ (GitStatusLineState.ADDED, GitStatusLineState.ADDED),
3997
+ (GitStatusLineState.DELETED, GitStatusLineState.DELETED),
3998
+ ])
3999
+
4000
+
4001
+ @dc.dataclass(frozen=True)
4002
+ class GitStatusLine:
4003
+ x: GitStatusLineState
4004
+ y: GitStatusLineState
4005
+
4006
+ a: str
4007
+ b: ta.Optional[str]
4008
+
4009
+ @property
4010
+ def is_unmerged(self) -> bool:
4011
+ return (
4012
+ self.x is GitStatusLineState.UPDATED_BUT_UNMERGED or
4013
+ self.y is GitStatusLineState.UPDATED_BUT_UNMERGED or
4014
+ (self.x, self.y) in _EXTRA_UNMERGED_GIT_STATUS_LINE_STATES
4015
+ )
4016
+
4017
+ def __repr__(self) -> str:
4018
+ return (
4019
+ f'{self.__class__.__name__}('
4020
+ f'x={self.x.name}, '
4021
+ f'y={self.y.name}, '
4022
+ f'a={self.a!r}' +
4023
+ (f', b={self.b!r}' if self.b is not None else '') +
4024
+ ')'
4025
+ )
4026
+
4027
+
4028
+ def parse_git_status_line(l: str) -> GitStatusLine:
4029
+ if len(l) < 3 or l[2] != ' ':
4030
+ raise ValueError(l)
4031
+ x, y = l[0], l[1]
4032
+
4033
+ fields = list(yield_git_status_line_fields(l[3:]))
4034
+ if len(fields) == 1:
4035
+ a, b = fields[0], None
4036
+ elif len(fields) == 3:
4037
+ check_state(fields[1] == '->', l)
4038
+ a, b = fields[0], fields[2]
4039
+ else:
4040
+ raise ValueError(l)
4041
+
4042
+ return GitStatusLine(
4043
+ GitStatusLineState(x),
4044
+ GitStatusLineState(y),
4045
+ a,
4046
+ b,
4047
+ )
4048
+
4049
+
4050
+ class GitStatus(ta.Sequence[GitStatusLine]):
4051
+ def __init__(self, lines: ta.Iterable[GitStatusLine]) -> None:
4052
+ super().__init__()
4053
+
4054
+ self._lst = list(lines)
4055
+
4056
+ by_x: ta.Dict[GitStatusLineState, list[GitStatusLine]] = {}
4057
+ by_y: ta.Dict[GitStatusLineState, list[GitStatusLine]] = {}
4058
+
4059
+ by_a: ta.Dict[str, GitStatusLine] = {}
4060
+ by_b: ta.Dict[str, GitStatusLine] = {}
4061
+
4062
+ for l in self._lst:
4063
+ by_x.setdefault(l.x, []).append(l)
4064
+ by_y.setdefault(l.y, []).append(l)
4065
+
4066
+ if l.a in by_a:
4067
+ raise KeyError(l.a)
4068
+ by_a[l.a] = l
4069
+
4070
+ if l.b is not None:
4071
+ if l.b in by_b:
4072
+ raise KeyError(l.b)
4073
+ by_b[l.b] = l
4074
+
4075
+ self._by_x = by_x
4076
+ self._by_y = by_y
4077
+
4078
+ self._by_a = by_a
4079
+ self._by_b = by_b
4080
+
4081
+ self._has_unmerged = any(l.is_unmerged for l in self)
4082
+
4083
+ #
4084
+
4085
+ def __iter__(self) -> ta.Iterator[GitStatusLine]:
4086
+ return iter(self._lst)
4087
+
4088
+ def __getitem__(self, index):
4089
+ return self._lst[index]
4090
+
4091
+ def __len__(self) -> int:
4092
+ return len(self._lst)
4093
+
4094
+ #
4095
+
4096
+ @property
4097
+ def by_x(self) -> ta.Mapping[GitStatusLineState, ta.Sequence[GitStatusLine]]:
4098
+ return self._by_x
4099
+
4100
+ @property
4101
+ def by_y(self) -> ta.Mapping[GitStatusLineState, ta.Sequence[GitStatusLine]]:
4102
+ return self._by_y
4103
+
4104
+ @property
4105
+ def by_a(self) -> ta.Mapping[str, GitStatusLine]:
4106
+ return self._by_a
4107
+
4108
+ @property
4109
+ def by_b(self) -> ta.Mapping[str, GitStatusLine]:
4110
+ return self._by_b
4111
+
4112
+ #
4113
+
4114
+ @property
4115
+ def has_unmerged(self) -> bool:
4116
+ return self._has_unmerged
4117
+
4118
+ @property
4119
+ def has_staged(self) -> bool:
4120
+ return any(l.x != GitStatusLineState.UNMODIFIED for l in self._lst)
4121
+
4122
+ @property
4123
+ def has_dirty(self) -> bool:
4124
+ return any(l.y != GitStatusLineState.UNMODIFIED for l in self._lst)
4125
+
4126
+
4127
+ def parse_git_status(s: str) -> GitStatus:
4128
+ return GitStatus(parse_git_status_line(l) for l in s.splitlines())
4129
+
4130
+
4131
+ def get_git_status(
4132
+ *,
4133
+ cwd: ta.Optional[str] = None,
4134
+ ignore_submodules: bool = False,
4135
+ verbose: bool = False,
4136
+ ) -> GitStatus:
4137
+ if cwd is None:
4138
+ cwd = os.getcwd()
4139
+
4140
+ proc = subprocess.run( # type: ignore
4141
+ subprocess_maybe_shell_wrap_exec(
4142
+ 'git',
4143
+ 'status',
4144
+ '--porcelain=v1',
4145
+ *(['--ignore-submodules'] if ignore_submodules else []),
4146
+ ),
4147
+ cwd=cwd,
4148
+ stdout=subprocess.PIPE,
4149
+ **(dict(stderr=subprocess.PIPE) if not verbose else {}),
4150
+ check=True,
4151
+ )
4152
+
4153
+ return parse_git_status(proc.stdout.decode()) # noqa
4154
+
4155
+
4156
+ ########################################
4157
+ # ../../interp/inspect.py
4158
+
4159
+
4160
+ @dc.dataclass(frozen=True)
4161
+ class InterpInspection:
4162
+ exe: str
4163
+ version: Version
4164
+
4165
+ version_str: str
4166
+ config_vars: ta.Mapping[str, str]
4167
+ prefix: str
4168
+ base_prefix: str
4169
+
4170
+ @property
4171
+ def opts(self) -> InterpOpts:
4172
+ return InterpOpts(
4173
+ threaded=bool(self.config_vars.get('Py_GIL_DISABLED')),
4174
+ debug=bool(self.config_vars.get('Py_DEBUG')),
4175
+ )
4176
+
4177
+ @property
4178
+ def iv(self) -> InterpVersion:
4179
+ return InterpVersion(
4180
+ version=self.version,
4181
+ opts=self.opts,
4182
+ )
4183
+
4184
+ @property
4185
+ def is_venv(self) -> bool:
4186
+ return self.prefix != self.base_prefix
4187
+
4188
+
4189
+ class InterpInspector:
4016
4190
 
4017
4191
  def __init__(self) -> None:
4018
4192
  super().__init__()
@@ -4076,1180 +4250,1300 @@ INTERP_INSPECTOR = InterpInspector()
4076
4250
 
4077
4251
 
4078
4252
  ########################################
4079
- # ../pkg.py
4253
+ # ../../interp/providers.py
4080
4254
  """
4081
4255
  TODO:
4082
- - ext scanning
4083
- - __revision__
4084
- - entry_points
4256
+ - backends
4257
+ - local builds
4258
+ - deadsnakes?
4259
+ - uv
4260
+ - loose versions
4261
+ """
4085
4262
 
4086
- ** NOTE **
4087
- setuptools now (2024/09/02) has experimental support for extensions in pure pyproject.toml - but we still want a
4088
- separate '-cext' package
4089
- https://setuptools.pypa.io/en/latest/userguide/ext_modules.html
4090
- https://github.com/pypa/setuptools/commit/1a9d87308dc0d8aabeaae0dce989b35dfb7699f0#diff-61d113525e9cc93565799a4bb8b34a68e2945b8a3f7d90c81380614a4ea39542R7-R8
4091
4263
 
4092
- --
4264
+ ##
4093
4265
 
4094
- https://setuptools.pypa.io/en/latest/references/keywords.html
4095
- https://packaging.python.org/en/latest/specifications/pyproject-toml
4096
4266
 
4097
- How to build a C extension in keeping with PEP 517, i.e. with pyproject.toml instead of setup.py?
4098
- https://stackoverflow.com/a/66479252
4267
+ class InterpProvider(abc.ABC):
4268
+ name: ta.ClassVar[str]
4099
4269
 
4100
- https://github.com/pypa/sampleproject/blob/db5806e0a3204034c51b1c00dde7d5eb3fa2532e/setup.py
4270
+ def __init_subclass__(cls, **kwargs: ta.Any) -> None:
4271
+ super().__init_subclass__(**kwargs)
4272
+ if abc.ABC not in cls.__bases__ and 'name' not in cls.__dict__:
4273
+ sfx = 'InterpProvider'
4274
+ if not cls.__name__.endswith(sfx):
4275
+ raise NameError(cls)
4276
+ setattr(cls, 'name', snake_case(cls.__name__[:-len(sfx)]))
4101
4277
 
4102
- https://pip.pypa.io/en/stable/cli/pip_install/#vcs-support
4103
- vcs+protocol://repo_url/#egg=pkg&subdirectory=pkg_dir
4104
- 'git+https://github.com/wrmsr/omlish@master#subdirectory=.pip/omlish'
4105
- """ # noqa
4278
+ @abc.abstractmethod
4279
+ def get_installed_versions(self, spec: InterpSpecifier) -> ta.Sequence[InterpVersion]:
4280
+ raise NotImplementedError
4106
4281
 
4282
+ @abc.abstractmethod
4283
+ def get_installed_version(self, version: InterpVersion) -> Interp:
4284
+ raise NotImplementedError
4107
4285
 
4108
- #
4286
+ def get_installable_versions(self, spec: InterpSpecifier) -> ta.Sequence[InterpVersion]:
4287
+ return []
4109
4288
 
4289
+ def install_version(self, version: InterpVersion) -> Interp:
4290
+ raise TypeError
4110
4291
 
4111
- class BasePyprojectPackageGenerator(abc.ABC):
4112
- def __init__(
4113
- self,
4114
- dir_name: str,
4115
- pkgs_root: str,
4116
- *,
4117
- pkg_suffix: str = '',
4118
- ) -> None:
4119
- super().__init__()
4120
- self._dir_name = dir_name
4121
- self._pkgs_root = pkgs_root
4122
- self._pkg_suffix = pkg_suffix
4123
4292
 
4124
- #
4293
+ ##
4125
4294
 
4295
+
4296
+ class RunningInterpProvider(InterpProvider):
4126
4297
  @cached_nullary
4127
- def about(self) -> types.ModuleType:
4128
- return importlib.import_module(f'{self._dir_name}.__about__')
4298
+ def version(self) -> InterpVersion:
4299
+ return InterpInspector.running().iv
4129
4300
 
4130
- #
4301
+ def get_installed_versions(self, spec: InterpSpecifier) -> ta.Sequence[InterpVersion]:
4302
+ return [self.version()]
4131
4303
 
4132
- @cached_nullary
4133
- def _pkg_dir(self) -> str:
4134
- pkg_dir: str = os.path.join(self._pkgs_root, self._dir_name + self._pkg_suffix)
4135
- if os.path.isdir(pkg_dir):
4136
- shutil.rmtree(pkg_dir)
4137
- os.makedirs(pkg_dir)
4138
- return pkg_dir
4304
+ def get_installed_version(self, version: InterpVersion) -> Interp:
4305
+ if version != self.version():
4306
+ raise KeyError(version)
4307
+ return Interp(
4308
+ exe=sys.executable,
4309
+ version=self.version(),
4310
+ )
4139
4311
 
4140
- #
4141
4312
 
4142
- _GIT_IGNORE: ta.Sequence[str] = [
4143
- '/*.egg-info/',
4144
- '/dist',
4145
- ]
4313
+ ########################################
4314
+ # ../../revisions.py
4315
+ """
4316
+ TODO:
4317
+ - omlish-lite, move to pyproject/
4318
+ - vendor-lite wheel.wheelfile
4319
+ """
4146
4320
 
4147
- def _write_git_ignore(self) -> None:
4148
- with open(os.path.join(self._pkg_dir(), '.gitignore'), 'w') as f:
4149
- f.write('\n'.join(self._GIT_IGNORE))
4150
4321
 
4151
- #
4322
+ ##
4152
4323
 
4153
- def _symlink_source_dir(self) -> None:
4154
- os.symlink(
4155
- os.path.relpath(self._dir_name, self._pkg_dir()),
4156
- os.path.join(self._pkg_dir(), self._dir_name),
4157
- )
4158
4324
 
4159
- #
4325
+ class GitRevisionAdder:
4326
+ def __init__(
4327
+ self,
4328
+ revision: ta.Optional[str] = None,
4329
+ output_suffix: ta.Optional[str] = None,
4330
+ ) -> None:
4331
+ super().__init__()
4332
+ self._given_revision = revision
4333
+ self._output_suffix = output_suffix
4160
4334
 
4161
4335
  @cached_nullary
4162
- def project_cls(self) -> type:
4163
- return self.about().Project
4336
+ def revision(self) -> str:
4337
+ if self._given_revision is not None:
4338
+ return self._given_revision
4339
+ return check_non_empty_str(get_git_revision())
4164
4340
 
4165
- @cached_nullary
4166
- def setuptools_cls(self) -> type:
4167
- return self.about().Setuptools
4341
+ REVISION_ATTR = '__revision__'
4168
4342
 
4169
- @staticmethod
4170
- def _build_cls_dct(cls: type) -> ta.Dict[str, ta.Any]: # noqa
4171
- dct = {}
4172
- for b in reversed(cls.__mro__):
4173
- for k, v in b.__dict__.items():
4174
- if k.startswith('_'):
4343
+ def add_to_contents(self, dct: ta.Dict[str, bytes]) -> bool:
4344
+ changed = False
4345
+ for n in dct:
4346
+ if not n.endswith('__about__.py'):
4347
+ continue
4348
+ src = dct[n].decode('utf-8')
4349
+ lines = src.splitlines(keepends=True)
4350
+ for i, l in enumerate(lines):
4351
+ if l != f'{self.REVISION_ATTR} = None\n':
4175
4352
  continue
4176
- dct[k] = v
4177
- return dct
4178
-
4179
- @staticmethod
4180
- def _move_dict_key(
4181
- sd: ta.Dict[str, ta.Any],
4182
- sk: str,
4183
- dd: ta.Dict[str, ta.Any],
4184
- dk: str,
4185
- ) -> None:
4186
- if sk in sd:
4187
- dd[dk] = sd.pop(sk)
4353
+ lines[i] = f"{self.REVISION_ATTR} = '{self.revision()}'\n"
4354
+ changed = True
4355
+ dct[n] = ''.join(lines).encode('utf-8')
4356
+ return changed
4188
4357
 
4189
- @dc.dataclass(frozen=True)
4190
- class Specs:
4191
- pyproject: ta.Dict[str, ta.Any]
4192
- setuptools: ta.Dict[str, ta.Any]
4358
+ def add_to_wheel(self, f: str) -> None:
4359
+ if not f.endswith('.whl'):
4360
+ raise Exception(f)
4361
+ log.info('Scanning wheel %s', f)
4193
4362
 
4194
- def build_specs(self) -> Specs:
4195
- return self.Specs(
4196
- self._build_cls_dct(self.project_cls()),
4197
- self._build_cls_dct(self.setuptools_cls()),
4198
- )
4363
+ zis: ta.Dict[str, zipfile.ZipInfo] = {}
4364
+ dct: ta.Dict[str, bytes] = {}
4365
+ with WheelFile(f) as wf:
4366
+ for zi in wf.filelist:
4367
+ if zi.filename == wf.record_path:
4368
+ continue
4369
+ zis[zi.filename] = zi
4370
+ dct[zi.filename] = wf.read(zi.filename)
4199
4371
 
4200
- #
4372
+ if self.add_to_contents(dct):
4373
+ of = f[:-4] + (self._output_suffix or '') + '.whl'
4374
+ log.info('Repacking wheel %s', of)
4375
+ with WheelFile(of, 'w') as wf:
4376
+ for n, d in dct.items():
4377
+ log.info('Adding zipinfo %s', n)
4378
+ wf.writestr(zis[n], d)
4201
4379
 
4202
- class _PkgData(ta.NamedTuple):
4203
- inc: ta.List[str]
4204
- exc: ta.List[str]
4380
+ def add_to_tgz(self, f: str) -> None:
4381
+ if not f.endswith('.tar.gz'):
4382
+ raise Exception(f)
4383
+ log.info('Scanning tgz %s', f)
4205
4384
 
4206
- @cached_nullary
4207
- def _collect_pkg_data(self) -> _PkgData:
4208
- inc: ta.List[str] = []
4209
- exc: ta.List[str] = []
4385
+ tis: ta.Dict[str, tarfile.TarInfo] = {}
4386
+ dct: ta.Dict[str, bytes] = {}
4387
+ with tarfile.open(f, 'r:gz') as tf:
4388
+ for ti in tf:
4389
+ tis[ti.name] = ti
4390
+ if ti.type == tarfile.REGTYPE:
4391
+ with tf.extractfile(ti.name) as tif: # type: ignore
4392
+ dct[ti.name] = tif.read()
4210
4393
 
4211
- for p, ds, fs in os.walk(self._dir_name): # noqa
4212
- for f in fs:
4213
- if f != '.pkgdata':
4214
- continue
4215
- rp = os.path.relpath(p, self._dir_name)
4216
- log.info('Found pkgdata %s for pkg %s', rp, self._dir_name)
4217
- with open(os.path.join(p, f)) as fo:
4218
- src = fo.read()
4219
- for l in src.splitlines():
4220
- if not (l := l.strip()):
4221
- continue
4222
- if l.startswith('!'):
4223
- exc.append(os.path.join(rp, l[1:]))
4394
+ if self.add_to_contents(dct):
4395
+ of = f[:-7] + (self._output_suffix or '') + '.tar.gz'
4396
+ log.info('Repacking tgz %s', of)
4397
+ with tarfile.open(of, 'w:gz') as tf:
4398
+ for n, ti in tis.items():
4399
+ log.info('Adding tarinfo %s', n)
4400
+ if n in dct:
4401
+ data = dct[n]
4402
+ ti.size = len(data)
4403
+ fo = io.BytesIO(data)
4224
4404
  else:
4225
- inc.append(os.path.join(rp, l))
4405
+ fo = None
4406
+ tf.addfile(ti, fileobj=fo)
4226
4407
 
4227
- return self._PkgData(inc, exc)
4408
+ EXTS = ('.tar.gz', '.whl')
4228
4409
 
4229
- #
4410
+ def add_to_file(self, f: str) -> None:
4411
+ if f.endswith('.whl'):
4412
+ self.add_to_wheel(f)
4230
4413
 
4231
- @abc.abstractmethod
4232
- def _write_file_contents(self) -> None:
4233
- raise NotImplementedError
4414
+ elif f.endswith('.tar.gz'):
4415
+ self.add_to_tgz(f)
4234
4416
 
4235
- #
4417
+ def add_to(self, tgt: str) -> None:
4418
+ log.info('Using revision %s', self.revision())
4236
4419
 
4237
- _STANDARD_FILES: ta.Sequence[str] = [
4238
- 'LICENSE',
4239
- 'README.rst',
4240
- ]
4420
+ if os.path.isfile(tgt):
4421
+ self.add_to_file(tgt)
4241
4422
 
4242
- def _symlink_standard_files(self) -> None:
4243
- for fn in self._STANDARD_FILES:
4244
- if os.path.exists(fn):
4245
- os.symlink(os.path.relpath(fn, self._pkg_dir()), os.path.join(self._pkg_dir(), fn))
4423
+ elif os.path.isdir(tgt):
4424
+ for dp, dns, fns in os.walk(tgt): # noqa
4425
+ for f in fns:
4426
+ if any(f.endswith(ext) for ext in self.EXTS):
4427
+ self.add_to_file(os.path.join(dp, f))
4246
4428
 
4247
- #
4248
4429
 
4249
- def children(self) -> ta.Sequence['BasePyprojectPackageGenerator']:
4250
- return []
4430
+ #
4251
4431
 
4252
- #
4253
4432
 
4254
- def gen(self) -> str:
4255
- log.info('Generating pyproject package: %s -> %s (%s)', self._dir_name, self._pkgs_root, self._pkg_suffix)
4433
+ ########################################
4434
+ # ../../interp/pyenv.py
4435
+ """
4436
+ TODO:
4437
+ - custom tags
4438
+ - 'aliases'
4439
+ - https://github.com/pyenv/pyenv/pull/2966
4440
+ - https://github.com/pyenv/pyenv/issues/218 (lol)
4441
+ - probably need custom (temp?) definition file
4442
+ - *or* python-build directly just into the versions dir?
4443
+ - optionally install / upgrade pyenv itself
4444
+ - new vers dont need these custom mac opts, only run on old vers
4445
+ """
4256
4446
 
4257
- self._pkg_dir()
4258
- self._write_git_ignore()
4259
- self._symlink_source_dir()
4260
- self._write_file_contents()
4261
- self._symlink_standard_files()
4262
4447
 
4263
- return self._pkg_dir()
4448
+ ##
4264
4449
 
4265
- #
4266
4450
 
4267
- @dc.dataclass(frozen=True)
4268
- class BuildOpts:
4269
- add_revision: bool = False
4270
- test: bool = False
4451
+ class Pyenv:
4271
4452
 
4272
- def build(
4453
+ def __init__(
4273
4454
  self,
4274
- output_dir: ta.Optional[str] = None,
4275
- opts: BuildOpts = BuildOpts(),
4455
+ *,
4456
+ root: ta.Optional[str] = None,
4276
4457
  ) -> None:
4277
- subprocess_check_call(
4278
- sys.executable,
4279
- '-m',
4280
- 'build',
4281
- cwd=self._pkg_dir(),
4282
- )
4458
+ if root is not None and not (isinstance(root, str) and root):
4459
+ raise ValueError(f'pyenv_root: {root!r}')
4283
4460
 
4284
- dist_dir = os.path.join(self._pkg_dir(), 'dist')
4461
+ super().__init__()
4285
4462
 
4286
- if opts.add_revision:
4287
- GitRevisionAdder().add_to(dist_dir)
4463
+ self._root_kw = root
4288
4464
 
4289
- if opts.test:
4290
- for fn in os.listdir(dist_dir):
4291
- tmp_dir = tempfile.mkdtemp()
4465
+ @cached_nullary
4466
+ def root(self) -> ta.Optional[str]:
4467
+ if self._root_kw is not None:
4468
+ return self._root_kw
4292
4469
 
4293
- subprocess_check_call(
4294
- sys.executable,
4295
- '-m', 'venv',
4296
- 'test-install',
4297
- cwd=tmp_dir,
4298
- )
4470
+ if shutil.which('pyenv'):
4471
+ return subprocess_check_output_str('pyenv', 'root')
4299
4472
 
4300
- subprocess_check_call(
4301
- os.path.join(tmp_dir, 'test-install', 'bin', 'python3'),
4302
- '-m', 'pip',
4303
- 'install',
4304
- os.path.abspath(os.path.join(dist_dir, fn)),
4305
- cwd=tmp_dir,
4306
- )
4473
+ d = os.path.expanduser('~/.pyenv')
4474
+ if os.path.isdir(d) and os.path.isfile(os.path.join(d, 'bin', 'pyenv')):
4475
+ return d
4307
4476
 
4308
- if output_dir is not None:
4309
- for fn in os.listdir(dist_dir):
4310
- shutil.copyfile(os.path.join(dist_dir, fn), os.path.join(output_dir, fn))
4477
+ return None
4311
4478
 
4479
+ @cached_nullary
4480
+ def exe(self) -> str:
4481
+ return os.path.join(check_not_none(self.root()), 'bin', 'pyenv')
4312
4482
 
4313
- #
4483
+ def version_exes(self) -> ta.List[ta.Tuple[str, str]]:
4484
+ if (root := self.root()) is None:
4485
+ return []
4486
+ ret = []
4487
+ vp = os.path.join(root, 'versions')
4488
+ if os.path.isdir(vp):
4489
+ for dn in os.listdir(vp):
4490
+ ep = os.path.join(vp, dn, 'bin', 'python')
4491
+ if not os.path.isfile(ep):
4492
+ continue
4493
+ ret.append((dn, ep))
4494
+ return ret
4314
4495
 
4496
+ def installable_versions(self) -> ta.List[str]:
4497
+ if self.root() is None:
4498
+ return []
4499
+ ret = []
4500
+ s = subprocess_check_output_str(self.exe(), 'install', '--list')
4501
+ for l in s.splitlines():
4502
+ if not l.startswith(' '):
4503
+ continue
4504
+ l = l.strip()
4505
+ if not l:
4506
+ continue
4507
+ ret.append(l)
4508
+ return ret
4315
4509
 
4316
- class PyprojectPackageGenerator(BasePyprojectPackageGenerator):
4510
+ def update(self) -> bool:
4511
+ if (root := self.root()) is None:
4512
+ return False
4513
+ if not os.path.isdir(os.path.join(root, '.git')):
4514
+ return False
4515
+ subprocess_check_call('git', 'pull', cwd=root)
4516
+ return True
4317
4517
 
4318
- #
4319
4518
 
4320
- @dc.dataclass(frozen=True)
4321
- class FileContents:
4322
- pyproject_dct: ta.Mapping[str, ta.Any]
4323
- manifest_in: ta.Optional[ta.Sequence[str]]
4519
+ ##
4324
4520
 
4325
- @cached_nullary
4326
- def file_contents(self) -> FileContents:
4327
- specs = self.build_specs()
4328
4521
 
4329
- #
4522
+ @dc.dataclass(frozen=True)
4523
+ class PyenvInstallOpts:
4524
+ opts: ta.Sequence[str] = ()
4525
+ conf_opts: ta.Sequence[str] = ()
4526
+ cflags: ta.Sequence[str] = ()
4527
+ ldflags: ta.Sequence[str] = ()
4528
+ env: ta.Mapping[str, str] = dc.field(default_factory=dict)
4330
4529
 
4331
- pyp_dct = {}
4530
+ def merge(self, *others: 'PyenvInstallOpts') -> 'PyenvInstallOpts':
4531
+ return PyenvInstallOpts(
4532
+ opts=list(itertools.chain.from_iterable(o.opts for o in [self, *others])),
4533
+ conf_opts=list(itertools.chain.from_iterable(o.conf_opts for o in [self, *others])),
4534
+ cflags=list(itertools.chain.from_iterable(o.cflags for o in [self, *others])),
4535
+ ldflags=list(itertools.chain.from_iterable(o.ldflags for o in [self, *others])),
4536
+ env=dict(itertools.chain.from_iterable(o.env.items() for o in [self, *others])),
4537
+ )
4332
4538
 
4333
- pyp_dct['build-system'] = {
4334
- 'requires': ['setuptools'],
4335
- 'build-backend': 'setuptools.build_meta',
4336
- }
4337
4539
 
4338
- prj = specs.pyproject
4339
- prj['name'] += self._pkg_suffix
4540
+ # TODO: https://github.com/pyenv/pyenv/blob/master/plugins/python-build/README.md#building-for-maximum-performance
4541
+ DEFAULT_PYENV_INSTALL_OPTS = PyenvInstallOpts(
4542
+ opts=[
4543
+ '-s',
4544
+ '-v',
4545
+ '-k',
4546
+ ],
4547
+ conf_opts=[
4548
+ '--enable-loadable-sqlite-extensions',
4340
4549
 
4341
- pyp_dct['project'] = prj
4550
+ # '--enable-shared',
4342
4551
 
4343
- self._move_dict_key(prj, 'optional_dependencies', pyp_dct, extrask := 'project.optional-dependencies')
4344
- if (extras := pyp_dct.get(extrask)):
4345
- pyp_dct[extrask] = {
4346
- 'all': [
4347
- e
4348
- for lst in extras.values()
4349
- for e in lst
4350
- ],
4351
- **extras,
4352
- }
4552
+ '--enable-optimizations',
4553
+ '--with-lto',
4353
4554
 
4354
- if (eps := prj.pop('entry_points', None)):
4355
- pyp_dct['project.entry-points'] = {TomlWriter.Literal(f"'{k}'"): v for k, v in eps.items()} # type: ignore # noqa
4555
+ # '--enable-profiling', # ?
4356
4556
 
4357
- if (scs := prj.pop('scripts', None)):
4358
- pyp_dct['project.scripts'] = scs
4557
+ # '--enable-ipv6', # ?
4558
+ ],
4559
+ cflags=[
4560
+ # '-march=native',
4561
+ # '-mtune=native',
4562
+ ],
4563
+ )
4359
4564
 
4360
- prj.pop('cli_scripts', None)
4565
+ DEBUG_PYENV_INSTALL_OPTS = PyenvInstallOpts(opts=['-g'])
4361
4566
 
4362
- ##
4567
+ THREADED_PYENV_INSTALL_OPTS = PyenvInstallOpts(conf_opts=['--disable-gil'])
4363
4568
 
4364
- st = dict(specs.setuptools)
4365
- pyp_dct['tool.setuptools'] = st
4366
4569
 
4367
- st.pop('cexts', None)
4570
+ #
4368
4571
 
4369
- #
4370
4572
 
4371
- # TODO: default
4372
- # find_packages = {
4373
- # 'include': [Project.name, f'{Project.name}.*'],
4374
- # 'exclude': [*SetuptoolsBase.find_packages['exclude']],
4375
- # }
4573
+ class PyenvInstallOptsProvider(abc.ABC):
4574
+ @abc.abstractmethod
4575
+ def opts(self) -> PyenvInstallOpts:
4576
+ raise NotImplementedError
4376
4577
 
4377
- fp = dict(st.pop('find_packages', {}))
4378
4578
 
4379
- pyp_dct['tool.setuptools.packages.find'] = fp
4579
+ class LinuxPyenvInstallOpts(PyenvInstallOptsProvider):
4580
+ def opts(self) -> PyenvInstallOpts:
4581
+ return PyenvInstallOpts()
4380
4582
 
4381
- #
4382
4583
 
4383
- # TODO: default
4384
- # package_data = {
4385
- # '*': [
4386
- # '*.c',
4387
- # '*.cc',
4388
- # '*.h',
4389
- # '.manifests.json',
4390
- # 'LICENSE',
4391
- # ],
4392
- # }
4584
+ class DarwinPyenvInstallOpts(PyenvInstallOptsProvider):
4393
4585
 
4394
- pd = dict(st.pop('package_data', {}))
4395
- epd = dict(st.pop('exclude_package_data', {}))
4586
+ @cached_nullary
4587
+ def framework_opts(self) -> PyenvInstallOpts:
4588
+ return PyenvInstallOpts(conf_opts=['--enable-framework'])
4396
4589
 
4397
- cpd = self._collect_pkg_data()
4398
- if cpd.inc:
4399
- pd['*'] = [*pd.get('*', []), *sorted(set(cpd.inc))]
4400
- if cpd.exc:
4401
- epd['*'] = [*epd.get('*', []), *sorted(set(cpd.exc))]
4590
+ @cached_nullary
4591
+ def has_brew(self) -> bool:
4592
+ return shutil.which('brew') is not None
4402
4593
 
4403
- if pd:
4404
- pyp_dct['tool.setuptools.package-data'] = pd
4405
- if epd:
4406
- pyp_dct['tool.setuptools.exclude-package-data'] = epd
4594
+ BREW_DEPS: ta.Sequence[str] = [
4595
+ 'openssl',
4596
+ 'readline',
4597
+ 'sqlite3',
4598
+ 'zlib',
4599
+ ]
4407
4600
 
4408
- #
4601
+ @cached_nullary
4602
+ def brew_deps_opts(self) -> PyenvInstallOpts:
4603
+ cflags = []
4604
+ ldflags = []
4605
+ for dep in self.BREW_DEPS:
4606
+ dep_prefix = subprocess_check_output_str('brew', '--prefix', dep)
4607
+ cflags.append(f'-I{dep_prefix}/include')
4608
+ ldflags.append(f'-L{dep_prefix}/lib')
4609
+ return PyenvInstallOpts(
4610
+ cflags=cflags,
4611
+ ldflags=ldflags,
4612
+ )
4409
4613
 
4410
- # TODO: default
4411
- # manifest_in = [
4412
- # 'global-exclude **/conftest.py',
4413
- # ]
4614
+ @cached_nullary
4615
+ def brew_tcl_opts(self) -> PyenvInstallOpts:
4616
+ if subprocess_try_output('brew', '--prefix', 'tcl-tk') is None:
4617
+ return PyenvInstallOpts()
4414
4618
 
4415
- mani_in = st.pop('manifest_in', None)
4619
+ tcl_tk_prefix = subprocess_check_output_str('brew', '--prefix', 'tcl-tk')
4620
+ tcl_tk_ver_str = subprocess_check_output_str('brew', 'ls', '--versions', 'tcl-tk')
4621
+ tcl_tk_ver = '.'.join(tcl_tk_ver_str.split()[1].split('.')[:2])
4416
4622
 
4417
- #
4623
+ return PyenvInstallOpts(conf_opts=[
4624
+ f"--with-tcltk-includes='-I{tcl_tk_prefix}/include'",
4625
+ f"--with-tcltk-libs='-L{tcl_tk_prefix}/lib -ltcl{tcl_tk_ver} -ltk{tcl_tk_ver}'",
4626
+ ])
4418
4627
 
4419
- return self.FileContents(
4420
- pyp_dct,
4421
- mani_in,
4628
+ # @cached_nullary
4629
+ # def brew_ssl_opts(self) -> PyenvInstallOpts:
4630
+ # pkg_config_path = subprocess_check_output_str('brew', '--prefix', 'openssl')
4631
+ # if 'PKG_CONFIG_PATH' in os.environ:
4632
+ # pkg_config_path += ':' + os.environ['PKG_CONFIG_PATH']
4633
+ # return PyenvInstallOpts(env={'PKG_CONFIG_PATH': pkg_config_path})
4634
+
4635
+ def opts(self) -> PyenvInstallOpts:
4636
+ return PyenvInstallOpts().merge(
4637
+ self.framework_opts(),
4638
+ self.brew_deps_opts(),
4639
+ self.brew_tcl_opts(),
4640
+ # self.brew_ssl_opts(),
4422
4641
  )
4423
4642
 
4424
- def _write_file_contents(self) -> None:
4425
- fc = self.file_contents()
4426
4643
 
4427
- with open(os.path.join(self._pkg_dir(), 'pyproject.toml'), 'w') as f:
4428
- TomlWriter(f).write_root(fc.pyproject_dct)
4644
+ PLATFORM_PYENV_INSTALL_OPTS: ta.Dict[str, PyenvInstallOptsProvider] = {
4645
+ 'darwin': DarwinPyenvInstallOpts(),
4646
+ 'linux': LinuxPyenvInstallOpts(),
4647
+ }
4429
4648
 
4430
- if fc.manifest_in:
4431
- with open(os.path.join(self._pkg_dir(), 'MANIFEST.in'), 'w') as f:
4432
- f.write('\n'.join(fc.manifest_in)) # noqa
4433
4649
 
4434
- #
4650
+ ##
4435
4651
 
4436
- @cached_nullary
4437
- def children(self) -> ta.Sequence[BasePyprojectPackageGenerator]:
4438
- out: ta.List[BasePyprojectPackageGenerator] = []
4439
4652
 
4440
- if self.build_specs().setuptools.get('cexts'):
4441
- out.append(_PyprojectCextPackageGenerator(
4442
- self._dir_name,
4443
- self._pkgs_root,
4444
- pkg_suffix='-cext',
4445
- ))
4446
-
4447
- if self.build_specs().pyproject.get('cli_scripts'):
4448
- out.append(_PyprojectCliPackageGenerator(
4449
- self._dir_name,
4450
- self._pkgs_root,
4451
- pkg_suffix='-cli',
4452
- ))
4653
+ class PyenvVersionInstaller:
4654
+ """
4655
+ Messy: can install freethreaded build with a 't' suffixed version str _or_ by THREADED_PYENV_INSTALL_OPTS - need
4656
+ latter to build custom interp with ft, need former to use canned / blessed interps. Muh.
4657
+ """
4453
4658
 
4454
- return out
4659
+ def __init__(
4660
+ self,
4661
+ version: str,
4662
+ opts: ta.Optional[PyenvInstallOpts] = None,
4663
+ interp_opts: InterpOpts = InterpOpts(),
4664
+ *,
4665
+ install_name: ta.Optional[str] = None,
4666
+ no_default_opts: bool = False,
4667
+ pyenv: Pyenv = Pyenv(),
4668
+ ) -> None:
4669
+ super().__init__()
4455
4670
 
4671
+ if no_default_opts:
4672
+ if opts is None:
4673
+ opts = PyenvInstallOpts()
4674
+ else:
4675
+ lst = [opts if opts is not None else DEFAULT_PYENV_INSTALL_OPTS]
4676
+ if interp_opts.debug:
4677
+ lst.append(DEBUG_PYENV_INSTALL_OPTS)
4678
+ if interp_opts.threaded:
4679
+ lst.append(THREADED_PYENV_INSTALL_OPTS)
4680
+ lst.append(PLATFORM_PYENV_INSTALL_OPTS[sys.platform].opts())
4681
+ opts = PyenvInstallOpts().merge(*lst)
4456
4682
 
4457
- #
4683
+ self._version = version
4684
+ self._opts = opts
4685
+ self._interp_opts = interp_opts
4686
+ self._given_install_name = install_name
4458
4687
 
4688
+ self._no_default_opts = no_default_opts
4689
+ self._pyenv = pyenv
4459
4690
 
4460
- class _PyprojectCextPackageGenerator(BasePyprojectPackageGenerator):
4691
+ @property
4692
+ def version(self) -> str:
4693
+ return self._version
4461
4694
 
4462
- #
4695
+ @property
4696
+ def opts(self) -> PyenvInstallOpts:
4697
+ return self._opts
4463
4698
 
4464
4699
  @cached_nullary
4465
- def find_cext_srcs(self) -> ta.Sequence[str]:
4466
- return sorted(find_magic_files(
4467
- CextMagic.STYLE,
4468
- [self._dir_name],
4469
- keys=[CextMagic.KEY],
4470
- ))
4471
-
4472
- #
4700
+ def install_name(self) -> str:
4701
+ if self._given_install_name is not None:
4702
+ return self._given_install_name
4703
+ return self._version + ('-debug' if self._interp_opts.debug else '')
4473
4704
 
4474
- @dc.dataclass(frozen=True)
4475
- class FileContents:
4476
- pyproject_dct: ta.Mapping[str, ta.Any]
4477
- setup_py: str
4705
+ @cached_nullary
4706
+ def install_dir(self) -> str:
4707
+ return str(os.path.join(check_not_none(self._pyenv.root()), 'versions', self.install_name()))
4478
4708
 
4479
4709
  @cached_nullary
4480
- def file_contents(self) -> FileContents:
4481
- specs = self.build_specs()
4710
+ def install(self) -> str:
4711
+ env = {**os.environ, **self._opts.env}
4712
+ for k, l in [
4713
+ ('CFLAGS', self._opts.cflags),
4714
+ ('LDFLAGS', self._opts.ldflags),
4715
+ ('PYTHON_CONFIGURE_OPTS', self._opts.conf_opts),
4716
+ ]:
4717
+ v = ' '.join(l)
4718
+ if k in os.environ:
4719
+ v += ' ' + os.environ[k]
4720
+ env[k] = v
4482
4721
 
4483
- #
4722
+ conf_args = [
4723
+ *self._opts.opts,
4724
+ self._version,
4725
+ ]
4484
4726
 
4485
- pyp_dct = {}
4727
+ if self._given_install_name is not None:
4728
+ full_args = [
4729
+ os.path.join(check_not_none(self._pyenv.root()), 'plugins', 'python-build', 'bin', 'python-build'),
4730
+ *conf_args,
4731
+ self.install_dir(),
4732
+ ]
4733
+ else:
4734
+ full_args = [
4735
+ self._pyenv.exe(),
4736
+ 'install',
4737
+ *conf_args,
4738
+ ]
4486
4739
 
4487
- pyp_dct['build-system'] = {
4488
- 'requires': ['setuptools'],
4489
- 'build-backend': 'setuptools.build_meta',
4490
- }
4740
+ subprocess_check_call(
4741
+ *full_args,
4742
+ env=env,
4743
+ )
4491
4744
 
4492
- prj = specs.pyproject
4493
- prj['dependencies'] = [f'{prj["name"]} == {prj["version"]}']
4494
- prj['name'] += self._pkg_suffix
4495
- for k in [
4496
- 'optional_dependencies',
4497
- 'entry_points',
4498
- 'scripts',
4499
- 'cli_scripts',
4500
- ]:
4501
- prj.pop(k, None)
4745
+ exe = os.path.join(self.install_dir(), 'bin', 'python')
4746
+ if not os.path.isfile(exe):
4747
+ raise RuntimeError(f'Interpreter not found: {exe}')
4748
+ return exe
4502
4749
 
4503
- pyp_dct['project'] = prj
4504
4750
 
4505
- #
4751
+ ##
4506
4752
 
4507
- st = dict(specs.setuptools)
4508
- pyp_dct['tool.setuptools'] = st
4509
4753
 
4510
- for k in [
4511
- 'cexts',
4754
+ class PyenvInterpProvider(InterpProvider):
4512
4755
 
4513
- 'find_packages',
4514
- 'package_data',
4515
- 'manifest_in',
4516
- ]:
4517
- st.pop(k, None)
4756
+ def __init__(
4757
+ self,
4758
+ pyenv: Pyenv = Pyenv(),
4518
4759
 
4519
- pyp_dct['tool.setuptools.packages.find'] = {
4520
- 'include': [],
4521
- }
4760
+ inspect: bool = False,
4761
+ inspector: InterpInspector = INTERP_INSPECTOR,
4522
4762
 
4523
- #
4763
+ *,
4524
4764
 
4525
- ext_lines = []
4765
+ try_update: bool = False,
4766
+ ) -> None:
4767
+ super().__init__()
4526
4768
 
4527
- for ext_src in self.find_cext_srcs():
4528
- ext_name = ext_src.rpartition('.')[0].replace(os.sep, '.')
4529
- ext_lines.extend([
4530
- 'st.Extension(',
4531
- f" name='{ext_name}',",
4532
- f" sources=['{ext_src}'],",
4533
- " extra_compile_args=['-std=c++20'],",
4534
- '),',
4535
- ])
4769
+ self._pyenv = pyenv
4536
4770
 
4537
- src = '\n'.join([
4538
- 'import setuptools as st',
4539
- '',
4540
- '',
4541
- 'st.setup(',
4542
- ' ext_modules=[',
4543
- *[' ' + l for l in ext_lines],
4544
- ' ]',
4545
- ')',
4546
- '',
4547
- ])
4771
+ self._inspect = inspect
4772
+ self._inspector = inspector
4548
4773
 
4549
- #
4774
+ self._try_update = try_update
4550
4775
 
4551
- return self.FileContents(
4552
- pyp_dct,
4553
- src,
4554
- )
4776
+ #
4555
4777
 
4556
- def _write_file_contents(self) -> None:
4557
- fc = self.file_contents()
4778
+ @staticmethod
4779
+ def guess_version(s: str) -> ta.Optional[InterpVersion]:
4780
+ def strip_sfx(s: str, sfx: str) -> ta.Tuple[str, bool]:
4781
+ if s.endswith(sfx):
4782
+ return s[:-len(sfx)], True
4783
+ return s, False
4784
+ ok = {}
4785
+ s, ok['debug'] = strip_sfx(s, '-debug')
4786
+ s, ok['threaded'] = strip_sfx(s, 't')
4787
+ try:
4788
+ v = Version(s)
4789
+ except InvalidVersion:
4790
+ return None
4791
+ return InterpVersion(v, InterpOpts(**ok))
4558
4792
 
4559
- with open(os.path.join(self._pkg_dir(), 'pyproject.toml'), 'w') as f:
4560
- TomlWriter(f).write_root(fc.pyproject_dct)
4793
+ class Installed(ta.NamedTuple):
4794
+ name: str
4795
+ exe: str
4796
+ version: InterpVersion
4561
4797
 
4562
- with open(os.path.join(self._pkg_dir(), 'setup.py'), 'w') as f:
4563
- f.write(fc.setup_py)
4798
+ def _make_installed(self, vn: str, ep: str) -> ta.Optional[Installed]:
4799
+ iv: ta.Optional[InterpVersion]
4800
+ if self._inspect:
4801
+ try:
4802
+ iv = check_not_none(self._inspector.inspect(ep)).iv
4803
+ except Exception as e: # noqa
4804
+ return None
4805
+ else:
4806
+ iv = self.guess_version(vn)
4807
+ if iv is None:
4808
+ return None
4809
+ return PyenvInterpProvider.Installed(
4810
+ name=vn,
4811
+ exe=ep,
4812
+ version=iv,
4813
+ )
4564
4814
 
4815
+ def installed(self) -> ta.Sequence[Installed]:
4816
+ ret: ta.List[PyenvInterpProvider.Installed] = []
4817
+ for vn, ep in self._pyenv.version_exes():
4818
+ if (i := self._make_installed(vn, ep)) is None:
4819
+ log.debug('Invalid pyenv version: %s', vn)
4820
+ continue
4821
+ ret.append(i)
4822
+ return ret
4565
4823
 
4566
- ##
4824
+ #
4567
4825
 
4826
+ def get_installed_versions(self, spec: InterpSpecifier) -> ta.Sequence[InterpVersion]:
4827
+ return [i.version for i in self.installed()]
4568
4828
 
4569
- class _PyprojectCliPackageGenerator(BasePyprojectPackageGenerator):
4829
+ def get_installed_version(self, version: InterpVersion) -> Interp:
4830
+ for i in self.installed():
4831
+ if i.version == version:
4832
+ return Interp(
4833
+ exe=i.exe,
4834
+ version=i.version,
4835
+ )
4836
+ raise KeyError(version)
4570
4837
 
4571
4838
  #
4572
4839
 
4573
- @dc.dataclass(frozen=True)
4574
- class FileContents:
4575
- pyproject_dct: ta.Mapping[str, ta.Any]
4576
-
4577
- @cached_nullary
4578
- def file_contents(self) -> FileContents:
4579
- specs = self.build_specs()
4840
+ def _get_installable_versions(self, spec: InterpSpecifier) -> ta.Sequence[InterpVersion]:
4841
+ lst = []
4580
4842
 
4581
- #
4582
-
4583
- pyp_dct = {}
4584
-
4585
- pyp_dct['build-system'] = {
4586
- 'requires': ['setuptools'],
4587
- 'build-backend': 'setuptools.build_meta',
4588
- }
4589
-
4590
- prj = specs.pyproject
4591
- prj['dependencies'] = [f'{prj["name"]} == {prj["version"]}']
4592
- prj['name'] += self._pkg_suffix
4593
- for k in [
4594
- 'optional_dependencies',
4595
- 'entry_points',
4596
- 'scripts',
4597
- ]:
4598
- prj.pop(k, None)
4599
-
4600
- pyp_dct['project'] = prj
4601
-
4602
- if (scs := prj.pop('cli_scripts', None)):
4603
- pyp_dct['project.scripts'] = scs
4604
-
4605
- #
4843
+ for vs in self._pyenv.installable_versions():
4844
+ if (iv := self.guess_version(vs)) is None:
4845
+ continue
4846
+ if iv.opts.debug:
4847
+ raise Exception('Pyenv installable versions not expected to have debug suffix')
4848
+ for d in [False, True]:
4849
+ lst.append(dc.replace(iv, opts=dc.replace(iv.opts, debug=d)))
4606
4850
 
4607
- st = dict(specs.setuptools)
4608
- pyp_dct['tool.setuptools'] = st
4851
+ return lst
4609
4852
 
4610
- for k in [
4611
- 'cexts',
4853
+ def get_installable_versions(self, spec: InterpSpecifier) -> ta.Sequence[InterpVersion]:
4854
+ lst = self._get_installable_versions(spec)
4612
4855
 
4613
- 'find_packages',
4614
- 'package_data',
4615
- 'manifest_in',
4616
- ]:
4617
- st.pop(k, None)
4856
+ if self._try_update and not any(v in spec for v in lst):
4857
+ if self._pyenv.update():
4858
+ lst = self._get_installable_versions(spec)
4618
4859
 
4619
- pyp_dct['tool.setuptools.packages.find'] = {
4620
- 'include': [],
4621
- }
4860
+ return lst
4622
4861
 
4623
- #
4862
+ def install_version(self, version: InterpVersion) -> Interp:
4863
+ inst_version = str(version.version)
4864
+ inst_opts = version.opts
4865
+ if inst_opts.threaded:
4866
+ inst_version += 't'
4867
+ inst_opts = dc.replace(inst_opts, threaded=False)
4624
4868
 
4625
- return self.FileContents(
4626
- pyp_dct,
4869
+ installer = PyenvVersionInstaller(
4870
+ inst_version,
4871
+ interp_opts=inst_opts,
4627
4872
  )
4628
4873
 
4629
- def _write_file_contents(self) -> None:
4630
- fc = self.file_contents()
4631
-
4632
- with open(os.path.join(self._pkg_dir(), 'pyproject.toml'), 'w') as f:
4633
- TomlWriter(f).write_root(fc.pyproject_dct)
4874
+ exe = installer.install()
4875
+ return Interp(exe, version)
4634
4876
 
4635
4877
 
4636
4878
  ########################################
4637
- # ../../interp/providers.py
4879
+ # ../../interp/system.py
4638
4880
  """
4639
4881
  TODO:
4640
- - backends
4641
- - local builds
4642
- - deadsnakes?
4643
- - uv
4644
- - loose versions
4882
+ - python, python3, python3.12, ...
4883
+ - check if path py's are venvs: sys.prefix != sys.base_prefix
4645
4884
  """
4646
4885
 
4647
4886
 
4648
4887
  ##
4649
4888
 
4650
4889
 
4651
- class InterpProvider(abc.ABC):
4652
- name: ta.ClassVar[str]
4890
+ @dc.dataclass(frozen=True)
4891
+ class SystemInterpProvider(InterpProvider):
4892
+ cmd: str = 'python3'
4893
+ path: ta.Optional[str] = None
4653
4894
 
4654
- def __init_subclass__(cls, **kwargs: ta.Any) -> None:
4655
- super().__init_subclass__(**kwargs)
4656
- if abc.ABC not in cls.__bases__ and 'name' not in cls.__dict__:
4657
- sfx = 'InterpProvider'
4658
- if not cls.__name__.endswith(sfx):
4659
- raise NameError(cls)
4660
- setattr(cls, 'name', snake_case(cls.__name__[:-len(sfx)]))
4895
+ inspect: bool = False
4896
+ inspector: InterpInspector = INTERP_INSPECTOR
4661
4897
 
4662
- @abc.abstractmethod
4663
- def get_installed_versions(self, spec: InterpSpecifier) -> ta.Sequence[InterpVersion]:
4664
- raise NotImplementedError
4898
+ #
4665
4899
 
4666
- @abc.abstractmethod
4667
- def get_installed_version(self, version: InterpVersion) -> Interp:
4668
- raise NotImplementedError
4900
+ @staticmethod
4901
+ def _re_which(
4902
+ pat: re.Pattern,
4903
+ *,
4904
+ mode: int = os.F_OK | os.X_OK,
4905
+ path: ta.Optional[str] = None,
4906
+ ) -> ta.List[str]:
4907
+ if path is None:
4908
+ path = os.environ.get('PATH', None)
4909
+ if path is None:
4910
+ try:
4911
+ path = os.confstr('CS_PATH')
4912
+ except (AttributeError, ValueError):
4913
+ path = os.defpath
4669
4914
 
4670
- def get_installable_versions(self, spec: InterpSpecifier) -> ta.Sequence[InterpVersion]:
4671
- return []
4915
+ if not path:
4916
+ return []
4672
4917
 
4673
- def install_version(self, version: InterpVersion) -> Interp:
4674
- raise TypeError
4918
+ path = os.fsdecode(path)
4919
+ pathlst = path.split(os.pathsep)
4675
4920
 
4921
+ def _access_check(fn: str, mode: int) -> bool:
4922
+ return os.path.exists(fn) and os.access(fn, mode)
4676
4923
 
4677
- ##
4924
+ out = []
4925
+ seen = set()
4926
+ for d in pathlst:
4927
+ normdir = os.path.normcase(d)
4928
+ if normdir not in seen:
4929
+ seen.add(normdir)
4930
+ if not _access_check(normdir, mode):
4931
+ continue
4932
+ for thefile in os.listdir(d):
4933
+ name = os.path.join(d, thefile)
4934
+ if not (
4935
+ os.path.isfile(name) and
4936
+ pat.fullmatch(thefile) and
4937
+ _access_check(name, mode)
4938
+ ):
4939
+ continue
4940
+ out.append(name)
4678
4941
 
4942
+ return out
4679
4943
 
4680
- class RunningInterpProvider(InterpProvider):
4681
4944
  @cached_nullary
4682
- def version(self) -> InterpVersion:
4683
- return InterpInspector.running().iv
4945
+ def exes(self) -> ta.List[str]:
4946
+ return self._re_which(
4947
+ re.compile(r'python3(\.\d+)?'),
4948
+ path=self.path,
4949
+ )
4950
+
4951
+ #
4952
+
4953
+ def get_exe_version(self, exe: str) -> ta.Optional[InterpVersion]:
4954
+ if not self.inspect:
4955
+ s = os.path.basename(exe)
4956
+ if s.startswith('python'):
4957
+ s = s[len('python'):]
4958
+ if '.' in s:
4959
+ try:
4960
+ return InterpVersion.parse(s)
4961
+ except InvalidVersion:
4962
+ pass
4963
+ ii = self.inspector.inspect(exe)
4964
+ return ii.iv if ii is not None else None
4965
+
4966
+ def exe_versions(self) -> ta.Sequence[ta.Tuple[str, InterpVersion]]:
4967
+ lst = []
4968
+ for e in self.exes():
4969
+ if (ev := self.get_exe_version(e)) is None:
4970
+ log.debug('Invalid system version: %s', e)
4971
+ continue
4972
+ lst.append((e, ev))
4973
+ return lst
4974
+
4975
+ #
4684
4976
 
4685
4977
  def get_installed_versions(self, spec: InterpSpecifier) -> ta.Sequence[InterpVersion]:
4686
- return [self.version()]
4978
+ return [ev for e, ev in self.exe_versions()]
4687
4979
 
4688
4980
  def get_installed_version(self, version: InterpVersion) -> Interp:
4689
- if version != self.version():
4690
- raise KeyError(version)
4691
- return Interp(
4692
- exe=sys.executable,
4693
- version=self.version(),
4694
- )
4981
+ for e, ev in self.exe_versions():
4982
+ if ev != version:
4983
+ continue
4984
+ return Interp(
4985
+ exe=e,
4986
+ version=ev,
4987
+ )
4988
+ raise KeyError(version)
4695
4989
 
4696
4990
 
4697
4991
  ########################################
4698
- # ../../interp/pyenv.py
4992
+ # ../pkg.py
4699
4993
  """
4700
4994
  TODO:
4701
- - custom tags
4702
- - 'aliases'
4703
- - https://github.com/pyenv/pyenv/pull/2966
4704
- - https://github.com/pyenv/pyenv/issues/218 (lol)
4705
- - probably need custom (temp?) definition file
4706
- - *or* python-build directly just into the versions dir?
4707
- - optionally install / upgrade pyenv itself
4708
- - new vers dont need these custom mac opts, only run on old vers
4709
- """
4995
+ - ext scanning
4996
+ - __revision__
4997
+ - entry_points
4998
+
4999
+ ** NOTE **
5000
+ setuptools now (2024/09/02) has experimental support for extensions in pure pyproject.toml - but we still want a
5001
+ separate '-cext' package
5002
+ https://setuptools.pypa.io/en/latest/userguide/ext_modules.html
5003
+ https://github.com/pypa/setuptools/commit/1a9d87308dc0d8aabeaae0dce989b35dfb7699f0#diff-61d113525e9cc93565799a4bb8b34a68e2945b8a3f7d90c81380614a4ea39542R7-R8
4710
5004
 
5005
+ --
4711
5006
 
4712
- ##
5007
+ https://setuptools.pypa.io/en/latest/references/keywords.html
5008
+ https://packaging.python.org/en/latest/specifications/pyproject-toml
4713
5009
 
5010
+ How to build a C extension in keeping with PEP 517, i.e. with pyproject.toml instead of setup.py?
5011
+ https://stackoverflow.com/a/66479252
5012
+
5013
+ https://github.com/pypa/sampleproject/blob/db5806e0a3204034c51b1c00dde7d5eb3fa2532e/setup.py
5014
+
5015
+ https://pip.pypa.io/en/stable/cli/pip_install/#vcs-support
5016
+ vcs+protocol://repo_url/#egg=pkg&subdirectory=pkg_dir
5017
+ 'git+https://github.com/wrmsr/omlish@master#subdirectory=.pip/omlish'
5018
+ """ # noqa
5019
+
5020
+
5021
+ #
4714
5022
 
4715
- class Pyenv:
4716
5023
 
5024
+ class BasePyprojectPackageGenerator(abc.ABC):
4717
5025
  def __init__(
4718
5026
  self,
5027
+ dir_name: str,
5028
+ pkgs_root: str,
4719
5029
  *,
4720
- root: ta.Optional[str] = None,
5030
+ pkg_suffix: str = '',
4721
5031
  ) -> None:
4722
- if root is not None and not (isinstance(root, str) and root):
4723
- raise ValueError(f'pyenv_root: {root!r}')
4724
-
4725
5032
  super().__init__()
5033
+ self._dir_name = dir_name
5034
+ self._pkgs_root = pkgs_root
5035
+ self._pkg_suffix = pkg_suffix
4726
5036
 
4727
- self._root_kw = root
5037
+ #
4728
5038
 
4729
5039
  @cached_nullary
4730
- def root(self) -> ta.Optional[str]:
4731
- if self._root_kw is not None:
4732
- return self._root_kw
4733
-
4734
- if shutil.which('pyenv'):
4735
- return subprocess_check_output_str('pyenv', 'root')
5040
+ def about(self) -> types.ModuleType:
5041
+ return importlib.import_module(f'{self._dir_name}.__about__')
4736
5042
 
4737
- d = os.path.expanduser('~/.pyenv')
4738
- if os.path.isdir(d) and os.path.isfile(os.path.join(d, 'bin', 'pyenv')):
4739
- return d
4740
-
4741
- return None
5043
+ #
4742
5044
 
4743
5045
  @cached_nullary
4744
- def exe(self) -> str:
4745
- return os.path.join(check_not_none(self.root()), 'bin', 'pyenv')
4746
-
4747
- def version_exes(self) -> ta.List[ta.Tuple[str, str]]:
4748
- if (root := self.root()) is None:
4749
- return []
4750
- ret = []
4751
- vp = os.path.join(root, 'versions')
4752
- if os.path.isdir(vp):
4753
- for dn in os.listdir(vp):
4754
- ep = os.path.join(vp, dn, 'bin', 'python')
4755
- if not os.path.isfile(ep):
4756
- continue
4757
- ret.append((dn, ep))
4758
- return ret
5046
+ def _pkg_dir(self) -> str:
5047
+ pkg_dir: str = os.path.join(self._pkgs_root, self._dir_name + self._pkg_suffix)
5048
+ if os.path.isdir(pkg_dir):
5049
+ shutil.rmtree(pkg_dir)
5050
+ os.makedirs(pkg_dir)
5051
+ return pkg_dir
4759
5052
 
4760
- def installable_versions(self) -> ta.List[str]:
4761
- if self.root() is None:
4762
- return []
4763
- ret = []
4764
- s = subprocess_check_output_str(self.exe(), 'install', '--list')
4765
- for l in s.splitlines():
4766
- if not l.startswith(' '):
4767
- continue
4768
- l = l.strip()
4769
- if not l:
4770
- continue
4771
- ret.append(l)
4772
- return ret
5053
+ #
4773
5054
 
4774
- def update(self) -> bool:
4775
- if (root := self.root()) is None:
4776
- return False
4777
- if not os.path.isdir(os.path.join(root, '.git')):
4778
- return False
4779
- subprocess_check_call('git', 'pull', cwd=root)
4780
- return True
5055
+ _GIT_IGNORE: ta.Sequence[str] = [
5056
+ '/*.egg-info/',
5057
+ '/dist',
5058
+ ]
4781
5059
 
5060
+ def _write_git_ignore(self) -> None:
5061
+ with open(os.path.join(self._pkg_dir(), '.gitignore'), 'w') as f:
5062
+ f.write('\n'.join(self._GIT_IGNORE))
4782
5063
 
4783
- ##
5064
+ #
4784
5065
 
5066
+ def _symlink_source_dir(self) -> None:
5067
+ os.symlink(
5068
+ os.path.relpath(self._dir_name, self._pkg_dir()),
5069
+ os.path.join(self._pkg_dir(), self._dir_name),
5070
+ )
4785
5071
 
4786
- @dc.dataclass(frozen=True)
4787
- class PyenvInstallOpts:
4788
- opts: ta.Sequence[str] = ()
4789
- conf_opts: ta.Sequence[str] = ()
4790
- cflags: ta.Sequence[str] = ()
4791
- ldflags: ta.Sequence[str] = ()
4792
- env: ta.Mapping[str, str] = dc.field(default_factory=dict)
5072
+ #
4793
5073
 
4794
- def merge(self, *others: 'PyenvInstallOpts') -> 'PyenvInstallOpts':
4795
- return PyenvInstallOpts(
4796
- opts=list(itertools.chain.from_iterable(o.opts for o in [self, *others])),
4797
- conf_opts=list(itertools.chain.from_iterable(o.conf_opts for o in [self, *others])),
4798
- cflags=list(itertools.chain.from_iterable(o.cflags for o in [self, *others])),
4799
- ldflags=list(itertools.chain.from_iterable(o.ldflags for o in [self, *others])),
4800
- env=dict(itertools.chain.from_iterable(o.env.items() for o in [self, *others])),
4801
- )
5074
+ @cached_nullary
5075
+ def project_cls(self) -> type:
5076
+ return self.about().Project
4802
5077
 
5078
+ @cached_nullary
5079
+ def setuptools_cls(self) -> type:
5080
+ return self.about().Setuptools
4803
5081
 
4804
- # TODO: https://github.com/pyenv/pyenv/blob/master/plugins/python-build/README.md#building-for-maximum-performance
4805
- DEFAULT_PYENV_INSTALL_OPTS = PyenvInstallOpts(
4806
- opts=[
4807
- '-s',
4808
- '-v',
4809
- '-k',
4810
- ],
4811
- conf_opts=[
4812
- '--enable-loadable-sqlite-extensions',
5082
+ @staticmethod
5083
+ def _build_cls_dct(cls: type) -> ta.Dict[str, ta.Any]: # noqa
5084
+ dct = {}
5085
+ for b in reversed(cls.__mro__):
5086
+ for k, v in b.__dict__.items():
5087
+ if k.startswith('_'):
5088
+ continue
5089
+ dct[k] = v
5090
+ return dct
4813
5091
 
4814
- # '--enable-shared',
5092
+ @staticmethod
5093
+ def _move_dict_key(
5094
+ sd: ta.Dict[str, ta.Any],
5095
+ sk: str,
5096
+ dd: ta.Dict[str, ta.Any],
5097
+ dk: str,
5098
+ ) -> None:
5099
+ if sk in sd:
5100
+ dd[dk] = sd.pop(sk)
4815
5101
 
4816
- '--enable-optimizations',
4817
- '--with-lto',
5102
+ @dc.dataclass(frozen=True)
5103
+ class Specs:
5104
+ pyproject: ta.Dict[str, ta.Any]
5105
+ setuptools: ta.Dict[str, ta.Any]
4818
5106
 
4819
- # '--enable-profiling', # ?
5107
+ def build_specs(self) -> Specs:
5108
+ return self.Specs(
5109
+ self._build_cls_dct(self.project_cls()),
5110
+ self._build_cls_dct(self.setuptools_cls()),
5111
+ )
4820
5112
 
4821
- # '--enable-ipv6', # ?
4822
- ],
4823
- cflags=[
4824
- # '-march=native',
4825
- # '-mtune=native',
4826
- ],
4827
- )
5113
+ #
4828
5114
 
4829
- DEBUG_PYENV_INSTALL_OPTS = PyenvInstallOpts(opts=['-g'])
5115
+ class _PkgData(ta.NamedTuple):
5116
+ inc: ta.List[str]
5117
+ exc: ta.List[str]
4830
5118
 
4831
- THREADED_PYENV_INSTALL_OPTS = PyenvInstallOpts(conf_opts=['--disable-gil'])
5119
+ @cached_nullary
5120
+ def _collect_pkg_data(self) -> _PkgData:
5121
+ inc: ta.List[str] = []
5122
+ exc: ta.List[str] = []
4832
5123
 
5124
+ for p, ds, fs in os.walk(self._dir_name): # noqa
5125
+ for f in fs:
5126
+ if f != '.pkgdata':
5127
+ continue
5128
+ rp = os.path.relpath(p, self._dir_name)
5129
+ log.info('Found pkgdata %s for pkg %s', rp, self._dir_name)
5130
+ with open(os.path.join(p, f)) as fo:
5131
+ src = fo.read()
5132
+ for l in src.splitlines():
5133
+ if not (l := l.strip()):
5134
+ continue
5135
+ if l.startswith('!'):
5136
+ exc.append(os.path.join(rp, l[1:]))
5137
+ else:
5138
+ inc.append(os.path.join(rp, l))
4833
5139
 
4834
- #
5140
+ return self._PkgData(inc, exc)
4835
5141
 
5142
+ #
4836
5143
 
4837
- class PyenvInstallOptsProvider(abc.ABC):
4838
5144
  @abc.abstractmethod
4839
- def opts(self) -> PyenvInstallOpts:
5145
+ def _write_file_contents(self) -> None:
4840
5146
  raise NotImplementedError
4841
5147
 
5148
+ #
4842
5149
 
4843
- class LinuxPyenvInstallOpts(PyenvInstallOptsProvider):
4844
- def opts(self) -> PyenvInstallOpts:
4845
- return PyenvInstallOpts()
4846
-
5150
+ _STANDARD_FILES: ta.Sequence[str] = [
5151
+ 'LICENSE',
5152
+ 'README.rst',
5153
+ ]
4847
5154
 
4848
- class DarwinPyenvInstallOpts(PyenvInstallOptsProvider):
5155
+ def _symlink_standard_files(self) -> None:
5156
+ for fn in self._STANDARD_FILES:
5157
+ if os.path.exists(fn):
5158
+ os.symlink(os.path.relpath(fn, self._pkg_dir()), os.path.join(self._pkg_dir(), fn))
4849
5159
 
4850
- @cached_nullary
4851
- def framework_opts(self) -> PyenvInstallOpts:
4852
- return PyenvInstallOpts(conf_opts=['--enable-framework'])
5160
+ #
4853
5161
 
4854
- @cached_nullary
4855
- def has_brew(self) -> bool:
4856
- return shutil.which('brew') is not None
5162
+ def children(self) -> ta.Sequence['BasePyprojectPackageGenerator']:
5163
+ return []
4857
5164
 
4858
- BREW_DEPS: ta.Sequence[str] = [
4859
- 'openssl',
4860
- 'readline',
4861
- 'sqlite3',
4862
- 'zlib',
4863
- ]
5165
+ #
4864
5166
 
4865
- @cached_nullary
4866
- def brew_deps_opts(self) -> PyenvInstallOpts:
4867
- cflags = []
4868
- ldflags = []
4869
- for dep in self.BREW_DEPS:
4870
- dep_prefix = subprocess_check_output_str('brew', '--prefix', dep)
4871
- cflags.append(f'-I{dep_prefix}/include')
4872
- ldflags.append(f'-L{dep_prefix}/lib')
4873
- return PyenvInstallOpts(
4874
- cflags=cflags,
4875
- ldflags=ldflags,
4876
- )
5167
+ def gen(self) -> str:
5168
+ log.info('Generating pyproject package: %s -> %s (%s)', self._dir_name, self._pkgs_root, self._pkg_suffix)
4877
5169
 
4878
- @cached_nullary
4879
- def brew_tcl_opts(self) -> PyenvInstallOpts:
4880
- if subprocess_try_output('brew', '--prefix', 'tcl-tk') is None:
4881
- return PyenvInstallOpts()
5170
+ self._pkg_dir()
5171
+ self._write_git_ignore()
5172
+ self._symlink_source_dir()
5173
+ self._write_file_contents()
5174
+ self._symlink_standard_files()
4882
5175
 
4883
- tcl_tk_prefix = subprocess_check_output_str('brew', '--prefix', 'tcl-tk')
4884
- tcl_tk_ver_str = subprocess_check_output_str('brew', 'ls', '--versions', 'tcl-tk')
4885
- tcl_tk_ver = '.'.join(tcl_tk_ver_str.split()[1].split('.')[:2])
5176
+ return self._pkg_dir()
4886
5177
 
4887
- return PyenvInstallOpts(conf_opts=[
4888
- f"--with-tcltk-includes='-I{tcl_tk_prefix}/include'",
4889
- f"--with-tcltk-libs='-L{tcl_tk_prefix}/lib -ltcl{tcl_tk_ver} -ltk{tcl_tk_ver}'",
4890
- ])
5178
+ #
4891
5179
 
4892
- # @cached_nullary
4893
- # def brew_ssl_opts(self) -> PyenvInstallOpts:
4894
- # pkg_config_path = subprocess_check_output_str('brew', '--prefix', 'openssl')
4895
- # if 'PKG_CONFIG_PATH' in os.environ:
4896
- # pkg_config_path += ':' + os.environ['PKG_CONFIG_PATH']
4897
- # return PyenvInstallOpts(env={'PKG_CONFIG_PATH': pkg_config_path})
5180
+ @dc.dataclass(frozen=True)
5181
+ class BuildOpts:
5182
+ add_revision: bool = False
5183
+ test: bool = False
4898
5184
 
4899
- def opts(self) -> PyenvInstallOpts:
4900
- return PyenvInstallOpts().merge(
4901
- self.framework_opts(),
4902
- self.brew_deps_opts(),
4903
- self.brew_tcl_opts(),
4904
- # self.brew_ssl_opts(),
5185
+ def build(
5186
+ self,
5187
+ output_dir: ta.Optional[str] = None,
5188
+ opts: BuildOpts = BuildOpts(),
5189
+ ) -> None:
5190
+ subprocess_check_call(
5191
+ sys.executable,
5192
+ '-m',
5193
+ 'build',
5194
+ cwd=self._pkg_dir(),
4905
5195
  )
4906
5196
 
5197
+ dist_dir = os.path.join(self._pkg_dir(), 'dist')
4907
5198
 
4908
- PLATFORM_PYENV_INSTALL_OPTS: ta.Dict[str, PyenvInstallOptsProvider] = {
4909
- 'darwin': DarwinPyenvInstallOpts(),
4910
- 'linux': LinuxPyenvInstallOpts(),
4911
- }
5199
+ if opts.add_revision:
5200
+ GitRevisionAdder().add_to(dist_dir)
4912
5201
 
5202
+ if opts.test:
5203
+ for fn in os.listdir(dist_dir):
5204
+ tmp_dir = tempfile.mkdtemp()
4913
5205
 
4914
- ##
5206
+ subprocess_check_call(
5207
+ sys.executable,
5208
+ '-m', 'venv',
5209
+ 'test-install',
5210
+ cwd=tmp_dir,
5211
+ )
4915
5212
 
5213
+ subprocess_check_call(
5214
+ os.path.join(tmp_dir, 'test-install', 'bin', 'python3'),
5215
+ '-m', 'pip',
5216
+ 'install',
5217
+ os.path.abspath(os.path.join(dist_dir, fn)),
5218
+ cwd=tmp_dir,
5219
+ )
4916
5220
 
4917
- class PyenvVersionInstaller:
4918
- """
4919
- Messy: can install freethreaded build with a 't' suffixed version str _or_ by THREADED_PYENV_INSTALL_OPTS - need
4920
- latter to build custom interp with ft, need former to use canned / blessed interps. Muh.
4921
- """
5221
+ if output_dir is not None:
5222
+ for fn in os.listdir(dist_dir):
5223
+ shutil.copyfile(os.path.join(dist_dir, fn), os.path.join(output_dir, fn))
4922
5224
 
4923
- def __init__(
4924
- self,
4925
- version: str,
4926
- opts: ta.Optional[PyenvInstallOpts] = None,
4927
- interp_opts: InterpOpts = InterpOpts(),
4928
- *,
4929
- install_name: ta.Optional[str] = None,
4930
- no_default_opts: bool = False,
4931
- pyenv: Pyenv = Pyenv(),
4932
- ) -> None:
4933
- super().__init__()
4934
5225
 
4935
- if no_default_opts:
4936
- if opts is None:
4937
- opts = PyenvInstallOpts()
4938
- else:
4939
- lst = [opts if opts is not None else DEFAULT_PYENV_INSTALL_OPTS]
4940
- if interp_opts.debug:
4941
- lst.append(DEBUG_PYENV_INSTALL_OPTS)
4942
- if interp_opts.threaded:
4943
- lst.append(THREADED_PYENV_INSTALL_OPTS)
4944
- lst.append(PLATFORM_PYENV_INSTALL_OPTS[sys.platform].opts())
4945
- opts = PyenvInstallOpts().merge(*lst)
5226
+ #
4946
5227
 
4947
- self._version = version
4948
- self._opts = opts
4949
- self._interp_opts = interp_opts
4950
- self._given_install_name = install_name
4951
5228
 
4952
- self._no_default_opts = no_default_opts
4953
- self._pyenv = pyenv
5229
+ class PyprojectPackageGenerator(BasePyprojectPackageGenerator):
4954
5230
 
4955
- @property
4956
- def version(self) -> str:
4957
- return self._version
5231
+ #
4958
5232
 
4959
- @property
4960
- def opts(self) -> PyenvInstallOpts:
4961
- return self._opts
5233
+ @dc.dataclass(frozen=True)
5234
+ class FileContents:
5235
+ pyproject_dct: ta.Mapping[str, ta.Any]
5236
+ manifest_in: ta.Optional[ta.Sequence[str]]
4962
5237
 
4963
5238
  @cached_nullary
4964
- def install_name(self) -> str:
4965
- if self._given_install_name is not None:
4966
- return self._given_install_name
4967
- return self._version + ('-debug' if self._interp_opts.debug else '')
5239
+ def file_contents(self) -> FileContents:
5240
+ specs = self.build_specs()
4968
5241
 
4969
- @cached_nullary
4970
- def install_dir(self) -> str:
4971
- return str(os.path.join(check_not_none(self._pyenv.root()), 'versions', self.install_name()))
5242
+ #
4972
5243
 
4973
- @cached_nullary
4974
- def install(self) -> str:
4975
- env = {**os.environ, **self._opts.env}
4976
- for k, l in [
4977
- ('CFLAGS', self._opts.cflags),
4978
- ('LDFLAGS', self._opts.ldflags),
4979
- ('PYTHON_CONFIGURE_OPTS', self._opts.conf_opts),
4980
- ]:
4981
- v = ' '.join(l)
4982
- if k in os.environ:
4983
- v += ' ' + os.environ[k]
4984
- env[k] = v
5244
+ pyp_dct = {}
4985
5245
 
4986
- conf_args = [
4987
- *self._opts.opts,
4988
- self._version,
4989
- ]
5246
+ pyp_dct['build-system'] = {
5247
+ 'requires': ['setuptools'],
5248
+ 'build-backend': 'setuptools.build_meta',
5249
+ }
4990
5250
 
4991
- if self._given_install_name is not None:
4992
- full_args = [
4993
- os.path.join(check_not_none(self._pyenv.root()), 'plugins', 'python-build', 'bin', 'python-build'),
4994
- *conf_args,
4995
- self.install_dir(),
4996
- ]
4997
- else:
4998
- full_args = [
4999
- self._pyenv.exe(),
5000
- 'install',
5001
- *conf_args,
5002
- ]
5251
+ prj = specs.pyproject
5252
+ prj['name'] += self._pkg_suffix
5003
5253
 
5004
- subprocess_check_call(
5005
- *full_args,
5006
- env=env,
5007
- )
5254
+ pyp_dct['project'] = prj
5008
5255
 
5009
- exe = os.path.join(self.install_dir(), 'bin', 'python')
5010
- if not os.path.isfile(exe):
5011
- raise RuntimeError(f'Interpreter not found: {exe}')
5012
- return exe
5256
+ self._move_dict_key(prj, 'optional_dependencies', pyp_dct, extrask := 'project.optional-dependencies')
5257
+ if (extras := pyp_dct.get(extrask)):
5258
+ pyp_dct[extrask] = {
5259
+ 'all': [
5260
+ e
5261
+ for lst in extras.values()
5262
+ for e in lst
5263
+ ],
5264
+ **extras,
5265
+ }
5013
5266
 
5267
+ if (eps := prj.pop('entry_points', None)):
5268
+ pyp_dct['project.entry-points'] = {TomlWriter.Literal(f"'{k}'"): v for k, v in eps.items()} # type: ignore # noqa
5014
5269
 
5015
- ##
5270
+ if (scs := prj.pop('scripts', None)):
5271
+ pyp_dct['project.scripts'] = scs
5016
5272
 
5273
+ prj.pop('cli_scripts', None)
5017
5274
 
5018
- class PyenvInterpProvider(InterpProvider):
5275
+ ##
5019
5276
 
5020
- def __init__(
5021
- self,
5022
- pyenv: Pyenv = Pyenv(),
5277
+ st = dict(specs.setuptools)
5278
+ pyp_dct['tool.setuptools'] = st
5023
5279
 
5024
- inspect: bool = False,
5025
- inspector: InterpInspector = INTERP_INSPECTOR,
5280
+ st.pop('cexts', None)
5026
5281
 
5027
- *,
5282
+ #
5028
5283
 
5029
- try_update: bool = False,
5030
- ) -> None:
5031
- super().__init__()
5284
+ # TODO: default
5285
+ # find_packages = {
5286
+ # 'include': [Project.name, f'{Project.name}.*'],
5287
+ # 'exclude': [*SetuptoolsBase.find_packages['exclude']],
5288
+ # }
5032
5289
 
5033
- self._pyenv = pyenv
5290
+ fp = dict(st.pop('find_packages', {}))
5034
5291
 
5035
- self._inspect = inspect
5036
- self._inspector = inspector
5292
+ pyp_dct['tool.setuptools.packages.find'] = fp
5037
5293
 
5038
- self._try_update = try_update
5294
+ #
5039
5295
 
5040
- #
5296
+ # TODO: default
5297
+ # package_data = {
5298
+ # '*': [
5299
+ # '*.c',
5300
+ # '*.cc',
5301
+ # '*.h',
5302
+ # '.manifests.json',
5303
+ # 'LICENSE',
5304
+ # ],
5305
+ # }
5041
5306
 
5042
- @staticmethod
5043
- def guess_version(s: str) -> ta.Optional[InterpVersion]:
5044
- def strip_sfx(s: str, sfx: str) -> ta.Tuple[str, bool]:
5045
- if s.endswith(sfx):
5046
- return s[:-len(sfx)], True
5047
- return s, False
5048
- ok = {}
5049
- s, ok['debug'] = strip_sfx(s, '-debug')
5050
- s, ok['threaded'] = strip_sfx(s, 't')
5051
- try:
5052
- v = Version(s)
5053
- except InvalidVersion:
5054
- return None
5055
- return InterpVersion(v, InterpOpts(**ok))
5307
+ pd = dict(st.pop('package_data', {}))
5308
+ epd = dict(st.pop('exclude_package_data', {}))
5056
5309
 
5057
- class Installed(ta.NamedTuple):
5058
- name: str
5059
- exe: str
5060
- version: InterpVersion
5310
+ cpd = self._collect_pkg_data()
5311
+ if cpd.inc:
5312
+ pd['*'] = [*pd.get('*', []), *sorted(set(cpd.inc))]
5313
+ if cpd.exc:
5314
+ epd['*'] = [*epd.get('*', []), *sorted(set(cpd.exc))]
5061
5315
 
5062
- def _make_installed(self, vn: str, ep: str) -> ta.Optional[Installed]:
5063
- iv: ta.Optional[InterpVersion]
5064
- if self._inspect:
5065
- try:
5066
- iv = check_not_none(self._inspector.inspect(ep)).iv
5067
- except Exception as e: # noqa
5068
- return None
5069
- else:
5070
- iv = self.guess_version(vn)
5071
- if iv is None:
5072
- return None
5073
- return PyenvInterpProvider.Installed(
5074
- name=vn,
5075
- exe=ep,
5076
- version=iv,
5077
- )
5316
+ if pd:
5317
+ pyp_dct['tool.setuptools.package-data'] = pd
5318
+ if epd:
5319
+ pyp_dct['tool.setuptools.exclude-package-data'] = epd
5078
5320
 
5079
- def installed(self) -> ta.Sequence[Installed]:
5080
- ret: ta.List[PyenvInterpProvider.Installed] = []
5081
- for vn, ep in self._pyenv.version_exes():
5082
- if (i := self._make_installed(vn, ep)) is None:
5083
- log.debug('Invalid pyenv version: %s', vn)
5084
- continue
5085
- ret.append(i)
5086
- return ret
5321
+ #
5087
5322
 
5088
- #
5323
+ # TODO: default
5324
+ # manifest_in = [
5325
+ # 'global-exclude **/conftest.py',
5326
+ # ]
5089
5327
 
5090
- def get_installed_versions(self, spec: InterpSpecifier) -> ta.Sequence[InterpVersion]:
5091
- return [i.version for i in self.installed()]
5328
+ mani_in = st.pop('manifest_in', None)
5092
5329
 
5093
- def get_installed_version(self, version: InterpVersion) -> Interp:
5094
- for i in self.installed():
5095
- if i.version == version:
5096
- return Interp(
5097
- exe=i.exe,
5098
- version=i.version,
5099
- )
5100
- raise KeyError(version)
5330
+ #
5101
5331
 
5102
- #
5332
+ return self.FileContents(
5333
+ pyp_dct,
5334
+ mani_in,
5335
+ )
5103
5336
 
5104
- def _get_installable_versions(self, spec: InterpSpecifier) -> ta.Sequence[InterpVersion]:
5105
- lst = []
5337
+ def _write_file_contents(self) -> None:
5338
+ fc = self.file_contents()
5106
5339
 
5107
- for vs in self._pyenv.installable_versions():
5108
- if (iv := self.guess_version(vs)) is None:
5109
- continue
5110
- if iv.opts.debug:
5111
- raise Exception('Pyenv installable versions not expected to have debug suffix')
5112
- for d in [False, True]:
5113
- lst.append(dc.replace(iv, opts=dc.replace(iv.opts, debug=d)))
5340
+ with open(os.path.join(self._pkg_dir(), 'pyproject.toml'), 'w') as f:
5341
+ TomlWriter(f).write_root(fc.pyproject_dct)
5114
5342
 
5115
- return lst
5343
+ if fc.manifest_in:
5344
+ with open(os.path.join(self._pkg_dir(), 'MANIFEST.in'), 'w') as f:
5345
+ f.write('\n'.join(fc.manifest_in)) # noqa
5116
5346
 
5117
- def get_installable_versions(self, spec: InterpSpecifier) -> ta.Sequence[InterpVersion]:
5118
- lst = self._get_installable_versions(spec)
5347
+ #
5119
5348
 
5120
- if self._try_update and not any(v in spec for v in lst):
5121
- if self._pyenv.update():
5122
- lst = self._get_installable_versions(spec)
5349
+ @cached_nullary
5350
+ def children(self) -> ta.Sequence[BasePyprojectPackageGenerator]:
5351
+ out: ta.List[BasePyprojectPackageGenerator] = []
5123
5352
 
5124
- return lst
5353
+ if self.build_specs().setuptools.get('cexts'):
5354
+ out.append(_PyprojectCextPackageGenerator(
5355
+ self._dir_name,
5356
+ self._pkgs_root,
5357
+ pkg_suffix='-cext',
5358
+ ))
5125
5359
 
5126
- def install_version(self, version: InterpVersion) -> Interp:
5127
- inst_version = str(version.version)
5128
- inst_opts = version.opts
5129
- if inst_opts.threaded:
5130
- inst_version += 't'
5131
- inst_opts = dc.replace(inst_opts, threaded=False)
5360
+ if self.build_specs().pyproject.get('cli_scripts'):
5361
+ out.append(_PyprojectCliPackageGenerator(
5362
+ self._dir_name,
5363
+ self._pkgs_root,
5364
+ pkg_suffix='-cli',
5365
+ ))
5132
5366
 
5133
- installer = PyenvVersionInstaller(
5134
- inst_version,
5135
- interp_opts=inst_opts,
5136
- )
5367
+ return out
5137
5368
 
5138
- exe = installer.install()
5139
- return Interp(exe, version)
5140
5369
 
5370
+ #
5141
5371
 
5142
- ########################################
5143
- # ../../interp/system.py
5144
- """
5145
- TODO:
5146
- - python, python3, python3.12, ...
5147
- - check if path py's are venvs: sys.prefix != sys.base_prefix
5148
- """
5149
5372
 
5373
+ class _PyprojectCextPackageGenerator(BasePyprojectPackageGenerator):
5150
5374
 
5151
- ##
5375
+ #
5152
5376
 
5377
+ @cached_nullary
5378
+ def find_cext_srcs(self) -> ta.Sequence[str]:
5379
+ return sorted(find_magic_files(
5380
+ CextMagic.STYLE,
5381
+ [self._dir_name],
5382
+ keys=[CextMagic.KEY],
5383
+ ))
5153
5384
 
5154
- @dc.dataclass(frozen=True)
5155
- class SystemInterpProvider(InterpProvider):
5156
- cmd: str = 'python3'
5157
- path: ta.Optional[str] = None
5385
+ #
5158
5386
 
5159
- inspect: bool = False
5160
- inspector: InterpInspector = INTERP_INSPECTOR
5387
+ @dc.dataclass(frozen=True)
5388
+ class FileContents:
5389
+ pyproject_dct: ta.Mapping[str, ta.Any]
5390
+ setup_py: str
5161
5391
 
5162
- #
5392
+ @cached_nullary
5393
+ def file_contents(self) -> FileContents:
5394
+ specs = self.build_specs()
5163
5395
 
5164
- @staticmethod
5165
- def _re_which(
5166
- pat: re.Pattern,
5167
- *,
5168
- mode: int = os.F_OK | os.X_OK,
5169
- path: ta.Optional[str] = None,
5170
- ) -> ta.List[str]:
5171
- if path is None:
5172
- path = os.environ.get('PATH', None)
5173
- if path is None:
5174
- try:
5175
- path = os.confstr('CS_PATH')
5176
- except (AttributeError, ValueError):
5177
- path = os.defpath
5396
+ #
5178
5397
 
5179
- if not path:
5180
- return []
5398
+ pyp_dct = {}
5181
5399
 
5182
- path = os.fsdecode(path)
5183
- pathlst = path.split(os.pathsep)
5400
+ pyp_dct['build-system'] = {
5401
+ 'requires': ['setuptools'],
5402
+ 'build-backend': 'setuptools.build_meta',
5403
+ }
5184
5404
 
5185
- def _access_check(fn: str, mode: int) -> bool:
5186
- return os.path.exists(fn) and os.access(fn, mode)
5405
+ prj = specs.pyproject
5406
+ prj['dependencies'] = [f'{prj["name"]} == {prj["version"]}']
5407
+ prj['name'] += self._pkg_suffix
5408
+ for k in [
5409
+ 'optional_dependencies',
5410
+ 'entry_points',
5411
+ 'scripts',
5412
+ 'cli_scripts',
5413
+ ]:
5414
+ prj.pop(k, None)
5187
5415
 
5188
- out = []
5189
- seen = set()
5190
- for d in pathlst:
5191
- normdir = os.path.normcase(d)
5192
- if normdir not in seen:
5193
- seen.add(normdir)
5194
- if not _access_check(normdir, mode):
5195
- continue
5196
- for thefile in os.listdir(d):
5197
- name = os.path.join(d, thefile)
5198
- if not (
5199
- os.path.isfile(name) and
5200
- pat.fullmatch(thefile) and
5201
- _access_check(name, mode)
5202
- ):
5203
- continue
5204
- out.append(name)
5416
+ pyp_dct['project'] = prj
5205
5417
 
5206
- return out
5418
+ #
5207
5419
 
5208
- @cached_nullary
5209
- def exes(self) -> ta.List[str]:
5210
- return self._re_which(
5211
- re.compile(r'python3(\.\d+)?'),
5212
- path=self.path,
5420
+ st = dict(specs.setuptools)
5421
+ pyp_dct['tool.setuptools'] = st
5422
+
5423
+ for k in [
5424
+ 'cexts',
5425
+
5426
+ 'find_packages',
5427
+ 'package_data',
5428
+ 'manifest_in',
5429
+ ]:
5430
+ st.pop(k, None)
5431
+
5432
+ pyp_dct['tool.setuptools.packages.find'] = {
5433
+ 'include': [],
5434
+ }
5435
+
5436
+ #
5437
+
5438
+ ext_lines = []
5439
+
5440
+ for ext_src in self.find_cext_srcs():
5441
+ ext_name = ext_src.rpartition('.')[0].replace(os.sep, '.')
5442
+ ext_lines.extend([
5443
+ 'st.Extension(',
5444
+ f" name='{ext_name}',",
5445
+ f" sources=['{ext_src}'],",
5446
+ " extra_compile_args=['-std=c++20'],",
5447
+ '),',
5448
+ ])
5449
+
5450
+ src = '\n'.join([
5451
+ 'import setuptools as st',
5452
+ '',
5453
+ '',
5454
+ 'st.setup(',
5455
+ ' ext_modules=[',
5456
+ *[' ' + l for l in ext_lines],
5457
+ ' ]',
5458
+ ')',
5459
+ '',
5460
+ ])
5461
+
5462
+ #
5463
+
5464
+ return self.FileContents(
5465
+ pyp_dct,
5466
+ src,
5213
5467
  )
5214
5468
 
5215
- #
5469
+ def _write_file_contents(self) -> None:
5470
+ fc = self.file_contents()
5216
5471
 
5217
- def get_exe_version(self, exe: str) -> ta.Optional[InterpVersion]:
5218
- if not self.inspect:
5219
- s = os.path.basename(exe)
5220
- if s.startswith('python'):
5221
- s = s[len('python'):]
5222
- if '.' in s:
5223
- try:
5224
- return InterpVersion.parse(s)
5225
- except InvalidVersion:
5226
- pass
5227
- ii = self.inspector.inspect(exe)
5228
- return ii.iv if ii is not None else None
5472
+ with open(os.path.join(self._pkg_dir(), 'pyproject.toml'), 'w') as f:
5473
+ TomlWriter(f).write_root(fc.pyproject_dct)
5229
5474
 
5230
- def exe_versions(self) -> ta.Sequence[ta.Tuple[str, InterpVersion]]:
5231
- lst = []
5232
- for e in self.exes():
5233
- if (ev := self.get_exe_version(e)) is None:
5234
- log.debug('Invalid system version: %s', e)
5235
- continue
5236
- lst.append((e, ev))
5237
- return lst
5475
+ with open(os.path.join(self._pkg_dir(), 'setup.py'), 'w') as f:
5476
+ f.write(fc.setup_py)
5477
+
5478
+
5479
+ ##
5480
+
5481
+
5482
+ class _PyprojectCliPackageGenerator(BasePyprojectPackageGenerator):
5238
5483
 
5239
5484
  #
5240
5485
 
5241
- def get_installed_versions(self, spec: InterpSpecifier) -> ta.Sequence[InterpVersion]:
5242
- return [ev for e, ev in self.exe_versions()]
5486
+ @dc.dataclass(frozen=True)
5487
+ class FileContents:
5488
+ pyproject_dct: ta.Mapping[str, ta.Any]
5243
5489
 
5244
- def get_installed_version(self, version: InterpVersion) -> Interp:
5245
- for e, ev in self.exe_versions():
5246
- if ev != version:
5247
- continue
5248
- return Interp(
5249
- exe=e,
5250
- version=ev,
5251
- )
5252
- raise KeyError(version)
5490
+ @cached_nullary
5491
+ def file_contents(self) -> FileContents:
5492
+ specs = self.build_specs()
5493
+
5494
+ #
5495
+
5496
+ pyp_dct = {}
5497
+
5498
+ pyp_dct['build-system'] = {
5499
+ 'requires': ['setuptools'],
5500
+ 'build-backend': 'setuptools.build_meta',
5501
+ }
5502
+
5503
+ prj = specs.pyproject
5504
+ prj['dependencies'] = [f'{prj["name"]} == {prj["version"]}']
5505
+ prj['name'] += self._pkg_suffix
5506
+ for k in [
5507
+ 'optional_dependencies',
5508
+ 'entry_points',
5509
+ 'scripts',
5510
+ ]:
5511
+ prj.pop(k, None)
5512
+
5513
+ pyp_dct['project'] = prj
5514
+
5515
+ if (scs := prj.pop('cli_scripts', None)):
5516
+ pyp_dct['project.scripts'] = scs
5517
+
5518
+ #
5519
+
5520
+ st = dict(specs.setuptools)
5521
+ pyp_dct['tool.setuptools'] = st
5522
+
5523
+ for k in [
5524
+ 'cexts',
5525
+
5526
+ 'find_packages',
5527
+ 'package_data',
5528
+ 'manifest_in',
5529
+ ]:
5530
+ st.pop(k, None)
5531
+
5532
+ pyp_dct['tool.setuptools.packages.find'] = {
5533
+ 'include': [],
5534
+ }
5535
+
5536
+ #
5537
+
5538
+ return self.FileContents(
5539
+ pyp_dct,
5540
+ )
5541
+
5542
+ def _write_file_contents(self) -> None:
5543
+ fc = self.file_contents()
5544
+
5545
+ with open(os.path.join(self._pkg_dir(), 'pyproject.toml'), 'w') as f:
5546
+ TomlWriter(f).write_root(fc.pyproject_dct)
5253
5547
 
5254
5548
 
5255
5549
  ########################################