python-ecd 0.1.9__tar.gz → 0.2.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.

Potentially problematic release.


This version of python-ecd might be problematic. Click here for more details.

@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: python-ecd
3
- Version: 0.1.9
3
+ Version: 0.2.1
4
4
  Summary: Python CLI for Everybody Codes
5
5
  Author: Pablo Garcia
6
6
  Author-email: Pablo Garcia <pablofueros@gmail.com>
7
7
  Classifier: Programming Language :: Python :: 3.13
8
8
  Classifier: License :: OSI Approved :: MIT License
9
- Requires-Dist: everybody-codes-data==0.2
9
+ Requires-Dist: everybody-codes-data==0.3
10
10
  Requires-Dist: typer>=0.20.0
11
11
  Requires-Python: >=3.13
12
12
  Description-Content-Type: text/markdown
@@ -84,10 +84,10 @@ Note that is not necessary if you set it during initialization.
84
84
 
85
85
  ### Download Puzzle Input
86
86
 
87
- Get the input for a specific puzzle:
87
+ Download the input for a specific puzzle:
88
88
 
89
89
  ```bash
90
- ecd get <QUEST_NUMBER> [OPTIONS]
90
+ ecd pull <QUEST_NUMBER> [OPTIONS]
91
91
  ```
92
92
 
93
93
  Options:
@@ -95,6 +95,8 @@ Options:
95
95
  - `--part`, `-p`: Puzzle part (default: 1)
96
96
  - `--force`, `-f`: Overwrite existing files
97
97
 
98
+ This command will create the necessary dirs and files if they do not exist.
99
+
98
100
  ### Run Solutions
99
101
 
100
102
  Execute your solution for a specific puzzle:
@@ -119,6 +121,18 @@ Options:
119
121
  - `--year`: Event year (default: actual)
120
122
  - `--part`: Part number to test (default: 1)
121
123
 
124
+ ### Submit Solutions
125
+
126
+ Submit your solution for a specific puzzle:
127
+
128
+ ```bash
129
+ ecd push <QUEST_NUMBER> [OPTIONS]
130
+ ```
131
+
132
+ Options:
133
+ - `--year`: Event year (default: actual)
134
+ - `--part`: Part number to test (default: 1)
135
+
122
136
  ### Display Version
123
137
 
124
138
  Show the current version of the tool:
@@ -129,7 +143,7 @@ ecd --version
129
143
 
130
144
  ## ©️ License
131
145
 
132
- [MIT License](LICENSE)
146
+ This project is licensed under the terms of the MIT license.
133
147
 
134
148
  ## 🤝 Contributing
135
149
 
@@ -71,10 +71,10 @@ Note that is not necessary if you set it during initialization.
71
71
 
72
72
  ### Download Puzzle Input
73
73
 
74
- Get the input for a specific puzzle:
74
+ Download the input for a specific puzzle:
75
75
 
76
76
  ```bash
77
- ecd get <QUEST_NUMBER> [OPTIONS]
77
+ ecd pull <QUEST_NUMBER> [OPTIONS]
78
78
  ```
79
79
 
80
80
  Options:
@@ -82,6 +82,8 @@ Options:
82
82
  - `--part`, `-p`: Puzzle part (default: 1)
83
83
  - `--force`, `-f`: Overwrite existing files
84
84
 
85
+ This command will create the necessary dirs and files if they do not exist.
86
+
85
87
  ### Run Solutions
86
88
 
87
89
  Execute your solution for a specific puzzle:
@@ -106,6 +108,18 @@ Options:
106
108
  - `--year`: Event year (default: actual)
107
109
  - `--part`: Part number to test (default: 1)
108
110
 
111
+ ### Submit Solutions
112
+
113
+ Submit your solution for a specific puzzle:
114
+
115
+ ```bash
116
+ ecd push <QUEST_NUMBER> [OPTIONS]
117
+ ```
118
+
119
+ Options:
120
+ - `--year`: Event year (default: actual)
121
+ - `--part`: Part number to test (default: 1)
122
+
109
123
  ### Display Version
110
124
 
111
125
  Show the current version of the tool:
@@ -116,7 +130,7 @@ ecd --version
116
130
 
117
131
  ## ©️ License
118
132
 
119
- [MIT License](LICENSE)
133
+ This project is licensed under the terms of the MIT license.
120
134
 
121
135
  ## 🤝 Contributing
122
136
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "python-ecd"
3
- version = "0.1.9"
3
+ version = "0.2.1"
4
4
  description = "Python CLI for Everybody Codes"
5
5
  readme = "README.md"
6
6
  classifiers = [
@@ -9,7 +9,10 @@ classifiers = [
9
9
  ]
10
10
  authors = [{ name = "Pablo Garcia", email = "pablofueros@gmail.com" }]
11
11
  requires-python = ">=3.13"
12
- dependencies = ["everybody-codes-data==0.2", "typer>=0.20.0"]
12
+ dependencies = [
13
+ "everybody-codes-data==0.3",
14
+ "typer>=0.20.0",
15
+ ]
13
16
 
14
17
  [project.scripts]
15
18
  python-ecd = "python_ecd.cli:app"
@@ -85,14 +85,13 @@ def set_token_cmd(
85
85
  typer.echo("🔑 Session token saved to /home/.config/ecd/token")
86
86
 
87
87
 
88
- @app.command("get")
89
- def get_cmd(
88
+ @app.command("pull")
89
+ def pull_cmd(
90
90
  quest: int = typer.Argument(..., help="Quest number (e.g. 3)"),
91
91
  year: int = typer.Option(datetime.now().year, "--year", "-y", help="Event year"),
92
- part: int = typer.Option(1, "--part", "-p", help="Puzzle part (1, 2, or 3)"),
93
92
  force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing files"),
94
93
  ) -> None:
95
- """Download input data for a given quest and create local structure."""
94
+ """Download available input data for a given quest and create local structure."""
96
95
 
97
96
  # 0. Locate base directory
98
97
  base_dir = utils.find_base()
@@ -106,10 +105,9 @@ def get_cmd(
106
105
 
107
106
  # 2. Download the input for the given part
108
107
  try:
109
- input_text = utils.download_input(year, quest, part)
108
+ input_dict = utils.download_input(year, quest)
110
109
  except Exception as e:
111
110
  typer.echo(f"❌ Failed to fetch input: {e}")
112
- typer.echo("ℹ️ Maybe ")
113
111
  raise typer.Exit(1)
114
112
 
115
113
  # 3. Prepare directory structure
@@ -118,29 +116,34 @@ def get_cmd(
118
116
  # 4. Create solution.py if missing
119
117
  solution_file = quest_dir / "solution.py"
120
118
  if solution_file.exists() and not force:
121
- typer.echo("⚠️ solution.py already exists (use --force to overwrite).")
119
+ typer.echo(
120
+ "⚠️ The solution python file already exists (use --force to overwrite)."
121
+ )
122
122
  else:
123
123
  utils.create_solution(quest_dir, force)
124
124
  typer.echo("🧩 Created solution.py")
125
125
 
126
- # 5. Save input
127
- input_file = quest_dir / f"input/input_p{part}.txt"
128
- if input_file.exists() and not force:
129
- typer.echo("⚠️ Input file already exists (use --force to overwrite).")
130
- else:
131
- input_file.write_text(input_text, encoding="utf-8")
132
- typer.echo(f"📥 Saved input for quest {quest:02d} part {part}.")
133
-
134
- # 6. Ensure empty test file exists
135
- test_file = quest_dir / f"test/test_p{part}.txt"
136
- if test_file.exists() and not force:
137
- typer.echo("⚠️ Test file already exists (use --force to overwrite).")
138
- else:
139
- test_file.touch(exist_ok=True)
140
-
141
- typer.echo(
142
- f"✅ Quest {quest:02d} (Part {part}) ready at {solution_file.relative_to(base_dir)}"
143
- )
126
+ for key, input in input_dict.items():
127
+ # 5. Save available inputs
128
+ input_file = quest_dir / f"input/input_p{key}.txt"
129
+ if input_file.exists() and not force:
130
+ typer.echo(
131
+ f"⚠️ Input file for part {key} already exists (use --force to overwrite)."
132
+ )
133
+ else:
134
+ input_file.write_text(input, encoding="utf-8")
135
+ typer.echo(f"📥 Saved input for quest {quest:02d} part {key}.")
136
+
137
+ # 6. Ensure empty test file exists
138
+ test_file = quest_dir / f"test/test_p{key}.txt"
139
+ if test_file.exists() and not force:
140
+ typer.echo(
141
+ f"⚠️ Test file for part {key} already exists (use --force to overwrite)."
142
+ )
143
+ else:
144
+ test_file.touch(exist_ok=True)
145
+
146
+ typer.echo(f"✅ Quest {quest:02d} ready at {solution_file.relative_to(base_dir)}")
144
147
 
145
148
 
146
149
  @app.command("run")
@@ -185,5 +188,75 @@ def test_cmd(
185
188
  typer.echo(f"🧪 Test result for Quest {quest} Part {part}:\n{result}")
186
189
 
187
190
 
191
+ @app.command("push")
192
+ def push_cmd(
193
+ quest: int = typer.Argument(..., help="Quest number (e.g. 3)"),
194
+ year: int = typer.Option(datetime.now().year, "--year", "-y", help="Event year"),
195
+ part: int = typer.Option(1, "--part", "-p", help="Part number to test"),
196
+ ) -> None:
197
+ """Submit the solution for a given quest and part."""
198
+
199
+ # 0. Locate base directory
200
+ base_dir = utils.find_base()
201
+
202
+ # 1. Submit the solution
203
+ try:
204
+ result = utils.push_solution(base_dir, quest, year, part)
205
+ except Exception as e:
206
+ typer.echo(f"❌ Failed to submit solution for Quest {quest} Part {part}: {e}")
207
+ raise typer.Exit(1)
208
+
209
+ if result.get("correct"):
210
+ typer.echo(
211
+ f"✅ Correct answer for Quest {quest} Part {part}!"
212
+ f"\n🏅 - Global place: {result.get('globalPlace', '?')}"
213
+ f"\n🏅 - Global score: {result.get('globalScore', '?')}"
214
+ f"\n⏱️ - Global time: {utils.format_duration(result.get('globalTime'))}"
215
+ f"\n⏱️ - Local time: {utils.format_duration(result.get('localTime'))}"
216
+ )
217
+
218
+ # Download the next part (if the quest is not ended)
219
+ if part == 3:
220
+ return
221
+ else:
222
+ typer.echo()
223
+
224
+ # Download the input for the given part
225
+ try:
226
+ input_dict = utils.download_input(year, quest)
227
+ except Exception as e:
228
+ typer.echo(f"❌ Failed to fetch input: {e}")
229
+ raise typer.Exit(1)
230
+
231
+ # Prepare directory structure
232
+ quest_dir = utils.create_quest_dir(base_dir, year, quest)
233
+
234
+ # 5. Save available inputs
235
+ input_file = quest_dir / f"input/input_p{part + 1}.txt"
236
+ if input_file.exists():
237
+ typer.echo(
238
+ f"⚠️ Input file for part {part + 1} already exists (use ecd pull --force to overwrite)."
239
+ )
240
+ else:
241
+ input_file.write_text(input_dict[str(part + 1)], encoding="utf-8")
242
+ typer.echo(f"📥 Saved input for quest {quest:02d} part {part + 1}.")
243
+
244
+ # 6. Ensure empty test file exists
245
+ test_file = quest_dir / f"test/test_p{part + 1}.txt"
246
+ if test_file.exists():
247
+ typer.echo(
248
+ f"⚠️ Test file for part {part + 1} already exists (use ecd pull --force to overwrite)."
249
+ )
250
+ else:
251
+ test_file.touch(exist_ok=True)
252
+
253
+ else:
254
+ typer.echo(
255
+ f"❌ Incorrect answer for Quest {quest} Part {part}:"
256
+ f"\n - lengthCorrect={result.get('lengthCorrect')}, "
257
+ f"\n - firstCorrect={result.get('firstCorrect')}"
258
+ )
259
+
260
+
188
261
  if __name__ == "__main__":
189
262
  app()
@@ -2,7 +2,7 @@
2
2
 
3
3
  My solutions to the [Everybody Codes](https://everybody.codes/) puzzles — powered by **[`python-ecd`](https://github.com/pablofueros/python-ecd)** ⚙️
4
4
 
5
- > A lightweight CLI tool to fetch, run, and organize Everybody Codes challenges with ease.
5
+ > A lightweight CLI tool to fetch, test, and submit Everybody Codes challenges with ease.
6
6
 
7
7
  ---
8
8
 
@@ -35,10 +35,13 @@ Note that **[`python-ecd`](https://github.com/pablofueros/python-ecd)** must be
35
35
  ecd init
36
36
 
37
37
  # Fetch a puzzle input ()
38
- ecd get 3 # Quest 3 of the current year
38
+ ecd pull 3 # Quest 3 of the current year
39
39
 
40
40
  # Run your test cases
41
41
  ecd test 3 --part 1
42
42
 
43
43
  # Execute your actual input
44
44
  ecd run 3 --part 1
45
+
46
+ # Submit your answer
47
+ ecd push 3 --part 1
@@ -2,9 +2,11 @@ import contextlib
2
2
  import importlib.util
3
3
  import io
4
4
  import subprocess
5
+ from datetime import timedelta
5
6
  from pathlib import Path
7
+ from typing import Literal, cast
6
8
 
7
- from ecd import get_inputs
9
+ from ecd import get_inputs, submit
8
10
 
9
11
 
10
12
  def _read_template(template_name: str) -> str:
@@ -107,20 +109,12 @@ def get_token() -> str | None:
107
109
  return TOKEN_PATH.read_text().strip()
108
110
 
109
111
 
110
- def download_input(year: int, quest: int, part: int) -> str:
112
+ def download_input(year: int, quest: int) -> dict[str, str]:
111
113
  """
112
- Fetch the input text for a specific quest and part.
113
- Raises if part not available.
114
+ Fetch the available input text for a specific quest.
114
115
  """
115
-
116
116
  with contextlib.redirect_stderr(io.StringIO()):
117
- data = get_inputs(quest=quest, event=year)
118
-
119
- key = str(part)
120
- if key not in data:
121
- raise ValueError(f"Part {part} not unlocked or not available.")
122
-
123
- return data[key]
117
+ return get_inputs(quest=quest, event=year)
124
118
 
125
119
 
126
120
  def execute_part(
@@ -176,3 +170,86 @@ def execute_part(
176
170
  raise ValueError(f"{func_name} returned None.")
177
171
 
178
172
  return str(result)
173
+
174
+
175
+ def push_solution(
176
+ base_dir: Path,
177
+ quest: int,
178
+ year: int,
179
+ part: int,
180
+ ) -> dict:
181
+ """
182
+ Submit the solution for a given quest and part using input data.
183
+
184
+ Returns:
185
+ The response message from the submission.
186
+ """
187
+
188
+ quest_dir = base_dir / "events" / str(year) / f"quest_{quest:02d}"
189
+ solution_path = quest_dir / "solution.py"
190
+ if not solution_path.exists():
191
+ raise FileNotFoundError(
192
+ f"Missing solution file: {solution_path.relative_to(base_dir)}"
193
+ )
194
+
195
+ # Dynamic import
196
+ spec = importlib.util.spec_from_file_location("solution", solution_path)
197
+ if spec is None or spec.loader is None:
198
+ raise ImportError(
199
+ f"Could not load module from {solution_path.relative_to(base_dir)}"
200
+ )
201
+ module = importlib.util.module_from_spec(spec)
202
+ spec.loader.exec_module(module)
203
+
204
+ func_name = f"part_{part}"
205
+ func = getattr(module, func_name, None)
206
+ if not callable(func):
207
+ raise AttributeError(f"No function '{func_name}' defined in solution.py")
208
+
209
+ # Fetch input data
210
+ input_dict = download_input(year=year, quest=quest)
211
+ input_text = input_dict[str(part)]
212
+
213
+ # Run the function
214
+ answer = func(input_text)
215
+ if answer is None:
216
+ raise ValueError(f"{func_name} returned None.")
217
+
218
+ # Submit the answer
219
+ with contextlib.redirect_stderr(io.StringIO()):
220
+ result = submit(
221
+ quest=quest,
222
+ event=year,
223
+ part=cast(Literal[1, 2, 3], part),
224
+ answer=str(answer),
225
+ quiet=True,
226
+ )
227
+
228
+ if result.status != 200:
229
+ raise RuntimeError(f"Submission failed with status {result.status}")
230
+
231
+ return result.json()
232
+
233
+
234
+ def format_duration(ms: int | float | None) -> str:
235
+ """Format duration in milliseconds to a human-readable string using timedelta."""
236
+ if ms is None:
237
+ return "?"
238
+
239
+ td = timedelta(milliseconds=ms)
240
+
241
+ days = td.days
242
+ hours, remainder = divmod(td.seconds, 3600)
243
+ minutes, seconds = divmod(remainder, 60)
244
+
245
+ parts = []
246
+ if days:
247
+ parts.append(f"{days}d")
248
+ if hours:
249
+ parts.append(f"{hours}h")
250
+ if minutes:
251
+ parts.append(f"{minutes}m")
252
+ if seconds or not parts:
253
+ parts.append(f"{seconds}s")
254
+
255
+ return " ".join(parts)