diff --git a/.github/workflows/python-ci.yml b/.github/workflows/python-ci.yml index 2e0453b1..6f7ecdeb 100644 --- a/.github/workflows/python-ci.yml +++ b/.github/workflows/python-ci.yml @@ -3,7 +3,7 @@ on: branches: [main] pull_request: paths: - - 'python/**' + - "python/**" defaults: run: working-directory: python/selfie-lib @@ -24,9 +24,9 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version-file: 'python/selfie-lib/pyproject.toml' - cache: 'poetry' + python-version-file: "python/selfie-lib/pyproject.toml" + cache: "poetry" - run: poetry install - run: poetry run pytest -vv - run: poetry run pyright - - run: poetry run ruff check + - run: poetry run ruff format --check diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..7b016a89 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "java.compile.nullAnalysis.mode": "automatic" +} \ No newline at end of file diff --git a/python/selfie-lib/selfie_lib/ArrayMap.py b/python/selfie-lib/selfie_lib/ArrayMap.py new file mode 100644 index 00000000..485fb296 --- /dev/null +++ b/python/selfie-lib/selfie_lib/ArrayMap.py @@ -0,0 +1,123 @@ +from collections.abc import Set, Iterator, Mapping +from typing import List, TypeVar, Union +from abc import abstractmethod, ABC + +T = TypeVar("T") +V = TypeVar("V") +K = TypeVar("K") + + +class ListBackedSet(Set[T], ABC): + @abstractmethod + def __len__(self) -> int: + ... + + @abstractmethod + def __getitem__(self, index: Union[int, slice]) -> Union[T, List[T]]: + ... + + def __contains__(self, item: object) -> bool: + for i in range(len(self)): + if self[i] == item: + return True + return False + + +class ArraySet(ListBackedSet[K]): + __data: List[K] + + def __init__(self, data: List[K]): + raise NotImplementedError("Use ArraySet.empty() instead") + + @classmethod + def __create(cls, data: List[K]) -> "ArraySet[K]": + # Create a new instance without calling __init__ + instance = super().__new__(cls) + instance.__data = data + return instance + + def __iter__(self) -> Iterator[K]: + return iter(self.__data) + + @classmethod + def empty(cls) -> "ArraySet[K]": + if not hasattr(cls, "__EMPTY"): + cls.__EMPTY = cls([]) + return cls.__EMPTY + + def __len__(self) -> int: + return len(self.__data) + + def __getitem__(self, index: Union[int, slice]) -> Union[K, List[K]]: + if isinstance(index, int): + return self.__data[index] + elif isinstance(index, slice): + return self.__data[index] + else: + raise TypeError("Invalid argument type.") + + def plusOrThis(self, element: K) -> "ArraySet[K]": + # TODO: use binary search, and also special sort order for strings + if element in self.__data: + return self + else: + new_data = self.__data[:] + new_data.append(element) + new_data.sort() # type: ignore[reportOperatorIssue] + return ArraySet.__create(new_data) + + +class ArrayMap(Mapping[K, V]): + def __init__(self, data: list): + # TODO: hide this constructor as done in ArraySet + self.__data = data + + @classmethod + def empty(cls) -> "ArrayMap[K, V]": + if not hasattr(cls, "__EMPTY"): + cls.__EMPTY = cls([]) + return cls.__EMPTY + + def __getitem__(self, key: K) -> V: + index = self.__binary_search_key(key) + if index >= 0: + return self.__data[2 * index + 1] + raise KeyError(key) + + def __iter__(self) -> Iterator[K]: + return (self.__data[i] for i in range(0, len(self.__data), 2)) + + def __len__(self) -> int: + return len(self.__data) // 2 + + def __binary_search_key(self, key: K) -> int: + # TODO: special sort order for strings + low, high = 0, (len(self.__data) // 2) - 1 + while low <= high: + mid = (low + high) // 2 + mid_key = self.__data[2 * mid] + if mid_key < key: + low = mid + 1 + elif mid_key > key: + high = mid - 1 + else: + return mid + return -(low + 1) + + def plus(self, key: K, value: V) -> "ArrayMap[K, V]": + index = self.__binary_search_key(key) + if index >= 0: + raise ValueError("Key already exists") + insert_at = -(index + 1) + new_data = self.__data[:] + new_data[insert_at * 2 : insert_at * 2] = [key, value] + return ArrayMap(new_data) + + def minus_sorted_indices(self, indicesToRemove: List[int]) -> "ArrayMap[K, V]": + if not indicesToRemove: + return self + newData = [] + for i in range(0, len(self.__data), 2): + if i // 2 not in indicesToRemove: + newData.extend(self.__data[i : i + 2]) + return ArrayMap(newData) diff --git a/python/selfie-lib/selfie_lib/CommentTracker.py b/python/selfie-lib/selfie_lib/CommentTracker.py new file mode 100644 index 00000000..e8ee1bf0 --- /dev/null +++ b/python/selfie-lib/selfie_lib/CommentTracker.py @@ -0,0 +1,87 @@ +from typing import Dict, Iterable, Tuple +from enum import Enum, auto +import threading +from selfie_lib.TypedPath import TypedPath +from selfie_lib.Slice import Slice + + +# Placeholder implementations for CallStack, SnapshotFileLayout, and FS +class CallStack: + pass + + +class SnapshotFileLayout: + def sourcePathForCall(self, location) -> "TypedPath": + # Placeholder return or raise NotImplementedError + raise NotImplementedError("sourcePathForCall is not implemented") + + +class WritableComment(Enum): + NO_COMMENT = auto() + ONCE = auto() + FOREVER = auto() + + @property + def writable(self) -> bool: + return self != WritableComment.NO_COMMENT + + +class CommentTracker: + def __init__(self): + self.cache: Dict[TypedPath, WritableComment] = {} + self.lock = threading.Lock() + + def pathsWithOnce(self) -> Iterable[TypedPath]: + with self.lock: + return [ + path + for path, comment in self.cache.items() + if comment == WritableComment.ONCE + ] + + def hasWritableComment(self, call: CallStack, layout: SnapshotFileLayout) -> bool: + path = layout.sourcePathForCall(call) + with self.lock: + if path in self.cache: + comment = self.cache[path] + if comment.writable: + return True + else: + return False + else: + new_comment, _ = self.__commentAndLine(path) + self.cache[path] = new_comment + return new_comment.writable + + @staticmethod + def commentString(typedPath: TypedPath) -> Tuple[str, int]: + comment, line = CommentTracker.__commentAndLine(typedPath) + if comment == WritableComment.NO_COMMENT: + raise ValueError("No writable comment found") + elif comment == WritableComment.ONCE: + return ("//selfieonce", line) + elif comment == WritableComment.FOREVER: + return ("//SELFIEWRITE", line) + else: + raise ValueError("Invalid comment type") + + @staticmethod + def __commentAndLine(typedPath: TypedPath) -> Tuple[WritableComment, int]: + with open(typedPath.absolute_path, "r") as file: + content = Slice(file.read()) + for comment_str in [ + "//selfieonce", + "// selfieonce", + "//SELFIEWRITE", + "// SELFIEWRITE", + ]: + index = content.indexOf(comment_str) + if index != -1: + lineNumber = content.baseLineAtOffset(index) + comment = ( + WritableComment.ONCE + if "once" in comment_str + else WritableComment.FOREVER + ) + return (comment, lineNumber) + return (WritableComment.NO_COMMENT, -1) diff --git a/python/selfie-lib/selfie_lib/EscapeLeadingWhitespace.py b/python/selfie-lib/selfie_lib/EscapeLeadingWhitespace.py new file mode 100644 index 00000000..b20d0e8b --- /dev/null +++ b/python/selfie-lib/selfie_lib/EscapeLeadingWhitespace.py @@ -0,0 +1,12 @@ +from enum import Enum, auto + + +class EscapeLeadingWhitespace(Enum): + NEVER = auto() + + def escape_line(self, line: str, space: str, tab: str) -> str: + return line + + @staticmethod + def appropriate_for(file_content: str) -> "EscapeLeadingWhitespace": + return EscapeLeadingWhitespace.NEVER diff --git a/python/selfie-lib/selfie_lib/LineReader.py b/python/selfie-lib/selfie_lib/LineReader.py new file mode 100644 index 00000000..60aad77a --- /dev/null +++ b/python/selfie-lib/selfie_lib/LineReader.py @@ -0,0 +1,35 @@ +import io + + +class LineReader: + def __init__(self, content: bytes): + self.__buffer = io.BytesIO(content) + self.__uses_unix_newlines = self.__detect_newline_type() + self.__line_count = 0 # Initialize line count + + @classmethod + def for_binary(cls, content: bytes): + return cls(content) + + @classmethod + def for_string(cls, content: str): + return cls(content.encode("utf-8")) + + def __detect_newline_type(self) -> bool: + first_line = self.__buffer.readline() + self.__buffer.seek(0) # Reset buffer for actual reading + return b"\r\n" not in first_line + + def unix_newlines(self) -> bool: + return self.__uses_unix_newlines + + def read_line(self) -> str: + line_bytes = self.__buffer.readline() + if line_bytes: + self.__line_count += 1 # Increment line count for each line read + line = line_bytes.decode("utf-8") + return line.rstrip("\r\n" if not self.__uses_unix_newlines else "\n") + + # Method to get the current line number + def get_line_number(self) -> int: + return self.__line_count diff --git a/python/selfie-lib/selfie_lib/Literals.py b/python/selfie-lib/selfie_lib/Literals.py new file mode 100644 index 00000000..cd27e92e --- /dev/null +++ b/python/selfie-lib/selfie_lib/Literals.py @@ -0,0 +1,89 @@ +from enum import Enum, auto +from typing import Protocol, TypeVar +from abc import abstractmethod +from .EscapeLeadingWhitespace import EscapeLeadingWhitespace +import io + +T = TypeVar("T") + + +class Language(Enum): + PYTHON = auto() + + @classmethod + def from_filename(cls, filename: str) -> "Language": + extension = filename.rsplit(".", 1)[-1] + if extension == "py": + return cls.PYTHON + else: + raise ValueError(f"Unknown language for file {filename}") + + +class LiteralValue: + def __init__(self, expected: T | None, actual: T, format: "LiteralFormat") -> None: + self.expected = expected + self.actual = actual + self.format = format + + +class LiteralFormat(Protocol[T]): + @abstractmethod + def encode( + self, value: T, language: Language, encoding_policy: "EscapeLeadingWhitespace" + ) -> str: + raise NotImplementedError("Subclasses must implement the encode method") + + @abstractmethod + def parse(self, string: str, language: Language) -> T: + raise NotImplementedError("Subclasses must implement the parse method") + + +MAX_RAW_NUMBER = 1000 +PADDING_SIZE = len(str(MAX_RAW_NUMBER)) - 1 + + +class LiteralInt(LiteralFormat[int]): + def _encode_underscores( + self, buffer: io.StringIO, value: int, language: Language + ) -> io.StringIO: + if value >= MAX_RAW_NUMBER: + mod = value % MAX_RAW_NUMBER + left_padding = PADDING_SIZE - len(str(mod)) + self._encode_underscores(buffer, value // MAX_RAW_NUMBER, language) + buffer.write("_") + buffer.write("0" * left_padding) + buffer.write(str(mod)) + return buffer + elif value < 0: + buffer.write("-") + self._encode_underscores(buffer, abs(value), language) + return buffer + else: + buffer.write(str(value)) + return buffer + + def encode( + self, value: int, language: Language, encoding_policy: EscapeLeadingWhitespace + ) -> str: + return self._encode_underscores(io.StringIO(), value, language).getvalue() + + def parse(self, string: str, language: Language) -> int: + return int(string.replace("_", "")) + + +class LiteralBoolean(LiteralFormat[bool]): + def encode( + self, value: bool, language: Language, encoding_policy: EscapeLeadingWhitespace + ) -> str: + return str(value) + + def __to_boolean_strict(self, string: str) -> bool: + if string.lower() == "true": + return True + elif string.lower() == "false": + return False + else: + raise ValueError("String is not a valid boolean representation: " + string) + + def parse(self, string: str, language: Language) -> bool: + return self.__to_boolean_strict(string) diff --git a/python/selfie-lib/selfie_lib/PerCharacterEscaper.py b/python/selfie-lib/selfie_lib/PerCharacterEscaper.py new file mode 100644 index 00000000..3f9d33df --- /dev/null +++ b/python/selfie-lib/selfie_lib/PerCharacterEscaper.py @@ -0,0 +1,95 @@ +from typing import List + + +class PerCharacterEscaper: + def __init__( + self, + escape_code_point: int, + escaped_code_points: List[int], + escaped_by_code_points: List[int], + ): + self.__escape_code_point = escape_code_point + self.__escaped_code_points = escaped_code_points + self.__escaped_by_code_points = escaped_by_code_points + + def __first_offset_needing_escape(self, input_string: str) -> int: + length = len(input_string) + for offset in range(length): + codepoint = ord(input_string[offset]) + if ( + codepoint == self.__escape_code_point + or codepoint in self.__escaped_code_points + ): + return offset + return -1 + + def escape(self, input_string: str) -> str: + no_escapes = self.__first_offset_needing_escape(input_string) + if no_escapes == -1: + return input_string + else: + result = [] + result.append(input_string[:no_escapes]) + for char in input_string[no_escapes:]: + codepoint = ord(char) + if codepoint in self.__escaped_code_points: + idx = self.__escaped_code_points.index(codepoint) + result.append(chr(self.__escape_code_point)) + result.append(chr(self.__escaped_by_code_points[idx])) + else: + result.append(char) + return "".join(result) + + def unescape(self, input_string: str) -> str: + if input_string.endswith( + chr(self.__escape_code_point) + ) and not input_string.endswith(chr(self.__escape_code_point) * 2): + raise ValueError( + "Escape character '{}' can't be the last character in a string.".format( + chr(self.__escape_code_point) + ) + ) + + no_escapes = self.__first_offset_needing_escape(input_string) + if no_escapes == -1: + return input_string + else: + result = [input_string[:no_escapes]] + skip_next = False + for i in range(no_escapes, len(input_string)): + if skip_next: + skip_next = False + continue + codepoint = ord(input_string[i]) + if codepoint == self.__escape_code_point and (i + 1) < len( + input_string + ): + next_codepoint = ord(input_string[i + 1]) + if next_codepoint in self.__escaped_by_code_points: + idx = self.__escaped_by_code_points.index(next_codepoint) + result.append(chr(self.__escaped_code_points[idx])) + skip_next = True + else: + result.append(input_string[i + 1]) + skip_next = True + else: + result.append(chr(codepoint)) + return "".join(result) + + @classmethod + def self_escape(cls, escape_policy): + code_points = [ord(c) for c in escape_policy] + escape_code_point = code_points[0] + return cls(escape_code_point, code_points, code_points) + + @classmethod + def specified_escape(cls, escape_policy): + code_points = [ord(c) for c in escape_policy] + if len(code_points) % 2 != 0: + raise ValueError( + "Escape policy string must have an even number of characters." + ) + escape_code_point = code_points[0] + escaped_code_points = code_points[0::2] + escaped_by_code_points = code_points[1::2] + return cls(escape_code_point, escaped_code_points, escaped_by_code_points) diff --git a/python/selfie-lib/selfie_lib/Slice.py b/python/selfie-lib/selfie_lib/Slice.py index 63b15cdd..517b45be 100644 --- a/python/selfie-lib/selfie_lib/Slice.py +++ b/python/selfie-lib/selfie_lib/Slice.py @@ -1,18 +1,23 @@ -from typing import Optional -from typing import Union +from typing import Optional +from typing import Union from collections import Counter + class Slice: """Represents a slice of a base string from startIndex to endIndex.""" - def __init__(self, base: str, startIndex: int = 0, endIndex: Optional[int] = None) -> None: + def __init__( + self, base: str, startIndex: int = 0, endIndex: Optional[int] = None + ) -> None: self.base = base self.base = base self.startIndex = startIndex self.endIndex = endIndex if endIndex is not None else len(base) - assert 0 <= self.startIndex <= self.endIndex <= len(base), "Invalid start or end index" - + assert ( + 0 <= self.startIndex <= self.endIndex <= len(base) + ), "Invalid start or end index" + def __len__(self) -> int: return self.endIndex - self.startIndex @@ -21,10 +26,10 @@ def __getitem__(self, index: int) -> str: raise IndexError("Index out of range") return self.base[self.startIndex + index] - def subSequence(self, start: int, end: int) -> 'Slice': + def subSequence(self, start: int, end: int) -> "Slice": return Slice(self.base, self.startIndex + start, self.startIndex + end) - def trim(self) -> 'Slice': + def trim(self) -> "Slice": start, end = 0, len(self) while start < end and self[start].isspace(): start += 1 @@ -33,9 +38,9 @@ def trim(self) -> 'Slice': return self.subSequence(start, end) if start > 0 or end < len(self) else self def __str__(self) -> str: - return self.base[self.startIndex:self.endIndex] + return self.base[self.startIndex : self.endIndex] - def sameAs(self, other: Union['Slice', str]) -> bool: + def sameAs(self, other: Union["Slice", str]) -> bool: if isinstance(other, Slice): return str(self) == str(other) elif isinstance(other, str): @@ -48,18 +53,24 @@ def sameAs(self, other: Union['Slice', str]) -> bool: return False def indexOf(self, lookingFor: str, startOffset: int = 0) -> int: - result = self.base.find(lookingFor, self.startIndex + startOffset, self.endIndex) + result = self.base.find( + lookingFor, self.startIndex + startOffset, self.endIndex + ) return -1 if result == -1 else result - self.startIndex - def unixLine(self, count: int) -> 'Slice': + def unixLine(self, count: int) -> "Slice": assert count > 0, "Count must be positive" lineStart = 0 for i in range(1, count): - lineStart = self.indexOf('\n', lineStart) + lineStart = self.indexOf("\n", lineStart) assert lineStart >= 0, f"This string has only {i - 1} lines, not {count}" lineStart += 1 - lineEnd = self.indexOf('\n', lineStart) - return Slice(self.base, self.startIndex + lineStart, self.endIndex if lineEnd == -1 else self.startIndex + lineEnd) + lineEnd = self.indexOf("\n", lineStart) + return Slice( + self.base, + self.startIndex + lineStart, + self.endIndex if lineEnd == -1 else self.startIndex + lineEnd, + ) def __eq__(self, other: object) -> bool: if self is other: @@ -75,10 +86,10 @@ def __hash__(self) -> int: return h def replaceSelfWith(self, s: str) -> str: - return self.base[:self.startIndex] + s + self.base[self.endIndex:] - + return self.base[: self.startIndex] + s + self.base[self.endIndex :] + def count(self, char: str) -> int: - return Counter(self.base[self.startIndex:self.endIndex])[char] - + return Counter(self.base[self.startIndex : self.endIndex])[char] + def baseLineAtOffset(self, index: int) -> int: - return 1 + Slice(self.base, 0, index).count('\n') + return 1 + Slice(self.base, 0, index).count("\n") diff --git a/python/selfie-lib/selfie_lib/SourceFile.py b/python/selfie-lib/selfie_lib/SourceFile.py new file mode 100644 index 00000000..6ab05fc9 --- /dev/null +++ b/python/selfie-lib/selfie_lib/SourceFile.py @@ -0,0 +1,188 @@ +from .Slice import Slice +from .Literals import Language, LiteralFormat, LiteralValue +from .EscapeLeadingWhitespace import EscapeLeadingWhitespace +from typing import Any + + +class SourceFile: + TRIPLE_QUOTE = '"""' + + def __init__(self, filename: str, content: str) -> None: + self.__unix_newlines: bool = "\r" not in content + self.__content_slice: Slice = Slice(content.replace("\r\n", "\n")) + self.__language: Language = Language.from_filename(filename) + self.__escape_leading_whitespace = EscapeLeadingWhitespace.appropriate_for( + self.__content_slice.__str__() + ) + + @property + def as_string(self) -> str: + return ( + self.__content_slice.__str__() + if self.__unix_newlines + else self.__content_slice.__str__().replace("\n", "\r\n") + ) + + class ToBeLiteral: + def __init__( + self, + dot_fun_open_paren: str, + function_call_plus_arg: Slice, + arg: Slice, + language: Language, + escape_leading_whitespace: EscapeLeadingWhitespace, + ) -> None: + self.__dot_fun_open_paren = dot_fun_open_paren + self.__function_call_plus_arg = function_call_plus_arg + self.__arg = arg + self.__language = language + self.__escape_leading_whitespace = escape_leading_whitespace + + def _get_function_call_plus_arg(self): + return self.__function_call_plus_arg + + def _get_arg(self): + return self.__arg + + def set_literal_and_get_newline_delta(self, literal_value: LiteralValue) -> int: + encoded = literal_value.format.encode( + literal_value.actual, self.__language, self.__escape_leading_whitespace + ) + round_tripped = literal_value.format.parse(encoded, self.__language) + if round_tripped != literal_value.actual: + raise ValueError( + f"There is an error in {literal_value.format.__class__.__name__}, " + "the following value isn't round tripping.\n" + f"Please report this error and the data below at " + "https://github.com/diffplug/selfie/issues/new\n" + f"```\n" + f"ORIGINAL\n{literal_value.actual}\n" + f"ROUNDTRIPPED\n{round_tripped}\n" + f"ENCODED ORIGINAL\n{encoded}\n" + f"```\n" + ) + existing_newlines = self.__function_call_plus_arg.count("\n") + new_newlines = encoded.count("\n") + self.__content_slice = self.__function_call_plus_arg.replaceSelfWith( + f"{self.__dot_fun_open_paren}{encoded})" + ) + return new_newlines - existing_newlines + + def parse_literal(self, literal_format: LiteralFormat) -> Any: + return literal_format.parse(self.__arg.__str__(), self.__language) + + def find_on_line(self, to_find: str, line_one_indexed: int) -> Slice: + line_content = self.__content_slice.unixLine(line_one_indexed) + idx = line_content.indexOf(to_find) + if idx == -1: + raise AssertionError( + f"Expected to find `{to_find}` on line {line_one_indexed}, " + f"but there was only `{line_content}`" + ) + start_index = idx + end_index = idx + len(to_find) + return line_content.subSequence(start_index, end_index) + + def replace_on_line(self, line_one_indexed: int, find: str, replace: str) -> None: + assert "\n" not in find + assert "\n" not in replace + line_content = self.__content_slice.unixLine(line_one_indexed).__str__() + new_content = line_content.replace(find, replace) + self.__content_slice = Slice(self.__content_slice.replaceSelfWith(new_content)) + + def parse_to_be_like(self, line_one_indexed: int) -> ToBeLiteral: + line_content = self.__content_slice.unixLine(line_one_indexed) + dot_fun_open_paren = None + + for to_be_like in TO_BE_LIKES: + idx = line_content.indexOf(to_be_like) + if idx != -1: + dot_fun_open_paren = to_be_like + break + if dot_fun_open_paren is None: + raise AssertionError( + f"Expected to find inline assertion on line {line_one_indexed}, but there was only `{line_content}`" + ) + + dot_function_call_in_place = line_content.indexOf(dot_fun_open_paren) + dot_function_call = dot_function_call_in_place + line_content.startIndex + arg_start = dot_function_call + len(dot_fun_open_paren) + + if self.__content_slice.__len__() == arg_start: + raise AssertionError( + f"Appears to be an unclosed function call `{dot_fun_open_paren}` " + f"on line {line_one_indexed}" + ) + while self.__content_slice[arg_start].isspace(): + arg_start += 1 + if self.__content_slice.__len__() == arg_start: + raise AssertionError( + f"Appears to be an unclosed function call `{dot_fun_open_paren}` " + f"on line {line_one_indexed}" + ) + + end_arg = -1 + end_paren = 0 + if self.__content_slice[arg_start] == '"': + if self.__content_slice[arg_start].startswith(self.TRIPLE_QUOTE): + end_arg = self.__content_slice.indexOf( + self.TRIPLE_QUOTE, arg_start + len(self.TRIPLE_QUOTE) + ) + if end_arg == -1: + raise AssertionError( + f"Appears to be an unclosed multiline string literal `{self.TRIPLE_QUOTE}` " + f"on line {line_one_indexed}" + ) + else: + end_arg += len(self.TRIPLE_QUOTE) + end_paren = end_arg + else: + end_arg = arg_start + 1 + while ( + self.__content_slice[end_arg] != '"' + or self.__content_slice[end_arg - 1] == "\\" + ): + end_arg += 1 + if end_arg == self.__content_slice.__len__(): + raise AssertionError( + f'Appears to be an unclosed string literal `"` ' + f"on line {line_one_indexed}" + ) + end_arg += 1 + end_paren = end_arg + else: + end_arg = arg_start + while not self.__content_slice[end_arg].isspace(): + if self.__content_slice[end_arg] == ")": + break + end_arg += 1 + if end_arg == self.__content_slice.__len__(): + raise AssertionError( + f"Appears to be an unclosed numeric literal " + f"on line {line_one_indexed}" + ) + end_paren = end_arg + while self.__content_slice[end_paren] != ")": + if not self.__content_slice[end_paren].isspace(): + raise AssertionError( + f"Non-primitive literal in `{dot_fun_open_paren}` starting at " + f"line {line_one_indexed}: error for character " + f"`{self.__content_slice[end_paren]}` on line " + f"{self.__content_slice.baseLineAtOffset(end_paren)}" + ) + end_paren += 1 + if end_paren == self.__content_slice.__len__(): + raise AssertionError( + f"Appears to be an unclosed function call `{dot_fun_open_paren}` " + f"starting at line {line_one_indexed}" + ) + return self.ToBeLiteral( + dot_fun_open_paren.replace("_TODO", ""), + self.__content_slice.subSequence(dot_function_call, end_paren + 1), + self.__content_slice.subSequence(arg_start, end_arg), + self.__language, + self.__escape_leading_whitespace, + ) + + +TO_BE_LIKES = [".toBe(", ".toBe_TODO(", ".toBeBase64(", ".toBeBase64_TODO("] diff --git a/python/selfie-lib/selfie_lib/TypedPath.py b/python/selfie-lib/selfie_lib/TypedPath.py new file mode 100644 index 00000000..7165db0f --- /dev/null +++ b/python/selfie-lib/selfie_lib/TypedPath.py @@ -0,0 +1,76 @@ +from functools import total_ordering + + +@total_ordering +class TypedPath: + def __init__(self, absolute_path: str): + self.absolute_path = absolute_path + + def __hash__(self): + return hash(self.absolute_path) + + @property + def name(self) -> str: + if self.absolute_path.endswith("/"): + path = self.absolute_path[:-1] + else: + path = self.absolute_path + last_slash = path.rfind("/") + return path[last_slash + 1 :] + + @property + def is_folder(self) -> bool: + return self.absolute_path.endswith("/") + + def assert_folder(self) -> None: + if not self.is_folder: + raise AssertionError( + f"Expected {self} to be a folder but it doesn't end with `/`" + ) + + def parent_folder(self) -> "TypedPath": + if self.absolute_path == "/": + raise ValueError("Path does not have a parent folder") + trimmed_path = self.absolute_path.rstrip("/") + last_idx = trimmed_path.rfind("/") + return TypedPath.of_folder(trimmed_path[: last_idx + 1]) + + def resolve_file(self, child: str) -> "TypedPath": + self.assert_folder() + if child.startswith("/") or child.endswith("/"): + raise ValueError("Child path is not valid for file resolution") + return self.of_file(f"{self.absolute_path}{child}") + + def resolve_folder(self, child: str) -> "TypedPath": + self.assert_folder() + if child.startswith("/"): + raise ValueError("Child path starts with a slash") + return self.of_folder(f"{self.absolute_path}{child}/") + + def relativize(self, child: "TypedPath") -> str: + self.assert_folder() + if not child.absolute_path.startswith(self.absolute_path): + raise ValueError(f"Expected {child} to start with {self.absolute_path}") + return child.absolute_path[len(self.absolute_path) :] + + def __eq__(self, other: object) -> bool: + if not isinstance(other, TypedPath): + return NotImplemented + return self.absolute_path == other.absolute_path + + def __lt__(self, other: "TypedPath") -> bool: + return self.absolute_path < other.absolute_path + + @classmethod + def of_folder(cls, path: str) -> "TypedPath": + unix_path = path.replace("\\", "/") + if not unix_path.endswith("/"): + unix_path += "/" + return cls(unix_path) + + @classmethod + def of_file(cls, path: str) -> "TypedPath": + unix_path = path.replace("\\", "/") + if unix_path.endswith("/"): + raise ValueError("Expected path to not end with a slash for a file") + return cls(unix_path) diff --git a/python/selfie-lib/selfie_lib/WriteTracker.py b/python/selfie-lib/selfie_lib/WriteTracker.py new file mode 100644 index 00000000..37e2cd9b --- /dev/null +++ b/python/selfie-lib/selfie_lib/WriteTracker.py @@ -0,0 +1,134 @@ +from typing import List, Optional, Generic, TypeVar, Dict +from selfie_lib.CommentTracker import SnapshotFileLayout +from abc import ABC, abstractmethod +import inspect, threading +from functools import total_ordering + +T = TypeVar("T") +U = TypeVar("U") + + +@total_ordering +class CallLocation: + def __init__(self, file_name: Optional[str], line: int): + self._file_name = file_name + self._line = line + + @property + def file_name(self) -> Optional[str]: + return self._file_name + + @property + def line(self) -> int: + return self._line + + def with_line(self, line: int) -> "CallLocation": + return CallLocation(self._file_name, line) + + def ide_link(self, layout: "SnapshotFileLayout") -> str: + return f"File: {self._file_name}, Line: {self._line}" + + def same_path_as(self, other: "CallLocation") -> bool: + if not isinstance(other, CallLocation): + return False + return self._file_name == other.file_name + + def source_filename_without_extension(self) -> str: + if self._file_name is not None: + return self._file_name.rsplit(".", 1)[0] + return "" + + def __lt__(self, other) -> bool: + if not isinstance(other, CallLocation): + return NotImplemented + return (self._file_name, self._line) < (other.file_name, other.line) + + def __eq__(self, other) -> bool: + if not isinstance(other, CallLocation): + return NotImplemented + return (self._file_name, self._line) == (other.file_name, other.line) + + +class CallStack: + def __init__(self, location: CallLocation, rest_of_stack: List[CallLocation]): + self.location = location + self.rest_of_stack = rest_of_stack + + def ide_link(self, layout: "SnapshotFileLayout") -> str: + links = [self.location.ide_link(layout)] + [ + loc.ide_link(layout) for loc in self.rest_of_stack + ] + return "\n".join(links) + + def __eq__(self, other): + if not isinstance(other, CallStack): + return NotImplemented + return ( + self.location == other.location + and self.rest_of_stack == other.rest_of_stack + ) + + def __hash__(self): + return hash((self.location, tuple(self.rest_of_stack))) + + +def recordCall(callerFileOnly: bool = False) -> CallStack: + stack_frames = inspect.stack()[1:] + + if callerFileOnly: + caller_file = stack_frames[0].filename + stack_frames = [ + frame for frame in stack_frames if frame.filename == caller_file + ] + + call_locations = [ + CallLocation(frame.filename, frame.lineno) for frame in stack_frames + ] + + location = call_locations[0] + rest_of_stack = call_locations[1:] + + return CallStack(location, rest_of_stack) + + +class FirstWrite(Generic[U]): + def __init__(self, snapshot: U, call_stack: CallStack): + self.snapshot = snapshot + self.call_stack = call_stack + + +class WriteTracker(ABC, Generic[T, U]): + def __init__(self): + self.lock = threading.Lock() + self.writes: Dict[T, FirstWrite[U]] = {} + + @abstractmethod + def record(self, key: T, snapshot: U, call: CallStack, layout: SnapshotFileLayout): + pass + + def recordInternal( + self, + key: T, + snapshot: U, + call: CallStack, + layout: SnapshotFileLayout, + allow_multiple_equivalent_writes: bool = True, + ): + with self.lock: + this_write = FirstWrite(snapshot, call) + if key not in self.writes: + self.writes[key] = this_write + return + + existing = self.writes[key] + if existing.snapshot != snapshot: + raise ValueError( + f"Snapshot was set to multiple values!\n first time: {existing.call_stack.location.ide_link(layout)}\n this time: {call.location.ide_link(layout)}\n" + ) + elif not allow_multiple_equivalent_writes: + raise ValueError("Snapshot was set to the same value multiple times.") + + +class DiskWriteTracker(WriteTracker[T, U]): + def record(self, key: T, snapshot: U, call: CallStack, layout: SnapshotFileLayout): + super().recordInternal(key, snapshot, call, layout) diff --git a/python/selfie-lib/selfie_lib/__init__.py b/python/selfie-lib/selfie_lib/__init__.py index 2cd86814..8a329004 100644 --- a/python/selfie-lib/selfie_lib/__init__.py +++ b/python/selfie-lib/selfie_lib/__init__.py @@ -1 +1,4 @@ +from .LineReader import LineReader as LineReader from .Slice import Slice as Slice +from .SourceFile import SourceFile as SourceFile +from .PerCharacterEscaper import PerCharacterEscaper as PerCharacterEscaper diff --git a/python/selfie-lib/tests/ArrayMap_test.py b/python/selfie-lib/tests/ArrayMap_test.py new file mode 100644 index 00000000..0af17e5d --- /dev/null +++ b/python/selfie-lib/tests/ArrayMap_test.py @@ -0,0 +1,147 @@ +import pytest +from selfie_lib.ArrayMap import ArrayMap + + +def assertEmpty(map): + assert len(map) == 0 + assert list(map.keys()) == [] + assert list(map.values()) == [] + assert list(map.items()) == [] + with pytest.raises(KeyError): + _ = map["key"] + assert map == {} + assert map == ArrayMap.empty() + + +def assertSingle(map, key, value): + assert len(map) == 1 + assert set(map.keys()) == {key} + assert list(map.values()) == [value] + assert set(map.items()) == {(key, value)} + assert map[key] == value + with pytest.raises(KeyError): + _ = map[key + "blah"] + assert map == {key: value} + assert map == ArrayMap.empty().plus(key, value) + + +def assertDouble(map, key1, value1, key2, value2): + assert len(map) == 2 + assert set(map.keys()) == {key1, key2} + assert list(map.values()) == [value1, value2] + assert set(map.items()) == {(key1, value1), (key2, value2)} + assert map[key1] == value1 + assert map[key2] == value2 + with pytest.raises(KeyError): + _ = map[key1 + "blah"] + assert map == {key1: value1, key2: value2} + assert map == {key2: value2, key1: value1} + assert map == ArrayMap.empty().plus(key1, value1).plus(key2, value2) + assert map == ArrayMap.empty().plus(key2, value2).plus(key1, value1) + + +def assertTriple(map, key1, value1, key2, value2, key3, value3): + assert len(map) == 3 + assert set(map.keys()) == {key1, key2, key3} + assert list(map.values()) == [value1, value2, value3] + assert set(map.items()) == {(key1, value1), (key2, value2), (key3, value3)} + assert map[key1] == value1 + assert map[key2] == value2 + assert map[key3] == value3 + with pytest.raises(KeyError): + _ = map[key1 + "blah"] + assert map == {key1: value1, key2: value2, key3: value3} + assert map == ArrayMap.empty().plus(key1, value1).plus(key2, value2).plus( + key3, value3 + ) + + +def test_empty(): + assertEmpty(ArrayMap.empty()) + + +def test_single(): + empty = ArrayMap.empty() + single = empty.plus("one", "1") + assertEmpty(empty) + assertSingle(single, "one", "1") + + +def test_double(): + empty = ArrayMap.empty() + single = empty.plus("one", "1") + double = single.plus("two", "2") + assertEmpty(empty) + assertSingle(single, "one", "1") + assertDouble(double, "one", "1", "two", "2") + assertDouble(single.plus("a", "sorted"), "a", "sorted", "one", "1") + + with pytest.raises(ValueError) as context: + single.plus("one", "2") + assert str(context.value) == "Key already exists" + + +def test_triple(): + triple = ArrayMap.empty().plus("1", "one").plus("2", "two").plus("3", "three") + assertTriple(triple, "1", "one", "2", "two", "3", "three") + + +def test_multi(): + test_triple() # Calling another test function directly is unusual but works + triple = ArrayMap.empty().plus("2", "two").plus("3", "three").plus("1", "one") + assertTriple(triple, "1", "one", "2", "two", "3", "three") + triple = ArrayMap.empty().plus("3", "three").plus("1", "one").plus("2", "two") + assertTriple(triple, "1", "one", "2", "two", "3", "three") + + +def test_minus_sorted_indices(): + initial_map = ( + ArrayMap.empty() + .plus("1", "one") + .plus("2", "two") + .plus("3", "three") + .plus("4", "four") + ) + modified_map = initial_map.minus_sorted_indices([1, 3]) + assert len(modified_map) == 2 + assert list(modified_map.keys()) == ["1", "3"] + assert list(modified_map.values()) == ["one", "three"] + with pytest.raises(KeyError): + _ = modified_map["2"] + with pytest.raises(KeyError): + _ = modified_map["4"] + assert modified_map == {"1": "one", "3": "three"} + + +def test_plus_with_existing_keys(): + map_with_duplicates = ArrayMap.empty().plus("a", "alpha").plus("b", "beta") + with pytest.raises(ValueError): + map_with_duplicates.plus("a", "new alpha") + updated_map = map_with_duplicates.plus("c", "gamma") + assert len(updated_map) == 3 + assert updated_map["a"] == "alpha" + assert updated_map["b"] == "beta" + assert updated_map["c"] == "gamma" + modified_map = map_with_duplicates.minus_sorted_indices([0]).plus( + "a", "updated alpha" + ) + assert len(modified_map) == 2 + assert modified_map["a"] == "updated alpha" + assert modified_map["b"] == "beta" + + +def test_map_length(): + map = ArrayMap.empty() + assert len(map) == 0, "Length should be 0 for an empty map" + map = map.plus("key1", "value1") + assert len(map) == 1, "Length should be 1 after adding one item" + map = map.plus("key2", "value2") + assert len(map) == 2, "Length should be 2 after adding another item" + map = map.plus("key3", "value3") + assert len(map) == 3, "Length should be 3 after adding a third item" + map = map.minus_sorted_indices([1]) + assert len(map) == 2, "Length should be 2 after removing one item" + map = map.minus_sorted_indices([0]) + assert len(map) == 1, "Length should be 1 after removing another item" + map = map.minus_sorted_indices([0]) + assert len(map) == 0, "Length should be 0 after removing all items" diff --git a/python/selfie-lib/tests/LineReader_test.py b/python/selfie-lib/tests/LineReader_test.py new file mode 100644 index 00000000..95c40453 --- /dev/null +++ b/python/selfie-lib/tests/LineReader_test.py @@ -0,0 +1,49 @@ +from selfie_lib import LineReader + + +def test_should_find_unix_separator_from_binary(): + reader = LineReader.for_binary(b"This is a new line\n") + assert reader.unix_newlines() is True + assert reader.read_line() == "This is a new line" + + +def test_should_find_windows_separator_from_binary(): + reader = LineReader.for_binary(b"This is a new line\r\n") + assert reader.unix_newlines() is False + assert reader.read_line() == "This is a new line" + + +def test_should_find_unix_separator_from_string(): + reader = LineReader.for_string("This is a new line\n") + assert reader.unix_newlines() is True + assert reader.read_line() == "This is a new line" + + +def test_should_find_windows_separator_from_string(): + reader = LineReader.for_string("This is a new line\r\n") + assert reader.unix_newlines() is False + assert reader.read_line() == "This is a new line" + + +def test_should_get_unix_line_separator_when_there_is_none(): + reader = LineReader.for_binary(b"This is a new line") + assert reader.unix_newlines() is True + assert reader.read_line() == "This is a new line" + + +def test_should_read_next_line_without_problem(): + reader = LineReader.for_binary(b"First\r\nSecond\r\n") + assert reader.unix_newlines() is False + assert reader.read_line() == "First" + assert reader.unix_newlines() is False + assert reader.read_line() == "Second" + assert reader.unix_newlines() is False + + +def test_should_use_first_line_separator_and_ignore_next(): + reader = LineReader.for_binary(b"First\r\nAnother separator\n") + assert reader.unix_newlines() is False + assert reader.read_line() == "First" + assert reader.unix_newlines() is False + assert reader.read_line() == "Another separator" + assert reader.unix_newlines() is False diff --git a/python/selfie-lib/tests/LiteralBoolean_test.py b/python/selfie-lib/tests/LiteralBoolean_test.py new file mode 100644 index 00000000..ef84b987 --- /dev/null +++ b/python/selfie-lib/tests/LiteralBoolean_test.py @@ -0,0 +1,26 @@ +from selfie_lib.Literals import LiteralBoolean, Language +from selfie_lib.EscapeLeadingWhitespace import EscapeLeadingWhitespace + + +def _encode(value: bool, expected: str): + literal_boolean = LiteralBoolean() + actual = literal_boolean.encode( + value, Language.PYTHON, EscapeLeadingWhitespace.NEVER + ) + assert actual == expected, f"Expected: {expected}, Got: {actual}" + + +def _decode(value: str, expected: bool): + literal_boolean = LiteralBoolean() + actual = literal_boolean.parse(value, Language.PYTHON) + assert actual == expected, f"Expected: {expected}, Got: {actual}" + + +class TestLiteralBoolean: + def test_encode(self): + _encode(True, "True") + _encode(False, "False") + + def test_decode(self): + _decode("true", True) + _decode("false", False) diff --git a/python/selfie-lib/tests/LiteralInt_test.py b/python/selfie-lib/tests/LiteralInt_test.py new file mode 100644 index 00000000..b28577f4 --- /dev/null +++ b/python/selfie-lib/tests/LiteralInt_test.py @@ -0,0 +1,52 @@ +from selfie_lib.Literals import LiteralInt, Language +from selfie_lib.EscapeLeadingWhitespace import EscapeLeadingWhitespace + + +def _encode(value: int, expected: str): + literal_int = LiteralInt() + actual = literal_int.encode(value, Language.PYTHON, EscapeLeadingWhitespace.NEVER) + assert actual == expected, f"Expected '{expected}', but got '{actual}'" + + +def _decode(value: str, expected: int): + literal_int = LiteralInt() + actual = literal_int.parse(value, Language.PYTHON) + assert actual == expected, f"Expected '{expected}', but got '{actual}'" + + +class TestLiteralInt: + def test_encode(self): + test_cases = [ + (0, "0"), + (1, "1"), + (-1, "-1"), + (999, "999"), + (-999, "-999"), + (1_000, "1_000"), + (-1_000, "-1_000"), + (1_000_000, "1_000_000"), + (-1_000_000, "-1_000_000"), + (2400500, "2_400_500"), + (2400501, "2_400_501"), + (200, "200"), + (1001, "1_001"), + (1010, "1_010"), + (10010, "10_010"), + ] + for value, expected in test_cases: + _encode(value, expected) + + def test_decode(self): + test_cases = [ + ("0", 0), + ("1", 1), + ("-1", -1), + ("999", 999), + ("9_99", 999), + ("9_9_9", 999), + ("-999", -999), + ("-9_99", -999), + ("-9_9_9", -999), + ] + for value, expected in test_cases: + _decode(value, expected) diff --git a/python/selfie-lib/tests/PerCharacterEscaper_test.py b/python/selfie-lib/tests/PerCharacterEscaper_test.py new file mode 100644 index 00000000..87f8fec5 --- /dev/null +++ b/python/selfie-lib/tests/PerCharacterEscaper_test.py @@ -0,0 +1,88 @@ +import pytest + +from selfie_lib import PerCharacterEscaper + + +class TestPerCharacterEscaper: + def test_performance_optimization_self(self): + escaper = PerCharacterEscaper.self_escape("`123") + abc = "abc" + # Correct use of 'is' for checking object identity. + assert ( + escaper.escape(abc) is abc + ), "Escape should return the original object when no change is made" + assert ( + escaper.unescape(abc) is abc + ), "Unescape should return the original object when no change is made" + + # Use '==' for checking value equality. + assert ( + escaper.escape("1") == "`1" + ), "Escaping '1' should prepend the escape character" + assert ( + escaper.escape("`") == "``" + ), "Escaping the escape character should duplicate it" + assert ( + escaper.escape("abc123`def") == "abc`1`2`3``def" + ), "Escaping 'abc123`def' did not produce the expected result" + + assert escaper.unescape("`1") == "1", "Unescaping '`1' should produce '1'" + assert escaper.unescape("``") == "`", "Unescaping '``' should produce '`'" + assert ( + escaper.unescape("abc`1`2`3``def") == "abc123`def" + ), "Unescaping 'abc`1`2`3``def' did not produce the expected result" + + def test_performance_optimization_specific(self): + escaper = PerCharacterEscaper.specified_escape("`a1b2c3d") + abc = "abc" + # Correct use of 'is' for object identity. + assert ( + escaper.escape(abc) is abc + ), "Escape should return the original object when no change is made" + assert ( + escaper.unescape(abc) is abc + ), "Unescape should return the original object when no change is made" + + # Use '==' for value equality. + assert escaper.escape("1") == "`b", "Escaping '1' should produce '`b'" + assert escaper.escape("`") == "`a", "Escaping '`' should produce '`a'" + assert ( + escaper.escape("abc123`def") == "abc`b`c`d`adef" + ), "Escaping 'abc123`def' did not produce the expected result" + + assert escaper.unescape("`b") == "1", "Unescaping '`b' should produce '1'" + assert escaper.unescape("`a") == "`", "Unescaping '`a' should produce '`'" + assert ( + escaper.unescape("abc`1`2`3``def") == "abc123`def" + ), "Unescaping 'abc`1`2`3``def' did not produce the expected result" + + def test_corner_cases_self(self): + escaper = PerCharacterEscaper.self_escape("`123") + with pytest.raises(ValueError) as excinfo: + escaper.unescape("`") + assert ( + str(excinfo.value) + == "Escape character '`' can't be the last character in a string." + ), "Unescaping a string ending with a single escape character should raise ValueError" + assert escaper.unescape("`a") == "a", "Unescaping '`a' should produce 'a'" + + def test_corner_cases_specific(self): + escaper = PerCharacterEscaper.specified_escape("`a1b2c3d") + with pytest.raises(ValueError) as excinfo: + escaper.unescape("`") + assert ( + str(excinfo.value) + == "Escape character '`' can't be the last character in a string." + ), "Unescaping a string ending with a single escape character should raise ValueError" + assert escaper.unescape("`e") == "e", "Unescaping '`e' should produce 'e'" + + def test_roundtrip(self): + escaper = PerCharacterEscaper.self_escape("`<>") + + def roundtrip(str): + assert ( + escaper.unescape(escaper.escape(str)) == str + ), f"Roundtrip of '{str}' did not return the original string" + + roundtrip("") + roundtrip("~`/") diff --git a/python/selfie-lib/tests/RecordCall_test.py b/python/selfie-lib/tests/RecordCall_test.py new file mode 100644 index 00000000..cb9b6980 --- /dev/null +++ b/python/selfie-lib/tests/RecordCall_test.py @@ -0,0 +1,50 @@ +from unittest.mock import Mock +from selfie_lib.WriteTracker import CallLocation, CallStack, recordCall +import os + + +def test_call_location_ide_link(): + layout = Mock() + location = CallLocation(file_name="example.py", line=10) + expected_link = "File: example.py, Line: 10" + + assert location.ide_link(layout) == expected_link + + +def test_call_stack_ide_link(): + layout = Mock() + location1 = CallLocation(file_name="example1.py", line=10) + location2 = CallLocation(file_name="example2.py", line=20) + call_stack = CallStack(location=location1, rest_of_stack=[location2]) + + expected_links = "File: example1.py, Line: 10\nFile: example2.py, Line: 20" + assert call_stack.ide_link(layout) == expected_links + + +def test_record_call_with_caller_file_only_false(): + call_stack = recordCall(False) + + assert ( + len(call_stack.rest_of_stack) > 0 + ), "Expected the rest of stack to contain more than one CallLocation" + + expected_call_location_str = "File: RecordCall_test.py, Line: 25" + + if call_stack.location.file_name is not None: + actual_file_name = os.path.basename(call_stack.location.file_name) + actual_call_location_str = ( + f"File: {actual_file_name}, Line: {call_stack.location.line}" + ) + else: + actual_call_location_str = "File name is None" + + assert ( + actual_call_location_str == expected_call_location_str + ), f"Expected call location to be '{expected_call_location_str}' but was '{actual_call_location_str}'" + + +def test_record_call_with_caller_file_only_true(): + call_stack = recordCall(True) + assert ( + len(call_stack.rest_of_stack) >= 0 + ), "Expected the rest of stack to potentially contain only the caller's file location" diff --git a/python/selfie-lib/tests/Slice_test.py b/python/selfie-lib/tests/Slice_test.py index fad1af52..42968e7c 100644 --- a/python/selfie-lib/tests/Slice_test.py +++ b/python/selfie-lib/tests/Slice_test.py @@ -1,13 +1,14 @@ from selfie_lib import Slice + def test_unixLine(): slice_1 = Slice("A single line") assert str(slice_1.unixLine(1)) == "A single line" - + one_two_three = Slice("\nI am the first\nI, the second\n\nFOURTH\n") assert str(one_two_three.unixLine(1)) == "" assert str(one_two_three.unixLine(2)) == "I am the first" assert str(one_two_three.unixLine(3)) == "I, the second" assert str(one_two_three.unixLine(4)) == "" assert str(one_two_three.unixLine(5)) == "FOURTH" - assert str(one_two_three.unixLine(6)) == "" \ No newline at end of file + assert str(one_two_three.unixLine(6)) == "" diff --git a/python/selfie-lib/tests/SourceFile_test.py b/python/selfie-lib/tests/SourceFile_test.py new file mode 100644 index 00000000..221f2718 --- /dev/null +++ b/python/selfie-lib/tests/SourceFile_test.py @@ -0,0 +1,90 @@ +from selfie_lib import SourceFile + + +def python_test(source_raw, function_call_plus_arg_raw, arg_raw=""): + source = source_raw.replace("'", '"') + function_call_plus_arg = function_call_plus_arg_raw.replace("'", '"') + arg = arg_raw.replace("'", '"') + parsed = SourceFile("UnderTest.py", source) + to_be_literal = parsed.parse_to_be_like(1) + assert to_be_literal._get_function_call_plus_arg() == function_call_plus_arg + assert to_be_literal._get_arg() == arg + + +def python_test_error(source_raw, error_msg): + try: + python_test(source_raw, "unusedArg") + except AssertionError as e: + assert str(e) == error_msg + + +def todo(): + python_test(".toBe_TODO()", ".toBe_TODO()", "") + python_test(" .toBe_TODO() ", ".toBe_TODO()", "") + python_test(" .toBe_TODO( ) ", ".toBe_TODO( )", "") + python_test(" .toBe_TODO( \n ) ", ".toBe_TODO( \n )", "") + + +def numeric(): + python_test(".toBe(7)", ".toBe(7)", "7") + python_test(" .toBe(7)", ".toBe(7)", "7") + python_test(".toBe(7) ", ".toBe(7)", "7") + python_test(" .toBe(7) ", ".toBe(7)", "7") + python_test(" .toBe( 7 ) ", ".toBe( 7 )", "7") + python_test(" .toBe(\n7) ", ".toBe(\n7)", "7") + python_test(" .toBe(7\n) ", ".toBe(7\n)", "7") + + +def single_line_string(): + python_test(".toBe('7')", "'7'") + python_test(".toBe('')", "''") + python_test(".toBe( '' )", "''") + python_test(".toBe( \n '' \n )", "''") + python_test(".toBe( \n '78' \n )", "'78'") + python_test(".toBe('\\'')", "'\\''") + + +def multi_line_string(): + python_test(".toBe('''7''')", "'''7'''") + python_test(".toBe(''' 7 ''')", "''' 7 '''") + python_test(".toBe('''\n7\n''')", "'''\n7\n'''") + python_test(".toBe(''' ' '' ' ''')", "''' ' '' ' '''") + + +def error_unclosed(): + python_test_error( + ".toBe(", "Appears to be an unclosed function call `.toBe()` on line 1" + ) + python_test_error( + ".toBe( \n ", "Appears to be an unclosed function call `.toBe()` on line 1" + ) + python_test_error( + ".toBe_TODO(", + "Appears to be an unclosed function call `.toBe_TODO()` on line 1", + ) + python_test_error( + ".toBe_TODO( \n ", + "Appears to be an unclosed function call `.toBe_TODO()` on line 1", + ) + python_test_error( + ".toBe_TODO(')", 'Appears to be an unclosed string literal `"` on line 1' + ) + python_test_error( + ".toBe_TODO(''')", + 'Appears to be an unclosed multiline string literal `"""` on line 1', + ) + + +def error_non_primitive(): + python_test_error( + ".toBe(1 + 1)", + "Non-primitive literal in `.toBe()` starting at line 1: error for character `+` on line 1", + ) + python_test_error( + ".toBe('1' + '1')", + "Non-primitive literal in `.toBe()` starting at line 1: error for character `+` on line 1", + ) + python_test_error( + ".toBe('''1''' + '''1''')", + "Non-primitive literal in `.toBe()` starting at line 1: error for character `+` on line 1", + ) diff --git a/python/selfie-lib/tests/TypedPath_test.py b/python/selfie-lib/tests/TypedPath_test.py new file mode 100644 index 00000000..493e1c18 --- /dev/null +++ b/python/selfie-lib/tests/TypedPath_test.py @@ -0,0 +1,89 @@ +import pytest +from selfie_lib.TypedPath import TypedPath + + +def test_initialization(): + path = TypedPath("/home/user/") + assert path.absolute_path == "/home/user/" + assert path.is_folder + assert path.name == "user" + + +def test_parent_folder(): + path = TypedPath("/home/user/documents/") + parent = path.parent_folder() + assert isinstance(parent, TypedPath) + assert parent.absolute_path == "/home/user/" + + +def test_resolve_file(): + folder = TypedPath("/home/user/") + file = folder.resolve_file("document.txt") + assert file.absolute_path == "/home/user/document.txt" + assert not file.is_folder + assert file.name == "document.txt" + + +def test_resolve_folder(): + folder = TypedPath("/home/user/") + subfolder = folder.resolve_folder("documents") + assert subfolder.absolute_path == "/home/user/documents/" + assert subfolder.is_folder + assert subfolder.name == "documents" + + +def test_relativize(): + folder = TypedPath("/home/user/") + file = TypedPath("/home/user/document.txt") + relative_path = folder.relativize(file) + assert relative_path == "document.txt" + + +def test_of_folder_class_method(): + folder = TypedPath.of_folder("/home/user/documents") + assert folder.absolute_path == "/home/user/documents/" + assert folder.is_folder + + +def test_of_file_class_method(): + file = TypedPath.of_file("/home/user/document.txt") + assert file.absolute_path == "/home/user/document.txt" + assert not file.is_folder + + +def test_assert_folder_failure(): + with pytest.raises(AssertionError): + file = TypedPath("/home/user/document.txt") + file.assert_folder() + + +def test_parent_folder_failure(): + with pytest.raises(ValueError): + path = TypedPath("/") + path.parent_folder() + + +def test_equality(): + path1 = TypedPath("/home/user/") + path2 = TypedPath("/home/user/") + assert path1 == path2 + + +def test_inequality(): + path1 = TypedPath("/home/user/") + path2 = TypedPath("/home/another_user/") + assert path1 != path2 + + +def test_ordering(): + path1 = TypedPath("/home/a/") + path2 = TypedPath("/home/b/") + assert path1 < path2 + assert path2 > path1 + + +def test_relativize_error(): + parent = TypedPath("/home/user/") + child = TypedPath("/home/another_user/document.txt") + with pytest.raises(ValueError): + parent.relativize(child)