Skip to content

Commit 666fc21

Browse files
authored
Add docs, community md etc (#25)
fixes #4
1 parent 0699324 commit 666fc21

20 files changed

Lines changed: 698 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Changelog
2+
3+
Generated from merged pull requests with `build_tools/changelog.py`; see that file's
4+
docstring for how classification works.

CONTRIBUTING.md

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Contributing
2+
3+
PyQit is `0.1.0b1`, unstable, and mostly one maintainer plus dependabot. Issues and PRs
4+
are welcome, and this doubles as a note-to-self for keeping things consistent.
5+
6+
## Setup
7+
8+
```bash
9+
git clone https://github.com/phoeenniixx/pyqit.git
10+
cd pyqit
11+
pip install -e ".[dev]" # base + test tooling
12+
pip install -e ".[dev,all_extras]" # + torch, lightning, matplotlib, rich
13+
pre-commit install
14+
```
15+
16+
`all_extras` is what CI runs against, so a PR touching torch/lightning/matplotlib/rich
17+
needs it locally to actually exercise that code. Anything new in that area also needs to
18+
degrade gracefully with only `[dev]` installed, since CI runs both.
19+
20+
## Before opening a PR
21+
22+
```bash
23+
python -m pytest
24+
pre-commit run --all-files # ruff, ruff-format, nbQA on notebooks
25+
```
26+
27+
Run the notebooks if you touched anything they import. `run-notebook-tutorials` runs them
28+
in CI too, `--inplace`, so the same command regenerates their stored outputs locally
29+
before you commit.
30+
31+
## Adding an ansatz, embedding, model, or loss
32+
33+
There's no registration step. A class with the right `object_type` tag is picked up by
34+
`all_objects()` and enrolled in the test suite automatically, as long as it implements
35+
`get_test_params()`. One catch: skbase's discovery walk skips any module whose name
36+
starts with `_`, so a class defined there is silently never found. Put it somewhere
37+
else.
38+
39+
## Soft dependencies
40+
41+
torch, lightning, matplotlib, and rich are optional. Guard any import of them with
42+
`skbase.utils.dependencies._check_soft_dependencies(..., severity="none")`, not
43+
`_safe_import`. `_safe_import` falls back to a `MagicMock`, and a missing dependency
44+
then returns mock objects instead of raising, which fails in stranger ways somewhere
45+
downstream instead of at the import.
46+
47+
## Labeling PRs
48+
49+
`build_tools/changelog.py` builds the changelog from merged PRs, sorted into Bug fixes,
50+
Enhancements, Documentation, and Maintenance by GitHub label first, a keyword guess from
51+
the title second. Add one of `bug`, `enhancement`, `documentation`, `maintenance` when
52+
you open or merge a PR. An unlabeled PR that also doesn't match a keyword lands in a
53+
"Needs a label" bucket rather than getting force-fit into one of the four; the fix is to
54+
label it and rerun the script, not to hand-edit the output.
55+
56+
## Commit messages
57+
58+
This repo squash-merges, so a PR's title becomes its one commit message on `main`. Write
59+
the title as you want it to read in the changelog and commit history.

build_tools/changelog.py

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
#!/usr/bin/env python3
2+
"""Draft a changelog entry from merged pull requests on GitHub.
3+
4+
python build_tools/changelog.py
5+
python build_tools/changelog.py --since-pr 20
6+
python build_tools/changelog.py --since-date 2026-06-01
7+
8+
Needs the GitHub CLI (``gh``), logged in (``gh auth login``). Queries merged
9+
PRs against the repository's ``main`` branch on GitHub itself, not local git
10+
state, so the result does not depend on which branch or commit you happen to
11+
have checked out.
12+
13+
A PR's ``bug`` / ``enhancement`` / ``documentation`` / ``maintenance`` label
14+
decides its section. Many PRs in this repo carry no label, so an unlabeled PR
15+
falls back to a keyword match on its title. A PR that matches no label and no
16+
keyword lands in "Needs a label" rather than a guessed section, so labeling
17+
it and re-running is the fix, not moving it by hand.
18+
19+
Nothing is written to CHANGELOG.md automatically. Paste the output in
20+
yourself, replacing the whole "## Unreleased" section each time -- it is
21+
meant to be regenerated in full, not appended to. Until you rename that
22+
heading to a real version and start a fresh "## Unreleased" above it,
23+
nothing counts as released, so a plain re-run always shows every merged PR
24+
again. Renaming it to a version heading is what makes "--since-pr" (or the
25+
auto-detected default) start narrowing later runs.
26+
"""
27+
28+
import argparse
29+
import json
30+
from pathlib import Path
31+
import re
32+
import shutil
33+
import subprocess
34+
import sys
35+
36+
SECTIONS = {
37+
"bug": "Bug fixes",
38+
"enhancement": "Enhancements",
39+
"documentation": "Documentation",
40+
"maintenance": "Maintenance",
41+
}
42+
43+
# Not a real GitHub label; where a PR lands when no label and no keyword
44+
# rule below matched it. Kept separate from SECTIONS so it renders last and
45+
# is never treated as a labelable category to search for on a PR.
46+
UNCLASSIFIED = "unclassified"
47+
UNCLASSIFIED_HEADING = "Needs a label"
48+
49+
# Tried in order against the title, for PRs with no matching label.
50+
KEYWORD_RULES = [
51+
("bug", re.compile(r"\bfix|bug|hotfix|\bpatch", re.I)),
52+
(
53+
"documentation",
54+
re.compile(r"\bdocs?\b|readme|tutorial|notebook|changelog|contributing", re.I),
55+
),
56+
(
57+
"maintenance",
58+
re.compile(
59+
r"\bchore|refactor|cleanup|bump|deps?\b|dependabot|\bci\b|revert"
60+
r"|merge branch",
61+
re.I,
62+
),
63+
),
64+
]
65+
66+
CHANGELOG_PATH = Path(__file__).resolve().parent.parent / "CHANGELOG.md"
67+
68+
69+
def _run(cmd: list[str]) -> str:
70+
result = subprocess.run(cmd, capture_output=True, text=True)
71+
if result.returncode != 0:
72+
sys.exit(f"$ {' '.join(cmd)}\n{result.stderr.strip()}")
73+
return result.stdout
74+
75+
76+
def _require_gh() -> None:
77+
if shutil.which("gh") is None:
78+
sys.exit("Needs the GitHub CLI ('gh'). Install it, then 'gh auth login'.")
79+
if subprocess.run(["gh", "auth", "status"], capture_output=True).returncode != 0:
80+
sys.exit("'gh' is not logged in. Run 'gh auth login' first.")
81+
82+
83+
def _default_repo() -> str:
84+
"""OWNER/REPO, read from the 'origin' remote rather than hardcoded."""
85+
url = _run(["git", "remote", "get-url", "origin"]).strip()
86+
match = re.search(r"github\.com[:/](?P<repo>[^/]+/[^/]+?)(\.git)?$", url)
87+
if not match:
88+
sys.exit(f"Could not read a GitHub repo from the 'origin' remote: {url!r}")
89+
return match.group("repo")
90+
91+
92+
def _last_released_pr() -> int | None:
93+
"""Highest PR number under the most recent *released* heading.
94+
95+
"## Unreleased" is not a release: it gets fully regenerated on every run
96+
until you rename it to a real version and start a fresh, empty
97+
"Unreleased" above it. Counting PRs already listed there as "handled"
98+
would mean a plain re-run stops reporting anything the moment you first
99+
paste a draft in, which is the wrong direction -- there is nothing to
100+
protect against re-showing until something has actually shipped.
101+
"""
102+
if not CHANGELOG_PATH.exists():
103+
return None
104+
sections = re.split(r"^## (.+)$", CHANGELOG_PATH.read_text(), flags=re.M)
105+
for heading, body in zip(sections[1::2], sections[2::2]):
106+
if heading.strip().lower() == "unreleased":
107+
continue
108+
numbers = [int(n) for n in re.findall(r"\[#(\d+)\]", body)]
109+
return max(numbers) if numbers else None
110+
return None
111+
112+
113+
def fetch_merged_prs(repo: str, base: str, limit: int) -> list[dict]:
114+
out = _run(
115+
[
116+
"gh",
117+
"pr",
118+
"list",
119+
"-R",
120+
repo,
121+
"--base",
122+
base,
123+
"--state",
124+
"merged",
125+
"--limit",
126+
str(limit),
127+
"--json",
128+
"number,title,author,labels,mergedAt,url",
129+
]
130+
)
131+
return json.loads(out)
132+
133+
134+
def classify(pr: dict) -> str:
135+
labels = {label["name"] for label in pr["labels"]}
136+
for key in SECTIONS:
137+
if key in labels:
138+
return key
139+
for key, pattern in KEYWORD_RULES:
140+
if pattern.search(pr["title"]):
141+
return key
142+
return UNCLASSIFIED
143+
144+
145+
def render(prs: list[dict], heading: str) -> str:
146+
all_sections = {**SECTIONS, UNCLASSIFIED: UNCLASSIFIED_HEADING}
147+
buckets: dict[str, list[dict]] = {key: [] for key in all_sections}
148+
for pr in prs:
149+
buckets[classify(pr)].append(pr)
150+
151+
lines = [
152+
"<!-- Draft, not a source of truth. A PR's label decides its "
153+
"section; an unlabeled PR is guessed from its title. Anything "
154+
"matching neither ends up in 'Needs a label' -- label it on "
155+
"GitHub and re-run rather than moving it here by hand. -->",
156+
"",
157+
f"## {heading}",
158+
"",
159+
]
160+
for key, section_heading in all_sections.items():
161+
entries = buckets[key]
162+
if not entries:
163+
continue
164+
lines.append(f"### {section_heading}")
165+
lines.append("")
166+
for pr in entries:
167+
login = pr["author"].get("login", "unknown")
168+
lines.append(
169+
f"- {pr['title']} ([#{pr['number']}]({pr['url']})) "
170+
f"by [@{login}](https://github.com/{login})"
171+
)
172+
lines.append("")
173+
174+
contributors = sorted(
175+
{pr["author"]["login"] for pr in prs if not pr["author"].get("is_bot", False)},
176+
key=str.lower,
177+
)
178+
if contributors:
179+
lines.append("### Contributors")
180+
lines.append("")
181+
lines.append("Thanks to the following people for this release:")
182+
lines.append("")
183+
lines.append(
184+
", ".join(
185+
f"[@{login}](https://github.com/{login})" for login in contributors
186+
)
187+
)
188+
lines.append("")
189+
190+
return "\n".join(lines)
191+
192+
193+
def main() -> None:
194+
parser = argparse.ArgumentParser(
195+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
196+
)
197+
parser.add_argument(
198+
"--repo", default=None, help="OWNER/REPO. Default: read from 'origin'."
199+
)
200+
parser.add_argument(
201+
"--base", default="main", help="Base branch merged PRs target. Default: main."
202+
)
203+
parser.add_argument(
204+
"--since-pr", type=int, default=None, help="Only PRs numbered above this."
205+
)
206+
parser.add_argument(
207+
"--since-date", default=None, help="Only PRs merged after this (YYYY-MM-DD)."
208+
)
209+
parser.add_argument(
210+
"--limit", type=int, default=300, help="Max merged PRs to fetch. Default 300."
211+
)
212+
parser.add_argument(
213+
"--title", default="Unreleased", help="Section heading. Default: Unreleased."
214+
)
215+
parser.add_argument("--output", default=None, help="Also write the markdown here.")
216+
args = parser.parse_args()
217+
218+
_require_gh()
219+
repo = args.repo or _default_repo()
220+
221+
since_pr = args.since_pr
222+
if since_pr is None and args.since_date is None:
223+
since_pr = _last_released_pr()
224+
225+
prs = fetch_merged_prs(repo, args.base, args.limit)
226+
227+
if since_pr is not None:
228+
prs = [pr for pr in prs if pr["number"] > since_pr]
229+
if args.since_date is not None:
230+
prs = [pr for pr in prs if pr["mergedAt"][:10] > args.since_date]
231+
232+
if not prs:
233+
sys.exit("No merged PRs in range. Nothing to report.")
234+
235+
prs.sort(key=lambda pr: pr["number"], reverse=True)
236+
237+
markdown = render(prs, args.title)
238+
print(markdown)
239+
240+
if args.output:
241+
Path(args.output).write_text(markdown)
242+
243+
244+
if __name__ == "__main__":
245+
main()

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,7 @@ github-actions = ["pytest-github-actions-annotate-failures"]
8282

8383
[tool.setuptools.packages.find]
8484
exclude = ["build_tools"]
85-
where = ["."]
86-
include = ["pyqit*"]
85+
include = ["pyqit", "pyqit*"]
8786

8887
[build-system]
8988
build-backend = "setuptools.build_meta"
@@ -133,6 +132,7 @@ extend-ignore = [
133132

134133
[tool.ruff.lint.per-file-ignores]
135134
"__init__.py" = ["F401"]
135+
"build_tools/changelog.py" = ["S603", "S607"]
136136

137137
[tool.ruff.lint.isort]
138138
known-first-party = ["pyqit"]

pyqit/ansatzes/base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55

66
class BaseAnsatz(_PyQitObject):
7+
"""Base class for a parameterized quantum circuit block."""
8+
79
_tags = {
810
"object_type": "ansatz",
911
"ansatz_type": None,

pyqit/core/adapters/lightning.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,20 @@
1111
)
1212

1313
class Callback:
14+
"""Stub used when lightning/torch is missing; raises on construction."""
15+
1416
def __init__(self, *args, **kwargs):
1517
raise ImportError(_MESSAGE)
1618

1719
class LightningModule:
20+
"""Stub used when lightning/torch is missing; raises on construction."""
21+
1822
def __init__(self, *args, **kwargs):
1923
raise ImportError(_MESSAGE)
2024

2125
class LightningDataModule:
26+
"""Stub used when lightning/torch is missing; raises on construction."""
27+
2228
def __init__(self, *args, **kwargs):
2329
raise ImportError(_MESSAGE)
2430

pyqit/core/callbacks/base.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,5 @@ def on_fit_end(self, state: LoopState) -> None:
6060

6161
@classmethod
6262
def get_test_params(cls):
63+
"""List constructor kwargs used to parametrize this class in the test suite."""
6364
return [{}]

pyqit/core/callbacks/checkpoint.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,4 +200,5 @@ def _write(self, model, weights: dict, stem: str) -> str:
200200

201201
@classmethod
202202
def get_test_params(cls):
203+
"""List constructor kwargs used to parametrize this class in the test suite."""
203204
return [{}, {"save_best": False, "save_last": True}]

pyqit/core/callbacks/history.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ def on_epoch_end(self, state: LoopState) -> None:
3939

4040
@classmethod
4141
def get_test_params(cls):
42+
"""List constructor kwargs used to parametrize this class in the test suite."""
4243
from pyqit.core.trainer import TrainingHistory
4344

4445
return [{"history_obj": TrainingHistory()}]

0 commit comments

Comments
 (0)