diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..79dc1af --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,42 @@ +# Changelog for ``thsl`` + +### Current Version: 0.1.0 + +# Release Notes + +### 0.1.0 +Initial Release + +#### Added +- Comments +- Trailing Commas +- Supports loading a thsl file into Python with the following types + - Dicts + - Lists + - Sets + - Tuples + - Ints + - Bytes + - Strings + - Chars + - Bools + - Ints + - Floats + - Decimals + - Strings + - Chars + - Binary + - Hexes + - Octals + - Base64 + - Complex numbers + - Ranges + - Dates + - DateTimes + - Intervals + - IP Addresses + - URLs + - Environment Variables + - Paths + - Semantic Version Numbers + - Regex patterns diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..251b855 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at postanthony3000@gmail.com. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..855e4d7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,86 @@ +# Contributing + +When contributing to this repository, please first discuss the change you wish to make +via issue, email, or any other method with the owners of this repository before making +a change. + +Please note we have a code of conduct, please follow it in all your interactions with +the project. + +## Pull Requests + +Please use the autoformatters iSort and Black and validate the code using MyPy. It is +not always possible to make MyPy entirely happy without silencing some type errors but +at least do what you can and use the comment `# type: ignore` sparingly. Lastly, try to +provide the most acurate type hints possible. + +## Code of Conduct + +### Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and +maintainers pledge to making participation in our project and our community a +harassment-free experience for everyone, regardless of age, body size, disability, +ethnicity, gender identity and expression, level of experience, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +### Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery for unwelcome sexual attention or advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional + setting + +### Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior +and are expected to take appropriate and fair corrective action in response to any +instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are not +aligned to this Code of Conduct, or to ban temporarily or permanently any contributor +for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +### Scope + +This Code of Conduct applies both within project spaces and in public spaces when an +individual is representing the project or its community. Examples of representing a +project or community include using an official project e-mail address, posting via an +official social media account, or acting as an appointed representative at an online or +offline event. Representation of a project may be further defined and clarified by +project maintainers. + +### Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by +contacting the project team at postanthony3000@gmail.com. All complaints will be +reviewed and investigated and will result in a response that is deemed necessary and +appropriate to the circumstances. The project team is obligated to maintain +confidentiality with regard to the reporter of an incident. Further details of specific +enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may +face temporary or permanent repercussions as determined by other members of the +project's leadership. + +### Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at [http://contributor-covenant.org/version/1/4][version] + +[homepage]: http://contributor-covenant.org +[version]: http://contributor-covenant.org/version/1/4/ diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..46e1340 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Anthony Nelson Post + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..d3222c8 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,3 @@ +recursive-include thsl *.py +include requirements.txt +include CHANGELOG.md diff --git a/README.md b/README.md index 7b9a39c..d9d2cdc 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,33 @@ Indentation is done via tabs only See [sample.thsl](sample.thsl) for more. +## Install +This is currently a beta level project + +```commandline +pip install thsl +``` + +## Usage +```python +>>> from pathlib import Path +>>> import thsl + +>>> data = thsl.load(Path("data.thsl")) +{ + 'debug': False, + 'name': 'My Name', + 'graphics': { + 'target_framerate': 60, + 'fullscreen': False, + 'resolution': { + 'width': 1920, + 'height': 1080 + } + } +} +``` + ## Features Not finalized. Subject to change @@ -64,12 +91,16 @@ Not finalized. Subject to change - [x] Ranges - [x] exclusive - [x] inclusive - - [x] Dates - - [x] Times - - [x] DateTimes - - [x] Intervals + - [x] Dates (with the help of the dateutil library) + - [x] Times (with the help of the dateutil library) + - [x] DateTimes (with the help of the dateutil library) + - [x] Intervals (with the help of the tempora library) - [x] IP Addresses - [x] URLs + - [x] Environment Variables + - [x] Paths + - [x] Semantic Version Numbers (using the semantic_version library) + - [x] Regex - Inheritance - Interfaces - Type Aliases @@ -85,8 +116,6 @@ Not finalized. Subject to change - conversion would be lossy unless only compatible types are used - YAML or JSON input - zlib (de)compression -- regex -- semantic version numbers - type addon system ## Benefits @@ -132,6 +161,7 @@ maintain it in one place. - [ ] string templating ## Other TODO: +- [ ] dump to file - [ ] Finalize grammar and token structure - [ ] Python code generation - [ ] Code generation for other languages diff --git a/requirements.txt b/requirements.txt index 7630bf3..0a1d5dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ python-dateutil==2.8.2 tempora==4.1.1 +semantic_version==2.8.5 diff --git a/sample.thsl b/sample.thsl index 4e932d3..2eb1e71 100644 --- a/sample.thsl +++ b/sample.thsl @@ -8,7 +8,7 @@ name_single_quotes .str: 'My name is {name}' # single quotes allow templating escaping .str: "My \"Name " # currently the colon is not required but it helps with readability -less_readable .int -3 +less_readable .int 3 # curly braces need escaping to be included in single quotes escaping_single_quotes .str: 'My name is \{{name}\}' @@ -25,7 +25,10 @@ graphics: height .int: 1080 key .str # defaults to empty string key_without_value .float # defaults to 0 -# null is not an option by default, a nullable decorator is available to mark as such + + # null is not an option by default, a nullable decorator is available to mark as such + @nullable + nullable_key .float # will be null # indentation has to be a tab a_char .char: a # can only be a single character @@ -36,7 +39,7 @@ string is lines. " -"keys can be in quotes" .int: 1 # for keys to have spaces it must be in quotes +"keys can be in quotes" .int: 1 # for a key to have spaces it must be in quotes # no need to worry about using type identifiers as keys or values int .int: 1 @@ -124,16 +127,15 @@ dict_one_liner: {one .int: 1, two .float: 2} # structs are very similar to dictionaries but can be handled differently by the consuming language # Python could create a dataclass for example -# either dynamically or human readable code can be generated +# either dynamically or human-readable code can be generated MyStruct .struct: - nested_struct .struct: + NestedStruct .struct: first .int: 1 # provided values will be defaults for a struct second .int: 2 - enum_dict .dict: # dict type is optional + my_dict: third .float: 3 fourth: .str: 4 key .str: value - key_without_value .float recursion .MyStruct # maybe, maybe not AnotherStruct .struct: diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..e132566 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,7 @@ +[mypy] +disallow_untyped_defs = True +disallow_untyped_calls = True +disallow_incomplete_defs = True +check_untyped_defs = True +disallow_untyped_decorators = True +pretty = True diff --git a/setup.py b/setup.py index 624c68a..6c6a0f7 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ with open('CHANGELOG.md', 'r') as fh: for line in fh.readlines(): if 'Current Version: ' in line: - version = line.replace('Current Version: ', '') + version = line.replace('Current Version: ', '').strip() break with open("README.md", "r") as fh: @@ -12,8 +12,11 @@ with open("requirements.txt", "r") as fh: requirements = fh.readlines() -with open("dev_requirements.txt", "r") as fh: - dev_requirements = fh.readlines() +try: + with open("dev_requirements.txt", "r") as fh: + dev_requirements = fh.readlines() +except FileNotFoundError: + dev_requirements = [] setup( name="thsl", diff --git a/tests/test_lexer.py b/tests/test_lexer.py index 9d39c72..de4564a 100644 --- a/tests/test_lexer.py +++ b/tests/test_lexer.py @@ -1,7 +1,7 @@ import pytest -from src.grammar import TokenType -from src.lexer import Lexer, Token +from thsl.grammar import TokenType +from thsl.lexer import Lexer, Token @pytest.fixture diff --git a/thsl/__init__.py b/thsl/__init__.py new file mode 100644 index 0000000..f110b88 --- /dev/null +++ b/thsl/__init__.py @@ -0,0 +1,19 @@ +from pathlib import Path + +from thsl.exceptions import ThslLoadError +from thsl.src.compiler import Compiler +from typing import TextIO + + +def loads(text: str) -> dict: + compiler = Compiler(text) + try: + return compiler.compile() + except Exception as err: + raise ThslLoadError from err + + +def load(file_path: TextIO | Path) -> dict: + if isinstance(file_path, Path): + return loads(file_path.open().read()) + return loads(file_path.read()) diff --git a/thsl/exceptions.py b/thsl/exceptions.py new file mode 100644 index 0000000..8f0c992 --- /dev/null +++ b/thsl/exceptions.py @@ -0,0 +1,4 @@ +class ThslLoadError(Exception): + """ + Raised when there is any error when trying to parse the thsl text to a valid object + """ diff --git a/src/__init__.py b/thsl/src/__init__.py similarity index 100% rename from src/__init__.py rename to thsl/src/__init__.py diff --git a/src/abstract_syntax_tree.py b/thsl/src/abstract_syntax_tree.py similarity index 95% rename from src/abstract_syntax_tree.py rename to thsl/src/abstract_syntax_tree.py index 3a8c598..abb9775 100644 --- a/src/abstract_syntax_tree.py +++ b/thsl/src/abstract_syntax_tree.py @@ -1,6 +1,6 @@ from dataclasses import dataclass, field -from src.grammar import DataTypes, Operators +from thsl.src.grammar import DataTypes, Operators @dataclass diff --git a/src/compiler.py b/thsl/src/compiler.py similarity index 88% rename from src/compiler.py rename to thsl/src/compiler.py index d9d0bee..b1f8840 100644 --- a/src/compiler.py +++ b/thsl/src/compiler.py @@ -9,12 +9,14 @@ from pprint import pprint from typing import Any, Optional +import re +import semantic_version import tempora from dateutil import parser as dateutil -from src.grammar import DataTypes -from src.parser import Parser -from src.abstract_syntax_tree import Key, Value, Collection, Void +from thsl.src.grammar import DataTypes +from thsl.src.parser import Parser +from thsl.src.abstract_syntax_tree import Key, Value, Collection, Void class Compiler: @@ -137,6 +139,12 @@ def cast_scalar(self, value: str | Void, cast_type: DataTypes) -> Any: result = range(int(value[0]), int(value[-1])) case DataTypes.ENV: result = os.getenv(value) + case DataTypes.PATH: + result = Path(value) + case DataTypes.SEMVER: + result = semantic_version.Version(value) + case DataTypes.REGEX: + result = re.compile(value) return result def get_default_value(self, cast_type: DataTypes) -> Any: @@ -185,8 +193,20 @@ def get_default_value(self, cast_type: DataTypes) -> Any: return range(1) case DataTypes.ENV: return "" - case DataTypes.DICT | DataTypes.LIST | DataTypes.SET | DataTypes.TUPLE: + case DataTypes.PATH: + return Path() + case DataTypes.SEMVER: + return semantic_version.Version('0.0.0') + case DataTypes.SEMVER: + return re.compile('') + case DataTypes.DICT: return {} + case DataTypes.LIST: + return [] + case DataTypes.SET: + return set() + case DataTypes.TUPLE: + return tuple() raise NotImplementedError( f"Still need to add default for type {cast_type.value}" ) @@ -196,7 +216,7 @@ def compile(self) -> dict: if __name__ == "__main__": - file = Path('../test.thsl') + file = Path('../../test.thsl') compiler = Compiler(file) data = compiler.compile() pprint(data) diff --git a/src/grammar.py b/thsl/src/grammar.py similarity index 97% rename from src/grammar.py rename to thsl/src/grammar.py index ad27273..fc43222 100644 --- a/src/grammar.py +++ b/thsl/src/grammar.py @@ -34,6 +34,9 @@ class DataTypes(EnumDict): IP_NETWORK = "network" URL = "url" ENV = "env" + PATH = "path" + SEMVER = "semver" + REGEX = "regex" LIST = "list" SET = "set" diff --git a/src/lexer.py b/thsl/src/lexer.py similarity index 93% rename from src/lexer.py rename to thsl/src/lexer.py index bae215d..48c000e 100644 --- a/src/lexer.py +++ b/thsl/src/lexer.py @@ -3,7 +3,7 @@ from pathlib import Path from typing import Optional, Iterator, Literal -from src.grammar import ( +from thsl.src.grammar import ( TokenType, DataTypes, Operators, @@ -108,6 +108,10 @@ def text(self, text: str) -> None: def _current_state(self) -> LexarState: return self._type_stack[-1] + @property + def _len(self) -> int: + return len(self._text) + def _make_token( self, token_type: TokenType, @@ -129,7 +133,7 @@ def _make_token( def _next_char(self) -> None: self._pos += 1 self._column += 1 - if self._pos > len(self.text) - 1: + if self._pos > self._len - 1: self._current_char = None self._char_type = None else: @@ -139,7 +143,7 @@ def _next_char(self) -> None: def _skip_char(self) -> None: self._pos += 1 self._column += 1 - if self._pos > len(self.text) - 1: + if self._pos > self._len - 1: self._current_char = None self._char_type = None else: @@ -154,10 +158,14 @@ def _reset_word(self) -> str: def _peek(self, num: int, ignore_whitespace: bool = False) -> Optional[str]: peek_pos = self._pos + num if ignore_whitespace: - while peek_pos > len(self.text) - 1 and self.text[peek_pos].isspace(): + while ( + peek_pos < self._len - 1 + and self.text[peek_pos].isspace() + and self.text[peek_pos] != TokenType.NEWLINE.value + ): peek_pos += 1 return self.text[peek_pos] - if peek_pos > len(self.text) - 1: + if peek_pos > self._len - 1: return None else: return self.text[peek_pos] @@ -449,8 +457,8 @@ def _get_type(char) -> TokenType: def _get_next_token(self) -> Token: if ( - self._current_char == Operators.LIST_DELIMITER.value - and self._peek(1) == Operators.LIST_DELIMITER.value + self._current_char == Operators.LIST_DELIMITER.value + and self._peek(1) == Operators.LIST_DELIMITER.value ): raise SyntaxError( f"Exected value line={self._line_num} column={self._column + 1}" @@ -460,7 +468,14 @@ def _get_next_token(self) -> Token: return self._eof() if self._current_char == Operators.VALUE_DELIMITER.value: - self._skip_char() + peek = self._peek(1, True) + if self._current_data_type is None and ( + peek == TokenType.NEWLINE.value or peek == TokenType.COMMENT.value + ): + self._next_char() + return self._make_token(TokenType.TYPE, DataTypes.DICT.value) + else: + self._skip_char() if self._current_char == TokenType.NEWLINE.value: return self._eat_newline() @@ -490,7 +505,10 @@ def _get_next_token(self) -> Token: if not self._word_type: self._word_type = self._char_type - if self._word_type == TokenType.OPERATOR: + if ( + self._word_type == TokenType.OPERATOR + and self._current_data_type not in (DataTypes.PATH, DataTypes.REGEX) + ): return self._eat_operator() if self._current_data_type in ( @@ -505,6 +523,8 @@ def _get_next_token(self) -> Token: DataTypes.BASE64, DataTypes.BASE64E, DataTypes.RANGE, + DataTypes.PATH, + DataTypes.REGEX, ): return self._eat_rest_of_line() @@ -535,11 +555,12 @@ def analyze(self) -> Iterator[Token]: yield token def parse(self) -> list[Token]: - return list(self.analyze()) + tokens = list(self.analyze()) + return [token for token in tokens] if __name__ == "__main__": - file = Path("../test.thsl") + file = Path("../../test.thsl") lexer = Lexer(file.open().read()) for t in lexer.parse(): print(t) diff --git a/src/parser.py b/thsl/src/parser.py similarity index 94% rename from src/parser.py rename to thsl/src/parser.py index 45a6d24..f0d6e0b 100644 --- a/src/parser.py +++ b/thsl/src/parser.py @@ -2,9 +2,9 @@ from pprint import pprint from typing import Optional -from src.abstract_syntax_tree import Collection, Key, Value, AST, Void -from src.grammar import TokenType, DataTypes, Operators -from src.lexer import Lexer, Token +from thsl.src.abstract_syntax_tree import Collection, Key, Value, AST, Void +from thsl.src.grammar import TokenType, DataTypes, Operators +from thsl.src.lexer import Lexer, Token class Parser: @@ -112,6 +112,8 @@ def eat_key(self) -> Optional[Key]: self.next_token() key_type = self.eat_type() self.next_token() + if key_type == DataTypes.DICT and self.type == TokenType.NEWLINE: + self.next_token() if self.type == TokenType.OPERATOR: value = self.eat_operator() elif self.indent > self._indent: @@ -173,6 +175,6 @@ def eat_operator(self) -> Collection: if __name__ == "__main__": - file = Path("../test.thsl") + file = Path("../../test.thsl") parser = Parser(file) pprint(parser.parse())