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.
- project_youler-0.0.1/LICENSE +24 -0
- project_youler-0.0.1/PKG-INFO +138 -0
- project_youler-0.0.1/README.md +117 -0
- project_youler-0.0.1/pyproject.toml +50 -0
- project_youler-0.0.1/pyproject.toml.orig +43 -0
- project_youler-0.0.1/src/youler/__init__.py +4 -0
- project_youler-0.0.1/src/youler/answer.py +71 -0
- project_youler-0.0.1/src/youler/cli.py +411 -0
- project_youler-0.0.1/src/youler/config.py +63 -0
- project_youler-0.0.1/src/youler/format.py +168 -0
- project_youler-0.0.1/src/youler/intervals.py +81 -0
- project_youler-0.0.1/src/youler/problem.py.template +7 -0
- project_youler-0.0.1/src/youler/problems.py +98 -0
- project_youler-0.0.1/src/youler/py.typed +0 -0
|
@@ -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,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,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
|