diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..e0cc78f
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,27 @@
+**/__pycache__
+**/.venv
+**/.classpath
+**/.dockerignore
+**/.env
+**/.git
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/bin
+**/charts
+**/docker-compose*
+**/compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml
+LICENSE
+README.md
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5a050c7
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,154 @@
+### TODO: This was taken from a random repo. Update it to be more relevant to this project.
+# debug
+output/
+.dev/
+.vscode/
+..vscode/
+.git/
+
+# package files
+config.json
+default_config.json
+
+# General
+*.ipynb
+.pytype
+.DS_Store
+.vscode
+.idea
+mypy_report
+docs/build
+docs/source/_build
+tools/*.txt
+playground/
+
+# 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/
+*.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/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+.hypothesis/
+.pytest_cache/
+.nox/
+*.pstats
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# pyenv
+.python-version
+
+# celery beat schedule file
+celerybeat-schedule
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+__pycache__/
+*.pyc
+*.pyo
+*.pyd
+*.pyc
+*.so
+*.egg-info/
+dist/
+build/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+/docs-offline
+/mkdocs-nav-online.yml
+/mkdocs-nav-offline.yml
+
+# mypy
+.mypy_cache/
+
+# Snapshot testing report output directory
+tests/snapshot_tests/output
+
+# Sandbox folder - convenient place for us to develop small test apps without leaving the repo
+sandbox/
+
+# Cache of screenshots used in the docs
+.screenshot_cache
+
+# Used by mkdocs-material social plugin
+.cache
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..58c63fb
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023 Quaternion Media
+
+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.
\ No newline at end of file
diff --git a/README.md b/README.md
index c5f0c2c..9cb53db 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,122 @@
-# CodeCartographer
-Dev project for mapping code
+# Codecarto:
+
+Development tool for mapping source code.
+
+Create graphs.
+
+Plot the graphs.
+
+Create JSON object of the graph.
+
+---
+
+## Installation
+
+### From pypi:
+
+```
+python -m venv venv
+
+.\venv\Scripts\activate
+
+pip install codecarto
+```
+
+### From Git [dev use]:
+
+clone repo
+
+open terminal
+
+navigate to repo
+
+```
+python -m venv venv
+
+.\venv\Scripts\activate
+
+pip install -e .
+```
+
+---
+
+## Usage
+
+### Help Information
+
+Check this first to see all usage information.
+
+```
+codecarto help
+```
+
+### Check output dir:
+
+To show the current output directory
+
+```
+codecarto output
+```
+
+### Change output:
+
+-s | --set : options can be used to set the output directory
+
+If directory does not exist, will ask if you'd like to make it
+
+```
+codecarto output -s DIR_PATH
+```
+
+### Demo:
+
+Parse the package source code
+
+```
+codecarto demo
+```
+
+### Passed file:
+
+Can pass a file or running script of source code in.
+
+```
+codecarto FILE_PATH
+```
+
+---
+
+## Testing
+
+Can test the package using nox commands.
+
+### All Tests
+
+```
+nox
+```
+
+### Session Tests
+
+Test the use of package as an imported library.
+
+```
+nox -s unit_test
+```
+
+Test the package CLI commands.
+
+```
+nox -s test_dir
+nox -s test_help
+nox -s test_output
+nox -s test_palette
+nox -s test_palette_import
+nox -s test_palette_export
+nox -s test_palette_reset
+nox -s test_palette_types
+nox -s test_palette_new
+nox -s test_demo
+nox -s test_empty
+nox -s test_file
+```
diff --git a/docker-compose.debug.yml b/docker-compose.debug.yml
new file mode 100644
index 0000000..bb7b60b
--- /dev/null
+++ b/docker-compose.debug.yml
@@ -0,0 +1,17 @@
+# version: '3.4'
+
+# services:
+# codecartographer:
+# image: codecartographer
+# build:
+# context: .
+# dockerfile: ./Dockerfile
+# command:
+# [
+# 'sh',
+# '-c',
+# "pip install debugpy -t /tmp && python /tmp/debugpy --wait-for-client --listen 0.0.0.0:5678 -m uvicorn src.codecarto\__init__:app --host 0.0.0.0 --port 2000",
+# ]
+# ports:
+# - 2000:2000
+# - 5678:5678
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..cc354db
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,37 @@
+version: '3'
+
+services:
+ web:
+ build:
+ context: .
+ dockerfile: ./src/codecarto/containers/web/Dockerfile
+ ports:
+ - '2000:2000'
+ networks:
+ - external_network
+ - internal_network
+ volumes:
+ - ./src/codecarto/containers/web/src:/app/src
+
+ processor:
+ build:
+ context: .
+ dockerfile: ./src/codecarto/containers/processor/Dockerfile
+ networks:
+ - internal_network
+
+ database:
+ image: mongo:latest
+ ports:
+ - '27017:27017'
+ environment:
+ MONGO_INITDB_ROOT_USERNAME: root
+ MONGO_INITDB_ROOT_PASSWORD: examplepassword
+ networks:
+ - internal_network
+
+networks:
+ external_network:
+ driver: bridge
+ internal_network:
+ driver: bridge
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..bc48852
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,59 @@
+[tool.poetry]
+name = "codecarto"
+version = "0.2.0"
+homepage = "https://github.com/QuaternionMedia/codecarto"
+description = "A tool used to analyze and graph source code."
+authors = ['"Quaternion Media" Code Cartographer
+
+
+
+Palette
+
+
+
+ Default Palette Properties
+
+
`
+ }
+ // remove last
+ content = content.slice(0, -4)
+ content += `
`
+ } else {
+ // If the value is not an object (file)
+ if (key === 'file') {
+ content += `
`
+ } else {
+ content += `
`
+ }
+ }
+ }
+
+ return content
+}
diff --git a/src/codecarto/containers/web/src/pages/parse/__init__.py b/src/codecarto/containers/web/src/pages/parse/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/codecarto/containers/web/src/pages/parse/parse.css b/src/codecarto/containers/web/src/pages/parse/parse.css
new file mode 100644
index 0000000..5604ada
--- /dev/null
+++ b/src/codecarto/containers/web/src/pages/parse/parse.css
@@ -0,0 +1,38 @@
+/* parse.html specific styles */
+
+#graph_desc {
+ font-size: 20px;
+}
+#githubUrl {
+ width: 500px;
+ height: 20px;
+ border-radius: 5px;
+ padding-left: 5px;
+ margin-left: 5px;
+}
+
+.gitLink,
+.jsonLink,
+.plotLink {
+ color: #e1c48f;
+}
+.gitLink:link,
+.jsonLink:link,
+.plotLink:link {
+ color: #e1c48f;
+}
+.gitLink:visited,
+.jsonLink:visited,
+.plotLink:visited {
+ color: #c29e63;
+}
+.gitLink:hover,
+.jsonLink:hover,
+.plotLink:hover {
+ color: #e8dbad;
+}
+.gitLink:active,
+.jsonLink:active,
+.plotLink:active {
+ color: #e8dbad;
+}
diff --git a/src/codecarto/containers/web/src/pages/parse/parse.html b/src/codecarto/containers/web/src/pages/parse/parse.html
new file mode 100644
index 0000000..524554d
--- /dev/null
+++ b/src/codecarto/containers/web/src/pages/parse/parse.html
@@ -0,0 +1,43 @@
+{% extends "base.html" %} {% block title %} - Parse{% endblock %} {% block
+content %}
+
+
+
+
+
+
+
+Parser
+
+
+
+
+
+ `
+ for (const [key, value] of Object.entries(obj)) {
+ html += `
`
+ }
+ // Return the html
+ return html
+}
+
+/**
+ * Get the directories and files from the given GitHub URL.
+ */
+async function handleGithubURL() {
+ // Check if the url input is blank or not
+ if (document.getElementById('githubUrl').value === '') {
+ document.getElementById('url_content').innerHTML = 'Please enter a URL'
+ return
+ } else {
+ try {
+ // Get the url from the input, and encode it
+ document.getElementById('url_content').innerHTML = ''
+ document.getElementById('github_loader').style.display = 'inline'
+ let githubUrl = document.getElementById('githubUrl').value
+ if (githubUrl[githubUrl.length - 1] !== '/') {
+ githubUrl += '/'
+ }
+ const encodedGithubUrl = encodeURIComponent(githubUrl)
+ const href_line = `/parser/handle_github_url?github_url=${encodedGithubUrl}`
+ const response = await fetch(href_line)
+ const responseData = await response.json()
+
+ if (response.ok) {
+ // Check if the response is an error from the backend
+ if (responseData.status === 'error') {
+ displayError(
+ 'url_content',
+ responseData.message,
+ `Error with response data: ${responseData.detail}`
+ )
+ } else {
+ // Refactor the data and display it
+ const data = responseData.results
+ const refactoredData = refactorGitHubData(data)
+ document.getElementById('url_content').innerHTML = refactoredData
+ attachCollapsibleListeners()
+ }
+ } else {
+ displayError(
+ 'url_content',
+ 'API Error',
+ `Error with response status: ${response.status}`
+ )
+ }
+ } catch (error) {
+ displayError(
+ 'url_content',
+ 'JS Error',
+ `Error - parse.js - handleGithubURL(): ${error}`
+ )
+ } finally {
+ document.getElementById('github_loader').style.display = 'none'
+ }
+ }
+}
+
+/**
+ * Attach collapsible listeners to the collapsible buttons.
+ * @param {Object} data - The GitHub data to be converted.
+ * @return {string} - The formatted HTML content.
+ */
+function refactorGitHubData(data) {
+ // Check if the data is null
+ let html = ''
+ if (!data) {
+ html += `There is no content to display`
+ } else {
+ // Extract the owner, repo, and contents from the data
+ const dataOwner = data.package_owner
+ const dataRepo = data.package_name
+ const dataContents = data.contents
+ const dataDict = { contents: dataContents }
+ const contentHtml = handleGitHubData(dataDict)
+ // Add package owner and name to the html
+ html += ``
+ html += `Package Owner: ${dataOwner}
`
+ html += `${contentHtml}`
+ }
+ // Return the html
+ return html
+}
+
+/**
+ * Create a collapsible button structure from the given GitHub data.
+ * @param {Object} data - The GitHub data to be converted.
+ * @param {boolean} nested - Whether the data is nested or not.
+ * @return {string} - The formatted HTML content.
+ */
+function handleGitHubData(data, nested = false) {
+ // Iterate through the data
+ let content = ''
+ for (const [key, value] of Object.entries(data)) {
+ if (typeof value === 'object') {
+ // If the key is "files", it's a list of filenames
+ if (key === 'files') {
+ link_style = 'style="color: #e1c48f; text-decoration: none;"'
+ for (const file of value) {
+ let json_link = `/polygraph/raw_to_json?file_url=${file['download_url']}`
+ let plot_link = `/plotter/?file_url=${file['download_url']}`
+ content += `
`
+ html += `Package Name: ${dataRepo}`
+ html += `Plotter
+
+
+
+
+ Plot Demo Graph
+
+
+
+
+
+
+
+
Report generated on 01-Aug-2023 at 14:30:59 by pytest-html v3.2.0
+1 tests ran in 10.00 seconds.
+(Un)check the boxes to filter the results.
0 passed, 0 skipped, 1 failed, 0 errors, 0 expected failures, 0 unexpected passes +Result | +Test | +Duration | +Links |
---|---|---|---|
No results found. Try to check the filters | |||
Failed | +tests/test_cli/test_cli_palette/test_cli_palette_new.py::test_palette_new | +3.43 | +|
+ [gw1] win32 -- Python 3.11.4 D:\Users\Cameron\Documents\Projects\Programming\repos\qm\CodeCartographer\.nox\debug-3-11\Scripts\python.EXE [gw1] win32 -- Python 3.11.4 D:\Users\Cameron\Documents\Projects\Programming\repos\qm\CodeCartographer\.nox\debug-3-11\Scripts\python.EXE[gw1] win32 -- Python 3.11.4 D:\Users\Cameron\Documents\Projects\Programming\repos\qm\CodeCartographer\.nox\debug-3-11\Scripts\python.EXE def test_palette_new(): """Test the palette new command.""" with tempfile.TemporaryDirectory() as temp_dir: #TODO: set up a test default_config.json file in the temp_dir # difficult to use the codecarto from temp dir or nox env # because the codecarto is installed in the base env # need to change this so that when the codecarto is installed # it will set up default_config.json, don't want to save it in the repo ########### Helper functions ########### # have to create these here to maintain # the scope of the temp_file_path variable def assert_result(expected, actual): try: assert str(expected) in str(actual) except AssertionError as e: raise AssertionError(f"Error in test_cli_palette_new.py: " f"\n\nExpected:\n---------\n{expected}\n" f"\n\nActual:\n-------\n{actual}\n" ) def get_palette_data(): """Return the palette data.""" import json # get the palette data from the temp_palette_new.json file with open(temp_file_path, "r") as f: palette_data = json.load(f) return palette_data def check_new_data(command): """Check that the new type is in palette.json and has correct params.""" palette_data = get_palette_data() for command in commands: assert_result(command[3],palette_data["bases"]) assert_result(command[5],palette_data["labels"]) assert_result(command[6],palette_data["shapes"]) assert_result(command[7],palette_data["colors"]) assert_result(int(command[8])*100,palette_data["sizes"]) assert_result(round(0.1 * int(command[9]), ndigits=1),palette_data["alphas"]) def check_output(command, result, input:str = ""): """Check the output of the command.""" actual_output = result.stdout if input == "": # check that the new type added prompt is in the output assert_result((f"\nNew theme added to palette: {temp_file_path}"),actual_output) assert_result((f"New theme '{command[3]}' created with parameters: " f"base={command[4]}, label={command[5]}, shape={command[6]}, color={command[7]}, " f"size={(int(command[8])*100)}, alpha={round(0.1 * int(command[9]), ndigits=1)}\n"),actual_output) elif input == "n\n": assert_result((f"\n{command[3]} already exists. \n " f"base:{command[4]}" f"label:{command[5]}" f"shape:{command[6]}" f"color:{command[7]}" f"size={(int(command[8])*100)}, alpha={round(0.1 * int(command[9]), ndigits=1)}\n" f"\n\nOverwrite? Y/N "),actual_output) elif input == "y\n": assert_result((f"\n{command[3]} already exists. \n " f"base:{command[4]}" f"label:{command[5]}" f"shape:{command[6]}" f"color:{command[7]}" f"size={(int(command[8])*100)}, alpha={round(0.1 * int(command[9]), ndigits=1)}\n" f"\n\nOverwrite? Y/N "),actual_output) assert_result((f"\nNew theme added to palette: {temp_file_path}"),actual_output) assert_result((f"New theme '{command[3]}' created with parameters: " f"base={command[4]}, label={command[5]}, shape={command[6]}, color={command[7]}, " f"size={(int(command[8])*100)}, alpha={round(0.1 * int(command[9]), ndigits=1)}\n"),actual_output) def get_config_prop(_prop_name:str): """Get the config properties.""" from codecarto import Config return Config().config_data[_prop_name] def set_config_prop(_prop_name:str, _value:str = "reset"): """Set the config properties.""" from codecarto import Config if _value == "reset": Config().reset_config_data() else: Config().set_config_property(_prop_name, _value) def reset_palette_manually(temp_file_path): """Reset the palette manually.""" import os import shutil # get the default palette default_palette_path = get_config_prop("default_palette_path") # delete the appdata palette if os.path.exists(temp_file_path): os.remove(temp_file_path) # copy the default palette to the appdata directory shutil.copy(default_palette_path, temp_file_path) def check_palette_matches_default(temp_file_path) -> bool: """Check if the palette is the same as the default palette.""" import json # get the default palette default_palette_path = get_config_prop("default_palette_path") # check if the palette file is the same as the default palette with open(default_palette_path, "r") as f: default_palette = json.load(f) with open(temp_file_path, "r") as f: appdata_palette = json.load(f) if default_palette == appdata_palette: return True else: return False ########### Test functions ########### # create a temporary file for palette.json temp_file_name = "test_palette_new.json" temp_file_path = os.path.join(temp_dir, temp_file_name) > set_config_prop("palette_file_name", temp_file_name) tests\test_cli\test_cli_palette\test_cli_palette_new.py:133: _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ tests\test_cli\test_cli_palette\test_cli_palette_new.py:87: in set_config_prop from codecarto import Config .nox\debug-3-11\Lib\site-packages\codecarto\__init__.py:45: in <module> from .json.json_graph import JsonGraph .nox\debug-3-11\Lib\site-packages\codecarto\json\json_graph.py:4: in <module> from ..palette.palette import Palette .nox\debug-3-11\Lib\site-packages\codecarto\palette\palette.py:3: in <module> from ..utils.directory.palette_dir import PALETTE_DIRECTORY .nox\debug-3-11\Lib\site-packages\codecarto\utils\directory\palette_dir.py:106: in <module> "name": get_palette_appdata_file_name(), .nox\debug-3-11\Lib\site-packages\codecarto\utils\directory\palette_dir.py:69: in get_palette_appdata_file_name config: Config = Config() .nox\debug-3-11\Lib\site-packages\codecarto\config\config.py:14: in __init__ self.config_path = self.get_config_path() .nox\debug-3-11\Lib\site-packages\codecarto\config\config.py:59: in get_config_path from ..utils.directory.config_dir import get_config_path .nox\debug-3-11\Lib\site-packages\codecarto\utils\directory\config_dir.py:99: in <module> "dir": os.path.dirname(get_default_config_path()), _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ def get_default_config_path() -> str: """Return the path of the default config file path. Returns: -------- str The path of the default config file path. """ from .package_dir import get_package_dir config_dir = os.path.join(get_package_dir(), "config\\default_config.json") if not os.path.exists(config_dir): > raise RuntimeError("Config directory not found. Package may be corrupted.") E RuntimeError: Config directory not found. Package may be corrupted. .nox\debug-3-11\Lib\site-packages\codecarto\utils\directory\config_dir.py:58: RuntimeError[gw1] win32 -- Python 3.11.4 D:\Users\Cameron\Documents\Projects\Programming\repos\qm\CodeCartographer\.nox\debug-3-11\Scripts\python.EXE |
", f"" + ) + with open(index_html_path, "w") as f: + f.write(updated_html) + + +# tests on python 3.8, 3.9 and 3.11 +# nox -s unit_tests +@nox.session(python=["3.8", "3.9", "3.11"]) +def unit_tests(session): + session.install(".") + session.install("matplotlib") # needed to close the matplot show window + session.install( + "pytest", "pytest-html", "pytest-cov", "pytest-xdist" + ) # needed to run pytest and pytest extensions + session.run( + "python", + "-m", + "cProfile", + "-o", + "profile_output.pstats", + "-m", + "pytest", + "tests", + "-n", + "4", + "--confcutdir=tests/test_reports/assets", + "--cov=codecarto", + "--cov-report=html:tests/test_reports/coverage", + "--html=tests/test_reports/report.html", + "--css=tests/test_reports/assets/codecarto.css", + ) + inject_js_to_coverage_report( + "tests/test_reports/assets/add_link.js", "tests/test_reports/coverage" + ) + + +# can use this to test a specific file +# nox -s debug +@nox.session(python=["3.11"]) +def debug(session): + file_path = "tests/test_cli/test_cli_palette/test_cli_palette_new.py" + session.install(".") + session.install("matplotlib") # needed to close the matplot show window + session.install( + "pytest", "pytest-html", "pytest-cov", "pytest-xdist" + ) # needed to run pytest and pytest extensions + session.run( + "python", + "-m", + "cProfile", + "-o", + "profile_output.pstats", + "-m", + "pytest", + "-vv", + file_path, + "-n", + "4", + "--confcutdir=tests/test_reports/assets", + "--cov=codecarto", + "--cov-report=html:tests/test_reports/coverage", + "--html=tests/test_reports/report.html", + "--css=tests/test_reports/assets/codecarto.css", + ) diff --git a/src/codecarto/local/src/__init__.py b/src/codecarto/local/src/__init__.py new file mode 100644 index 0000000..2847cc9 --- /dev/null +++ b/src/codecarto/local/src/__init__.py @@ -0,0 +1,125 @@ +### codecarto\__init__.py +############################################################################## +# TODO: extend these docstrings with new classes + +# DO NOT REMOVE THIS FOLDER! THIS IS A WORKING PACKAGE FOR LOCAL USE ONLY! + +############################################################################## +"""A tool used to analyze and graph source code. \n + +This package is used to analyze source code and create a graph of the \n +relationships between the various components of the code. The graph can be \n +saved as a json file and/or plotted as a graph image. \n + +The package is designed to be used as a command line tool, but can also be \n +used as a library. \n + +Classes: +-------- + PolyGraph - A class used to create a json representation of a graph. \n + Functions: \n + graph_to_json_file - A function used to convert a networkx graph to a json object. \n + json_file_to_graph - A function used to convert a json object to a networkx graph. \n + graph_to_json_data - A function used to convert a networkx graph to a json object. \n + json_data_to_graph - A function used to convert a json object to a networkx graph. \n + graphdata_to_nx - A function used to convert a GraphData object to a networkx graph. \n + + Palette - A class used to create a color palette. \n + Attributes: \n + colors - A list of colors to use for the palette. \n + + Functions: \n + get_color - A function used to get a color from the palette. \n + Parser - A class used to parse source code. \n + Processor - A class used to process source code. \n + Plotter - A class used to plot a graph. +""" + +from .config.config_process import initiate_package + +initiate_package() + +########################### IMPORTS ####################################### +# # Import the sub modules to make them easier to get at +# from .codecarto import ( +# Config, +# DirectoryHandler as Directory, +# GraphData, +# LogHandler, +# ModelHandler as Model, +# PaletteHandler as Palette, +# ParserHandler as Parser, +# PlotterHandler as Plotter, +# PolyGraphHandler as PolyGraph, +# PositionHandler as Position, +# ProcessorHandler as Processor, +# Theme, +# UtilityHandler as Utility, +# save_json, +# load_json, +# ) + + +# ########################### EXPORTS ######################################### +# # Export the submodules. +# __all__ = [ +# "Config", +# "Directory", +# "GraphData", +# "Json", +# "LogHandler", +# "Model", +# "Palette", +# "Parser", +# "Plotter", +# "PolyGraph", +# "Position", +# "Processor", +# "Theme", +# "Utility", +# ] + +# ########################### LOGGER ########################################## +# import logging + +# # create logger with 'codecarto' +# logger = logging.getLogger("codecarto") +# logger.setLevel(logging.DEBUG) # set root logger level + +# # logging log levels +# # CRITICAL = 50 +# # FATAL = CRITICAL +# # ERROR = 40 +# # WARNING = 30 +# # WARN = WARNING +# # INFO = 20 +# # DEBUG = 10 +# # NOTSET = 0 + +# # create console handler with a higher log level +# ch = logging.StreamHandler() +# ch.setLevel(logging.INFO) # set handler level + +# # create formatter and add it to the handlers +# formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") +# ch.setFormatter(formatter) + +# # add the handlers to the logger +# logger.addHandler(ch) + +# # can use logger instance in your code, like this: +# # logger.debug("This is a debug message.") +# # logger.info("This is an informational message.") +# # logger.warning("This is a warning message.") +# # logger.error("This is an error message.") +# # logger.critical("This is a critical message.") + +# # create file handler which logs even debug messages +# fh = logging.FileHandler("codecarto.log") +# fh.setLevel(logging.DEBUG) +# fh.setFormatter(formatter) +# logger.addHandler(fh) + + +############################################################################## +### __init__.py ends here diff --git a/src/codecarto/local/src/cli/__init__.py b/src/codecarto/local/src/cli/__init__.py new file mode 100644 index 0000000..182bee7 --- /dev/null +++ b/src/codecarto/local/src/cli/__init__.py @@ -0,0 +1,2 @@ +# CLI folder holds the logic for the command line interface. +# This is used for a local version of CodeCarto. \ No newline at end of file diff --git a/src/codecarto/local/src/cli/cli.py b/src/codecarto/local/src/cli/cli.py new file mode 100644 index 0000000..1f56b29 --- /dev/null +++ b/src/codecarto/local/src/cli/cli.py @@ -0,0 +1,625 @@ +from __future__ import annotations +import click +import functools +from trogon import tui + + +################### HELPER FUNCTIONS + + +def run_codecarto( + import_name: str, json: bool, labels: bool, grid: bool, show: bool, uno: bool +) -> dict | None: + """Run the codecarto package on the provided import_name. + + Args: + ----- + import_name : str + The import name of the Python file to visualize. + + Optional Args: + -------------- + Using the options sets them to true, not using them sets them to false. + + json : bool + Whether to convert the json data to a graph and plot. + labels : bool + Whether to display labels on the graph. + grid : bool + Whether to display a grid on the graph. + show : bool + Whether to show the graph plot. + uno : bool + Whether to run for a single file or all of source directory. + + Returns + ------- + dict | None + The output directories of the package. + """ + from ..processor import process + + output_dirs: dict = process( + file_path=import_name, + plot=True, + labels=labels, + json=json, + grid=grid, + show_plot=show, + single_file=uno, + ) + return output_dirs + + +def get_version(): + """Get the version of the codecarto package.""" + from ..config.directory.package_dir import CODE_CARTO_PACKAGE_VERSION + + return CODE_CARTO_PACKAGE_VERSION + + +def print_help(): + """Print the usage information, command descriptions, \n + and links to documentation for valid types, colors, and shapes.""" + help_text = """ +Usage: + codecarto config + -s | --set DIR + codecarto demo + -l | --labels (default True) + -g | --grid (default False) + -s | --show (default False) + -j | --json (default False) + -d | --dir (default False) + -u | --uno (default False) + codecarto dir + codecarto help | -h | --help + codecarto output -s | --set DIR + codecarto FILE + -l | --labels (default False) + -g | --grid (default False) + -s | --show (default False) + -j | --json (default False) + -d | --dir (default False) + -u | --uno (default False) + codecarto palette + -i | --import FILE + -e | --export DIR + -t | --types + -n | --new PARAMS + +Command Description: + config : Show the various directories used by package. + --set | -s : Set the config file to the provided directory. + dir : Show the various directories used by package. + help : Display this information + output : Show the output directory. + --set | -s : Set the output directory to the provided directory. + --reset | -r : Reset the output directory to the default directory. + demo : Runs the package on itself. + FILE : The path of the Python file to visualize + FILE & demo Options: + --labels | -l : Display labels on the graph. Default is False. + --grid | -g : Display a grid on the graph. Default is False. + --show | -s : Show the graph plot. Default is False. + --json | -j : Converts json data to graph and plots. Default is False. + --dir | -d : Prints passed file's source code to be used in process. + Does NOT run the package. Default is False. + --uno | -u : Whether to run for a single file or all of source directory. Default is False. + Examples: + codecarto foo.py -l --grid --json + codecarto demo -labels -g -show + palette : Show the directory of palette.json and shows current themes. + --import | -i : Import palette from a provided JSON file path. + --export | -e : Export package palette.json to a provided directory. + --types | -t : Display the styles for all types or for a specific type. + --new | -n : Create a new theme with the specified parameters. + PARAMS must be in the format: TYPE NAME SHAPE COLOR SIZE ALPHA + Examples: + codecarto palette -n ClassDef def.class Cl o red 5 10 + codecarto palette --export EXPORT_DIR + codecarto palette -i IMPORT_FILE + +New Theme Information: + For a list of valid types : https://docs.python.org/3/library/ast.html#abstract-grammar + For a list of valid shapes : https://matplotlib.org/stable/api/markers_api.html + For a list of valid colors : https://matplotlib.org/stable/gallery/color/named_colors.html + Size must be an integer between 0 and 10. Represents [100, 200, 300, ... , 1000] size. + Alpha must be an integer between 0 and 10. Represents [0.0, 0.1, 0.2., ... , 1.0] transparency. + +TUI Command Builder: + codecarto tui + This will open a TUI that will help you build a command. + Thank you Textualize.Trogon! : https://github.com/Textualize/trogon + """ + print(help_text) + + +################### SET UP SHARED COMMANDS + + +def shared_options(func): + """Shared options for the run and demo commands.""" + + @click.option( + "--json", + "-j", + is_flag=True, + help="Whether to convert json back to graph and plot.", + ) + @click.option( + "--labels", + "-l", + is_flag=True, + help="Whether to show labels on plots.", + ) + @click.option( + "--grid", + "-g", + is_flag=True, + help="Whether to have all plots in a grid layout.", + ) + @click.option( + "--show", + "-s", + is_flag=True, + help="Whether to show plots.", + ) + @click.option( + "--dir", + "-d", + is_flag=True, + help="Prints passed file's source code to be used in process.", + ) + @click.option( + "--uno", + "-u", + is_flag=True, + help="Whether to do a single file or the whole source directory.", + ) + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + +################### SETUP HELP GROUP + + +class CustomHelpGroup(click.Group): + def get_command(self, ctx, cmd_name): + rv = click.Group.get_command(self, ctx, cmd_name) + if rv is not None: + return rv + + return self.command_not_found(ctx, cmd_name) + + def command_not_found(self, ctx, cmd_name): + """Handles scenario where cmd_name is treated as a file path.""" + from pathlib import Path + + # check if cmd_name is a file path to a Python file + file_path = Path(cmd_name) + if file_path.exists() and file_path.suffix == ".py": + # get the full path of the file if not already + file_path = file_path.resolve() + + # create a command for the file path + @click.command(name=cmd_name) + @shared_options + @click.pass_context + def run_codecarto_cmd(ctx, json, labels, grid, show, dir, uno): + if dir: + from ..parser.import_source_dir import get_all_source_files + + source_dirs: list = get_all_source_files(file_path) + print("\nPackage files to parse:") + for source_dir in source_dirs: + print(source_dir) + print() + return source_dirs + else: + print("\nStarting package process...\n") + output_dirs: dict = run_codecarto( + str(file_path), json, labels, grid, show, uno + ) + return output_dirs + + return run_codecarto_cmd + else: + # check if file_path has a suffix + if file_path.suffix != "": + # check if file_path is a Python file + if file_path.suffix != ".py": + raise click.ClickException( + f"File '{cmd_name}' is not a Python file." + ) + else: + raise click.ClickException( + f"File path '{cmd_name}' does not exist in current directory." + ) + else: + raise click.ClickException( + f"Command '{cmd_name}' not found. See --help for more information." + ) + + def get_help(self, ctx): + """Get the help text for the group. + + Args: + ----- + ctx : click.Context + The context of the command. + """ + return print_help() + + def parse_args(self, ctx, args): + """Parse the arguments for the group. + + Args: + ----- + ctx : click.Context + The context of the command. + args : list + The arguments of the command. + """ + if "-h" in args: + args.remove("-h") + ctx.invoke(self.get_command(ctx, "help")) + ctx.exit() + super().parse_args(ctx, args) + + +################### RUN COMMAND + + +@tui() +@click.group(cls=CustomHelpGroup) +@click.version_option(get_version()) +def run() -> None: + pass + + +################### HELP COMMAND + + +@run.command("help") +def run_help(): + """Display usage information.""" + print_help() + + +################### CONFIG COMMAND + + +@run.command("config") +@click.option( + "--set", + "-s", + help="Set a config property to a given value.", +) +@click.option( + "--reset", + "-r", + is_flag=True, + help="Reset the config data to the default values.", +) +def run_config(set: str, reset: bool): + """Display configuration information.""" + from ..config.config_process import ( + get_config_data, + reset_config_CLI, + set_config_property, + ) + + """Show the current config data or change it.""" + if set: + set_config_property(set) + print(f"\nConfig property has been updated.\n") + elif reset: + reset_config_CLI() + + # get the config data + config = get_config_data() + # find the maximum length of the keys + max_key_len = max([len(key) for key in config.keys()]) + 1 + # print the config + print() + for key, value in config.items(): + print(f"{key:<{max_key_len}}: {value}") + print() + + +################### DEMO COMMAND + + +@run.command("demo") +@shared_options +def demo( + json: bool, labels: bool, grid: bool, show: bool, dir: bool, uno: bool +) -> dict | None: + """Runs the package on itself. + + Optional Parameters: + ----------- + Using the options sets them to true, not using them sets them to false. + + json : bool + Whether to convert json back to graph and plot. + labels : bool + Whether to show labels on plots. + grid : bool + Whether to have all plots in a grid layout. + show : bool + Whether to show plots. Will pause process on each plot. + dir : bool + Prints passed file's source code to be used in process. + uno : bool + Whether to do a single file or the whole source directory. + + Returns: + -------- + dict | None\n + The source code directories if the dir flag is passed. + The output directories if the command is successful, otherwise None. + """ + from ..config.directory.package_dir import PROCESSOR_FILE_PATH + from ..parser.import_source_dir import get_all_source_files + + demo_file_path = PROCESSOR_FILE_PATH + if dir: + # Print source code + source_dirs: list = get_all_source_files(demo_file_path) + print("\nPackage files used in demo:") + for source_dir in source_dirs: + print(source_dir) + print() + return source_dirs + else: + # Call run_codecarto + print("\nRunning demo on package files:") + output_dirs: dict = run_codecarto(demo_file_path, json, labels, grid, show, uno) + return output_dirs + + +################### DIR COMMAND + + +@run.command("dir") +def dir() -> dict: + """Print the available directories. + + Returns: + -------- + dict\n + The available directories. + """ + from ..config.directory.directories import print_all_directories + from ..config.directory.package_dir import PROCESSOR_FILE_PATH + from ..parser.import_source_dir import get_all_source_files + + all_dirs: dict = print_all_directories() + print("Package Source Python Files:") + source_dirs: list = get_all_source_files(PROCESSOR_FILE_PATH) + all_dirs.update({"source": source_dirs}) + for path in source_dirs: + # only print files in codecarto directory + # drop the __init__.py file + if "__init__.py" not in path: + if "codecarto" in path: + # trim the path to only show the codecarto directory + path = path.split("codecarto\\")[1] + print(f"...carto\\{path}") + print() + + return all_dirs + + +################### OUTPUT COMMAND + + +@run.command("output") +@click.option( + "--set", + "-s", + metavar="DIRECTORY", + help="Set the output directory to a given file directory.", +) +@click.option( + "--reset", + "-r", + is_flag=True, + help="Set the output directory back to the package directory.", +) +def output(set: str, reset: bool): + from ..config.directory.output_dir import ( + set_output_dir, + reset_output_dir, + get_output_dir, + ) + + """Show the current output directory or change it.""" + if set: + set_output_dir(set) + print(f"\nOutput directory changed to '{set}'") + print(f"{set}\n") + elif reset: + # ask the user if they're sure they want to change the output directory + user_resp = input( + "\nAre you sure you want to reset the output directory to the default directory? (y/n) : " + ) + if user_resp.lower() == "y": + reset_output_dir() + print("\nOutput directory has been reset to the default directory.") + print("USERS\\Documents\\CodeCartographer\\output\n") + else: + print("Exiting...\n") + else: + current_output_dir = get_output_dir() + print(f"\nCurrent output directory: '{current_output_dir}'\n") + + +################### PALETTE COMMAND + + +@run.command("palette") +@click.option( + "--set", + "-s", + "set_path", + metavar="FILEPATH", + help="Set the palette to a given JSON file.", +) +@click.option( + "--import", + "-i", + "import_path", + metavar="FILEPATH", + help="Import palette from a JSON file.", +) +@click.option( + "--export", + "-e", + "export_dir", + metavar="DIRECTORY", + help="Export package palette.json to a directory.", +) +@click.option( + "--reset", + "-r", + "reset", + is_flag=True, + help="Reset the palette.json to the default_palette.json.", +) +@click.option( + "--types", + "-t", + "types", + is_flag=True, + help="Print the available node types and their corresponding properties.", +) +@click.option( + "--new", + "-n", + nargs=7, + type=(str, str, str, str, str, int, int), + help="Create a new theme with the specified parameters. 'codecarto -help' for more info.", + metavar="TYPE BASE LABEL SHAPE COLOR SIZE ALPHA", +) +def palette( + set_path: str, + import_path: str, + export_dir: str, + reset: bool, + new: bool, + types: bool, +) -> None: + """ + Prints information about the package palette.\n + Additionally, this function can be used to import and export a palette from/to a JSON file. + + Optional Args:\n + set_path (str): The filepath of the JSON file to set the palette to.\n + import_path (str): The filepath of the JSON file to import a palette from.\n + export_dir (str): The directory to export the current palette to.\n + reset (bool): Whether to reset the palette.json to the default_palette.json.\n + new (bool): Whether to create a new theme with the specified parameters.\n + types (bool): Whether to print the available node types and their corresponding properties.\n + """ + # Call the appropriate subcommand function + from ..plotter.palette import Palette + + palette: Palette = Palette() + if set_path: + palette.set_palette(set_path, True) + elif import_path: + palette.import_palette(import_path, True) + elif export_dir: + palette.export_palette(export_dir) + print(f"Palette exported to '{export_dir}'\n") + elif reset: + palette.reset_palette(True) + elif types: + palette_types(palette) + elif new: + node_type, base, label, shape, color, size, alpha = new + node_type = palette.create_new_theme( + node_type, + base, + label, + shape, + color, + palette._sizes[size - 1], + palette._alphas[alpha], + True, + ) + else: + palette_print(palette) + + +################### PALETTE COMMAND HELPERS + + +def palette_print(_palette=None): + """Print the available base themes and their corresponding properties.\n + Also prints where the user's palette.json file is located. + """ + from ..plotter.palette import Palette + + # Load palette data + palette: Palette = _palette if _palette else Palette() + palette_data = palette.get_palette_data() + + # Group the themes by base + base_themes: dict[str, list] = {} + for node_type in palette_data["bases"].keys(): + base = palette_data["bases"][node_type] + if base not in base_themes: + base_themes[base] = [] + base_themes[base].append(node_type) + + # print themes by base + for base, node_types in base_themes.items(): + max_width = max(len(prop) for prop in palette_data.keys()) + 1 + print(f"{'Base ':{max_width}}: {base}") + for prop in palette_data.keys(): + if prop != "bases": + print(f" {prop:{max_width}}: {palette_data[prop][base]}") + print() + print( + f"\nBase themes and properties can be found in 'palette.json': {palette._palette_user_path}\n" + ) + + +def palette_types(_palette=None): + """Print the available node types and their corresponding properties.\n + And some information for valid node type options.""" + from ..plotter.palette import Palette + + # Load palette data + palette: Palette = _palette if _palette else Palette() + palette_data = palette.get_palette_data() + + # Check if palette_data is not empty + if not palette_data: + raise ValueError("Palette data is empty. Please create a new theme first.") + + # Print node types + print("\nNode types and properties:\n") + for node_type in sorted(palette_data["bases"].keys()): + base = palette_data["bases"][node_type] + max_width = max(len(prop) for prop in palette_data.keys()) + 1 + print(f"{'Node_Type':{max_width}} : {node_type}") + print(f" {'base':{max_width}}: {base}") + for prop in palette_data.keys(): + if prop != "bases": + print(f" {prop:{max_width}}: {palette_data[prop][base]}") + print("") + print( + """Information: + For a list of valid node types : https://docs.python.org/3/library/ast.html#abstract-grammar + For a list of valid colors : https://matplotlib.org/stable/gallery/color/named_colors.html + For a list of valid shapes : https://matplotlib.org/stable/api/markers_api.html + """ + ) diff --git a/src/codecarto/local/src/cli/progressbar.py b/src/codecarto/local/src/cli/progressbar.py new file mode 100644 index 0000000..bf4ea69 --- /dev/null +++ b/src/codecarto/local/src/cli/progressbar.py @@ -0,0 +1,148 @@ +class ProgressBar: + """A simple progress bar for Python.""" + + def __init__(self, total, prefix = '', suffix = '', bar_length = 50, percent_decimals = 0, fill_char = 'â–ˆ', print_end = "\r", line_number = -1, extra_msg = ""): + """Initializes the progress bar. + + Parameters: + ----------- + total (int): + Total number of iterations. + prefix (str, Default: ""): + Prefix string. + suffix (str, Default: ""): + Suffix string. + bar_length (int, Default: 50): + Character length of bar. Defaults to 100. + percent_decimals (int, Default: 1): + Number of decimals to show in percentage. Defaults to 0. + fill_char (str, Default: "â–ˆ"): + Bar fill character. + print_end (str, Default: "\\r"): + End character (e.g. "\\r", "\\r\\n"). + line_number (int, Default: -1): + The line number to print the progress bar at. + extra_msg (str, Default: ""): + Extra message to append to the progress bar. + """ + self.total = total + self.prefix = prefix + self.suffix = suffix + self.decimals = percent_decimals + self.length = bar_length + self.fill = fill_char + self.print_end = print_end + self.current_line = line_number + self.end_msg = extra_msg + self.iteration = 0 + self.has_children = False + + ############ DOERS ############ + def increment(self, extra_msg: str = ""): + """Increments the progress bar by 1. + + Parameters: + ----------- + extra_msg (str, Default: ""): + Extra message to append to the progress bar. + """ + from math import floor + + if extra_msg == "" and self.end_msg != "": + extra_msg = self.end_msg + + if self.has_children: + prev_cursor_line: int = self.get_current_cursor_position()[1] # need this to print line after parent finishes + self.current_line -= 1 # this is to move the cursor to the parent bar after child has printed blank line + + self.iteration += 1 + percent = ("{0:." + str(self.decimals) + "f}").format(floor(100 * (self.iteration / float(self.total)))) + filledLength = int(self.length * self.iteration // self.total) + bar = self.fill * filledLength + '-' * (self.length - filledLength) + if extra_msg != "": + extra_msg = f" - {extra_msg}" + elif self.end_msg != "": + extra_msg = f" - {self.end_msg}" + self.move_cursor_to_current_line() + print(f'\r{self.prefix} |{bar}| {percent}% {self.suffix}{extra_msg}', end=self.print_end) + + # Check if we've completed + if self.iteration >= self.total: + # if the progress bar has children, when the parent bar finishes, + # we need to move back to after the children before printing a new line + if self.has_children: + self.set_cursor_position(0, prev_cursor_line) + + # Print New Line on Complete + print() + + def clear_line(self): + """Clears the current line.""" + print("\033[K", end="") + + def reset_iteration(self): + """Resets the progress bar iterations to 0.""" + self.iteration = 0 + self.increment() + + def move_cursor_to_current_line(self): + """Sets the cursor position to the progress bars coordinate.""" + self.set_cursor_position(0, self.current_line) + self.clear_line() + + def set_cursor_position(self, x, y): + """Sets the cursor position to a given x and y coordinate. + + Parameters: + ----------- + x (int): + The x coordinate to set the cursor to. + y (int): + The y coordinate to set the cursor to. + """ + print(f"\033[{y};{x}H", end="") + + def get_progress(self) -> float: + """Returns the current progress of the bar progress. + + Returns: + -------- + float: + The current progress of the bar progress. + """ + return self.iteration / self.total + + def get_percent(self) -> float: + """Returns the current progress of the bar progress in percentage. + + Returns: + -------- + float: + The current progress of the bar progress in percentage. + """ + return self.get_progress() * 100 + + def get_bar(self) -> str: + """Returns the current progress bar. + + Returns: + -------- + str: + The current progress bar. + """ + percent = ("{0:." + str(self.decimals) + "f}").format(self.get_percent()) + filledLength = int(self.length * self.get_progress()) + bar = self.fill * filledLength + '-' * (self.length - filledLength) + return f'\r{self.prefix} |{bar}| {percent}% {self.suffix}' + + def get_current_cursor_position(self) -> tuple: + """Returns the current cursor position. + + Returns: + -------- + tuple: + The current cursor (x, y) position. + """ + import shutil + cols, lines = shutil.get_terminal_size() + return cols, lines \ No newline at end of file diff --git a/src/codecarto/local/src/codecarto.py b/src/codecarto/local/src/codecarto.py new file mode 100644 index 0000000..f3de298 --- /dev/null +++ b/src/codecarto/local/src/codecarto.py @@ -0,0 +1,772 @@ +""" CodeCarto: A Python library for code visualization. """ + +# TODO: I talk myself back and forth for the need of this module. +# I like having the functionality pulled into one place. +# I don't like having a large file with a bunch of functions. + + +# This will pull all the package functionality to the top level which allows +# for easier use of the package. +# The actual exporting of the functions happens in the top __init__.py file. +# Doing this here also allows for tying togethr multiple functions, as well +# as validating the data passed to the functions in a single place. + +# API: API will call the functions from importing this file +# and will be the only file that needs to be imported + +# Lib: A local clone/fork/installation of the package will use +# this file to call the functions from importing this file + +# CLI: Using local CLI commands will call the functions from +# importing this file + +# CLI-API: CLI commands can be used to call the API functions +# which in turn will reference the functions from this file + +import os +import networkx as nx +from pydantic import BaseModel +from .config.config import Config +from .config.directory.package_dir import ( + CODE_CARTO_PACKAGE_VERSION, + PROCESSOR_FILE_PATH, +) +from .config.directory.directories import print_all_directories +from .config.directory.output_dir import ( + get_output_dir, + reset_output_dir, + set_output_dir, + get_last_dated_output_dirs, +) +from .models.graph_data import GraphData, get_graph_description +from .parser.parser import Parser +from .parser.import_source_dir import get_all_source_files +from .plotter.palette import Palette +from .plotter.palette_dir import PALETTE_DIRECTORY +from .plotter.plotter import Plotter +from .plotter.positions import Positions +from .polygraph.polygraph import PolyGraph +from .processor import Processor +from .utils.utils import ( + check_file_path, + get_date_time_file_format, + load_json, + save_json, +) + + +class Theme(BaseModel): + node_type: str + base: str + label: str + shape: str + color: str + size: str + alpha: str + + +class ParserHandler: + def __init__(self): + self.parser: Parser = Parser() + + def parse_source_files(self, source: str | list = None) -> nx.DiGraph: + """Parses a source file or list of source files into a networkx graph. + + Args: + ----- + source (str): The source file to get the source files from, then parse. \n + Or source (list): The list of source files to parse. + + Returns: + -------- + nx.DiGraph: The networkx graph. + """ + if source is None: + raise ValueError("No source file or list of source files provided.") + if isinstance(source, str): + source = DirectoryHandler.get_source_files(source) + parser: Parser = Parser(source) + return parser.graph + + +class PlotterHandler: + def __init__(self): + self.plotter: Plotter = Plotter() + + def set_plotter_attrs( + self, + dirs: dict[str, str] = None, + file_path: str = "", + labels: bool = False, + grid: bool = False, + json: bool = False, + show_plot: bool = False, + single_file: bool = False, + ntx_layouts: bool = True, + custom_layouts: bool = True, + ): + """Sets the plotter attributes. + + Parameters: + ----------- + dirs (dict): + The directories to use for the plotter. + file_path (str): + The file path to use for the plotter. + labels (bool): + Whether or not to show the labels. + grid (bool): + Whether or not to show the grid. + json (bool): + Whether or not to save the json file. + show_plot (bool): + Whether or not to show the plot. + single_file (bool): + Whether or not to save the plot to a single file. + ntx_layouts (bool): + Whether or not to save the plot to a networkx file. + custom_layouts (bool): + Whether or not to save the plot to a custom file. + """ + self.plotter.dirs = dirs if dirs is not None else self.plotter.dirs + self.plotter.file_path = ( + file_path if file_path is not None else self.plotter.file_path + ) + self.plotter.labels = labels + self.plotter.grid = grid + self.plotter.json = json + self.plotter.show_plot = show_plot + self.plotter.single_file = single_file + self.plotter.ntx_layouts = ntx_layouts + self.plotter.custom_layouts = custom_layouts + + def plot_graph( + self, + graph: GraphData, + output_dir: str = None, + file_name: str = None, + specific_layout: str = "", + grid: bool = False, + json: bool = False, + api: bool = False, + ) -> dict[dict, str]: + """Plots a graph representing code. + + Parameters: + ----------- + graph (GraphData): + The networkx graph to plot. + output_dir (str): + The directory to save the plot to. + file_name (str): + The name of the plot file. + specific_layout (str): + The specific layout to use for the plot. + default (""): will plot all layouts. + grid (bool): + Whether or not to show the grid. + json (bool): + Whether or not to save the json file. + api (bool): + Whether or not the function is being called from the API. + + Returns: + -------- + dict: + The Json data and output directory. + """ + + # Validate the data + if graph is None: + raise ValueError("No graph provided.") + if not api and output_dir is None: + output_dir = get_output_dir() + self.plotter.dirs["output_dir"] = output_dir + if file_name is None: + file_name = f"GraphPlot {get_date_time_file_format()}" + + # Convert the GraphData object to a networkx graph + graph = PolyGraph.graphdata_to_nx(GraphData) + + # Set the plotter parameters + self.plotter.grid = grid + self.plotter.json = json + + # Plot the graph + self.plotter.plot(graph, specific_layout) + + # Get the last dated folder in output folder + last_date_folder = DirectoryHandler.get_last_dated_output_dirs() + + # Get the json object + json_file_path = last_date_folder + "/json/graph_data.json" + json_data: dict = load_json(json_file_path) + + # Return the json object and output directory of the plots + return {"graph_data": json_data, "output_dir": self.plotter.dirs["output_dir"]} + + def set_plot_output_dir(self, output_dir: str = None): + """Sets the plot output directory. + + Parameters: + ----------- + output_dir (str) Default = None: + The directory to use. + """ + + # Validate the data + if output_dir is None: + raise ValueError("No output directory provided.") + if not os.path.isdir(output_dir): + raise ValueError("The provided output directory does not exist.") + if not output_dir == self.plotter.dirs["output_dir"]: + # Set the plot output directory + self.plotter.dirs["output_dir"] = output_dir + self.plotter.dirs["graph_code_dir"] = os.path.join(output_dir, "code") + self.plotter.dirs["graph_json_dir"] = os.path.join(output_dir, "json") + else: + raise ValueError( + "The provided output directory is already set as the plot output directory." + ) + + def reset_plot_output_dir(self): + """Resets the plot output directory to the default output directory.""" + from .config.directory.output_dir import reset_output_dir + + self.plotter.dirs = reset_output_dir(make_dir=True) + + +class ModelHandler: + def get_graph_description() -> dict: + """Gets the graph description. + + Returns: + -------- + dict: The graph description. + """ + return get_graph_description() + + +class PolyGraphHandler: + def __init__(self): + """Converts an assortment of data objects to an nx.DiGraph object and vice versa.""" + self.polygraph: PolyGraph = PolyGraph() + + def graph_to_json_file_to_graph( + self, graph: nx.DiGraph, json_file_path: str + ) -> nx.DiGraph: + """Converts a networkx graph to a json file and then back to a networkx graph from the json file.\n + This is used to ensure that the json file is valid and can be converted back to a networkx graph. + + Parameters: + ----------- + graph (networkx.classes.graph.Graph): + The networkx graph to convert to json data. + json_file_path (str): + The path to save the json data to. + + Returns: + -------- + networkx.classes.graph.Graph: The networkx graph generated from the saved json data. + """ + + # Validate the data + if graph is None: + raise ValueError("No graph provided.") + if json_file_path is None: + raise ValueError("No json file path provided.") + + # Convert the networkx graph to a json data and save it to a json file + json_data: dict = self.graph_to_json_data(graph) + save_json(json_file_path, json_data) + + # Convert the json file back to a networkx graph + return self.json_file_to_graph(json_file_path) + + def json_file_to_graph(self, json_file_path: str) -> nx.DiGraph: + """Converts a json file to a networkx graph. + + Parameters: + ----------- + json_file_path (str): + The path to the json file to convert to a networkx graph. + + Returns: + -------- + networkx.classes.graph.Graph: The networkx graph. + """ + + # Validate the data + if json_file_path is None: + raise ValueError("No json file path provided.") + if not os.path.isfile(json_file_path): + raise ValueError("The provided json file path does not exist.") + + # Convert the json data to a networkx graph + return self.json_data_to_graph(load_json(json_file_path)) + + def graph_to_json_data(self, graph: nx.DiGraph) -> dict: + """Converts a networkx graph to a json object. + + Parameters: + ----------- + graph (networkx.classes.graph.Graph): + The graph to convert to json. + + Returns: + -------- + dict: The json object. + """ + + # Validate the data + if graph is None: + raise ValueError("No graph provided.") + + return self.polygraph.graph_to_json_data(graph) + + def json_data_to_graph(self, json_data: dict) -> nx.DiGraph: + """Converts a json object to a networkx graph. + + Parameters: + ----------- + json_data (dict): + The json object to convert to a networkx graph. + + Returns: + -------- + networkx.classes.graph.Graph: The networkx graph. + """ + + # Validate the data + if json_data is None: + raise ValueError("No json data provided.") + + return self.polygraph.json_data_to_graph(json_data) + + def source_code_to_json_data(self, source: str | list) -> dict: + """Converts a source file or list of source files to a json serializable object. + + Parameters: + ----------- + source (str): + The source file to get the source files from, then convert to json. \n + OR source (list): + The list of source files to convert to json. + + Returns: + -------- + dict: The json serializable object. + """ + if source is None: + raise ValueError("No source file path provided.") + if isinstance(source, str): + source = DirectoryHandler.get_source_files(source) + if isinstance(source, list): + graph = Parser.parse_code(source) + return self.polygraph.graph_to_json_data(graph) + else: + raise ValueError("'source' must be a file path or list of file paths.") + + +class PaletteHandler: + def __init__(self): + self.palette = Palette() + + def get_palette(self): + """Get the data of the current palette. + + Returns: + -------- + dict: A dictionary containing the data of the current palette. + """ + return self.palette.get_palette_data() + + def set_palette(self, palette_file_path: str): + """Sets the palette for plots. + + Args: + ----- + palette_file_path (str): The path to the palette.json file to set. + """ + self.palette.load_palette(palette_file_path) + + def reset_palette(self): + """Resets the palette to the default package palette.""" + self.palette.reset_palette() + + def export_palette(self, path: str) -> str: + """Gets the palette.json file. + + Args: + ----- + path (str): The path to export the palette. + + Returns: + -------- + str: The palette file. + """ + import os + + if not os.path.exists(path): + raise ValueError(f"Path {path} does not exist.") + return self.palette.export_palette(path) + + def import_palette(self, path: str): + """Imports a palette.json file. + + Args: + ----- + path (str): The path to import the palette from. + """ + import os + + if not os.path.exists(path): + raise ValueError(f"Path {path} does not exist.") + self.palette.import_palette(path) + + def create_new_theme(self, theme: Theme) -> dict: + """Creates a new theme. + + Args: + ----- + node_type (str): The node type to create a new theme for. + base (str): The base color to use for the theme. + label (str): The label color to use for the theme. + shape (str): The shape color to use for the theme. + color (str): The color to use for the theme. + size (str): The size to use for the theme. + alpha (str): The alpha to use for the theme. + + Returns: + -------- + dict: The new theme. + """ + return self.palette.create_new_theme( + theme.node_type, + theme.base, + theme.label, + theme.shape, + theme.color, + theme.size, + theme.alpha, + ) + + +class PositionHandler: + def __init__(self, include_networkx: bool = True, include_custom: bool = True): + self.layouts = Positions(include_networkx, include_custom) + + def add_layout(self, name: str, function: callable, attributes: list): + """Adds a layout to the list of available layouts. + + Args: + ----- + name (str): The name of the layout to add. \n + function (callable): The function to use for the layout. \n + attributes (list): The attributes to use for the layout. + """ + self.layouts.add_layout(name, function, attributes) + + def add_networkx_layouts(self): + """Adds all networkx layouts to the list of available layouts.""" + self.layouts.add_networkx_layouts() + + def add_custom_layouts(self): + """Adds all custom layouts to the list of available layouts.""" + self.layouts.add_custom_layouts() + + def get_layout_names(self) -> list: + """Gets all layout names from the list of available layouts. + + Returns: + -------- + list: The name of available layouts. + """ + return self.layouts.get_layout_names() + + def get_positions(self, graph: nx.DiGraph, layout: str = "") -> dict: + """Gets the positions of the nodes in the graph. + + Args: + ----- + graph (nx.DiGraph): The networkx graph to get the positions of. \n + layout (str): The layout to use to get the positions of the nodes. + + Returns: + -------- + dict: The positions of the nodes in the graph. + """ + return self.layouts.get_positions(graph, layout) + + def get_layouts(self) -> list: + """Gets the available layouts. + + Returns: + -------- + list: The available layouts. + """ + return self.layouts.get_layouts() + + def get_layout(self, name: str) -> tuple: + """Gets a layout from the list of available layouts. + + Args: + ----- + name (str): The name of the layout to get. + + Returns: + -------- + tuple: The layout. + """ + return self.layouts.get_layout(name) + + +class ProcessorHandler: + def process( + self, + source: str, + api: bool = False, + plot: bool = True, + labels: bool = False, + json: bool = False, + grid: bool = False, + show: bool = False, + single_file: bool = False, + output_dir: str = None, + ) -> dict: + """Parses the source code, creates a graph, creates a plot, creates a json file, and outputs the results. + + Parameters: + ----------- + source (str): + The source directory or source file to process. + api (bool): + Whether calling from api or not. + single_file (bool): + Whether to process a single file or the whole source file directory. + plot (bool): + Whether to plot the graph or not. + labels (bool): + Whether to show the labels or not. + json (bool): + Whether to save the json file or not. + grid (bool): + Whether to show the grid or not. + show (bool): + Whether to show the plot or not. (will interrupt the program until the plot is closed) + RECOMMENDED TO KEEP '_show' FALSE. + output_dir (str): + The output directory to save the json file to. + + Returns: + -------- + dict | None + If called from the API dict is json object of the graph.\n + If called locally dict is the paths to the output directory. + 'version': + the runtime version of the process. + 'output_dir': + the path to the output directory. + 'version_dir': + the path to the output/version directory. + 'graph_dir': + the path to the output/graph directory. + 'graph_code_dir': + the path to the output/graph/from_code directory. + 'graph_json_dir': + the path to the output/graph/from_json directory. + 'json_dir': + the path to the output/json directory. + 'json_graph_file_path': + the path to the output/json/graph.json file. + """ + from .processor import process + + # Validate the source + if not os.path.exists(source): + raise ValueError(f"Source {source} does not exist.") + if not os.path.isdir(source) and not os.path.isfile(source): + raise ValueError(f"Source {source} is not a directory or file.") + + if not api: + return process( + source, api, plot, labels, json, grid, show, single_file, output_dir + ) + else: + return process(source=source, api=api, single_file=single_file) + + +class DirectoryHandler: + def __init__(self, source_files: list = None): + self.source: list = source_files + self.parser = None + self.graph = None + + def get_source_files(self, source_file: str = None) -> list: + """Gets all source files from a source file or directory. + + Args: + ----- + source_file (str): The source file or directory to get the source files from. + + Returns: + -------- + list: The list of source files. + """ + _source_file: str = self.source[0] if source_file is None else source_file + if _source_file is None: + raise ValueError("No source provided.") + return get_all_source_files(_source_file) + + def get_output_dir(self) -> str: + """Gets the output directory. + + Returns: + -------- + str: The output directory. + """ + return get_output_dir() + + def reset_output_dir(self) -> str: + """Resets the output directory to the default package output.""" + return reset_output_dir() + + def set_output_dir(self, output_dir: str) -> str: + """Sets the output directory. + + Args: + ----- + output_dir (str): The output directory to set. + """ + return set_output_dir(output_dir) + + def create_output_dir(self, make_dir: bool = False) -> str: + """Setup the output directory. + + Parameters: + ----------- + make_dir : bool + Whether or not to make the output directory. If False, the output directory is set to 'RUN_TIME' filler string. + + Returns: + -------- + str + The path to the output directory. + """ + return reset_output_dir(make_dir) + + def get_last_dated_output_dirs() -> dict: + """Gets the last dated output directories. + + Returns: + -------- + dict: The last dated output directories. + """ + return get_last_dated_output_dirs() + + def get_processor_path(self) -> str: + """Gets the processor.py path. + + Returns: + -------- + str: The processor.py path. + """ + return PROCESSOR_FILE_PATH + + def get_package_version(self) -> str: + """Gets the package version. + + Returns: + -------- + str: The package version. + """ + return CODE_CARTO_PACKAGE_VERSION + + def get_palette_directory(self, default: bool = False) -> str: + """Gets the palette directory. + + Parameters: + ----------- + default : bool + Whether to get the default palette directory or not. + + Returns: + -------- + str: The palette directory. + """ + if default: + return PALETTE_DIRECTORY["default"] + else: + return PALETTE_DIRECTORY["user"] + + def print_all_directories(self) -> dict: + """Prints all directories.""" + return print_all_directories() + + +class UtilityHandler: + def __init__(self): + self.utility = None + + def get_date_time_file_format(self) -> str: + """Gets the date time file format. + + Returns: + -------- + str: The date time file format. + """ + return get_date_time_file_format() + + def check_file_exists(self, file: str) -> bool: + """Checks if a file exists. + + Args: + ----- + file (str): The file to check. + + Returns: + -------- + bool: Whether the file exists or not. + """ + return check_file_path(file) + + +class LogHandler: + from datetime import datetime + + def __init__(self): + self.log_handler = None + + def log_duration( + self, + path: str = None, + start: datetime = None, + end: datetime = None, + duration: float = None, + ) -> None: + """Logs process duration. + + Parameters: + ----------- + path (str): + The API router path. + start (datetime): + The start time of the process. + end (datetime): + The end time of the process. + duration (float): + The duration of the process. + """ + if path is None: + raise ValueError("No path provided.") + if start is None: + raise ValueError("No start time provided.") + if end is None: + raise ValueError("No end time provided.") + if duration is None: + raise ValueError("No duration provided.") + + # TODO: Move this to a Logging sub module + # connect to the database + # insert error into ErrorLog table in database + pass diff --git a/src/codecarto/local/src/config/__init__.py b/src/codecarto/local/src/config/__init__.py new file mode 100644 index 0000000..062c9e8 --- /dev/null +++ b/src/codecarto/local/src/config/__init__.py @@ -0,0 +1 @@ +# Config folder holds logic for the configuration of the app as well as all the directory logic for the application. \ No newline at end of file diff --git a/src/codecarto/local/src/config/config.py b/src/codecarto/local/src/config/config.py new file mode 100644 index 0000000..de1270f --- /dev/null +++ b/src/codecarto/local/src/config/config.py @@ -0,0 +1,104 @@ +from .config_process import ( + load_json, + save_json, + get_config_path, + create_config_file, + reset_config_data, +) + + +class Config: + def __init__(self): + """Initialize the config object to write to JSON file.""" + default_config_path = get_config_path(True) + default_config_data = load_json(default_config_path) + user_config_path = default_config_data["user_config_path"] + self.config_data = load_json(user_config_path) + + def create_config_file(self) -> dict: + """Create the config file. + + Returns: + -------- + dict + The config data. + """ + self.config_data = create_config_file() + return self.config_data + + def reset_config_data(self) -> dict: + """Recreate the config data to default. + + Returns: + -------- + dict + The config data. + """ + self.config_data = reset_config_data() + return self.config_data + + def reload_config_data(self) -> dict: + """Reload the config data from the config file. + + Returns: + -------- + dict + The config data. + """ + default_config_path: str = get_config_path(True) + default_config_data: dict = load_json(default_config_path) + self.config_data = load_json(default_config_data["user_config_path"]) + return self.config_data + + def save_config_data(self, config_path: str = None) -> dict: + """Save the config data to the config file. + + Parameters: + ----------- + config_path: str + The path of the config file to save the config data to. + + Returns: + -------- + dict + The config data. + """ + try: + if config_path is None: + config_path = self.config_data["config_path"] + # try to set the value of the property in the appdata config file + save_json(config_path, self.config_data) + except Exception as e: + # if it fails, likely due to save permissions + # send exception to the console + print(e) + finally: + return self.config_data + + def set_config_property(self, property_name: str, property_value: str) -> dict: + """Set the value of a property in the config file. + + Parameters: + ----------- + property_name: str + The name of the property to set. + property_value: str + The value of the property to set. + + Returns: + -------- + dict + The updated config data. + """ + try: + # set the value of the property in the package config file + config_path: str = self.config_data["config_path"] + self.config_data[property_name] = property_value + # try to set the value of the property in the appdata config file + save_json(config_path, self.config_data) + except Exception as e: + # if it fails, likely due to save permissions + # send exception to the console + print(e) + finally: + return self.config_data diff --git a/src/codecarto/local/src/config/config_process.py b/src/codecarto/local/src/config/config_process.py new file mode 100644 index 0000000..c95cf7e --- /dev/null +++ b/src/codecarto/local/src/config/config_process.py @@ -0,0 +1,554 @@ +# The saving of new config data or paths is not done by API, +# sense those are all server directories. +# So those only apply for the local pacakge use case. + +import os +from ..utils.utils import load_json, save_json + + +CONFIG_FILE = "config.json" +DEFAULT_CONFIG_FILE = "default_config.json" + +# dir is a folder. Example: C:\Users\user\AppData\Roaming\codecarto +# path is a file. Example: C:\Users\user\AppData\Roaming\codecarto\config.json +# filename is a file name. Example: config.json + +def initiate_package(): + """Initiate the CodeCartographer package. + + Returns: + -------- + dict + The user's config data. + """ + from .directory.appdata_dir import CODECARTO_APPDATA_DIRECTORY, APPDATA_DIRECTORY + from ..plotter.palette_dir import get_package_palette_path, NEW_PALETTE_FILENAME, DEFAULT_PALETTE_FILENAME + + # Check if the appdata directory exists + # This should exist, it's the system's appdata directory + if os.path.exists(APPDATA_DIRECTORY): + codecarto_folder_exists: bool = os.path.exists(CODECARTO_APPDATA_DIRECTORY) + if not codecarto_folder_exists: + # Create the codecarto appdata directory + print("Initiate package...") + print("Making CodeCartographr in environment folder...") + os.makedirs(CODECARTO_APPDATA_DIRECTORY, exist_ok=True) + + # Check if we need to make any files + default_palette_path:str = os.path.join(CODECARTO_APPDATA_DIRECTORY, DEFAULT_PALETTE_FILENAME) + new_palette_path:str = os.path.join(CODECARTO_APPDATA_DIRECTORY, NEW_PALETTE_FILENAME) + default_config_path: str = os.path.join(CODECARTO_APPDATA_DIRECTORY, DEFAULT_CONFIG_FILE) + user_config_file: str = os.path.join(CODECARTO_APPDATA_DIRECTORY, CONFIG_FILE) + if not (os.path.exists(CODECARTO_APPDATA_DIRECTORY) + or os.path.exists(default_palette_path) + or os.path.exists(new_palette_path) + or os.path.exists(default_config_path) + or os.path.exists(user_config_file)): + if codecarto_folder_exists: + # If the codecarto folder exists, but the files don't, + # print the initiate message + print("Initiate package...") + + # Check we have the default palette file + package_palette_path: str = get_package_palette_path() + print("Loading package default palette...") + print(f"Package default palette path {package_palette_path}") + if not os.path.exists(package_palette_path): + raise FileNotFoundError( + f"Package Missing Files: Default palette file not found at {package_palette_path}" + ) + + # Check if the default palette file exists in the appdata directory + print("Creating default app data files...") + print(f"Default palette: {default_palette_path}") + if not os.path.exists(default_palette_path): + # Create the default palette file in the appdata directory. + # This is so we have a backup of the default palette file. + default_palette_data: dict = load_json(package_palette_path) + save_json(default_palette_path, default_palette_data) + + # Check if the new palette file exists in the appdata directory + # Shouldn't be the case that default does exist and this doesn't, but lets check. + print(f"User's palette: {new_palette_path}") + if not os.path.exists(new_palette_path): + # Create the default palette file in the appdata directory. + # This is so users can create their own palette file. + default_palette_data: dict = load_json(package_palette_path) + save_json(new_palette_path, default_palette_data) + + # Create the default/user config files + print("Creating config files...") + create_config_file() + + # Double check that all appdata folder and four files exist + print(f"Default config file: {default_config_path}") + print(f"User config file: {user_config_file}") + if not (os.path.exists(CODECARTO_APPDATA_DIRECTORY) + or os.path.exists(default_palette_path) + or os.path.exists(new_palette_path) + or os.path.exists(default_config_path) + or os.path.exists(user_config_file)): + raise FileNotFoundError( + f"CRITICAL ERROR: Package was unable to create all necessary files and folders. Please contact the package maintainer." + ) + + print("Package initiated successfully!") + + + + +def create_user_config_file( + config_path: str = "", palette_path: str = "", output_dir: str = "" +) -> dict: + """Create the user config file with default values. + + Parameters: + ----------- + config_path: str + The path of the config file to create. + palette_path: str + The path of the palette file to create. + output_dir: str + The path of the output directory to create. + + Returns: + -------- + dict + The user's config data. + """ + from .directory.appdata_dir import CODECARTO_APPDATA_DIRECTORY + from .directory.package_dir import CODE_CARTO_PACKAGE_VERSION + from .directory.directories import get_users_documents_dir + from ..plotter.palette_dir import DEFAULT_PALETTE_FILENAME + + ########## CONFIG PATH ########## + if config_path == "": + config_path = os.path.join(CODECARTO_APPDATA_DIRECTORY, CONFIG_FILE) + elif not os.path.exists(config_path): + # create it if it doesn't exist + os.makedirs(os.path.dirname(config_path), exist_ok=True) + + ########## PALETTE PATH ########## + if palette_path == "": + palette_name: str = DEFAULT_PALETTE_FILENAME.replace("default_", "") + palette_path = os.path.join(CODECARTO_APPDATA_DIRECTORY, palette_name) + elif not os.path.exists(palette_path): + # create it if it doesn't exist and save the default palette to it + os.makedirs(os.path.dirname(palette_path), exist_ok=True) + default_palette_data: dict = load_json( + os.path.join(CODECARTO_APPDATA_DIRECTORY, DEFAULT_CONFIG_FILE) + ) + save_json(palette_path, default_palette_data["default_palette_path"]) + + ########## OUTPUT DIR ########## + if output_dir == "": + output_dir = os.path.join( + get_users_documents_dir(), "CodeCartographer", "output" + ) + elif not os.path.exists(output_dir): + # create it if it doesn't exist + os.makedirs(output_dir, exist_ok=True) + + # Create the user config file + user_config_data: dict = { + "version": CODE_CARTO_PACKAGE_VERSION, + "config_path": config_path, + "palette_path": palette_path, + "output_dir": output_dir, + } + save_json(user_config_data["config_path"], user_config_data) + + return user_config_data + + +def create_config_file(user_config_path: str = "") -> dict: + """Create the config file with default values. + + Parameters: + ----------- + user_config_path: str + The path of the config file to create. + + Returns: + -------- + dict + The config data. + """ + from .directory.appdata_dir import CODECARTO_APPDATA_DIRECTORY + from .directory.package_dir import CODE_CARTO_PACKAGE_VERSION + from .directory.directories import get_users_documents_dir + from ..plotter.palette_dir import DEFAULT_PALETTE_FILENAME + + # !!!! DO NOT USE CONFIG.JSON FILE FOR DEFAULTS !!!! + # If we're creating it, we don't want to try + # and get anything from the config.json file + # so need to create the default values + # !!!! DO NOT USE CONFIG.JSON FILE FOR DEFAULTS !!!! + + # get the app data and package directories + version: str = CODE_CARTO_PACKAGE_VERSION + appdata_codecarto: str = CODECARTO_APPDATA_DIRECTORY + default_palette_name: str = DEFAULT_PALETTE_FILENAME + + # create the default config file + default_config_path: str = os.path.join(appdata_codecarto, DEFAULT_CONFIG_FILE) + default_palette_path: str = os.path.join(appdata_codecarto, default_palette_name) + default_output_dir: str = os.path.join( + get_users_documents_dir(), "CodeCartographer", "output" + ) + if not os.path.exists(default_output_dir): + os.makedirs(default_output_dir, exist_ok=True) + + # check if the user config path is valid, exists and is a json file + if ( + user_config_path != "" + and os.path.isfile(user_config_path) + and user_config_path.endswith(".json") + ): + if os.path.exists(user_config_path): + config_path: str = user_config_path + else: + # make the file if it doesn't exist + with open(user_config_path, "w") as f: + f.write("") + config_path: str = user_config_path + else: + print( + "Invalid user config path, using default config path.\n%APPDATA%/Roaming/CodeCartographer/config.json\n" + ) + config_path: str = os.path.join(appdata_codecarto, CONFIG_FILE) + + default_config_data: dict = { + "version": version, + "default_config_path": default_config_path, + "default_palette_path": default_palette_path, + "default_output_dir": default_output_dir, + "user_config_path": config_path, + } + save_json(default_config_path, default_config_data) + + user_config_data = create_user_config_file(config_path) + + return user_config_data + + +def reset_config_data( + reset_user: bool = False, reset_output: bool = False, reset_palette: bool = False +) -> dict: + """Remove the old config file and recreate the config file with defaults. + + Parameters: + ----------- + reset_user: bool + If True, reset the user config file path. + reset_output: bool + If True, reset the output directory. + reset_palette: bool + If True, reset the palette file. + + Returns: + -------- + dict + The reset config data. + """ + ########## GET PREVIOUS CONFIG DATA ########## + prev_user_config_data: dict[str:str] = get_config_data() + prev_user_config_path = prev_user_config_data["config_path"] + prev_user_palette_path = prev_user_config_data["palette_path"] + prev_user_output_dir = prev_user_config_data["output_dir"] + if os.path.exists(prev_user_config_path): + # remove the old config file if it exists + os.remove(prev_user_config_path) + + ########## LOAD DEFAULT CONFIG DATA ########## + config_data: dict[str:str] = {} + default_config_data: dict[str:str] = get_config_data(True) + default_config_path: str = default_config_data["default_config_path"] + default_palette_path: str = default_config_data["default_palette_path"] + default_output_dir: str = default_config_data["default_output_dir"] + if not os.path.exists(default_config_path): + # create the default config file if it doesn't exist + config_data = create_config_file() + + ########## USER CONFIG PATH ########## + if reset_user: + # reset the user's config file path + default_config_data["user_config_path"] = default_config_path.replace( + "default_", "" + ) + user_config_path: str = default_config_data["user_config_path"] + else: + # keep the user's config file path + # update the default config file with the users config path + default_config_data["user_config_path"] = prev_user_config_path + user_config_path: str = default_config_data["user_config_path"] + save_json(default_config_path, default_config_data) + + ########## PALETTE PATH ########## + if reset_palette: + # reset the palette path + palette_path: str = default_palette_path.replace("default_", "") + else: + # create the user config file + palette_path: str = prev_user_palette_path + + ########## OUTPUT DIRECTORY ########## + if reset_output: + # reset the output directory + output_dir: str = default_output_dir + else: + # keep the output directory + output_dir: str = prev_user_output_dir + + # create the user config data + user_config_data: dict = { + "version": default_config_data["version"], + "config_path": user_config_path, + "palette_path": palette_path, + "output_dir": output_dir, + } + save_json(user_config_data["config_path"], user_config_data) + + return config_data + + +def reset_config_CLI() -> dict: + """Validate information and asks the user if they want to reset the various config properties. + + Returns: + -------- + dict + The reset config data. + """ + # Check if the user is sure they want to reset config file + user_resp: str = input( + "\nAre you sure you want to reset the config file to default values? (y/n) : " + ) + if user_resp.lower() == "y": + ########## GET PREVIOUS CONFIG DATA ########## + reset_user: bool = False + reset_output: bool = False + reset_palette: bool = False + prev_user_config_data: dict[str:str] = get_config_data() + prev_user_config_path: str = prev_user_config_data["config_path"] + prev_user_palette_path: str = prev_user_config_data["palette_path"] + prev_user_output_dir: str = prev_user_config_data["output_dir"] + exists: bool = True + pre_msg: str = "\nDo you want to KEEP your current" + + ########## USER CONFIG PATH ########## + exists = os.path.exists(prev_user_config_path) + msg = f"{pre_msg} config path?\nCurrent config path: {prev_user_config_path}\n" + if not exists: + msg += "\nWARNING: The current config file was not found! It will be created.\n" + msg += "\nKeep the current config file path (y/n) : " + if input(msg).lower() == "n": + reset_user = True + elif not exists: + # create the config file if it doesn't exist + create_config_file() + + ########## USER PALETTE PATH ########## + exists = os.path.exists(prev_user_palette_path) + msg = ( + f"{pre_msg} palette file?\nCurrent palette path: {prev_user_palette_path}\n" + ) + if not exists: + msg += "\nWARNING: The current palette file was not found! It will be created with default values.\n" + msg += "\nKeep the current palette file path (y/n) : " + if input(msg).lower() == "n": + reset_palette = True + elif not exists: + # create the palette file if it doesn't exist + os.makedirs(prev_user_palette_path, exist_ok=True) + # copy the default palette file to the user's palette file + default_palette_data: dict = load_json( + get_config_data(True)["default_palette_data"] + ) + save_json(prev_user_palette_path, default_palette_data) + + ########## USER OUTPUT DIRECTORY ########## + exists = os.path.exists(prev_user_output_dir) + msg = f"{pre_msg} output directory?\nCurrent output directory: {prev_user_output_dir}\n" + if not exists: + msg += "\nWARNING: The current output directory was not found! It will be created.\n" + msg += "\nKeep the current output direcotry (y/n) : " + if input(msg).lower() == "n": + reset_output = True + elif not exists: + # create the output file if it doesn't exist + os.makedirs(prev_user_output_dir, exist_ok=True) + + ########## RESET THE CONFIG DATA ########## + return reset_config_data( + reset_user=reset_user, + reset_output=reset_output, + reset_palette=reset_palette, + ) + else: + import sys + + print("Exiting ... \n") + sys.exit(0) + + +def get_config_path(default: bool = False) -> str: + """Return the path of the codecarto config file. + + Parameters: + ----------- + default: bool + Whether to return the default config file path. + + Returns: + -------- + str + The path of the codecarto config file. + """ + from .directory.appdata_dir import CODECARTO_APPDATA_DIRECTORY + + # get the default config file path + default_config_path: str = os.path.join( + CODECARTO_APPDATA_DIRECTORY, DEFAULT_CONFIG_FILE + ) + if not os.path.exists(default_config_path): + create_config_file() + default_config_data: dict = load_json(default_config_path) + + # return the config file path + if not default: + # returns the user's config file path + return default_config_data["user_config_path"] + else: + return default_config_data["default_config_path"] + + +def set_config_property(property_name: str, property_value: str) -> dict: + """Set the value of a property in the config file. + + Parameters: + ----------- + property_name: str + The name of the property to set. + property_value: str + The value of the property to set. + + Returns: + -------- + dict + The updated config data. + """ + config_data: dict = {} + try: + # get the user's config file path from the default config file + default_config_path: str = get_config_path(True) + default_config_data: dict = load_json(default_config_path) + user_config_path: str = default_config_data["user_config_path"] + user_config_data: dict = load_json(user_config_path) + + # check if this is changing the user's config path + new_config_path: str = "" + if property_name == "config_path": + new_config_path = property_value + # if the new config path is the same as the current config path + # then don't do anything + if new_config_path == user_config_path: + return user_config_data + + # check if the new config path exists + if not os.path.exists(new_config_path): + raise Exception(f"Provided path does not exist: {new_config_path}") + # check if the new config path is a file + if not os.path.isfile(new_config_path): + raise Exception(f"Provided path is not a file: {new_config_path}") + # check if the new config path is a JSON file + if not new_config_path.endswith(".json"): + raise Exception(f"Provided path is not a JSON file: {new_config_path}") + + # save the new config file + if save_user_config_path(new_config_path, user_config_data): + config_data = user_config_data + else: + raise Exception(f"Failed to save config file: {new_config_path}") + else: + # save the new property value in the user's config data + user_config_data[property_name] = property_value + save_json(user_config_path, user_config_data) + config_data = user_config_data + except Exception as e: + # if it fails, likely due to save permissions + # send exception to the console + print(e) + + return config_data + + +def save_user_config_path(user_config_path: str, user_config_data: dict) -> bool: + """Save the user's config data to the provided location. + + Parameters: + ----------- + user_config_path: str + The path of the user config file. + + Returns: + -------- + bool + If save was successful. + """ + # Check if the user config path has been set in data dict + if user_config_data["config_path"] != user_config_path: + user_config_data["config_path"] = user_config_path + + # Update the default config file with the new user config path + default_config_path = get_config_path(True) + default_config_data = load_json(default_config_path) + old_config_path = default_config_data["user_config_path"] + default_config_data["user_config_path"] = user_config_path + save_json(default_config_path, default_config_data) + + # Save the new config file + save_json(user_config_path, user_config_data) + + # Remove the old config file + if os.path.exists(old_config_path): + os.remove(old_config_path) + + # Check if save was successful + if os.path.exists(user_config_path): + return True + else: + return False + + +def get_config_data(default: bool = False) -> dict: + """Return the codecarto config data. + + Parameters: + ----------- + default: bool + Whether to return the default config data. + + Returns: + -------- + dict + The codecarto config data. + """ + # get the default config file path + default_config_path: str = get_config_path(True) + if not os.path.exists(default_config_path): + create_config_file() + default_config_data: dict = load_json(default_config_path) + + # return the config data + if not default: + # returns the user's config file path + return load_json(default_config_data["user_config_path"]) + else: + return default_config_data + + +CONFIG_DIRECTORY = { + "default": get_config_path(True), + "user": get_config_path(), +} diff --git a/src/codecarto/local/src/config/directory/__init__.py b/src/codecarto/local/src/config/directory/__init__.py new file mode 100644 index 0000000..3b1ec21 --- /dev/null +++ b/src/codecarto/local/src/config/directory/__init__.py @@ -0,0 +1 @@ +# Directory folder has the logic for main appdata/package application directories. diff --git a/src/codecarto/local/src/config/directory/appdata_dir.py b/src/codecarto/local/src/config/directory/appdata_dir.py new file mode 100644 index 0000000..6f36aa6 --- /dev/null +++ b/src/codecarto/local/src/config/directory/appdata_dir.py @@ -0,0 +1,26 @@ +import os + + +def get_appdata_dir() -> str: + """Return the application data directory.""" + if os.name == "nt": + # On Windows, use %APPDATA% environment variable + return os.getenv("APPDATA") + elif os.name == "posix": + # On Linux/Unix/Mac, use ~/.local/share directory + return os.path.expanduser("~/.local/share") + else: + # Unsupported operating system + raise RuntimeError("Unsupported operating system.") + + +def get_codecarto_appdata_dir() -> str: + """Return the C:\\Users\\USER\\AppData\Roaming\CodeCartographer directory.""" + codecarto_appdata_dir = os.path.join(get_appdata_dir(), "CodeCartographer") + if not os.path.exists(codecarto_appdata_dir): + os.makedirs(codecarto_appdata_dir, exist_ok=True) + return codecarto_appdata_dir + + +APPDATA_DIRECTORY = get_appdata_dir() +CODECARTO_APPDATA_DIRECTORY = get_codecarto_appdata_dir() diff --git a/src/codecarto/local/src/config/directory/directories.py b/src/codecarto/local/src/config/directory/directories.py new file mode 100644 index 0000000..0544b02 --- /dev/null +++ b/src/codecarto/local/src/config/directory/directories.py @@ -0,0 +1,77 @@ +# TODO: import all directory files here to make it easier to import them from other files +# TODO: rework all directory files, i feel like there is a lot of duplicated code +import os + + +def get_all_directories() -> dict: + """Get all the directories. + + Returns: + -------- + dict + All the directories. + """ + from .appdata_dir import CODECARTO_APPDATA_DIRECTORY, APPDATA_DIRECTORY + from .package_dir import PACKAGE_DIRECTORY, PROCESSOR_FILE_PATH + from ...plotter.palette_dir import PALETTE_DIRECTORY + from .output_dir import OUTPUT_DIRECTORY, OUTPUT_GROUPING + from ...config.config_process import CONFIG_DIRECTORY + + return { + "appdata_dir": APPDATA_DIRECTORY, + "codecarto_appdata_dir": CODECARTO_APPDATA_DIRECTORY, + "package_dir": PACKAGE_DIRECTORY, + "processor_file": PROCESSOR_FILE_PATH, + "config_dirs": CONFIG_DIRECTORY, + "palette_dirs": PALETTE_DIRECTORY, + "output_dirs": OUTPUT_DIRECTORY, + "output_grouping": OUTPUT_GROUPING, + } + + +def print_all_directories(directory: dict = None, indent: str = "") -> dict: + """Print all the directories. + + Parameters: + ----------- + directory: dict + The directory to print. + indent: str + The indent to use. + + Returns: + -------- + dict + The directories. + """ + if directory is None: + directory = get_all_directories() + + # get directories and the max width of the keys + max_width = max([len(key) for key in directory.keys()]) + 1 + + # print the directories + if indent == "": + print() + for key, value in directory.items(): + if isinstance(value, dict) and value != {}: + # print the key then recursively call the function + print(f"{indent}{key}:") + print_all_directories(value, f"{indent}\t") + else: + print(f"{indent}{key:<{max_width}}: {value}") + if indent == "": + print() + + return directory + + +def get_users_documents_dir() -> str: + """Get the path to the user's documents directory. + + Returns: + -------- + str + The path to the user's documents directory. + """ + return os.path.expanduser("~\\Documents") diff --git a/src/codecarto/local/src/config/directory/output_dir.py b/src/codecarto/local/src/config/directory/output_dir.py new file mode 100644 index 0000000..2772716 --- /dev/null +++ b/src/codecarto/local/src/config/directory/output_dir.py @@ -0,0 +1,312 @@ +import os + +JSON_GRAPH_FILE = "graph_data.json" + + +def get_run_version() -> str: + """Get the version number of the run. + + Returns: + -------- + str + The version number of the run. + """ + from ...utils.utils import get_date_time_file_format + + return get_date_time_file_format() + + +def get_output_dir(default: bool = False, ask_user: bool = False) -> str: + """Get the path to the output directory. + + Parameters: + ----------- + default: bool + Whether to return the default output directory. + ask_user: bool + Whether or not to ask the user if they'd like to reset the output directory. + + Returns: + -------- + str + The path to the output directory. + """ + from ..config_process import get_config_data + from ...utils.utils import load_json + + # Get the config file + default_config_data: str = get_config_data(True) + config_data: dict = load_json(default_config_data["user_config_path"]) + + # Get the output directory + output_dir: str = "" + if default: + output_dir = default_config_data["default_output_dir"] + else: + output_dir = config_data["output_dir"] + + # Check if the output dir exists, shouldn't hit from API + if (not output_dir) or (not os.path.exists(output_dir)): + if ask_user: # from cli + # Ask user if they'd like to make it + make_dir = input( + f"The output directory does not exist. Would you like to reset it to default output location? (y/n) " + ) + if make_dir.lower() == "y": + output_dir = reset_output_dir() + else: + raise ValueError("The output directory does not exist.") + else: # from lib + # Create the output dir + os.makedirs(output_dir, exist_ok=True) + + # Return the output dir + return output_dir + + +def set_output_dir( + new_dir: str, ask_user: bool = True, makedir: bool = False, default: bool = False +) -> str: + """Set the output directory to a new directory. + + If new directory does not exist: + If running from CLI, user asked if they'd like to make the new directory.\n + If running from library, makes the new directory. + + Parameters: + ----------- + new_dir : str + The new output directory. + ask_user : bool + Whether or not to ask the user if they'd like to make the new directory. + makedir : bool + Whether or not to make the new directory. + default : bool + Whether or not to set the output directory to the default output directory. + + Returns: + -------- + str + The new output directory. + """ + from ..config_process import get_config_data + from ...utils.utils import save_json + + # check if the new dir exists + if not os.path.exists(new_dir): + # check if the new dir is a folder or a file + if os.path.isfile(new_dir): + raise ValueError("The new output directory cannot be a file.") + + dir_type = "default" if default else "new" + if ask_user and not makedir: + # ask user if they'd like to make it + make_dir = input( + f"\nThe {dir_type} output directory does not exist. Would you like to make it? (y/n) : " + ) + if make_dir.lower() == "y": + os.makedirs(new_dir, exist_ok=True) + else: + import sys + + print("Exiting...\n") + sys.exit(0) + elif makedir: + # make the dir, used in library and testing + os.makedirs(new_dir, exist_ok=True) + else: + raise ValueError(f"The {dir_type} output directory does not exist.") + + # Save to the config file + config_data = get_config_data() + config_data["output_dir"] = new_dir + save_json(config_data["config_path"], config_data) + return config_data["output_dir"] + + +def reset_output_dir() -> str: + """Reset the output directory to the default output directory. + + Returns: + -------- + str + The default output directory. + """ + from ..config_process import get_config_data + + config_data: dict = get_config_data(True) + output_dir: str = config_data["default_output_dir"] + + return set_output_dir(output_dir, ask_user=False, default=True) + + +def create_output_dirs() -> dict: + """Get the path to the output sub directories. + + Returns: + -------- + dict + A dictionary of the output sub directories. + """ + # Get the output directory + output_dir: str = get_output_dir(True) + + # Get the run version + run_version: str = get_run_version() + + # Create the run version directory path + run_version_dir: str = os.path.join(output_dir, run_version) + + # Check if the run version already exists + if os.path.exists(run_version_dir): + # If it does, add a number to the end of the run version + i: int = 1 + while os.path.exists(run_version_dir): + run_version_dir = f"{run_version}_{i}" + i += 1 + + # Create the other sub directories paths + output_graph_dir: str = os.path.join(get_output_dir(), run_version, "graph") + output_graph_from_code_dir: str = os.path.join(output_graph_dir, "from_code") + output_graph_from_json_dir: str = os.path.join(output_graph_dir, "from_json") + output_json_dir: str = os.path.join(get_output_dir(), run_version, "json") + output_json_file: str = os.path.join(output_json_dir, JSON_GRAPH_FILE) + + # Create the output sub directories + os.makedirs(run_version_dir, exist_ok=True) + os.makedirs(output_graph_dir, exist_ok=True) + os.makedirs(output_graph_from_code_dir, exist_ok=True) + os.makedirs(output_graph_from_json_dir, exist_ok=True) + os.makedirs(output_json_dir, exist_ok=True) + + # Return the output sub directories + return { + "version": run_version, + "output_dir": output_dir, + "version_dir": run_version_dir, + "graph_dir": output_graph_dir, + "graph_code_dir": output_graph_from_code_dir, + "graph_json_dir": output_graph_from_json_dir, + "json_dir": output_json_dir, + "json_file_path": output_json_file, + } + + +def get_last_dated_output_dirs(): + """Get the paths of the last dated output sub directories. + + Returns: + -------- + str + The path to the last dated directory in the output directory. + """ + # Get the output directory + output_dir = get_output_dir() + + # Get a list of all dated directories + dated_dirs = [ + d for d in os.listdir(output_dir) if os.path.isdir(os.path.join(output_dir, d)) + ] + + # Sort the dated directories + dated_dirs.sort(reverse=True) + + # Get the last dated directory + last_dated_dir = os.path.join(output_dir, dated_dirs[0]) + + # Check if the last dated directory exists, if not, raise an error + if not os.path.exists(last_dated_dir): + raise ValueError( + f"The last dated directory does not exist: {os.path.join(output_dir, dated_dirs[0])}" + ) + else: + # Get the run version from the tail of last dated directory + run_version: str = os.path.basename(last_dated_dir) + + # Get the output sub directories + output_graph_dir: str = os.path.join(last_dated_dir, "graph") + output_graph_from_code_dir: str = os.path.join(output_graph_dir, "from_code") + output_graph_from_json_dir: str = os.path.join(output_graph_dir, "from_json") + output_json_dir: str = os.path.join(last_dated_dir, "json") + output_json_file: str = os.path.join(output_json_dir, JSON_GRAPH_FILE) + + # Check that all the dirs exist, if any don't, create them + if not os.path.exists(output_graph_dir): + os.makedirs(output_graph_dir, exist_ok=True) + if not os.path.exists(output_graph_from_code_dir): + os.makedirs(output_graph_from_code_dir, exist_ok=True) + if not os.path.exists(output_graph_from_json_dir): + os.makedirs(output_graph_from_json_dir, exist_ok=True) + if not os.path.exists(output_json_dir): + os.makedirs(output_json_dir, exist_ok=True) + + # Return the output sub directories + return { + "version": run_version, + "output_dir": output_dir, + "version_dir": last_dated_dir, + "graph_dir": output_graph_dir, + "graph_code_dir": output_graph_from_code_dir, + "graph_json_dir": output_graph_from_json_dir, + "json_dir": output_json_dir, + "json_file_path": output_json_file, + } + + +def get_specific_output_dirs(run_version: str = "") -> dict: + """Get the output directories of the specified or last run_version directory. + + Parameters: + ----------- + run_version: str (default = "") + The run version to use. Default returns last dated directory. + + Returns: + -------- + dict + A dictionary of the output sub directories. + """ + # Get the output directory + output_dir: str = get_output_dir() + + # Get the run version + if run_version == "": + run_version = get_last_dated_output_dirs() + else: + # Check if the run version provided exists + if not os.path.exists(os.path.join(output_dir, run_version)): + # If it doesn't, raise an error + raise ValueError( + f"The run version provided does not exist: {os.path.join(output_dir, run_version)}" + ) + + # Create the other sub directories paths + output_graph_dir: str = os.path.join(output_dir, run_version, "graph") + output_graph_from_code_dir: str = os.path.join(output_graph_dir, "from_code") + output_graph_from_json_dir: str = os.path.join(output_graph_dir, "from_json") + output_json_dir: str = os.path.join(output_dir, run_version, "json") + output_json_file: str = os.path.join(output_json_dir, JSON_GRAPH_FILE) + + # Return the output sub directories + return { + "version": run_version, + "output_dir": output_dir, + "version_dir": run_version, + "graph_dir": output_graph_dir, + "graph_code_dir": output_graph_from_code_dir, + "graph_json_dir": output_graph_from_json_dir, + "json_dir": output_json_dir, + "json_file_path": output_json_file, + } + + +OUTPUT_DIRECTORY = {"default": get_output_dir(True), "user": get_output_dir()} + +OUTPUT_GROUPING = { + "dir": OUTPUT_DIRECTORY["user"], + "graph_dir": OUTPUT_DIRECTORY["user"] + "\CREATED_DATE\graph", + "graph_code_dir": OUTPUT_DIRECTORY["user"] + "\CREATED_DATE\graph\\from_code", + "graph_json_dir": OUTPUT_DIRECTORY["user"] + "\CREATED_DATE\graph\\from_json", + "json_dir": OUTPUT_DIRECTORY["user"] + "\CREATED_DATE\json", + "json_file_path": OUTPUT_DIRECTORY["user"] + "\CREATED_DATE\json\graph.json", +} diff --git a/src/codecarto/local/src/config/directory/package_dir.py b/src/codecarto/local/src/config/directory/package_dir.py new file mode 100644 index 0000000..a8ce9f6 --- /dev/null +++ b/src/codecarto/local/src/config/directory/package_dir.py @@ -0,0 +1,43 @@ +import os +from importlib_metadata import version + +CODE_CARTO_PACKAGE_NAME = "codecarto" +CODE_CARTO_PACKAGE_VERSION = version(CODE_CARTO_PACKAGE_NAME) + + +def get_package_dir() -> str: + """Return the directory of the codecarto package.""" + # this file is in the directory + # codecarto\src\codecarto\config\directory\ <--- this directory level + # so we need to go up 2 directories to get to the codecarto package directory + # codecarto\src\codecarto <--- this directory level + + # abspath, dirname, dirname, dirname + # filename, directory, config, codecarto + package_dir = os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + if os.path.basename(package_dir) == CODE_CARTO_PACKAGE_NAME: + return package_dir + else: + return None + + +PACKAGE_DIRECTORY = get_package_dir() + + +def get_processor_path() -> str: + """Get the real path to the main file. + + Returns: + -------- + str + The real path to the main file. + """ + main_file_path = os.path.realpath(os.path.join(get_package_dir(), "processor.py")) + if not os.path.exists(main_file_path): + raise RuntimeError("Main package file not found. Package may be corrupted.") + return main_file_path + + +PROCESSOR_FILE_PATH = get_processor_path() diff --git a/src/codecarto/local/src/database/__init__.py b/src/codecarto/local/src/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/codecarto/local/src/database/error.py b/src/codecarto/local/src/database/error.py new file mode 100644 index 0000000..825f2fa --- /dev/null +++ b/src/codecarto/local/src/database/error.py @@ -0,0 +1,73 @@ +from ..utils.errors import ( + ThemeError, + ThemeCreationError, + ThemeNotFoundError, + ThemeDirNotFoundError, + CliError, + ThemeBaseNotFoundError, + MissingParameterError, +) + + +class ErrorHandler: + def __init__(self): + self.error_handler = None + + # Parent types + self.CliError = CliError + self.ThemeError = ThemeError + + # Children types + self.MissingParameterError = MissingParameterError + self.ThemeBaseNotFoundError = ThemeBaseNotFoundError + self.ThemeCreationError = ThemeCreationError + self.ThemeDirNotFoundError = ThemeDirNotFoundError + self.ThemeNotFoundError = ThemeNotFoundError + + def log( + self, + exception: Exception = None, + caller: str = None, + break_line: int = -1, + message: str = None, + ) -> None: + """Logs an exception. + + Parameters: + ----------- + exception (Exception): + The exception to log. + caller (str): + The caller of the exception. + break_line (int): + The line code broke at. + message (str): + The message to log. + """ + + # TODO: Move this to an Error sub module + # Log the error in error.log and record in ErrorLog table in database + # TODO: need to format error.log write input + pass + + def raise_error(self, error: str, exception: Exception = None) -> None: + """Raises an error. + + Parameters: + ----------- + error (str): + The error message to raise. + exception (Exception): + The exception to raise. + """ + + # TODO: Adapt this to be codecarto custom instead of just random raised error + # need the type of ERROR to be general and not just ValueError or whatever + + if exception: + # Raise exception if provided + raise exception + else: + # Raise ValueError if no exception provided + # This is in cases where not wrapped in try/catch + raise ValueError(error) diff --git a/src/codecarto/local/src/models/__init__.py b/src/codecarto/local/src/models/__init__.py new file mode 100644 index 0000000..a42288e --- /dev/null +++ b/src/codecarto/local/src/models/__init__.py @@ -0,0 +1 @@ +# Models folder will hold data models for the application. \ No newline at end of file diff --git a/src/codecarto/local/src/models/graph_data.py b/src/codecarto/local/src/models/graph_data.py new file mode 100644 index 0000000..c38b4c1 --- /dev/null +++ b/src/codecarto/local/src/models/graph_data.py @@ -0,0 +1,59 @@ +from pydantic import BaseModel, Field + + +# This is a description of the Graph class accepted by the plotter. +# This way the client can send a general graph object and the server +# can convert it to a networkx graph object, the expected data. +# Doing so makes it easier to handle and validate the incoming data, +# as well as keeping the API purely HTTP/JSON without needing to +# serialize/deserialize complex objects. +class Edge(BaseModel): + id: int = None + type: str = "" + source: int = None + target: int = None + + +class Node(BaseModel): + id: int = None + type: str = "" + label: str = "" + base: str = "" + parent: int = None + children: list["Node"] = [] + edges: list[Edge] = [] + + +Node.update_forward_refs() + + +class GraphData(BaseModel): + nodes: dict[str, Node] = Field(..., alias="nodes") + edges: dict[str, Edge] = Field(..., alias="edges") + + +def get_graph_description() -> dict: + """Returns a description of the GraphData class. + + Returns: + -------- + dict: A description of the GraphData class. + """ + return { + "nodes": "Dictionary where each key is the ID of a node and the value is the node's data.", + "edges": "Dictionary where each key is the ID of an edge and the value is the edge's data.", + "node data": { + "id": "The node's ID.", + "type": "The type of the node.", + "label": "The node's label.", + "base": "The node's base.", + "parent": "The ID of the node's parent, or null if the node has no parent.", + "children": "List of the node's children. Each child is represented by its data.", + }, + "edge data": { + "id": "The edge's ID.", + "type": "The type of the edge.", + "source": "The ID of the edge's source node.", + "target": "The ID of the edge's target node.", + }, + } diff --git a/src/codecarto/local/src/parser/__init__.py b/src/codecarto/local/src/parser/__init__.py new file mode 100644 index 0000000..e856f80 --- /dev/null +++ b/src/codecarto/local/src/parser/__init__.py @@ -0,0 +1,2 @@ +# Parser holds the logic for parsing through source code. +# this includes directory information for source crawling diff --git a/src/codecarto/local/src/parser/import_source_dir.py b/src/codecarto/local/src/parser/import_source_dir.py new file mode 100644 index 0000000..c6e9d4d --- /dev/null +++ b/src/codecarto/local/src/parser/import_source_dir.py @@ -0,0 +1,111 @@ +import os + + +def find_starting_file(source_files: list) -> str: + """Find the starting file. + + Parameters: + ----------- + source_files : list + List of source files. + + Returns: + -------- + str + The starting file. + """ + # Prioritize user-specified starting file + user_specified_file = os.environ.get("STARTING_FILE") + if user_specified_file: + for source_file in source_files: + source_file_name = os.path.basename(source_file) + if source_file_name == user_specified_file: + if os.path.exists(source_file): + return source_file + + # Heuristics to guess the starting file + possible_starting_files = [ + "main.py", + "app.py", + "__init__.py", + "run.py", + "cli.py", + "application.py", + ] + for possible_starting_file in possible_starting_files: + for source_file in source_files: + source_file_name = os.path.basename(source_file) + if source_file_name == possible_starting_file: + if os.path.exists(source_file): + return source_file + + # If no Python files found, return None + return None + + +def find_top_level_directory(file_path) -> str: + """Returns the top level directory of the starting file. + + Parameters + ---------- + file_path : str + The path to the starting file. + + Returns + ------- + str + The top level directory of the starting file. + """ + current_dir = os.path.dirname(file_path) + last_dir_with_init = None + + while os.path.exists(os.path.join(current_dir, "__init__.py")): + last_dir_with_init = current_dir + parent_dir = os.path.dirname(current_dir) + if parent_dir == current_dir: + break + current_dir = parent_dir + + return last_dir_with_init or current_dir + + +def get_all_source_files(starting_file_path) -> list: + """Returns a list of all Python source files in the directory of the starting file. + + Parameters + ---------- + starting_file_path : str + The path to the starting file. + + Returns + ------- + list + A list of all Python source files in the directory of the starting file. + """ + top_level_directory = find_top_level_directory(starting_file_path) + + source_files: list = [] + for root, dirs, files in os.walk(top_level_directory): + filters = [".dev", ".git", ".env", "env", ".venv", "venv", "__pycache__", ".nox", ".pytest_cache", ".benchmarks"] + # remove dirs in filters list + dirs[:] = [d for d in dirs if d not in filters] + # remove files in filters list + files[:] = [f for f in files if f not in filters] + # add files to source_files list + for file in files: + if file.endswith(".py"): + source_files.append(os.path.join(root, file)) + return source_files + + +def get_file_source_directory(file_path) -> dict: + # source_files = get_all_source_files(file_path) + # top_level_directory = find_top_level_directory(file_path) + # starting_file = find_starting_file(top_level_directory) + + # return { + # "start_file": starting_file, + # "top_level_directory": top_level_directory, + # "source_files": source_files, + # } + pass diff --git a/src/codecarto/local/src/parser/parser.py b/src/codecarto/local/src/parser/parser.py new file mode 100644 index 0000000..69dea3b --- /dev/null +++ b/src/codecarto/local/src/parser/parser.py @@ -0,0 +1,2223 @@ +import ast +import networkx as nx +import os + +# Walk through the steps +# Note: Not every visitor will add a node to the graph. They will do one more more of the following: +# 1. Add a node to the graph +# 2. Add an edge to the graph +# 4. Act as a helper node for other nodes +# 5. Visit the node's children +# 6. Do nothing +# 1. loop through source files +# 1. add file to parsed files +# 2. get a tree of each file +# 3. parse the tree, which visits each ast.node found for each item in the tree representing the file. +# 1. The first visit should be to a module for each file. Files are modules. Module parents will be the root graph. +# 2. When Module is visited, it will visit it's children with 'generic_visit'. +# 3. The immediate children are typically imports, functions, or classes. +# - These would have a parent of the module. +# 4. Each child also calls the 'generic_visit' method, which will visit the child's children. +# - Their parents would be the child of the module. +# 5. Some children have children, like functions and classes, and some do not like 'pass' and 'break'. +# 1. Typical containing children are classes, functions, collections +# 2. Typical non-containing children are variables and constants +# - Imports will be considered barren, they do have children, but for this they act a connecting node to other module graphs +# - ImportFroms are similar to imports, but they will be a connecting node from the current module to the from module's object +# that we're importing +# - They need to be nodes in the current module graph, but are not the imported module graph itself +# - The imported obj will point to the import | importfrom node +# - The module that is importing will point to the import | importfrom node as well +# - The import | importfrom node will point to the obj using the imported obj in the module importing the obj +# - So Graph(module).node(import|importfram) -> Graph(module).node(imported item) +# - processor.py -> Processor -> main -> parser <- (from parser.py import Parser) <- processor.py +# - processor.py -> import utils -> utils module graph +# - processor.py -> (from parser.py import Parser) <- Parser, a node in the parser module graph +# - parser.py -> Parser -> (from parser.py import Parser) <- processor.py +# 4. once we get back to the module graph, we can mark it as complete and add it to the root graph +# 5. next file + + +class Parser(ast.NodeVisitor): + """Parse a python source file into a networkx graph.""" + + # TODO: when we eventually add import and importFrom, they need to be the id of the Module they represent + # TODO: when this gets updated, do logic of option 'uno' + def __init__(self, source_files: list): + """Initialize the parser. + + Parameters: + ----------- + source_files : set + A set of source files to parse. + """ + # The graph to populate + self.source_files: list = source_files + self.graph: nx.DiGraph = nx.DiGraph() + # To track current elements + self.current_file: str = None # file + self.current_node: nx.DiGraph = None # node + self.current_type: str = None # type + # self.current_module: nx.DiGraph = None # module + # self.current_class: nx.DiGraph = None # class + # self.current_function: nx.DiGraph = None # function + self.current_parent: nx.DiGraph = None # for, while, if, etc. + # Create root and python nodes + self.root: nx.DiGraph = nx.DiGraph(name="root") + self.python: nx.DiGraph = nx.DiGraph(name="python") + self.add_start_nodes() + # Parse the source code + self.parsed_files: list = [] + self.parse_list(source_files) + + def add_start_nodes(self): + """Add root and python node to the graph.""" + # add the root node + self.graph.add_node( + id(self.root), type="Module", label="root", base="module", parent=None + ) + # add the python node + self.graph.add_node( + id(self.python), + type="Module", + label="python", + base="module", + parent=id(self.root), + ) + self.graph.add_edge(id(self.root), id(self.python)) + + def parse_list(self, source_files: list) -> nx.DiGraph: + """Parse the codes in the list. + + Parameters: + ----------- + source_files : list + A list of source files to parse. + """ + test = False + if test: + # TODO: for local testing purpose + _files = ["plotter.py"] + # loop through the list of source files + for file_path in source_files: + # TODO: testing purpose: check if base name of file_path in _files + if os.path.basename(file_path) in _files: + # Check if the file has already been parsed + if file_path in self.parsed_files: + continue + # Add the file to the parsed files + self.parsed_files.append(file_path) + # Parse the code + self.current_file = file_path + self.parse_code(file_path) + else: + # check if graph only has root and python nodes + if not source_files or len(source_files) == 0: + if len(self.graph.nodes) == 2: + # remove the root and python nodes + self.graph.remove_node(id(self.root)) + self.graph.remove_node(id(self.python)) + return None + + # loop through the list of source files + for file_path in source_files: + # Check if the file has already been parsed + if file_path in self.parsed_files: + continue + # Add the file to the parsed files + self.parsed_files.append(file_path) + # Parse the code + self.current_file = file_path + self.parse_code(file_path) + + def parse_code(self, file_path) -> nx.DiGraph: + """Parse the code in the specified file path. + + Parameters: + ----------- + file_path : str + The path to the file to parse. + """ + # Parse the code + with open(file_path, "r") as with_file: + code = with_file.read() + tree = ast.parse(code, filename=file_path) + # self.pretty_ast_dump(tree) + # Visit the tree + # this starts the decent through the file's code objects + self.visit(tree) + + def pretty_ast_dump(self, node, indent=0): + """Pretty print the ast tree. + + Parameters: + ----------- + node : ast.AST + The ast node to print. + indent : int + The indent level. + """ + if isinstance(node, ast.AST): + node_name = node.__class__.__name__ + print(" " * indent + node_name) + + for field_name, field_value in ast.iter_fields(node): + print(" " * (indent + 1) + field_name + ":") + self.pretty_ast_dump(field_value, indent + 2) + elif isinstance(node, list): + for item in node: + self.pretty_ast_dump(item, indent) + else: + print(" " * indent + repr(node)) + + def create_new_node( + self, node_id: int, node_type: str, node_label: str, node_parent_id: int + ) -> nx.DiGraph: + """Create new node. + + Parameters: + ----------- + node_id : int + The id of the new node. + node_type : str + The type of the new node. + node_label : str + The label of the new node. + node_parent_id : int + The id of the parent new node. + """ + if not node_label: + node_label = f"{node_type} (u)" + _node = self.graph.add_node( + node_id, type=node_type, label=node_label, parent=node_parent_id + ) + self.graph.add_edge(node_parent_id, node_id) + return _node + + # Deprecated + # def visit_Bytes(self, node : ast.Bytes): + # def visit_Ellipsis(self, node : ast.Ellipsis): + # def visit_ExtSlice(self, node : ast.ExtSlice): + # def visit_Index(self, node : ast.Index): + # def visit_NameConstant(self, node : ast.NameConstant): + # def visit_Num(self, node : ast.Num): + # def visit_Str(self, node : ast.Str): + # def visit_Param(self, node: ast.Param): + + # region Mode + def visit_Expression(self, node: ast.Expression): + """Visit the expression node. + + Parameters: + ----------- + node : ast.Expression + The expression node to visit. + + Notes: + ------ + In the following "xsqur = x * x", the ast.Expression node represents "x * x". \n + While ast.Name.id represents 'xsqur'. + """ + return + # Add the expression node to the graph + self.create_new_node( + node_id=id(node), + node_type="Expression", + node_label=None, + node_parent_id=id(self.current_parent), + ) + # Visit the children + self.generic_visit(node) + + def visit_FunctionType(self, node: ast.FunctionType): + """Visit the function type node. + + Parameters: + ----------- + node : ast.FunctionType + The function type node to visit. + + Notes: + ------ + In the following "def foo(x: int) -> int:", the ast.FunctionType node represents "x: int -> int". \n + While ast.Name.id represents 'foo'. + """ + return + # Add the function type node to the graph + self.create_new_node( + node_id=id(node), + node_type="FunctionType", + node_label=None, + node_parent_id=id(self.current_parent), + ) + # Visit the children + self.generic_visit(node) + + def visit_Interactive(self, node: ast.Interactive): + """Visit the interactive node. + + Parameters: + ----------- + node : ast.Interactive + The interactive node to visit. + + Notes: + ------ + In the following "python -i", the ast.Interactive node represents "python -i". + """ + return + # Add the interactive node to the graph + self.create_new_node( + node_id=id(node), + node_type="Interactive", + node_label=None, + node_parent_id=id(self.current_parent), + ) + # Visit the children + self.generic_visit(node) + + def visit_Module(self, node: ast.Module): + """Visit the module node. + + Parameters: + ----------- + node : ast.Module + The module node to visit. + + Notes: + ------ + ast.Module represents the entire python file. + """ + # Add the module node to the graph + self.create_new_node( + node_id=id(node), + node_type="Module", + node_label=os.path.basename(self.current_file), + node_parent_id=id(self.root), + ) + # Set the current parent to the module node + self.current_parent = node + # self.current_module = node + # Visit the children of the Module + self.generic_visit(node) + + # endregion + + # region Literals + def visit_Constant(self, node: ast.Constant): + """Visit the constant node. + + Parameters: + ----------- + node : ast.Constant + The constant node to visit. + + Notes: + ------ + In the following "x = 1", the ast.Constant node represents "1". \n + While ast.Name.id represents 'x'. + """ + return + # Add the constant node to the graph + self.graph.add_node( + id(node), + type="Constant", + label=node.value, + base="literal", + parent=id(self.current_parent), + ) + # Add an edge from the current parent to the constant node + self.graph.add_edge(id(self.current_parent), id(node)) + # Set the current parent to the constant node + self.current_parent = node + # Visit the children + self.generic_visit(node) + + def visit_Dict(self, node: ast.Dict): + """Visit the dict node. + + Parameters: + ----------- + node : ast.Dict + The dict node to visit. + + Notes: + ------ + In the following "x = {1: 2}", the ast.Dict node represents "{1: 2}". \n + While ast.Name.id represents 'x'. + """ + # I don't really want the 'Dict' node in the graph, + # but to say that it's children are of type Dict + # so set the current_type to Dict, visit children, + # then unset the current_type + + # Set the current type to Dict + self.current_type = "Dict" + # Visit the children + self.generic_visit(node) + # Unset the current type + self.current_type = None + + # # Add the dict node to the graph + # self.create_new_node( + # node_id=id(node), + # node_type="Dict", + # node_label="dict", + # node_parent_id=id(self.current_parent), + # ) + # # Set the current parent to the dict node + # self.current_parent = node + # # Visit the children + # self.generic_visit(node) + + def visit_FormattedValue(self, node: ast.FormattedValue): + """Visit the formatted value node. + + Parameters: + ----------- + node : ast.FormattedValue + The formatted value node to visit. + + Notes: + ------ + In the following "x = f'{1}'", the ast.FormattedValue node represents "'{1}'". \n + While ast.Name.id represents 'x'. + """ + return + # Add the formatted value node to the graph + self.create_new_node( + node_id=id(node), + node_type="FormattedValue", + node_label=None, + node_parent_id=id(self.current_parent), + ) + # Visit the children + self.generic_visit(node) + + def visit_JoinedStr(self, node: ast.JoinedStr): + """Visit the joined string node. + + Parameters: + ----------- + node : ast.JoinedStr + The joined string node to visit. + + Notes: + ------ + In the following "x = f'{1}'", the ast.JoinedStr node represents "f'{1}'". \n + While ast.Name.id represents 'x'. + """ + return + # Add the joined string node to the graph + self.create_new_node( + node_id=id(node), + node_type="JoinedStr", + node_label=None, + node_parent_id=id(self.current_parent), + ) + # Visit the children + self.generic_visit(node) + + def visit_List(self, node: ast.List): + """Visit the list node. + + Parameters: + ----------- + node : ast.List + The list node to visit. + + Notes: + ------ + In the following "x = [1, 2]", the ast.List node represents "[1, 2]". \n + While ast.Name.id represents 'x'. + """ + # I don't really want the 'List' node in the graph, + # but to say that it's children are of type List + # so set the current_type to List, visit children, + # then unset the current_type + + # Set the current type to List + self.current_type = "List" + # Visit the children + self.generic_visit(node) + # Unset the current type + self.current_type = None + + # # Add the list node to the graph + # self.create_new_node( + # node_id=id(node), + # node_type="List", + # node_label="list", + # node_parent_id=id(self.current_parent), + # ) + # # Set the current parent to the list node + # self.current_parent = node + # # Visit the children + # self.generic_visit(node) + + def visit_Set(self, node: ast.Set): + """Visit the set node. + + Parameters: + ----------- + node : ast.Set + The set node to visit. + + Notes: + ------ + In the following "x = {1, 2}", the ast.Set node represents "{1, 2}". \n + While ast.Name.id represents 'x'. + """ + # I don't really want the 'Set' node in the graph, + # but to say that it's children are of type Set + # so set the current_type to Set, visit children, + # then unset the current_type + + # Set the current type to Set + self.current_type = "Set" + # Visit the children + self.generic_visit(node) + # Unset the current type + self.current_type = None + + # # Add the set node to the graph + # self.create_new_node( + # node_id=id(node), + # node_type="Set", + # node_label="set", + # node_parent_id=id(self.current_parent), + # ) + # # Set the current parent to the set node + # self.current_parent = node + # # Visit the children + # self.generic_visit(node) + + def visit_Tuple(self, node: ast.Tuple): + """Visit the tuple node. + + Parameters: + ----------- + node : ast.Tuple + The tuple node to visit. + + Notes: + ------ + In the following "x = (1, 2)", the ast.Tuple node represents "(1, 2)". \n + While ast.Name.id represents 'x'. + """ + # I don't really want the 'Tuple' node in the graph, + # but to say that it's children are of type Tuple + # so set the current_type to Tuple, visit children, + # then unset the current_type + + # Set the current type to Tuple + self.current_type = "Tuple" + # Visit the children + self.generic_visit(node) + # Unset the current type + self.current_type = None + + # # Add the tuple node to the graph + # self.create_new_node( + # node_id=id(node), + # node_type="Tuple", + # node_label="tuple", + # node_parent_id=id(self.current_parent), + # ) + # # Set the current parent to the tuple node + # self.current_parent = node + # # Visit the children + # self.generic_visit(node) + + # endregion + + # region Variables + def visit_Name(self, node: ast.Name): + """Visit the name node. + + Parameters: + ----------- + node : ast.Name + The name node to visit. + + Notes: + ------ + In the following "x = 1", the ast.Name node represents 'x'. \n + While ast.Constant.value represents 1. + """ + # Add the name node to the graph + _type: str = None + if self.current_type: + _type = self.current_type + else: + _type = "Variable" + self.create_new_node( + node_id=id(node), + node_type=_type, + node_label=node.id, + node_parent_id=id(self.current_parent), + ) + # Set the current parent to the name node + self.current_parent = node + # Visit the children + self.generic_visit(node) + + def visit_Store(self, node: ast.Store): + """Visit the store node. + + Parameters: + ----------- + node : ast.Store + The store node to visit. + + Notes: + ------ + The ast.Store node indicates that this is a store context (i.e., the var is being assigned a value). \n + ast.Name.id represents the name of the variable being assigned. \n + ast.Constant.value represents the value being assigned to the variable. + """ + return + # Add the store node to the graph + self.create_new_node( + node_id=id(node), + node_type="Store", + node_label=None, + node_parent_id=id(self.current_parent), + ) + # Visit the children + self.generic_visit(node) + + def visit_Starred(self, node: ast.Starred): + """Visit the starred node. + + Parameters: + ----------- + node : ast.Starred + The starred node to visit. + + Notes: + ------ + In the following "x = [*range(10)]", the ast.Starred node represents "*range(10)". \n + While ast.Name.id represents 'x'. + """ + return + # Add the starred node to the graph + self.create_new_node( + node_id=id(node), + node_type="Starred", + node_label=None, + node_parent_id=id(self.current_parent), + ) + # Visit the children + self.generic_visit(node) + + def visit_arg(self, node: ast.arg): + """Visit the arg node. + + Parameters: + ----------- + node : ast.arg + The arg node to visit. + + Notes: + ------ + In the following "def some_func(x):", the ast.arg node represents 'x'. \n + While ast.Name.id represents 'some_func'. + """ + # Add the arg node to the graph + _type: str = None + if self.current_type: + _type = self.current_type + else: + _type = "Variable" + self.create_new_node( + node_id=id(node), + node_type=_type, + node_label=node.arg, + node_parent_id=id(self.current_parent), + ) + # Set the current parent to the arg node + self.current_parent = node + + # endregion + + # region Expressions + def visit_Attribute(self, node: ast.Attribute): + """Visit the attribute node. + + Parameters: + ----------- + node : ast.Attribute + The attribute node to visit. + + Notes: + ------ + In the following "x = some_obj.some_attr" : \n + ast.Attribute.value node represents 'some_obj'. \n + ast.Attribute.attr node represents 'some_attr'. \n + While ast.Name.id represents 'x'. + """ + return + # Add the attribute node to the graph + self.create_new_node( + node_id=id(node), + node_type="Attribute", + node_label=node.attr, + node_parent_id=id(self.current_parent), + ) + # Set the current parent to the attribute node + self.current_parent = node + # Visit the children + self.generic_visit(node) + + def visit_BinOp(self, node: ast.BinOp): + """Visit the binop node. + + Parameters: + ----------- + node : ast.BinOp + The binop node to visit. + + Notes: + ------ + In the following "x = 1 + 2", the ast.BinOp node represents Binary Operation itself. \n + ast.BinOp.value outputs "BinOp(left=Num(n=1), op=Add(), right=Num(n=2))" : \n + ast.BinOp.left node represents '1'. \n + ast.BinOp.op node represents 'Add()'. \n + ast.BinOp.right node represents '2'. \n + While ast.Name.id represents 'x'. + """ + return + # Add the binop node to the graph + self.create_new_node( + node_id=id(node), + node_type="BinOp", + node_label="BinOp", + node_parent_id=id(self.current_parent), + ) + # Set the current parent to the binop node + self.current_parent = node + # Visit the children + self.generic_visit(node) + + def visit_BoolOp(self, node: ast.BoolOp): + """Visit the boolop node. + + Parameters: + ----------- + node : ast.BoolOp + The boolop node to visit. + + Notes: + ------ + In the following "x = True and False", the ast.BoolOp node represents Boolean Operation itself. \n + ast.BoolOp.values outputs "BoolOp(values=[NameConstant(value=True), NameConstant(value=False)])" : \n + ast.BoolOp.values[0] node represents 'True'. \n + ast.BoolOp.values[1] node represents 'False'. \n + While ast.Name.id represents 'x'. + """ + return + # Add the boolop node to the graph + self.create_new_node( + node_id=id(node), + node_type="BoolOp", + node_label="BoolOp", + node_parent_id=id(self.current_parent), + ) + # Set the current parent to the boolop node + self.current_parent = node + # Visit the children + self.generic_visit(node) + + def visit_Call(self, node: ast.Call): + """Visit the call node. + + Parameters: + ----------- + node : ast.Call + The call node to visit. + + Notes: + ------ + In the following "x = some_func(1, 2)", the ast.Call node represents the function call itself. \n + ast.Call.func outputs "Name(id='some_func', ctx=Load())" : \n + ast.Call.func.id node represents 'some_func'. \n + ast.Call.args[0] node represents '1'. \n + ast.Call.args[1] node represents '2'. \n + While ast.Name.id represents 'x'. + """ + return + # Add the call node to the graph + self.graph.add_node( + id(node), type="Call", label="Call", parent=id(self.current_parent) + ) + # Add an edge from the current parent to the call node + self.graph.add_edge(id(self.current_parent), id(node)) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the call node + self.current_parent = node + # Visit the call's children + self.generic_visit(node) + # Set the current parent back to the call's parent + self.current_parent = old_parent + + def visit_Compare(self, node: ast.Compare): + """Visit the compare node. + + Parameters: + ----------- + node : ast.Compare + The compare node to visit. + + Notes: + ------ + In the following "x = 1 < 2", the ast.Compare node represents the comparison itself. \n + ast.Compare.left outputs "Num(n=1)" : \n + ast.Compare.left node represents '1'. \n + ast.Compare.ops[0] node represents 'Lt()'. \n + ast.Compare.comparators[0] node represents '2'. \n + While ast.Name.id represents 'x'. + """ + return + # Add the compare node to the graph + self.create_new_node( + node_id=id(node), + node_type="Compare", + node_label="Compare", + node_parent_id=id(self.current_parent), + ) + # Set the current parent to the compare node + self.current_parent = node + # Visit the children + self.generic_visit(node) + + def visit_Expr(self, node: ast.Expr): + """Visit the expression node. + + Parameters: + ----------- + node : ast.Expr + The expression node to visit. + + Notes: + ------ + In the following "x = 1", the ast.Expr node represents the expression itself. \n + ast.Expr.value outputs "Num(n=1)" : \n + ast.Expr.value node represents '1'. \n + While ast.Name.id represents 'x'.\n \n + The difference between ast.Expr and ast.Expression is that ast.Expr is a statement, while ast.Expression is an expression. \n + For example, "x = 1" is a statement, while "1" is an expression. + """ + return + # Add the expression node to the graph + self.graph.add_node( + id(node), type="Expr", label="Expr", parent=id(self.current_parent) + ) + # Add an edge from the current parent to the expression node + self.graph.add_edge(id(self.current_parent), id(node)) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the expression node + self.current_parent = node + # Visit the expression's children + self.generic_visit(node) + # Set the current parent back to the expression's parent + self.current_parent = old_parent + + def visit_IfExp(self, node: ast.IfExp): + """Visit the ifexp node. + + Parameters: + ----------- + node : ast.IfExp + The ifexp node to visit. + + Notes: + ------ + In the following "x = 1 if True else 2", the ast.IfExp node represents the if expression itself. \n + ast.IfExp.body outputs "Num(n=1)" : \n + ast.IfExp.body node represents '1'. \n + ast.IfExp.test node represents 'True'. \n + ast.IfExp.orelse node represents '2'. \n + While ast.Name.id represents 'x'. + """ + # Add the ifexp node to the graph + self.create_new_node( + node_id=id(node), + node_type="IfExp", + node_label="IfExp", + node_parent_id=id(self.current_parent), + ) + # Set the current parent to the ifexp node + self.current_parent = node + # Visit the children + self.generic_visit(node) + + def visit_NamedExpr(self, node: ast.NamedExpr): + """Visit the namedexpr node. + + Parameters: + ----------- + node : ast.NamedExpr + The namedexpr node to visit. + + Notes: + ------ + In the following "x := 1", the ast.NamedExpr node represents the named expression itself. \n + ast.NamedExpr.value outputs "Num(n=1)" : \n + ast.NamedExpr.value node represents '1'. \n + While ast.Name.id represents 'x'. + """ + return + # Add the namedexpr node to the graph + self.create_new_node( + node_id=id(node), + node_type="NamedExpr", + node_label="NamedExpr", + node_parent_id=id(self.current_parent), + ) + # Set the current parent to the namedexpr node + self.current_parent = node + # Visit the children + self.generic_visit(node) + + def visit_UnaryOp(self, node: ast.UnaryOp): + """Visit the unaryop node. + + Parameters: + ----------- + node : ast.UnaryOp + The unaryop node to visit. + + Notes: + ------ + In the following "-x", the ast.UnaryOp node represents the unary operation itself. \n + ast.UnaryOp.operand outputs "Name(id='x')" : \n + ast.UnaryOp.operand node represents 'x'. \n + ast.UnaryOp.op node represents 'USub()'. \n + While ast.Name.id represents 'x'. + """ + return + # Add the unaryop node to the graph + self.create_new_node( + node_id=id(node), + node_type="UnaryOp", + node_label="UnaryOp", + node_parent_id=id(self.current_parent), + ) + # Set the current parent to the unaryop node + self.current_parent = node + # Visit the children + self.generic_visit(node) + + # endregion + + # region Expression - Comprehensions + def visit_DictComp(self, node: ast.DictComp): + """Visit the dictcomp node. + + Parameters: + ----------- + node : ast.DictComp + The dictcomp node to visit. + + Notes: + ------ + In the following "squares = {x: x**2 for x in range(1, 6)}", the ast.DictComp node represents the dictionary comprehension itself. \n + ast.DictComp.key outputs "Name(id='x')" : \n + ast.DictComp.key node represents 'x'. \n + ast.DictComp.value node represents an ast.BinOp node 'BinOp()'. \n + ast.DictComp.generators node represents 'comprehension()'. \n + While ast.Name.id represents 'squares'. + """ + return + # Add the dictcomp node to the graph + self.create_new_node( + node_id=id(node), + node_type="DictComp", + node_label="DictComp", + node_parent_id=id(self.current_parent), + ) + # Set the current parent to the dictcomp node + self.current_parent = node + # Visit the children + self.generic_visit(node) + + def visit_GeneratorExp(self, node: ast.GeneratorExp): + """Visit the generatorexp node. + + Parameters: + ----------- + node : ast.GeneratorExp + The generatorexp node to visit. + + Notes: + ------ + In the following "squares = (x**2 for x in range(1, 6))", the ast.GeneratorExp node represents the generator expression itself. \n + ast.GeneratorExp.elt outputs "BinOp()" : \n + ast.GeneratorExp.elt node represents 'BinOp()'. \n + ast.GeneratorExp.generators node represents 'comprehension()'. \n + While ast.Name.id represents 'squares'. + """ + return + # Add the generatorexp node to the graph + self.create_new_node( + node_id=id(node), + node_type="GeneratorExp", + node_label="GenExp", + node_parent_id=id(self.current_parent), + ) + # Set the current parent to the generatorexp node + self.current_parent = node + # Visit the children + self.generic_visit(node) + + def visit_ListComp(self, node: ast.ListComp): + """Visit the listcomp node. + + Parameters: + ----------- + node : ast.ListComp + The listcomp node to visit. + + Notes: + ------ + In the following "squares = [x**2 for x in range(1, 6)]", the ast.ListComp node represents the list comprehension itself. \n + ast.ListComp.elt outputs "BinOp()" : \n + ast.ListComp.elt node represents a ast.BinOp node 'BinOp()'. \n + ast.ListComp.generators node represents 'comprehension()'. \n + While ast.Name.id represents 'squares'. + """ + # Add the listcomp node to the graph + self.create_new_node( + node_id=id(node), + node_type="ListComp", + node_label="ListComp", + node_parent_id=id(self.current_parent), + ) + # Set the current parent to the listcomp node + self.current_parent = node + # Visit the children + self.generic_visit(node) + + def visit_SetComp(self, node: ast.SetComp): + """Visit the setcomp node. + + Parameters: + ----------- + node : ast.SetComp + The setcomp node to visit. + + Notes: + ------ + In the following "squares = {x**2 for x in range(1, 6)}", the ast.SetComp node represents the set comprehension itself. \n + ast.SetComp.elt outputs "BinOp()" : \n + ast.SetComp.elt node represents a ast.BinOp node 'BinOp()'. \n + ast.SetComp.generators node represents 'comprehension()'. \n + While ast.Name.id represents 'squares'. + """ + # Add the setcomp node to the graph + self.create_new_node( + node_id=id(node), + node_type="SetComp", + node_label="SetComp", + node_parent_id=id(self.current_parent), + ) + # Set the current parent to the setcomp node + self.current_parent = node + # Visit the children + self.generic_visit(node) + + # endregion + + # region Expression - Subscripting + def visit_Slice(self, node: ast.Slice): + """Visit the slice node. + + Parameters: + ----------- + node : ast.Slice + The slice node to visit. + + Notes: + ------ + In the following "x[1:2]", the ast.Slice node represents the slice itself. \n + ast.Slice.lower outputs "Constant(value=1)" : \n + ast.Slice.lower node represents 'Constant(value=1)'. \n + ast.Slice.upper node represents 'Constant(value=2)'. \n + ast.Slice.step node represents 'None'. \n + While ast.Name.id represents 'x'. + """ + return + # Add the slice node to the graph + self.create_new_node( + node_id=id(node), + node_type="Slice", + node_label="Slice", + node_parent_id=id(self.current_parent), + ) + # Set the current parent to the slice node + self.current_parent = node + # Visit the children + self.generic_visit(node) + + def visit_Subscript(self, node: ast.Subscript): + """Visit the subscript node. + + Parameters: + ----------- + node : ast.Subscript + The subscript node to visit. + + Notes: + ------ + In the following "x[1:2]", the ast.Subscript node represents the subscript itself. \n + ast.Subscript.value outputs "Name(id='x')" : \n + ast.Subscript.value node represents an ast.Name node Name(id='x'). \n + ast.Subscript.slice node represents an ast.Slice node 'Slice()'. \n + While ast.Name.id represents 'x'. + """ + return + # Add the subscript node to the graph + self.create_new_node( + node_id=id(node), + node_type="Subscript", + node_label="Subscript", + node_parent_id=id(self.current_parent), + ) + # Set the current parent to the subscript node + self.current_parent = node + # Visit the children + self.generic_visit(node) + + # endregion + + # region Statements + def visit_AnnAssign(self, node: ast.AnnAssign): + """Visit the annotated assignment node. + + Parameters: + ----------- + node : ast.AnnAssign + The annotated assignment node to visit. + + Notes: + ------ + In the following "x: int = 1", the ast.AnnAssign node represents the annotated assignment itself. \n + ast.AnnAssign.target outputs "Name(id='x')" : \n + ast.AnnAssign.target node represents an ast.Name node Name(id='x'). \n + ast.AnnAssign.annotation node represents 'Name(id='int')'. \n + ast.AnnAssign.value node represents 'Constant(value=1)'. \n + While ast.Name.id represents 'x'. + """ + self.generic_visit(node) + return + # Add the annotated assignment node to the graph + self.graph.add_node( + id(node), + type="AnnAssign", + label="AnnAssign", + parent=id(self.current_parent), + ) + # Add an edge from the current parent to the annotated assignment node + self.graph.add_edge(id(self.current_parent), id(node)) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the annotated assignment node + self.current_parent = node + # Visit the annotated assignment's children + self.generic_visit(node) + # Set the current parent back to the annotated assignment's parent + self.current_parent = old_parent + + def visit_Assert(self, node: ast.Assert): + """Visit the assert node. + + Parameters: + ----------- + node : ast.Assert + The assert node to visit. + + Notes: + ------ + In the following "assert x == 1", the ast.Assert node represents the assert itself. \n + ast.Assert.test outputs "Compare()" : \n + ast.Assert.test node represents a ast.Compare node 'Compare()'. \n + ast.Assert.msg node represents 'None'. \n + While ast.Name.id represents 'x'. + """ + self.generic_visit(node) + return + # Add the assert node to the graph + self.graph.add_node( + id(node), + type="Assert", + label="Assert", + parent=id(self.current_parent), + ) + # Add an edge from the current parent to the assert node + self.graph.add_edge(id(self.current_parent), id(node)) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the assert node + self.current_parent = node + # Visit the assert's children + self.generic_visit(node) + # Set the current parent back to the assert's parent + self.current_parent = old_parent + + # def infer_type(self, node): + # if isinstance(node, ast.Constant): + # # For Python 3.8+ + # return type(node.value).__name__ + # elif isinstance(node, ast.Num): + # return type(node.n).__name__ + # elif isinstance(node, ast.Str): + # return "str" + # # Add other cases if needed + # else: + # return "var" + + def visit_Assign(self, node: ast.Assign): + """Visit the assignment node. + + Parameters: + ----------- + node : ast.Assign + The assignment node to visit. + + Notes: + ------ + In the following "x = 1", the ast.Assign node represents the assignment itself. \n + ast.Assign.targets outputs "Name(id='x')" : \n + ast.Assign.targets node represents an ast.Name node Name(id='x'). \n + ast.Assign.value node represents 'Constant(value=1)'. \n + While ast.Name.id represents 'x'. + """ + # Assuming the target is a single variable + # check if the target has an id attribute + self.generic_visit(node) + return + _name = "" + parent_id = id(self.current_parent) + + if hasattr(node.targets[0], "id"): + _name = node.targets[0].id + elif hasattr(node.targets[0], "attr"): + _name = node.targets[0].attr + if ( + isinstance(node.targets[0].value, ast.Name) + and node.targets[0].value.id == "self" + ): + # If the attribute is assigned to `self`, set the parent to the current class + parent_id = id(self.current_class) + elif hasattr(node.targets[0], "value"): + _name = node.targets[0].value + else: + _name = node.value + + # # Infer the type of the assigned value + # var_type = self.infer_type(node.value) + + # Now you can create a node with the label as the variable name and base as the inferred type + self.create_new_node( + node_id=id(node), + node_type="Variable", + node_label=_name, + node_parent_id=id(self.current_parent), + ) + + def visit_AugAssign(self, node: ast.AugAssign): + """Visit the augmented assignment node. + + Parameters: + ----------- + node : ast.AugAssign + The augmented assignment node to visit. + + Notes: + ------ + In the following "x += 1", the ast.AugAssign node represents the augmented assignment itself. \n + ast.AugAssign.target outputs "Name(id='x')" : \n + ast.AugAssign.target node represents an ast.Name node Name(id='x'). \n + ast.AugAssign.op node represents an ast.Add node 'Add()'. \n + ast.AugAssign.value node represents an ast.Constant node 'Constant(value=1)'. \n + While ast.Name.id represents 'x'. + """ + self.generic_visit(node) + return + # Add the augmented assignment node to the graph + self.graph.add_node( + id(node), + type="AugAssign", + label="AugAssign", + parent=id(self.current_parent), + ) + # Add an edge from the current parent to the augmented assignment node + self.graph.add_edge(id(self.current_parent), id(node)) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the augmented assignment node + self.current_parent = node + # Visit the augmented assignment's children + self.generic_visit(node) + # Set the current parent back to the augmented assignment's parent + self.current_parent = old_parent + + def visit_Delete(self, node: ast.Delete): + """Visit the delete node. + + Parameters: + ----------- + node : ast.Delete + The delete node to visit. + + Notes: + ------ + In the following "del x", the ast.Delete node represents the delete itself. \n + ast.Delete.targets outputs "Name(id='x')" : \n + ast.Delete.targets node represents an ast.Name node Name(id='x'). \n + While ast.Name.id represents 'x'. + """ + # Add the delete node to the graph + self.create_new_node( + node_id=id(node), + node_type="Delete", + node_label="del", + node_parent_id=id(self.current_parent), + ) + + def visit_Pass(self, node: ast.Pass): + """Visit the pass node. + + Parameters: + ----------- + node : ast.Pass + The pass node to visit. + + Notes: + ------ + In the following "def my_func(): pass", the ast.Pass node represents the pass itself. \n + ast.Pass has no children. + """ + + def visit_Raise(self, node: ast.Raise): + """Visit the raise node. + + Parameters: + ----------- + node : ast.Raise + The raise node to visit. + + Notes: + ------ + In the following "raise Exception()", the ast.Raise node represents the raise itself. \n + ast.Raise.exc outputs "Name(id='Exception')" : \n + ast.Raise.exc node represents an ast.Name node Name(id='Exception'). \n + While ast.Name.id represents 'Exception'. + """ + # Add the raise node to the graph + self.create_new_node( + node_id=id(node), + node_type="Raise", + node_label="raise", + node_parent_id=id(self.current_parent), + ) + + # endregion + + # region Statements - Imports + def visit_Import(self, node: ast.Import): + """Visit the import node. + + Parameters: + ----------- + node : ast.Import + The import node to visit. + + Notes: + ------ + In the following "import os", the ast.Import node represents the import itself. \n + ast.Import.names outputs "alias(name='os')" : \n + ast.Import.names node represents an ast.alias node alias(name='os'). \n + While ast.alias.name represents 'os'. + """ + return + # Add the import node to the graph + self.create_new_node( + node_id=id(node), + node_type="Import", + node_label="import", + node_parent_id=id(self.current_parent), + ) + + def visit_ImportFrom(self, node: ast.ImportFrom): + """Visit the import from node. + + Parameters: + ----------- + node : ast.ImportFrom + The import from node to visit. + + Notes: + ------ + In the following "from os import path", the ast.ImportFrom node represents the import from itself. \n + ast.ImportFrom.module outputs "os" : \n + ast.ImportFrom.module node represents an ast.Module node for os. \n + """ + return + # Add the import from node to the graph + self.create_new_node( + node_id=id(node), + node_type="ImportFrom", + node_label="import from", + node_parent_id=id(self.current_parent), + ) + + # endregion + + # region Control Flow + def visit_Break(self, node: ast.Break): + """Visit the break node. + + Parameters: + ----------- + node : ast.Break + The break node to visit. + + Notes: + ------ + In the following "while True: break", the ast.Break node represents the break itself. \n + ast.Break has no children. + """ + return + # Add the break node to the graph + self.create_new_node( + node_id=id(node), + node_type="Break", + node_label="break", + node_parent_id=id(self.current_parent), + ) + + def visit_Continue(self, node: ast.Continue): + """Visit the continue node. + + Parameters: + ----------- + node : ast.Continue + The continue node to visit. + + Notes: + ------ + In the following "while True: continue", the ast.Continue node represents the continue itself. \n + ast.Continue has no children. + """ + return + # Add the continue node to the graph + self.create_new_node( + node_id=id(node), + node_type="Continue", + node_label="continue", + node_parent_id=id(self.current_parent), + ) + + def visit_ExceptHandler(self, node: ast.ExceptHandler): + """Visit the except handler node. + + Parameters: + ----------- + node : ast.ExceptHandler + The except handler node to visit. + + Notes: + ------ + In the following "try: except Exception as e: pass", the ast.ExceptHandler node represents the except handler itself. \n + ast.ExceptHandler.type outputs "Name(id='Exception')" : \n + ast.ExceptHandler.type node represents an ast.Name node Name(id='Exception'). \n + While ast.Name.id represents 'Exception'. + """ + # Add the except handler node to the graph + self.create_new_node( + node_id=id(node), + node_type="ExceptHandler", + node_label="except", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the except handler node + self.current_parent = node + # Visit the except handler's children + self.generic_visit(node) + # Set the current parent back to the except handler's parent + self.current_parent = old_parent + + def visit_For(self, node: ast.For): + """Visit the for node. + + Parameters: + ----------- + node : ast.For + The for node to visit. + + Notes: + ------ + In the following "for i in range(10): pass", the ast.For node represents the for itself. \n + ast.For.target node represents an ast.Name node Name(id='i'). \n + ast.For.iter node represents an ast.Call node Call(func=Name(id='range'), args=[Num(n=10)], keywords=[]). + """ + # Add the for node to the graph + self.create_new_node( + node_id=id(node), + node_type="For", + node_label="for", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the for node + self.current_parent = node + # Visit the for's children + self.generic_visit(node) + # Set the current parent back to the for's parent + self.current_parent = old_parent + + def visit_If(self, node: ast.If): + """Visit the if node. + + Parameters: + ----------- + node : ast.If + The if node to visit. + + Notes: + ------ + In the following "if x==z: pass", the ast.If node represents the if itself. \n + ast.If.test node represents an ast.Compare node Compare(left=Name(id='x'), ops=[Eq()], comparators=[Name(id='z')]). + """ + # Add the if node to the graph + self.create_new_node( + node_id=id(node), + node_type="If", + node_label="if", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the if node + self.current_parent = node + # Visit the if's children + self.generic_visit(node) + # Set the current parent back to the if's parent + self.current_parent = old_parent + + def visit_Try(self, node: ast.Try): + """Visit the try node. + + Parameters: + ----------- + node : ast.Try + The try node to visit. + + Notes: + ------ + In the following "try: x += 5" except: pass else: print(worked) finally: print(done), the ast.Try node represents the try itself. \n + ast.Try.body represents the contents of try, in this case an ast.Assign node. \n + ast.Try.handlers represents the except handlers, in this case an ast.ExceptHandler node. \n + ast.Try.orelse represents the else clause of the try, in this case an ast.Expr node. \n + ast.Try.finalbody represents the finally clause of the try, in this case an ast.Expr node. + """ + # Add the try node to the graph + self.create_new_node( + node_id=id(node), + node_type="Try", + node_label="try", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the try node + self.current_parent = node + # Visit the try's children + self.generic_visit(node) + # Set the current parent back to the try's parent + self.current_parent = old_parent + + # def visit_TryStar(self, node : ast.TryStar): + def visit_While(self, node: ast.While): + """Visit the while node. + + Parameters: + ----------- + node : ast.While + The while node to visit. + + Notes: + ------ + In the following "while x < 10: y + 5", the ast.While node represents the while itself. \n + ast.While.test node represents an ast.Compare node for 'x < 10'. \n + ast.While.body represents the contents of the while, in this case an ast.Expr node. + """ + # Add the while node to the graph + self.create_new_node( + node_id=id(node), + node_type="While", + node_label="while", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the while node + self.current_parent = node + # Visit the while's children + self.generic_visit(node) + # Set the current parent back to the while's parent + self.current_parent = old_parent + + def visit_With(self, node: ast.With): + """Visit the with node. + + Parameters: + ----------- + node : ast.With + The with node to visit. + + Notes: + ------ + In the following "with open('file.txt', 'r') as f: f.read()", the ast.With node represents the with itself. \n + ast.With.items represents the context managers, in this case an ast.withitem node. \n + ast.With.body represents the contents of the with, in this case an ast.Expr node. + """ + # Add the with node to the graph + self.create_new_node( + node_id=id(node), + node_type="With", + node_label="with", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the with node + self.current_parent = node + # Visit the with's children + self.generic_visit(node) + # Set the current parent back to the with's parent + self.current_parent = old_parent + + # endregion + + # region Pattern Matching + def visit_Match(self, node: ast.Match): + """Visit the match node. + + Parameters: + ----------- + node : ast.Match + The match node to visit. + + Notes: + ------ + In the following "match x: case 1: pass", the ast.Match node represents the match itself. \n + ast.Match.cases represents the cases of the match, in this case an ast.MatchCase node. + """ + return + # Add the match node to the graph + self.create_new_node( + node_id=id(node), + node_type="Match", + node_label="match", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the match node + self.current_parent = node + # Visit the match's children + self.generic_visit(node) + # Set the current parent back to the match's parent + self.current_parent = old_parent + + def visit_MatchAs(self, node: ast.MatchAs): + """Visit the match as node. + + Parameters: + ----------- + node : ast.MatchAs + The match as node to visit. + + Notes: + ------ + In the following "match x: case 1 as y: pass", the ast.MatchAs node represents the match as itself. \n + ast.MatchAs.pattern represents the pattern of the match as, in this case an ast.Name node. \n + ast.MatchAs.name represents the name of the match as, in this case an ast.Name node. + """ + return + # Add the match as node to the graph + self.create_new_node( + node_id=id(node), + node_type="MatchAs", + node_label="match as", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the match as node + self.current_parent = node + # Visit the match as's children + self.generic_visit(node) + # Set the current parent back to the match as's parent + self.current_parent = old_parent + + def visit_MatchClass(self, node: ast.MatchClass): + """Visit the match class node. + + Parameters: + ----------- + node : ast.MatchClass + The match class node to visit. + + Notes: + ------ + In the following "match x: case A(y): pass", the ast.MatchClass node represents the match class itself. \n + ast.MatchClass.pattern represents the pattern of the match class, in this case an ast.Call node. + """ + return + # Add the match class node to the graph + self.create_new_node( + node_id=id(node), + node_type="MatchClass", + node_label="match class", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the match class node + self.current_parent = node + # Visit the match class's children + self.generic_visit(node) + # Set the current parent back to the match class's parent + self.current_parent = old_parent + + def visit_MatchMapping(self, node: ast.MatchMapping): + """Visit the match mapping node. + + Parameters: + ----------- + node : ast.MatchMapping + The match mapping node to visit. + + Notes: + ------ + In the following "match x: case {1: y}: pass", the ast.MatchMapping node represents the match mapping itself. \n + ast.MatchMapping.pattern represents the pattern of the match mapping, in this case an ast.Dict node. + """ + return + # Add the match mapping node to the graph + self.create_new_node( + node_id=id(node), + node_type="MatchMapping", + node_label="match mapping", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the match mapping node + self.current_parent = node + # Visit the match mapping's children + self.generic_visit(node) + # Set the current parent back to the match mapping's parent + self.current_parent = old_parent + + def visit_MatchOr(self, node: ast.MatchOr): + """Visit the match or node. + + Parameters: + ----------- + node : ast.MatchOr + The match or node to visit. + + Notes: + ------ + In the following "match x: case 1 | 2: pass", the ast.MatchOr node represents the match or itself. \n + ast.MatchOr.left represents the left side of the match or, in this case an ast.Constant node. \n + ast.MatchOr.right represents the right side of the match or, in this case an ast.Constant node. + """ + return + # Add the match or node to the graph + self.create_new_node( + node_id=id(node), + node_type="MatchOr", + node_label="match or", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the match or node + self.current_parent = node + # Visit the match or's children + self.generic_visit(node) + # Set the current parent back to the match or's parent + self.current_parent = old_parent + + def visit_MatchSequence(self, node: ast.MatchSequence): + """Visit the match sequence node. + + Parameters: + ----------- + node : ast.MatchSequence + The match sequence node to visit. + + Notes: + ------ + In the following "match x: case [1, 2]: pass", the ast.MatchSequence node represents the match sequence itself. \n + ast.MatchSequence.pattern represents the pattern of the match sequence, in this case an ast.List node. + """ + return + # Add the match sequence node to the graph + self.create_new_node( + node_id=id(node), + node_type="MatchSequence", + node_label="match sequence", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the match sequence node + self.current_parent = node + # Visit the match sequence's children + self.generic_visit(node) + # Set the current parent back to the match sequence's parent + self.current_parent = old_parent + + def visit_MatchSingleton(self, node: ast.MatchSingleton): + """Visit the match singleton node. + + Parameters: + ----------- + node : ast.MatchSingleton + The match singleton node to visit. + + Notes: + ------ + In the following "match x: case 1: pass", the ast.MatchSingleton node represents the match singleton itself. \n + ast.MatchSingleton.pattern represents the pattern of the match singleton, in this case an ast.Constant node. + """ + return + # Add the match singleton node to the graph + self.create_new_node( + node_id=id(node), + node_type="MatchSingleton", + node_label="match singleton", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the match singleton node + self.current_parent = node + # Visit the match singleton's children + self.generic_visit(node) + # Set the current parent back to the match singleton's parent + self.current_parent = old_parent + + def visit_MatchStar(self, node: ast.MatchStar): + """Visit the match star node. + + Parameters: + ----------- + node : ast.MatchStar + The match star node to visit. + + Notes: + ------ + In the following "match x: case [1, *y]: pass", the ast.MatchStar node represents the match star itself. \n + ast.MatchStar.pattern represents the pattern of the match star, in this case an ast.List node. + """ + return + # Add the match star node to the graph + self.create_new_node( + node_id=id(node), + node_type="MatchStar", + node_label="match star", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the match star node + self.current_parent = node + # Visit the match star's children + self.generic_visit(node) + # Set the current parent back to the match star's parent + self.current_parent = old_parent + + def visit_MatchValue(self, node: ast.MatchValue): + """Visit the match value node. + + Parameters: + ----------- + node : ast.MatchValue + The match value node to visit. + + Notes: + ------ + In the following "match x: case 1: pass", the ast.MatchValue node represents the match value itself. \n + ast.MatchValue.pattern represents the pattern of the match value, in this case an ast.Constant node. + """ + return + # Add the match value node to the graph + self.create_new_node( + node_id=id(node), + node_type="MatchValue", + node_label="match value", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the match value node + self.current_parent = node + # Visit the match value's children + self.generic_visit(node) + # Set the current parent back to the match value's parent + self.current_parent = old_parent + + # endregion + + # region Functions and Class Definitions + def visit_ClassDef(self, node: ast.ClassDef): + """Visit the class node. + + Parameters: + ----------- + node : ast.ClassDef + The class node to visit. + + Notes: + ------ + In the following "class A(baseClass): def __init__(self, a:int=5): self.x=a*2", the ast.ClassDef node represents the class itself. \n + ast.ClassDef.name represents the name of the class, in this case 'A'. \n + ast.ClassDef.bases represents the base classes, in this case 'baseClass'. \n + ast.ClassDef.keywords represents the keyword arguments, in this case an ast.arg node to represent 'self' and 'a'. \n + ast.ClassDef.body represents the contents of the class, in this case an ast.FunctionDef node for '__init__'. + """ + # Add the class node to the graph + # current_parent should be a module node, unless it's a sub class + self.create_new_node( + node_id=id(node), + node_type="ClassDef", + node_label=node.name, + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the class node + # because now we'll be visiting the class' children + self.current_parent = node + # # Save the previous current_class + # previous_class = self.current_class + # # Set the current_class + # self.current_class = node + # Visit the class' children + self.generic_visit(node) + # Set the current parent back to the class' parent + # we've finished visiting the class' children, so go back to class' parent + self.current_parent = old_parent + # # Restore the previous current_class + # self.current_class = previous_class + + def visit_FunctionDef(self, node: ast.FunctionDef): + """Visit the function node. + + Parameters: + ----------- + node : ast.FunctionDef + The function node to visit. + + Notes: + ------ + In the following "def func(a:int=5): b = a*2 return b", the ast.FunctionDef node represents the function itself. \n + ast.FunctionDef.name represents the name of the function, in this case 'func'. \n + ast.FunctionDef.args represents the arguments, in this case an ast.arg node to represent 'a'. \n + ast.FunctionDef.body represents the contents of the function, in this case an ast.Assign node for 'b = a*2' and an ast.Return node. + """ + # function_parent: nx.DiGraph + # if self.current_class: + # function_parent = self.current_class + # else: + # if self.current_module: + # function_parent = self.current_module + # else: + # function_parent = self.current_parent + # Add the function node to the graph + self.create_new_node( + node_id=id(node), + node_type="FunctionDef", + node_label=node.name, + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the function node + self.current_parent = node + # # Save the previous current_function + # previous_function = self.current_function + # # Set the current_function + # self.current_function = node + # Visit the function's children + self.generic_visit(node) + # Set the current parent back to the function's parent + self.current_parent = old_parent + # # Restore the previous current_function + # self.current_function = previous_function + + def visit_Global(self, node: ast.Global): + """Visit the global node. + + Parameters: + ----------- + node : ast.Global + The global node to visit. + + Notes: + ------ + In the following "global a b", the ast.Global node represents the global itself. \n + ast.Global doesn't have any children nodes to represent 'a' and 'b'. + """ + # Add the global node to the graph + self.create_new_node( + node_id=id(node), + node_type="Global", + node_label="global", + node_parent_id=id(self.current_parent), + ) + + def visit_Lambda(self, node: ast.Lambda): + """Visit the lambda node. + + Parameters: + ----------- + node : ast.Lambda + The lambda node to visit. + + Notes: + ------ + In the following "lambda a: a*2", the ast.Lambda node represents the lambda itself. \n + ast.Lambda.args represents the arguments, in this case an ast.arg node to represent 'a'. \n + ast.Lambda.body represents the contents of the lambda, in this case an ast.BinOp node for 'a*2'. + """ + return + # Add the lambda node to the graph + self.create_new_node( + node_id=id(node), + node_type="Lambda", + node_label="lambda", + node_parent_id=id(self.current_parent), + ) + + def visit_Nonlocal(self, node: ast.Nonlocal): + """Visit the nonlocal node. + + Parameters: + ----------- + node : ast.Nonlocal + The nonlocal node to visit. + + Notes: + ------ + In the following "nonlocal a b", the ast.Nonlocal node represents the nonlocal itself. \n + ast.Nonlocal doesn't have any children nodes to represent 'a' and 'b'. + """ + return + # Add the nonlocal node to the graph + self.create_new_node( + node_id=id(node), + node_type="Nonlocal", + node_label="nonlocal", + node_parent_id=id(self.current_parent), + ) + + def visit_Return(self, node: ast.Return): + """Visit the return node. + + Parameters: + ----------- + node : ast.Return + The return node to visit. + + Notes: + ------ + In the following "return a", the ast.Return node represents the return itself. \n + ast.Return.value represents the value to return, in this case an ast.Name node for 'a'. + """ + # Add the return node to the graph + self.create_new_node( + node_id=id(node), + node_type="Return", + node_label="return", + node_parent_id=id(self.current_parent), + ) + + def visit_Yield(self, node: ast.Yield): + """Visit the yield node. + + Parameters: + ----------- + node : ast.Yield + The yield node to visit. + + Notes: + ------ + In the following "yield a", the ast.Yield node represents the yield itself. \n + ast.Yield.value represents the value to yield, in this case an ast.Name node for 'a'. + """ + return + # Add the yield node to the graph + self.create_new_node( + node_id=id(node), + node_type="Yield", + node_label="yield", + node_parent_id=id(self.current_parent), + ) + + def visit_YieldFrom(self, node: ast.YieldFrom): + """Visit the yield from node. + + Parameters: + ----------- + node : ast.YieldFrom + The yield from node to visit. + + Notes: + ------ + In the following "yield from a", the ast.YieldFrom node represents the yield from itself. \n + ast.YieldFrom.value represents the value to yield from, in this case an ast.Name node for 'a'. + """ + return + # Add the yield from node to the graph + self.create_new_node( + node_id=id(node), + node_type="YieldFrom", + node_label="yield from", + node_parent_id=id(self.current_parent), + ) + + # endregion + + # region Async and Await + def visit_AsyncFor(self, node: ast.AsyncFor): + """Visit the async for node. + + Parameters: + ----------- + node : ast.AsyncFor + The async for node to visit. + + Notes: + ------ + In the following "async for a in b: a+5", the ast.AsyncFor node represents the async for itself. \n + ast.AsyncFor.target represents the target of the async for, in this case an ast.Name node for 'a'. \n + ast.AsyncFor.iter represents the iterable of the async for, in this case an ast.Name node for 'b'. \n + ast.AsyncFor.body represents the contents of the async for, in this case an ast.BinOp node for 'a+5'. + """ + return + # Add the async for node to the graph + self.create_new_node( + node_id=id(node), + node_type="AsyncFor", + node_label="async for", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the async for node + self.current_parent = node + # Visit the async for's children + self.generic_visit(node) + # Set the current parent back to the async for's parent + self.current_parent = old_parent + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef): + """Visit the async function node. + + Parameters: + ----------- + node : ast.AsyncFunctionDef + The async function node to visit. + + Notes: + ------ + In the following "async def a(b): b+5", the ast.AsyncFunctionDef node represents the async function itself. \n + ast.AsyncFunctionDef.name represents the name of the async function, in this case an ast.Name node for 'a'. \n + ast.AsyncFunctionDef.args represents the arguments of the async function, in this case an ast.arguments node for 'b'. \n + ast.AsyncFunctionDef.body represents the contents of the async function, in this case an ast.BinOp node for 'b+5'. + """ + return + # Add the async function node to the graph + self.create_new_node( + node_id=id(node), + node_type="AsyncFunctionDef", + node_label=node.name, + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the async function node + self.current_parent = node + # Visit the async function's children + self.generic_visit(node) + # Set the current parent back to the async function's parent + self.current_parent = old_parent + + def visit_AsyncWith(self, node: ast.AsyncWith): + """Visit the async with node. + + Parameters: + ----------- + node : ast.AsyncWith + The async with node to visit. + + Notes: + ------ + In the following "async with a as b: b+5", the ast.AsyncWith node represents the async with itself. \n + ast.AsyncWith.items represents the items of the async with, in this case an ast.withitem node for 'a as b'. \n + ast.AsyncWith.body represents the contents of the async with, in this case an ast.BinOp node for 'b+5'. + """ + return + # Add the async with node to the graph + self.create_new_node( + node_id=id(node), + node_type="AsyncWith", + node_label="async with", + node_parent_id=id(self.current_parent), + ) + # Set the old parent to the current parent + old_parent = self.current_parent + # Set the current parent to the async with node + self.current_parent = node + # Visit the async with's children + self.generic_visit(node) + # Set the current parent back to the async with's parent + self.current_parent = old_parent + + def visit_Await(self, node: ast.Await): + """Visit the await node. + + Parameters: + ----------- + node : ast.Await + The await node to visit. + + Notes: + ------ + In the following "await a", the ast.Await node represents the await itself. \n + ast.Await.value represents the value to await, in this case an ast.Name node for 'a'. + """ + return + # Add the await node to the graph + self.create_new_node( + node_id=id(node), + node_type="Await", + node_label="await", + node_parent_id=id(self.current_parent), + ) + + # endregion diff --git a/src/codecarto/local/src/plotter/__init__.py b/src/codecarto/local/src/plotter/__init__.py new file mode 100644 index 0000000..380140d --- /dev/null +++ b/src/codecarto/local/src/plotter/__init__.py @@ -0,0 +1,2 @@ +# Plotter folder contains the modules needed to plot graphs. +# This includes the palette, pal dirs, positioning, layouts, etc. diff --git a/src/codecarto/local/src/plotter/custom_layouts/arch_layout.py b/src/codecarto/local/src/plotter/custom_layouts/arch_layout.py new file mode 100644 index 0000000..be7830e --- /dev/null +++ b/src/codecarto/local/src/plotter/custom_layouts/arch_layout.py @@ -0,0 +1,23 @@ +import matplotlib.pyplot as plt +import networkx as nx + +def arc_layout(G): + pos = nx.spring_layout(G) # To make the initial layout more interesting + nodes = G.nodes() + plt.figure(figsize=(8, 4)) + # Draw nodes + for node in nodes: + plt.scatter(pos[node][0], 0, s=100, c='blue') + + # Draw edges + for edge in G.edges(): + start, end = pos[edge[0]], pos[edge[1]] + x = [start[0], end[0]] + y = [0, 0] + plt.plot(x, y, c='red', alpha=0.5, zorder=1) + + # Remove y-axis + plt.gca().axes.get_yaxis().set_visible(False) + + # Show the plot + plt.show() \ No newline at end of file diff --git a/src/codecarto/local/src/plotter/custom_layouts/cluster_layout.py b/src/codecarto/local/src/plotter/custom_layouts/cluster_layout.py new file mode 100644 index 0000000..ea25526 --- /dev/null +++ b/src/codecarto/local/src/plotter/custom_layouts/cluster_layout.py @@ -0,0 +1,91 @@ +import numpy as np + +def cluster_layout(G, root, radius=1): + positions = {root: np.array([0, 0])} # Initialize the positions dictionary with the root at the center + unvisited = set(G.nodes()) - {root} # Nodes that haven't been visited yet + + stack = [(root, 0, 2*np.pi, radius)] # Initialize the stack with the root, start_angle, end_angle, and depth + + while stack: + node, angle, d_angle, depth = stack.pop() # Pop a node from the stack + children = list(G.neighbors(node)) + num_children = len(children) + for i, child in enumerate(children): + child_angle = angle - d_angle/2 + i*d_angle/num_children # calculate the angle for the child + positions[child] = depth * np.array([np.cos(child_angle), np.sin(child_angle)]) # calculate the position for the child + stack.append((child, child_angle, d_angle/num_children, depth+radius)) # add the child to the stack + + # Process unvisited nodes, if any + while unvisited: + node = unvisited.pop() + if node in positions: # If we've already visited the node, continue + continue + parents = list(G.predecessors(node)) # Get the node's parents + if parents: # If the node has parents + parent = parents[0] # Assume that the node has only one parent + if parent in positions: # If the parent has been visited + positions[node] = positions[parent] # Position the node at the parent's position + # Layout the subgraph rooted at the node, iteratively + stack.append((node, 0, 2*np.pi, radius)) + + return positions + + + + + + + + + + + + + + + + +# import math + +# def create_clusters(G, root): +# clusters = {} +# visited = set() + +# def dfs(node): +# visited.add(node) +# clusters[node] = [] +# for child in G.neighbors(node): +# if child not in visited: +# clusters[node].append(child) +# dfs(child) + +# dfs(root) +# return clusters + +# def cluster_layout(G, root): +# clusters = create_clusters(G, root) +# positions = {root: (0, 0)} + +# def count_nodes(node): +# return 1 + sum(count_nodes(child) for child in clusters[node]) + +# node_sizes = {node: count_nodes(node) for node in clusters} + +# def layout_clusters(node, radius, start_angle, end_angle): +# children = clusters[node] +# if not children: +# return +# total_size = sum(node_sizes[child] for child in children) +# angle_step = (end_angle - start_angle) / max(total_size, 1) +# angle = start_angle +# for child in children: +# child_size = node_sizes[child] +# mid_angle = angle + angle_step * child_size / 2 +# x = radius * math.cos(mid_angle) +# y = radius * math.sin(mid_angle) +# positions[child] = (x, y) +# layout_clusters(child, radius + 1, angle, angle + angle_step * child_size) +# angle += angle_step * child_size + +# layout_clusters(root, 1, 0, 2 * math.pi) +# return positions diff --git a/src/codecarto/local/src/plotter/custom_layouts/sorted_square_layout.py b/src/codecarto/local/src/plotter/custom_layouts/sorted_square_layout.py new file mode 100644 index 0000000..ebeda7a --- /dev/null +++ b/src/codecarto/local/src/plotter/custom_layouts/sorted_square_layout.py @@ -0,0 +1,32 @@ +import networkx as nx + +def sorted_square_layout(G: nx.Graph): + """Position nodes in a grid. + + Parameters + ---------- + G : NetworkX graph or list of nodes + A position will be assigned to every node in G. + + Returns + ------- + pos : dict + A dictionary of positions keyed by node + """ + import math + import numpy as np + + num_nodes = len(G.nodes()) + sqrt_num_nodes = math.sqrt(num_nodes) + grid_size = math.ceil(sqrt_num_nodes) + + # Sort nodes by 'type' attribute + sorted_nodes = sorted(G.nodes(data=True), key=lambda x: x[1]["type"]) + + # Create a grid of positions + positions = np.array([(x, y) for x in range(grid_size) for y in range(grid_size)]) + + # Create a mapping from node to position + pos = {node: pos for (node, _attr), pos in zip(sorted_nodes, positions)} + + return pos \ No newline at end of file diff --git a/src/codecarto/local/src/plotter/default_palette.json b/src/codecarto/local/src/plotter/default_palette.json new file mode 100644 index 0000000..090dd94 --- /dev/null +++ b/src/codecarto/local/src/plotter/default_palette.json @@ -0,0 +1,259 @@ +{ + "bases": { + "Unknown": "unknown", + "Async": "async", + "AsyncFor": "async.for", + "AsyncWith": "async.with", + "AsyncFunctionDef": "async.function", + "Control": "control", + "Conditional": "control.cond", + "Break": "control.break", + "Continue": "control.continue", + "ExceptHandler": "control", + "For": "control.loop.for", + "If": "control.cond.if", + "Try": "control.try", + "While": "control.loop.while", + "With": "control.loop.with", + "Definitions": "def", + "ClassDef": "def.class", + "FunctionDef": "def.function", + "Global": "def.global", + "Nonlocal": "def.nonlocal", + "Return": "def", + "Yield": "def", + "YieldFrom": "def", + "Argument": "deprecated", + "Body": "deprecated", + "Bytes": "deprecated", + "Ellipsis": "deprecated", + "ExtSlice": "deprecated", + "Float": "deprecated", + "Index": "deprecated", + "Int": "deprecated", + "Loop": "deprecated", + "Method": "deprecated", + "NameConstant": "deprecated", + "Num": "deprecated", + "Str": "deprecated", + "Expressions": "expr", + "Attribute": "expr", + "BinOp": "expr", + "BoolOp": "expr", + "Call": "expr", + "Compare": "expr", + "Expr": "expr", + "IfExp": "expr", + "UnaryOp": "expr", + "Slice": "expr.subscript", + "Subscript": "expr.subscript", + "DictComp": "expr.comp", + "GeneratorExp": "expr.comp", + "ListComp": "expr.comp", + "SetComp": "expr.comp", + "Literals": "literals", + "Constant": "literals.constant", + "Dict": "literals.dict", + "List": "literals", + "Set": "literals", + "Tuple": "literals", + "Match": "match", + "MatchAs": "match", + "MatchClass": "match", + "MatchMap": "match", + "MatchOr": "match", + "MatchSequence": "match", + "MatchSingleton": "match", + "MatchStar": "match", + "MatchValue": "match", + "Module": "module", + "FunctionType": "module", + "Interactive": "module", + "Statements": "statements", + "AnnAssign": "statements", + "Assert": "statements", + "Assign": "statements", + "Delete": "statements", + "Pass": "statements", + "Raise": "statements", + "Import": "statements.import", + "ImportFrom": "statements.importfrom", + "Variable": "variables", + "Name": "variables" + }, + "labels": { + "unknown": "u", + "async": "@", + "async.for": "@for", + "async.with": "@wi", + "async.function": "@Fn", + "control": "c", + "control.cond": "cc", + "control.cond.loop": "ccl", + "control.break": "brk", + "control.continue": "cont", + "control.loop.for": "for", + "control.cond.if": "if", + "control.try": "try", + "control.loop.while": "wh", + "control.loop.with": "wi", + "def": "d", + "def.class": "Cl", + "def.function": "Fn", + "def.global": "gl", + "def.nonlocal": "nl", + "deprecated": "x", + "expr": "e", + "expr.subscript": "sbscpt", + "expr.comp": "comp", + "literals": "l", + "literals.constant": "const", + "literals.dict": "dict", + "match": "mat", + "module": "Mod", + "statements": "s", + "statements.import": "I", + "statements.importfrom": "IF", + "variables": "var" + }, + "alphas": { + "unknown": 0.3, + "async": 0.3, + "async.for": 0.3, + "async.with": 0.3, + "async.function": 0.3, + "control": 0.3, + "control.cond": 0.3, + "control.cond.loop": 0.3, + "control.break": 0.3, + "control.continue": 0.3, + "control.loop.for": 0.3, + "control.cond.if": 0.3, + "control.try": 0.3, + "control.loop.while": 0.3, + "control.loop.with": 0.3, + "def": 0.3, + "def.class": 0.3, + "def.function": 0.3, + "def.global": 0.3, + "def.nonlocal": 0.3, + "deprecated": 0.3, + "expr": 0.5, + "expr.subscript": 0.5, + "expr.comp": 0.5, + "literals": 0.3, + "literals.constant": 0.3, + "literals.dict": 0.3, + "match": 0.3, + "module": 0.5, + "statements": 0.3, + "statements.import": 0.3, + "statements.importfrom": 0.3, + "variables": 0.5 + }, + "sizes": { + "unknown": 400, + "async": 400, + "async.for": 400, + "async.with": 400, + "async.function": 800, + "control": 400, + "control.cond": 400, + "control.cond.loop": 400, + "control.break": 400, + "control.continue": 400, + "control.loop.for": 400, + "control.cond.if": 400, + "control.try": 400, + "control.loop.while": 400, + "control.loop.with": 400, + "def": 400, + "def.class": 1000, + "def.function": 800, + "def.global": 600, + "def.nonlocal": 400, + "deprecated": 400, + "expr": 400, + "expr.subscript": 400, + "expr.comp": 400, + "literals": 400, + "literals.constant": 600, + "literals.dict": 400, + "match": 400, + "module": 1000, + "statements": 400, + "statements.import": 800, + "statements.importfrom": 800, + "variables": 400 + }, + "shapes": { + "unknown": "o", + "async": "^", + "async.for": "^", + "async.with": "^", + "async.function": "^", + "control": "s", + "control.cond": "s", + "control.cond.loop": "s", + "control.break": "s", + "control.continue": "s", + "control.loop.for": "s", + "control.cond.if": "s", + "control.try": "s", + "control.loop.while": "s", + "control.loop.with": "s", + "def": "H", + "def.class": "H", + "def.function": "H", + "def.global": "H", + "def.nonlocal": "H", + "deprecated": "x", + "expr": "<", + "expr.subscript": "<", + "expr.comp": "<", + "literals": ">", + "literals.constant": ">", + "literals.dict": ">", + "match": "D", + "module": "s", + "statements": "d", + "statements.import": "d", + "statements.importfrom": "d", + "variables": "o" + }, + "colors": { + "unknown": "gray", + "async": "pink", + "async.for": "pink", + "async.with": "pink", + "async.function": "pink", + "control": "orange", + "control.cond": "green", + "control.cond.loop": "maroon", + "control.break": "violet", + "control.continue": "violet", + "control.loop.for": "maroon", + "control.cond.if": "green", + "control.try": "green", + "control.loop.while": "maroon", + "control.loop.with": "maroon", + "def": "purple", + "def.class": "purple", + "def.function": "purple", + "def.global": "purple", + "def.nonlocal": "purple", + "deprecated": "red", + "expr": "khaki", + "expr.subscript": "khaki", + "expr.comp": "khaki", + "literals": "gold", + "literals.constant": "gold", + "literals.dict": "gold", + "match": "salmon", + "module": "red", + "statements": "blue", + "statements.import": "lightblue", + "statements.importfrom": "darkblue", + "variables": "skyblue" + } +} diff --git a/src/codecarto/local/src/plotter/palette.py b/src/codecarto/local/src/plotter/palette.py new file mode 100644 index 0000000..812c85e --- /dev/null +++ b/src/codecarto/local/src/plotter/palette.py @@ -0,0 +1,339 @@ +import os +import shutil +from pydantic import BaseModel +from ..utils.utils import ( + get_date_time_file_format, + save_json, + load_json, +) +from ..plotter.palette_dir import PALETTE_DIRECTORY + + +class Theme(BaseModel): + node_type: str + base: str + label: str + shape: str + color: str + size: str + alpha: str + + +class Palette: + """A class to manage the graph plot themes.""" + + def __init__(self): + """Initialize a palette.""" + self._palette_default_path = PALETTE_DIRECTORY["default"] + self._palette_user_path = PALETTE_DIRECTORY["user"] + self._alphas = [round(0.1 * i, ndigits=1) for i in range(11)] + self._sizes = [(100 * i) for i in range(1, 11)] + self._theme = { + "bases": {}, + "labels": {}, + "shapes": {}, + "colors": {}, + "sizes": {}, + "alphas": {}, + } + self.load_palette() + + def save_palette(self, default: bool = False): + """Save the current palette to the palette json file. + + Args: + default (bool, optional): Whether to save the palette to the default palette file. Defaults to False. + """ + # create dictionary with current palette data + palette_data = { + "bases": self.bases, + "labels": self.labels, + "shapes": self.shapes, + "colors": self.colors, + "sizes": self.sizes, + "alphas": self.alphas, + } + # write palette data to file + if default: + save_json(self._palette_default_path, palette_data) + else: + save_json(self._palette_user_path, palette_data) + + def load_palette(self, default: bool = False): + """Load the palette from the palette json file. + + Args: + default (bool, optional): Whether to load the palette from the default palette file. Defaults to False. + """ + # load palette data from file + palette_data: dict = {} + try: + if default: + palette_data = load_json(self._palette_default_path) + else: + palette_data = load_json(self._palette_user_path) + # check if palette data is none + if palette_data is None: + # load the default palette + palette_data = load_json(self._palette_default_path) + if palette_data is None: + raise ValueError("No palette data found. Package may be corrupted.") + # check if palette data was loaded + if len(palette_data.keys()) > 0: + # load palette data + self.bases: dict = palette_data["bases"] + self.labels: dict = palette_data["labels"] + self.shapes: dict = palette_data["shapes"] + self.colors: dict = palette_data["colors"] + self.sizes: dict = palette_data["sizes"] + self.alphas: dict = palette_data["alphas"] + self.types: list = list(self.bases.keys()) + except FileNotFoundError: + raise ValueError("No palette data found. Package may be corrupted.") + + def reset_palette(self, ask_user: bool = False): + """Reset the palette to the default palette.""" + if ask_user: + # check if user wants to overwrite existing palette + overwrite = input( + f"\nAre you sure you want to reset the palette to the default palette?\nOverwrite? (y/n) : " + ) + if overwrite.lower() == "n": + print("Exiting ... \n") # blank line + return + # load the default palette + palette_data = load_json(self._palette_default_path) + # check if palette data was loaded + if len(palette_data.keys()) > 0: + # overwrite user's palette with the appdata palette + shutil.copy(self._palette_default_path, self._palette_user_path) + else: + raise ValueError("No default palette data found. Package may be corrupted.") + if ask_user: + print(f"Palette reset to default.\n") + + def set_palette(self, file_path: str, ask_user: bool = False): + """Set the palette to the specified palette file. + + Parameters: + ----------- + file_path : str + The path to the palette file to set as the current palette. + """ + # check if user wants to overwrite existing palette + if ask_user: + overwrite = input( + f"\nAre you sure you want to set the palette to the specified palette file? (y/n) : " + ) + if overwrite.lower() == "n": + print("Exiting ... \n") # blank line + return + + # check if the new config path exists + if not os.path.exists(file_path): + raise Exception(f"Provided path does not exist: {file_path}") + # check if the new config path is a file + if not os.path.isfile(file_path): + raise Exception(f"Provided path is not a file: {file_path}") + # check if the new config path is a JSON file + if not file_path.endswith(".json"): + raise Exception(f"Provided path is not a JSON file: {file_path}") + + # set the pallette path property in the config file + from ..config.config_process import CONFIG_DIRECTORY + + config_path = CONFIG_DIRECTORY["user"] + config_data = load_json(config_path) + config_data["palette_path"] = file_path + save_json(config_path, config_data) + + # load palette file + self.load_palette() + print(f"Now using palette file '{file_path}'\n") + + def import_palette(self, import_path: str, ask_user: bool = False): + """Import a palette file from the specified file path. + + Parameters: + ----------- + import_path : str + The path to the palette file to import. + """ + # check if the new config path exists + if not os.path.exists(import_path): + raise Exception(f"Provided path does not exist: {import_path}") + # check if the new config path is a file + if not os.path.isfile(import_path): + raise Exception(f"Provided path is not a file: {import_path}") + # check if the new config path is a JSON file + if not import_path.endswith(".json"): + raise Exception(f"Provided path is not a JSON file: {import_path}") + + # check if user wants to overwrite existing palette + if ask_user: + overwrite = input( + f"\nAre you sure you want to import a palette file? This will overwrite the current palette.\nOverwrite? (y/n) : " + ) + if overwrite.lower() == "n": + print("Exiting ... \n") # blank line + return + + # overwrite palette file in appdata directory + shutil.copy(import_path, self._palette_user_path) + # load palette file + self.load_palette() + + if ask_user: + print(f"Palette imported from '{import_path}'\n") + + def export_palette(self, export_path: str): + """Export the current palette file to the specified directory. + + Parameters: + ----------- + export_path : str + The path to the directory to which to export the palette file. + + Returns: + -------- + str + The path to the exported palette file path. + """ + # check if directory exists + if not os.path.exists(export_path): + raise FileNotFoundError(f"Directory not found: {export_path}") + # check if directory is a directory + if not os.path.isdir(export_path): + raise TypeError(f"Path must be a directory: {export_path}") + # check if palette file exists and is not empty + palette_file = self._palette_user_path + if not os.path.exists(palette_file) or os.path.getsize(palette_file) == 0: + palette_file = self._palette_user_path + if not os.path.exists(palette_file) or os.path.getsize(palette_file) == 0: + raise ValueError("No palette file found. Package may be corrupted.") + # create export file + export_date = get_date_time_file_format() + export_name = str(os.path.basename(palette_file)).split(".")[0] + export_name = f"{export_name}_{export_date}.json" + export_file = os.path.join(export_path, export_name) + shutil.copy(palette_file, export_file) + # check if export file exists + if not os.path.exists(export_file): + raise FileNotFoundError(f"Export failed.") + # return export file path + return export_file + + def create_new_theme( + self, + node_type: str, + base: str, + label: str, + shape: str, + color: str, + size: float, + alpha: float, + ask_user: bool = False, + ) -> dict: + """Create a new theme with the specified parameters. + + Parameters: + ----------- + node_type : str + The type of node for which to create a new theme. + label : str + The label of the nodes in the new theme. + shape : str + The shape of the nodes in the new theme. + color : str + The color of the nodes in the new theme. + size : float + The size of the nodes in the new theme. + alpha : float + The alpha (transparency) value of the nodes in the new theme. + + Returns: + -------- + dict + Dictionary of the new theme. Keys are the values are the theme parameters. + """ + if ask_user: + # check if node type already exists + if node_type in self.bases.keys(): + # ask user if they want to overwrite + node = self.get_node_styles(node_type) + overwrite = input( + f"\n{node_type} already exists in '{self._palette_user_path}' with parameters: \n {node} \n\nOverwrite? Y/N " + ) + if overwrite.upper() == "N": + print(f"\nNew theme not created.\n") + return None + # create new node type + self.bases[node_type] = base + self.labels[base] = label + self.shapes[base] = shape + self.colors[base] = color + self.sizes[base] = size + self.alphas[base] = alpha + + # save themes to palette file + self.save_palette() + if ask_user: + print(f"\nNew theme added to palette: {self._palette_user_path}") + print( + f"New theme '{node_type}' created with parameters: base={base}, label={label}, shape={shape}, color={color}, size={size}, alpha={alpha}\n" + ) + return self.get_node_styles(node_type) + + def get_node_styles(self, type: str = None) -> dict: + """Get the styles for all node types. + + Parameters: + ----------- + type : str (optional) (default=None) + If specified, only the style for the specified node type will be returned. + + Returns: + -------- + dict[node_type(str), styles(dict)] + A dictionary containing the styles for all node types. + """ + if type: + return { + type: { + "base": self.bases[type], + "label": self.labels[self.bases[type]], + "shape": self.shapes[self.bases[type]], + "color": self.colors[self.bases[type]], + "size": self.sizes[self.bases[type]], + "alpha": self.alphas[self.bases[type]], + } + } + else: + styles = {} + for type in self.bases.keys(): + styles[type] = { + "base": self.bases[type], + "label": self.labels[self.bases[type]], + "shape": self.shapes[self.bases[type]], + "color": self.colors[self.bases[type]], + "size": self.sizes[self.bases[type]], + "alpha": self.alphas[self.bases[type]], + } + return styles + + def get_palette_data(self) -> dict[str, dict]: + """Get the data of the current palette. + + Returns: + -------- + dict + A dictionary containing the data of the current palette. + """ + return { + "bases": self.bases, + "labels": self.labels, + "shapes": self.shapes, + "colors": self.colors, + "sizes": self.sizes, + "alphas": self.alphas, + } diff --git a/src/codecarto/local/src/plotter/palette_dir.py b/src/codecarto/local/src/plotter/palette_dir.py new file mode 100644 index 0000000..657833a --- /dev/null +++ b/src/codecarto/local/src/plotter/palette_dir.py @@ -0,0 +1,109 @@ +import os +import shutil + +NEW_PALETTE_FILENAME = "palette.json" +DEFAULT_PALETTE_FILENAME = "default_palette.json" + +# the package default palette will be in src/plotter/default_palette.json +# but when packaged, palette.json will be in appdata/CodeCartographer/palette.json +# when users edit their own palette, they will be edited on the appdata/Roaming/CodeCartographer/palette.json file +# when the package loads the palette, it checks if appdata/Roaming/CodeCartographer/palette.json exists +# if it does, it will load file from there +# if it doesn't, it will copy/load default palette file to +# appdata/Roaming/CodeCartographer/palette.json + +# TODO: figure out how to update palette file +# when package is updated, the default palette file will be updated +# but the user's palette file will not be overwritten +# the user will be asked if they want to attempt to merge the default palette file with their palette file +# if they say yes, we can make a function to attempt to merge the default palette file with the user's palette file +# Update function will be interesting if the user is updating from a really old version +# Do we make a merge file that keeps track of the changes made to the default palette file? +# Then we get the version of user's package and then canonically go through update functions +# to update the user's palette file to the latest version? Seems like it could be a big file +# Maybe it would be better to go through user's palette and change names and add new palette as needed +# if they say no, notify them that they'll have to manually update their palette file + + +def get_palette_dir(default: bool = False) -> str: + """Get the palette directory. + + Parameters: + ----------- + default: bool (default: False) + If True, return the package's default palette dir. + + Returns: + -------- + str: + The palette directory. + """ + from ..config.config_process import get_config_data + + if default: + return os.path.dirname(get_config_data(True)["default_palette_path"]) + else: + return os.path.dirname(get_config_data()["palette_path"]) + + +def get_palette_name(default: bool = False) -> str: + """Get the name of the palette file. + + Parameters: + ----------- + default: bool (default: False) + If True, return the default palette file name. + + Returns: + -------- + str + The name of the palette file. + """ + from ..config.config_process import get_config_data + + if default: + file_name: str = get_config_data(True)["default_palette_path"].split("/")[-1] + return file_name + else: + file_name: str = get_config_data()["palette_path"].split("/")[-1] + return file_name + + +def get_palette_path(default: bool = False) -> str: + """Get the path to the palette file. + + Parameters: + ----------- + default: bool (default: False) + If True, return the path to the default palette file. + + Returns: + -------- + str + The path to the palette file. + """ + palette_path: str = os.path.join( + get_palette_dir(default), get_palette_name(default) + ) + # Check if the user's palette file exists + if not default and not os.path.exists(palette_path): + # copy the package's default palette file to the palette path + shutil.copy( + os.path.join(get_palette_dir(True), get_palette_name(True)), palette_path + ) + + return palette_path + +def get_package_palette_path() -> str: + """Get the path to the package's default palette file. + + Returns: + -------- + str + The path to the package's default palette file. + """ + from ..config.directory.package_dir import get_package_dir + + return os.path.join(get_package_dir(), "plotter", DEFAULT_PALETTE_FILENAME) + +PALETTE_DIRECTORY = {"default": get_package_palette_path(), "user": get_palette_path()} diff --git a/src/codecarto/local/src/plotter/plotter.py b/src/codecarto/local/src/plotter/plotter.py new file mode 100644 index 0000000..c1e1409 --- /dev/null +++ b/src/codecarto/local/src/plotter/plotter.py @@ -0,0 +1,692 @@ +import os +import math +import matplotlib.lines as mlines +import matplotlib.pyplot as plt +import networkx as nx +import random +import inspect + + +class Plotter: + def __init__( + self, + dirs: dict[str, str] = None, + file_path: str = "", + labels: bool = False, + grid: bool = False, + json: bool = False, + show_plot: bool = False, + single_file: bool = False, + ntx_layouts: bool = True, + custom_layouts: bool = True, + ): + """Plots a graph using matplotlib and outputs the plots to the output directory. + + Parameters: + ----------- + dirs (dict[str, str]) Default = None: + The directories to use. + file_path (str) Default = "": + The path to the file to plot. + labels (bool) Default = False: + Whether or not to show the labels. + grid (bool) Default = False: + Whether or not to plot all layouts in a grid. + json (bool) Default = False: + Whether or not to return the json data. + show_plot (bool) Default = False: + Whether or not to show the plots. + single_file (bool) Default = False: + Whether or not to plot all layouts in a single file. + ntx_layouts (bool) Default = True: + Whether or not to include networkx layouts. + custom_layouts (bool) Default = True: + Whether or not to include custom layouts. + """ + from .positions import Positions + + self.api: bool = False + self.seed: dict[str, int] = {} + self.dirs: dict[str, str] = dirs + self.file_path: str = file_path + self.labels: bool = labels + self.grid: bool = grid + self.json: bool = json + self.show_plot: bool = show_plot + self.single_file: bool = single_file + self.ntx_layouts: bool = ntx_layouts + self.custom_layouts: bool = custom_layouts + self.layouts: tuple(str, function, list) = Positions( + self.ntx_layouts, custom_layouts + ).get_layouts() + + def set_plotter_attrs( + self, + dirs: dict[str, str] = None, + file_path: str = "", + labels: bool = False, + grid: bool = False, + json: bool = False, + show_plot: bool = False, + single_file: bool = False, + ntx_layouts: bool = True, + custom_layouts: bool = True, + ): + """Sets the plotter attributes. + + Parameters: + ----------- + dirs (dict): + The directories to use for the plotter. + file_path (str): + The file path to use for the plotter. + labels (bool): + Whether or not to show the labels. + grid (bool): + Whether or not to show the grid. + json (bool): + Whether or not to save the json file. + show_plot (bool): + Whether or not to show the plot. + single_file (bool): + Whether or not to save the plot to a single file. + ntx_layouts (bool): + Whether or not to save the plot to a networkx file. + custom_layouts (bool): + Whether or not to save the plot to a custom file. + """ + self.dirs = dirs if dirs is not None else self.dirs + self.file_path = file_path if file_path is not None else self.file_path + self.labels = labels + self.grid = grid + self.json = json + self.show_plot = show_plot + self.single_file = single_file + self.ntx_layouts = ntx_layouts + self.custom_layouts = custom_layouts + + def plot(self, _graph: nx.DiGraph, specific_layout: str = ""): + """Plots a graph using matplotlib. + + Parameters: + ----------- + _graph (networkx.classes.graph.Graph): + The graph to plot. + _layout (str) Default = "": + The layout to use. + """ + # Check if graph provided + if not _graph or not isinstance(_graph, nx.Graph): + raise ValueError("No graph provided.") + # Plot based on args + if specific_layout != "": + print(f"Plotting {specific_layout} layout...") + self.plot_layout(_graph, specific_layout, self.json) + elif self.grid: + self.plot_all_in_grid(_graph, self.json) + else: + self.plot_all_separate(_graph, self.json) + + def plot_layout(self, _graph: nx.DiGraph, _layout: str, _json: bool = False): + pass + + def plotting_progress(self): + pass + + def plot_all_separate(self, _graph, _json: bool = False): + """Plots a graph using matplotlib. + + Parameters: + ----------- + _graph (networkx.classes.graph.Graph): + The graph to plot. + _json (bool) Default = True: + Whether the graph is in JSON format. + """ + # check if graph + if _graph and isinstance(_graph, nx.classes.graph.Graph): + from ..plotter.palette import Palette + from ..cli.progressbar import ProgressBar + + # check if json + if _json == False: + graph_dir = self.dirs["graph_code_dir"] + else: + graph_dir = self.dirs["graph_json_dir"] + + # Create the overall progress bar + overall_total: int = ( + len(self.layouts.items()) + 1 + ) # plus 1 for when the overall bar finishes + progress_overall: ProgressBar = ProgressBar( + overall_total, " Plotting:", "Complete", extra_msg="Plotting..." + ) + overall_line = ( + progress_overall.get_current_cursor_position()[1] + ) - 1 # move back one line + line_number: int = ( + overall_line + 2 + ) # plus 2 for where we want the children to start + progress_overall.has_children = True + progress_overall.current_line = ( + overall_line + 1 + ) # plus 1 to combat the -1 in first call of increment() + max_layout_name_length: int = max( + [len(layout_name) for layout_name in self.layouts.keys()] + ) + # TODO: after creating sub progress bars set the line number - number of layouts for overall progress bar + # TODO: May need to print blank lines equal to number of layouts to get the cursor to the correct position + # TODO: Create the sub progress bars for each layout in dict, set line number for each layout + + # Loop through all layouts + for layout_name, layout_info in self.layouts.items(): + # Unpack layout info + layout, layout_params = layout_info + progress_overall.increment() + + # Calculate ProgressBar total + node_styles = Palette().get_node_styles() + node_data: dict[str, list] = { + node_type: [] for node_type in node_styles.keys() + } + len_node_data: int = len(node_data.items()) + num_of_nodes: int = _graph.number_of_nodes() + unique_node_types = set() + for _, node_type in _graph.nodes(data="type"): + if node_type is not None: + unique_node_types.add(node_type) + len_unigue_node_types: int = len(unique_node_types) + len_layout_param: int = len(layout_params) + param_len: int = 0 + for param in layout_params: + if param == "seed": + param_len += 1 + elif param == "nshells" and layout_name == "shell_layout": + param_len += num_of_nodes + 1 + elif param == "root" and layout_name == "cluster_layout": + param_len += num_of_nodes + elif param != "G": + param_len += 1 + progress_total: int = 8 # the number of .increment()s outside of loops + progress_total += ( + num_of_nodes + + len_layout_param + + param_len + + len_node_data + + len_unigue_node_types + ) # various lengths of expected loops + + # Create progress bar extra messages + extra_msg: dict[str, str] = { + "Start": "Starting Plot", + "Init": "Initializing Plot Figure", + "Nodes": "Collecting Graph Nodes", + "Layout": "Getting Layout Params", + "Position": "Computing Node Positions", + "GError": "Error: G is not planar", + "Loop": "Looping Node Shapes", + "Shapes": "Drawing Node Shapes", + "Edges": "Drawing Edges and Labels", + "Legend": "Drawing Legend", + "Filename": "Making Filename", + "Save": "Saving Plot File", + "Final": "Finished", + } + max_extra_msg_len: int = max([len(msg) for msg in extra_msg.values()]) + + # create ProgressBar + progress_plot: ProgressBar = ProgressBar( + progress_total, + f" {layout_name:{max_layout_name_length}}:", + "Complete", + line_number=line_number, + ) + progress_plot.increment( + extra_msg=f"{extra_msg['Start']:<{max_extra_msg_len}}" + ) + + # Initialize figure, axes (w, h), title, and position on monitor + fig, ax = plt.subplots(figsize=(15, 15)) + fig.canvas.manager.window.wm_geometry("+0+0") + _title: str = ( + f"{str(layout_name).replace('_layout', '').capitalize()} Layout" + ) + if self.single_file: + _file_name: str = os.path.basename(self.file_path) + _title = f"{_title} for '{_file_name}'" + ax.set_title(_title) + ax.axis("off") + progress_plot.increment( + extra_msg=f"{extra_msg['Init']:<{max_extra_msg_len}}" + ) + + # Collect nodes and their attributes + for n, a in _graph.nodes(data=True): + node_type = a.get("type", "Unknown") + if node_type not in node_styles.keys(): + node_type = "Unknown" + node_data[node_type].append(n) + progress_plot.increment( + extra_msg=f"{extra_msg['Nodes']:<{max_extra_msg_len}}" + ) + + # Get layout parameters + seed = -1 + layout_kwargs = {"G": _graph} + for param in layout_params: + if param == "seed": + if _json: + # Use the same seed for the same layout + seed = self.seed[layout_name] + else: + seed = random.randint(0, 1000) + self.seed[layout_name] = seed + layout_kwargs["seed"] = seed + progress_plot.increment( + extra_msg=f"{extra_msg['Layout']:<{max_extra_msg_len}}" + ) + elif param == "nshells" and layout_name == "shell_layout": + # Group nodes by parent + grouped_nodes: dict[str, list] = {} + for node, data in _graph.nodes(data=True): + parent = data.get("parent", "Unknown") + if parent not in grouped_nodes: + grouped_nodes[parent] = [] + grouped_nodes[parent].append(node) + progress_plot.increment( + extra_msg=f"{extra_msg['Layout']:<{max_extra_msg_len}}" + ) + # Create the list of lists (shells) + shells = list(grouped_nodes.values()) + layout_kwargs["nshells"] = shells + progress_plot.increment( + extra_msg=f"{extra_msg['Layout']:<{max_extra_msg_len}}" + ) + elif param == "root" and layout_name == "cluster_layout": + # get the node at the very top + root = None + for node, data in _graph.nodes(data=True): + if data.get("label", "") == "root": + root = node + break + progress_plot.increment( + extra_msg=f"{extra_msg['Layout']:<{max_extra_msg_len}}" + ) + layout_kwargs["root"] = root + progress_plot.increment( + extra_msg=f"{extra_msg['Layout']:<{max_extra_msg_len}}" + ) + elif param != "G": + # TODO: Handle other parameters here + progress_plot.increment( + extra_msg=f"{extra_msg['Layout']:<{max_extra_msg_len}}" + ) + progress_plot.increment( + extra_msg=f"{extra_msg['Layout']:<{max_extra_msg_len}}" + ) + + # Compute layout positions + progress_plot.increment( + extra_msg=f"{extra_msg['Position']:<{max_extra_msg_len}}" + ) + try: + from .positions import Positions + + layout_pos = Positions( + include_networkx=self.ntx_layouts, + include_custom=self.custom_layouts, + ) + pos = layout_pos.get_positions(layout_name, **layout_kwargs) + except Exception as e: + progress_plot.increment( + extra_msg=f"{extra_msg['GError']:<{max_extra_msg_len}}" + ) + print() # needs an extra line + continue + + # Draw nodes with different shapes + progress_plot.increment( + extra_msg=f"{extra_msg['Loop']:<{max_extra_msg_len}}" + ) + for node_type, nodes in node_data.items(): + nx.drawing.draw_networkx_nodes( + _graph, + pos, + nodelist=nodes, + node_color=node_styles[node_type]["color"], + node_shape=node_styles[node_type]["shape"], + node_size=node_styles[node_type]["size"], + alpha=node_styles[node_type]["alpha"], + ) + progress_plot.increment( + extra_msg=f"{extra_msg['Shapes']:<{max_extra_msg_len}}" + ) + + # Draw edges and labels + progress_plot.increment( + extra_msg=f"{extra_msg['Edges']:<{max_extra_msg_len}}" + ) + nx.drawing.draw_networkx_edges(_graph, pos, alpha=0.2) + if self.labels: + nx.drawing.draw_networkx_labels( + _graph, + pos, + labels=nx.classes.get_node_attributes(_graph, "label"), + font_size=10, + font_family="sans-serif", + ) + + # Draw legend + _colors: dict = {} + _shapes: dict = {} + for node_type in unique_node_types: + _colors[node_type] = node_styles[node_type]["color"] + _shapes[node_type] = node_styles[node_type]["shape"] + progress_plot.increment( + extra_msg=f"{extra_msg['Legend']:<{max_extra_msg_len}}" + ) + legend_elements = [ + mlines.Line2D( + [0], + [0], + color=color, + marker=shape, + linestyle="None", + markersize=10, + label=theme, + ) + for theme, color, shape in zip( + _colors, _colors.values(), _shapes.values() + ) + ] + ax.legend(handles=legend_elements, loc="upper right", fontsize=10) + + # Save the file to the interations folder + progress_plot.increment( + extra_msg=f"{extra_msg['Filename']:<{max_extra_msg_len}}" + ) + plot_name = "" + if seed == -1: + if _json: + plot_name = f"JSON_{layout.__name__}.png" + else: + plot_name = f"CODE_{layout.__name__}.png" + else: + if _json: + plot_name = f"JSON_{seed}_{layout.__name__}.png" + else: + plot_name = f"CODE_{seed}_{layout.__name__}.png" + file_path = os.path.join(graph_dir, plot_name) + + progress_plot.increment( + extra_msg=f"{extra_msg['Save']:<{max_extra_msg_len}}" + ) + plt.tight_layout() + plt.savefig(file_path) + + # Show the plot + if self.show_plot: + if self.api: + import mpld3 + + mpld3.show() + else: + plt.show() + # Close the plot + plt.close() + + progress_plot.increment( + extra_msg=f"{extra_msg['Final']:<{max_extra_msg_len}}" + ) + line_number += 1 + # TODO: loop through layout progress bars and decrement the start_line, call their 'increment' method after each loop with 'Final' message + progress_overall.increment(extra_msg="Finished ") + + def plot_all_in_grid(self, _graph, _json: bool = False): + """ + Plots the given graph in a grid of subplots, one subplot for each layout. + """ + + # check if graph + if _graph and isinstance(_graph, nx.classes.graph.Graph): + from .palette import Palette + + # check if json + if _json == False: + graph_dir = self.dirs["graph_code_dir"] + else: + graph_dir = self.dirs["graph_json_dir"] + + print("Plotting grid...") + + # compute grid + num_layouts = len(self.layouts) + grid_size = math.ceil(math.sqrt(num_layouts)) + + # Set the figure size + figsize = (5, 5) + fig, axes = plt.subplots( + grid_size, + grid_size, + figsize=(figsize[0] * grid_size, figsize[1] * grid_size), + ) + fig.set_size_inches(18.5, 9.5) + + # Set the figure position to the top-left corner of the screen plus the margin + mng = plt.get_current_fig_manager() + mng.window.wm_geometry("+0+0") + + # Loop through all layouts + # empty_axes_indices = [] + idx: int = 0 + for layout_name, layout_info in self.layouts.items(): + layout, layout_params = layout_info + + # Set up ax + ax = axes[idx // grid_size, idx % grid_size] if grid_size > 1 else axes + ax.set_title(f"{layout_name}") + ax.axis("off") + + # Collect nodes and their attributes + node_styles = Palette().get_node_styles() + node_data: dict[str, list] = { + node_type: [] for node_type in node_styles.keys() + } + for n, a in _graph.nodes(data=True): + node_type = a.get("type", "Unknown") + if node_type not in node_styles.keys(): + node_type = "Unknown" + node_data[node_type].append(n) + + # Get layout parameters + seed = -1 + layout_kwargs = {"G": _graph} + for param in layout_params: + if param == "seed": + if _json: + # Use the same seed for the same layout + seed = self.seed[layout_name] + else: + seed = random.randint(0, 1000) + self.seed[layout_name] = seed + layout_kwargs["seed"] = seed + elif param == "nshells" and layout_name == "shell_layout": + # Group nodes by parent + grouped_nodes: dict[str, list] = {} + for node, data in _graph.nodes(data=True): + parent = data.get("parent", "Unknown") + if parent not in grouped_nodes: + grouped_nodes[parent] = [] + grouped_nodes[parent].append(node) + + # Create the list of lists (shells) + shells = list(grouped_nodes.values()) + layout_kwargs["nshells"] = shells + elif param != "G": + # TODO: Handle other parameters here + pass + + # Compute Layout + try: + from .positions import Positions + + layout_pos = Positions( + include_networkx=self.ntx_layouts, + include_custom=self.custom_layouts, + ) + pos = layout_pos.get_positions(layout_name, **layout_kwargs) + except Exception as e: + print(f"Skipping {layout_name} due to an error: {e}") + # empty_axes_indices.append(idx) + continue + + # Draw nodes with different shapes + for node_type, nodes in node_data.items(): + nx.drawing.draw_networkx_nodes( + _graph, + pos, + nodelist=nodes, + node_color=node_styles[node_type]["color"], + node_shape=node_styles[node_type]["shape"], + node_size=node_styles[node_type]["size"], + alpha=node_styles[node_type]["alpha"], + ax=ax, + ) + + # Draw edges and labels + nx.drawing.draw_networkx_edges(_graph, pos, alpha=0.2, ax=ax) + if self.labels: + nx.drawing.draw_networkx_labels( + _graph, + pos, + labels=nx.classes.get_node_attributes(_graph, "label"), + font_size=10, + ax=ax, + font_family="sans-serif", + ) + + # Create legend + unique_node_types = set( + node_type + for _, node_type in _graph.nodes(data="type") + if node_type is not None + ) + _colors = { + node_type: node_styles[node_type]["color"] + for node_type in unique_node_types + } + _shapes = { + node_type: node_styles[node_type]["shape"] + for node_type in unique_node_types + } + legend_elements = [ + mlines.Line2D( + [0], + [0], + color=color, + marker=shape, + linestyle="None", + markersize=10, + label=theme, + ) + for theme, color, shape in zip( + _colors, _colors.values(), _shapes.values() + ) + ] + ax.legend(handles=legend_elements, loc="upper right", fontsize=10) + idx += 1 + + # Remove extra subplots if the grid is not fully filled + # TODO: this is not working + # for idx in reversed(empty_axes_indices): + # fig.delaxes(fig.axes[idx]) + + # TODO: attempting to recreate the figure with the correct grid size, but not working out yet + # import copy + # # Remove titles of empty plots + # for ax in fig.axes: + # if ax not in empty_axes_indices: + # ax.set_title("") + + # # Get new grid size + # new_grid_size = (math.ceil(num_layouts / math.floor(math.sqrt(num_layouts - len(empty_axes_indices)))), math.floor(math.sqrt(num_layouts - len(empty_axes_indices)))) + + # # Get new figure size + # new_figsize = (new_grid_size[0] * 5, new_grid_size[1] * 5) + + # # Create a new figure and axes with the correct size and shape + # new_fig, new_axes = plt.subplots(new_grid_size[0], new_grid_size[1], figsize=(new_figsize[0], new_figsize[1])) + + # # Loop through the non-empty axes of the old figure and recreate the contents of each non-empty axis in the corresponding axis in the new figure + # old_ax_index = 0 + # for new_ax_index in range(new_grid_size[0] * new_grid_size[1]): + # if new_ax_index in empty_axes_indices: + # # This axis is empty, skip it + # continue + + # # Copy the contents of the old axis to the new axis + # old_ax = fig.axes[old_ax_index] + # old_ax_title = old_ax.get_title() + # old_ax_lines = old_ax.lines + # old_ax_patches = old_ax.patches + # old_ax_images = old_ax.images + # old_ax_collections = old_ax.collections + # old_ax_tables = old_ax.tables + # old_ax_legends = old_ax.legend() + # old_ax_texts = old_ax.texts + + # new_ax = new_axes.flatten()[new_ax_index] + # new_ax.set_title(old_ax_title) + # for line in old_ax_lines: + # new_ax.add_line(line) + # for patch in old_ax_patches: + # new_patch = type(patch)(**patch.properties()) + # new_patch.set_transform(new_ax.transData) + # new_ax.add_patch(new_patch) + # for image in old_ax_images: + # new_ax.add_image(image) + # for collection in old_ax_collections: + # new_ax.add_collection(collection) + # for table in old_ax_tables: + # new_ax.add_table(table) + # for legend in old_ax_legends: + # new_ax.add_artist(legend) + # for text in old_ax_texts: + # new_ax.add_artist(text) + + # # Move to the next non-empty axis in the old figure + # old_ax_index += 1 + + if not self.api: + # Save the file to the interations folder + if _json: + file_path = os.path.join(graph_dir, "JSON_grid.png") + else: + file_path = os.path.join(graph_dir, "CODE_grid.png") + + plt.savefig(file_path) + + # Show plot + if self.show_plot: + plt.show() + plt.close() + + +########## OPTIONAL ########## +# in the plot() function after creating pos, can use this to center the main and plot nodes + +# Center the main and plot nodes +# import numpy as np +# x_center = ( +# max(x for x, _ in pos.values()) + min(x for x, _ in pos.values()) +# ) / 2 +# y_center = ( +# max(y for _, y in pos.values()) + min(y for _, y in pos.values()) +# ) / 2 + +# if "main" in pos: +# print("main") +# pos["main"] = np.array([x_center, y_center + 0.2]) +# if "plot" in pos: +# print("plot") +# pos["plot"] = np.array([x_center, y_center]) diff --git a/src/codecarto/local/src/plotter/positions.py b/src/codecarto/local/src/plotter/positions.py new file mode 100644 index 0000000..8408ff7 --- /dev/null +++ b/src/codecarto/local/src/plotter/positions.py @@ -0,0 +1,134 @@ +import networkx as nx +from typing import Callable + + +class Positions: + def __init__(self, include_networkx: bool = True, include_custom: bool = True): + """Constructor for Layouts + + Parameters + ---------- + layouts : tuple(str,function,list) + A tuple of layout_names, the layout_function, and their attributes + """ + self._layouts: tuple(str, function, list) = {} + if include_networkx: + self.add_networkx_layouts() + if include_custom: + self.add_custom_layouts() + + def add_layout(self, name: str, layout: Callable, attr: list): + """Add a layout to the list of available layouts + + Parameters + ---------- + name : str + The name of the layout + layout : function + The layout function + attr : list + The attributes of the layout + """ + self._layouts[name] = (layout, attr) + + def add_networkx_layouts(self): + """Add all networkx layouts to the list of available layouts""" + # self.add_layout( + # "spring_layout", + # nx.layout.spring_layout, + # ["graph", "seed"], + # ) + self.add_layout("spiral_layout", nx.layout.spiral_layout, ["graph"]) + self.add_layout("circular_layout", nx.layout.circular_layout, ["graph"]) + self.add_layout("random_layout", nx.layout.random_layout, ["graph", "seed"]) + # self.add_layout("shell_layout", nx.layout.shell_layout, ["graph", "nshells"]) + self.add_layout("spectral_layout", nx.layout.spectral_layout, ["graph"]) + # self.add_layout("planar_layout", nx.layout.planar_layout, ["graph"]) + + def add_custom_layouts(self): + """Add all custom layouts to the list of available layouts""" + from .custom_layouts.sorted_square_layout import sorted_square_layout + + # from .custom_layouts.cluster_layout import cluster_layout + + self.add_layout("sorted_square_layout", sorted_square_layout, ["graph"]) + # self.add_layout("cluster_layout", cluster_layout, ["graph", "root"]) + + def get_layout_names(self): + """Get all layout names from the list of available layouts + + Returns + ------- + list + The name of available layouts + """ + return self._layouts.keys() + + def get_layouts(self): + """Get all layouts with their attributes from the list of available layouts + + Returns + ------- + list[tuple(name,function,attributes)] + The layouts with their attributes + """ + return self._layouts + + def get_layout(self, name: str): + """Get a layout from the list of available layouts + + Parameters + ---------- + name : str + The name of the layout + + Returns + ------- + tuple(str,function,list) + The layout with its attributes + """ + return self._layouts[name] + + def get_positions(self, name: str, seed: int = -1, **kwargs): + """Get a positions from the list of available layouts + + Parameters + ---------- + name : str + The name of the layout + seed : int (optional, default=-1) + The seed to use for the layout + **kwargs : dict + The attributes of the layout + + Returns + ------- + dict + The positions of the layout + """ + _graph: nx.Graph = kwargs.get("G", None) + layout, layout_params = self._layouts[name] + layout_kwargs: dict = {} + for param in layout_params: + if param == "seed" and seed != -1: + # Set the seed if it is not -1 + layout_kwargs["seed"] = seed + elif param == "nshells" and name == "shell_layout": + # Group nodes by parent + if "G" not in kwargs: + grouped_nodes: dict[str, list] = {} + for node, data in kwargs["G"].nodes(data=True): + parent = data.get("parent", "Unknown") + if parent not in grouped_nodes: + grouped_nodes[parent] = [] + grouped_nodes[parent].append(node) + # Create the list of lists (shells) + shells = list(grouped_nodes.values()) + layout_kwargs["nshells"] = shells + elif param == "root" and name == "cluster_layout": + # Set the root node + layout_kwargs["root"] = kwargs["root"] + elif param != "G": + # TODO Handle other parameters here + pass + return layout(G=_graph, **layout_kwargs) diff --git a/src/codecarto/local/src/polygraph/__init__.py b/src/codecarto/local/src/polygraph/__init__.py new file mode 100644 index 0000000..aa7b332 --- /dev/null +++ b/src/codecarto/local/src/polygraph/__init__.py @@ -0,0 +1,2 @@ +# Polygraph folder holds the logic for the polygraph module +# as well as logic around conversions and data manipulation. diff --git a/src/codecarto/local/src/polygraph/polygraph.py b/src/codecarto/local/src/polygraph/polygraph.py new file mode 100644 index 0000000..99e9705 --- /dev/null +++ b/src/codecarto/local/src/polygraph/polygraph.py @@ -0,0 +1,194 @@ +import networkx as nx +from ..models.graph_data import GraphData +from ..utils.utils import save_json, load_json + + +class PolyGraph: + """A class used to convert data types to a networkx graph and vice versa.""" + + def graph_to_json_file(self, graph: GraphData, json_path: str) -> str: + """Converts a networkx graph to a JSON object. + + Parameters: + ----------- + graph (GraphData): The graph to convert. + json_path (str): The path to save the JSON file to. + + Returns: + -------- + str: The JSON object. + """ + # Validate inputs + if graph is None: + raise ValueError("No graph provided.") + if json_path is None or json_path == "": + raise ValueError("No json_path provided.") + + # Convert the graph to a JSON object and save it to a file + json_data = self.graph_to_json_data(graph) + return save_json(json_path, json_data) + + def json_file_to_graph(self, json_file: str) -> nx.DiGraph: + """Converts a JSON object to a networkx graph. + + Parameters: + ----------- + json_file (str): The path to the JSON file to load. + + Returns: + -------- + nx.DiGraph: The networkx graph. + """ + # Validate inputs + if json_file is None or json_file == "": + raise ValueError("No json_file provided.") + + # Load the JSON file and convert it to a graph + graph_data = load_json(json_file) + return self.json_data_to_graph(graph_data) + + def graph_to_json_data(self, graph: GraphData) -> dict: + """Converts a networkx graph to a JSON object. + + Parameters: + ----------- + graph (GraphData): The graph to convert. + + Returns: + -------- + dict: The JSON object. + """ + from ..plotter.palette import Palette + + # Validate inputs + if graph is None: + raise ValueError("No graph provided.") + if not isinstance(graph, nx.DiGraph): + try: + graph: nx.DiGraph = self.graphdata_to_nx(graph) + except: + raise ValueError("'graph' must be formatted as a GraphData object.") + + # Create the JSON object + graph_data: dict[str, dict[str, dict[str, list]]] = {"nodes": {}, "edges": {}} + + # Create all node objects + node_styles = Palette().get_node_styles() + for node_id, data in graph.nodes.data(True): + node_type = data.get("type", "Unknown") + if node_type not in node_styles.keys(): + node_type = "Unknown" + + node_obj = { + "id": node_id, + "type": node_type, + "label": data.get("label", node_id), + "base": data.get("base", "unknown"), + "parent": data.get("parent"), + "children": [], + "edges": [], + } + graph_data["nodes"][node_id] = node_obj + + # Link parent and child nodes together + for node_id, node_obj in graph_data["nodes"].items(): + parent_id = node_obj["parent"] + if parent_id and parent_id in graph_data["nodes"]: + graph_data["nodes"][parent_id]["children"].append(node_obj) + + # Create edge objects and link them to their source nodes + for edge_id, (source, target) in enumerate(graph.edges()): + if source not in graph_data["nodes"] or target not in graph_data["nodes"]: + continue + source_node: dict[str, list] = graph_data["nodes"][source] + target_node: dict[str, list] = graph_data["nodes"][target] + + edge_obj = { + "id": edge_id, + "type": "edge", + "source": source_node["id"], + "target": target_node["id"], + } + graph_data["edges"][edge_id] = edge_obj + source_node["edges"].append(edge_obj) + + # # Clean out any graph_data["nodes"] that have parents + # for node_id, node_obj in list(graph_data["nodes"].items()): + # if node_obj["parent"]: + # del graph_data["nodes"][node_id] + + return graph_data + + def json_data_to_graph(self, json_data: dict[str, dict]) -> nx.DiGraph: + """Converts a JSON object to a networkx graph. + + Parameters: + ----------- + json_data (dict): The JSON object to convert. + + Returns: + -------- + networkx.classes.graph.DiGraph: The graph. + """ + + # Validate inputs + if json_data is None: + raise ValueError("No json provided.") + + # Create the graph + graph = nx.DiGraph() + + def add_node_and_children(node_id, node_obj): + # Recursively add children + graph.add_node( + node_id, + type=node_obj["type"], + label=node_obj["label"], + base=node_obj["base"], + parent=node_obj["parent"], + ) + for child_obj in node_obj["children"]: + child_id = child_obj["id"] + add_node_and_children(child_id, child_obj) + + # Add nodes and their children to the graph + for node_id, node_obj in json_data["nodes"].items(): + add_node_and_children(node_id, node_obj) + + # Add edges to the graph + for edge_id, edge_obj in json_data["edges"].items(): + graph.add_edge(edge_obj["source"], edge_obj["target"]) + + return graph + + def graphdata_to_nx(graph_data: GraphData) -> nx.DiGraph: + """Converts a GraphData object to a networkx graph. + + Parameters: + ----------- + graph_data (GraphData): The GraphData object to convert. + + Returns: + -------- + networkx.classes.graph.Graph: The graph. + """ + + # Validate inputs + if graph_data is None: + raise ValueError("No graph provided.") + + # Create the graph + try: + G = nx.DiGraph() + + # Add nodes to the graph + for node_id, node in graph_data.nodes.items(): + G.add_node(node_id, label=node.label, type=node.type, base=node.base) + + # Add edges to the graph + for edge_id, edge in graph_data.edges.items(): + G.add_edge(edge.source, edge.target, id=edge_id, type=edge.type) + + return G + except: + raise ValueError("'graph' must be formatted as a GraphData object.") diff --git a/src/codecarto/local/src/processor.py b/src/codecarto/local/src/processor.py new file mode 100644 index 0000000..167b925 --- /dev/null +++ b/src/codecarto/local/src/processor.py @@ -0,0 +1,213 @@ + +previous_output_dir: str = "" + +def process( + file_path: str = __file__, + from_api: bool = False, + single_file: bool = False, + plot: bool = True, + json: bool = False, + labels: bool = False, + grid: bool = False, + show_plot: bool = False, + output_dir: str = "", +) -> dict | None: + """Parses the source code, creates a graph, creates a plot, creates a json file, and outputs the results. + + Parameters: + ----------- + config (Config) - Default: None + The code cartographer config object. + file_path (str) - Default: __file__ + The path to the file to be analyzed. + from_api (bool) - Default: False + Whether the process is being run from the API. + single_file (bool) - Default: False + Whether to analyze a single file. + plot (bool) - Default: True + Whether to plot the graph. + json (bool) - Default: False + Whether to create a json file of the graph. + labels (bool) - Default: False + Whether to label the nodes of the graph. + grid (bool) - Default: False + Whether to add a grid to the graph. + show_plot (bool) - Default: False + Whether to show the graph. + output_dir (str) - Default: "" + The path to the output directory. + + Returns: + -------- + dict | None + If called from the API dict is json object of the graph.\n + If called locally dict is the paths to the output directory. + 'version': + the runtime version of the process. + 'output_dir': + the path to the output directory. + 'version_dir': + the path to the output/version directory. + 'graph_dir': + the path to the output/graph directory. + 'graph_code_dir': + the path to the output/graph/from_code directory. + 'graph_json_dir': + the path to the output/graph/from_json directory. + 'json_dir': + the path to the output/json directory. + 'json_graph_file_path': + the path to the output/json/graph.json file. + """ + from .parser.parser import Parser + from .parser.import_source_dir import get_all_source_files + from .config.config_process import get_config_data + config_data: dict = get_config_data() + config_path: str = config_data["config_path"] + + #TODO: Should we do a progress bar for all of these?? + # If we do, we'll need to calculate the + + ############# SETUP OUTPUT ############# + if not from_api: + print_status( + f"\nCodeCartographer:\nProcessing File:\n{file_path}", from_api + ) + # if the user provides an output directory, use it instead of the one in the config + # BE SURE TO CHANGE THE OUTPUT DIRECTORY IN THE CONFIG FILE BACK TO THE PREVIOUS ONE + if output_dir and output_dir != "": + previous_output_dir: str = config_data["output_dir"] + config_data["output_dir"] = output_dir + save_json(config_data, config_path) + + ############# PARSE THE CODE ############# + source_files: list[str] = [] + if single_file: + source_files = [file_path] + else: + source_files = get_all_source_files(file_path) + graph = Parser(source_files=source_files).graph + parse_msg: str = f"... {len(source_files)} source files parsed ...\n" + print_status(parse_msg, from_api) + + ############# PROCESS THE GRAPH ############# + return_data:dict = None + if graph and graph.number_of_nodes() > 0: + from .plotter.plotter import Plotter + from .polygraph.polygraph import PolyGraph + from .config.directory.output_dir import create_output_dirs + + # TODO: until we figure out how to return a plot through API, + # we won't do the plotting we'll just return the json file of the graph + pg: PolyGraph = PolyGraph() + if not from_api: + # Create the output directory + paths = create_output_dirs() + + # Plot the graph + if plot: + # Create the graph plotter, needs to be same + # Plotter for both to handle seed correctly + plot: Plotter = Plotter() + + # Set the plotter attributes + plot.set_plotter_attrs( + dirs=paths, + file_path=file_path, + labels=labels, + grid=grid, + json=False, + show_plot=show_plot, + single_file=single_file, + ntx_layouts=True, + custom_layouts=True, + ) + + # Plot the graph made from code + print_process_settings(plot, from_api) + print_status("", from_api) + if grid: + print_status("Plotting all layouts to a grid...", from_api) + else: + print_status("Plotting all layouts in separate files...", from_api) + print_status("Plotting Source Code Graph...\n", from_api) + plot.plot(graph) + print_status("Source Code Plots Saved...\n", from_api) + + # Create a json file of the graph + print_status("Converting Graph to JSON...", from_api) + json_graph_file = paths["json_file_path"] + pg.graph_to_json_file(graph, json_graph_file) + + # Create the json graph + if json: # this is asking if we should convert back from json to graph + print_status("Converting JSON back to Graph...", from_api) + json_graph = pg.json_file_to_graph(json_graph_file) + + # Plot the graph from json file + plot.json = True + if grid: + print_status("Plotting all layouts to a grid...", from_api) + else: + print_status("Plotting all layouts in separate files...", from_api) + print_status("Plotting JSON Graph...\n", from_api) + plot.plot(json_graph) + print_status("JSON Plots Saved...\n", from_api) + else: + from .utils.utils import save_json + + # Create a json file of the graph + pg.graph_to_json_data(graph) + save_json(graph, paths["json_file_path"]) + + print_status("\nFinished!\n") + print_status( + f"Output Directory:\n{paths['output_dir']}\n", from_api + ) + return_data = paths + else: + # TODO: this is just until we can figure out how to return a plot through API + # this is being run through the API, don't create a bunch of stuff on server + # just return the graph as json data + return_data = pg.graph_to_json_data(graph) + else: + if not from_api: + print_status("No graph to plot\n", from_api) + return_data = None + else: + raise ValueError("Graph was not able to be created from file.") + + # Change the output dir back to the previous one + if not from_api: # not needed for API + # BE SURE TO CHANGE THE OUTPUT DIRECTORY IN THE CONFIG FILE BACK TO THE PREVIOUS ONE + if output_dir and output_dir != "" and previous_output_dir != "": + config_data["output_dir"] = previous_output_dir + save_json(config_data, config_path) + + return return_data + +def print_status(message: str = None, from_api: bool = False): + """Print the status of the code cartographer. + + Parameters: + ----------- + message (str) - Default: None + The message to print. + """ + # TODO: this function is for local use until we can figure out how to return status messages through API + if message and not from_api: + print(message) + +def print_process_settings(plotter, from_api: bool = False): + """Print the settings for the process.""" + from .plotter.plotter import Plotter + plot: Plotter = plotter + settings_msg:str = f"Plot Settings:\n" + settings_msg += f" Labels: {plot.labels}\n" + settings_msg += f" Grid: {plot.grid}\n" + settings_msg += f" JSON: {plot.json}\n" + settings_msg += f" Show Plot: {plot.show_plot}\n" + settings_msg += f" Single File: {plot.single_file}\n" + settings_msg += f" NTX Layouts: {plot.ntx_layouts}\n" + settings_msg += f" Custom Layouts: {plot.custom_layouts}\n" + print_status(settings_msg, from_api) diff --git a/src/codecarto/local/src/utils/__init__.py b/src/codecarto/local/src/utils/__init__.py new file mode 100644 index 0000000..fc1e2d3 --- /dev/null +++ b/src/codecarto/local/src/utils/__init__.py @@ -0,0 +1 @@ +# Utils folder holds any utility functions that are used throughout the application. \ No newline at end of file diff --git a/src/codecarto/local/src/utils/errors.py b/src/codecarto/local/src/utils/errors.py new file mode 100644 index 0000000..3faa463 --- /dev/null +++ b/src/codecarto/local/src/utils/errors.py @@ -0,0 +1,44 @@ +# TODO: go through errors in package and make sure they're custom and not just generic exceptions. +# ^^^ IF NEEDED + + +class CliError(Exception): + """Base class for CLI-related errors.""" + + pass + + +class MissingParameterError(CliError): + """Raised when a new command requires all parameters.""" + + pass + + +class ThemeError(Exception): + """Base class for exceptions in the Theme module.""" + + pass + + +class ThemeBaseNotFoundError(ThemeError): + """Raised when the specified base theme is not found.""" + + pass + + +class ThemeCreationError(ThemeError): + """Raised when a new theme could not be created.""" + + pass + + +class ThemeDirNotFoundError(CliError): + """Raised when themes.json cannot be found.""" + + pass + + +class ThemeNotFoundError(ThemeError): + """Raised when the specified theme is not found.""" + + pass diff --git a/src/codecarto/local/src/utils/utils.py b/src/codecarto/local/src/utils/utils.py new file mode 100644 index 0000000..0e26a3f --- /dev/null +++ b/src/codecarto/local/src/utils/utils.py @@ -0,0 +1,100 @@ +import os +import json + + +def get_date_time_file_format(): + """Get the current date and time in a file format. + + Returns: + -------- + str + The current date and time in a file format. + Format: MM-DD-YY_HH-MM-SS + """ + from datetime import datetime + + return datetime.now().strftime("%m-%d-%y_%H-%M-%S") + + +def check_file_path(file_path): + """Check if a file path is valid. + + Parameters: + ----------- + file_path : str + The path to the file to check. + + Raises: + ------- + ValueError + If the file path is invalid. + """ + # check if path exists + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + # check if file is a file + if not os.path.isfile(file_path): + raise ValueError(f"Invalid file path: {file_path}") + return True + + +# this use to be in the polygraph folder, but was moved +# here because it does not relate to converting data +def load_json(file_path) -> dict: + """Load data from a json file. + + Parameters: + ----------- + file_path : str + The path to the file to load. + + Returns: + -------- + dict + The data loaded from the file. + """ + # Check if file exists + if not os.path.exists(file_path): + print(f"File {file_path} not found.") + raise FileNotFoundError(f"File not found: {file_path}") + + # Load data from file + try: + with open(file_path, "r") as f: + data = json.load(f) + except json.JSONDecodeError as e: + print(f"Failed to load data from {file_path}.") + raise e + + # The calling function should do a validation check on type of data expecting + return data + + +# this use to be in the polygraph folder, but was moved +# here because it does not relate to converting data +def save_json(file_path: str, data: dict) -> bool: + """Save data to a json file. + + Parameters: + ----------- + file_path : str + The path to the file to save. + data : dict + The data to save to the file. + + Returns: + -------- + bool + True if the file was saved successfully, False otherwise. + """ + if not os.path.exists(os.path.dirname(file_path)): + os.makedirs(os.path.dirname(file_path), exist_ok=True) + with open(file_path, "w") as f: + json.dump(data, f, indent=4) + + # Check if file was created + if os.path.exists(file_path): + return True + else: + print(f"File {file_path} was not created.") + return False diff --git a/src/codecarto/local/tests/__init__.py b/src/codecarto/local/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/codecarto/local/tests/test_cli/test_cli_dir.py b/src/codecarto/local/tests/test_cli/test_cli_dir.py new file mode 100644 index 0000000..0b192ea --- /dev/null +++ b/src/codecarto/local/tests/test_cli/test_cli_dir.py @@ -0,0 +1,53 @@ +import subprocess +import tempfile + + +def test_dir(): + """Test the dir command.""" + with tempfile.TemporaryDirectory() as temp_dir: + command = ["codecarto", "dir"] + result = subprocess.run(command, capture_output=True, text=True) + + # Check if certain strings are in the directory section + expected_strings = [ + "appdata_dir", + "codecarto_appdata_dir", + "package_dir", + "config_dirs", + "palette_dirs", + "output_dirs", + "version", + "output_dir", + "version_dir", + "graph_dir", + "graph_code_dir", + "graph_json_dir", + "json_dir", + "json_graph_file_path", + "Package Source Python Files:", + ] + for string in expected_strings: + assert string in result.stdout + + # Check if key files are in the package source files section + key_files = [ + "errors.py", + "parser.py", + "plotter.py", + "processor.py", + "palette.py", + "json_graph.py", + "json_utils.py", + "cli.py", + "config.py", + "directories.py", + "utils.py", + "appdata_dir.py", + "config_dir.py", + "import_source_dir.py", + "output_dir.py", + "package_dir.py", + "palette_dir.py", + ] + for string in key_files: + assert string in result.stdout diff --git a/src/codecarto/local/tests/test_cli/test_cli_help.py b/src/codecarto/local/tests/test_cli/test_cli_help.py new file mode 100644 index 0000000..25665df --- /dev/null +++ b/src/codecarto/local/tests/test_cli/test_cli_help.py @@ -0,0 +1,74 @@ +import subprocess +import tempfile + +def test_help(): + """Test the help command.""" + with tempfile.TemporaryDirectory() as temp_dir: + commands = [ + ["codecarto", "help"], + ["codecarto", "-h"], + ["codecarto", "--help"], + ] + + for command in commands: + result = subprocess.run(command, capture_output=True, text=True) + + expected_strings = [ + "Usage:", + "codecarto demo", + "-l | --labels (default True)", + "-g | --grid (default False)", + "-s | --show (default False)", + "-j | --json (default False)", + "-d | --dir (default False)", + "-u | --uno (default False)", + "codecarto dir", + "codecarto help | -h | --help", + "codecarto output -s | --set DIR", + "codecarto FILE", + "codecarto palette", + "-i | --import FILE", + "-e | --export DIR", + "-t | --types", + "-n | --new PARAMS", + "Command Description:", + "dir : Show the various directories used by package.", + "help : Display this information", + "output : Show the output directory.", + "--set | -s : Set the output directory to the provided directory.", + "--reset | -r : Reset the output directory to the default directory.", + "demo : Runs the package on itself.", + "FILE : The path of the Python file to visualize", + "FILE & demo Options:", + "--labels | -l : Display labels on the graph. Default is False.", + "--grid | -g : Display a grid on the graph. Default is False.", + "--show | -s : Show the graph plot. Default is False.", + "--json | -j : Converts json data to graph and plots. Default is False.", + "--dir | -d : Prints passed file's source code to be used in process.", + "Does NOT run the package. Default is False.", + "--uno | -u : Whether to run for a single file or all of source directory. Default is False.", + "Examples:", + "codecarto foo.py -l --grid --json", + "codecarto demo -labels -g -show", + "palette : Show the directory of palette.json and shows current themes.", + "--import | -i : Import palette from a provided JSON file path.", + "--export | -e : Export package palette.json to a provided directory.", + "--types | -t : Display the styles for all types or for a specific type.", + "--new | -n : Create a new theme with the specified parameters.", + "PARAMS must be in the format: TYPE NAME SHAPE COLOR SIZE ALPHA", + "Examples:", + "codecarto palette -n ClassDef def.class Cl o red 5 10", + "codecarto palette --export EXPORT_DIR", + "codecarto palette -i IMPORT_FILE", + "New Theme Information:", + "For a list of valid types : https://docs.python.org/3/library/ast.html#abstract-grammar", + "For a list of valid shapes : https://matplotlib.org/stable/api/markers_api.html", + "For a list of valid colors : https://matplotlib.org/stable/gallery/color/named_colors.html", + "Size must be an integer between 0 and 10. Represents [100, 200, 300, ... , 1000] size.", + "Alpha must be an integer between 0 and 10. Represents [0.0, 0.1, 0.2., ... , 1.0] transparency.", + ] + for string in expected_strings: + # the string does not include the newline character or spacing + # so we need to check that the 'string' is in the result.stdout + # but not exactly equal to the result.stdout + assert string in result.stdout diff --git a/src/codecarto/local/tests/test_cli/test_cli_output.py b/src/codecarto/local/tests/test_cli/test_cli_output.py new file mode 100644 index 0000000..fc154d7 --- /dev/null +++ b/src/codecarto/local/tests/test_cli/test_cli_output.py @@ -0,0 +1,97 @@ +import os +import json +import subprocess +import tempfile + + +def test_output(): + """Test the output command.""" + with tempfile.TemporaryDirectory() as temp_dir: + commands = [ + ["codecarto", "output"], + ["codecarto", "-s", temp_dir], + ["codecarto", "--set", temp_dir], + ["codecarto", "-r"], + ["codecarto", "--reset"], + ] + + for command in commands: + result = subprocess.run( + command, input="y\n", capture_output=True, text=True + ) + + if command == ["codecarto", "output"]: + # check output directory prints + assert "Current output directory: " in result.stdout + # get the path + path = result.stdout.split("Current output directory: ")[1] + path = path[:-1] + # check if the path exists + assert os.path.exists(path) + elif command == ["codecarto", "-s", temp_dir] or command == [ + "codecarto", + "--set", + temp_dir, + ]: + # set output directory + assert "Output directory changed to " in result.stdout + # get the path + path = result.stdout.split("Output directory changed to ")[1] + path = path[:-1] + # check if the path exists + assert os.path.exists(path) + elif command == ["codecarto", "-r"] or command == ["codecarto", "--reset"]: + # reset output directory + assert "Output directory reset to " in result.stdout + # get the path + path = result.stdout.split("Output directory reset to ")[1] + path = path[:-1] + # check if the path exists + assert os.path.exists(path) + + # check the config file + # get the config file in the \src\codecarto\ directory + config_file = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "src\\codecarto\\config.json", + ) + with open(config_file, "r") as f: + config = json.load(f) + # check if the output directory is the same as the one in the config file + assert config["output_dir"] == path + + +def test_output_dir_yes(): + with tempfile.TemporaryDirectory() as temp_dir: + non_existent_dir = os.path.join(temp_dir, "not_here") + command = ["codecarto", "output", "-s", non_existent_dir] + + # Run the command and send the input "y\n" + result = subprocess.run( + command, input="y\n", text=True, capture_output=True, shell=True + ) + + # Check if the output contains the expected prompt question and response + assert ( + "The new output directory does not exist. Would you like to make it? (y/n)" + in result.stdout + ) + assert f"Output directory changed to '{non_existent_dir}'" in result.stdout + + +def test_output_dir_no(): + with tempfile.TemporaryDirectory() as temp_dir: + non_existent_dir = os.path.join(temp_dir, "not_here") + command = ["codecarto", "output", "-s", non_existent_dir] + + # Run the command and send the input "n\n" + result = subprocess.run( + command, input="n\n", text=True, capture_output=True, shell=True + ) + + # Check if the output contains the expected prompt question and response + assert ( + "The new output directory does not exist. Would you like to make it? (y/n)" + in result.stdout + ) + assert "Exiting" in result.stdout diff --git a/src/codecarto/local/tests/test_cli/test_cli_palette/cli_palette_helper.py b/src/codecarto/local/tests/test_cli/test_cli_palette/cli_palette_helper.py new file mode 100644 index 0000000..279d729 --- /dev/null +++ b/src/codecarto/local/tests/test_cli/test_cli_palette/cli_palette_helper.py @@ -0,0 +1,59 @@ +def get_palette_data(item: str, default: bool = True) -> dict | str: + """Load the palette data from the Palette class.""" + from ....src.codecarto.plotter.palette import Palette + + if item == "bases": + return Palette().get_palette_data() + elif item == "path" and default: + return Palette()._palette_default_path + elif item == "path" and not default: + return Palette()._palette_user_path + + +def check_palette_matches_default() -> bool: + """Check if the palette is the same as the default palette.""" + import json + + # get the default palette + default_palette_path = get_palette_data("path", False) + + # get the appdata palette + appdata_palette_path = get_palette_data("path") + + # check if the palette file is the same as the default palette + with open(default_palette_path, "r") as f: + default_palette = json.load(f) + with open(appdata_palette_path, "r") as f: + appdata_palette = json.load(f) + if default_palette == appdata_palette: + return True + else: + return False + + +def reset_palette_manually(): + """Reset the palette manually.""" + import os + import shutil + + # get the default palette + default_palette_path = get_palette_data("path", False) + + # get the appdata palette + appdata_palette_path = get_palette_data("path") + + # delete the appdata palette + os.remove(appdata_palette_path) + + # copy the default palette to the appdata directory + shutil.copy(default_palette_path, appdata_palette_path) + + +def set_config_prop(_prop_name: str, _value: str = "reset"): + """Set the config properties.""" + from ....src.codecarto.config.config import Config + + if _value == "reset": + Config().reset_config_data() + else: + Config().set_config_property(_prop_name, _value) diff --git a/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_cmd.py b/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_cmd.py new file mode 100644 index 0000000..bce4d60 --- /dev/null +++ b/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_cmd.py @@ -0,0 +1,74 @@ +import os +import json +import subprocess +import tempfile +from cli_palette_helper import get_palette_data + + +# # print themes by base +# for base, node_types in base_themes.items(): +# max_width = max(len(prop) for prop in palette_data.keys()) + 1 +# print(f"{'Base ':{max_width}}: {base}") +# for prop in palette_data.keys(): +# if prop != "bases": +# print(f" {prop:{max_width}}: {palette_data[prop][base]}") +# print() +# print( +# f"\nBase themes and properties can be found in 'palette.json': {palette._palette_user_path}\n" +# ) + + +def test_palette(): + """Test the palette command.""" + with tempfile.TemporaryDirectory() as temp_dir: + result = subprocess.run( + ["codecarto", "palette"], capture_output=True, text=True + ) + + # define the expected strings + palette_path = get_palette_data("path") + expected_strings = [ + f"Base themes and properties can be found in 'palette.json': {palette_path}" + ] + + # check if the expected strings are in the output + for string in expected_strings: + assert string in result.stdout + + # Load palette data + palette_data = get_palette_data("bases") + + # Group the themes by base + base_themes: dict[str, list] = {} + for node_type in palette_data["bases"].keys(): + base = palette_data["bases"][node_type] + if base not in base_themes: + base_themes[base] = [] + base_themes[base].append(node_type) + + # print themes by base + for base, node_types in base_themes.items(): + max_width = max(len(prop) for prop in palette_data.keys()) + 1 + assert f"{'Base ':{max_width}}: {base}" in result.stdout + for prop in palette_data.keys(): + if prop != "bases": + assert ( + f" {prop:{max_width}}: {palette_data[prop][base]}" + in result.stdout + ) + assert "\n" in result.stdout + assert "\n" in result.stdout + + # check the config file to make sure it didn't change during printing + # get the config file in the \src\codecarto\ directory + config_file = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "src\\codecarto\\config.json", + ) + with open(config_file, "r") as f: + config = json.load(f) + # check if the output directory is the same as the one in the config file + assert config["palette_path"] == palette_path + + # # check if the base style string is in the output + # assert data in result.stdout diff --git a/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_export.py b/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_export.py new file mode 100644 index 0000000..3a69719 --- /dev/null +++ b/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_export.py @@ -0,0 +1,33 @@ +import os +import json +import subprocess +import tempfile +from cli_palette_helper import get_palette_data + + +def test_palette_export(): + """Test the palette export command.""" + with tempfile.TemporaryDirectory() as temp_dir: + # create a temporary file + temp_file = os.path.join(temp_dir, "test_palette_export.json") + + # define commands + commands = [ + ["codecarto", "palette", "-e", temp_file], + ["codecarto", "palette", "--export", temp_file], + ] + + # run commands + for command in commands: + result = subprocess.run(command, capture_output=True, text=True) + assert "Palette exported to " in result.stdout + + # get the default palette + palette_path = get_palette_data("path", False) + + # check if the palette file is the same as the default palette + with open(palette_path, "r") as f: + default_palette = json.load(f) + with open(temp_file, "r") as f: + exported_palette = json.load(f) + assert default_palette == exported_palette diff --git a/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_import.py b/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_import.py new file mode 100644 index 0000000..5e29038 --- /dev/null +++ b/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_import.py @@ -0,0 +1,37 @@ +import os +import json +import shutil +import subprocess +import tempfile +from cli_palette_helper import get_palette_data + + +def test_palette_import(): + """Test the palette import command.""" + with tempfile.TemporaryDirectory() as temp_dir: + # create a temporary file + temp_file = os.path.join(temp_dir, "test_palette_import.json") + + # get the default palette + palette_path = get_palette_data("path", False) + + # copy the default palette to the temporary directory + shutil.copy(palette_path, temp_file) + + # define commands + commands = [ + ["codecarto", "palette", "-i", temp_file], + ["codecarto", "palette", "--import", temp_file], + ] + + # run commands + for command in commands: + result = subprocess.run(command, capture_output=True, text=True) + assert "Palette imported from " in result.stdout + + # check if the palette file is the same as the default palette + with open(palette_path, "r") as f: + default_palette = json.load(f) + with open(temp_file, "r") as f: + imported_palette = json.load(f) + assert default_palette == imported_palette diff --git a/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_new.py b/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_new.py new file mode 100644 index 0000000..7c21f03 --- /dev/null +++ b/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_new.py @@ -0,0 +1,227 @@ +import os +import subprocess +import tempfile + + +def test_palette_new(): + """Test the palette new command.""" + with tempfile.TemporaryDirectory() as temp_dir: + # TODO: set up a test default_config.json file in the temp_dir + # difficult to use the codecarto from temp dir or nox env + # because the codecarto is installed in the base env + # need to change this so that when the codecarto is installed + # it will set up default_config.json, don't want to save it in the repo + + ########### Helper functions ########### + # have to create these here to maintain + # the scope of the temp_file_path variable + + def assert_result(expected, actual): + try: + assert str(expected) in str(actual) + except AssertionError as e: + raise AssertionError( + f"Error in test_cli_palette_new.py: " + f"\n\nExpected:\n---------\n{expected}\n" + f"\n\nActual:\n-------\n{actual}\n" + ) + + def get_palette_data(): + """Return the palette data.""" + import json + + # get the palette data from the temp_palette_new.json file + with open(temp_file_path, "r") as f: + palette_data = json.load(f) + return palette_data + + def check_new_data(command): + """Check that the new type is in palette.json and has correct params.""" + + palette_data = get_palette_data() + for command in commands: + assert_result(command[3], palette_data["bases"]) + assert_result(command[5], palette_data["labels"]) + assert_result(command[6], palette_data["shapes"]) + assert_result(command[7], palette_data["colors"]) + assert_result(int(command[8]) * 100, palette_data["sizes"]) + assert_result( + round(0.1 * int(command[9]), ndigits=1), palette_data["alphas"] + ) + + def check_output(command, result, input: str = ""): + """Check the output of the command.""" + + actual_output = result.stdout + + if input == "": + # check that the new type added prompt is in the output + assert_result( + (f"\nNew theme added to palette: {temp_file_path}"), actual_output + ) + assert_result( + ( + f"New theme '{command[3]}' created with parameters: " + f"base={command[4]}, label={command[5]}, shape={command[6]}, color={command[7]}, " + f"size={(int(command[8])*100)}, alpha={round(0.1 * int(command[9]), ndigits=1)}\n" + ), + actual_output, + ) + elif input == "n\n": + assert_result( + ( + f"\n{command[3]} already exists. \n " + f"base:{command[4]}" + f"label:{command[5]}" + f"shape:{command[6]}" + f"color:{command[7]}" + f"size={(int(command[8])*100)}, alpha={round(0.1 * int(command[9]), ndigits=1)}\n" + f"\n\nOverwrite? Y/N " + ), + actual_output, + ) + elif input == "y\n": + assert_result( + ( + f"\n{command[3]} already exists. \n " + f"base:{command[4]}" + f"label:{command[5]}" + f"shape:{command[6]}" + f"color:{command[7]}" + f"size={(int(command[8])*100)}, alpha={round(0.1 * int(command[9]), ndigits=1)}\n" + f"\n\nOverwrite? Y/N " + ), + actual_output, + ) + assert_result( + (f"\nNew theme added to palette: {temp_file_path}"), actual_output + ) + assert_result( + ( + f"New theme '{command[3]}' created with parameters: " + f"base={command[4]}, label={command[5]}, shape={command[6]}, color={command[7]}, " + f"size={(int(command[8])*100)}, alpha={round(0.1 * int(command[9]), ndigits=1)}\n" + ), + actual_output, + ) + + def get_config_prop(_prop_name: str): + """Get the config properties.""" + from ....src.codecarto.config.config import Config + + return Config().config_data[_prop_name] + + def set_config_prop(_prop_name: str, _value: str = "reset"): + """Set the config properties.""" + from ....src.codecarto.config.config import Config + + if _value == "reset": + Config().reset_config_data() + else: + Config().set_config_property(_prop_name, _value) + + def reset_palette_manually(temp_file_path): + """Reset the palette manually.""" + import os + import shutil + + # get the default palette + default_palette_path = get_config_prop("default_palette_path") + + # delete the appdata palette + if os.path.exists(temp_file_path): + os.remove(temp_file_path) + + # copy the default palette to the appdata directory + shutil.copy(default_palette_path, temp_file_path) + + def check_palette_matches_default(temp_file_path) -> bool: + """Check if the palette is the same as the default palette.""" + import json + + # get the default palette + default_palette_path = get_config_prop("default_palette_path") + + # check if the palette file is the same as the default palette + with open(default_palette_path, "r") as f: + default_palette = json.load(f) + with open(temp_file_path, "r") as f: + appdata_palette = json.load(f) + if default_palette == appdata_palette: + return True + else: + return False + + ########### Test functions ########### + + # create a temporary file for palette.json + temp_file_name = "test_palette_new.json" + temp_file_path = os.path.join(temp_dir, temp_file_name) + + set_config_prop("palette_path", os.path.join(temp_dir, temp_file_name)) + + # Make the directory if it doesn't exist + os.makedirs(os.path.dirname(temp_dir), exist_ok=True) + + # define commands for new palette and checks + commands = [ + [ + "codecarto", + "palette", + "-n", + "Type_Test_Short", + "basic", + "Label_Test_Short", + "o", + "red", + "3", + "1", + ], + [ + "codecarto", + "palette", + "--new", + "Type_Test_Long", + "basic", + "Label_Test_Long", + "s", + "blue", + "4", + "2", + ], + ] + + # run commands + for command in commands: + # before starting reset palette manually, to keep the tests isolated + reset_palette_manually(temp_file_path) + if not check_palette_matches_default(temp_file_path): + raise Exception("Setting up test for 'palette new' failed.") + # run command + result = subprocess.run(command, capture_output=True, text=True) + # check output + check_output(command, result) + # check that new type in palette.json and has correct params + check_new_data(command) + # change type color + _old_color = command[7] + command[7] = "green" + # run command again to check that overwrite prompt worksl, input 'n' + result = subprocess.run( + command, input="n\n", capture_output=True, text=True + ) + # check output + check_output(command, result) + # change type color back for check + command[7] = _old_color + # check that new type is still in palette.json and color is not changed + check_new_data(command) + # run command again to check that overwrite prompt works, input 'y' + command[7] = "yellow" + result = subprocess.run( + command, input="y\n", capture_output=True, text=True + ) + # check output + check_output(command, result) + # check that new type is still in palette.json and color is changed + check_new_data(command) diff --git a/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_reset.py b/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_reset.py new file mode 100644 index 0000000..ee6c16f --- /dev/null +++ b/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_reset.py @@ -0,0 +1,49 @@ +import json +import subprocess +import tempfile +from cli_palette_helper import check_palette_matches_default, get_palette_data, reset_palette_manually + + +def test_palette_reset(): + """Test the palette reset command with a 'y' and 'n' responses.""" + with tempfile.TemporaryDirectory() as temp_dir: + # define commands and responses + commands:list = [ + ["codecarto", "palette", "-r"], + ["codecarto", "palette", "--reset"], + ] + responses:list = ["y\n", "n\n"] + + for command in commands: + for response in responses: + # need to make palette is not the same as default palette for 'n' response + if response == "n\n": + # reset the palette to the default + reset_palette_manually() + if not check_palette_matches_default(): + raise Exception("Setting up test for palette reset 'n' response failed.") + # manually change the palette + # don't use the cli, to keep the tests isolated + appdata_palette_path = get_palette_data("bases") + with open(appdata_palette_path, "r") as f: + appdata_palette = json.load(f) + appdata_palette["colors"]["unknown"] = "pink" # normally, gray + with open(appdata_palette_path, "w") as f: + json.dump(appdata_palette, f) + # double check if the palette is different + if check_palette_matches_default(): + raise Exception("Setting up test for palette reset 'n' response failed.") + + # Run the command and send the response + result = subprocess.run( + command, input=response, text=True, capture_output=True, shell=True + ) + # check for reset question and repsponse + assert "Are you sure you want to reset the palette to the default palette?\nOverwrite? Y/N" in result.stdout + if response == "y\n": + assert "Palette reset to default." in result.stdout + assert check_palette_matches_default() + elif response == "n\n": + assert not check_palette_matches_default() + + diff --git a/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_types.py b/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_types.py new file mode 100644 index 0000000..ef2574d --- /dev/null +++ b/src/codecarto/local/tests/test_cli/test_cli_palette/test_cli_palette_types.py @@ -0,0 +1,44 @@ +import subprocess +import tempfile +from cli_palette_helper import get_palette_data + + +def test_palette_types(): + with tempfile.TemporaryDirectory() as temp_dir: + # define commands + commands = [ + ["codecarto", "palette", "-t"], + ["codecarto", "palette", "--types"], + ] + + # define expected strings + expected_strings = [ + "Node types and properties:", + "Information:", + "For a list of valid types : https://docs.python.org/3/library/ast.html#abstract-grammar", + "for a list of valid colors : https://matplotlib.org/stable/gallery/color/named_colors.html", + "for a list of valid shapes : https://matplotlib.org/stable/api/markers_api.html", + ] + + # run commands + for command in commands: + result = subprocess.run(command, capture_output=True, text=True) + for string in expected_strings: + assert string in result.stdout + + # Check that the bases are in the output + palette_data = get_palette_data("bases") + + # Print node types + data: str = "" + for node_type in sorted(palette_data["bases"].keys()): + base = palette_data["bases"][node_type] + max_width = max(len(prop) for prop in palette_data.keys()) + 1 + data += f"{'Node_Type':{max_width}} : {node_type}\n" + data += f" {'base':{max_width}}: {base}\n" + for prop in palette_data.keys(): + if prop != "bases": + data += f" {prop:{max_width}}: {palette_data[prop][base]}\n" + + # check if the base style string is in the output + assert data in result.stdout diff --git a/src/codecarto/local/tests/test_cli/test_cli_run/cli_run_helper.py b/src/codecarto/local/tests/test_cli/test_cli_run/cli_run_helper.py new file mode 100644 index 0000000..cf5daf6 --- /dev/null +++ b/src/codecarto/local/tests/test_cli/test_cli_run/cli_run_helper.py @@ -0,0 +1,87 @@ +import subprocess +import tempfile + + +def run_test(demo, labels, grid, json, file_path=None): + """Run the test for the demo/file command.""" + with tempfile.TemporaryDirectory() as temp_dir: + from ....src.codecarto.config.directory.package_dir import PROCESSOR_FILE_PATH + + # define file path + _file_path: str = file_path if file_path is not None else PROCESSOR_FILE_PATH + + # define static strings + starting_strings: list = [ + "Code Cartographer:", + "Processing File:", + _file_path, + "Visited Tree", + ] + running_strings: list = [ + "Plotting Code Graph", + "Code Plots Saved", + "Finished", + "Output Directory:", + ] + json_strings: list = [ + "Plotting JSON Graph", + "JSON Plots Saved", + ] + grid_strings: list = [ + "Plotting grid...", + ] + no_graph_strings: list = [ + "No graph to plot", + ] + + # define options and options related strings + # 'condition' is used to determine if the option should be added + options_data = { + "labels": { + "short": "-l", + "long": "--labels", + "strings": running_strings, + "condition": labels, + }, + "grid": { + "short": "-g", + "long": "--grid", + "strings": running_strings + grid_strings, + "condition": grid, + }, + "json": { + "short": "-j", + "long": "--json", + "strings": json_strings, + "condition": json, + }, + } + + # add options to lists and expected strings + options_short = [] + options_long = [] + expected_strings = starting_strings + for key, data in options_data.items(): + if data["condition"]: + options_short.append(data["short"]) + options_long.append(data["long"]) + expected_strings += data["strings"] + + # add empty strings if file path is given + if file_path is not None: + expected_strings += no_graph_strings + + # convert back to list to remove duplicates + expected_strings = list(expected_strings) + + # run commands and check output + run_argument: str = "demo" if demo else _file_path + + # run commands + for options in [options_short, options_long]: + result = subprocess.run( + ["codecarto", run_argument, *options], + capture_output=True, + text=True, + check=True, + ) diff --git a/src/codecarto/local/tests/test_cli/test_cli_run/empty.py b/src/codecarto/local/tests/test_cli/test_cli_run/empty.py new file mode 100644 index 0000000..e69de29 diff --git a/src/codecarto/local/tests/test_cli/test_cli_run/test_cli_run_demo.py b/src/codecarto/local/tests/test_cli/test_cli_run/test_cli_run_demo.py new file mode 100644 index 0000000..4c851b5 --- /dev/null +++ b/src/codecarto/local/tests/test_cli/test_cli_run/test_cli_run_demo.py @@ -0,0 +1,12 @@ +import pytest +import itertools +from cli_run_helper import run_test + + +@pytest.mark.parametrize( + "labels,grid,json", + [args for args in itertools.product([False, True], repeat=3)], +) +def test_demo(labels, grid, json): + """Test the run command with the demo file.""" + run_test(True, labels, grid, json) diff --git a/src/codecarto/local/tests/test_cli/test_cli_run/test_cli_run_empty.py b/src/codecarto/local/tests/test_cli/test_cli_run/test_cli_run_empty.py new file mode 100644 index 0000000..6b500f3 --- /dev/null +++ b/src/codecarto/local/tests/test_cli/test_cli_run/test_cli_run_empty.py @@ -0,0 +1,15 @@ +import os +import pytest +import itertools +from cli_run_helper import run_test + + +@pytest.mark.parametrize( + "labels,grid,json", + [args for args in itertools.product([False, True], repeat=3)], +) +def test_empty(labels, grid, json): + """Test the run command with an empty file.""" + # define the path to the empty file in the same directory as this file + empty_file_path = os.path.join(os.path.dirname(__file__), "empty.py") + run_test(False, labels, grid, json, empty_file_path) diff --git a/src/codecarto/local/tests/test_cli/test_cli_run/test_cli_run_file.py b/src/codecarto/local/tests/test_cli/test_cli_run/test_cli_run_file.py new file mode 100644 index 0000000..f314337 --- /dev/null +++ b/src/codecarto/local/tests/test_cli/test_cli_run/test_cli_run_file.py @@ -0,0 +1,12 @@ +import pytest +import itertools +from cli_run_helper import run_test + + +@pytest.mark.parametrize( + "labels,grid,json", + [args for args in itertools.product([False, True], repeat=3)] +) +def test_file(labels, grid, json): + """Test the run command with a file.""" + run_test(False, labels, grid, json) diff --git a/src/codecarto/local/tests/test_lib/test_lib_json_graph.py b/src/codecarto/local/tests/test_lib/test_lib_json_graph.py new file mode 100644 index 0000000..2c3fbb4 --- /dev/null +++ b/src/codecarto/local/tests/test_lib/test_lib_json_graph.py @@ -0,0 +1,71 @@ +import json +import tempfile +import networkx as nx +from pathlib import Path + +from ...src.codecarto.polygraph.polygraph import PolyGraph + + +def test_json_graph(): + """Test that json graph can be created and then converted back to a graph.""" + try: + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = Path(tmpdir) + + # create some digraph with decent size using networkx + G = nx.DiGraph() + G.add_nodes_from( + [ + ( + i, + { + "type": f"Type_{i}", + "label": f"Node_{i}", + "parent": i - 1 if i > 0 else None, + }, + ) + for i in range(5) + ] + ) + G.add_edges_from([(i, i + 1) for i in range(4)]) + + # use graph_to_json to pass graph + json_file_path = tmpdir_path / "test_graph.json" + json_graph_obj = PolyGraph(str(json_file_path), G) + + # check that the json file created an object that represents the graph created + with open(json_file_path, "r") as f: + json_data = json.load(f) + + # check that the json file has the same number of nodes and edges as the graph + assert len(json_data["nodes"]) == len(G.nodes) + assert len(json_data["edges"]) == len(G.edges) + + # Check if the JSON data's nodes have the same attributes as the test graph's nodes + for node in json_data["nodes"]: + node_id = node["id"] + assert G.nodes[node_id]["type"] == node["type"] + assert G.nodes[node_id]["label"] == node["label"] + assert G.nodes[node_id]["parent"] == node["parent"] + + # Check if the JSON data's edges match the test graph's edges + json_edges = [ + (edge["source"], edge["target"]) for edge in json_data["edges"] + ] + for edge in G.edges: + assert edge in json_edges + + # pass json file into json_to_graph + new_G = json_graph_obj.json_to_graph(json_data) + + # check that graph was created + assert new_G is not None + + # check that graph matches orig graph + assert nx.is_isomorphic( + G, new_G, node_match=lambda n1, n2: n1["label"] == n2["label"] + ) + + except Exception as e: + # Raise exception + raise e diff --git a/src/codecarto/local/tests/test_lib/test_lib_palette.py b/src/codecarto/local/tests/test_lib/test_lib_palette.py new file mode 100644 index 0000000..cc61223 --- /dev/null +++ b/src/codecarto/local/tests/test_lib/test_lib_palette.py @@ -0,0 +1,76 @@ +import tempfile +from pathlib import Path + +from ...src.codecarto.plotter.palette import Palette + + +def test_palette(): + """Test Palette class functions.""" + try: + # Create temporary directory + with tempfile.TemporaryDirectory() as temp_dir: + # Initialize a new Palette object + palette: Palette = Palette() + + # Test save, load, and get_palette_data methods + palette.save_palette() + palette.load_palette() + palette_data = palette.get_palette_data() + assert palette_data is not None + + # Test create_new_theme method + new_node_type = "custom_node" + new_theme = palette.create_new_theme( + node_type=new_node_type, + base="basic", + label="Custom Label", + shape="o", + color="white", + size=5, + alpha=5, + ) + assert new_theme == new_node_type + + # Test get_node_style method + node_style = palette.get_node_styles(new_node_type) + assert node_style is not None + + # Test get_node_styles method + node_styles = palette.get_node_styles() + assert node_styles is not None + assert new_node_type in node_styles + + # Test reset_palette method + palette.reset_palette() + reset_palette_data = palette.get_palette_data() + assert reset_palette_data is not None + assert new_node_type not in reset_palette_data["bases"] + + # Test import_palette and export_palette methods + with tempfile.NamedTemporaryFile( + dir=temp_dir, suffix=".json", delete=False + ) as temp_file: + palette_file = Path(temp_file.name) + with palette_file.open("w") as dest, open( + palette._palette_user_path, "r" + ) as src: + dest.write(src.read()) + palette.import_palette(palette_file) + exported_file = palette.export_palette(temp_dir) + assert exported_file.exists() + + # Check if the exported file is the same as palette._palette_user_path + with palette_file.open("r") as f: + palette_file_data = f.read() + with exported_file.open("r") as f: + exported_file_data = f.read() + assert palette_file_data == exported_file_data + + # Test ThemeNotFoundError + try: + palette.get_node_styles("nonexistent_node_type") + except: + raise Exception("ThemeNotFoundError not raised") + except Exception as e: + # Raise exception + raise e diff --git a/src/codecarto/local/tests/test_lib/test_lib_parser.py b/src/codecarto/local/tests/test_lib/test_lib_parser.py new file mode 100644 index 0000000..6661579 --- /dev/null +++ b/src/codecarto/local/tests/test_lib/test_lib_parser.py @@ -0,0 +1,41 @@ +import tempfile +from pathlib import Path + +from ...src.codecarto.parser.parser import Parser + + +def test_parser(): + """Test Parser class functions and output graph.""" + try: + # Create temporary directory + with tempfile.TemporaryDirectory() as temp_dir: + # Get the source files of 'test_source_code' directory + source_files = list(Path("tests/test_source_code").rglob("*.py")) + + # Create a Parser object + parser: Parser = Parser(source_files) + + # get back parser's graph + graph = parser.graph + + # check nodes in the graph + assert len(graph.nodes) > 0 + assert len(graph.nodes) == 9 + assert graph.number_of_nodes() == 9 + + # get the ids of nodes in the graph + node_ids = [node_id for node_id in graph.nodes] + + # check if the ids are unique + assert len(node_ids) == len(set(node_ids)) + + # check if the ids are in the range of the number of nodes + assert all( + node_id in range(graph.number_of_nodes()) for node_id in node_ids + ) + + # check some parameters around graph.nodes + + except Exception as e: + # Raise exception + raise e diff --git a/src/codecarto/local/tests/test_lib/test_lib_plotter.py b/src/codecarto/local/tests/test_lib/test_lib_plotter.py new file mode 100644 index 0000000..a5fe83a --- /dev/null +++ b/src/codecarto/local/tests/test_lib/test_lib_plotter.py @@ -0,0 +1,92 @@ +import os +import tempfile +import itertools +import networkx as nx +import matplotlib.pyplot as plt +from pathlib import Path + +from ...src.codecarto.plotter.plotter import Plotter + + +def create_sample_graph(): + """Create a sample graph for testing that looks similar to Parser graph.""" + # Create Graph + G: nx.DiGraph = nx.DiGraph(name="root") + # Create Nodes + G.add_node(1, type="Unknown", label="A1", base="basic.unknown", parent=0) + G.add_node(2, type="Unknown", label="B1", base="basic.unknown", parent=1) + G.add_node(3, type="Unknown", label="C1", base="basic.unknown", parent=1) + G.add_node(4, type="Unknown", label="D1", base="basic.unknown", parent=2) + G.add_node(5, type="Unknown", label="E1", base="basic.unknown", parent=2) + G.add_node(6, type="Unknown", label="F1", base="basic.unknown", parent=3) + G.add_node(7, type="Unknown", label="G1", base="basic.unknown", parent=3) + G.add_node(8, type="Unknown", label="H1", base="basic.unknown", parent=4) + G.add_node(9, type="Unknown", label="I1", base="basic.unknown", parent=4) + # Create Edges + G.add_edge(1, 2) + G.add_edge(1, 3) + G.add_edge(2, 4) + G.add_edge(2, 5) + G.add_edge(3, 6) + G.add_edge(3, 7) + G.add_edge(4, 8) + G.add_edge(4, 9) + return G + + +def test_plotter(): + """Test Plotter class functions and that outputs exist.""" + try: + # Create temporary directory + with tempfile.TemporaryDirectory() as temp_dir: + temp_dir_path = Path(temp_dir) + + # Set up test directories + test_dirs: dict = { + "graph_code_dir": temp_dir_path / "graph_code", + "graph_json_dir": temp_dir_path / "graph_json", + } + + # Create the required directories + test_dirs["graph_code_dir"].mkdir() + test_dirs["graph_json_dir"].mkdir() + + for json, labels, grid, show in itertools.product([False, True], repeat=4): + # Create a sample graph + _graph: nx.DiGraph = create_sample_graph() + + # Test Plotter object with different options + graph_plot: Plotter = Plotter() + graph_plot.set_plotter_attrs( + dirs=test_dirs, labels=labels, grid=grid, show_plot=show + ) + + # Pass graph to Plotter object and plot + plt.ioff() # Turn off interactive mode so plt.show() doesn't block the test + graph_plot.plot(_graph, _json=False) + if json == True: + graph_plot.plot(_graph, _json=True) + plt.ion() # Turn interactive mode back on so the test can continue + + # Check if output files exist + dir_suffixes: list[tuple] = [ + ("graph_code_dir", True), + ("graph_json_dir", json), + ] + for dir_suffix, cond in dir_suffixes: + plot_files: list = [ + f + for f in os.listdir(test_dirs[dir_suffix]) + if f.endswith(".png") + ] + + if cond == True: + if grid == True: + assert len(plot_files) == 1 + else: + assert len(plot_files) > 1 # should get at least 2 + else: + assert len(plot_files) == 0 + except Exception as e: + # Raise exception + raise e diff --git a/src/codecarto/local/tests/test_lib/test_lib_processor.py b/src/codecarto/local/tests/test_lib/test_lib_processor.py new file mode 100644 index 0000000..c0fb863 --- /dev/null +++ b/src/codecarto/local/tests/test_lib/test_lib_processor.py @@ -0,0 +1,90 @@ +import os +import tempfile +import itertools +import matplotlib.pyplot as plt +import matplotlib._pylab_helpers as pylab_helpers +from pathlib import Path + +from ...src.codecarto.processor.src.local import process +from ...src.codecarto.processor.src.local.config.directory.output_dir import ( + set_output_dir, +) +from ...src.codecarto.processor.src.local.config.directory.package_dir import ( + PROCESSOR_FILE_PATH, +) + + +def test_processor(): + """Test Processor's outputs exist with all options.""" + try: + # Create temporary directory + with tempfile.TemporaryDirectory() as temp_dir: + for json, labels, grid, show in itertools.product([False, True], repeat=4): + # Turn off interactive mode so plt.show() doesn't block the test + plt.ioff() + + # Run demo command + set_output_dir(Path(temp_dir), ask_user=False) + output_dirs: dict = process( + source=PROCESSOR_FILE_PATH, + json=json, + labels=labels, + grid=grid, + show=show, + ) + + # Check if demo closed the plot + closed_plots = len(pylab_helpers.Gcf.get_all_fig_managers()) == 0 + + # Turn interactive mode back on so the test can continue + plt.ion() + + # Check if the plot was closed + assert closed_plots + + # Check if the main directories exist + assert os.path.exists(output_dirs["output_dir"]) + assert os.path.exists(output_dirs["version_dir"]) + assert os.path.exists(output_dirs["graph_dir"]) + assert os.path.exists(output_dirs["graph_code_dir"]) + assert os.path.exists(output_dirs["graph_json_dir"]) + assert os.path.exists(output_dirs["json_dir"]) + + # Check if the JSON file exists + assert os.path.exists(output_dirs["json_graph_file_path"]) + + # Check if at least one plot file is created in the graph_code_dir + plot_files = [ + f + for f in os.listdir(output_dirs["graph_code_dir"]) + if f.endswith(".png") + ] + assert len(plot_files) > 0 + + # Check if only one plot file is created in the graph_code_dir when grid is True + if grid == True: + assert len(plot_files) == 1 + else: + assert ( + len(plot_files) > 1 + ) # should get at least 2 plots when grid is False + + # Check if at least one plot file is created in the graph_json_dir + if json == True: + plot_files = [ + f + for f in os.listdir(output_dirs["graph_json_dir"]) + if f.endswith(".png") + ] + assert len(plot_files) > 0 + + # Check if only one plot file is created in the graph_code_dir when grid is True + if grid == True: + assert len(plot_files) == 1 + else: + assert ( + len(plot_files) > 1 + ) # should get at least 2 plots when grid is False + except Exception as e: + # Raise the exception + raise e diff --git a/src/codecarto/local/tests/test_reports/assets/add_link.js b/src/codecarto/local/tests/test_reports/assets/add_link.js new file mode 100644 index 0000000..4328de7 --- /dev/null +++ b/src/codecarto/local/tests/test_reports/assets/add_link.js @@ -0,0 +1,12 @@ +// used to add a link to the pytest report page to go back to the main report page + +document.addEventListener('DOMContentLoaded', function () { + var header = document.querySelector('div#header'); + if (header) { + var link = document.createElement('a'); + link.href = '../report.html'; + link.innerText = 'Back to Pytest Report'; + link.style.marginLeft = '20px'; + header.appendChild(link); + } +}); diff --git a/src/codecarto/local/tests/test_reports/assets/codecarto.css b/src/codecarto/local/tests/test_reports/assets/codecarto.css new file mode 100644 index 0000000..fd86055 --- /dev/null +++ b/src/codecarto/local/tests/test_reports/assets/codecarto.css @@ -0,0 +1,206 @@ +:root { + --primary-bg-color: #1e1e1e; + --secondary-bg-color: #252526; + --tertiary-bg-color: #333333; + --primary-text-color: #CCCCCC; + --secondary-text-color: #C2CCC2; + --tertiary-text-color: #858585; + --primary-border-color: #898989; + --secondary-border-color: #6b6b6b; + --tertiary-border-color: #535353; + --primary-button-color: #8dc9ea; + --secondary-button-color: #3999ff; + --tertiary-button-color: #1969be; + --success-color: #73C991; + --warning-color: #d6ac3a; + --error-color: #F88070; +} + + +body { + font-family: Helvetica, Arial, sans-serif; + font-size: 12px; + /* do not increase min-width as some may use split screens */ + min-width: 800px; + color: var(--primary-text-color); +} + +h1 { + font-size: 24px; + color: var(--primary-text-color); +} + +h2 { + font-size: 16px; + color: var(--secondary-text-color); +} + +p { + color: var(--tertiary-text-color); +} + +a { + color: var(--primary-button-color); +} + +table { + border-collapse: collapse; +} + +/****************************** + * SUMMARY INFORMATION + ******************************/ +#environment td { + padding: 5px; + border: 1px solid var(--tertiary-border-color); +} +#environment tr:nth-child(odd) { + background-color: var(--tertiary-bg-color); +} + +/****************************** + * TEST RESULT COLORS + ******************************/ +span.passed, +.passed .col-result { + color: var(--success-color); +} + +span.skipped, +span.xfailed, +span.rerun, +.skipped .col-result, +.xfailed .col-result, +.rerun .col-result { + color: var(--warning-color); +} + +span.error, +span.failed, +span.xpassed, +.error .col-result, +.failed .col-result, +.xpassed .col-result { + color: var(--error-color); +} + +/****************************** + * RESULTS TABLE + * + * 1. Table Layout + * 2. Extra + * 3. Sorting items + * + ******************************/ +/*------------------ + * 1. Table Layout + *------------------*/ +#results-table { + border: 1px solid var(--tertiary-border-color); + color: var(--primary-text-color); + font-size: 12px; + width: 100%; +} +#results-table th, +#results-table td { + padding: 5px; + border: 1px solid var(--tertiary-border-color); + text-align: left; +} +#results-table th { + font-weight: bold; + background-color: var(--tertiary-bg-color); +} + +/*------------------ + * 2. Extra + *------------------*/ +.log { + background-color: var(--secondary-bg-color); + border: 1px solid var(--tertiary-border-color); + color: var(--secondary-text-color); + display: block; + font-family: "Courier New", Courier, monospace; + height: 230px; + overflow-y: scroll; + padding: 5px; + white-space: pre-wrap; +} +.log:only-child { + height: inherit; +} + +div.image { + border: 1px solid var(--tertiary-border-color); + float: right; + height: 240px; + margin-left: 5px; + overflow: hidden; + width: 320px; +} +div.image img { + width: 320px; +} + +div.video { + border: 1px solid var(--tertiary-border-color); + float: right; + height: 240px; + margin-left: 5px; + overflow: hidden; + width: 320px; +} +div.video video { + overflow: hidden; + width: 320px; + height: 240px; +} + +.collapsed { + display: none; +} + +.expander::after { + content: " (show details)"; + color: var(--secondary-text-color); + font-style: italic; + cursor: pointer; +} + +.collapser::after { + content: " (hide details)"; + color: var(--secondary-text-color); + font-style: italic; + cursor: pointer; +} + +/*------------------ + * 3. Sorting items + *------------------*/ +.sortable { + cursor: pointer; +} + +.sort-icon { + font-size: 0px; + float: left; + margin-right: 5px; + margin-top: 5px; + /*triangle*/ + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; +} +.inactive .sort-icon { + /*finish triangle*/ + border-top: 8px solid var(--primary-border-color); +} +.asc.active .sort-icon { + /*finish triangle*/ + border-bottom: 8px solid var(--secondary-border-color); +} +.desc.active .sort-icon { + /*finish triangle*/ + border-top: 8px solid var(--tertiary-border-color); +} diff --git a/src/codecarto/local/tests/test_reports/assets/conftest.py b/src/codecarto/local/tests/test_reports/assets/conftest.py new file mode 100644 index 0000000..f1f4ee0 --- /dev/null +++ b/src/codecarto/local/tests/test_reports/assets/conftest.py @@ -0,0 +1,24 @@ +# used to customize pytest html report +import os + +# def pytest_html_report_title(report): +# # get coverage path, in the directory above this one +# coverage_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "coverage") +# # get coverage index.html path +# coverage_index_path = os.path.join(coverage_path, "index.html") +# # get coverage link +# coverage_link = f'Coverage Report' +# report.title = f"{report.title} - {coverage_link}" + +def pytest_html_results_summary(prefix, summary, postfix): + # get coverage path, in the directory above this one + coverage_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "coverage") + # get coverage index.html path + coverage_index_path = os.path.join(coverage_path, "index.html") + # get coverage link + coverage_link = f'Coverage Report' + prefix.append(f"
") + +# def pytest_html_report_title(report): +# report.title = "CodeCartographer Test Report" +# report.head.append('Coverage Report') \ No newline at end of file diff --git a/src/codecarto/local/tests/test_reports/assets/style.css b/src/codecarto/local/tests/test_reports/assets/style.css new file mode 100644 index 0000000..9816bd1 --- /dev/null +++ b/src/codecarto/local/tests/test_reports/assets/style.css @@ -0,0 +1,398 @@ +body { + font-family: Helvetica, Arial, sans-serif; + font-size: 12px; + /* do not increase min-width as some may use split screens */ + min-width: 800px; + color: #999; +} + +h1 { + font-size: 24px; + color: black; +} + +h2 { + font-size: 16px; + color: black; +} + +p { + color: black; +} + +a { + color: #999; +} + +table { + border-collapse: collapse; +} + +/****************************** + * SUMMARY INFORMATION + ******************************/ +#environment td { + padding: 5px; + border: 1px solid #E6E6E6; +} +#environment tr:nth-child(odd) { + background-color: #f6f6f6; +} + +/****************************** + * TEST RESULT COLORS + ******************************/ +span.passed, +.passed .col-result { + color: green; +} + +span.skipped, +span.xfailed, +span.rerun, +.skipped .col-result, +.xfailed .col-result, +.rerun .col-result { + color: orange; +} + +span.error, +span.failed, +span.xpassed, +.error .col-result, +.failed .col-result, +.xpassed .col-result { + color: red; +} + +/****************************** + * RESULTS TABLE + * + * 1. Table Layout + * 2. Extra + * 3. Sorting items + * + ******************************/ +/*------------------ + * 1. Table Layout + *------------------*/ +#results-table { + border: 1px solid #e6e6e6; + color: #999; + font-size: 12px; + width: 100%; +} +#results-table th, +#results-table td { + padding: 5px; + border: 1px solid #E6E6E6; + text-align: left; +} +#results-table th { + font-weight: bold; +} + +/*------------------ + * 2. Extra + *------------------*/ +.log { + background-color: #e6e6e6; + border: 1px solid #e6e6e6; + color: black; + display: block; + font-family: "Courier New", Courier, monospace; + height: 230px; + overflow-y: scroll; + padding: 5px; + white-space: pre-wrap; +} +.log:only-child { + height: inherit; +} + +div.image { + border: 1px solid #e6e6e6; + float: right; + height: 240px; + margin-left: 5px; + overflow: hidden; + width: 320px; +} +div.image img { + width: 320px; +} + +div.video { + border: 1px solid #e6e6e6; + float: right; + height: 240px; + margin-left: 5px; + overflow: hidden; + width: 320px; +} +div.video video { + overflow: hidden; + width: 320px; + height: 240px; +} + +.collapsed { + display: none; +} + +.expander::after { + content: " (show details)"; + color: #BBB; + font-style: italic; + cursor: pointer; +} + +.collapser::after { + content: " (hide details)"; + color: #BBB; + font-style: italic; + cursor: pointer; +} + +/*------------------ + * 3. Sorting items + *------------------*/ +.sortable { + cursor: pointer; +} + +.sort-icon { + font-size: 0px; + float: left; + margin-right: 5px; + margin-top: 5px; + /*triangle*/ + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; +} +.inactive .sort-icon { + /*finish triangle*/ + border-top: 8px solid #E6E6E6; +} +.asc.active .sort-icon { + /*finish triangle*/ + border-bottom: 8px solid #999; +} +.desc.active .sort-icon { + /*finish triangle*/ + border-top: 8px solid #999; +} + +/****************************** + * CUSTOM CSS + * tests/test_reports/assets/codecarto.css + ******************************/ + +:root { + --primary-bg-color: #1e1e1e; + --secondary-bg-color: #252526; + --tertiary-bg-color: #333333; + --primary-text-color: #CCCCCC; + --secondary-text-color: #C2CCC2; + --tertiary-text-color: #858585; + --primary-border-color: #898989; + --secondary-border-color: #6b6b6b; + --tertiary-border-color: #535353; + --primary-button-color: #8dc9ea; + --secondary-button-color: #3999ff; + --tertiary-button-color: #1969be; + --success-color: #73C991; + --warning-color: #d6ac3a; + --error-color: #F88070; +} + + +body { + font-family: Helvetica, Arial, sans-serif; + font-size: 12px; + /* do not increase min-width as some may use split screens */ + min-width: 800px; + color: var(--primary-text-color); +} + +h1 { + font-size: 24px; + color: var(--primary-text-color); +} + +h2 { + font-size: 16px; + color: var(--secondary-text-color); +} + +p { + color: var(--tertiary-text-color); +} + +a { + color: var(--primary-button-color); +} + +table { + border-collapse: collapse; +} + +/****************************** + * SUMMARY INFORMATION + ******************************/ +#environment td { + padding: 5px; + border: 1px solid var(--tertiary-border-color); +} +#environment tr:nth-child(odd) { + background-color: var(--tertiary-bg-color); +} + +/****************************** + * TEST RESULT COLORS + ******************************/ +span.passed, +.passed .col-result { + color: var(--success-color); +} + +span.skipped, +span.xfailed, +span.rerun, +.skipped .col-result, +.xfailed .col-result, +.rerun .col-result { + color: var(--warning-color); +} + +span.error, +span.failed, +span.xpassed, +.error .col-result, +.failed .col-result, +.xpassed .col-result { + color: var(--error-color); +} + +/****************************** + * RESULTS TABLE + * + * 1. Table Layout + * 2. Extra + * 3. Sorting items + * + ******************************/ +/*------------------ + * 1. Table Layout + *------------------*/ +#results-table { + border: 1px solid var(--tertiary-border-color); + color: var(--primary-text-color); + font-size: 12px; + width: 100%; +} +#results-table th, +#results-table td { + padding: 5px; + border: 1px solid var(--tertiary-border-color); + text-align: left; +} +#results-table th { + font-weight: bold; + background-color: var(--tertiary-bg-color); +} + +/*------------------ + * 2. Extra + *------------------*/ +.log { + background-color: var(--secondary-bg-color); + border: 1px solid var(--tertiary-border-color); + color: var(--secondary-text-color); + display: block; + font-family: "Courier New", Courier, monospace; + height: 230px; + overflow-y: scroll; + padding: 5px; + white-space: pre-wrap; +} +.log:only-child { + height: inherit; +} + +div.image { + border: 1px solid var(--tertiary-border-color); + float: right; + height: 240px; + margin-left: 5px; + overflow: hidden; + width: 320px; +} +div.image img { + width: 320px; +} + +div.video { + border: 1px solid var(--tertiary-border-color); + float: right; + height: 240px; + margin-left: 5px; + overflow: hidden; + width: 320px; +} +div.video video { + overflow: hidden; + width: 320px; + height: 240px; +} + +.collapsed { + display: none; +} + +.expander::after { + content: " (show details)"; + color: var(--secondary-text-color); + font-style: italic; + cursor: pointer; +} + +.collapser::after { + content: " (hide details)"; + color: var(--secondary-text-color); + font-style: italic; + cursor: pointer; +} + +/*------------------ + * 3. Sorting items + *------------------*/ +.sortable { + cursor: pointer; +} + +.sort-icon { + font-size: 0px; + float: left; + margin-right: 5px; + margin-top: 5px; + /*triangle*/ + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; +} +.inactive .sort-icon { + /*finish triangle*/ + border-top: 8px solid var(--primary-border-color); +} +.asc.active .sort-icon { + /*finish triangle*/ + border-bottom: 8px solid var(--secondary-border-color); +} +.desc.active .sort-icon { + /*finish triangle*/ + border-top: 8px solid var(--tertiary-border-color); +} diff --git a/src/codecarto/local/tests/test_reports/report.html b/src/codecarto/local/tests/test_reports/report.html new file mode 100644 index 0000000..a42ec49 --- /dev/null +++ b/src/codecarto/local/tests/test_reports/report.html @@ -0,0 +1,278 @@ + + +
+ +
+ +