project-youler 0.0.1__tar.gz

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.
@@ -0,0 +1,24 @@
1
+ This is free and unencumbered software released into the public domain.
2
+
3
+ Anyone is free to copy, modify, publish, use, compile, sell, or
4
+ distribute this software, either in source code form or as a compiled
5
+ binary, for any purpose, commercial or non-commercial, and by any
6
+ means.
7
+
8
+ In jurisdictions that recognize copyright laws, the author or authors
9
+ of this software dedicate any and all copyright interest in the
10
+ software to the public domain. We make this dedication for the benefit
11
+ of the public at large and to the detriment of our heirs and
12
+ successors. We intend this dedication to be an overt act of
13
+ relinquishment in perpetuity of all present and future rights to this
14
+ software under copyright law.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19
+ IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22
+ OTHER DEALINGS IN THE SOFTWARE.
23
+
24
+ For more information, please refer to <https://unlicense.org>
@@ -0,0 +1,138 @@
1
+ Metadata-Version: 2.4
2
+ Name: project-youler
3
+ Version: 0.0.1
4
+ Summary: A CLI tool for managing solutions to Project Euler problems.
5
+ Keywords: project-euler,euler
6
+ Author: Henry Swanson
7
+ Author-email: Henry Swanson <henryswanson94@gmail.com>
8
+ License-Expression: Unlicense
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
15
+ Requires-Dist: beautifulsoup4>=0.0.2
16
+ Requires-Dist: click>=8.3.1
17
+ Requires-Dist: requests>=2.32.5
18
+ Requires-Python: >=3.13
19
+ Project-URL: Repository, https://github.com/HenrySwanson/project-euler-framework
20
+ Description-Content-Type: text/markdown
21
+
22
+ # project-youler
23
+
24
+ A command line tool for managing your [Project Euler](https://projecteuler.net/) solutions (if they're written in Python).
25
+
26
+ Features include:
27
+ - Stores all your confirmed answers in a local JSON file. If you refactor your solution, you can run the `check` command to make sure none of your solvers have broken.
28
+ - Fetches the problem description from the Project Euler website and creates a template file for you to fill in with your solution.
29
+ - Shows an overview of how many problems you've solved and/or attempted.
30
+ - Really basic timing info for profiling your solutions.
31
+
32
+ This package doesn't contain any solutions itself (that would be uncool).
33
+
34
+ ## Setup
35
+
36
+ In the project where you've written your solutions, add this package as a dependency, and create an entry point for the `click` CLI returned by `make_cli`.
37
+
38
+ Example:
39
+ ```python
40
+ # src/my_euler/__main__.py
41
+ from pathlib import Path
42
+
43
+ from youler import EulerConfig, make_cli
44
+
45
+ from . import bonus, problems
46
+
47
+ cli = make_cli(
48
+ EulerConfig(
49
+ root=Path(__file__).parents[2],
50
+ problems=problems,
51
+ bonus=bonus,
52
+ answer_file="answers.json",
53
+ )
54
+ )
55
+
56
+ if __name__ == "__main__":
57
+ cli()
58
+ ```
59
+
60
+ If you wire it up as a script, you can run `euler` from anywhere in the
61
+ project:
62
+
63
+ ```toml
64
+ [project.scripts]
65
+ euler = "my_euler.__main__:cli"
66
+ ```
67
+
68
+ Aside: `make_cli` returns an ordinary `click.Group`, so you can register extra commands of your own on it.
69
+
70
+ ### Configuration
71
+
72
+ | field | meaning |
73
+ | --- | --- |
74
+ | `root` | Root of your repository; `answer_file` is resolved against it. |
75
+ | `problems` | The imported package holding your numbered solvers. |
76
+ | `bonus` | The imported package holding your bonus solvers. Optional. |
77
+ | `answer_file` | Where confirmed answers are saved. Defaults to `answers.json`. |
78
+
79
+ ## Writing a solver
80
+
81
+ A solver is a module named `pNNNN.py` inside your problems package, exporting
82
+ `solve_problem()`:
83
+
84
+ ```python
85
+ # src/my_euler/problems/p0001.py
86
+ """
87
+ If we list all the natural numbers below 10 that are multiples of 3 or 5, we
88
+ get 3, 5, 6 and 9. The sum of these multiples is 23.
89
+
90
+ Find the sum of all the multiples of 3 or 5 below 1000.
91
+ """
92
+
93
+
94
+ def solve_problem() -> int:
95
+ return sum(n for n in range(1000) if n % 3 == 0 or n % 5 == 0)
96
+ ```
97
+
98
+ (*okay, there's **one** solution in this package*)
99
+
100
+ `euler create 1` creates that file for you, with the problem statement fetched
101
+ from the Project Euler website and formatted into the docstring.
102
+
103
+ An answer may be an `int` or a `str`, as far as I know.
104
+
105
+ ### Bonus problems
106
+
107
+ <details>
108
+ <summary>Spoiler warning</summary>
109
+
110
+ Project Euler has some mysterious bonus problems that unlock after unknown conditions. Personally, I've only seen two of them. I've managed to wedge them into this framework in a reasonably clean way, just specify an arbitrary string key and it'll create a bonus problem.
111
+
112
+ For example, if you create a file `bonus/p18i.py`, you can run it with `euler run 18i` and it'll work like other problems.
113
+
114
+ Details subject to change since I have no idea what other bonus problems lurk in the darkness.
115
+ </details>
116
+
117
+ ## Commands
118
+
119
+ > [!NOTE]
120
+ > Most of these commands take arguments of the form `a-b`, meaning problems _a_
121
+ > to _b_ inclusive, the id of a bonus problem, and the special value `all`,
122
+ > which means slightly different things to different commands.
123
+ >
124
+ > You can always pass `--help` for more detail on a particular command.
125
+
126
+ - `create <n>`: Create `problems/pNNNN.py` for problem #n, prefilled with the problem statement from the Project Euler website.
127
+ - `run <n>`: Run the solver for problem #n. If there is already a saved answer, it'll be compared with it, if not, you'll be prompted whether you want to save it. (Submit it to PE first so you know it's right!)
128
+ - `check <n>`: Run the solver for problem #n and compare it against the saved answer, without prompting. `check all` covers everything you've written or saved, which is nice for validating a refactor.
129
+ - `time <n>`: Run one problem several times and report the average time taken.
130
+ - `status`: Show which problems have been downloaded, solved, or neither.
131
+ - `answers`: Subcommands for manipulating the answer save file.
132
+ - `show <n>`: Show saved answers.
133
+ - `delete <n>`: Delete saved answers.
134
+
135
+ ## Development
136
+
137
+ I use `uv`, `mypy` and `pytest`, in a very typical setup.
138
+
@@ -0,0 +1,117 @@
1
+ # project-youler
2
+
3
+ A command line tool for managing your [Project Euler](https://projecteuler.net/) solutions (if they're written in Python).
4
+
5
+ Features include:
6
+ - Stores all your confirmed answers in a local JSON file. If you refactor your solution, you can run the `check` command to make sure none of your solvers have broken.
7
+ - Fetches the problem description from the Project Euler website and creates a template file for you to fill in with your solution.
8
+ - Shows an overview of how many problems you've solved and/or attempted.
9
+ - Really basic timing info for profiling your solutions.
10
+
11
+ This package doesn't contain any solutions itself (that would be uncool).
12
+
13
+ ## Setup
14
+
15
+ In the project where you've written your solutions, add this package as a dependency, and create an entry point for the `click` CLI returned by `make_cli`.
16
+
17
+ Example:
18
+ ```python
19
+ # src/my_euler/__main__.py
20
+ from pathlib import Path
21
+
22
+ from youler import EulerConfig, make_cli
23
+
24
+ from . import bonus, problems
25
+
26
+ cli = make_cli(
27
+ EulerConfig(
28
+ root=Path(__file__).parents[2],
29
+ problems=problems,
30
+ bonus=bonus,
31
+ answer_file="answers.json",
32
+ )
33
+ )
34
+
35
+ if __name__ == "__main__":
36
+ cli()
37
+ ```
38
+
39
+ If you wire it up as a script, you can run `euler` from anywhere in the
40
+ project:
41
+
42
+ ```toml
43
+ [project.scripts]
44
+ euler = "my_euler.__main__:cli"
45
+ ```
46
+
47
+ Aside: `make_cli` returns an ordinary `click.Group`, so you can register extra commands of your own on it.
48
+
49
+ ### Configuration
50
+
51
+ | field | meaning |
52
+ | --- | --- |
53
+ | `root` | Root of your repository; `answer_file` is resolved against it. |
54
+ | `problems` | The imported package holding your numbered solvers. |
55
+ | `bonus` | The imported package holding your bonus solvers. Optional. |
56
+ | `answer_file` | Where confirmed answers are saved. Defaults to `answers.json`. |
57
+
58
+ ## Writing a solver
59
+
60
+ A solver is a module named `pNNNN.py` inside your problems package, exporting
61
+ `solve_problem()`:
62
+
63
+ ```python
64
+ # src/my_euler/problems/p0001.py
65
+ """
66
+ If we list all the natural numbers below 10 that are multiples of 3 or 5, we
67
+ get 3, 5, 6 and 9. The sum of these multiples is 23.
68
+
69
+ Find the sum of all the multiples of 3 or 5 below 1000.
70
+ """
71
+
72
+
73
+ def solve_problem() -> int:
74
+ return sum(n for n in range(1000) if n % 3 == 0 or n % 5 == 0)
75
+ ```
76
+
77
+ (*okay, there's **one** solution in this package*)
78
+
79
+ `euler create 1` creates that file for you, with the problem statement fetched
80
+ from the Project Euler website and formatted into the docstring.
81
+
82
+ An answer may be an `int` or a `str`, as far as I know.
83
+
84
+ ### Bonus problems
85
+
86
+ <details>
87
+ <summary>Spoiler warning</summary>
88
+
89
+ Project Euler has some mysterious bonus problems that unlock after unknown conditions. Personally, I've only seen two of them. I've managed to wedge them into this framework in a reasonably clean way, just specify an arbitrary string key and it'll create a bonus problem.
90
+
91
+ For example, if you create a file `bonus/p18i.py`, you can run it with `euler run 18i` and it'll work like other problems.
92
+
93
+ Details subject to change since I have no idea what other bonus problems lurk in the darkness.
94
+ </details>
95
+
96
+ ## Commands
97
+
98
+ > [!NOTE]
99
+ > Most of these commands take arguments of the form `a-b`, meaning problems _a_
100
+ > to _b_ inclusive, the id of a bonus problem, and the special value `all`,
101
+ > which means slightly different things to different commands.
102
+ >
103
+ > You can always pass `--help` for more detail on a particular command.
104
+
105
+ - `create <n>`: Create `problems/pNNNN.py` for problem #n, prefilled with the problem statement from the Project Euler website.
106
+ - `run <n>`: Run the solver for problem #n. If there is already a saved answer, it'll be compared with it, if not, you'll be prompted whether you want to save it. (Submit it to PE first so you know it's right!)
107
+ - `check <n>`: Run the solver for problem #n and compare it against the saved answer, without prompting. `check all` covers everything you've written or saved, which is nice for validating a refactor.
108
+ - `time <n>`: Run one problem several times and report the average time taken.
109
+ - `status`: Show which problems have been downloaded, solved, or neither.
110
+ - `answers`: Subcommands for manipulating the answer save file.
111
+ - `show <n>`: Show saved answers.
112
+ - `delete <n>`: Delete saved answers.
113
+
114
+ ## Development
115
+
116
+ I use `uv`, `mypy` and `pytest`, in a very typical setup.
117
+
@@ -0,0 +1,50 @@
1
+ [project]
2
+ name = "project-youler"
3
+ version = "0.0.1"
4
+ description = "A CLI tool for managing solutions to Project Euler problems."
5
+ readme = "README.md"
6
+ license = "Unlicense"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.13"
9
+ keywords = [
10
+ "project-euler",
11
+ "euler",
12
+ ]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Education",
18
+ "Topic :: Scientific/Engineering :: Mathematics",
19
+ ]
20
+ dependencies = [
21
+ "beautifulsoup4>=0.0.2",
22
+ "click>=8.3.1",
23
+ "requests>=2.32.5",
24
+ ]
25
+
26
+ [[project.authors]]
27
+ name = "Henry Swanson"
28
+ email = "henryswanson94@gmail.com"
29
+
30
+ [project.urls]
31
+ Repository = "https://github.com/HenrySwanson/project-euler-framework"
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "mypy>=2.3.1",
36
+ "pytest>=9.1.1",
37
+ "types-beautifulsoup4>=4.12.0.20250516",
38
+ "types-requests>=2.33.0.20260712",
39
+ ]
40
+
41
+ [build-system]
42
+ requires = ["uv_build>=0.9.28,<0.13.0"]
43
+ build-backend = "uv_build"
44
+
45
+ [tool.uv.build-backend]
46
+ module-name = "youler"
47
+
48
+ [tool.mypy]
49
+ allow_redefinition = true
50
+ strict = true
@@ -0,0 +1,43 @@
1
+ [project]
2
+ name = "project-youler"
3
+ version = "0.0.1"
4
+
5
+ # Various metadata for PyPI
6
+ description = "A CLI tool for managing solutions to Project Euler problems."
7
+ readme = "README.md"
8
+ license = "Unlicense"
9
+ license-files = ["LICENSE"]
10
+ authors = [{ name = "Henry Swanson", email = "henryswanson94@gmail.com" }]
11
+ requires-python = ">=3.13"
12
+ keywords = ["project-euler", "euler"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: Education",
18
+ "Topic :: Scientific/Engineering :: Mathematics",
19
+ ]
20
+ urls = { Repository = "https://github.com/HenrySwanson/project-euler-framework" }
21
+
22
+ # Build and dependency stuff
23
+ dependencies = ["beautifulsoup4>=0.0.2", "click>=8.3.1", "requests>=2.32.5"]
24
+
25
+ [dependency-groups]
26
+ dev = [
27
+ "mypy>=2.3.1",
28
+ "pytest>=9.1.1",
29
+ "types-beautifulsoup4>=4.12.0.20250516",
30
+ "types-requests>=2.33.0.20260712",
31
+ ]
32
+
33
+ [build-system]
34
+ requires = ["uv_build>=0.9.28,<0.13.0"]
35
+ build-backend = "uv_build"
36
+
37
+ [tool.uv.build-backend]
38
+ module-name = "youler"
39
+
40
+ # Other tools
41
+ [tool.mypy]
42
+ allow_redefinition = true
43
+ strict = true
@@ -0,0 +1,4 @@
1
+ from .cli import make_cli
2
+ from .config import EulerConfig
3
+
4
+ __all__ = ["EulerConfig", "make_cli"]
@@ -0,0 +1,71 @@
1
+ """
2
+ Previously, I saved these files in a weakly obscured binary format, in order to
3
+ discourage accidentally stumbling across the answers. I no longer think this is
4
+ necessary -- the answers are all over the web, and I'm not even publishing this
5
+ version of the repo with all my answers in it. So now it's just JSON.
6
+ """
7
+
8
+ import json
9
+ from collections.abc import Mapping
10
+ from pathlib import Path
11
+ from typing import TypeAlias
12
+
13
+ # Numbered problems are identified by their number, and the bonus problems that
14
+ # Project Euler doesn't number by a string like "18i". Both appear as keys in
15
+ # the answer file, which is why the type lives here.
16
+ ProblemId: TypeAlias = int | str
17
+ Answer: TypeAlias = int | str
18
+
19
+
20
+ def parse_problem_id(key: str) -> ProblemId:
21
+ """Reads an answer file key, as a number if it looks like one."""
22
+ try:
23
+ return int(key)
24
+ except ValueError:
25
+ return key
26
+
27
+
28
+ def problem_sort_key(id: ProblemId) -> tuple[int, int, str]:
29
+ """
30
+ Sorts numbered problems numerically and ahead of the bonus ones, which go
31
+ alphabetically. Needed because ints and strs don't compare against
32
+ each other.
33
+ """
34
+ if isinstance(id, int):
35
+ return (0, id, "")
36
+ return (1, 0, id)
37
+
38
+
39
+ def parse_answer_file(path: Path) -> dict[ProblemId, Answer]:
40
+ """
41
+ Reads the answer file and returns a dictionary mapping problem ids to
42
+ their answers. A repository that hasn't saved any yet has no file at all,
43
+ which reads as empty.
44
+ """
45
+ if not path.exists():
46
+ return {}
47
+
48
+ with open(path, "r") as f:
49
+ contents = json.load(f)
50
+
51
+ # confirm the structure
52
+ answers = {}
53
+ assert isinstance(contents, dict)
54
+ for k, v in contents.items():
55
+ assert isinstance(v, Answer)
56
+ answers[parse_problem_id(k)] = v
57
+
58
+ return answers
59
+
60
+
61
+ def save_answer_file(path: Path, answers: Mapping[ProblemId, Answer]) -> None:
62
+ """
63
+ Overwrites the contents of the answer file with the given answer mapping.
64
+ """
65
+
66
+ # JSON keys are strings, so sort before writing rather than with
67
+ # `sort_keys`, which has no way to order a bonus id against a number.
68
+ ordered = {str(id): answers[id] for id in sorted(answers, key=problem_sort_key)}
69
+
70
+ with open(path, "w") as f:
71
+ json.dump(ordered, f, indent=True)
@@ -0,0 +1,411 @@
1
+ import enum
2
+ from collections import defaultdict
3
+ from pathlib import Path
4
+ from time import perf_counter
5
+ from typing import Final, Iterable
6
+
7
+ import click
8
+
9
+ from .answer import (
10
+ Answer,
11
+ ProblemId,
12
+ parse_answer_file,
13
+ parse_problem_id,
14
+ problem_sort_key,
15
+ save_answer_file,
16
+ )
17
+ from .config import EulerConfig
18
+ from .intervals import format_as_intervals, parse_interval_string
19
+ from .problems import (
20
+ format_problem_id,
21
+ get_problem_description,
22
+ list_all_problems,
23
+ list_bonus_problems,
24
+ list_problems,
25
+ problem_path,
26
+ run_problem,
27
+ run_problem_if_exists,
28
+ )
29
+
30
+ TEMPLATE_FILE = Path(__file__).parent / "problem.py.template"
31
+
32
+ # ==== TODO LIST ====
33
+ # - can i do some python deepmagic to detect the '...' in the problem file?
34
+ # might help with distinguishing "downloaded" from "started"
35
+
36
+
37
+ class CheckStatus(enum.Enum):
38
+ SUCCESS = enum.auto()
39
+ FAILURE = enum.auto()
40
+ UNSOLVED = enum.auto()
41
+ NEEDS_ATTENTION = enum.auto()
42
+
43
+
44
+ class ProblemAlias(enum.Enum):
45
+ """
46
+ Synonyms for certain subsets of problems. Now that we're using strings
47
+ for the bonus problem IDs, it's nice to have a type-system-level difference
48
+ between "all" and other strings.
49
+ """
50
+
51
+ # TODO: implement others?
52
+ ALL = enum.auto()
53
+
54
+
55
+ def parse_interval_input(args: Iterable[str]) -> set[ProblemId] | ProblemAlias:
56
+ """
57
+ Returns either a set of problem ids that was explicitly specified by the
58
+ user, or the ALL sentinel, if "all" was passed in. (The caller may want to
59
+ handle that themselves.)
60
+ """
61
+
62
+ problems: set[ProblemId] = set()
63
+
64
+ for arg in args:
65
+ if arg == "all":
66
+ return ProblemAlias.ALL # doesn't matter what else was passed
67
+
68
+ try:
69
+ (start, end) = parse_interval_string(arg)
70
+ except ValueError:
71
+ # neither a number nor a range, so it's probably a bonus problem
72
+ problems.add(arg)
73
+ continue
74
+
75
+ problems.update(range(start, end + 1))
76
+
77
+ return problems
78
+
79
+
80
+ # ==== CLI Commands ====
81
+
82
+
83
+ @click.command
84
+ @click.argument("problems", type=str, nargs=-1, required=True)
85
+ @click.pass_obj
86
+ def create(config: EulerConfig, problems: tuple[str, ...]) -> None:
87
+ """Use the template file to create a starting point for the given problems."""
88
+
89
+ numbers = parse_interval_input(problems)
90
+ if numbers is ProblemAlias.ALL:
91
+ raise click.UsageError('"all" is not a valid argument for `create`')
92
+
93
+ for n in sorted(numbers, key=problem_sort_key):
94
+ # Project Euler has no page to fetch for the problems it doesn't number
95
+ if not isinstance(n, int):
96
+ click.secho(f"Can't create a file for bonus problem {n}", fg="red")
97
+ continue
98
+
99
+ try:
100
+ create_single_file(config, n)
101
+ except Exception as e:
102
+ click.secho(f"Unable to create file for problem {n}: {e}", fg="red")
103
+
104
+
105
+ def create_single_file(config: EulerConfig, n: int) -> None:
106
+ """Creates a solver file for the given problem."""
107
+
108
+ dst_path = problem_path(config, n)
109
+
110
+ if dst_path.exists() and not click.confirm(
111
+ f"File {dst_path} already exists; do you want to overwrite?"
112
+ ):
113
+ return
114
+
115
+ with open(TEMPLATE_FILE, "r") as f:
116
+ template_contents = f.read()
117
+
118
+ output = template_contents.format(description=get_problem_description(n))
119
+
120
+ with open(dst_path, "w", encoding="utf-8") as f:
121
+ f.write(output)
122
+
123
+ click.echo(f"Created file for problem {n} at {dst_path}")
124
+
125
+
126
+ @click.command
127
+ @click.argument("problems", type=str, nargs=-1, required=True)
128
+ @click.pass_obj
129
+ def run(config: EulerConfig, problems: tuple[str, ...]) -> None:
130
+ """Run the solver for the given problems and print the answer."""
131
+
132
+ numbers = parse_interval_input(problems)
133
+ if numbers is ProblemAlias.ALL:
134
+ numbers = set(list_all_problems(config))
135
+
136
+ for n in sorted(numbers, key=problem_sort_key):
137
+ run_single_problem(config, n)
138
+ click.echo()
139
+
140
+
141
+ def run_single_problem(config: EulerConfig, n: ProblemId) -> None:
142
+ """
143
+ Runs a single problem, showing its output to the user, and prompting the
144
+ user to save if the answer is not already saved.
145
+ """
146
+
147
+ click.echo(f"Running problem {format_problem_id(n)}...")
148
+ answer = run_problem(config, n)
149
+ click.secho(f"Answer: {answer}", bold=True)
150
+
151
+ saved = parse_answer_file(config.answer_path)
152
+ old_answer = saved.get(n)
153
+
154
+ if old_answer is None:
155
+ click.echo("No previously saved answer")
156
+ elif old_answer == answer:
157
+ click.secho("Answer matches previously saved answer", fg="green")
158
+ else:
159
+ click.secho(
160
+ f"WARNING: answer does not match previously saved answer {old_answer}!",
161
+ fg="red",
162
+ )
163
+
164
+ if old_answer != answer and click.confirm("Would you like to save this answer?"):
165
+ # Actually write the answer and save it
166
+ saved[n] = answer
167
+ save_answer_file(config.answer_path, saved)
168
+ click.echo("Saved!")
169
+
170
+
171
+ @click.command
172
+ @click.argument("problems", type=str, nargs=-1, required=True)
173
+ @click.pass_obj
174
+ def check(config: EulerConfig, problems: tuple[str, ...]) -> None:
175
+ """
176
+ Solve all problems and check if the answers match those in the cache.
177
+ This is nice for checking the validity of any refactoring I'm doing.
178
+ """
179
+
180
+ # Figure out which problems the user specified
181
+ numbers = parse_interval_input(problems)
182
+
183
+ saved_answers: dict[ProblemId, Answer] = parse_answer_file(config.answer_path)
184
+
185
+ if numbers is ProblemAlias.ALL:
186
+ numbers = set(list_all_problems(config)) | saved_answers.keys()
187
+
188
+ statuses: dict[CheckStatus, int] = defaultdict(int)
189
+ for n in sorted(numbers, key=problem_sort_key):
190
+ status = check_single_problem(config, n, saved_answers.get(n))
191
+ statuses[status] += 1
192
+
193
+ # Decide what color the summary message should show up as
194
+ if statuses[CheckStatus.FAILURE] > 0:
195
+ color = "red"
196
+ elif statuses[CheckStatus.NEEDS_ATTENTION] > 0:
197
+ color = "yellow"
198
+ else:
199
+ color = "green"
200
+
201
+ click.secho(
202
+ f"Ran {len(numbers)} problems: {statuses[CheckStatus.SUCCESS]} succeeded, {statuses[CheckStatus.FAILURE]} failed, {statuses[CheckStatus.UNSOLVED]} unsolved, {statuses[CheckStatus.NEEDS_ATTENTION]} need attention",
203
+ fg=color,
204
+ )
205
+
206
+
207
+ def check_single_problem(
208
+ config: EulerConfig, n: ProblemId, saved_answer: Answer | None
209
+ ) -> CheckStatus:
210
+ """
211
+ Checks the status of a single problem against the saved answer, and returns
212
+ a `CheckStatus` enum.
213
+ """
214
+
215
+ try:
216
+ current_answer = run_problem_if_exists(config, n)
217
+ except Exception as e:
218
+ # a solver that blows up shouldn't take the rest of the run with it
219
+ click.secho(
220
+ f"Problem {format_problem_id(n)} raised {type(e).__name__}: {e}",
221
+ fg="red",
222
+ bold=True,
223
+ )
224
+ return CheckStatus.FAILURE
225
+
226
+ # Big ol' match statement
227
+ if saved_answer is None:
228
+ if current_answer is None:
229
+ click.echo(f"Problem {format_problem_id(n)} is unsolved")
230
+ return CheckStatus.UNSOLVED
231
+ else:
232
+ click.secho(
233
+ f"Problem {format_problem_id(n)} produces answer {current_answer}, but it is not saved",
234
+ fg="yellow",
235
+ )
236
+ return CheckStatus.NEEDS_ATTENTION
237
+ else:
238
+ if current_answer is None:
239
+ click.secho(
240
+ f"Problem {format_problem_id(n)} has a saved answer {saved_answer}, but no solver",
241
+ fg="yellow",
242
+ )
243
+ return CheckStatus.NEEDS_ATTENTION
244
+ elif current_answer == saved_answer:
245
+ click.secho(f"Problem {format_problem_id(n)} is good!", fg="green")
246
+ return CheckStatus.SUCCESS
247
+ else:
248
+ click.secho(
249
+ f"Problem {format_problem_id(n)} failed: solver has {current_answer}, save file has {saved_answer}",
250
+ fg="red",
251
+ bold=True,
252
+ )
253
+ return CheckStatus.FAILURE
254
+
255
+
256
+ @click.command
257
+ @click.pass_obj
258
+ def status(config: EulerConfig) -> None:
259
+ """
260
+ Check which problems have been done and which are not.
261
+
262
+ Helpful for checking if I forgot to save an answer.
263
+ """
264
+
265
+ # Do the regular problems first
266
+ problems_saved = {
267
+ id for id in parse_answer_file(config.answer_path) if isinstance(id, int)
268
+ }
269
+ problems_with_files = set(list_problems(config))
270
+
271
+ click.echo("Problems solved:")
272
+ click.echo(format_as_intervals(problems_with_files & problems_saved))
273
+
274
+ click.echo("Problems downloaded and unsolved:")
275
+ click.echo(format_as_intervals(problems_with_files - problems_saved))
276
+
277
+ if not problems_saved <= problems_with_files:
278
+ click.echo("Problems solved but not downloaded (???):")
279
+ click.echo(format_as_intervals(problems_saved - problems_with_files))
280
+
281
+ problems_known = problems_with_files | problems_saved
282
+ first_unknown = max(problems_known) + 1 if problems_known else 1
283
+ click.echo("Problems remaining:")
284
+ click.echo(
285
+ format_as_intervals(
286
+ set(range(1, first_unknown)) - problems_known, infinite_tail=first_unknown
287
+ )
288
+ )
289
+
290
+ # The bonus problems don't form intervals, so just list them.
291
+ bonus_saved = {
292
+ id for id in parse_answer_file(config.answer_path) if isinstance(id, str)
293
+ }
294
+ bonus_with_files = set(list_bonus_problems(config))
295
+
296
+ if bonus_with_files or bonus_saved:
297
+ click.echo("Bonus problems solved:")
298
+ click.echo(", ".join(sorted(bonus_with_files & bonus_saved)))
299
+
300
+ click.echo("Bonus problems downloaded and unsolved:")
301
+ click.echo(", ".join(sorted(bonus_with_files - bonus_saved)))
302
+
303
+
304
+ @click.command
305
+ @click.argument("problem", type=str)
306
+ @click.pass_obj
307
+ def time(config: EulerConfig, problem: str) -> None:
308
+ """
309
+ Run the indicated problem several times and give some stats on the timing.
310
+ """
311
+
312
+ id = parse_problem_id(problem)
313
+
314
+ run_times = []
315
+ for i in range(10):
316
+ # Run the problem
317
+ start = perf_counter()
318
+ run_problem(config, id)
319
+ elapsed = perf_counter() - start
320
+ run_times.append(elapsed)
321
+ # Print to reassure the user something's happening
322
+ click.echo(f"Trial #{i + 1}: {elapsed:.3f}s")
323
+
324
+ click.echo("Test complete!")
325
+ click.echo(f"Mean: {sum(run_times) / len(run_times):.3f}")
326
+
327
+
328
+ @click.group()
329
+ def answers() -> None:
330
+ """
331
+ Commands for manipulating the answer save file.
332
+ """
333
+
334
+
335
+ @answers.command()
336
+ @click.argument("problems", type=str, nargs=-1, required=True)
337
+ @click.pass_obj
338
+ def show(config: EulerConfig, problems: tuple[str, ...]) -> None:
339
+ """
340
+ Show specific answers from the answer list.
341
+ """
342
+
343
+ numbers = parse_interval_input(problems)
344
+ answers = parse_answer_file(config.answer_path)
345
+
346
+ if numbers is ProblemAlias.ALL:
347
+ numbers = set(answers.keys())
348
+
349
+ for n in sorted(numbers, key=problem_sort_key):
350
+ if n in answers:
351
+ click.echo(f"Problem {format_problem_id(n)}: {answers[n]}")
352
+ else:
353
+ click.secho(
354
+ f"No saved answer for problem {format_problem_id(n)}", fg="yellow"
355
+ )
356
+
357
+
358
+ @answers.command()
359
+ @click.argument("problems", type=str, nargs=-1, required=True)
360
+ @click.pass_obj
361
+ def delete(config: EulerConfig, problems: tuple[str, ...]) -> None:
362
+ """Delete a specific answer from the save file."""
363
+
364
+ numbers = parse_interval_input(problems)
365
+ answers = parse_answer_file(config.answer_path)
366
+
367
+ if numbers is ProblemAlias.ALL:
368
+ numbers = set(answers.keys())
369
+
370
+ click.confirm(
371
+ f"Are you sure you want to delete the saved answer for "
372
+ f"problem(s) {' '.join(problems)}?",
373
+ abort=True,
374
+ )
375
+
376
+ for problem in sorted(numbers, key=problem_sort_key):
377
+ if problem in answers:
378
+ del answers[problem]
379
+ else:
380
+ click.secho(
381
+ f"No saved answer for problem {format_problem_id(problem)}", fg="yellow"
382
+ )
383
+
384
+ save_answer_file(config.answer_path, answers)
385
+
386
+
387
+ def make_cli(config: EulerConfig) -> click.Group:
388
+ """
389
+ Creates the CLI interface for users of this framework.
390
+
391
+ The result is just a regular `click.Group` object, so a) users just need to
392
+ call the result of this function in order to invoke the CLI, and b) users can
393
+ register additional commands if they so choose.
394
+ """
395
+
396
+ @click.group()
397
+ @click.pass_context
398
+ def cli(ctx: click.Context) -> None:
399
+ ctx.obj = config
400
+
401
+ # Register our commands. We have to do this when we are invoked, not with
402
+ # the usual decorator, because those functions are defined before the
403
+ # user has created this `click.Group` object we want to return.
404
+ cli.add_command(create)
405
+ cli.add_command(run)
406
+ cli.add_command(check)
407
+ cli.add_command(status)
408
+ cli.add_command(time)
409
+ cli.add_command(answers)
410
+
411
+ return cli
@@ -0,0 +1,63 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from types import ModuleType
6
+ from typing import Union
7
+
8
+ PathLike = Union[str, Path]
9
+
10
+
11
+ @dataclass
12
+ class EulerConfig:
13
+ """
14
+ Describes the layout of a repository of Project Euler solutions.
15
+
16
+ This is what the user will pass to `make_cli` so that this framework can find
17
+ the solutions, answer file, etc.
18
+ """
19
+
20
+ # Root of the solution repository. Every relative path below is resolved
21
+ # against it. Usually `Path(__file__).parents[2]` from the entry point.
22
+ root: PathLike
23
+
24
+ # Package holding the numbered solvers, as an imported module. Solvers are
25
+ # the files inside it named `pNNNN.py`.
26
+ problems: ModuleType
27
+
28
+ # Package holding the bonus solvers, named `p<id>.py` for a non-numeric id.
29
+ bonus: ModuleType | None = None
30
+
31
+ # Where saved answers live.
32
+ answer_file: PathLike = "answers.json"
33
+
34
+ @property
35
+ def problems_dir(self) -> Path:
36
+ """Where the solution modules live."""
37
+ return _module_dir(self.problems)
38
+
39
+ @property
40
+ def bonus_dir(self) -> Path | None:
41
+ """Where the bonus solution modules live, if there are any."""
42
+ if self.bonus is None:
43
+ return None
44
+ return _module_dir(self.bonus)
45
+
46
+ @property
47
+ def answer_path(self) -> Path:
48
+ """Path to the answer file."""
49
+ return Path(self.root) / self.answer_file
50
+
51
+
52
+ class ConfigException(Exception):
53
+ pass
54
+
55
+
56
+ def _module_dir(module: ModuleType) -> Path:
57
+ """The single directory a package's modules live in."""
58
+ if len(module.__path__) != 1:
59
+ raise ConfigException(
60
+ # not sure what this means or what to do about it
61
+ f"Multiple paths found for module {module.__name__}"
62
+ )
63
+ return Path(module.__path__[0])
@@ -0,0 +1,168 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections.abc import Iterable
5
+ from enum import Enum
6
+
7
+ from bs4 import BeautifulSoup, NavigableString, PageElement, Tag
8
+
9
+ IGNORED_EMPHASIS_TAGS = ["var", "i", "b", "a"]
10
+ PREFIX_SYMBOL_TAGS = {"sup": "^", "sub": "_"}
11
+
12
+ # https://html.spec.whatwg.org/multipage/dom.html#content-models
13
+
14
+
15
+ class Mode(Enum):
16
+ BLOCK = 1
17
+ INLINE = 2
18
+ CELL = 3
19
+
20
+
21
+ def htmlToDocstring(text: str) -> str:
22
+ soup = BeautifulSoup(text, "html.parser")
23
+ return "\n\n".join(
24
+ block
25
+ for child in soup.children
26
+ if (block := format_element(child, Mode.BLOCK)) is not None
27
+ )
28
+
29
+
30
+ def format_elements(elements: Iterable[PageElement], mode: Mode) -> list[str]:
31
+ return [s for elt in elements if (s := format_element(elt, mode)) is not None]
32
+
33
+
34
+ def format_element(element: PageElement, mode: Mode) -> str | None:
35
+ if isinstance(element, Tag):
36
+ return format_tag(element, mode)
37
+ elif isinstance(element, NavigableString):
38
+ return format_string(element, mode)
39
+ else:
40
+ raise Exception(f"Unexpected element: {element}")
41
+
42
+
43
+ def format_tag(tag: Tag, mode: Mode) -> str:
44
+ if tag.name in IGNORED_EMPHASIS_TAGS:
45
+ return "".join(format_elements(tag.children, mode))
46
+ if tag.name in PREFIX_SYMBOL_TAGS:
47
+ return PREFIX_SYMBOL_TAGS[tag.name] + "".join(
48
+ format_elements(tag.children, mode)
49
+ )
50
+ if tag.name == "p":
51
+ return "".join(format_elements(tag.children, Mode.INLINE))
52
+ elif tag.name == "div":
53
+ sep = "\n" if mode == Mode.BLOCK else ""
54
+ return sep.join(format_elements(tag.children, mode))
55
+ elif tag.name == "blockquote":
56
+ text = "".join(format_elements(tag.children, Mode.INLINE))
57
+ return indent_block(text, 4, False)
58
+ elif tag.name == "ol":
59
+ items = format_elements(tag.children, mode)
60
+ return "\n".join(
61
+ f"{i + 1}. {indent_block(item, 3, True)}" for (i, item) in enumerate(items)
62
+ )
63
+ elif tag.name == "ul":
64
+ items = format_elements(tag.children, mode)
65
+ return "\n".join(f"- {indent_block(item, 2, True)}" for item in items)
66
+ elif tag.name == "li":
67
+ return "".join(format_elements(tag.children, Mode.INLINE))
68
+ elif tag.name == "br":
69
+ return "<br>" if mode == Mode.CELL else "\n"
70
+ elif tag.name == "table":
71
+ return "\n".join(format_elements(tag.children, mode))
72
+ elif tag.name == "tr":
73
+ return " ".join(format_elements(tag.children, mode))
74
+ elif tag.name == "td":
75
+ return "".join(format_elements(tag.children, Mode.CELL))
76
+ else:
77
+ attrs = ",".join(
78
+ f"{key}={''.join(values)}" for (key, values) in tag.attrs.items()
79
+ )
80
+ contents = "".join(format_elements(tag.children, mode))
81
+ return f"<{tag.name} {attrs}>{contents}</{tag.name}>"
82
+
83
+
84
+ def format_string(ns: NavigableString, mode: Mode) -> str | None:
85
+ text = handle_whitespace(ns, mode)
86
+ if text is None:
87
+ return None
88
+
89
+ # there's a fair amount of LaTeX notation in these problems; do some
90
+ # common replacements with Unicode
91
+ for latex, unicode in LATEX_TO_UNICODE_REPLACEMENTS.items():
92
+ text = text.replace(latex, unicode)
93
+
94
+ # if we have some text between $s, and it's simple enough (the previous
95
+ # step helps), then we can eliminate the dollar signs.
96
+ # But also, we gotta watch out for $$, so we do the same thing beforehand,
97
+ # with $$ as the delimiter.
98
+ def simplify_math_blocks(src: str, delimiter: str) -> str:
99
+ out = ""
100
+ in_math_mode = False
101
+ for piece in src.split(delimiter):
102
+ if in_math_mode:
103
+ # if we're only dealing with safe characters, we can remove the $
104
+ if all(ch not in "\\{}&" for ch in piece):
105
+ out_piece = piece
106
+ else:
107
+ # keep it and manually fix later
108
+ out_piece = delimiter + piece + delimiter
109
+ else:
110
+ # not in math mode; keep it
111
+ out_piece = piece
112
+ out += out_piece
113
+ in_math_mode = not in_math_mode
114
+ # we should be out of math mode at the end, but we flipped that flag
115
+ # one more time at the end, so the next one *would* be math mode
116
+ assert in_math_mode, "failure to parse math modes"
117
+ return out
118
+
119
+ text = simplify_math_blocks(text, "$$")
120
+ text = simplify_math_blocks(text, "$")
121
+ return text
122
+
123
+
124
+ def handle_whitespace(ns: NavigableString, mode: Mode) -> str | None:
125
+ # If we're in a block context, discard leading and trailing space,
126
+ # and call it a day
127
+ if mode == Mode.BLOCK:
128
+ return str(ns).strip() or None
129
+ elif mode == Mode.INLINE:
130
+ # https://www.w3.org/TR/css-text-3/#white-space-rules
131
+ text = str(ns).replace("\t", " ")
132
+ text = re.sub(" *\n *", " ", text)
133
+ text = re.sub(" +", " ", text)
134
+ # Okay, here's a kludge. We want to preserve spaces most of the time,
135
+ # (e.g., `1<sup>st</sup> thing` should keep the space).
136
+ # But `1<br />\n2` should drop the newline
137
+ prev = ns.previous_sibling
138
+ if isinstance(prev, Tag) and prev.name == "br":
139
+ text = text.lstrip()
140
+ next = ns.next_sibling
141
+ if isinstance(next, Tag) and next.name == "br":
142
+ text = text.rstrip()
143
+
144
+ return text or None
145
+ elif mode == Mode.CELL:
146
+ return str(ns).strip().replace("\n", "<br>")
147
+
148
+ raise AssertionError()
149
+
150
+
151
+ def indent_block(s: str, indent: int, skip_first: bool) -> str:
152
+ return "\n".join(
153
+ line if skip_first and i == 0 else " " * indent + line
154
+ for (i, line) in enumerate(s.splitlines())
155
+ )
156
+
157
+
158
+ LATEX_TO_UNICODE_REPLACEMENTS = {
159
+ "\\times": "×",
160
+ "\\le": "≤",
161
+ "\\ge": "≥",
162
+ "\\lt": "<",
163
+ "\\gt": ">",
164
+ "\\dots": "...",
165
+ "\\cdots": "...",
166
+ "\\gcd": "gcd",
167
+ "\\pm": "±",
168
+ }
@@ -0,0 +1,81 @@
1
+ import itertools
2
+ from collections.abc import Iterable
3
+
4
+
5
+ def intervalize(numbers: Iterable[int]) -> list[tuple[int, int]]:
6
+ """
7
+ Given an collection of integers, groups them into contiguous intervals.
8
+ Intervals are reported with both endpoints inclusive, and single integers are
9
+ reported as (a, a).
10
+ """
11
+ numbers = sorted(numbers)
12
+
13
+ if not numbers:
14
+ return []
15
+
16
+ intervals = []
17
+
18
+ start = numbers[0] # start point of current interval
19
+ for prev, n in itertools.pairwise(numbers):
20
+ if start is None:
21
+ start = prev
22
+
23
+ if n == prev + 1:
24
+ continue
25
+
26
+ intervals.append((start, prev))
27
+ start = n
28
+
29
+ # Make sure to close the last interval.
30
+ intervals.append((start, numbers[-1]))
31
+
32
+ return intervals
33
+
34
+
35
+ def format_as_intervals(
36
+ numbers: Iterable[int], infinite_tail: int | None = None
37
+ ) -> str:
38
+ """
39
+ Pretty-prints the collection of numbers by grouping them into intervals and printing
40
+ the intervals.
41
+
42
+ Able to handle certain infinite collections by specifying `infinite_tail=n` (all
43
+ integers at least n are included in the set.)
44
+ """
45
+ intervals = intervalize(numbers)
46
+
47
+ # If there's an infinite tail, and it overlaps or is adjacent to any of the intervals,
48
+ # merge those intervals into the tail.
49
+ if infinite_tail is not None:
50
+ while len(intervals) > 0:
51
+ (start, end) = intervals[-1]
52
+ # `end+1` so that (5, 10) will merge with infinite_tail=11
53
+ if infinite_tail <= end + 1:
54
+ infinite_tail = min(start, infinite_tail)
55
+ intervals.pop()
56
+ else:
57
+ # intervals are in increasing order, so if infinite_tail is
58
+ # larger than the last interval it's larger than all of them
59
+ break
60
+
61
+ # Stringify things
62
+ output = [
63
+ f"{start}-{end}" if start != end else f"{start}" for (start, end) in intervals
64
+ ]
65
+
66
+ if infinite_tail is not None:
67
+ output.append(f"{infinite_tail}-inf")
68
+
69
+ return ", ".join(output)
70
+
71
+
72
+ def parse_interval_string(s: str) -> tuple[int, int]:
73
+ try:
74
+ x = int(s)
75
+ return (x, x)
76
+ except ValueError:
77
+ pass
78
+
79
+ # This ValueError through, we want to throw
80
+ start, end = s.split("-")
81
+ return (int(start), int(end))
@@ -0,0 +1,7 @@
1
+ """
2
+ {description}
3
+ """
4
+
5
+
6
+ def solve_problem() -> int:
7
+ ...
@@ -0,0 +1,98 @@
1
+ from importlib import import_module
2
+ from pathlib import Path
3
+
4
+ import requests
5
+
6
+ from .answer import Answer, ProblemId
7
+ from .config import EulerConfig
8
+ from .format import htmlToDocstring
9
+
10
+
11
+ def filename(n: int) -> str:
12
+ return f"p{n:04}"
13
+
14
+
15
+ def format_problem_id(id: ProblemId) -> str:
16
+ """How a problem is named back to the user."""
17
+ return f"#{id:04}" if isinstance(id, int) else f"bonus-{id}"
18
+
19
+
20
+ def problem_path(config: EulerConfig, n: int) -> Path:
21
+ return config.problems_dir / f"{filename(n)}.py"
22
+
23
+
24
+ def module_name(config: EulerConfig, id: ProblemId) -> str:
25
+ """The importable name of the module solving the given problem."""
26
+ if isinstance(id, int):
27
+ return f"{config.problems.__name__}.{filename(id)}"
28
+
29
+ if config.bonus is None:
30
+ raise ValueError("Config did not specify a module for the bonus problems!")
31
+ return f"{config.bonus.__name__}.p{id}"
32
+
33
+
34
+ def run_problem(config: EulerConfig, id: ProblemId) -> Answer:
35
+ module = import_module(module_name(config, id))
36
+ answer = module.solve_problem()
37
+ if not isinstance(answer, Answer):
38
+ raise TypeError(
39
+ f"{module.__name__}.solve_problem() returned {answer!r}, "
40
+ "which is not an int or a str"
41
+ )
42
+ return answer
43
+
44
+
45
+ def run_problem_if_exists(config: EulerConfig, id: ProblemId) -> Answer | None:
46
+ # A bonus problem can't exist if the repo never named a package for them.
47
+ if isinstance(id, str) and config.bonus is None:
48
+ return None
49
+
50
+ try:
51
+ return run_problem(config, id)
52
+ except ImportError:
53
+ return None
54
+
55
+
56
+ def get_problem_description(n: int) -> str:
57
+ """
58
+ Fetch the project description from the Project Euler website, and apply some light formatting.
59
+ """
60
+
61
+ # TODO error handling (just capture from outside this fn)
62
+ resp = requests.get(f"https://projecteuler.net/minimal={n}")
63
+ return htmlToDocstring(resp.text)
64
+
65
+
66
+ def list_problems(config: EulerConfig) -> list[int]:
67
+ """
68
+ Returns a list of integers for which a solver file exists.
69
+ """
70
+
71
+ problems = []
72
+ for file in config.problems_dir.glob("p*.py"):
73
+ try:
74
+ problems.append(int(file.stem.removeprefix("p")))
75
+ except ValueError:
76
+ pass
77
+
78
+ return problems
79
+
80
+
81
+ def list_bonus_problems(config: EulerConfig) -> list[str]:
82
+ """
83
+ Returns the ids of the bonus problems for which a solver file exists.
84
+ """
85
+
86
+ if config.bonus_dir is None:
87
+ return []
88
+
89
+ return [
90
+ file.stem.removeprefix("p")
91
+ for file in config.bonus_dir.glob("p*.py")
92
+ if file.stem != "p"
93
+ ]
94
+
95
+
96
+ def list_all_problems(config: EulerConfig) -> list[ProblemId]:
97
+ """Every problem, numbered or bonus, that has a solver file."""
98
+ return [*list_problems(config), *list_bonus_problems(config)]
File without changes