diff --git a/.github/workflows/ci_pytests.yml b/.github/workflows/ci_pytests.yml new file mode 100644 index 0000000..c4660fa --- /dev/null +++ b/.github/workflows/ci_pytests.yml @@ -0,0 +1,29 @@ +#------------------------------------------------ +# GitHub Action Run Tests +# +# Copyleft (c) by Egor Ovchinnikov 2023. +# +name: Ci-Pytests + +on: [ push ] + +jobs: + run_tests: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [ "3.10" ] + + steps: + - name: Copy directory from git + uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python3 -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run tests + run: python -m pytest diff --git a/.github/workflows/ci_test_for_main.yml b/.github/workflows/ci_test_for_main.yml new file mode 100644 index 0000000..c91e63d --- /dev/null +++ b/.github/workflows/ci_test_for_main.yml @@ -0,0 +1,39 @@ +name: Run tests + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + name: Run tests on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + python-version: [ "3.10" ] + os: [ ubuntu-latest, windows-latest, macos-latest ] + steps: + - name: Set up Git repository + uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt + pip list + + - name: Run tests with pytest + run: python -m pytest + + - name: Run tests with unittest + run: python -m unittest discover -f test -v diff --git a/.github/workflows/code_style.yml b/.github/workflows/code_style.yml new file mode 100644 index 0000000..a8b40c0 --- /dev/null +++ b/.github/workflows/code_style.yml @@ -0,0 +1,57 @@ +# This is a basic workflow to help you get started with Actions + +name: Check code style + +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events + [ push, pull_request ] + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + + # This workflow contains a single job called "style" + style: + + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # A strategy creates a build matrix for your jobs + strategy: + + # You can define a matrix of different job configurations + matrix: + + # Each option you define in the matrix has a key and value + python-version: [ "3.10" ] + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - name: Set up Git repository + uses: actions/checkout@v2 + + # Setup Python with version from matrix + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + # Install requirements + - name: Install requirements + + # Runs command-line programs using the operating system's shell + run: | + python -m pip install --upgrade pip wheel setuptools + python -m pip install -r requirements.txt + python -m pip list + + # Install pre-commit from .pre-commit-config.yaml + - name: Install pre-commit + run: | + pre-commit install + + # Run pre-commit on all the files in the repo + - name: Run pre-commit + run: | + pre-commit run --all-files --color always --verbose --show-diff-on-failure diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..835de3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,225 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# End of https://mrkandreev.name/snippets/gitignore-generator/#Python,PyCharm,VisualStudioCode diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f2f1f4a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,12 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.4.0 + hooks: + - id: check-yaml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: requirements-txt-fixer + - repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black diff --git a/README.md b/README.md index 0e0b9ef..8361e0f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,13 @@ # CLI Реализация `CLI` на языке `Python`. -Работа выполнена студентами `ИТМО ИПКН` групп `M414x`: +Работа выполнена студентами `ИТМО ИПКН` групп `M414x`: 1. Стойко Николай 2. Гамора Константин 3. Овчинников Егор + + +Запуск приложения и тестов [project/README](./project/README.md) (Часть 1) diff --git a/architecture/README.md b/architecture/README.md index fb35b55..f15d4e6 100644 --- a/architecture/README.md +++ b/architecture/README.md @@ -1,8 +1,9 @@ -# Практика 1: задача про CLI +# Практика 4: CLI # Описание архитектуры -[UML class diagram](https://viewer.diagrams.net/?tags=%7B%7D&highlight=0000ff&layers=1&nav=1&title=cli-class_scheme.drawio#R7V1bc9o4FP41mck%2B0MFXyCOXpN2ddJfdZLfbvmQEVkBbY1FbTqC%2FfiVbMliWwRAbQ%2FBMp8XHQtj6vnPR0ZF6ZQzmy48%2BWMw%2BYwe6V3rbWV4ZwytdNzWD%2Fs0Eq1jQ0cxYMPWRE4u0teAB%2FYRc2ObSEDkwSDUkGLsELdLCCfY8OCEpGfB9%2FJpu9ozd9K8uwBRmBA8T4GalX5BDZrG0a7XX8k8QTWfil7U2vzMGk%2B9TH4ce%2Fz0PezC%2BMweiG940mAEHv26IjNsrY%2BBjTOJP8%2BUAumxUxYjF37vLuZs8sg89UuQLo%2BDp9vNs%2BOs33PvRab08%2FB7Cx5YV9%2FIC3JAPxQB7BC7JZ%2BDRl%2Fb5o5OVGKngFc1dQF%2FS6D%2FTlg%2F8jkavgYumHv08oQ9Ev2n0X6BPEB3kHr9B8IJKJzPkOvdghUP22AGhIyiu%2BjPso5%2B0W%2BDyPultn3C%2B6HaqxQP7JhW3qdSHAW0zEmOhSaLPYJlqeA8CwgUT7LpgEaBx8hpz4E%2BR18eE4DlvlB1qPvrsDeFyQ8SH%2FiPEc0j8FW3C79ompwHXEE3Q4nXNN83mslmKazrnOef4NOl7DTb9wPHeA3s7g30rVrEYf4pVL%2BLxIkMC1iACx8ff4QC7mKI9jKhPWYFcVxIJYrjwmeTSIliACfKm91GbobmW%2FMXHgokw%2Fe6zG2nRDDkO9BikmAACYvwYWAuMPBKNldWnf%2BiQDtofrCuLPviAXmvra%2FqHNfcJ5Tx9F4AidCElxytkBFHgvlWJdpOBg6%2FbxbAX7UqHvpOBPoOxiyLsYoyFUdQOAnhOoXLhGtFHBviwpWVQN7KoGwqEXTCG7ggHiCDM%2BvfjthLyu8BNsxd5M%2BijKkG39GKgdyvCvKvAvM8wh%2BQf4CM2vtff4YppCW%2F1S6P4JXPALmj0K1P8mxwSTNMk%2BCW2%2FQ%2FEpzg0LCiZBd26zb8Iuzdo0FssXIpKZFGbmK%2BcmM%2FQd8d8yRQo5QIqA17LAB%2Fr%2FyQd9Ufav2Mm0FgBtU69JQpUsqE6M6A3YeBbw8D9YVfEgWojUBXqRo4N8EPvugn5SsdbEfMdWc3NLOAD%2FarXDwlyEVmxiz59f%2Fr7hsZG9R4uTyvzY7bPOAqQEj%2FJ9a7or2NWxYds4qcx%2B%2FuafbMwDVZpGtQ1%2FddUOR9m9l24vI4nenzWF9tSPveLTWfjFMpmQ7cYG6pzCop00Fan8BCOA4JISBrXUJpr0KQJomEpjET3mL5Bz2YGGt%2Bwr2%2FoFubBFt%2Bggr0q36DnpQWCROnTLqJJDFZEBIVbUBGhMrcg1L2wW6C2NGg8QnkpQzvtEcy2wjR0juoRFLPHxiPs5xESrXqTR1DBXplHyBaGxB5hwfT9enN%2BwF3C7RJOwnjkG7dQNhsUbkHFhurcQjZlsAXvHONvHMH4G2dt%2FC0rZfytonGhZlcFvJGNB1iREH39cA6Z3tAJg0garDMGjQHYbgDswswQE8OCRCjDAPyn6U9IHw3%2B%2BHP4OLp7vB39NvzWylszCIjDtFKwgFIAgvmF45%2BBtTAl8teMFBUDSvxLiAL%2FXiIw9sL72afesGt1vj49%2F%2FBbuRNE4kDfb%2FCvWP%2B7det%2FHv4%2BJKHvPU2wA2MGsDG5bPgrUH%2BtXVT%2FKwsAVdWDzSxwv1ng3mqvifCrtsRgXr0gjKL%2FKCsYGXzmCJDXVA%2BUT4Gic4DKVN9QLRfbbqQ%2BKbDtHyHby8InR9QdtBfL5LIVYdczIxFjRYtj3Uumguse6Kcp%2Fzf6nbEQfBkIEX2VsdyMyhZrmcRENZckwm2whrFTTGDjuaWm8%2Bs7MEcuw%2BcTdF8g65VNOslcTEhLmApqUh6w08nSQMUCwZbyWaBaPq6JBbeTGb4QHiQgnwwPVIFATTwYAHIhNJDNQVexUHxcGqgCg5poMPoyvBAayNagdhqYqnlhXV5hiS7FHCSrgifDg2yauDYe9IKAfekymVB7fGBmM8UfXTwG7v7rRc3%2Boh3gW%2FIkQZUmVFUV2pWhny0WYMtFwSogcP608PEEBkEG%2FyZJoFah89lPbqrKBZr04F7pwf1hr3tHuaVaFZRQh57TYwe00Kuxi5lx7VOR8JR2fHkX%2BdAIig33mB5PBz6DMHLfkAZ6%2F7LmH6yOya%2B%2FMvzbH9ptgwuGwjpHF6uNixGFhb4%2F8yax%2BWant0BHtHCmUDggivUMT7FHnddamm%2B4Axz6E7hlvEQmjfqfKSySJmQPs5UIKuR96AKCXmDq2VTY8%2B5GOFqzSaaaNzlORXQRvyb%2F1ppBmY6SYua8juJhyHRE6QJWG824EuY%2BsC4WR8TvmO2tz2VKG3Kk9vRD%2FARrvUgG%2FA2qUqCKrgJVOSK3u2fAbV2TqHIot02Z20ZF3JbzPp3t3Jbbn6YuFAgWzlsXOuegC7qkCzeH2nk5RW1KRSgl6YJ8msJpcrvA3srz5vbNOXDbSHO72z6U23IwZFXEbTmxu8POZ3XuFHWhwCljZ60LIut52rpgSrqgHRrzyKlGuxpdyHDV2JPbxjG4XaAe6ry5LY5hPWluWxK39UO5rUkckrdTlcRtS1642WHn5fanaedVa8HvSheMc9AFW9IFeUpaWBfkOLtbjS7IJ%2BLa3KaWxVVlHbueperW7a3xqhVlnkzoZoPrJk33OAfZSKNuFD0Ss4wNrkpKNPtb91m62KpVp3gYjvKB83a3srJmtrl1Y7GabXCV%2BXC5C5YlwX%2FE02%2BUT7xf9I4X0UCvIxQtHb7o6YAlHZgcHnZoRcMOcfxQHWGHIdlz8%2BAQ%2FGZHRzlhR1mRwX5B7AlRYptKngIjdM06jBG6SCSLjqzjMkJxSvKZUGLrsQN1cMIUG5kSTtiHccLU0x0ZBRfeSuNEtuBVK0CKDdwlRkSBvWjtIDDHnvM4Y6WRqZhfM4Vgk1w757w7Io8xD%2B7luTQvsNA2iitYyLCtsuINBC6aabIK8pdTRexZFUxp3F2KyIqZ8BkbN3HUVi3Gzbwpx7iJDEjCCTlVXzUnsvVc8f7uDDM2oM61KVn2OCCYRaRIk6crmTLojvHrphWLBPSG%2BCmWrgj9l4RfZbFNHHpUXp7vrcySykyMg9foOzs6qppZ%2B5U%2FVW9ttmUFTjrN%2B36MTT2rw03xaKWLEHItZufQ8EheyJJp%2Fk6LR9Wq8t4X2M6iYC5Tf3bq3K65YI5erv9z07j5%2Bv%2BONW7%2FBw%3D%3D) +[UML class diagram](https://viewer.diagrams.net/?tags=%7B%7D&target=blank&highlight=0000ff&edit=_blank&layers=1&nav=1&title=cli-class_scheme.drawio#Uhttps%3A%2F%2Fraw.githubusercontent.com%2Fkgamora%2Fcli-itmo-sd-2023%2Fupdate_architecture%2Farchitecture%2Fcli-class_scheme.drawio) + ![UML class diagram](cli-class_scheme.drawio.png) @@ -18,19 +19,19 @@ 1. Чтение входных данных до символа переноса строки. -2. `(Фаза 2)` Разбиение входной строки по символам | ( `pipeline` ). Получение массива строк. +2. Разбиение строки на лексемы с помощью `Lexer`. Разбиение входной строки на список лексем в соответствие с синтаксисом bash; в том числе разбиение по лексемам `|` (pipeline), `=`, находящимся вне строковых литералов, с сохранением этих лексем в списке. 4. Подстановка переменных из контекста приложения в каждую из полученных строк с помощью `Substituter`. -5. Разбиение строк на лексемы с помощью `Lexer`. Если ввод не пустой и `Lexer` успешно обработал строки, то результат передается в `Constructor` , иначе пользователю сообщается об ошибке и выполняется переход к пункту 1. +5. Разделение списка лексем по лексемам `|` (pipeline) на список списков. -6. Создание экземпляров класса `Executable` с помощью класса `Constructor`. +6. Создание экземпляров класса `Executable` с помощью `Constructor` для каждого из полученных списка лексем. -7. Последовательное исполнение экземпляров `Executable` с помощью `Executor`. Происходит передача вывода предыдущих экземпляров `Executable` на ввод последующим. +7. Последовательное исполнение экземпляров `Executable` с помощью `Executor`. При этом происходит передача вывода предыдущих экземпляров `Executable` на ввод последующим. 8. В случае успешного завершения всех `Executable` производится вывод пользователю результата последнего экземпляра `Executable`. В случае возникновения ошибки во время исполнения одного из `Executable` выполнение прекращается и выводится информации об ошибке. -9. В случае выполнения команды `exit` работа приложения завершается. +`NB` В случае выполнения команды `exit` работа приложения завершается. ## Обработка ввода @@ -38,26 +39,26 @@ ### Substitution (+ Context variables) `(Фаза 2)` -Класс `ContextManager` отвечает за хранение пользовательских переменных. -Пользовательской переменной называется символическое имя, состоящие из букв, цифр и знака `_`, которой соответствует некоторая строка. +### Lexer -Класс `Substitution` имеет метод `substitute`, принимающий строку и выполняющий подстановку следующим образом: +Данный класс содержит метод `lex`, принимающий строку и разбивающий ее на массивы лексем следующим образом. -1. Осуществляется линейный проход по строке. -2. Если встретилась одинарная или двойная кавычка и стек кавычек пуст, то кавычка добаляется в стек. -3. Если встретилась одинарная или двойная кавычка и в вершине стека лежит такая же кавычка, то кавычка удаляется из текущей позиции строки и из того места, откуда была добавлена в стек кавычка; кавычка удаляется из стека. -2. Если встречается символ `$` и в стеке нет одинарных кавычек, то находится самая длинная подстрока из букв, цифр и знаков `_` , начинающаяся после `$` - имя пользовательской переменной. -3. Производится замена символа `$` и имени переменной на значение из контекста приложения. -4. Продолжается проход по строке со следующей позиции. +1. Строка разделяется по пробельным символам. +2. Если в строке есть символ `=` или `|`, то строка разделяется по этому символу, сам символ `=` или `|` тоже выделяется в отдельную лексему. +3. Если в строке встречаются кавычки, то разделения в подстроке, ограниченной кавычками не происходит. +4. Если отсутствует закрывающая кавычка для любой из открывающей кавычки, то пользователю сообщается ошибка о нарушении синтаксиса, обработка пользовательского ввода прекращается. -Если в стеке осталась кавычка, то выбрасывается ошибка подстановки. +### Substituter -### Lexer +Класс `ContextManager` отвечает за хранение пользовательских переменных. +Пользовательской переменной называется символическое имя, состоящие из букв, цифр и знака `_`, которой соответствует некоторая строка. -Данный класс содержит метод `lex`, принимающий строку и разбивающий ее на массивы лексемы следующим образом. +Класс `Substituter` имеет метод `substitute`, принимающий строку и выполняющий подстановку следующим образом: -1. Строка разделяется по пробельным символам. -2. Если в строке есть символ `=`, то строка разделяется по этому символу, причем сам символ `=` тоже выделяется в отдельную лексему. +1. Если строка ограничена одинарными кавычками, строка возвращаяется без кавычек. +2. Если строка ограничена двойными кавычками, то кавычки удаляются. +3. Выполняется замена всех вхождений в строке, удовлетворяющий регулярному выражению `"(\$\b\w*)"` на переменную из контекста, имеющую название совпадающее с `\b\w*`. +4. Если переменная с таким названием отсутствует в контексте, то выполняется замена на пустую строку. @@ -65,7 +66,7 @@ Данный класс имеет метод `construct`, принимающий список лексем и конструирующий из него объекты класса `Executable`. -1. Если вторая лексема **соответствует** символу `=`, то создается соответствующий наследник класса `Executable` отвечающий за добавление или изменения пользовательских переменных. Остальные лексемы передаются в качестве аргументов. +1. Если вторая лексема **соответствует** символу `=`, то создается соответствующий наследник класса `Executable` отвечающий за добавление или изменение пользовательских переменных. Остальные лексемы передаются в качестве аргументов. 1. Если первая лексема **соответствует** названию встроенной команды, то создается соответствующий наследник класса `Executable`. Остальные лексемы передаются в качестве аргументов. 2. Если первая лексема **не** **соответствует** названию встроенной команды, то создается наследник класса `Executable`, позволяющий сделать системный вызов этой команды. Остальные лексемы передаются в качестве аргументов. @@ -91,7 +92,7 @@ В данном разделе описаны представители класса `Executable`, реализованные в приложении. -`cat [FILE]` — вывести в поток выводы содержимое файла. +`cat [FILE]` — вывести в поток вывода содержимое файла. `echo [FILE]` — вывести на экран свой аргумент (или аргументы). @@ -99,10 +100,29 @@ `pwd` — вывести текущую директорию. -`=` - инфиксная команда, которая добавляет в контекст пользовательскую переменную с именем предыдущей лексемы и значением последующей. +`=` - инфиксная команда, которая добавляет в контекст пользовательскую переменную с именем предыдущей лексемы и значением последующей. + +`grep` - выполнить поиск паттернов в файлах. Требуется поддержка: + - регулярных выражений в запросе; + - ключа `-w` — поиск только слова целиком; + - формально `grep -w` ищет подстроки, ограниченные `non-word constituent character`, а именно не буквы, цифры или символ подчёркивания; однако что такое буквы — лучше спросить у вашей стандартной библиотеки, потому что бывает `Unicode` и его классы символов, ваша реализация может отличаться от настоящего поведения `grep`; + - ключа `-i` — регистронезависимый (`case-insensitive`) поиск; + - ключа `-A` — следующее за `-A` число говорит, сколько строк после совпадения надо распечатать, например, `-A 0` печатает только строку, на которой найдено совпадение, а `-A 10` — ещё и `10` строк ниже; `exit` — выйти из интерпретатора. #### Внешние команды Класс `GlobalExecutable` отвечает за вызов команд, которые не предусмотрены в данном приложении. + +# Примечание + +#### Grep + +Для реализации функциональности `grep'а` рассматривались библиотеки: `argparse`, `optparse`, `click`. Выбор был сделан в пользу `argparse`, по перечисленным ниже причинам: + +> `сlick`: хоть и современный и имеет большую функциональность, но не входит в `stdlib` + +> `argopt`: входит в `stdlib`, является устаревшим аналогом `argparse` + +> `argparse`: входит в `stdlib`, обладает всем необходимым для реализации `grep'а` функциональностью, а также достаточно простой в использовании diff --git a/architecture/cli-class_scheme.drawio b/architecture/cli-class_scheme.drawio index 41a5f43..e1ab417 100644 --- a/architecture/cli-class_scheme.drawio +++ b/architecture/cli-class_scheme.drawio @@ -1,6 +1,6 @@ - + - + @@ -229,6 +229,11 @@ + + + + + @@ -249,6 +254,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/architecture/cli-class_scheme.drawio.png b/architecture/cli-class_scheme.drawio.png index 8f7c871..d3fb3bc 100644 Binary files a/architecture/cli-class_scheme.drawio.png and b/architecture/cli-class_scheme.drawio.png differ diff --git a/project/README.md b/project/README.md new file mode 100644 index 0000000..cb32f88 --- /dev/null +++ b/project/README.md @@ -0,0 +1,21 @@ +# Реализация проекта на Python + + +#### Форматирование кода +```shell +pre-commit run --all-files +``` + + +#### Запуск приложения CLI +``` +# Try our command line interface! +python -m project +``` + + +#### Запуск тестов +```shell +# Запуск тестов +cd .. && python -m pytest && cd project +``` diff --git a/project/__init__.py b/project/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/__main__.py b/project/__main__.py new file mode 100644 index 0000000..6bd6d62 --- /dev/null +++ b/project/__main__.py @@ -0,0 +1,4 @@ +from project.application.application import Application + +if __name__ == "__main__": + Application().run() diff --git a/project/application/__init__.py b/project/application/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/application/application.py b/project/application/application.py new file mode 100644 index 0000000..37ff890 --- /dev/null +++ b/project/application/application.py @@ -0,0 +1,62 @@ +from project.application.context_manager import ContextManager +from project.execution.executor import Executor +from project.parsing.constructor import Constructor +from project.parsing.lexer import Lexer +from project.parsing.substituter import Substituter +from project.utils.metaclasses import Singleton +from project.execution.executable import Executable +from project.utils.parserutils import split + + +class Application(metaclass=Singleton): + _STDERROR_COLOR_BEGIN = "\033[91m" + _STDERROR_COLOR_END = "\033[0m" + + def __init__(self): + self.context_manager = ContextManager() + + def run(self): + """ + Read-Execute-Print Loop + :return: None + """ + while True: + try: + stdin = input() + executables = self.get_executables(stdin) + report = Executor.exec(executables) + if report.stdout and len(report.stdout) > 0: + print(report.stdout) + if report.stderr and len(report.stderr) > 0: + print( + self._STDERROR_COLOR_BEGIN, + report.stderr, + self._STDERROR_COLOR_END, + ) + except KeyboardInterrupt: + exit(0) + except Exception as e: + print(e) + + def get_executables(self, stdin: str) -> list[Executable]: + """ + Converts input string into list of Executables + :param stdin: user input to parse + :return: list of commands to execute + """ + tokens: list[str] = Lexer.lex(stdin) + substituted_tokens: list[str] = Substituter.substitute_all(tokens) + substituted_tokens_list: list[str] = split(substituted_tokens, "|") + + constructor = Constructor() + for substituted_tokens in substituted_tokens_list: + executable = constructor.construct(substituted_tokens) + if executable: + yield executable + + def get_context_manager(self): + """ + returns application's context manager + :return: + """ + return self.context_manager diff --git a/project/application/context_manager.py b/project/application/context_manager.py new file mode 100644 index 0000000..4cb1592 --- /dev/null +++ b/project/application/context_manager.py @@ -0,0 +1,48 @@ +import os + +from project.utils.metaclasses import Singleton + + +class ContextManager(metaclass=Singleton): + def __init__(self): + self.__var_map: dict[str, str] = {} + + # return environment variable + def get_var(self, name_str) -> str: + """ + Returns variable by its name. + :param name_str: name of the variable to get + :return: value of the variable + """ + if name_str in self.__var_map.keys(): + return self.__var_map[name_str] + return os.environ.get(name_str, "") + + def set_var(self, name_str, value_str) -> None: + """ + Updates or sets context variable name_str to value_str + :param name_str: name of the variable to set + :param value_str: value to set + :return: None + """ + self.__var_map[name_str] = value_str + + def remove_var(self, name_str: str): + """ + removes variable name_str from shell context (Note: it cannot remove variable from system context) + :param name_str: name of the variable to remove from shell context + :return: None + """ + self.__var_map.pop(name_str, "") + + def _clear(self): + self.__var_map.clear() + + def get_current_env(self) -> dict[str, str]: + """ + Combines system variables and current context variables and returns them + :return: dictionary: name to value. + """ + current_env = os.environ.copy() + current_env.update(self.__var_map) + return current_env diff --git a/project/execution/__init__.py b/project/execution/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/execution/commands/assign.py b/project/execution/commands/assign.py new file mode 100644 index 0000000..3e61600 --- /dev/null +++ b/project/execution/commands/assign.py @@ -0,0 +1,17 @@ +from project.application.context_manager import ContextManager +from project.execution.executable import Executable + + +class Assign(Executable): + def __init__(self, arguments: list[str]): + super().__init__(arguments) + + def execute(self, stdin: str): + """ + Executes the command and captures its output (stdout, stderr and return code). + Behaves in the exact same manner as assigning variable in standard Linux distros. + :param stdin: command input stream + :return: None + """ + ContextManager().set_var(self.arguments[0], self.arguments[1]) + self.ret_code = 0 diff --git a/project/execution/commands/cat.py b/project/execution/commands/cat.py new file mode 100644 index 0000000..6482645 --- /dev/null +++ b/project/execution/commands/cat.py @@ -0,0 +1,22 @@ +from project.execution.executable import Executable + + +class Cat(Executable): + def __init__(self, arguments: list[str] | None = []): + super().__init__(arguments) + + @Executable._may_throw + def execute(self, stdin: str = ""): + """ + Executes the command and captures its output (stdout, stderr and return code). + Concatenate files to stdout like cat command in standard Linux distros but lacks its flags. + :param stdin: command input stream + :return: None + """ + if not self.arguments: + self.stdout += stdin + else: + for file_name in self.arguments: + with open(file_name) as file: + self.stdout += "".join([line for line in file]) + self.ret_code = 0 diff --git a/project/execution/commands/echo.py b/project/execution/commands/echo.py new file mode 100644 index 0000000..a0694f8 --- /dev/null +++ b/project/execution/commands/echo.py @@ -0,0 +1,17 @@ +from project.execution.executable import Executable + + +class Echo(Executable): + def __init__(self, arguments: list[str] | None = None): + super().__init__(arguments) + + @Executable._may_throw + def execute(self, stdin: str): + """ + Executes the command and captures its output (stdout, stderr and return code). + Behaves in the exact same manner as echo command in standard Linux distros but lacks its flags. + :param stdin: command input stream + :return: None + """ + self.stdout = " ".join(self.arguments) + self.ret_code = 0 diff --git a/project/execution/commands/exit.py b/project/execution/commands/exit.py new file mode 100644 index 0000000..77dbb7a --- /dev/null +++ b/project/execution/commands/exit.py @@ -0,0 +1,17 @@ +import sys + +from project.execution.executable import Executable + + +class Exit(Executable): + def __init__(self, arguments: list[str] | None = None): + super().__init__(arguments) + + def execute(self, stdin: str): + """ + Executes the command and captures its output (stdout, stderr and return code). + Behaves in the exact same manner as exit command in standard Linux distros but lacks its flags. + :param stdin: command input stream + :return: None + """ + sys.exit(0) diff --git a/project/execution/commands/global_executable.py b/project/execution/commands/global_executable.py new file mode 100644 index 0000000..36bb2a2 --- /dev/null +++ b/project/execution/commands/global_executable.py @@ -0,0 +1,31 @@ +import subprocess + +from project.application.context_manager import ContextManager +from project.execution.executable import Executable +from subprocess import CompletedProcess + + +class GlobalExecutor(Executable): + def __init__(self, arguments: list[str] | None = None): + super().__init__(arguments) + self.system_process = None + + @Executable._may_throw + def execute(self, stdin: str): + """ + Executes the command and captures its output (stdout, stderr and return code). + :param stdin: command input stream + :return: None + """ + completed_process: CompletedProcess = subprocess.run( + input=stdin, + args=self.arguments, + capture_output=True, + universal_newlines=True, + env=ContextManager().get_current_env(), + ) + self.ret_code, self.stdout, self.stderr = ( + completed_process.returncode, + completed_process.stdout, + completed_process.stderr, + ) diff --git a/project/execution/commands/grep.py b/project/execution/commands/grep.py new file mode 100644 index 0000000..756f4e7 --- /dev/null +++ b/project/execution/commands/grep.py @@ -0,0 +1,124 @@ +from project.execution.executable import Executable +from project.parsing.custom_arg_parser import CustomArgumentParser +import re +from os.path import isfile + + +class Grep(Executable): + class GrepException(Exception): + pass + + _parser = CustomArgumentParser() + _parser.add_argument( + "-w", action="store_const", default=None, const=True, help="searching words" + ) + _parser.add_argument( + "-A", default=0, type=int, help="how much lines show after matching" + ) + _parser.add_argument( + "-i", action="store_const", default=None, const=True, help="ignore case" + ) + _parser.add_argument( + "pattern", metavar="PATTERN", type=str, help="pattern to searching" + ) + _parser.add_argument( + "files", + metavar="FILES", + type=str, + nargs="+", + help="list of files to search at", + ) + _parser.prog = "grep" + _parser.description = """grep searches for PATTERNS in each FILE. PATTERNS is one or more + patterns separated by newline characters, and grep prints each line + that matches a pattern. + Typically, PATTERNS should be quoted when grep is used in a shell command.""" + + def __init__(self, arguments: list[str] | None = None): + super().__init__(arguments) + self.os_asked_help = False + try: + self.args = self._parser.parse_args(self.arguments) + except SystemExit as se: + self.ret_code = se.code + except CustomArgumentParser.ArgumentException as ex: + self.stderr += str(ex) + "\n" + self.ret_code = 2 + + def _init_pattern(self): + pattern: str + if self.args.w: + pattern = self._get_patten_for_word(self.args.pattern) + else: + pattern = self.args.pattern + if self.args.i: + pattern = self._get_patten_for_case_insensitive(pattern) + return pattern + + @staticmethod + def _get_patten_for_word(word: str): + return rf"(\W|^){word}($|\W)" + + @staticmethod + def _get_patten_for_case_insensitive(pattern: str): + return rf"(?i){pattern}" + + @staticmethod + def _find_by_regex( + pattern: str, target_lines, additional_lines_number: int + ) -> list[(str, (int, int))]: + result = list() + current_accept = 0 + + try: + for line in target_lines: + matches = re.search(pattern=pattern, string=line) + if matches: + current_accept = additional_lines_number + left, right = matches.span() + result.append((line, (left, right))) + elif current_accept > 0: + result.append((line, (0, 0))) + current_accept -= 1 + return result + except re.error as err: + raise Grep.GrepException( + f"{err.lineno}:{err.colno} Error of pattern matching: {err.msg}: '{err.pattern}'." + ) + except BaseException: + raise Grep.GrepException("Error due pattern matching was occurred.") + + @Executable._may_throw + def execute(self, stdin: str): + """ + grep searches for PATTERNS in each FILE. PATTERNS is one or more + patterns separated by newline characters, and grep prints each line + that matches a pattern. + Typically, PATTERNS should be quoted when grep is used in a shell command. + :param stdin: command input stream is ignored + :return: None + """ + if self.ret_code is not None: + return + + pattern = self._init_pattern() + lines_number = self.args.A + results = {} + for file in self.args.files: + if isfile(file): + results[file] = self._find_by_regex( + pattern, Grep._get_file_text(file), lines_number + ) + else: + self.stderr += "grep" + f" {file}: Это каталог\n" + for file in sorted(results): + for text, (_, _) in results[file]: + self.stdout += ": ".join((file, text)) + + self.ret_code = 0 + + @staticmethod + def _get_file_text(file_name: str): + with open(file_name, "r") as content_file: + for line in content_file: + yield line diff --git a/project/execution/commands/pwd.py b/project/execution/commands/pwd.py new file mode 100644 index 0000000..09993bd --- /dev/null +++ b/project/execution/commands/pwd.py @@ -0,0 +1,19 @@ +import os + +from project.execution.executable import Executable + + +class PWD(Executable): + def __init__(self, arguments: list[str] | None = None): + super().__init__(arguments) + + @Executable._may_throw + def execute(self, stdin: str): + """ + Executes the command and captures its output (stdout, stderr and return code). + Behaves in the exact same manner as pwd command in standard Linux distros but lacks its flags. + :param stdin: command input stream + :return: None + """ + self.stdout = os.getcwd() + self.ret_code = 0 diff --git a/project/execution/commands/wc.py b/project/execution/commands/wc.py new file mode 100644 index 0000000..b0724d2 --- /dev/null +++ b/project/execution/commands/wc.py @@ -0,0 +1,90 @@ +import os + +from project.execution.executable import Executable + + +class WC(Executable): + def __init__(self, arguments: list[str] | None = None): + super().__init__(arguments) + self.max_len = 0 + self.total_line = 0 + self.total_word = 0 + self.total_byte = 0 + if not self.arguments: + self.arguments = [] + + @Executable._may_throw + def execute(self, stdin: str = ""): + """ + Executes the command and captures its output (stdout, stderr and return code). + Behaves in the exact same manner as wc command in standard Linux distros but lacks its flags. + :param stdin: command input stream + :return: None + """ + self.stdout = "" + self.__print_if(stdin and len(stdin) != 0, stdin) + output, offset = self.__get_info() + template = ( + "{:" + offset + "d}{:" + offset + "d}{:" + offset + "d} {:" + offset + "s}" + ) + for out in output: + if "dir" in out: + self.stdout += out["dir"] + "\n" + if "file" in out: + self.stdout += template.format(*out["file"]) + "\n" + if len(self.arguments) > 1: + self.stdout += template.format( + self.total_line, self.total_word, self.total_byte, "итого" + ) + self.stdout = self.stdout.removesuffix("\n") + self.ret_code = 0 + + def __get_info(self): + output = [] + for argument in self.arguments: + if self.__is_file(argument): + count_new_line, count_words, size = self.__get_info_about_file(argument) + self.total_line += count_new_line + self.total_word += count_words + self.total_byte += size + max_len = max(self.max_len, len(str(self.total_byte))) + output.append({"file": (count_new_line, count_words, size, argument)}) + elif self.__is_dir(argument): + output.append({"dir": "wc:" + str(argument) + ": Это каталог"}) + output.append({"file": (0, 0, 0, argument)}) + else: + output.append( + {"dir": "wc: " + str(argument) + ": Нет такого файла или каталога"} + ) + offset = str((self.max_len // 6 + 1) * 6) + return output, offset + + def __print_if(self, logic: bool, stdin: str): + if logic: + self.__save_stdin(stdin) + + def __save_stdin(self, stdin: str): + new_lines = len(stdin.strip().split("\n")) + count = len(stdin.strip().split()) + bytes = len(stdin.encode("utf-8")) + self.stdout += "{:7d}{:8d}{:8d}".format(new_lines, count, bytes + 1) + + def __is_file(self, name: str): + return os.path.isfile(name) + + def __is_dir(self, name: str): + return os.path.isdir(name) + + def __get_info_about_file(self, name_file): + file = open(name_file, "r") + new_line, count_words = 0, 0 + while True: + line = file.readline() + if not line: + break + count_words = count_words + len(line.strip().split()) + if line[-1] == "\n": + new_line += 1 + size = file.seek(0, os.SEEK_END) + file.close() + return new_line, count_words, size diff --git a/project/execution/executable.py b/project/execution/executable.py new file mode 100644 index 0000000..9b6cc8b --- /dev/null +++ b/project/execution/executable.py @@ -0,0 +1,28 @@ +from abc import ABCMeta, abstractmethod + + +class Executable(metaclass=ABCMeta): + def __init__(self, arguments: list[str] | None): + self.arguments: list[str] | None = arguments + self.stdout: str | None = "" + self.stderr: str | None = "" + self.ret_code: int | None = None + + @abstractmethod + def execute(self, stdin: str) -> None: + """ + Executes the command and captures its output (stdout, stderr and return code). + :param stdin: command input stream + :return: None + """ + pass + + def _may_throw(execution: callable): + def wrapper(self: Executable, stdin: str = ""): + try: + execution(self, stdin) + except BaseException as e: + self.stderr += str(e) + self.ret_code = 2 + + return wrapper diff --git a/project/execution/executor.py b/project/execution/executor.py new file mode 100644 index 0000000..92d9862 --- /dev/null +++ b/project/execution/executor.py @@ -0,0 +1,21 @@ +from project.execution.executor_report import ExecutorReport +from project.execution.executable import Executable + + +class Executor: + @staticmethod + def exec(executables: list[Executable]) -> ExecutorReport: + """ + Get list of Executable and executes it redirected output of previous to input next + :param executables: list of Executable to run + :return: ExecutionReport of the last successful execution + """ + report = ExecutorReport("", "", 0) + for executable in executables: + executable.execute(report.stdout) + report = ExecutorReport( + executable.stdout, executable.stderr, executable.ret_code + ) + if report.ret_code != 0: + break + return report diff --git a/project/execution/executor_report.py b/project/execution/executor_report.py new file mode 100644 index 0000000..c9a1360 --- /dev/null +++ b/project/execution/executor_report.py @@ -0,0 +1,3 @@ +from collections import namedtuple + +ExecutorReport = namedtuple("ExecutorReport", "stdout stderr ret_code") diff --git a/project/parsing/__init__.py b/project/parsing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/parsing/constructor.py b/project/parsing/constructor.py new file mode 100644 index 0000000..4b3de49 --- /dev/null +++ b/project/parsing/constructor.py @@ -0,0 +1,71 @@ +from project.execution.commands.assign import Assign +from project.execution.commands.cat import Cat +from project.execution.commands.wc import WC +from project.execution.executable import Executable +from project.execution.commands.global_executable import GlobalExecutor +from project.execution.commands.echo import Echo +from project.execution.commands.pwd import PWD +from project.execution.commands.exit import Exit +from project.execution.commands.grep import Grep + + +class Constructor: + def __init__(self): + """ + Private methods to construct Executable with 'name' + must be named "_construct_'name'" + """ + self._executable_constructions_keyword = "_construct_" + self.map_of_executables: dict = {} + for k in self.__dir__(): + if k.startswith(self._executable_constructions_keyword): + self.map_of_executables[ + k.removeprefix(self._executable_constructions_keyword) + ] = getattr(self, k) + + def construct(self, tokens_list: list[str]) -> Executable | None: + """ + Constructs from list of tokens instance of Executable + :param tokens_list: input list of tokens + :return: Executable + """ + if len(tokens_list) == 0: + return None + if len(tokens_list) > 1 and tokens_list[1] == "=": + return Assign(tokens_list[0:1] + tokens_list[2:]) + + if tokens_list[0] in self.map_of_executables.keys(): + return self.map_of_executables[tokens_list[0]](tokens_list[1:]) + else: + return self._construct_global_executable(tokens_list) + + def construct_all(self, tokens_lists: list[list[str]]) -> list[Executable | None]: + """ + Construct several Executable from list of strings. + :param tokens_lists: + :return: list of Executable + """ + if len(tokens_lists) == 0: + return [] + return list(map(self.construct, tokens_lists)) + + def _construct_global_executable(self, tokens_list: list[str]): + return GlobalExecutor(tokens_list) + + def _construct_echo(self, tokens_list: list[str]): + return Echo(tokens_list) + + def _construct_pwd(self, tokens_list: list[str]): + return PWD(tokens_list) + + def _construct_exit(self, tokens_list: list[str]): + return Exit(tokens_list) + + def _construct_wc(self, tokens: list[str]): + return WC(tokens) + + def _construct_cat(self, tokens: list[str]): + return Cat(tokens) + + def _construct_grep(self, tokens: list[str]): + return Grep(tokens) diff --git a/project/parsing/custom_arg_parser.py b/project/parsing/custom_arg_parser.py new file mode 100644 index 0000000..273c8be --- /dev/null +++ b/project/parsing/custom_arg_parser.py @@ -0,0 +1,11 @@ +import argparse + + +class CustomArgumentParser(argparse.ArgumentParser): + class ArgumentException(Exception): + pass + + def error(self, message): + if message: + raise CustomArgumentParser.ArgumentException(message) + super(CustomArgumentParser, self).error(message) diff --git a/project/parsing/exceptions/LexingException.py b/project/parsing/exceptions/LexingException.py new file mode 100644 index 0000000..ef2d41a --- /dev/null +++ b/project/parsing/exceptions/LexingException.py @@ -0,0 +1,6 @@ +class LexerException(Exception): + pass + + +class LexerAssignException(LexerException): + pass diff --git a/project/parsing/lexer.py b/project/parsing/lexer.py new file mode 100644 index 0000000..f784b0a --- /dev/null +++ b/project/parsing/lexer.py @@ -0,0 +1,107 @@ +import shlex +from abc import ABCMeta, abstractmethod + +from project.parsing.exceptions.LexingException import ( + LexerException, + LexerAssignException, +) + + +class LexerAction(metaclass=ABCMeta): + @abstractmethod + def perform(self, tokens: list[str]) -> list[str]: + """ + Auxiliary lexing method. Performs one of the lexing stages. + :param tokens: list of tokens + :return: list of tokens + """ + pass + + @staticmethod + def is_string(line): + return line[0] == "'" and line[-1] == "'" or line[0] == '"' and line[-1] == '"' + + +class SeparateAssign(LexerAction): + def perform(self, tokens: list[str]): + """ + Auxiliary lexing method. Performs separation by equals sign. + :param tokens: list of tokens + :return: list of tokens + """ + i = 0 + while i < len(tokens): + if self.is_string(tokens[i]): + i += 1 + continue + + new_tokens = tokens[i].split("=") + + if len(new_tokens) > 2: + raise LexerAssignException() + if ( + len(new_tokens) > 1 + and len(new_tokens[0]) > 0 + and len(new_tokens[1]) > 0 + ): + tokens = ( + tokens[0:i] + [new_tokens[0], "=", new_tokens[1]] + tokens[i + 1 :] + ) + i += 1 + + return tokens + + +class SeparatePipe(LexerAction): + pipe = "|" + + def perform(self, tokens: list[str]): + """ + Auxiliary lexing method. Performs separation by pipe sign. + :param tokens: list of tokens + :return: list of tokens + """ + i = 0 + while i < len(tokens): + if self.is_string(tokens[i]) or tokens[i] == self.pipe: + i += 1 + continue + + new_tokens = tokens[i].split(self.pipe) + if len(new_tokens) > 2: + raise LexerAssignException() + if len(new_tokens) > 1: + median = [new_tokens[0]] if len(new_tokens[0]) > 0 else [] + for tok in new_tokens[1:]: + median += [self.pipe, tok] if len(tok) > 0 else [self.pipe] + tokens = tokens[0:i] + median + tokens[i + 1 :] + continue + i += 1 + return tokens + + +class Lexer: + _actions = [SeparateAssign(), SeparatePipe()] + + def __init__(self, actions: list[LexerAction] = None): + if actions: + self.actions = actions + else: + self.actions = Lexer._actions + + @classmethod + def lex(cls, tokens_str: str) -> list[str]: + """ + Split the input string by whitespace and '=', '|' signs if it is not surrounded with single-quotes. + :param tokens_str: input string + :return: list of tokens + """ + try: + tokens = shlex.split(tokens_str, posix=False) + except ValueError as error: + raise LexerException(error) + + for action in cls._actions: + tokens = action.perform(tokens) + + return tokens diff --git a/project/parsing/substituter.py b/project/parsing/substituter.py new file mode 100644 index 0000000..ed6b617 --- /dev/null +++ b/project/parsing/substituter.py @@ -0,0 +1,40 @@ +import re +from project.application.context_manager import ContextManager + + +class Substituter: + @staticmethod + def substitute(token: str) -> str: + """ + Replace variables at "..." with values from ContextManager. + Remove "..." and '...'. + :param token: string + :return: string with substitution + """ + # passing with substitute at part2 of project + + result: str + + if len(token) > 1 and token[0] == "'" and token[-1] == "'": + result = token[1:-1] + else: + result = re.sub(r'"(.*)"', r"\1", token) + + # here is substitution + def get_var_value(name: re.Match): + key = name[0] + v = ContextManager().get_var(key[1:]) + return v + + result = re.sub(r"(\$\b\w*)", get_var_value, result) + + return result + + @staticmethod + def substitute_all(token_strings: list[str]) -> list[str]: + """ + Substitutes each element at token_strings and return list of token + :param token_strings: + :return: return list of substituted strings + """ + return list(map(Substituter.substitute, token_strings)) diff --git a/project/utils/__init__.py b/project/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/project/utils/metaclasses.py b/project/utils/metaclasses.py new file mode 100644 index 0000000..fe5fd43 --- /dev/null +++ b/project/utils/metaclasses.py @@ -0,0 +1,8 @@ +class Singleton(type): + instances = {} + + def __call__(cls, *args, **kwargs): + key = (cls.__name__, args + tuple(kwargs.values())) + if key not in cls.instances: + cls.instances[key] = super().__call__(*args, **kwargs) + return cls.instances[key] diff --git a/project/utils/parserutils.py b/project/utils/parserutils.py new file mode 100644 index 0000000..140c197 --- /dev/null +++ b/project/utils/parserutils.py @@ -0,0 +1,12 @@ +def split(sequence: list, sep): + """ + Splits sequence list into list of lists by sep value + """ + chunk = [] + for val in sequence: + if val == sep: + yield chunk + chunk = [] + else: + chunk.append(val) + yield chunk diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0bce7fb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,21 @@ +attrs==22.2.0 +black==23.1.0 +cfgv==3.3.1 +click==8.1.3 +distlib==0.3.6 +exceptiongroup==1.1.0 +filelock==3.9.0 +identify==2.5.18 +iniconfig==2.0.0 +jesth==0.0.6 +mypy-extensions==1.0.0 +nodeenv==1.7.0 +packaging==23.0 +pathspec==0.11.0 +platformdirs==3.1.0 +pluggy==1.0.0 +pre-commit==3.1.1 +pytest==7.2.2 +PyYAML==6.0 +tomli==2.0.1 +virtualenv==20.20.0 diff --git a/test/commands/test_assign.py b/test/commands/test_assign.py new file mode 100644 index 0000000..716c51b --- /dev/null +++ b/test/commands/test_assign.py @@ -0,0 +1,40 @@ +import pytest +import project # on import will print something from __init__ file +from project.execution.commands.assign import Assign +from project.application.context_manager import ContextManager + + +def setup_module(module): + print("basic setup module") + + +def teardown_module(module): + print("teardown module") + + +def test_throws(): + ignore = "this text should be ignored" + with pytest.raises(IndexError): + Assign(["not_enough_args"]).execute(ignore) + + +def test_basic(): + ContextManager()._clear() + var, val = "var", "val" + assert not ContextManager().get_var(var) + assign = Assign([var, val]) + assign.execute("") + assert ContextManager().get_var(var) == val + assert not assign.stdout + ContextManager()._clear() + + +def test_string_assign(): + ContextManager()._clear() + var, val = "var", "'sdf|fds'" + assert not ContextManager().get_var(var) + assign = Assign([var, val]) + assign.execute("") + assert ContextManager().get_var(var) == val + assert not assign.stdout + ContextManager()._clear() diff --git a/test/commands/test_cat.py b/test/commands/test_cat.py new file mode 100644 index 0000000..6916ab0 --- /dev/null +++ b/test/commands/test_cat.py @@ -0,0 +1,47 @@ +import os + +from project.execution.commands.cat import Cat + + +def setup_module(module): + print("basic setup module") + + +def teardown_module(module): + print("basic teardown module") + + +FILE_NAME = "capital.txt" +FILE_NAME_NOT_EXISTS = "" +str_test: str = "Hyderabad Itanagar Dispur Patna Raipur" + + +def create_file(): + with open(FILE_NAME, "w+") as file: + file.write(str_test) + + +def remove_file(): + os.remove(FILE_NAME) + + +def test_stdin(): + stdin = "Hello, world" + cat = Cat() + cat.execute(stdin) + assert stdin == cat.stdout + + +def test_file_exists(): + create_file() + cat = Cat([FILE_NAME]) + cat.execute() + remove_file() + print(cat.stdout) + assert str_test == cat.stdout + + +def test_file_not_exists(): + cat = Cat([FILE_NAME_NOT_EXISTS]) + cat.execute("") + assert cat.stderr != "" diff --git a/test/commands/test_echo.py b/test/commands/test_echo.py new file mode 100644 index 0000000..4acaf17 --- /dev/null +++ b/test/commands/test_echo.py @@ -0,0 +1,26 @@ +import pytest +import project # on import will print something from __init__ file +from project.execution.commands.echo import Echo + + +def setup_module(module): + print("basic setup module") + + +def teardown_module(module): + print("basic teardown module") + + +def test_basic(): + text_to_ignore = "This text should be ignored" + text_to_return = "This text should be returned" + echo = Echo(text_to_return.split()) + echo.execute() + assert echo.stdout != text_to_ignore + assert echo.stdout == text_to_return + + +def test_empty(): + empty_echo = Echo([]) + empty_echo.execute() + assert not empty_echo.stdout diff --git a/test/commands/test_exit.py b/test/commands/test_exit.py new file mode 100644 index 0000000..414b9c0 --- /dev/null +++ b/test/commands/test_exit.py @@ -0,0 +1,16 @@ +import pytest +import project # on import will print something from __init__ file +from project.execution.commands.exit import Exit + + +def setup_module(module): + print("basic setup module") + + +def teardown_module(module): + print("teardown module") + + +def test_exit(): + with pytest.raises(SystemExit): + Exit().execute("") diff --git a/test/commands/test_global_executable.py b/test/commands/test_global_executable.py new file mode 100644 index 0000000..c41e602 --- /dev/null +++ b/test/commands/test_global_executable.py @@ -0,0 +1,28 @@ +import os + +import pytest +import project # on import will print something from __init__ file +from project.execution.commands.global_executable import GlobalExecutor + + +def setup_module(module): + print("basic setup module") + + +def teardown_module(module): + print("basic teardown module") + + +# def test_poweroff(): +# assert True +# GlobalExecutor(["poweroff"]).execute() +# assert True + + +def test_ls(): + ge = GlobalExecutor(["ls", "-al"]) + ge.execute() + filename = os.path.basename(__file__) + print(filename) + print(ge.stdout) + assert ge.stdout.__contains__(filename) or ge.stdout.__contains__("README.md") diff --git a/test/commands/test_grep.py b/test/commands/test_grep.py new file mode 100644 index 0000000..b3dc408 --- /dev/null +++ b/test/commands/test_grep.py @@ -0,0 +1,68 @@ +import os + +from project.execution.commands.grep import Grep + + +def setup_module(module): + print("basic setup module") + + +def teardown_module(module): + print("basic teardown module") + + +FILE_NAME = "capital.txt" +str_test: str = """Hyderabad Itanagar +Dispur Patna Raipur, itanagar +Random Text Hello World""" + + +def create_file(): + with open(FILE_NAME, "w+") as file: + file.write(str_test) + + +def remove_file(): + os.remove(FILE_NAME) + + +def test_easy_pattern(): + create_file() + grep = Grep(["a", f"./{FILE_NAME}"]) + grep.execute(None) + remove_file() + str_answer: str = """./capital.txt: Hyderabad Itanagar +./capital.txt: Dispur Patna Raipur, itanagar +./capital.txt: Random Text Hello World""" + assert str_answer == grep.stdout + + +def test_search_word(): + create_file() + grep = Grep(["-w", "Hyderabad", f"./{FILE_NAME}"]) + grep.execute(None) + remove_file() + str_answer = "./capital.txt: Hyderabad Itanagar\n" + assert str_answer == grep.stdout + + +def test_flag_i_word(): + create_file() + grep = Grep(["-w", "itanagar", "-i", f"./{FILE_NAME}"]) + grep.execute(None) + remove_file() + str_answer = """./capital.txt: Hyderabad Itanagar +./capital.txt: Dispur Patna Raipur, itanagar +""" + assert str_answer == grep.stdout + + +def test_flag_a_word(): + create_file() + grep = Grep(["-w", "Itanagar", "-i", "-A", "1", f"./{FILE_NAME}"]) + grep.execute(None) + remove_file() + str_answer = """./capital.txt: Hyderabad Itanagar +./capital.txt: Dispur Patna Raipur, itanagar +./capital.txt: Random Text Hello World""" + assert str_answer == grep.stdout diff --git a/test/commands/test_pwd.py b/test/commands/test_pwd.py new file mode 100644 index 0000000..b7bc076 --- /dev/null +++ b/test/commands/test_pwd.py @@ -0,0 +1,16 @@ +from project.execution.commands.pwd import * + + +def setup_module(module): + print("basic setup module") + + +def teardown_module(module): + print("basic teardown module") + + +def test_1(): + pwd = PWD() + pwd.execute("") + assert os.getcwd() == pwd.stdout + assert pwd.ret_code == 0 diff --git a/test/commands/test_wc.py b/test/commands/test_wc.py new file mode 100644 index 0000000..b3a11be --- /dev/null +++ b/test/commands/test_wc.py @@ -0,0 +1,36 @@ +import os +import pathlib +import pytest +import project # on import will print something from __init__ file +from project.execution.commands.wc import * + + +def setup_module(module): + print("basic setup module") + + +def teardown_module(module): + print("basic teardown module") + + +def test_stdin(): + wc = WC() + test_example = "Andhra Pradesh\nArunachal Pradesh\nAssam Bihar\nChhattisgarh" + wc.execute(test_example) + assert wc.stderr == "" + assert wc.stdout == " 4 7 58" + assert wc.ret_code == 0 + + +def test_from_file(): + file_name = str(pathlib.Path(__file__).parent) + "/capitals.txt" + wc = WC([file_name]) + capitals: list[str] = ["Hyderabad", "Itanagar", "Dispur", "Patna", "Raipur"] + with open(file_name, "w+") as file: + for capital in capitals: + file.write(capital) + wc.execute("") + os.remove(file_name) + assert wc.stderr == "" + assert wc.stdout == f" 0 1 34 {file_name}" + assert wc.ret_code == 0 diff --git a/test/parsing/test_construct.py b/test/parsing/test_construct.py new file mode 100644 index 0000000..ec9210d --- /dev/null +++ b/test/parsing/test_construct.py @@ -0,0 +1,30 @@ +import pytest +import project # on import will print something from __init__ file +from project.execution.commands.cat import Cat +from project.execution.commands.pwd import PWD +from project.parsing.constructor import Constructor + + +def setup_module(module): + print("basic setup module") + + +def teardown_module(module): + print("basic teardown module") + + +def test_construct_pwd(): + constructor = Constructor() + assert constructor.construct(["pwd"]).__class__ == PWD + assert constructor.construct(["pwd", "a", "b"]).__class__ == PWD + + +def test_construct_cat(): + constructor = Constructor() + my_cat = constructor.construct(["cat", "text.txt"]) + assert my_cat.__class__ == Cat + assert my_cat.arguments == ["text.txt"] + assert constructor.construct(["cat", "text1.txt", "text2.txt"]).arguments == [ + "text1.txt", + "text2.txt", + ] diff --git a/test/parsing/test_lexer.py b/test/parsing/test_lexer.py new file mode 100644 index 0000000..304037d --- /dev/null +++ b/test/parsing/test_lexer.py @@ -0,0 +1,25 @@ +import pytest +import project # on import will print something from __init__ file +from project.parsing.lexer import Lexer + + +def setup_module(module): + print("basic setup module") + + +def teardown_module(module): + print("basic teardown module") + + +def test_1(): + assert Lexer.lex("") == [] + assert Lexer.lex("pwd") == ["pwd"] + assert Lexer.lex('echo "cat"') == ["echo", '"cat"'] + assert Lexer.lex('echo "|cat"') == ["echo", '"|cat"'] + + +def test_2(): + assert Lexer.lex('echo | "cat"') == ["echo", "|", '"cat"'] + assert Lexer.lex("echo ; 'cat'") == ["echo", ";", "'cat'"] + assert Lexer.lex("echo = 'cat'") == ["echo", "=", "'cat'"] + assert Lexer.lex("echo=cat") == ["echo", "=", "cat"] diff --git a/test/parsing/test_substitution.py b/test/parsing/test_substitution.py new file mode 100644 index 0000000..706d218 --- /dev/null +++ b/test/parsing/test_substitution.py @@ -0,0 +1,40 @@ +import pytest +import project # on import will print something from __init__ file +from project.application.context_manager import ContextManager +from project.parsing.substituter import Substituter + + +def setup_module(module): + print("basic setup module") + + +def teardown_module(module): + print("basic teardown module") + + +def test_single_quotes(): + assert Substituter.substitute("'a'") == "a" + assert Substituter.substitute("'a b'") == "a b" + assert Substituter.substitute("'a | b'") == "a | b" + + +def test_double_quotes(): + assert Substituter.substitute('"a"') == "a" + assert Substituter.substitute('"a b"') == "a b" + assert Substituter.substitute('"a | b"') == "a | b" + + +def test_easy(): + ContextManager().set_var("name", "world") + ContextManager().set_var("x", "3") + ContextManager().set_var("y", "4") + assert "Hello, world" == Substituter.substitute("Hello, $name") + assert "3+4=7" == Substituter.substitute("$x+$y=7") + assert "world 34" == Substituter.substitute("$name $x$y") + ContextManager()._clear() + + +def test_not_exists_substitution(): + assert "" == Substituter.substitute("$name_world") + assert "" == Substituter.substitute("$x") + assert "" == Substituter.substitute("$y") diff --git a/test/test_pipe.py b/test/test_pipe.py new file mode 100644 index 0000000..c16ed15 --- /dev/null +++ b/test/test_pipe.py @@ -0,0 +1,146 @@ +import os +import pathlib +import sys + +from project.application.application import Application +from project.application.context_manager import ContextManager + + +def setup_module(module): + print("basic setup module") + + +def teardown_module(module): + print("basic teardown module") + + +FILE_IN = str(pathlib.Path(__file__).parent) + "/test_in.txt" +FILE_OUT = str(pathlib.Path(__file__).parent) + "/test_out.txt" +TEST_TEXT_IN = ["echo 'hello world' | cat\n", "echo 'alone' | cat\n"] +TEST_TEXT_OUT = ["hello world\n", "alone\n"] + + +def init_files(input_list: list[str] = TEST_TEXT_IN): + i = open(FILE_IN, "w") + i.write("".join(input_list)) + i.write("exit") + + +def delete_files(): + os.remove(FILE_IN) + os.remove(FILE_OUT) + + +class ModuleTest: + def __init__(self, input_str=None): + self.out = None + self.i = None + self.tmp_out = None + self.tmp_in = None + ContextManager()._clear() + self.app = Application() + if input_str: + init_files(input_str) + else: + init_files() + + def change_streams(self): + self.tmp_in = sys.stdin + self.tmp_out = sys.stdout + self.i = open(FILE_IN, "r") + self.out = open(FILE_OUT, "w") + sys.stdin = self.i + sys.stdout = self.out + + def run(self): + try: + self.app.run() + except BaseException: + ... + + def return_streams(self): + sys.stdin = self.tmp_in + sys.stdout = self.tmp_out + self.i.close() + self.out.close() + + def __del__(self): + delete_files() + + +def test_easy(): + test = ModuleTest() + test.change_streams() + test.run() + test.return_streams() + with open(FILE_OUT) as f: + for out_app, out in zip(f.readlines(), TEST_TEXT_OUT): + assert out_app == out + + +def test_some_inputs(): + test = ModuleTest( + [ + "var='sdf|fds'\n", + "echo $var\n", + ] + ) + test.change_streams() + test.run() + test.return_streams() + with open(FILE_OUT) as f: + for out_app, out in zip( + f.readlines(), + [ + "sdf|fds\n", + ], + ): + assert out_app == out + + +def test_empty(): + test = ModuleTest( + [ + "echo 'fff' | echo\n", + ] + ) + test.change_streams() + test.run() + test.return_streams() + with open(FILE_OUT) as f: + for out_app, out in zip(f.readlines(), [""]): + assert out_app == out + + +def test_empty(): + test = ModuleTest(["echo 'fff' | echo\n", "var=123 | echo $var | cat\n"]) + test.change_streams() + test.run() + test.return_streams() + with open(FILE_OUT) as f: + for out_app, out in zip(f.readlines(), ["", ""]): + assert out_app == out + + +def test_many_cat(): + test = ModuleTest( + [ + "echo 'fff' | cat | cat | cat | cat | cat | cat | cat | cat | cat | cat | cat\n" + ] + ) + test.change_streams() + test.run() + test.return_streams() + with open(FILE_OUT) as f: + for out_app, out in zip(f.readlines(), ["fff\n"]): + assert out_app == out + + +def test_without_space(): + test = ModuleTest(["echo 'F'|cat\n"]) + test.change_streams() + test.run() + test.return_streams() + with open(FILE_OUT) as f: + for out_app, out in zip(f.readlines(), ["F\n"]): + assert out_app == out