Skip to content

Commit 16acec1

Browse files
committed
Finish tasks migration
1 parent 210941d commit 16acec1

File tree

292 files changed

+16766
-466
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

292 files changed

+16766
-466
lines changed

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,4 @@
33
release/
44
release.tar.gz
55
release-v*.tar.gz
6-
.clj-kondo
76
.lsp

Makefile

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,37 @@ preview:
44
@pnpm --dir preview install --silent
55
@cd preview && pnpm dev
66

7-
build:
7+
build-private:
8+
@$(PYTHON) test_solutions.py private
9+
10+
check-names:
11+
@$(PYTHON) check_task_names.py
12+
13+
build: check-names
814
@$(PYTHON) test_solutions.py tasks
915

1016
build-and-preview: build preview
1117

1218
release: build
1319
tar -czf release.tar.gz release/
1420

15-
.PHONY: release preview build build-and-preview
21+
22+
push-local:
23+
@$(PYTHON) push_tasks.py http://localhost:4000/ext_api/tasks --hidden
24+
25+
push-private:
26+
@$(PYTHON) push_tasks.py https://codebattle.hexlet.io/ext_api/tasks --hidden
27+
28+
push-public:
29+
@$(PYTHON) push_tasks.py https://codebattle.hexlet.io/ext_api/tasks --public
30+
31+
push-packs-local:
32+
@$(PYTHON) push_task_packs.py http://localhost:4000/ext_api/task_packs --hidden
33+
34+
push-packs-private:
35+
@$(PYTHON) push_task_packs.py https://codebattle.hexlet.io/ext_api/task_packs --hidden
36+
37+
push-packs-public:
38+
@$(PYTHON) push_task_packs.py https://codebattle.hexlet.io/ext_api/task_packs --public
39+
40+
.PHONY: release preview build build-and-preview check-names push push-private push-public push-local push-packs-local push-packs-private push-packs-public

ai/project_structure.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
1. all tasks lives in tasks
2+
2. there are 4 levels of tasks: elementary, easy, medium, hard
3+
3. each task has a unique name
4+
4. each task has a set of tags which is a list of strings
5+
5. task path should be always as /tasks/<level>/<tags[0]>/<name>.toml
File renamed without changes.
File renamed without changes.

check_task_names.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Check uniqueness of task names across tasks/ and private/ directories.
4+
Task names are checked case-insensitively to prevent confusion.
5+
"""
6+
7+
import os
8+
import sys
9+
from collections import defaultdict
10+
from typing import Dict, List, Tuple
11+
12+
import tomli
13+
from termcolor import colored
14+
15+
16+
def find_toml_files(directory: str) -> List[str]:
17+
"""
18+
Recursively find all .toml files in directory.
19+
Returns list of absolute paths.
20+
Returns empty list if directory doesn't exist.
21+
"""
22+
if not os.path.exists(directory):
23+
return []
24+
25+
toml_files = []
26+
for root, _, files in os.walk(directory):
27+
for file in files:
28+
if file.endswith(".toml"):
29+
toml_files.append(os.path.join(root, file))
30+
31+
return toml_files
32+
33+
34+
def extract_task_names(toml_files: List[str], base_dir: str) -> Dict[str, List[str]]:
35+
"""
36+
Extract task names from TOML files.
37+
Returns mapping of lowercase_name -> [file_paths]
38+
Raises ValueError if any file is missing 'name' field.
39+
"""
40+
name_to_files = defaultdict(list)
41+
42+
for toml_path in toml_files:
43+
rel_path = os.path.relpath(toml_path, base_dir)
44+
45+
# Parse TOML file
46+
try:
47+
with open(toml_path, "rb") as f:
48+
data = tomli.load(f)
49+
except Exception as e:
50+
print(colored(f"ERROR: Failed to parse '{rel_path}': {str(e)}", "red"))
51+
sys.exit(1)
52+
53+
# Extract name field
54+
name = data.get("name")
55+
if not name:
56+
print(
57+
colored(
58+
f"ERROR: File '{rel_path}' is missing required 'name' field", "red"
59+
)
60+
)
61+
sys.exit(1)
62+
63+
# Store with lowercase key for case-insensitive comparison
64+
lowercase_name = name.lower()
65+
name_to_files[lowercase_name].append(toml_path)
66+
67+
return dict(name_to_files)
68+
69+
70+
def check_uniqueness(
71+
name_to_files: Dict[str, List[str]], base_dir: str
72+
) -> Tuple[bool, int, int]:
73+
"""
74+
Check for duplicate task names and print report.
75+
Returns (is_unique, total_files, duplicate_count)
76+
"""
77+
total_files = sum(len(files) for files in name_to_files.values())
78+
79+
print(colored("Checking task names for uniqueness...", "cyan"))
80+
print(
81+
f"Found {colored(str(total_files), 'cyan')} task files across tasks/ and private/\n"
82+
)
83+
84+
# Find duplicates (names appearing in more than one file)
85+
duplicates = {
86+
name: files for name, files in name_to_files.items() if len(files) > 1
87+
}
88+
89+
if not duplicates:
90+
print(colored("✅ All task names are unique!", "green", attrs=["bold"]))
91+
return (True, total_files, 0)
92+
93+
# Report duplicates
94+
print(colored("❌ DUPLICATE TASK NAMES FOUND:", "red", attrs=["bold"]))
95+
print()
96+
97+
for name, files in sorted(duplicates.items()):
98+
print(colored(f"Task name: '{name}' appears in {len(files)} files:", "yellow"))
99+
for file_path in files:
100+
rel_path = os.path.relpath(file_path, base_dir)
101+
print(f" - {rel_path}")
102+
print()
103+
104+
# Print summary
105+
duplicate_file_count = sum(len(files) for files in duplicates.values())
106+
print(colored("═" * 70, "blue"))
107+
print(
108+
colored(
109+
f"Summary: Found {len(duplicates)} duplicate task name(s) across {duplicate_file_count} files",
110+
"red",
111+
attrs=["bold"],
112+
)
113+
)
114+
115+
return (False, total_files, len(duplicates))
116+
117+
118+
def main():
119+
"""Main entry point."""
120+
# Get base directory (project root)
121+
base_dir = os.path.dirname(os.path.abspath(__file__))
122+
123+
# Define paths to check
124+
tasks_dir = os.path.join(base_dir, "tasks")
125+
private_dir = os.path.join(base_dir, "private")
126+
127+
# Find all TOML files
128+
toml_files = []
129+
toml_files.extend(find_toml_files(tasks_dir))
130+
toml_files.extend(find_toml_files(private_dir))
131+
132+
if not toml_files:
133+
print(colored("WARNING: No TOML files found in tasks/ or private/", "yellow"))
134+
sys.exit(0)
135+
136+
# Extract task names (case-insensitive)
137+
try:
138+
name_to_files = extract_task_names(toml_files, base_dir)
139+
except Exception as e:
140+
print(colored(f"ERROR: {str(e)}", "red"))
141+
sys.exit(1)
142+
143+
# Check for duplicates
144+
is_unique, total_files, duplicate_count = check_uniqueness(name_to_files, base_dir)
145+
146+
# Exit with appropriate code
147+
sys.exit(0 if is_unique else 1)
148+
149+
150+
if __name__ == "__main__":
151+
main()

private/.gitkeep

Whitespace-only changes.

push_task_packs.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Script to publish task pack JSON files to a remote URL.
4+
"""
5+
6+
import argparse
7+
import json
8+
import os
9+
import sys
10+
import urllib.error
11+
import urllib.request
12+
from pathlib import Path
13+
from typing import List
14+
15+
16+
def read_json_files(directory: str) -> List[tuple]:
17+
"""
18+
Read all JSON files from the specified directory.
19+
20+
Returns:
21+
List of tuples (filename, json_data)
22+
"""
23+
json_files = []
24+
task_packs_path = Path(directory)
25+
26+
if not task_packs_path.exists():
27+
print(f"Error: Directory '{directory}' does not exist", file=sys.stderr)
28+
sys.exit(1)
29+
30+
for json_file in task_packs_path.glob("*.json"):
31+
try:
32+
with open(json_file, "r", encoding="utf-8") as f:
33+
data = json.load(f)
34+
json_files.append((json_file.name, data))
35+
print(f"Read: {json_file.name}")
36+
except json.JSONDecodeError as e:
37+
print(f"Error reading {json_file.name}: {e}", file=sys.stderr)
38+
except Exception as e:
39+
print(f"Error processing {json_file.name}: {e}", file=sys.stderr)
40+
41+
return json_files
42+
43+
44+
def publish_task_pack(
45+
url: str, pack_data: dict, visibility: str, origin: str, auth_token: str
46+
) -> bool:
47+
"""
48+
Publish a single task pack to the remote URL.
49+
50+
Args:
51+
url: The target URL
52+
pack_data: The JSON data to publish
53+
visibility: Visibility setting ('public' or 'hidden')
54+
origin: Origin of the task packs
55+
auth_token: Authorization token from environment
56+
57+
Returns:
58+
True if successful, False otherwise
59+
"""
60+
# Prepare the payload
61+
payload = {"task_pack": pack_data, "visibility": visibility, "origin": origin}
62+
63+
# Prepare headers
64+
headers = {
65+
"Content-Type": "application/json",
66+
}
67+
68+
if auth_token:
69+
headers["X-AUTH-KEY"] = auth_token
70+
71+
# Convert payload to JSON bytes
72+
data = json.dumps(payload).encode("utf-8")
73+
74+
# Create request
75+
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
76+
77+
try:
78+
with urllib.request.urlopen(req) as response:
79+
status = response.status
80+
response_body = response.read().decode("utf-8")
81+
if status in (200, 201):
82+
return True
83+
else:
84+
print(f" Warning: Received status {status}", file=sys.stderr)
85+
print(f" Response: {response_body}", file=sys.stderr)
86+
return False
87+
except urllib.error.HTTPError as e:
88+
error_body = e.read().decode("utf-8") if e.fp else ""
89+
print(f" HTTP Error {e.code}: {e.reason}", file=sys.stderr)
90+
if error_body:
91+
print(f" Response: {error_body}", file=sys.stderr)
92+
return False
93+
except urllib.error.URLError as e:
94+
print(f" URL Error: {e.reason}", file=sys.stderr)
95+
return False
96+
except Exception as e:
97+
print(f" Error: {e}", file=sys.stderr)
98+
return False
99+
100+
101+
def main():
102+
parser = argparse.ArgumentParser(
103+
description="Publish task pack JSON files to a remote URL"
104+
)
105+
parser.add_argument("url", help="Target URL to publish task packs to")
106+
parser.add_argument(
107+
"--public",
108+
action="store_true",
109+
help="Set visibility to public (default: hidden)",
110+
)
111+
parser.add_argument(
112+
"--hidden", action="store_true", help="Set visibility to hidden (default)"
113+
)
114+
parser.add_argument(
115+
"--origin",
116+
default="github",
117+
help="Origin of the task packs (default: github)",
118+
)
119+
parser.add_argument(
120+
"--dir",
121+
default="task_packs",
122+
help="Directory containing JSON files (default: task_packs)",
123+
)
124+
125+
args = parser.parse_args()
126+
127+
# Determine visibility
128+
if args.public and args.hidden:
129+
print("Error: Cannot specify both --public and --hidden", file=sys.stderr)
130+
sys.exit(1)
131+
132+
visibility = "public" if args.public else "hidden"
133+
134+
# Get auth token from environment
135+
auth_token = os.environ.get("CODEBATTLE_AUTH_TOKEN", "")
136+
if not auth_token:
137+
print(
138+
"Warning: CODEBATTLE_AUTH_TOKEN environment variable not set",
139+
file=sys.stderr,
140+
)
141+
142+
# Read JSON files
143+
print(f"Reading JSON files from '{args.dir}/'...")
144+
json_files = read_json_files(args.dir)
145+
146+
if not json_files:
147+
print("No JSON files found to publish", file=sys.stderr)
148+
sys.exit(1)
149+
150+
print(f"\nPublishing {len(json_files)} task packs to {args.url}...")
151+
print(f"Visibility: {visibility}")
152+
print(f"Origin: {args.origin}")
153+
print()
154+
155+
# Publish each task pack
156+
success_count = 0
157+
fail_count = 0
158+
159+
for filename, pack_data in json_files:
160+
print(f"Publishing: {filename}... ", end="", flush=True)
161+
if publish_task_pack(args.url, pack_data, visibility, args.origin, auth_token):
162+
print("✓")
163+
success_count += 1
164+
else:
165+
print("✗")
166+
fail_count += 1
167+
168+
# Summary
169+
print()
170+
print(f"Summary: {success_count} successful, {fail_count} failed")
171+
172+
if fail_count > 0:
173+
sys.exit(1)
174+
175+
176+
if __name__ == "__main__":
177+
main()

0 commit comments

Comments
 (0)