Source code for contree_sdk.docker.parser
"""Parse a Dockerfile into a list of `DockerKeyword` instances."""
from __future__ import annotations
import hashlib
from collections.abc import Iterable, Mapping
from pathlib import Path
from .keyword import DockerKeyword
from .kw_add import AddKeyword
from .kw_arg import ArgKeyword
from .kw_copy import CopyKeyword
from .kw_env import EnvKeyword
from .kw_from import FromKeyword
from .kw_run import RunKeyword
from .kw_skipped import SkippedKeyword
from .kw_user import UserKeyword
from .kw_workdir import WorkdirKeyword
KEYWORDS: dict[str, type[DockerKeyword]] = {
"FROM": FromKeyword,
"RUN": RunKeyword,
"COPY": CopyKeyword,
"ADD": AddKeyword,
"WORKDIR": WorkdirKeyword,
"ENV": EnvKeyword,
"ARG": ArgKeyword,
"USER": UserKeyword,
}
SKIPPED_NAMES = frozenset({
"CMD",
"ENTRYPOINT",
"LABEL",
"EXPOSE",
"VOLUME",
"STOPSIGNAL",
"MAINTAINER",
"HEALTHCHECK",
"ONBUILD",
"SHELL",
})
[docs]
class DockerfileParser:
"""A per-instance directive registry. Subclass or register custom DockerKeyword classes."""
def __init__(
self, *, keywords: Mapping[str, type[DockerKeyword]] | None = None, skipped: Iterable[str] | None = None
) -> None:
self.keywords = dict(KEYWORDS if keywords is None else keywords)
self.skipped = set(SKIPPED_NAMES if skipped is None else skipped)
[docs]
def register(self, keyword: type[DockerKeyword]) -> None:
name = keyword.NAME.upper()
if not name or any(char.isspace() for char in name):
raise ValueError("keyword NAME must be a non-empty token")
self.keywords[name] = keyword
self.skipped.discard(name)
[docs]
def parse(self, text: str) -> list[DockerKeyword]:
"""Return directives using this parser's registry.
Returns:
The parsed directives in source order.
Raises:
ValueError: A directive is unknown or has invalid arguments.
"""
result: list[DockerKeyword] = []
for raw in join_continuations(text):
line = raw.strip()
if not line or line.startswith("#"):
continue
parts = line.split(maxsplit=1)
head = parts[0].upper()
rest = parts[1] if len(parts) > 1 else ""
if head in self.keywords:
result.append(self.keywords[head].parse(rest))
elif head in self.skipped:
result.append(SkippedKeyword.of(head, rest))
else:
raise ValueError(f"unknown Dockerfile directive: {head!r}")
return result
[docs]
def parse_dockerfile(text: str) -> list[DockerKeyword]:
return DockerfileParser().parse(text)
def join_continuations(text: str) -> list[str]:
# merge lines ending with a backslash into single logical lines
out: list[str] = []
buf: list[str] = []
for raw_line in text.splitlines():
line = raw_line.rstrip()
if line.endswith("\\"):
buf.append(line[:-1])
continue
if buf:
buf.append(line)
out.append(" ".join(part.strip() for part in buf))
buf = []
else:
out.append(line)
if buf:
out.append(" ".join(part.strip() for part in buf))
return out
def validate_first_directive(directives: list[DockerKeyword]) -> bool:
for directive in directives:
if isinstance(directive, FromKeyword):
return True
if isinstance(directive, ArgKeyword):
continue
return False
return False
def make_session_key(context_dir: Path) -> str:
digest = hashlib.sha256(str(context_dir).encode()).hexdigest()
return f"build:{digest[:16]}"