hackerrank-cli 2026.10.1.1__py3-none-any.whl
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.
- hackerrank_cli/__init__.py +224 -0
- hackerrank_cli/__main__.py +5 -0
- hackerrank_cli/_sources.py +182 -0
- hackerrank_cli/_version.py +24 -0
- hackerrank_cli/py.typed +0 -0
- hackerrank_cli-2026.10.1.1.dist-info/METADATA +143 -0
- hackerrank_cli-2026.10.1.1.dist-info/RECORD +11 -0
- hackerrank_cli-2026.10.1.1.dist-info/WHEEL +5 -0
- hackerrank_cli-2026.10.1.1.dist-info/entry_points.txt +2 -0
- hackerrank_cli-2026.10.1.1.dist-info/licenses/LICENSE +21 -0
- hackerrank_cli-2026.10.1.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Upload project starter code using the released HackerRank SDK."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from importlib.metadata import version
|
|
5
|
+
from io import BytesIO
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
import httpx
|
|
11
|
+
import httpx2
|
|
12
|
+
from hackerrank.client import HackerRank
|
|
13
|
+
from hackerrank.exceptions import HackerRankError
|
|
14
|
+
from hackerrank.transports import Transport
|
|
15
|
+
|
|
16
|
+
from hackerrank_cli._sources import PreparedSource, prepare_source
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def create_cli(*, transport: Transport | None = None) -> click.Group:
|
|
20
|
+
"""Create the CLI, optionally using a supported SDK transport."""
|
|
21
|
+
|
|
22
|
+
@click.group(name="hackerrank")
|
|
23
|
+
@click.version_option(
|
|
24
|
+
version=version(distribution_name="hackerrank-cli"),
|
|
25
|
+
prog_name="hackerrank",
|
|
26
|
+
)
|
|
27
|
+
def cli() -> None:
|
|
28
|
+
"""Upload starter files to existing HackerRank project
|
|
29
|
+
questions.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
@cli.group()
|
|
33
|
+
def questions() -> None:
|
|
34
|
+
"""Manage project-question starter files."""
|
|
35
|
+
|
|
36
|
+
@click.argument("question_id")
|
|
37
|
+
@click.option(
|
|
38
|
+
"--directory",
|
|
39
|
+
type=click.Path(path_type=Path, readable=False),
|
|
40
|
+
help="Project directory.",
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--file",
|
|
44
|
+
"source_file",
|
|
45
|
+
type=click.Path(path_type=Path, readable=False),
|
|
46
|
+
help="One starter file, uploaded as a single-file project archive.",
|
|
47
|
+
)
|
|
48
|
+
@click.option(
|
|
49
|
+
"--exclude",
|
|
50
|
+
multiple=True,
|
|
51
|
+
help="Additional Gitignore pattern at the upload root. Repeatable.",
|
|
52
|
+
)
|
|
53
|
+
@click.option(
|
|
54
|
+
"--dry-run",
|
|
55
|
+
is_flag=True,
|
|
56
|
+
help="Validate and list files without credentials or network access.",
|
|
57
|
+
)
|
|
58
|
+
@click.option(
|
|
59
|
+
"--retries",
|
|
60
|
+
type=click.IntRange(min=0),
|
|
61
|
+
default=0,
|
|
62
|
+
show_default=True,
|
|
63
|
+
help="Retry count delegated to the SDK for this repeatable upload.",
|
|
64
|
+
)
|
|
65
|
+
def upload( # noqa: PLR0913 - Click command options.
|
|
66
|
+
question_id: str,
|
|
67
|
+
directory: Path | None,
|
|
68
|
+
source_file: Path | None,
|
|
69
|
+
exclude: tuple[str, ...],
|
|
70
|
+
retries: int,
|
|
71
|
+
*,
|
|
72
|
+
dry_run: bool,
|
|
73
|
+
) -> None:
|
|
74
|
+
"""Replace project starter files while preserving question
|
|
75
|
+
metadata.
|
|
76
|
+
"""
|
|
77
|
+
_upload(
|
|
78
|
+
question_id=question_id,
|
|
79
|
+
directory=directory,
|
|
80
|
+
source_file=source_file,
|
|
81
|
+
exclude=exclude,
|
|
82
|
+
retries=retries,
|
|
83
|
+
dry_run=dry_run,
|
|
84
|
+
transport=transport,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
_ = questions.command()(upload)
|
|
88
|
+
return cli
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _validate_upload(
|
|
92
|
+
question_id: str,
|
|
93
|
+
directory: Path | None,
|
|
94
|
+
source_file: Path | None,
|
|
95
|
+
exclude: tuple[str, ...],
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Validate command combinations and the question identifier."""
|
|
98
|
+
if (directory is None) == (source_file is None):
|
|
99
|
+
msg = "Provide exactly one of --directory or --file."
|
|
100
|
+
raise click.UsageError(message=msg)
|
|
101
|
+
if (
|
|
102
|
+
not question_id.isascii()
|
|
103
|
+
or not question_id.isdecimal()
|
|
104
|
+
or int(question_id) < 1
|
|
105
|
+
):
|
|
106
|
+
msg = "QUESTION_ID must be a positive decimal integer."
|
|
107
|
+
raise click.BadParameter(message=msg, param_hint="QUESTION_ID")
|
|
108
|
+
if len(exclude) > 0 and directory is None:
|
|
109
|
+
msg = "--exclude requires --directory."
|
|
110
|
+
raise click.UsageError(message=msg)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _api_key() -> str:
|
|
114
|
+
"""Read a nonempty API token without printing its value."""
|
|
115
|
+
api_key = os.environ.get(key="HACKERRANK_API_TOKEN")
|
|
116
|
+
if api_key is None or api_key.strip() == "":
|
|
117
|
+
msg = (
|
|
118
|
+
"HACKERRANK_API_TOKEN is missing or empty. "
|
|
119
|
+
"Set it in the environment."
|
|
120
|
+
)
|
|
121
|
+
raise click.ClickException(message=msg)
|
|
122
|
+
return api_key
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _project_zip(source: PreparedSource) -> bytes:
|
|
126
|
+
"""Package prepared starter files for the released SDK's ZIP API."""
|
|
127
|
+
with BytesIO() as buffer:
|
|
128
|
+
with ZipFile(
|
|
129
|
+
file=buffer, mode="w", compression=ZIP_DEFLATED
|
|
130
|
+
) as archive:
|
|
131
|
+
if source.directory is None:
|
|
132
|
+
info = ZipInfo(filename=Path(source.files[0]).name)
|
|
133
|
+
info.external_attr = source.file_mode << 16
|
|
134
|
+
archive.writestr(
|
|
135
|
+
zinfo_or_arcname=info,
|
|
136
|
+
data=source.contents,
|
|
137
|
+
compress_type=ZIP_DEFLATED,
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
for name in source.files:
|
|
141
|
+
archive.write(
|
|
142
|
+
filename=source.directory / name, arcname=name
|
|
143
|
+
)
|
|
144
|
+
return buffer.getvalue()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _send_upload(
|
|
148
|
+
question_id: str,
|
|
149
|
+
archive: bytes,
|
|
150
|
+
retries: int,
|
|
151
|
+
transport: Transport | None,
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Delegate communication and retries to the SDK; report safe
|
|
154
|
+
errors.
|
|
155
|
+
"""
|
|
156
|
+
api_key = _api_key()
|
|
157
|
+
try:
|
|
158
|
+
with HackerRank(
|
|
159
|
+
api_key=api_key, transport=transport, retries=retries
|
|
160
|
+
) as client:
|
|
161
|
+
_ = client.questions.upload_project_zip(
|
|
162
|
+
question_id=question_id, file=archive
|
|
163
|
+
)
|
|
164
|
+
except HackerRankError as error:
|
|
165
|
+
# Response bodies can contain secrets; only report the status.
|
|
166
|
+
msg = f"HackerRank rejected the upload (HTTP {error.status_code})."
|
|
167
|
+
raise click.ClickException(message=msg) from None
|
|
168
|
+
except (httpx.TransportError, httpx2.TransportError):
|
|
169
|
+
msg = "Could not reach HackerRank. Check your network connection."
|
|
170
|
+
raise click.ClickException(message=msg) from None
|
|
171
|
+
except (ValueError, TypeError):
|
|
172
|
+
msg = (
|
|
173
|
+
"HackerRank returned an invalid response. The upload may have "
|
|
174
|
+
"succeeded; check the question before trying again."
|
|
175
|
+
)
|
|
176
|
+
raise click.ClickException(message=msg) from None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _upload( # noqa: PLR0913 - Click options plus the SDK transport boundary.
|
|
180
|
+
question_id: str,
|
|
181
|
+
directory: Path | None,
|
|
182
|
+
source_file: Path | None,
|
|
183
|
+
exclude: tuple[str, ...],
|
|
184
|
+
retries: int,
|
|
185
|
+
*,
|
|
186
|
+
dry_run: bool,
|
|
187
|
+
transport: Transport | None,
|
|
188
|
+
) -> None:
|
|
189
|
+
"""Prepare every byte before credentials or a network mutation."""
|
|
190
|
+
_validate_upload(
|
|
191
|
+
question_id=question_id,
|
|
192
|
+
directory=directory,
|
|
193
|
+
source_file=source_file,
|
|
194
|
+
exclude=exclude,
|
|
195
|
+
)
|
|
196
|
+
try:
|
|
197
|
+
with prepare_source(
|
|
198
|
+
directory=directory, file=source_file, excludes=exclude
|
|
199
|
+
) as source:
|
|
200
|
+
archive = _project_zip(source=source)
|
|
201
|
+
target = f"HackerRank project question {question_id}"
|
|
202
|
+
if dry_run:
|
|
203
|
+
click.echo(message=f"Would update {target}")
|
|
204
|
+
for path in source.files:
|
|
205
|
+
click.echo(message=f" {path}")
|
|
206
|
+
return
|
|
207
|
+
_send_upload(
|
|
208
|
+
question_id=question_id,
|
|
209
|
+
archive=archive,
|
|
210
|
+
retries=retries,
|
|
211
|
+
transport=transport,
|
|
212
|
+
)
|
|
213
|
+
click.echo(message=f"Updated {target}")
|
|
214
|
+
except UnicodeError:
|
|
215
|
+
msg = ".gitignore files must be valid UTF-8."
|
|
216
|
+
raise click.ClickException(message=msg) from None
|
|
217
|
+
except OSError as error:
|
|
218
|
+
msg = f"Could not prepare upload files ({type(error).__name__})."
|
|
219
|
+
raise click.ClickException(message=msg) from None
|
|
220
|
+
except ValueError as error:
|
|
221
|
+
raise click.ClickException(message=str(object=error)) from None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
main: click.Group = create_cli()
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Prepare local sources before handing them to the SDK."""
|
|
2
|
+
|
|
3
|
+
import stat
|
|
4
|
+
from collections.abc import Generator, Iterator, Sequence
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from shutil import copyfile, copymode
|
|
9
|
+
from tempfile import TemporaryDirectory
|
|
10
|
+
|
|
11
|
+
from pathspec import GitIgnoreSpec
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def validate_path(path: Path) -> None:
|
|
15
|
+
"""Reject symlinks in the path and its ancestors before resolving
|
|
16
|
+
it.
|
|
17
|
+
"""
|
|
18
|
+
for component in (path, *path.parents):
|
|
19
|
+
if component.name.casefold() == ".git":
|
|
20
|
+
msg = f"Git metadata cannot be uploaded: {component}"
|
|
21
|
+
raise ValueError(msg)
|
|
22
|
+
if component.is_symlink():
|
|
23
|
+
msg = f"Symbolic links are not supported: {component}"
|
|
24
|
+
raise ValueError(msg)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _rules(directory: Path) -> tuple[tuple[Path, GitIgnoreSpec], ...]:
|
|
28
|
+
"""Read one directory's ignore rules without following symlinks."""
|
|
29
|
+
ignore_file = directory / ".gitignore"
|
|
30
|
+
if ignore_file.is_symlink():
|
|
31
|
+
validate_path(path=ignore_file)
|
|
32
|
+
if ignore_file.is_file():
|
|
33
|
+
return (
|
|
34
|
+
(
|
|
35
|
+
directory,
|
|
36
|
+
GitIgnoreSpec.from_lines(
|
|
37
|
+
lines=ignore_file.read_text(encoding="utf-8").splitlines(),
|
|
38
|
+
),
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
return ()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _inherited_rules(
|
|
45
|
+
directory: Path,
|
|
46
|
+
) -> tuple[tuple[Path, GitIgnoreSpec], ...] | None:
|
|
47
|
+
"""Inherit rules from the nearest Git root, or start at the source."""
|
|
48
|
+
for candidate in (directory, *directory.parents):
|
|
49
|
+
if (candidate / ".git").exists():
|
|
50
|
+
bases = [directory]
|
|
51
|
+
while bases[-1] != candidate:
|
|
52
|
+
bases.append(bases[-1].parent)
|
|
53
|
+
rules: list[tuple[Path, GitIgnoreSpec]] = []
|
|
54
|
+
for base in reversed(bases):
|
|
55
|
+
if _is_ignored(path=base, rules=rules, suffix="/"):
|
|
56
|
+
return None
|
|
57
|
+
rules.extend(_rules(directory=base))
|
|
58
|
+
return tuple(rules)
|
|
59
|
+
return _rules(directory=directory)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _is_ignored(
|
|
63
|
+
path: Path,
|
|
64
|
+
rules: Sequence[tuple[Path, GitIgnoreSpec]],
|
|
65
|
+
*,
|
|
66
|
+
suffix: str,
|
|
67
|
+
) -> bool:
|
|
68
|
+
"""Apply the last matching rule from the deepest matching ignore
|
|
69
|
+
file.
|
|
70
|
+
"""
|
|
71
|
+
ignored = False
|
|
72
|
+
for base, spec in rules:
|
|
73
|
+
match = spec.check_file(
|
|
74
|
+
file=path.relative_to(base).as_posix() + suffix
|
|
75
|
+
)
|
|
76
|
+
if match.include is not None:
|
|
77
|
+
ignored = match.include
|
|
78
|
+
return ignored
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def require_regular_file(path: Path) -> None:
|
|
82
|
+
"""Reject devices, pipes, and other special files before reading."""
|
|
83
|
+
if not stat.S_ISREG(path.stat().st_mode):
|
|
84
|
+
msg = f"Only regular files are supported: {path}"
|
|
85
|
+
raise ValueError(msg)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def selected_files(
|
|
89
|
+
directory: Path, excludes: tuple[str, ...]
|
|
90
|
+
) -> tuple[Path, ...]:
|
|
91
|
+
"""Select regular files using layered Git ignores and final exclusions."""
|
|
92
|
+
exclusion_spec = GitIgnoreSpec.from_lines(lines=excludes)
|
|
93
|
+
|
|
94
|
+
def walk(
|
|
95
|
+
parent: Path, rules: tuple[tuple[Path, GitIgnoreSpec], ...]
|
|
96
|
+
) -> Iterator[Path]:
|
|
97
|
+
"""Traverse selected directories without following links."""
|
|
98
|
+
for path in sorted(parent.iterdir()):
|
|
99
|
+
if path.name.casefold() == ".git":
|
|
100
|
+
continue
|
|
101
|
+
is_directory = path.is_dir()
|
|
102
|
+
suffix = "/" if is_directory else ""
|
|
103
|
+
relative = path.relative_to(directory).as_posix() + suffix
|
|
104
|
+
if exclusion_spec.match_file(file=relative):
|
|
105
|
+
continue
|
|
106
|
+
if _is_ignored(path=path, rules=rules, suffix=suffix):
|
|
107
|
+
continue
|
|
108
|
+
validate_path(path=path)
|
|
109
|
+
if is_directory:
|
|
110
|
+
yield from walk(
|
|
111
|
+
parent=path, rules=(*rules, *_rules(directory=path))
|
|
112
|
+
)
|
|
113
|
+
else:
|
|
114
|
+
require_regular_file(path=path)
|
|
115
|
+
yield path.relative_to(directory)
|
|
116
|
+
|
|
117
|
+
inherited = _inherited_rules(directory=directory)
|
|
118
|
+
if inherited is None:
|
|
119
|
+
return ()
|
|
120
|
+
return tuple(walk(parent=directory, rules=inherited))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class PreparedSource:
|
|
125
|
+
"""Validated bytes or an isolated directory for ZIP preparation."""
|
|
126
|
+
|
|
127
|
+
contents: bytes
|
|
128
|
+
directory: Path | None
|
|
129
|
+
files: tuple[str, ...]
|
|
130
|
+
file_mode: int
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@contextmanager
|
|
134
|
+
def prepare_source(
|
|
135
|
+
*, directory: Path | None, file: Path | None, excludes: tuple[str, ...]
|
|
136
|
+
) -> Generator[PreparedSource]:
|
|
137
|
+
"""Read or stage everything before allowing a network mutation."""
|
|
138
|
+
if file is not None:
|
|
139
|
+
validate_path(path=file.absolute())
|
|
140
|
+
if not file.is_file():
|
|
141
|
+
msg = f"Not a regular file: {file}"
|
|
142
|
+
raise ValueError(msg)
|
|
143
|
+
# Preserve source bytes, including non-UTF-8 files.
|
|
144
|
+
contents = file.read_bytes()
|
|
145
|
+
yield PreparedSource(
|
|
146
|
+
contents=contents,
|
|
147
|
+
directory=None,
|
|
148
|
+
files=(str(object=file),),
|
|
149
|
+
file_mode=file.stat().st_mode,
|
|
150
|
+
)
|
|
151
|
+
return
|
|
152
|
+
if directory is None:
|
|
153
|
+
msg = "Provide exactly one of --directory or --file."
|
|
154
|
+
raise ValueError(msg)
|
|
155
|
+
validate_path(path=directory.absolute())
|
|
156
|
+
directory = directory.resolve(strict=True)
|
|
157
|
+
if not directory.is_dir():
|
|
158
|
+
msg = f"Not a directory: {directory}"
|
|
159
|
+
raise ValueError(msg)
|
|
160
|
+
files = selected_files(directory=directory, excludes=excludes)
|
|
161
|
+
if len(files) == 0:
|
|
162
|
+
msg = "No files selected for upload."
|
|
163
|
+
raise ValueError(msg)
|
|
164
|
+
with TemporaryDirectory(prefix="hackerrank-cli-") as temporary:
|
|
165
|
+
staged = Path(temporary).resolve() / "project"
|
|
166
|
+
staged.mkdir()
|
|
167
|
+
for relative in files:
|
|
168
|
+
source = directory / relative
|
|
169
|
+
validate_path(path=source)
|
|
170
|
+
target = staged / relative
|
|
171
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
# Never dereference a symlink introduced after selection.
|
|
173
|
+
# Validate the copied path before packaging its bytes.
|
|
174
|
+
_ = copyfile(src=source, dst=target, follow_symlinks=False)
|
|
175
|
+
validate_path(path=target)
|
|
176
|
+
copymode(src=source, dst=target, follow_symlinks=False)
|
|
177
|
+
yield PreparedSource(
|
|
178
|
+
contents=b"",
|
|
179
|
+
directory=staged,
|
|
180
|
+
files=tuple(path.as_posix() for path in files),
|
|
181
|
+
file_mode=0,
|
|
182
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '2026.10.1.1'
|
|
22
|
+
__version_tuple__ = version_tuple = (2026, 10, 1, 1)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
hackerrank_cli/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hackerrank-cli
|
|
3
|
+
Version: 2026.10.1.1
|
|
4
|
+
Summary: Upload starter files to existing HackerRank project questions.
|
|
5
|
+
Author-email: Adam Dangoor <adamdangoor@gmail.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Documentation, https://adamtheturtle.github.io/hackerrank-cli/
|
|
8
|
+
Project-URL: Issues, https://github.com/adamtheturtle/hackerrank-cli/issues
|
|
9
|
+
Project-URL: Source, https://github.com/adamtheturtle/hackerrank-cli
|
|
10
|
+
Keywords: cli,hackerrank,starter-code
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Requires-Python: >=3.13
|
|
18
|
+
Description-Content-Type: text/x-rst
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: click<8.6,>=8.3
|
|
21
|
+
Requires-Dist: hackerrank>=2026.9.16
|
|
22
|
+
Requires-Dist: httpx>=0.28
|
|
23
|
+
Requires-Dist: httpx2>=2.12
|
|
24
|
+
Requires-Dist: pathspec>=1
|
|
25
|
+
Dynamic: license-file
|
|
26
|
+
|
|
27
|
+
hackerrank-cli
|
|
28
|
+
==============
|
|
29
|
+
|
|
30
|
+
Upload starter files to an existing HackerRank for Work project question using
|
|
31
|
+
the ``hackerrank`` Python SDK.
|
|
32
|
+
This independent CLI replaces only the project archive, preserving the
|
|
33
|
+
question's name, problem statement, environment, scoring settings, and other
|
|
34
|
+
metadata.
|
|
35
|
+
|
|
36
|
+
The initial scope is project questions such as fullstack, frontend, and backend
|
|
37
|
+
projects.
|
|
38
|
+
Language-specific coding-question stubs are outside this CLI's initial scope.
|
|
39
|
+
|
|
40
|
+
Requires Python 3.13 or later.
|
|
41
|
+
Install from PyPI:
|
|
42
|
+
|
|
43
|
+
.. code-block:: console
|
|
44
|
+
|
|
45
|
+
uv tool install hackerrank-cli
|
|
46
|
+
hackerrank --help
|
|
47
|
+
hackerrank --version
|
|
48
|
+
|
|
49
|
+
You can also install with ``pip install hackerrank-cli``.
|
|
50
|
+
|
|
51
|
+
Usage
|
|
52
|
+
-----
|
|
53
|
+
|
|
54
|
+
Use synthetic question ID ``123456`` below in place of your own question ID.
|
|
55
|
+
Exactly one content source is required:
|
|
56
|
+
|
|
57
|
+
.. code-block:: console
|
|
58
|
+
|
|
59
|
+
hackerrank questions upload 123456 --directory ./starter --dry-run
|
|
60
|
+
hackerrank questions upload 123456 --directory ./starter --exclude '*.zip'
|
|
61
|
+
hackerrank questions upload 123456 --file ./starter.py
|
|
62
|
+
|
|
63
|
+
Real uploads read ``HACKERRANK_API_TOKEN`` from the environment.
|
|
64
|
+
Get a key from the HackerRank for Work API tokens page.
|
|
65
|
+
Missing, empty, and whitespace-only keys fail clearly.
|
|
66
|
+
Dry runs need no key and make no network requests.
|
|
67
|
+
|
|
68
|
+
Exactly one of ``--directory`` or ``--file`` is required.
|
|
69
|
+
Both replace the entire project archive.
|
|
70
|
+
``--file`` creates a one-file project using the source's basename; it does not
|
|
71
|
+
update a language-specific code stub or upload a prebuilt ZIP as the project
|
|
72
|
+
archive.
|
|
73
|
+
|
|
74
|
+
All file bytes are preserved exactly, including binary files, CRLF, blank
|
|
75
|
+
lines, trailing whitespace, UTF-8 BOMs, and documentation markers.
|
|
76
|
+
Empty single files are valid.
|
|
77
|
+
Neither paths nor IDs are inferred from repository configuration.
|
|
78
|
+
Inputs are fully read, staged, and archived before credentials are read or a
|
|
79
|
+
mutation is issued.
|
|
80
|
+
POSIX executable bits are preserved.
|
|
81
|
+
Errors exit nonzero.
|
|
82
|
+
API response bodies and credentials are never printed.
|
|
83
|
+
The default retry count is zero, matching the released SDK.
|
|
84
|
+
Set ``--retries 3`` to delegate retries and backoff to the SDK for this
|
|
85
|
+
repeatable upload.
|
|
86
|
+
The CLI adds no separate retry loop.
|
|
87
|
+
Each attempt uses the same prepared archive bytes.
|
|
88
|
+
|
|
89
|
+
Directory selection
|
|
90
|
+
-------------------
|
|
91
|
+
|
|
92
|
+
* Both Git and non-Git directories work.
|
|
93
|
+
Paths are relative to the current working directory.
|
|
94
|
+
A Git worktree's ``.git`` file also identifies its root.
|
|
95
|
+
* In a Git repository, rules are inherited from the nearest Git root down to
|
|
96
|
+
the upload directory.
|
|
97
|
+
Outside Git, rules start at the upload directory; unrelated parent
|
|
98
|
+
``.gitignore`` files are not used.
|
|
99
|
+
* Each traversed directory adds its own ``.gitignore`` rules.
|
|
100
|
+
Later matching rules override earlier ones, and deeper files override
|
|
101
|
+
ancestor rules.
|
|
102
|
+
Patterns are relative to their owning directory.
|
|
103
|
+
Directory-only rules and negations follow Gitignore semantics.
|
|
104
|
+
Excluded directories are pruned, so a child cannot re-include itself unless
|
|
105
|
+
its parent is re-included first.
|
|
106
|
+
This also applies to ancestors of an explicitly selected nested upload root.
|
|
107
|
+
* Ignore rules apply to tracked files too.
|
|
108
|
+
Global Git excludes and ``.git/info/exclude`` are not read.
|
|
109
|
+
Hidden files, including ``.gitignore``, are uploaded unless excluded.
|
|
110
|
+
* ``.git`` files and directories are always excluded at every depth.
|
|
111
|
+
Explicit source paths inside Git metadata are rejected, including an upload
|
|
112
|
+
rooted at a ``.git`` directory.
|
|
113
|
+
Repeatable ``--exclude PATTERN`` options form a final Gitignore rule layer,
|
|
114
|
+
relative to the upload root.
|
|
115
|
+
This layer can exclude files re-included by ``.gitignore``; its own later
|
|
116
|
+
negations can undo its earlier patterns.
|
|
117
|
+
It cannot re-include a file excluded by ``.gitignore`` or Git metadata.
|
|
118
|
+
* ZIP files are included by default.
|
|
119
|
+
Use ``--exclude '*.zip'`` to omit them.
|
|
120
|
+
Add other exclusions explicitly, such as ``--exclude node_modules/``.
|
|
121
|
+
* Selected symlinks (including directory, broken, and external links) and
|
|
122
|
+
symlinks in the source path are rejected rather than dereferenced.
|
|
123
|
+
Ignored links are skipped.
|
|
124
|
+
Symlinked ``.gitignore`` files are rejected when their rules would be read.
|
|
125
|
+
Special files are rejected.
|
|
126
|
+
Empty selections fail.
|
|
127
|
+
Selected files are copied to a temporary directory without dereferencing
|
|
128
|
+
links.
|
|
129
|
+
The CLI packages these prepared files with Python's standard ZIP library.
|
|
130
|
+
The released SDK handles multipart serialization, authentication, network
|
|
131
|
+
requests, response decoding, and retries.
|
|
132
|
+
|
|
133
|
+
Development and distribution
|
|
134
|
+
----------------------------
|
|
135
|
+
|
|
136
|
+
Development checks, release setup, and build instructions for Docker, Nix, and
|
|
137
|
+
standalone binaries are in ``docs/source/development.rst``.
|
|
138
|
+
The generated CLI reference is in ``docs/source/cli.rst``.
|
|
139
|
+
Prebuilt binaries are available from
|
|
140
|
+
`GitHub Releases <https://github.com/adamtheturtle/hackerrank-cli/releases>`_.
|
|
141
|
+
|
|
142
|
+
See ``docs/source/migration.rst`` for migrating an existing shell uploader and
|
|
143
|
+
keeping repository-specific snippet preparation outside this CLI.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
hackerrank_cli/__init__.py,sha256=-RqNWmLXJEibbEX794M_7Q-gzDRskf0kRZc1y9EQdTA,7293
|
|
2
|
+
hackerrank_cli/__main__.py,sha256=OCJH11M4QDshkTS5Rci0dUfLZr0YFnzK2aI9yrmWARM,94
|
|
3
|
+
hackerrank_cli/_sources.py,sha256=--ATApfmrcLjxNV4CEZjZrVWeolBm7MDoXjB-3-lJcw,6314
|
|
4
|
+
hackerrank_cli/_version.py,sha256=K6nZoD8HmqUbtHmEmxFXtR8SK0DcDTd6MDQMVfC4jZ8,533
|
|
5
|
+
hackerrank_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
hackerrank_cli-2026.10.1.1.dist-info/licenses/LICENSE,sha256=cnfgEzv8YMudbgPysNfgAdqeOo4zf0y2C3Ctw4RgEE4,1069
|
|
7
|
+
hackerrank_cli-2026.10.1.1.dist-info/METADATA,sha256=TZwDOMuKuOQqYiO1cWGwoRBtnbC4P8590rsxh--8sxM,6016
|
|
8
|
+
hackerrank_cli-2026.10.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
hackerrank_cli-2026.10.1.1.dist-info/entry_points.txt,sha256=3nj4emp_aSgrj_m-OUnLyAVsvW3_VPQpZ4gYe8_oMMg,51
|
|
10
|
+
hackerrank_cli-2026.10.1.1.dist-info/top_level.txt,sha256=SE7-lgO007qGO9eJpU0uWdu6Sy7k5kvojWMB1Im9lX0,15
|
|
11
|
+
hackerrank_cli-2026.10.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Adam Dangoor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hackerrank_cli
|