calkit-python 0.41.6__py3-none-any.whl → 0.41.8__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.
- calkit/cli/main/core.py +10 -1
- calkit/cli/new.py +80 -47
- calkit/cli/overleaf.py +12 -7
- calkit/core.py +9 -0
- calkit/invenio.py +20 -0
- calkit/licenses.py +152 -0
- calkit/overleaf.py +215 -79
- calkit/pipeline.py +126 -3
- calkit/releases.py +129 -29
- calkit/tests/cli/main/test_subprojects.py +53 -0
- calkit/tests/cli/test_new.py +427 -13
- calkit/tests/cli/test_overleaf.py +128 -4
- calkit/tests/test_core.py +19 -0
- calkit/tests/test_invenio.py +20 -0
- calkit/tests/test_licenses.py +105 -0
- calkit/tests/test_pipeline.py +111 -0
- calkit/tests/test_releases.py +118 -0
- {calkit_python-0.41.6.dist-info → calkit_python-0.41.8.dist-info}/METADATA +1 -1
- {calkit_python-0.41.6.dist-info → calkit_python-0.41.8.dist-info}/RECORD +37 -36
- {calkit_python-0.41.6.dist-info → calkit_python-0.41.8.dist-info}/WHEEL +1 -1
- {calkit_python-0.41.6.data → calkit_python-0.41.8.data}/data/etc/jupyter/jupyter_server_config.d/calkit.json +0 -0
- {calkit_python-0.41.6.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/install.json +0 -0
- {calkit_python-0.41.6.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/package.json +0 -0
- {calkit_python-0.41.6.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/package.json.orig +0 -0
- {calkit_python-0.41.6.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/schemas/calkit/plugin.json +0 -0
- {calkit_python-0.41.6.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/502.9a2c5772a15466e923ef.js +0 -0
- {calkit_python-0.41.6.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/695.2c41003a452d43d2b358.js +0 -0
- {calkit_python-0.41.6.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/867.a42a046aa5108f54f8fb.js +0 -0
- {calkit_python-0.41.6.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/909.e3f9cc3408834a7fdcc3.js +0 -0
- {calkit_python-0.41.6.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js +0 -0
- {calkit_python-0.41.6.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/946.050af2abf7845cfbdbd2.js.LICENSE.txt +0 -0
- {calkit_python-0.41.6.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/b2f1c3efe70cb539d121.png +0 -0
- {calkit_python-0.41.6.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/remoteEntry.65469af996e7a96aa983.js +0 -0
- {calkit_python-0.41.6.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/style.js +0 -0
- {calkit_python-0.41.6.data → calkit_python-0.41.8.data}/data/share/jupyter/labextensions/calkit/static/third-party-licenses.json +0 -0
- {calkit_python-0.41.6.dist-info → calkit_python-0.41.8.dist-info}/entry_points.txt +0 -0
- {calkit_python-0.41.6.dist-info → calkit_python-0.41.8.dist-info}/licenses/LICENSE +0 -0
calkit/cli/main/core.py
CHANGED
|
@@ -2418,7 +2418,16 @@ def run_in_env(
|
|
|
2418
2418
|
)
|
|
2419
2419
|
# TODO: Prefix should only be in the env file or calkit.yaml, not both?
|
|
2420
2420
|
prefix = env.get("prefix")
|
|
2421
|
-
|
|
2421
|
+
# Conda is often not on the PATH (especially on Windows), so search
|
|
2422
|
+
# typical install locations rather than relying on the bare name,
|
|
2423
|
+
# which would fail with a confusing FileNotFoundError traceback.
|
|
2424
|
+
conda_exe = calkit.conda.find_conda_exe()
|
|
2425
|
+
if conda_exe is None:
|
|
2426
|
+
raise_error(
|
|
2427
|
+
"Cannot find Conda executable; "
|
|
2428
|
+
"ensure Conda is installed and on the PATH"
|
|
2429
|
+
)
|
|
2430
|
+
conda_cmd = [conda_exe, "run"]
|
|
2422
2431
|
if prefix is not None:
|
|
2423
2432
|
conda_cmd += ["--prefix", os.path.abspath(prefix)]
|
|
2424
2433
|
else:
|
calkit/cli/new.py
CHANGED
|
@@ -7,6 +7,7 @@ import os
|
|
|
7
7
|
import pathlib
|
|
8
8
|
import shutil
|
|
9
9
|
import subprocess
|
|
10
|
+
import time
|
|
10
11
|
from enum import Enum
|
|
11
12
|
|
|
12
13
|
import typer
|
|
@@ -3124,18 +3125,7 @@ def new_release(
|
|
|
3124
3125
|
record_id = None
|
|
3125
3126
|
# Detect project license IDs if necessary
|
|
3126
3127
|
if not license_ids:
|
|
3127
|
-
license_file =
|
|
3128
|
-
license_txt = None
|
|
3129
|
-
license_candidates = [
|
|
3130
|
-
"LICENSE",
|
|
3131
|
-
"LICENSE.rst",
|
|
3132
|
-
"LICENSE.txt",
|
|
3133
|
-
"LICENSE.md",
|
|
3134
|
-
]
|
|
3135
|
-
for lc in license_candidates:
|
|
3136
|
-
if os.path.isfile(lc):
|
|
3137
|
-
license_file = lc
|
|
3138
|
-
break
|
|
3128
|
+
license_file = calkit.licenses.find_license_file()
|
|
3139
3129
|
if license_file is None:
|
|
3140
3130
|
typer.echo("No project license found.")
|
|
3141
3131
|
use_default_license = typer.confirm(
|
|
@@ -3155,15 +3145,18 @@ def new_release(
|
|
|
3155
3145
|
f.write(license_txt)
|
|
3156
3146
|
repo.git.add("LICENSE")
|
|
3157
3147
|
license_ids = ["mit", "cc-by-4.0"]
|
|
3158
|
-
|
|
3148
|
+
else:
|
|
3149
|
+
typer.echo(f"Detecting license(s) from {license_file}")
|
|
3159
3150
|
with open(license_file) as f:
|
|
3160
3151
|
license_txt = f.read()
|
|
3161
|
-
|
|
3162
|
-
if
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3152
|
+
license_ids = calkit.licenses.detect_license_ids(license_txt)
|
|
3153
|
+
if license_ids:
|
|
3154
|
+
typer.echo(f"Detected license(s): {', '.join(license_ids)}")
|
|
3155
|
+
else:
|
|
3156
|
+
warn(
|
|
3157
|
+
f"Could not detect any known license in {license_file}; "
|
|
3158
|
+
"specify one explicitly with --license"
|
|
3159
|
+
)
|
|
3167
3160
|
if not license_ids:
|
|
3168
3161
|
raise_error("Project has no license(s) defined")
|
|
3169
3162
|
invenio_metadata = dict(
|
|
@@ -3189,9 +3182,13 @@ def new_release(
|
|
|
3189
3182
|
)
|
|
3190
3183
|
invenio_metadata["related_identifiers"] = related_identifiers
|
|
3191
3184
|
# TODO: Add calkit.io URL if applicable?
|
|
3192
|
-
# Determine creators from authors
|
|
3193
|
-
|
|
3194
|
-
|
|
3185
|
+
# Determine creators from authors. CITATION.cff is the single source of
|
|
3186
|
+
# truth for authors, so read them from there; if none are defined yet,
|
|
3187
|
+
# prompt for them and persist them to CITATION.cff.
|
|
3188
|
+
authors = calkit.releases.read_authors_from_cff()
|
|
3189
|
+
if authors:
|
|
3190
|
+
typer.echo(f"Read {len(authors)} author(s) from CITATION.cff")
|
|
3191
|
+
else:
|
|
3195
3192
|
warn("No authors defined for the project")
|
|
3196
3193
|
still_entering_authors = True
|
|
3197
3194
|
n = 0
|
|
@@ -3218,24 +3215,27 @@ def new_release(
|
|
|
3218
3215
|
still_entering_authors = typer.confirm(
|
|
3219
3216
|
"Are there more authors to enter?", default=True
|
|
3220
3217
|
)
|
|
3221
|
-
|
|
3222
|
-
# Write authors out to calkit.yaml
|
|
3218
|
+
# Write authors out to CITATION.cff
|
|
3223
3219
|
if not dry_run:
|
|
3224
|
-
typer.echo("
|
|
3225
|
-
|
|
3226
|
-
|
|
3220
|
+
typer.echo("Writing authors to CITATION.cff")
|
|
3221
|
+
calkit.releases.set_cff_authors(authors, ck_info=ck_info)
|
|
3222
|
+
repo.git.add("CITATION.cff")
|
|
3227
3223
|
invenio_creators = []
|
|
3228
3224
|
for author in authors:
|
|
3229
3225
|
orcid = author.get("orcid")
|
|
3230
|
-
creator = {
|
|
3226
|
+
creator: dict = {
|
|
3231
3227
|
"person_or_org": {
|
|
3232
3228
|
"type": "personal",
|
|
3233
3229
|
"given_name": author["first_name"],
|
|
3234
3230
|
"family_name": author["last_name"],
|
|
3235
3231
|
"identifiers": [{"identifier": orcid}] if orcid else [],
|
|
3236
3232
|
},
|
|
3237
|
-
"affiliations": [{"name": author["affiliation"]}],
|
|
3238
3233
|
}
|
|
3234
|
+
# Affiliation is optional (e.g., authors read from CITATION.cff may
|
|
3235
|
+
# not specify one)
|
|
3236
|
+
affiliation = author.get("affiliation")
|
|
3237
|
+
if affiliation:
|
|
3238
|
+
creator["affiliations"] = [{"name": affiliation}]
|
|
3239
3239
|
invenio_creators.append(creator)
|
|
3240
3240
|
invenio_metadata["creators"] = invenio_creators # type: ignore
|
|
3241
3241
|
# Set InvenioRDM resource_type based on release_type and artifact kind
|
|
@@ -3326,22 +3326,23 @@ def new_release(
|
|
|
3326
3326
|
f"/records/{record_id}/draft/files/{filename}/commit",
|
|
3327
3327
|
service=to, # type: ignore
|
|
3328
3328
|
)
|
|
3329
|
-
#
|
|
3330
|
-
if
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3329
|
+
# Reserve a DOI on the draft before publishing. The publish action's
|
|
3330
|
+
# response only echoes back the DOI under "pids" if one was reserved
|
|
3331
|
+
# on the draft first (otherwise "pids" comes back empty), so always
|
|
3332
|
+
# reserve here to get a stable identifier.
|
|
3333
|
+
typer.echo(f"Reserving DOI for {to} draft record ID {record_id}")
|
|
3334
|
+
doi_resp = calkit.invenio.post(
|
|
3335
|
+
f"/records/{record_id}/draft/pids/doi",
|
|
3336
|
+
service=to, # type: ignore
|
|
3337
|
+
)
|
|
3338
|
+
if verbose:
|
|
3339
|
+
typer.echo(f"DOI reservation response:\n{doi_resp}")
|
|
3340
|
+
doi = calkit.invenio.extract_doi(doi_resp)
|
|
3341
|
+
if doi is None:
|
|
3342
|
+
raise_error(
|
|
3343
|
+
f"Failed to reserve DOI for {to} draft record {record_id}"
|
|
3336
3344
|
)
|
|
3337
|
-
|
|
3338
|
-
typer.echo(f"DOI response for draft:\n{doi_resp}")
|
|
3339
|
-
try:
|
|
3340
|
-
doi = doi_resp["pids"]["doi"]["identifier"]
|
|
3341
|
-
except KeyError:
|
|
3342
|
-
doi = doi_resp["doi"]
|
|
3343
|
-
except Exception as e:
|
|
3344
|
-
raise_error(f"Failed to reserve DOI for draft: {e}")
|
|
3345
|
+
if draft_only:
|
|
3345
3346
|
url = f"https://doi.org/{doi}"
|
|
3346
3347
|
typer.echo(f"Created {to} draft with reserved DOI: {doi}")
|
|
3347
3348
|
else:
|
|
@@ -3351,8 +3352,37 @@ def new_release(
|
|
|
3351
3352
|
f"/records/{record_id}/draft/actions/publish",
|
|
3352
3353
|
service=to, # type: ignore
|
|
3353
3354
|
)
|
|
3355
|
+
if verbose:
|
|
3356
|
+
typer.echo(f"Publish response:\n{invenio_dep}")
|
|
3354
3357
|
record_id = invenio_dep["id"]
|
|
3355
|
-
|
|
3358
|
+
# Prefer the DOI from the publish response, but fall back to the
|
|
3359
|
+
# reserved DOI (and a fetch of the published record) since the
|
|
3360
|
+
# publish action returns 202 and may not echo "pids" immediately.
|
|
3361
|
+
# Polling failures are tolerated: we already have the reserved DOI,
|
|
3362
|
+
# so a transient error while the record settles should not abort
|
|
3363
|
+
# the release.
|
|
3364
|
+
published_doi = calkit.invenio.extract_doi(invenio_dep)
|
|
3365
|
+
for _ in range(10):
|
|
3366
|
+
if published_doi is not None:
|
|
3367
|
+
break
|
|
3368
|
+
time.sleep(1)
|
|
3369
|
+
try:
|
|
3370
|
+
record = calkit.invenio.get(
|
|
3371
|
+
f"/records/{record_id}",
|
|
3372
|
+
service=to, # type: ignore
|
|
3373
|
+
)
|
|
3374
|
+
except Exception as e:
|
|
3375
|
+
if verbose:
|
|
3376
|
+
typer.echo(f"Polling for published DOI failed: {e}")
|
|
3377
|
+
continue
|
|
3378
|
+
published_doi = calkit.invenio.extract_doi(record)
|
|
3379
|
+
if published_doi is not None:
|
|
3380
|
+
doi = published_doi
|
|
3381
|
+
else:
|
|
3382
|
+
typer.echo(
|
|
3383
|
+
"Could not confirm minted DOI from published record; "
|
|
3384
|
+
f"falling back to reserved DOI {doi}"
|
|
3385
|
+
)
|
|
3356
3386
|
url = f"https://doi.org/{doi}"
|
|
3357
3387
|
typer.echo(f"Published to {to} with DOI: {doi}")
|
|
3358
3388
|
else:
|
|
@@ -3423,10 +3453,13 @@ def new_release(
|
|
|
3423
3453
|
)
|
|
3424
3454
|
releases[name] = release
|
|
3425
3455
|
ck_info["releases"] = releases
|
|
3426
|
-
# Create CITATION.cff file
|
|
3456
|
+
# Create/update CITATION.cff file, preserving existing authors
|
|
3427
3457
|
if release_type == "project":
|
|
3428
3458
|
cff = calkit.releases.create_citation_cff(
|
|
3429
|
-
ck_info=ck_info,
|
|
3459
|
+
ck_info=ck_info,
|
|
3460
|
+
release_name=name,
|
|
3461
|
+
release_date=release_date,
|
|
3462
|
+
authors=authors,
|
|
3430
3463
|
)
|
|
3431
3464
|
if dry_run:
|
|
3432
3465
|
typer.echo(f"Would write CITATION.cff:\n{cff}")
|
calkit/cli/overleaf.py
CHANGED
|
@@ -12,9 +12,9 @@ import typer
|
|
|
12
12
|
from typing_extensions import Annotated
|
|
13
13
|
|
|
14
14
|
import calkit
|
|
15
|
-
from calkit.cli import raise_error, warn
|
|
15
|
+
from calkit.cli import AliasGroup, raise_error, warn
|
|
16
16
|
|
|
17
|
-
overleaf_app = typer.Typer(no_args_is_help=True)
|
|
17
|
+
overleaf_app = typer.Typer(cls=AliasGroup, no_args_is_help=True)
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _extract_title_from_tex(tex_file_path: str) -> str | None:
|
|
@@ -369,8 +369,11 @@ def sync(
|
|
|
369
369
|
typer.Option(
|
|
370
370
|
"--no-commit",
|
|
371
371
|
help=(
|
|
372
|
-
"Do not
|
|
373
|
-
"Changes
|
|
372
|
+
"Do not create a commit in the project repo for this sync. "
|
|
373
|
+
"Changes pulled from Overleaf are still applied, but are "
|
|
374
|
+
"left staged so you can review or commit them yourself. "
|
|
375
|
+
"Changes are always committed and pushed to Overleaf "
|
|
376
|
+
"regardless."
|
|
374
377
|
),
|
|
375
378
|
),
|
|
376
379
|
] = False,
|
|
@@ -378,6 +381,7 @@ def sync(
|
|
|
378
381
|
bool,
|
|
379
382
|
typer.Option(
|
|
380
383
|
"--auto-commit",
|
|
384
|
+
"-a",
|
|
381
385
|
help=(
|
|
382
386
|
"Automatically commit changes to the project repo if a synced "
|
|
383
387
|
"folder has changes."
|
|
@@ -490,8 +494,9 @@ def sync(
|
|
|
490
494
|
)
|
|
491
495
|
else:
|
|
492
496
|
raise_error(
|
|
493
|
-
f"Uncommitted changes found in {wdir}
|
|
494
|
-
"
|
|
497
|
+
f"Uncommitted changes found in {wdir}. "
|
|
498
|
+
"Commit or stash them before syncing with Overleaf, "
|
|
499
|
+
"or use --auto-commit/-a to automatically commit them."
|
|
495
500
|
)
|
|
496
501
|
# Ensure we've cloned the Overleaf project
|
|
497
502
|
overleaf_project_dir = os.path.join(
|
|
@@ -585,7 +590,7 @@ def compare_folders_recursively(
|
|
|
585
590
|
return res
|
|
586
591
|
|
|
587
592
|
|
|
588
|
-
@overleaf_app.command(name="status")
|
|
593
|
+
@overleaf_app.command(name="status|st")
|
|
589
594
|
def get_status(
|
|
590
595
|
paths: Annotated[
|
|
591
596
|
list[str] | None,
|
calkit/core.py
CHANGED
|
@@ -50,6 +50,7 @@ AUTO_IGNORE_SUFFIXES = [
|
|
|
50
50
|
".env",
|
|
51
51
|
".pyc",
|
|
52
52
|
".synctex.gz",
|
|
53
|
+
".auxlock",
|
|
53
54
|
".ipynb_checkpoints",
|
|
54
55
|
]
|
|
55
56
|
AUTO_IGNORE_PATHS = [os.path.join(".dvc", "config.local")]
|
|
@@ -340,6 +341,14 @@ def check_dep_exists(
|
|
|
340
341
|
return True
|
|
341
342
|
if system_info is not None and system_info.get(f"{name}_version"):
|
|
342
343
|
return True
|
|
344
|
+
# Conda and mamba are frequently installed but not on the PATH (most
|
|
345
|
+
# commonly on Windows), so search their typical install locations
|
|
346
|
+
# rather than relying on the bare name being directly executable.
|
|
347
|
+
if name in ("conda", "mamba"):
|
|
348
|
+
from calkit.conda import find_conda_exe, find_mamba_exe
|
|
349
|
+
|
|
350
|
+
exe = find_conda_exe() if name == "conda" else find_mamba_exe()
|
|
351
|
+
return exe is not None
|
|
343
352
|
cmd = [name]
|
|
344
353
|
# Executables with non-conventional CLIs
|
|
345
354
|
if name == "matlab":
|
calkit/invenio.py
CHANGED
|
@@ -107,6 +107,26 @@ put = partial(_request, "put")
|
|
|
107
107
|
delete = partial(_request, "delete")
|
|
108
108
|
|
|
109
109
|
|
|
110
|
+
def extract_doi(record: dict) -> str | None:
|
|
111
|
+
"""Extract the DOI identifier from an InvenioRDM record or response.
|
|
112
|
+
|
|
113
|
+
Depending on the endpoint and whether the DOI has been minted yet, the
|
|
114
|
+
identifier can live in different places, so check all known locations and
|
|
115
|
+
return ``None`` if it isn't present yet.
|
|
116
|
+
"""
|
|
117
|
+
pids = record.get("pids")
|
|
118
|
+
if isinstance(pids, dict):
|
|
119
|
+
doi = pids.get("doi")
|
|
120
|
+
if isinstance(doi, dict) and doi.get("identifier"):
|
|
121
|
+
return doi["identifier"]
|
|
122
|
+
if record.get("doi"):
|
|
123
|
+
return record["doi"]
|
|
124
|
+
metadata = record.get("metadata")
|
|
125
|
+
if isinstance(metadata, dict) and metadata.get("doi"):
|
|
126
|
+
return metadata["doi"]
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
110
130
|
def get_download_urls(
|
|
111
131
|
record_id: int | str,
|
|
112
132
|
service: ServiceName = DEFAULT_SERVICE,
|
calkit/licenses.py
CHANGED
|
@@ -4,6 +4,158 @@ Calkit projects will often include code, data, text, and other content
|
|
|
4
4
|
and artifacts that may each have their own license.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
# A list of (SPDX license ID, required substring groups) used to detect
|
|
13
|
+
# licenses from the text of a license file.
|
|
14
|
+
# Each entry maps an SPDX ID to a list of "all-of" groups; the license
|
|
15
|
+
# matches if *every* group has at least one of its substrings present in the
|
|
16
|
+
# (lowercased, whitespace-normalized) license text.
|
|
17
|
+
# This is intentionally tolerant so that licenses written by hand, generated
|
|
18
|
+
# by GitHub, or produced by Calkit's own templates are all detected.
|
|
19
|
+
# A single license file may contain more than one license (e.g., Calkit's
|
|
20
|
+
# default dual MIT/CC-BY-4.0 license), so all matching IDs are returned.
|
|
21
|
+
_LICENSE_MARKERS: list[tuple[str, list[list[str]]]] = [
|
|
22
|
+
(
|
|
23
|
+
"mit",
|
|
24
|
+
[
|
|
25
|
+
[
|
|
26
|
+
"mit license",
|
|
27
|
+
"permission is hereby granted, free of charge, to any "
|
|
28
|
+
"person obtaining a copy",
|
|
29
|
+
]
|
|
30
|
+
],
|
|
31
|
+
),
|
|
32
|
+
(
|
|
33
|
+
"apache-2.0",
|
|
34
|
+
[["apache license"], ["version 2.0", "version 2,"]],
|
|
35
|
+
),
|
|
36
|
+
(
|
|
37
|
+
"bsd-3-clause",
|
|
38
|
+
[
|
|
39
|
+
["redistribution and use in source and binary forms"],
|
|
40
|
+
["neither the name of"],
|
|
41
|
+
],
|
|
42
|
+
),
|
|
43
|
+
(
|
|
44
|
+
"bsd-2-clause",
|
|
45
|
+
[
|
|
46
|
+
["redistribution and use in source and binary forms"],
|
|
47
|
+
],
|
|
48
|
+
),
|
|
49
|
+
(
|
|
50
|
+
"gpl-3.0",
|
|
51
|
+
[["gnu general public license"], ["version 3"]],
|
|
52
|
+
),
|
|
53
|
+
(
|
|
54
|
+
"gpl-2.0",
|
|
55
|
+
[["gnu general public license"], ["version 2"]],
|
|
56
|
+
),
|
|
57
|
+
(
|
|
58
|
+
"lgpl-3.0",
|
|
59
|
+
[["gnu lesser general public license"], ["version 3"]],
|
|
60
|
+
),
|
|
61
|
+
(
|
|
62
|
+
"agpl-3.0",
|
|
63
|
+
[["gnu affero general public license"], ["version 3"]],
|
|
64
|
+
),
|
|
65
|
+
(
|
|
66
|
+
"mpl-2.0",
|
|
67
|
+
[["mozilla public license"], ["version 2.0", "version 2,"]],
|
|
68
|
+
),
|
|
69
|
+
(
|
|
70
|
+
"cc-by-4.0",
|
|
71
|
+
[
|
|
72
|
+
[
|
|
73
|
+
"creative commons attribution 4.0",
|
|
74
|
+
"cc by 4.0",
|
|
75
|
+
"cc-by-4.0",
|
|
76
|
+
"creativecommons.org/licenses/by/4.0",
|
|
77
|
+
]
|
|
78
|
+
],
|
|
79
|
+
),
|
|
80
|
+
(
|
|
81
|
+
"cc0-1.0",
|
|
82
|
+
[
|
|
83
|
+
[
|
|
84
|
+
"cc0 1.0",
|
|
85
|
+
"creative commons zero",
|
|
86
|
+
"creativecommons.org/publicdomain/zero/1.0",
|
|
87
|
+
]
|
|
88
|
+
],
|
|
89
|
+
),
|
|
90
|
+
(
|
|
91
|
+
"isc",
|
|
92
|
+
[
|
|
93
|
+
[
|
|
94
|
+
"isc license",
|
|
95
|
+
"permission to use, copy, modify, and/or distribute this "
|
|
96
|
+
"software",
|
|
97
|
+
]
|
|
98
|
+
],
|
|
99
|
+
),
|
|
100
|
+
(
|
|
101
|
+
"unlicense",
|
|
102
|
+
[
|
|
103
|
+
[
|
|
104
|
+
"this is free and unencumbered software released into the "
|
|
105
|
+
"public domain"
|
|
106
|
+
]
|
|
107
|
+
],
|
|
108
|
+
),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
# Candidate file names to search for a project license, in priority order.
|
|
112
|
+
LICENSE_FILE_CANDIDATES = [
|
|
113
|
+
"LICENSE",
|
|
114
|
+
"LICENSE.txt",
|
|
115
|
+
"LICENSE.md",
|
|
116
|
+
"LICENSE.rst",
|
|
117
|
+
"LICENCE",
|
|
118
|
+
"LICENCE.txt",
|
|
119
|
+
"LICENCE.md",
|
|
120
|
+
"COPYING",
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def detect_license_ids(text: str) -> list[str]:
|
|
125
|
+
"""Detect SPDX license IDs present in license text.
|
|
126
|
+
|
|
127
|
+
The match is tolerant of whitespace and case, and a single body of text
|
|
128
|
+
can match multiple licenses (e.g., a dual-licensed project).
|
|
129
|
+
Returns the IDs in a deterministic order with duplicates removed.
|
|
130
|
+
"""
|
|
131
|
+
if not text:
|
|
132
|
+
return []
|
|
133
|
+
# Lowercase and collapse all runs of whitespace so that line wrapping in
|
|
134
|
+
# the source license file does not break multi-word substring matches
|
|
135
|
+
normalized = re.sub(r"\s+", " ", text.lower())
|
|
136
|
+
found: list[str] = []
|
|
137
|
+
for license_id, groups in _LICENSE_MARKERS:
|
|
138
|
+
# The "bsd-3-clause" and "bsd-2-clause" markers overlap; only report
|
|
139
|
+
# the 2-clause variant when the 3-clause variant did not match
|
|
140
|
+
if license_id == "bsd-2-clause" and "bsd-3-clause" in found:
|
|
141
|
+
continue
|
|
142
|
+
if all(
|
|
143
|
+
any(substring in normalized for substring in group)
|
|
144
|
+
for group in groups
|
|
145
|
+
):
|
|
146
|
+
found.append(license_id)
|
|
147
|
+
return found
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def find_license_file(wdir: str | None = None) -> str | None:
|
|
151
|
+
"""Return the path to the project license file, if one exists."""
|
|
152
|
+
for candidate in LICENSE_FILE_CANDIDATES:
|
|
153
|
+
path = candidate if wdir is None else os.path.join(wdir, candidate)
|
|
154
|
+
if os.path.isfile(path):
|
|
155
|
+
return path
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
|
|
7
159
|
LICENSE_TEMPLATE_DUAL = """
|
|
8
160
|
This project is licensed under a dual-license structure to appropriately cover
|
|
9
161
|
both the software code and the non-code content (data, text, figures, etc.).
|