Skip to content

Commit c28ea31

Browse files
authored
Merge pull request #131 from lsst/tickets/DM-42226
DM-42226: Write python package metadata
2 parents 0f9b150 + a8908ca commit c28ea31

File tree

6 files changed

+199
-25
lines changed

6 files changed

+199
-25
lines changed

.github/workflows/build.yaml

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,15 @@ jobs:
1111
strategy:
1212
matrix:
1313
os: [ubuntu-latest, macos-latest]
14-
pyversion: ["3.10", "3.11", "3.12"]
14+
pyversion: ["3.11", "3.12", "3.13"]
1515

1616
runs-on: ${{ matrix.os }}
1717
steps:
18-
- uses: actions/checkout@v3
18+
- uses: actions/checkout@v4
1919

20-
- uses: conda-incubator/setup-miniconda@v2
20+
- uses: conda-incubator/setup-miniconda@v3
2121
with:
2222
python-version: ${{ matrix.pyversion }}
23-
auto-update-conda: true
2423
channels: conda-forge,defaults
2524
miniforge-variant: Miniforge3
2625
use-mamba: true
@@ -37,7 +36,7 @@ jobs:
3736
shell: bash -l {0}
3837
run: |
3938
mamba install -y -q \
40-
pytest pytest-xdist pytest-openfiles pytest-cov pytest-session2file
39+
pytest pytest-xdist pytest-cov pytest-session2file
4140
4241
- name: List installed packages
4342
shell: bash -l {0}
@@ -50,8 +49,10 @@ jobs:
5049
run: |
5150
setup -k -r .
5251
scons -j2
52+
python -c 'import importlib.metadata as M; print(M.version("lsst.sconsUtils"))'
5353
5454
- name: Upload coverage to codecov
55-
uses: codecov/codecov-action@v2
55+
uses: codecov/codecov-action@v4
5656
with:
57-
file: tests/.tests/pytest-sconsUtils.xml-cov-sconsUtils.xml
57+
files: tests/.tests/pytest-sconsUtils.xml-cov-sconsUtils.xml
58+
token: ${{ secrets.CODECOV_TOKEN }}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,4 @@ tests/.tests
2323
tests/testFailedTests/python
2424
tests/testFailedTests/tests/.tests
2525
.coverage
26+
python/*.dist-info/

.pre-commit-config.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
repos:
22
- repo: https://github.com/pre-commit/pre-commit-hooks
3-
rev: v4.5.0
3+
rev: v5.0.0
44
hooks:
55
- id: check-toml
66
- id: check-yaml
@@ -9,7 +9,7 @@ repos:
99
- id: end-of-file-fixer
1010
- id: trailing-whitespace
1111
- repo: https://github.com/psf/black
12-
rev: 24.3.0
12+
rev: 24.10.0
1313
hooks:
1414
- id: black
1515
# It is recommended to specify the latest version of Python
@@ -24,6 +24,6 @@ repos:
2424
name: isort (python)
2525
- repo: https://github.com/astral-sh/ruff-pre-commit
2626
# Ruff version.
27-
rev: v0.3.3
27+
rev: v0.8.0
2828
hooks:
2929
- id: ruff

python/lsst/sconsUtils/builders.py

Lines changed: 168 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,14 @@
77
import os
88
import re
99
import shlex
10+
from stat import ST_MODE
1011

1112
import SCons.Script
1213
from SCons.Script.SConscript import SConsEnvironment
1314

1415
from . import state
1516
from .installation import determineVersion, getFingerprint
16-
from .utils import memberOf
17+
from .utils import memberOf, whichPython
1718

1819

1920
@memberOf(SConsEnvironment)
@@ -544,27 +545,32 @@ def Doxygen(self, config, **kwargs):
544545
return builder(self, config)
545546

546547

547-
@memberOf(SConsEnvironment)
548-
def VersionModule(self, filename, versionString=None):
548+
def _get_version_string(versionString):
549549
if versionString is None:
550550
for n in ("git", "hg", "svn"):
551551
if os.path.isdir(f".{n}"):
552552
versionString = n
553553

554554
if not versionString:
555555
versionString = "git"
556+
return versionString
556557

557-
def calcMd5(filename):
558-
try:
559-
import hashlib
560558

561-
md5 = hashlib.md5(open(filename, "rb").read()).hexdigest()
562-
except OSError:
563-
md5 = None
559+
def _calcMd5(filename):
560+
try:
561+
import hashlib
562+
563+
md5 = hashlib.md5(open(filename, "rb").read()).hexdigest()
564+
except OSError:
565+
md5 = None
564566

565-
return md5
567+
return md5
566568

567-
oldMd5 = calcMd5(filename)
569+
570+
@memberOf(SConsEnvironment)
571+
def VersionModule(self, filename, versionString=None):
572+
versionString = _get_version_string(versionString)
573+
oldMd5 = _calcMd5(filename)
568574

569575
def makeVersionModule(target, source, env):
570576
try:
@@ -629,10 +635,160 @@ def makeVersionModule(target, source, env):
629635
outFile.write(f' "{n}",\n')
630636
outFile.write(")\n")
631637

632-
if calcMd5(target[0].abspath) != oldMd5: # only print if something's changed
638+
if _calcMd5(target[0].abspath) != oldMd5: # only print if something's changed
633639
state.log.info(f'makeVersionModule(["{target[0]}"], [])')
634640

635641
result = self.Command(filename, [], self.Action(makeVersionModule, strfunction=lambda *args: None))
636642

637643
self.AlwaysBuild(result)
638644
return result
645+
646+
647+
@memberOf(SConsEnvironment)
648+
def PackageInfo(self, pythonDir, versionString=None):
649+
versionString = _get_version_string(versionString)
650+
651+
if not os.path.exists(pythonDir):
652+
return []
653+
654+
# Some information can come from the pyproject file.
655+
toml_metadata = {}
656+
if os.path.exists("pyproject.toml"):
657+
import tomllib
658+
659+
with open("pyproject.toml", "rb") as fd:
660+
toml_metadata = tomllib.load(fd)
661+
662+
toml_project = toml_metadata.get("project", {})
663+
pythonPackageName = ""
664+
if "name" in toml_project:
665+
pythonPackageName = toml_project["name"]
666+
else:
667+
if os.path.exists(os.path.join(pythonDir, "lsst")):
668+
pythonPackageName = "lsst_" + state.env["packageName"]
669+
else:
670+
pythonPackageName = state.env["packageName"]
671+
pythonPackageName = pythonPackageName.replace("_", "-")
672+
# The directory name is required to use "_" instead of "-"
673+
distDir = os.path.join(pythonDir, f"{pythonPackageName.replace('-', '_')}.dist-info")
674+
filename = os.path.join(distDir, "METADATA")
675+
oldMd5 = _calcMd5(filename)
676+
677+
def makePackageMetadata(target, source, env):
678+
# Create the metadata file.
679+
try:
680+
version = determineVersion(state.env, versionString)
681+
except RuntimeError:
682+
version = "unknown"
683+
684+
os.makedirs(os.path.dirname(target[0].abspath), exist_ok=True)
685+
with open(target[0].abspath, "w") as outFile:
686+
print("Metadata-Version: 1.0", file=outFile)
687+
print(f"Name: {pythonPackageName}", file=outFile)
688+
print(f"Version: {version}", file=outFile)
689+
690+
if _calcMd5(target[0].abspath) != oldMd5: # only print if something's changed
691+
state.log.info(f'PackageInfo(["{target[0]}"], [])')
692+
693+
results = []
694+
results.append(
695+
self.Command(filename, [], self.Action(makePackageMetadata, strfunction=lambda *args: None))
696+
)
697+
698+
# Create the entry points file if defined in the pyproject.toml file.
699+
entryPoints = toml_project.get("entry-points", {})
700+
if entryPoints:
701+
filename = os.path.join(distDir, "entry_points.txt")
702+
oldMd5 = _calcMd5(filename)
703+
704+
def makeEntryPoints(target, source, env):
705+
# Make the entry points file as necessary.
706+
if not entryPoints:
707+
return
708+
os.makedirs(os.path.dirname(target[0].abspath), exist_ok=True)
709+
710+
# Structure of entry points dict is something like:
711+
# "entry-points": {
712+
# "butler.cli": {
713+
# "pipe_base": "lsst.pipe.base.cli:get_cli_subcommands"
714+
# }
715+
# }
716+
# Which becomes a file with:
717+
# [butler.cli]
718+
# pipe_base = lsst.pipe.base.cli:get_cli_subcommands
719+
with open(target[0].abspath, "w") as fd:
720+
for entryGroup in entryPoints:
721+
print(f"[{entryGroup}]", file=fd)
722+
for entryPoint, entryValue in entryPoints[entryGroup].items():
723+
print(f"{entryPoint} = {entryValue}", file=fd)
724+
725+
if _calcMd5(target[0].abspath) != oldMd5: # only print if something's changed
726+
state.log.info(f'PackageInfo(["{target[0]}"], [])')
727+
728+
if entryPoints:
729+
results.append(
730+
self.Command(filename, [], self.Action(makeEntryPoints, strfunction=lambda *args: None))
731+
)
732+
733+
self.AlwaysBuild(results)
734+
return results
735+
736+
737+
@memberOf(SConsEnvironment)
738+
def PythonScripts(self):
739+
# Scripts are defined in the pyproject.toml file.
740+
toml_metadata = {}
741+
if os.path.exists("pyproject.toml"):
742+
import tomllib
743+
744+
with open("pyproject.toml", "rb") as fd:
745+
toml_metadata = tomllib.load(fd)
746+
747+
if not toml_metadata:
748+
return []
749+
750+
scripts = {}
751+
if "project" in toml_metadata and "scripts" in toml_metadata["project"]:
752+
scripts = toml_metadata["project"]["scripts"]
753+
754+
def makePythonScript(target, source, env):
755+
cmdfile = target[0].abspath
756+
command = os.path.basename(cmdfile)
757+
if command not in scripts:
758+
return
759+
os.makedirs(os.path.dirname(cmdfile), exist_ok=True)
760+
package, func = scripts[command].split(":", maxsplit=1)
761+
with open(cmdfile, "w") as fd:
762+
# Follow setuptools convention and always change the shebang.
763+
# Can not add noqa on Linux for long paths so do not add anywhere.
764+
print(
765+
rf"""#!{whichPython()}
766+
import sys
767+
from {package} import {func}
768+
if __name__ == '__main__':
769+
sys.exit({func}())
770+
""",
771+
file=fd,
772+
)
773+
774+
# Ensure the bin/ file is executable
775+
oldmode = os.stat(cmdfile)[ST_MODE] & 0o7777
776+
newmode = (oldmode | 0o555) & 0o7777
777+
if newmode != oldmode:
778+
state.log.info(f"Changing mode of {cmdfile} from {oldmode} to {newmode}")
779+
os.chmod(cmdfile, newmode)
780+
781+
results = []
782+
for cmd, code in scripts.items():
783+
filename = f"bin/{cmd}"
784+
785+
# Do not do anything if there is an equivalent target in bin.src
786+
# that shebang would trigger.
787+
if os.path.exists(f"bin.src/{cmd}"):
788+
continue
789+
790+
results.append(
791+
self.Command(filename, [], self.Action(makePythonScript, strfunction=lambda *args: None))
792+
)
793+
794+
return results

python/lsst/sconsUtils/scripts.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def initialize(
127127
disableCC : `bool`, optional
128128
Should the C++ compiler check be disabled? Disabling this checks
129129
allows a faster startup and permits building on systems that don't
130-
meet the requirements for the C++ compilter (e.g., for
130+
meet the requirements for the C++ compiler (e.g., for
131131
pure-python packages).
132132
133133
Returns
@@ -145,13 +145,20 @@ def initialize(
145145
state.env.BuildETags()
146146
if cleanExt is None:
147147
cleanExt = r"*~ core core.[1-9]* *.so *.os *.o *.pyc *.pkgc"
148-
state.env.CleanTree(cleanExt, "__pycache__ .pytest_cache")
148+
state.env.CleanTree(cleanExt, "__pycache__ .pytest_cache *.dist-info")
149149
if versionModuleName is not None:
150150
try:
151151
versionModuleName = versionModuleName % "/".join(packageName.split("_"))
152152
except TypeError:
153153
pass
154154
state.targets["version"] = state.env.VersionModule(versionModuleName)
155+
# Always attempt to write python package info into the python
156+
# directory.
157+
if os.path.exists("python"):
158+
state.targets["pkginfo"] = state.env.PackageInfo("python")
159+
# Python script generation does no harm since it will only do anything
160+
# if there is a scripts entry in pyproject.toml.
161+
state.targets["scripts"] = state.env.PythonScripts()
155162
scripts = []
156163
for root, dirs, files in os.walk("."):
157164
if "SConstruct" in files and root != ".":
@@ -233,7 +240,14 @@ def finish(defaultTargets=DEFAULT_TARGETS, subDirList=None, ignoreRegex=None):
233240
)
234241
if "version" in state.targets:
235242
state.env.Default(state.targets["version"])
236-
state.env.Requires(state.targets["tests"], state.targets["version"])
243+
state.env.Requires(state.targets["tests"], state.targets["version"])
244+
if "pkginfo" in state.targets:
245+
state.env.Default(state.targets["pkginfo"])
246+
state.env.Requires(state.targets["tests"], state.targets["pkginfo"])
247+
if "scripts" in state.targets:
248+
state.env.Default(state.targets["scripts"])
249+
state.env.Requires(state.targets["tests"], state.targets["scripts"])
250+
237251
state.env.Decider("MD5-timestamp") # if timestamps haven't changed, don't do MD5 checks
238252
#
239253
# Check if any of the tests failed by looking for *.failed files.

python/lsst/sconsUtils/state.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
"include": [],
5050
"version": [],
5151
"shebang": [],
52+
"pkginfo": [],
53+
"scripts": [],
5254
}
5355

5456
env = None

0 commit comments

Comments
 (0)