Initial commit
This commit is contained in:
commit
136d723510
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
# Python
|
||||
**/__pycache__/
|
||||
**/.mypy_cache/
|
||||
/.venv/
|
||||
|
||||
# IDEs/Editors
|
||||
/.vscode/
|
3
README.md
Normal file
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# CTF Challenge ToolKit
|
||||
|
||||
A CLI tool for automating challenge management and deployment.
|
12
cctk/__init__.py
Normal file
12
cctk/__init__.py
Normal file
|
@ -0,0 +1,12 @@
|
|||
# handle toml import here for other modules
|
||||
import sys
|
||||
if sys.version_info >= (3, 11):
|
||||
# Python >= 3.11 (stdlib module)
|
||||
import tomllib
|
||||
else:
|
||||
# Python <= 3.10 (third-party dependency)
|
||||
import tomli as tomllib
|
||||
|
||||
|
||||
# import CLI commands
|
||||
from . import commands
|
7
cctk/__main__.py
Normal file
7
cctk/__main__.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
from .commands import root as entrypoint
|
||||
|
||||
|
||||
# run the main Click entrypoint
|
||||
entrypoint()
|
52
cctk/commands/__init__.py
Normal file
52
cctk/commands/__init__.py
Normal file
|
@ -0,0 +1,52 @@
|
|||
"""Contains CLI commands exposed to the user."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from cctk.constants import TOOLKIT_DESCRIPTION
|
||||
from cctk.rt import CONSOLE
|
||||
from cctk.types import AppConfig
|
||||
|
||||
|
||||
# define the "root" command group which contains all commands and subgroups
|
||||
@click.group(
|
||||
# display application description
|
||||
help = TOOLKIT_DESCRIPTION,
|
||||
|
||||
# global context settings for Click
|
||||
context_settings = dict(
|
||||
# allow using -h in addition to --help
|
||||
help_option_names = ["-h", "--help"],
|
||||
),
|
||||
)
|
||||
@click.option("-R", "--repo", help="Specify the location of the challenge repository (instead of using the current working directory).")
|
||||
@click.option("-v", "--verbose", is_flag=True, help="Show more verbose information.")
|
||||
@click.pass_context
|
||||
def root(ctx: click.Context, repo: str | None, verbose: bool):
|
||||
# ensure ctx.obj exists
|
||||
ctx.ensure_object(AppConfig)
|
||||
assert isinstance(ctx.obj, AppConfig)
|
||||
|
||||
# store verbose setting
|
||||
ctx.obj.verbose = verbose
|
||||
|
||||
# determine repo path
|
||||
if repo is None:
|
||||
# default to cwd
|
||||
ctx.obj.repo_path = Path(".")
|
||||
else:
|
||||
ctx.obj.repo_path = Path(repo)
|
||||
|
||||
# log location of challenge repo
|
||||
CONSOLE.print(f"Using challenge repo at {str(ctx.obj.repo_path.resolve(strict=False))}")
|
||||
|
||||
|
||||
# define submodules
|
||||
__all__ = [
|
||||
"validate",
|
||||
"version",
|
||||
]
|
||||
|
||||
# import submodules
|
||||
from . import *
|
98
cctk/commands/shared.py
Normal file
98
cctk/commands/shared.py
Normal file
|
@ -0,0 +1,98 @@
|
|||
"""Shared code for parsing / interpreting / validating."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from rich.panel import Panel
|
||||
from rich.rule import Rule
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
import rich.box
|
||||
|
||||
from cctk.sources.challenge import Challenge
|
||||
from cctk.sources.repository import ChallengeRepo
|
||||
from cctk.rt import CONSOLE
|
||||
from cctk.validation import Severity, ValidationBook, ValidationError
|
||||
|
||||
|
||||
class DeploySource:
|
||||
"""Represents the local sources used for a deployment."""
|
||||
|
||||
def __init__(self, book: ValidationBook, repo_path: Path, challenge_ids: list[str] | None = None, verbose: bool = False):
|
||||
"""Initialize a new DeploySource instance.
|
||||
|
||||
Performs validation on loaded data.
|
||||
|
||||
Args:
|
||||
repo_path: Local path to the challenge repository.
|
||||
challenge_ids (optional): A tuple of challenge IDs to deploy. If None, all challenges found in the repository will be selected for deployment.
|
||||
"""
|
||||
|
||||
# initialize object attributes
|
||||
self._book = book
|
||||
self._repo_path = repo_path
|
||||
self._challenge_ids = challenge_ids
|
||||
# holds the IDs of challenges we failed to load
|
||||
self._failed_challenges = set()
|
||||
# record whether we were given a list of challenges or not
|
||||
self._challenges_specified = challenge_ids is not None
|
||||
|
||||
|
||||
# load + validate challenge repo
|
||||
with CONSOLE.status("Validating challenge repository"):
|
||||
self.repo = ChallengeRepo(book, repo_path)
|
||||
if verbose:
|
||||
CONSOLE.print("Done validating challenge repository", style="dim")
|
||||
|
||||
# find challenges if required
|
||||
if not self._challenges_specified:
|
||||
challenge_ids = list(self.repo.find_challenges())
|
||||
CONSOLE.print(f"Found {len(challenge_ids)} challenges in the repository")
|
||||
else:
|
||||
assert challenge_ids is not None
|
||||
CONSOLE.print(f"Validating {len(challenge_ids)} challenge{'s' if len(challenge_ids) > 1 else ''}")
|
||||
if verbose:
|
||||
CONSOLE.print(f"Challenge IDs: {challenge_ids}", style="dim")
|
||||
|
||||
with CONSOLE.status("Validating challenges") as status:
|
||||
# create map to store challenges
|
||||
self.challenges: dict[str, Challenge] = dict()
|
||||
|
||||
for challenge_id in challenge_ids:
|
||||
try:
|
||||
self.challenges[challenge_id] = Challenge(self.repo, book, repo_path / challenge_id, challenge_id)
|
||||
except ValidationError:
|
||||
# we handle these validation errors later, when deployment checks whether all challenges loaded successfully
|
||||
self._failed_challenges.add(challenge_id)
|
||||
|
||||
|
||||
def rich_repo_summary(self) -> Table:
|
||||
"""Build and return a rich-text summary of the loaded challenge repository."""
|
||||
raise NotImplementedError
|
||||
|
||||
def rich_challenge_summary(self) -> Table:
|
||||
"""Build and return a rich-text summary of all loaded challenges."""
|
||||
|
||||
loaded_challenges = Table(*["ID", "Difficulty", "Category", "Name", "Tags"], title="Loaded Challenges", box=rich.box.MINIMAL)
|
||||
for challenge in self.challenges.values():
|
||||
difficulty = challenge.config.difficulty
|
||||
|
||||
highest_issue_severity = Severity.NOTICE
|
||||
for issue in self._book.get_issues(challenge.challenge_id):
|
||||
if highest_issue_severity < issue.severity:
|
||||
highest_issue_severity = issue.severity
|
||||
|
||||
if highest_issue_severity > Severity.NOTICE:
|
||||
severity_style = f"validation.issue.{highest_issue_severity.as_string()}"
|
||||
else:
|
||||
severity_style = "green3"
|
||||
|
||||
loaded_challenges.add_row(
|
||||
Text(challenge.challenge_id, style=severity_style),
|
||||
Text(difficulty, style=f"challenge.difficulty.{difficulty}"),
|
||||
challenge.config.category,
|
||||
challenge.config.name,
|
||||
str(challenge.config.tags),
|
||||
#style = "green3",
|
||||
)
|
||||
|
||||
return loaded_challenges
|
51
cctk/commands/validate.py
Normal file
51
cctk/commands/validate.py
Normal file
|
@ -0,0 +1,51 @@
|
|||
import enum
|
||||
|
||||
import click
|
||||
from rich.console import Group
|
||||
from rich.panel import Panel
|
||||
|
||||
from cctk.commands import root
|
||||
from cctk.commands.shared import DeploySource
|
||||
from cctk.rt import CONSOLE
|
||||
from cctk.types import AppConfig
|
||||
from cctk.validation import (FatalValidationError, ValidationBook,
|
||||
ValidationError)
|
||||
|
||||
|
||||
class ValidationMode(enum.Enum):
|
||||
REPOSITORY = "repo"
|
||||
CHALLENGES = "challenges"
|
||||
|
||||
|
||||
@root.command()
|
||||
@click.argument("challenges", nargs=-1)
|
||||
@click.pass_context
|
||||
def validate(ctx: click.Context, challenges: tuple[str]):
|
||||
"""Validate challenge definitions.
|
||||
|
||||
Can validate either specific challenges, or the entire repository.
|
||||
|
||||
CHALLENGES are the IDs of the challenges to validate.
|
||||
If none are specified, validation is performed on all challenges in the repository.
|
||||
"""
|
||||
|
||||
app_ctx: AppConfig = ctx.obj
|
||||
assert app_ctx.repo_path is not None
|
||||
|
||||
# initialize validation book
|
||||
validation_book = ValidationBook()
|
||||
|
||||
# load repo and challenge data
|
||||
try:
|
||||
deploy_source = DeploySource(validation_book, app_ctx.repo_path, list(challenges) if len(challenges) > 0 else None, ctx.obj.verbose)
|
||||
except (FatalValidationError, ValidationError):
|
||||
raise SystemExit(1)
|
||||
|
||||
# print summary
|
||||
CONSOLE.print(
|
||||
"",
|
||||
Panel(Group(
|
||||
deploy_source.rich_challenge_summary(),
|
||||
validation_book.rich_issue_summary(),
|
||||
), title="Summary", expand=False, border_style="cyan"),
|
||||
)
|
10
cctk/commands/version.py
Normal file
10
cctk/commands/version.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
import click
|
||||
|
||||
from cctk.constants import TOOLKIT_VERSION
|
||||
from cctk.commands import root
|
||||
|
||||
|
||||
@root.command()
|
||||
def version():
|
||||
"""Display the installed toolkit version."""
|
||||
click.echo(f"CTF Challenge ToolKit {TOOLKIT_VERSION}")
|
7
cctk/constants.py
Normal file
7
cctk/constants.py
Normal file
|
@ -0,0 +1,7 @@
|
|||
# define global metadata
|
||||
TOOLKIT_DESCRIPTION = "CTF Challenge ToolKit - A tool to automate CTF challenge management."
|
||||
TOOLKIT_VERSION = "0.2.0"
|
||||
|
||||
# define constant config file names
|
||||
REPO_CONFIG_FILENAME = "challenge-repository.toml"
|
||||
CHALLENGE_CONFIG_FILENAME = "challenge.toml"
|
36
cctk/rt.py
Normal file
36
cctk/rt.py
Normal file
|
@ -0,0 +1,36 @@
|
|||
"""Rich text configuration."""
|
||||
|
||||
import click
|
||||
import rich.console
|
||||
import rich.theme
|
||||
import rich.traceback
|
||||
|
||||
|
||||
# theme with custom styles
|
||||
THEME = rich.theme.Theme({
|
||||
"validation.issue.fatal": "bold red1 on bright_white",
|
||||
#"validation.issue.fatal": "bold red1",
|
||||
"validation.issue.error": "bold red1",
|
||||
"validation.issue.warning": "dark_orange",
|
||||
"validation.issue.notice": "cyan",
|
||||
|
||||
"validation.target": "spring_green3",
|
||||
"validation.location": "deep_pink3 underline",
|
||||
|
||||
"challenge.difficulty.undefined": "grey50",
|
||||
"challenge.difficulty.easy": "spring_green3",
|
||||
"challenge.difficulty.medium": "yellow3",
|
||||
"challenge.difficulty.hard": "red1",
|
||||
}, inherit=True)
|
||||
|
||||
|
||||
# shared global console
|
||||
CONSOLE = rich.console.Console(
|
||||
theme = THEME,
|
||||
# for exporting console output
|
||||
record = True,
|
||||
)
|
||||
|
||||
|
||||
# use rich for pretty exceptions
|
||||
rich.traceback.install(suppress=[click])
|
17
cctk/schemas/__init__.py
Normal file
17
cctk/schemas/__init__.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
"""Contains Marshmallow schemas and supporting code."""
|
||||
|
||||
from marshmallow import validate, ValidationError
|
||||
|
||||
|
||||
# common validators for string IDs (lowercase alphanumeric with hyphens and underscores)
|
||||
VALIDATORS_STRING_ID = [
|
||||
# IDs should be at least 2 characters long (assuming a single character cannot convey enough information)
|
||||
validate.Length(min=2),
|
||||
validate.Regexp(r"^[a-z0-9\-\_]*$", error="Must contain only lowercase alphanumeric characters, hyphens, and underscores."),
|
||||
]
|
||||
|
||||
|
||||
def validate_unique(data: list):
|
||||
"""Marshmallow validator to ensure that a list does not contain duplicate items."""
|
||||
if len(set(data)) != len(data):
|
||||
raise ValidationError("Items must be unique.")
|
30
cctk/schemas/challenge.py
Normal file
30
cctk/schemas/challenge.py
Normal file
|
@ -0,0 +1,30 @@
|
|||
from marshmallow import fields, Schema, validate
|
||||
|
||||
from . import VALIDATORS_STRING_ID
|
||||
|
||||
|
||||
class ChallengeConfigMeta(Schema):
|
||||
id = fields.String(required=True, validate=VALIDATORS_STRING_ID)
|
||||
name = fields.String(required=False) # not required for development
|
||||
category = fields.String(required=True, validate=VALIDATORS_STRING_ID)
|
||||
difficulty = fields.String(required=True, validate=validate.OneOf(["undefined", "easy", "medium", "hard"])) # 'undefined' is only for development
|
||||
description = fields.String(required=False) # not required for development
|
||||
tags = fields.List(fields.String(), required=False, load_default=[])
|
||||
|
||||
|
||||
class ChallengeConfigHint(Schema):
|
||||
content = fields.String(required=True)
|
||||
|
||||
|
||||
class ChallengeConfigScoring(Schema):
|
||||
flag = fields.String(required=True)
|
||||
# NOTE: subject to change (scoring might be determined by difficulty)
|
||||
points = fields.Integer(strict=True)
|
||||
|
||||
|
||||
class ChallengeConfigSchema(Schema):
|
||||
"""Marshmallow schema for the challenge config file."""
|
||||
|
||||
meta = fields.Nested(ChallengeConfigMeta, required=True)
|
||||
scoring = fields.Nested(ChallengeConfigScoring, required=True)
|
||||
hints = fields.List(fields.Nested(ChallengeConfigHint), required=False)
|
66
cctk/schemas/formatting.py
Normal file
66
cctk/schemas/formatting.py
Normal file
|
@ -0,0 +1,66 @@
|
|||
"""Utilities for formatting Marshmallow validation exceptions."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from io import StringIO
|
||||
|
||||
from rich.console import Group, RenderableType
|
||||
from rich.padding import Padding
|
||||
from rich.pretty import Pretty
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
def collapse_to_dotted(messages: dict[str, Any]) -> dict[str, str | list[str]]:
|
||||
"""Recursively collapses a nested dictionary into a single dictionary of key-value pairs (adding dots as separators)."""
|
||||
|
||||
def _collapse_layer(dict_in: dict[str, Any], dict_out: dict[str, Any], prefix: str):
|
||||
for key, val in dict_in.items():
|
||||
if isinstance(val, dict):
|
||||
_collapse_layer(val, dict_out, f"{prefix}{key}.")
|
||||
else:
|
||||
dict_out[prefix + str(key)] = val
|
||||
|
||||
out: dict[str, Any] = dict()
|
||||
_collapse_layer(messages, out, "")
|
||||
return out
|
||||
|
||||
|
||||
def format_validation_exception_old(messages: list[str] | dict[str, str | list[str]]) -> RenderableType:
|
||||
"""Format Marshmallow validation error messages into a rich console renderable."""
|
||||
sio = StringIO()
|
||||
if isinstance(messages, dict):
|
||||
for err_key, err_msg in messages.items():
|
||||
if isinstance(err_msg, list):
|
||||
sio.write(f"{err_key}: {err_msg}\n")
|
||||
else:
|
||||
sio.write(f"{err_key}: {err_msg}\n")
|
||||
else:
|
||||
for err_msg in messages:
|
||||
sio.write(f"{err_msg}\n")
|
||||
|
||||
# return built string without the trailing newline
|
||||
return sio.getvalue()[:-1]
|
||||
|
||||
|
||||
def format_validation_exception(messages: list[str] | dict[str, str | list[str]], title: str) -> RenderableType:
|
||||
"""Format Marshmallow validation error messages into a rich console renderable."""
|
||||
|
||||
STYLE_ERR_KEY = "cyan3"
|
||||
STYLE_ERR_MSG = "yellow3"
|
||||
|
||||
items: list[RenderableType] = list()
|
||||
|
||||
if isinstance(messages, dict):
|
||||
for err_key, err_msg in sorted(collapse_to_dotted(messages).items()):
|
||||
t = Text.assemble(
|
||||
(err_key, STYLE_ERR_KEY),
|
||||
": ",
|
||||
Text(str(err_msg)),
|
||||
)
|
||||
t.highlight_regex("'.+?'", STYLE_ERR_MSG)
|
||||
items.append(t)
|
||||
else:
|
||||
for err_msg in messages:
|
||||
items.append(Pretty(err_msg))
|
||||
|
||||
return Group(title, Padding(Group(*items), (0,0,0,2)))
|
13
cctk/schemas/repository.py
Normal file
13
cctk/schemas/repository.py
Normal file
|
@ -0,0 +1,13 @@
|
|||
from marshmallow import fields, Schema
|
||||
|
||||
from . import validate_unique, VALIDATORS_STRING_ID
|
||||
|
||||
|
||||
class ChallengeRepoConfigSchema(Schema):
|
||||
"""Marshmallow schema for the challenge repo config file."""
|
||||
|
||||
categories = fields.List(
|
||||
fields.String(validate=VALIDATORS_STRING_ID),
|
||||
required = True,
|
||||
validate = validate_unique,
|
||||
)
|
164
cctk/sources/challenge.py
Normal file
164
cctk/sources/challenge.py
Normal file
|
@ -0,0 +1,164 @@
|
|||
from __future__ import annotations
|
||||
from typing import Literal
|
||||
|
||||
import decimal
|
||||
import enum
|
||||
from pathlib import Path
|
||||
|
||||
import attrs
|
||||
import marshmallow
|
||||
|
||||
from cctk import tomllib
|
||||
from cctk.constants import CHALLENGE_CONFIG_FILENAME
|
||||
from cctk.sources.repository import ChallengeRepo
|
||||
from cctk.schemas.challenge import ChallengeConfigSchema
|
||||
from cctk.schemas.formatting import format_validation_exception
|
||||
from cctk.validation import (Severity, Source, ValidationBook,
|
||||
ValidationError)
|
||||
|
||||
|
||||
class ChallengeDifficulty(enum.Enum):
|
||||
UNDEFINED = "undefined"
|
||||
EASY = "easy"
|
||||
MEDIUM = "medium"
|
||||
HARD = "hard"
|
||||
|
||||
ChallengeDifficultyStr = Literal["undefined", "easy", "medium", "hard"]
|
||||
|
||||
|
||||
@attrs.define(frozen=True)
|
||||
class ChallengeConfig:
|
||||
id: str
|
||||
name: str
|
||||
category: str
|
||||
difficulty: ChallengeDifficultyStr
|
||||
description: str | None
|
||||
tags: list[str]
|
||||
|
||||
flag: str
|
||||
points: int
|
||||
|
||||
hints: list[str]
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, repo: ChallengeRepo, book: ValidationBook, path: Path, challenge_id: str) -> ChallengeConfig:
|
||||
pen = book.bind(challenge_id, Source(path))
|
||||
|
||||
validation_error = False
|
||||
|
||||
# attempt to load the challenge config file
|
||||
try:
|
||||
with path.open(mode="rb") as fp:
|
||||
raw_data = tomllib.load(fp, parse_float = decimal.Decimal)
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
pen.issue(Severity.ERROR, "challenge-config-invalid-toml", f"Failed to parse {CHALLENGE_CONFIG_FILENAME}: {exc}")
|
||||
raise ValidationError
|
||||
|
||||
# validate with Marshmallow schema
|
||||
try:
|
||||
cleaned_data = ChallengeConfigSchema().load(raw_data)
|
||||
except marshmallow.ValidationError as exc:
|
||||
pen.issue(Severity.ERROR, "challenge-config-schema-failure",
|
||||
format_validation_exception(exc.messages, "Challenge config file failed schema validation"))
|
||||
raise ValidationError
|
||||
|
||||
# partially destructure
|
||||
clean_meta = cleaned_data["meta"]
|
||||
clean_scoring = cleaned_data["scoring"]
|
||||
clean_hints = cleaned_data.get("hints", [])
|
||||
del cleaned_data
|
||||
|
||||
# rebuild dictionary to match constructor parameters
|
||||
final_data = {
|
||||
"id": clean_meta["id"],
|
||||
"category": clean_meta["category"],
|
||||
"difficulty": clean_meta["difficulty"],
|
||||
"tags": clean_meta["tags"],
|
||||
|
||||
"flag": clean_scoring["flag"],
|
||||
}
|
||||
|
||||
if "name" in clean_meta:
|
||||
final_data["name"] = clean_meta["name"]
|
||||
else:
|
||||
pen.warn("challenge-config-missing-name", "Challenge config does not specify a name; using challenge ID")
|
||||
final_data["name"] = clean_meta["id"]
|
||||
|
||||
if "description" in clean_meta:
|
||||
final_data["description"] = clean_meta["description"]
|
||||
else:
|
||||
pen.warn("challenge-config-missing-description", "Challenge config does not specify a description")
|
||||
final_data["description"] = None
|
||||
|
||||
if "points" in clean_scoring:
|
||||
final_data["points"] = clean_scoring["points"]
|
||||
else:
|
||||
pen.warn("challenge-config-missing-points", "Challenge config does not specify a point value; defaulting to 0")
|
||||
final_data["points"] = 0
|
||||
|
||||
# collapse list of hint structures
|
||||
final_data["hints"] = [hint["content"] for hint in clean_hints]
|
||||
|
||||
|
||||
# error on challenge ID mismatch
|
||||
if final_data["id"] != challenge_id:
|
||||
pen.issue(Severity.ERROR, "challenge-config-id-mismatch", f"Challenge config ID does not match directory name ({final_data['id']!r} != {challenge_id!r})")
|
||||
validation_error = True
|
||||
|
||||
# error on undefined category
|
||||
if final_data["category"] not in repo.categories:
|
||||
pen.issue(Severity.ERROR, "challenge-", f"Challenge category is invalid ({final_data['category']!r} must match one of the repository-defined categories)")
|
||||
validation_error = True
|
||||
|
||||
# warn on undefined difficulty
|
||||
if final_data["difficulty"] == "undefined":
|
||||
pen.warn("challenge-config-difficulty-undefined", "Challenge difficulty is set to 'undefined'")
|
||||
|
||||
# TODO: (more) custom validation steps (for warnings and non-strict-schema issues)
|
||||
|
||||
if validation_error:
|
||||
raise ValidationError
|
||||
|
||||
return cls(**final_data)
|
||||
|
||||
|
||||
class Challenge:
|
||||
"""Interface to a challenge directory."""
|
||||
|
||||
def __init__(self, repo: ChallengeRepo, book: ValidationBook, path: Path, challenge_id: str):
|
||||
"""Initialize a new Challenge interface.
|
||||
|
||||
Validates the challenge directories and configuration files.
|
||||
|
||||
Args:
|
||||
book: The ValidationBook to report validation issues to.
|
||||
path: The path to the challenge directory.
|
||||
challenge_id: The ID of this challenge.
|
||||
"""
|
||||
|
||||
# store object attributes
|
||||
self.path = path
|
||||
self.config_path = path / CHALLENGE_CONFIG_FILENAME
|
||||
self.challenge_id = challenge_id
|
||||
|
||||
|
||||
# sanity-check challenge directory
|
||||
if not self.path.exists():
|
||||
book.issue(Severity.FATAL, challenge_id, Source(self.path), "challenge-not-found", "Challenge directory does not exist")
|
||||
elif not self.path.is_dir():
|
||||
book.issue(Severity.FATAL, challenge_id, Source(self.path), "challenge-not-directory", "Challenge directory exists but is not a directory")
|
||||
|
||||
# sanity-check challenge config file
|
||||
if not self.config_path.exists():
|
||||
book.issue(Severity.FATAL, challenge_id, Source(self.config_path), "challenge-config-not-found", f"Challenge directory does not contain a {CHALLENGE_CONFIG_FILENAME} file")
|
||||
elif not self.config_path.is_file():
|
||||
book.issue(Severity.FATAL, challenge_id, Source(self.config_path), "challenge-config-not-file", "Challenge config exists but is not a file")
|
||||
|
||||
|
||||
# load the config file (with validation)
|
||||
self.config = ChallengeConfig.from_file(repo, book, self.config_path, challenge_id)
|
||||
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Challenge id={self.challenge_id!r} config={self.config!r}>"
|
101
cctk/sources/repository.py
Normal file
101
cctk/sources/repository.py
Normal file
|
@ -0,0 +1,101 @@
|
|||
"""Interfaces for interacting with challenge repositories."""
|
||||
|
||||
from __future__ import annotations
|
||||
from typing import Iterator
|
||||
|
||||
import decimal
|
||||
from pathlib import Path
|
||||
|
||||
import attrs
|
||||
import marshmallow
|
||||
|
||||
from cctk import tomllib
|
||||
from cctk.constants import CHALLENGE_CONFIG_FILENAME, REPO_CONFIG_FILENAME
|
||||
from cctk.schemas.formatting import format_validation_exception
|
||||
from cctk.schemas.repository import ChallengeRepoConfigSchema
|
||||
from cctk.validation import (Severity, Source, ValidationBook,
|
||||
ValidationError)
|
||||
|
||||
|
||||
@attrs.define(frozen=True)
|
||||
class ChallengeRepoConfig:
|
||||
categories: list[str]
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, book: ValidationBook, path: Path) -> ChallengeRepoConfig:
|
||||
pen = book.bind(None, Source(path))
|
||||
|
||||
# attempt to load the repo config file
|
||||
try:
|
||||
with path.open(mode="rb") as fp:
|
||||
raw_data = tomllib.load(fp, parse_float = decimal.Decimal)
|
||||
except tomllib.TOMLDecodeError as exc:
|
||||
pen.issue(Severity.ERROR, "repo-config-invalid-toml", f"Failed to parse {REPO_CONFIG_FILENAME}: {exc}")
|
||||
raise ValidationError
|
||||
|
||||
# validate with Marshmallow schema
|
||||
try:
|
||||
cleaned_data = ChallengeRepoConfigSchema().load(raw_data)
|
||||
except marshmallow.ValidationError as exc:
|
||||
pen.issue(Severity.ERROR, "repository-config-schema-failure",
|
||||
format_validation_exception(exc.messages, "Repository config file failed schema validation"))
|
||||
raise ValidationError
|
||||
|
||||
# TODO: custom validation steps (for warnings and non-strict-schema issues)
|
||||
|
||||
return cls(**cleaned_data)
|
||||
|
||||
|
||||
class ChallengeRepo:
|
||||
"""Interface to a challenge repository."""
|
||||
|
||||
def __init__(self, book: ValidationBook, path: Path) -> None:
|
||||
"""Initialize a new ChallengeRepo interface.
|
||||
|
||||
Validates the challenge repository and its configuration.
|
||||
|
||||
Args:
|
||||
book: The ValidationBook to report validation issues to.
|
||||
path: The path to the challenge repository.
|
||||
"""
|
||||
|
||||
# store object attributes
|
||||
self.path = path
|
||||
self.config_path = path / REPO_CONFIG_FILENAME
|
||||
|
||||
|
||||
# NOTE: technically some of these issues don't have to be fatal, but it's not worth the special-casing to handle
|
||||
|
||||
# sanity-check repo directory
|
||||
if not self.path.exists():
|
||||
book.issue(Severity.FATAL, None, Source(self.path), "repo-not-found", "Challenge repository directory does not exist")
|
||||
elif not self.path.is_dir():
|
||||
book.issue(Severity.FATAL, None, Source(self.path), "repo-not-directory", "Challenge repository directory exists but is not a directory")
|
||||
|
||||
# sanity-check repo config file
|
||||
if not self.config_path.exists():
|
||||
book.issue(Severity.FATAL, None, Source(self.config_path), "repo-config-not-found", f"Challenge repository does not contain a {REPO_CONFIG_FILENAME} file")
|
||||
elif not self.config_path.is_file():
|
||||
book.issue(Severity.FATAL, None, Source(self.config_path), "repo-config-not-file", "Challenge repository config exists but is not a file")
|
||||
|
||||
|
||||
# load the repo config file (with validation)
|
||||
self.config = ChallengeRepoConfig.from_file(book, self.config_path)
|
||||
|
||||
|
||||
@property
|
||||
def categories(self) -> list[str]:
|
||||
"""Return a list of category IDs, as defined in the repo config file."""
|
||||
return self.config.categories
|
||||
|
||||
def find_challenges(self) -> Iterator[str]:
|
||||
"""Collect and return a list of all challenges within this repository."""
|
||||
|
||||
# iterate over all items within the directory
|
||||
for subpath in self.path.iterdir():
|
||||
# filter out non-directories
|
||||
if subpath.is_dir():
|
||||
# ignore directories without a challenge config
|
||||
if (subpath / CHALLENGE_CONFIG_FILENAME).exists():
|
||||
yield subpath.name
|
16
cctk/types.py
Normal file
16
cctk/types.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
"""Data-centered types."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import attrs
|
||||
|
||||
|
||||
@attrs.define
|
||||
class AppConfig:
|
||||
"""Holds application configuration (as set from Click)."""
|
||||
|
||||
# Whether to output verbose messages
|
||||
verbose: bool = False
|
||||
|
||||
# Path to the challenge repository to use.
|
||||
repo_path: Path | None = None
|
34
cctk/util.py
Normal file
34
cctk/util.py
Normal file
|
@ -0,0 +1,34 @@
|
|||
"""Utility functions."""
|
||||
|
||||
|
||||
def _fnv_1a_64(text: bytes) -> int:
|
||||
"""Computes the 64-bit FNV-1a hash of an input string.
|
||||
|
||||
See http://www.isthe.com/chongo/tech/comp/fnv/#FNV-1a
|
||||
"""
|
||||
|
||||
h = 14695981039346656037
|
||||
|
||||
for byte in text:
|
||||
h ^= byte
|
||||
h *= 1099511628211
|
||||
|
||||
return h
|
||||
|
||||
def challenge_id_hash(challenge_id: str) -> int:
|
||||
"""Implements a variation of FNV-1a to derive a 28-bit integer hash from a string ID.
|
||||
|
||||
Hash values will be positive integers in the range `[2^28, 2*2^28)`
|
||||
|
||||
28-bits was chosen to easily fit within the unsigned portion of a signed 32-bit integer,
|
||||
without sacrificing collision probability. Given 500 challenges, the chance of a collision is below 1/2000.
|
||||
"""
|
||||
|
||||
# calculate 64-bit FNV-1a hash
|
||||
fnv64hash = _fnv_1a_64(challenge_id.encode())
|
||||
|
||||
# fold into 28-bit hash (http://www.isthe.com/chongo/tech/comp/fnv/#xor-fold)
|
||||
hash28 = ((fnv64hash >> 28) ^ fnv64hash) & (2**28 - 1)
|
||||
|
||||
# add 2^28 for a uniform base-10 length
|
||||
return hash28 + 2**28
|
208
cctk/validation.py
Normal file
208
cctk/validation.py
Normal file
|
@ -0,0 +1,208 @@
|
|||
"""Validation of local sources"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from pathlib import Path
|
||||
|
||||
import attrs
|
||||
from rich.columns import Columns
|
||||
from rich.console import RenderableType
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
import rich.box
|
||||
|
||||
from cctk.rt import CONSOLE
|
||||
|
||||
|
||||
class ValidationError(Exception):
|
||||
"""Exception raised when a validation error occurs (should be caught by validation caller)."""
|
||||
|
||||
class FatalValidationError(ValidationError):
|
||||
"""Exception raised when a fatal validation error occurs."""
|
||||
|
||||
class Severity(enum.IntEnum):
|
||||
"""Represents the severity of a validation issue."""
|
||||
FATAL = 50
|
||||
ERROR = 40
|
||||
WARNING = 30
|
||||
NOTICE = 25
|
||||
|
||||
def as_string(self) -> str:
|
||||
return self.name.lower()
|
||||
|
||||
@attrs.define
|
||||
class Source:
|
||||
"""Metadata regarding the source of an error."""
|
||||
path: Path
|
||||
line: int | None = None
|
||||
col: int | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
# TODO: rich formatting? (unless line/col is never used)
|
||||
if self.line is None:
|
||||
return str(self.path)
|
||||
elif self.col is None:
|
||||
return f"{str(self.path)}:{self.line}"
|
||||
else:
|
||||
return f"{str(self.path)}:{self.line}:{self.col}"
|
||||
|
||||
@attrs.define(frozen=True)
|
||||
class Issue:
|
||||
severity: Severity
|
||||
challenge_id: str | None
|
||||
source: Source
|
||||
code: str
|
||||
message: RenderableType
|
||||
|
||||
|
||||
class ValidationBook:
|
||||
"""Handles recording and reporting of validation issues."""
|
||||
|
||||
TEXT_ISSUE_PREFIXES = {
|
||||
Severity.FATAL: Text("Fatal validation error", "validation.issue.fatal"),
|
||||
Severity.ERROR: Text("Validation error", "validation.issue.error"),
|
||||
Severity.WARNING: Text("Validation warning", "validation.issue.warning"),
|
||||
Severity.NOTICE: Text("Validation notice", "validation.issue.notice"),
|
||||
}
|
||||
TEXT_TARGET_REPO = Text("challenge repository", "validation.target")
|
||||
|
||||
|
||||
def __init__(self):
|
||||
self._issues: dict[str | None, list[Issue]] = dict()
|
||||
|
||||
def _record_issue(self, issue: Issue):
|
||||
try:
|
||||
self._issues[issue.challenge_id].append(issue)
|
||||
except KeyError:
|
||||
self._issues[issue.challenge_id] = [issue]
|
||||
|
||||
def _display_issue(self, issue: Issue):
|
||||
"""Display the validation issue to the user."""
|
||||
|
||||
issue_prefix = self.TEXT_ISSUE_PREFIXES[issue.severity]
|
||||
|
||||
if issue.challenge_id is None:
|
||||
validation_target = self.TEXT_TARGET_REPO
|
||||
else:
|
||||
validation_target = Text.assemble("challenge ", (f"{issue.challenge_id}", "validation.target"))
|
||||
|
||||
issue_location = Text.assemble("[location: ", Text(f"{issue.source}", "validation.location"), "]")
|
||||
issue_location.stylize("dim")
|
||||
|
||||
CONSOLE.print(
|
||||
# display issue header
|
||||
Text.assemble(issue_prefix, " from ", validation_target, " ", issue_location),
|
||||
# display specific message (indented with "arrow")
|
||||
Columns([" └─ ", issue.message], padding=0),
|
||||
)
|
||||
|
||||
def bind(self, challenge_id: str | None, source: Source) -> ValidationBoundPen:
|
||||
return ValidationBoundPen(self, challenge_id, source)
|
||||
|
||||
def issue(self, severity: Severity, challenge_id: str | None, source: Source, code: str, message: RenderableType):
|
||||
"""Raise a validation issue.
|
||||
|
||||
Args:
|
||||
severity: The severity of the validation issue.
|
||||
challenge_id: The challenge ID of the challenge (or None if this issue applies to the challenge repo itself).
|
||||
location: A string detailing the source of the issue.
|
||||
code: A short kebab-case ID for this specific validation issue.
|
||||
message: A human-friendly description of the validation issue.
|
||||
"""
|
||||
|
||||
issue = Issue(severity, challenge_id, source, code, message)
|
||||
|
||||
# record the issue
|
||||
self._record_issue(issue)
|
||||
|
||||
# display to user
|
||||
self._display_issue(issue)
|
||||
|
||||
# raise exception on fatal issues
|
||||
if severity == Severity.FATAL:
|
||||
raise FatalValidationError
|
||||
|
||||
def get_issues(self, challenge_id: str | None) -> list[Issue]:
|
||||
return list(self._issues.get(challenge_id, []))
|
||||
|
||||
def rich_issue_summary(self) -> Table | Text:
|
||||
"""Generate a rich text summary of all recorded issues (or an "all clear" if no issues were found)."""
|
||||
|
||||
if len(self._issues) == 0:
|
||||
return Text("No validation issues have occurred.", style="green3")
|
||||
|
||||
ISSUE_COUNT_SEPARATOR = Text(", ")
|
||||
|
||||
# sort challenges according to highest severity issues
|
||||
challenge_issues: dict[Severity, dict[str, list[Issue]]] = { s : dict() for s in Severity }
|
||||
for challenge_id, issues in self._issues.items():
|
||||
if challenge_id is None:
|
||||
continue
|
||||
|
||||
max_severity = Severity.NOTICE
|
||||
for issue in issues:
|
||||
if issue.severity > max_severity:
|
||||
max_severity = issue.severity
|
||||
|
||||
challenge_issues[max_severity][challenge_id] = issues
|
||||
|
||||
#table = Table(*["Challenge ID", "Issues"], title="Validation Issues", title_style="italic orange3")
|
||||
table = Table(title="Validation Issues", title_style="italic orange3", box=rich.box.MINIMAL)
|
||||
table.add_column("Challenge ID", ratio=2)
|
||||
table.add_column("Issues", ratio=5)
|
||||
|
||||
for max_severity, severity_challenges in sorted(challenge_issues.items(), reverse=True):
|
||||
for challenge_id, issues in sorted(severity_challenges.items()):
|
||||
issue_count_strings = []
|
||||
for severity in Severity:
|
||||
issue_count = len([issue for issue in issues if issue.severity == severity])
|
||||
if issue_count > 0:
|
||||
issue_count_strings.append(Text(
|
||||
f"{issue_count} {severity.name.lower()}",
|
||||
style=f"validation.issue.{severity.name.lower()}",
|
||||
))
|
||||
issue_count_strings.append(ISSUE_COUNT_SEPARATOR)
|
||||
|
||||
table.add_row(
|
||||
Text(challenge_id, style=f"validation.issue.{max_severity.name.lower()}"),
|
||||
Text.assemble(
|
||||
# total issue count
|
||||
Text(f"{len(issues)} issue{'s' if len(issues) > 1 else ''}"),
|
||||
" (",
|
||||
# issue severity breakdown
|
||||
*issue_count_strings[:-1],
|
||||
")",
|
||||
),
|
||||
)
|
||||
|
||||
return table
|
||||
|
||||
class ValidationBoundPen:
|
||||
"""Like a bound logger, but for validation issues."""
|
||||
|
||||
def __init__(self, book: ValidationBook, challenge_id: str | None, source: Source):
|
||||
self._book = book
|
||||
self.challenge_id = challenge_id
|
||||
self.source = source
|
||||
|
||||
def issue(self, severity: Severity, code: str, message: RenderableType):
|
||||
"""Raise a validation issue with a bound context.
|
||||
|
||||
Args:
|
||||
severity: The severity of the validation issue.
|
||||
code: A short kebab-case ID for this specific validation issue.
|
||||
message: A human-friendly description of the validation issue.
|
||||
"""
|
||||
|
||||
return self._book.issue(severity, self.challenge_id, self.source, code, message)
|
||||
|
||||
def warn(self, code: str, message: RenderableType):
|
||||
"""Raise a validation warning with a bound context.
|
||||
|
||||
Args:
|
||||
code: A short kebab-case ID for this specific validation issue.
|
||||
message: A human-friendly description of the validation issue.
|
||||
"""
|
||||
|
||||
return self._book.issue(Severity.WARNING, self.challenge_id, self.source, code, message)
|
289
poetry.lock
generated
Normal file
289
poetry.lock
generated
Normal file
|
@ -0,0 +1,289 @@
|
|||
[[package]]
|
||||
name = "attrs"
|
||||
version = "22.1.0"
|
||||
description = "Classes Without Boilerplate"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.5"
|
||||
|
||||
[package.extras]
|
||||
dev = ["cloudpickle", "coverage[toml] (>=5.0.2)", "furo", "hypothesis", "mypy (>=0.900,!=0.940)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "sphinx", "sphinx-notfound-page", "zope.interface"]
|
||||
docs = ["furo", "sphinx", "sphinx-notfound-page", "zope.interface"]
|
||||
tests = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "zope.interface"]
|
||||
tests_no_zope = ["cloudpickle", "coverage[toml] (>=5.0.2)", "hypothesis", "mypy (>=0.900,!=0.940)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins"]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.1.3"
|
||||
description = "Composable command line interface toolkit"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.5"
|
||||
description = "Cross-platform colored terminal text."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
|
||||
|
||||
[[package]]
|
||||
name = "commonmark"
|
||||
version = "0.9.1"
|
||||
description = "Python parser for the CommonMark Markdown spec"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[package.extras]
|
||||
test = ["flake8 (==3.7.8)", "hypothesis (==3.55.3)"]
|
||||
|
||||
[[package]]
|
||||
name = "isort"
|
||||
version = "5.10.1"
|
||||
description = "A Python utility / library to sort Python imports."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6.1,<4.0"
|
||||
|
||||
[package.extras]
|
||||
colors = ["colorama (>=0.4.3,<0.5.0)"]
|
||||
pipfile_deprecated_finder = ["pipreqs", "requirementslib"]
|
||||
plugins = ["setuptools"]
|
||||
requirements_deprecated_finder = ["pip-api", "pipreqs"]
|
||||
|
||||
[[package]]
|
||||
name = "marshmallow"
|
||||
version = "3.17.1"
|
||||
description = "A lightweight library for converting complex datatypes to and from native Python datatypes."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
packaging = ">=17.0"
|
||||
|
||||
[package.extras]
|
||||
dev = ["flake8 (==5.0.4)", "flake8-bugbear (==22.8.22)", "mypy (==0.971)", "pre-commit (>=2.4,<3.0)", "pytest", "pytz", "simplejson", "tox"]
|
||||
docs = ["alabaster (==0.7.12)", "autodocsumm (==0.2.9)", "sphinx (==5.1.1)", "sphinx-issues (==3.0.1)", "sphinx-version-warning (==1.1.2)"]
|
||||
lint = ["flake8 (==5.0.4)", "flake8-bugbear (==22.8.22)", "mypy (==0.971)", "pre-commit (>=2.4,<3.0)"]
|
||||
tests = ["pytest", "pytz", "simplejson"]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "0.971"
|
||||
description = "Optional static typing for Python"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
mypy-extensions = ">=0.4.3"
|
||||
tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""}
|
||||
typing-extensions = ">=3.10"
|
||||
|
||||
[package.extras]
|
||||
dmypy = ["psutil (>=4.0)"]
|
||||
python2 = ["typed-ast (>=1.4.0,<2)"]
|
||||
reports = ["lxml"]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "0.4.3"
|
||||
description = "Experimental type system extensions for programs checked with the mypy typechecker."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "21.3"
|
||||
description = "Core utilities for Python packages"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.dependencies]
|
||||
pyparsing = ">=2.0.2,<3.0.5 || >3.0.5"
|
||||
|
||||
[[package]]
|
||||
name = "Pygments"
|
||||
version = "2.13.0"
|
||||
description = "Pygments is a syntax highlighting package written in Python."
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
|
||||
[package.extras]
|
||||
plugins = ["importlib-metadata"]
|
||||
|
||||
[[package]]
|
||||
name = "pyparsing"
|
||||
version = "3.0.9"
|
||||
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6.8"
|
||||
|
||||
[package.extras]
|
||||
diagrams = ["jinja2", "railroad-diagrams"]
|
||||
|
||||
[[package]]
|
||||
name = "pytoolconfig"
|
||||
version = "1.2.2"
|
||||
description = "Python tool configuration"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
packaging = ">=21.3"
|
||||
tomli = {version = ">=2.0", markers = "python_version < \"3.11\""}
|
||||
|
||||
[package.extras]
|
||||
doc = ["sphinx (>=4.5.0)", "tabulate (>=0.8.9)"]
|
||||
gen_docs = ["pytoolconfig[doc]", "sphinx (>=4.5.0)", "sphinx-autodoc-typehints (>=1.18.1)", "sphinx-rtd-theme (>=1.0.0)"]
|
||||
global = ["appdirs (>=1.4.4)"]
|
||||
validation = ["pydantic (>=1.7.4)"]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "12.5.1"
|
||||
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.6.3,<4.0.0"
|
||||
|
||||
[package.dependencies]
|
||||
commonmark = ">=0.9.0,<0.10.0"
|
||||
pygments = ">=2.6.0,<3.0.0"
|
||||
|
||||
[package.extras]
|
||||
jupyter = ["ipywidgets (>=7.5.1,<8.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "rope"
|
||||
version = "1.3.0"
|
||||
description = "a python refactoring library..."
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[package.dependencies]
|
||||
pytoolconfig = ">=1.1.2"
|
||||
|
||||
[package.extras]
|
||||
dev = ["build (>=0.7.0)", "pytest (>=7.0.1)", "pytest-timeout (>=2.1.0)"]
|
||||
doc = ["pytoolconfig[doc]", "sphinx (>=4.5.0)", "sphinx-autodoc-typehints (>=1.18.1)", "sphinx-rtd-theme (>=1.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.0.1"
|
||||
description = "A lil' TOML parser"
|
||||
category = "main"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.3.0"
|
||||
description = "Backported and Experimental Type Hints for Python 3.7+"
|
||||
category = "dev"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
|
||||
[metadata]
|
||||
lock-version = "1.1"
|
||||
python-versions = "^3.10"
|
||||
content-hash = "9904b6af2c4c21959cb0c8a39087b3b9f204726958ff6c00eb299bfd5ca404a2"
|
||||
|
||||
[metadata.files]
|
||||
attrs = [
|
||||
{file = "attrs-22.1.0-py2.py3-none-any.whl", hash = "sha256:86efa402f67bf2df34f51a335487cf46b1ec130d02b8d39fd248abfd30da551c"},
|
||||
{file = "attrs-22.1.0.tar.gz", hash = "sha256:29adc2665447e5191d0e7c568fde78b21f9672d344281d0c6e1ab085429b22b6"},
|
||||
]
|
||||
click = [
|
||||
{file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"},
|
||||
{file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"},
|
||||
]
|
||||
colorama = [
|
||||
{file = "colorama-0.4.5-py2.py3-none-any.whl", hash = "sha256:854bf444933e37f5824ae7bfc1e98d5bce2ebe4160d46b5edf346a89358e99da"},
|
||||
{file = "colorama-0.4.5.tar.gz", hash = "sha256:e6c6b4334fc50988a639d9b98aa429a0b57da6e17b9a44f0451f930b6967b7a4"},
|
||||
]
|
||||
commonmark = [
|
||||
{file = "commonmark-0.9.1-py2.py3-none-any.whl", hash = "sha256:da2f38c92590f83de410ba1a3cbceafbc74fee9def35f9251ba9a971d6d66fd9"},
|
||||
{file = "commonmark-0.9.1.tar.gz", hash = "sha256:452f9dc859be7f06631ddcb328b6919c67984aca654e5fefb3914d54691aed60"},
|
||||
]
|
||||
isort = [
|
||||
{file = "isort-5.10.1-py3-none-any.whl", hash = "sha256:6f62d78e2f89b4500b080fe3a81690850cd254227f27f75c3a0c491a1f351ba7"},
|
||||
{file = "isort-5.10.1.tar.gz", hash = "sha256:e8443a5e7a020e9d7f97f1d7d9cd17c88bcb3bc7e218bf9cf5095fe550be2951"},
|
||||
]
|
||||
marshmallow = [
|
||||
{file = "marshmallow-3.17.1-py3-none-any.whl", hash = "sha256:1172ce82765bf26c24a3f9299ed6dbeeca4d213f638eaa39a37772656d7ce408"},
|
||||
{file = "marshmallow-3.17.1.tar.gz", hash = "sha256:48e2d88d4ab431ad5a17c25556d9da529ea6e966876f2a38d274082e270287f0"},
|
||||
]
|
||||
mypy = [
|
||||
{file = "mypy-0.971-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f2899a3cbd394da157194f913a931edfd4be5f274a88041c9dc2d9cdcb1c315c"},
|
||||
{file = "mypy-0.971-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:98e02d56ebe93981c41211c05adb630d1d26c14195d04d95e49cd97dbc046dc5"},
|
||||
{file = "mypy-0.971-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:19830b7dba7d5356d3e26e2427a2ec91c994cd92d983142cbd025ebe81d69cf3"},
|
||||
{file = "mypy-0.971-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:02ef476f6dcb86e6f502ae39a16b93285fef97e7f1ff22932b657d1ef1f28655"},
|
||||
{file = "mypy-0.971-cp310-cp310-win_amd64.whl", hash = "sha256:25c5750ba5609a0c7550b73a33deb314ecfb559c350bb050b655505e8aed4103"},
|
||||
{file = "mypy-0.971-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d3348e7eb2eea2472db611486846742d5d52d1290576de99d59edeb7cd4a42ca"},
|
||||
{file = "mypy-0.971-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3fa7a477b9900be9b7dd4bab30a12759e5abe9586574ceb944bc29cddf8f0417"},
|
||||
{file = "mypy-0.971-cp36-cp36m-win_amd64.whl", hash = "sha256:2ad53cf9c3adc43cf3bea0a7d01a2f2e86db9fe7596dfecb4496a5dda63cbb09"},
|
||||
{file = "mypy-0.971-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:855048b6feb6dfe09d3353466004490b1872887150c5bb5caad7838b57328cc8"},
|
||||
{file = "mypy-0.971-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:23488a14a83bca6e54402c2e6435467a4138785df93ec85aeff64c6170077fb0"},
|
||||
{file = "mypy-0.971-cp37-cp37m-win_amd64.whl", hash = "sha256:4b21e5b1a70dfb972490035128f305c39bc4bc253f34e96a4adf9127cf943eb2"},
|
||||
{file = "mypy-0.971-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:9796a2ba7b4b538649caa5cecd398d873f4022ed2333ffde58eaf604c4d2cb27"},
|
||||
{file = "mypy-0.971-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5a361d92635ad4ada1b1b2d3630fc2f53f2127d51cf2def9db83cba32e47c856"},
|
||||
{file = "mypy-0.971-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:b793b899f7cf563b1e7044a5c97361196b938e92f0a4343a5d27966a53d2ec71"},
|
||||
{file = "mypy-0.971-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d1ea5d12c8e2d266b5fb8c7a5d2e9c0219fedfeb493b7ed60cd350322384ac27"},
|
||||
{file = "mypy-0.971-cp38-cp38-win_amd64.whl", hash = "sha256:23c7ff43fff4b0df93a186581885c8512bc50fc4d4910e0f838e35d6bb6b5e58"},
|
||||
{file = "mypy-0.971-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:1f7656b69974a6933e987ee8ffb951d836272d6c0f81d727f1d0e2696074d9e6"},
|
||||
{file = "mypy-0.971-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d2022bfadb7a5c2ef410d6a7c9763188afdb7f3533f22a0a32be10d571ee4bbe"},
|
||||
{file = "mypy-0.971-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ef943c72a786b0f8d90fd76e9b39ce81fb7171172daf84bf43eaf937e9f220a9"},
|
||||
{file = "mypy-0.971-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:d744f72eb39f69312bc6c2abf8ff6656973120e2eb3f3ec4f758ed47e414a4bf"},
|
||||
{file = "mypy-0.971-cp39-cp39-win_amd64.whl", hash = "sha256:77a514ea15d3007d33a9e2157b0ba9c267496acf12a7f2b9b9f8446337aac5b0"},
|
||||
{file = "mypy-0.971-py3-none-any.whl", hash = "sha256:0d054ef16b071149917085f51f89555a576e2618d5d9dd70bd6eea6410af3ac9"},
|
||||
{file = "mypy-0.971.tar.gz", hash = "sha256:40b0f21484238269ae6a57200c807d80debc6459d444c0489a102d7c6a75fa56"},
|
||||
]
|
||||
mypy-extensions = [
|
||||
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
|
||||
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
|
||||
]
|
||||
packaging = [
|
||||
{file = "packaging-21.3-py3-none-any.whl", hash = "sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"},
|
||||
{file = "packaging-21.3.tar.gz", hash = "sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb"},
|
||||
]
|
||||
Pygments = [
|
||||
{file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"},
|
||||
{file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"},
|
||||
]
|
||||
pyparsing = [
|
||||
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
|
||||
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
|
||||
]
|
||||
pytoolconfig = [
|
||||
{file = "pytoolconfig-1.2.2-py3-none-any.whl", hash = "sha256:825d97b052e58b609c2684b04efeb543075588d33a4916a6dc2ae39676458c7d"},
|
||||
{file = "pytoolconfig-1.2.2.tar.gz", hash = "sha256:2512a1f261a40e73cef2e58e786184261b60c802ae7ed01249342b1949ec3aa2"},
|
||||
]
|
||||
rich = [
|
||||
{file = "rich-12.5.1-py3-none-any.whl", hash = "sha256:2eb4e6894cde1e017976d2975ac210ef515d7548bc595ba20e195fb9628acdeb"},
|
||||
{file = "rich-12.5.1.tar.gz", hash = "sha256:63a5c5ce3673d3d5fbbf23cd87e11ab84b6b451436f1b7f19ec54b6bc36ed7ca"},
|
||||
]
|
||||
rope = [
|
||||
{file = "rope-1.3.0-py3-none-any.whl", hash = "sha256:f0c82bd7167c2926339c6f0d9ecf7c93d6866cbe380c78639fa1bc4ac037c097"},
|
||||
{file = "rope-1.3.0.tar.gz", hash = "sha256:4e8ede637d8f43eb83847ef9ea7edbf4ceb9d641deea592ed38a8875cde64265"},
|
||||
]
|
||||
tomli = [
|
||||
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
|
||||
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
|
||||
]
|
||||
typing-extensions = [
|
||||
{file = "typing_extensions-4.3.0-py3-none-any.whl", hash = "sha256:25642c956049920a5aa49edcdd6ab1e06d7e5d467fc00e0506c44ac86fbfca02"},
|
||||
{file = "typing_extensions-4.3.0.tar.gz", hash = "sha256:e6d2677a32f47fc7eb2795db1dd15c1f34eff616bcaf2cfb5e997f854fa1c4a6"},
|
||||
]
|
38
pyproject.toml
Normal file
38
pyproject.toml
Normal file
|
@ -0,0 +1,38 @@
|
|||
[tool.poetry]
|
||||
name = "cctk"
|
||||
version = "0.2.0"
|
||||
description = "CTF Challenge ToolKit - for automated challenge management."
|
||||
authors = ["Thomas Bork <sudoBash418@gmail.com>"]
|
||||
|
||||
[tool.poetry.scripts]
|
||||
cctk = "cctk.__main__:entrypoint"
|
||||
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
click = "^8.1.3"
|
||||
rich = "^12.5.1"
|
||||
tomli = { version = "^2.0.1", python = "<3.11" } # Python >=3.11 distributes tomllib in the stdlib
|
||||
attrs = "^22.1.0"
|
||||
marshmallow = "^3.17.1"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
mypy = "^0.971"
|
||||
rope = "^1.3.0"
|
||||
isort = "^5.10.1"
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core>=1.0.0"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
|
||||
[tool.isort]
|
||||
profile = "google"
|
||||
src_paths = ["cctk"]
|
||||
|
||||
known_typing = ["typing", "types", "typing_extensions"]
|
||||
sections = ["FUTURE", "TYPING", "STDLIB", "THIRDPARTY", "FIRSTPARTY", "LOCALFOLDER"]
|
||||
no_lines_before = ["TYPING"]
|
||||
force_single_line = false
|
||||
lines_after_imports = 2
|
Loading…
Reference in a new issue