diff --git a/assets/docs/README.md b/README.md similarity index 78% rename from assets/docs/README.md rename to README.md index 692ed30..f4fa860 100644 --- a/assets/docs/README.md +++ b/README.md @@ -2,7 +2,7 @@ GynTree is a powerful Python application designed to analyze and visualize complex directory structures, providing deep insights into your project's architecture. Whether you're managing a large codebase, organizing a media library, or just trying to understand the layout of a new project, GynTree has got you covered. -![GynTree Logo](../images/GynTree_logo.png) +![GynTree Logo](./assets/images/GynTree_logo.png) ## 🌟 Key Features @@ -17,17 +17,17 @@ GynTree is a powerful Python application designed to analyze and visualize compl ## 🚀 Getting Started -Ready to dive in? Check out our [Installation Guide](INSTALL.md) to get GynTree up and running on your system in no time! +Ready to dive in? Check out our [Installation Guide](./assets/docs/INSTALL.md) to get GynTree up and running on your system in no time! ## 📖 Documentation -- [User Guide](user_guide.md): Learn how to use GynTree effectively. -- [Configuration](configuration.md): Customize GynTree to suit your needs. -- [API Reference](api_reference.md): For developers looking to extend GynTree's functionality. +- [User Guide](./assets/docs/user_guide.md): Learn how to use GynTree effectively. +- [Configuration](./assets/docs/configuration.md): Customize GynTree to suit your needs. +- [API Reference](./assets/docs/api_reference.md): For developers looking to extend GynTree's functionality. ## 🤝 Contributing -We welcome contributions from the community! Whether it's bug reports, feature requests, or code contributions, check out our [Contributing Guide](CONTRIBUTING.md) to get started. +We welcome contributions from the community! Whether it's bug reports, feature requests, or code contributions, check out our [Contributing Guide](./assets/docs/CONTRIBUTING.md) to get started. ## 📜 License diff --git a/assets/docs/CONTRIBUTING.md b/assets/docs/CONTRIBUTING.md index a930d34..2f0bba7 100644 --- a/assets/docs/CONTRIBUTING.md +++ b/assets/docs/CONTRIBUTING.md @@ -4,7 +4,7 @@ First off, thank you for considering contributing to GynTree! It's people like y ## Code of Conduct -By participating in this project, you are expected to uphold our [Code of Conduct](CODE_OF_CONDUCT.md). Please report unacceptable behavior to [project_email@example.com](mailto:project_email@example.com). +By participating in this project, you are expected to uphold our [Code of Conduct](CODE_OF_CONDUCT.md). Please report unacceptable behavior to [dsj7419@gmail.com](mailto:dsj7419@gmail.com). ## How Can I Contribute? diff --git a/config/projects/GynTree.json b/config/projects/GynTree.json deleted file mode 100644 index be773e5..0000000 --- a/config/projects/GynTree.json +++ /dev/null @@ -1,248 +0,0 @@ -{ - "name": "GynTree", - "start_directory": "G:/Projects/GynTree", - "excluded_dirs": [ - "G:\\Projects\\GynTree\\src\\services\\auto_exclude\\__pycache__", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\colorama\\__pycache__", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_io\\__pycache__", - "G:\\Projects\\GynTree\\venv", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pytest\\__pycache__", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_code\\__pycache__", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\mark\\__pycache__", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\iniconfig\\__pycache__", - "G:\\Projects\\GynTree\\src\\components\\UI\\__pycache__", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_py\\__pycache__", - "G:\\Projects\\GynTree\\src\\services\\__pycache__", - "G:\\Projects\\GynTree\\dist", - "G:\\Projects\\GynTree\\.pytest_cache", - "G:\\Projects\\GynTree\\src\\controllers\\__pycache__", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\__pycache__", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\operations\\build", - "G:\\Projects\\GynTree\\build", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pluggy\\__pycache__", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\PyQt5\\__pycache__", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\assertion\\__pycache__", - "G:\\Projects\\GynTree\\.git", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\config\\__pycache__", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__", - "G:\\Projects\\GynTree\\src\\models\\__pycache__", - "G:\\Projects\\GynTree\\src\\utilities\\__pycache__", - "G:\\Projects\\GynTree\\src\\components\\__pycache__", - "G:\\Projects\\GynTree\\assets", - "G:\\Projects\\GynTree\\config" - ], - "excluded_files": [ - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\rich\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\truststore\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\config\\__pycache__\\__init__.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\compat.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\components\\__pycache__\\TreeExporter.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\urllib3\\contrib\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_io\\__pycache__\\saferepr.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\colorama\\__pycache__\\ansi.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\mark\\__pycache__\\expression.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\runner.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\distributions\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\urllib3\\packages\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_py\\__pycache__\\error.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_io\\__pycache__\\__init__.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\deprecated.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\index\\__init__.py", - "G:\\Projects\\GynTree\\src\\models\\__pycache__\\Project.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\_argcomplete.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\components\\UI\\__pycache__\\ExclusionsManagerUI.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\pkg_resources\\__init__.py", - "G:\\Projects\\GynTree\\src\\components\\__pycache__\\__init__.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\distro\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\rpds\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\colorama\\__pycache__\\winterm.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\attr\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\PyQt5\\uic\\Loader\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_code\\__pycache__\\__init__.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\python.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\requests\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\__pycache__\\py.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\stash.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pyasn1\\codec\\ber\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\distlib\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\network\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\idna\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\pygments\\formatters\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\assertion\\__pycache__\\truncate.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\scope.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pluggy\\__pycache__\\_tracing.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\services\\auto_exclude\\__pycache__\\PythonAutoExclude.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\PyQt5\\__pycache__\\__init__.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\pygments\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\monkeypatch.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\nodes.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_io\\__pycache__\\terminalwriter.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\pathlib.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\services\\__pycache__\\CommentParser.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\colorama\\__pycache__\\initialise.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\PyQt5\\uic\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\resolvelib\\compat\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\urllib3\\util\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\cachecontrol\\caches\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\faulthandler.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\components\\UI\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\warnings.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\config\\__pycache__\\exceptions.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\config\\__pycache__\\findpaths.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\services\\auto_exclude\\__pycache__\\ExclusionService.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\timing.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\services\\auto_exclude\\__pycache__\\IDEandGitAutoExclude.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\services\\auto_exclude\\__pycache__\\WebAutoExclude.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\hookspec.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\iniconfig\\__pycache__\\_parse.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pluggy\\__pycache__\\_warnings.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\config\\__pycache__\\compat.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\iniconfig\\__pycache__\\__init__.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\services\\__pycache__\\ExclusionAggregator.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\jsonschema\\tests\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pluggy\\__pycache__\\_result.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\pygments\\filters\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\helpconfig.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\services\\__pycache__\\ProjectManager.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\legacypath.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pytest\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\config\\__pycache__\\argparsing.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_code\\__pycache__\\code.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\PyQt5\\uic\\Compiler\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\tomli\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\unraisableexception.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_py\\__pycache__\\__init__.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\PyQt5\\__init__.py", - "G:\\Projects\\GynTree\\src\\components\\__pycache__\\DirectoryTree.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\services\\__pycache__\\DirectoryStructureService.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\config\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\platformdirs\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\pygments\\styles\\__init__.py", - "G:\\Projects\\GynTree\\src\\components\\UI\\__pycache__\\__init__.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\assertion\\__pycache__\\__init__.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\packaging\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pluggy\\__pycache__\\__init__.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\urllib3\\packages\\backports\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pyasn1\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pyasn1\\codec\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\pyproject_hooks\\_in_process\\__init__.py", - "G:\\Projects\\GynTree\\src\\services\\__pycache__\\SettingsManager.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\debugging.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\setupplan.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\reports.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\jsonschema_specifications\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pluggy\\__pycache__\\_callers.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pluggy\\__pycache__\\_hooks.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\__init__.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\python_api.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\mark\\__pycache__\\structures.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\assertion\\__pycache__\\rewrite.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\recwarn.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\iniconfig\\__pycache__\\exceptions.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pluggy\\__init__.py", - "G:\\Projects\\GynTree\\src\\components\\__pycache__\\TreeStructureManager.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\msgpack\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\req\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_io\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\certifi\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_py\\__pycache__\\path.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\skipping.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\pastebin.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\components\\UI\\__pycache__\\DashboardUI.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\operations\\install\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\outcomes.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\iniconfig\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_py\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\freeze_support.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\components\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\jsonschema_specifications\\tests\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\colorama\\__pycache__\\ansitowin32.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\colorama\\__pycache__\\win32.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\logging.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\mark\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\doctest.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pluggy\\__pycache__\\_version.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\services\\__pycache__\\ExclusionServiceFactory.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\terminal.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\colorama\\__pycache__\\__init__.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\_version.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\packaging\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\models\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\PyQt5\\uic\\port_v2\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\mark\\__pycache__\\__init__.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pyasn1\\codec\\der\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\cli\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_code\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\threadexception.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\__init__.py", - "G:\\Projects\\GynTree\\src\\components\\UI\\__pycache__\\ResultUI.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\services\\__pycache__\\ExclusionService.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\colorama\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\python_path.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\stepwise.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\utils\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\cacheprovider.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\resolution\\legacy\\__init__.py", - "G:\\Projects\\GynTree\\src\\utilities\\__pycache__\\Utilities.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\resolvelib\\__init__.py", - "G:\\Projects\\GynTree\\requirements.txt", - "G:\\Projects\\GynTree\\src\\services\\__pycache__\\ProjectTypeDetector.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\PyQt5\\uic\\port_v3\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\setuponly.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\colorama\\tests\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pluggy\\__pycache__\\_manager.cpython-312.pyc", - "G:\\Projects\\GynTree\\.gitignore", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\unittest.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\operations\\build\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\operations\\__init__.py", - "G:\\Projects\\GynTree\\src\\services\\__pycache__\\DirectoryAnalyzer.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\locations\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\vcs\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\assertion\\__pycache__\\util.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\resolution\\resolvelib\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\jsonschema\\benchmarks\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\assertion\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\warning_types.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\resolution\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\pygments\\lexers\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\attrs\\__init__.py", - "G:\\Projects\\GynTree\\.pytest_cache\\.gitignore", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\cachecontrol\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pyasn1\\compat\\__init__.py", - "G:\\Projects\\GynTree\\src\\services\\__pycache__\\ExclusionManagerService.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pyasn1\\codec\\native\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_io\\__pycache__\\pprint.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\pytester.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\capture.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\junitxml.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\metadata\\importlib\\__init__.py", - "G:\\Projects\\GynTree\\src\\services\\auto_exclude\\__pycache__\\DatabaseAutoExclude.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\components\\UI\\__pycache__\\ProjectUI.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\components\\UI\\__pycache__\\DirectoryTreeUI.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\pyproject_hooks\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\referencing\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\rsa\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pyasn1\\codec\\cer\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_code\\__pycache__\\source.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\_io\\__pycache__\\wcwidth.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\fixtures.cpython-312.pyc", - "G:\\Projects\\GynTree\\src\\services\\auto_exclude\\__pycache__\\NextJsNodeJsAutoExclude.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\main.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\_pytest\\__pycache__\\tmpdir.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\commands\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\urllib3\\__init__.py", - "G:\\Projects\\GynTree\\src\\components\\UI\\__pycache__\\AutoExcludeUI.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\urllib3\\contrib\\_securetransport\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\referencing\\tests\\__init__.py", - "G:\\Projects\\GynTree\\src\\controllers\\__pycache__\\AppController.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_vendor\\__init__.py", - "G:\\Projects\\GynTree\\src\\services\\auto_exclude\\__pycache__\\AutoExcludeManager.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pytest\\__pycache__\\__init__.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\jsonschema\\__init__.py", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pip\\_internal\\metadata\\__init__.py", - "G:\\Projects\\GynTree\\src\\components\\UI\\__pycache__\\ExclusionsManager.cpython-312.pyc", - "G:\\Projects\\GynTree\\venv\\Lib\\site-packages\\pyasn1\\type\\__init__.py" - ] -} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index c4a2515..eda7491 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,39 +1,29 @@ -aiohttp==3.9.5 -aiosignal==1.3.1 -attrs==23.2.0 -awscli==1.34.10 -botocore==1.35.10 -certifi==2024.6.2 -charset-normalizer==3.3.2 +altgraph==0.17.4 +attrs==24.2.0 colorama==0.4.6 -docutils==0.16 -frozenlist==1.4.1 -idna==3.7 +execnet==2.1.1 iniconfig==2.0.0 -jmespath==1.0.1 -keyboard==0.13.5 -MouseInfo==0.1.3 -multidict==6.0.5 -packaging==24.0 +Jinja2==3.1.4 +jsonschema==4.23.0 +jsonschema-specifications==2023.12.1 +MarkupSafe==2.1.5 +packaging==24.1 +pefile==2024.8.26 pluggy==1.5.0 -psycopg2-binary==2.9.9 -pyasn1==0.6.0 -PyAutoGUI==0.9.54 -PyGetWindow==0.0.9 -PyMsgBox==1.0.9 -pyperclip==1.8.2 -PyRect==0.2.0 -PyScreeze==0.1.30 -pytest==8.2.2 -python-dateutil==2.9.0.post0 -python-dotenv==1.0.1 -pytweening==1.2.0 -PyYAML==6.0.2 -requests==2.32.3 -rsa==4.7.2 -s3transfer==0.10.2 -six==1.16.0 -tabulate==0.9.0 -urllib3==2.2.1 -uuid==1.30 -yarl==1.9.4 +psutil==6.0.0 +pyasn1==0.6.1 +pyinstaller==6.10.0 +pyinstaller-hooks-contrib==2024.8 +PyQt5==5.15.11 +PyQt5-Qt5==5.15.2 +PyQt5_sip==12.15.0 +pytest==8.3.3 +pytest-html==4.1.1 +pytest-metadata==3.1.1 +pytest-mock==3.14.0 +pytest-xdist==3.6.1 +pywin32-ctypes==0.2.3 +referencing==0.35.1 +rpds-py==0.20.0 +rsa==4.9 +setuptools==75.1.0 diff --git a/src/App.py b/src/App.py index c83fee2..c8be246 100644 --- a/src/App.py +++ b/src/App.py @@ -1,20 +1,37 @@ -""" -GynTree: This is the main entry point for the GynTree application. -It initializes the core components and starts the user interface. -The App module orchestrates the overall flow of the application, -connecting various components and services. +""" + GynTree: Main entry point for the GynTree application. Initializes core components and starts the user interface. + The app module orchestrates the overall flow of the application, connecting various components and services. """ +import sys +import logging from PyQt5.QtWidgets import QApplication from controllers.AppController import AppController +from utilities.error_handler import ErrorHandler + +logging.basicConfig(level=logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) def main(): - app = QApplication([]) + # Set global exception handling + sys.excepthook = ErrorHandler.global_exception_handler + app = QApplication(sys.argv) controller = AppController() + + # Connect cleanup method to be called on application quit + app.aboutToQuit.connect(controller.cleanup) + + # Start the application controller.run() - app.exec_() + # Start the event loop + sys.exit(app.exec_()) -if __name__ == '__main__': - main() +if __name__ == "__main__": + try: + main() + except Exception as e: + logger.critical(f"Fatal error in main: {str(e)}", exc_info=True) + sys.exit(1) diff --git a/src/components/UI/AutoExcludeUI.py b/src/components/UI/AutoExcludeUI.py index a107e2e..ada88d9 100644 --- a/src/components/UI/AutoExcludeUI.py +++ b/src/components/UI/AutoExcludeUI.py @@ -1,115 +1,110 @@ -# GynTree: Defines the user interface for managing automatic file and directory exclusions. - -from PyQt5.QtWidgets import (QMainWindow, QVBoxLayout, QLabel, QCheckBox, QPushButton, - QScrollArea, QWidget, QHBoxLayout, QTextEdit) -from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QMainWindow, QVBoxLayout, QLabel, QPushButton, QScrollArea, QWidget, QTreeWidget, QTreeWidgetItem, QMessageBox, QHeaderView, QHBoxLayout +from PyQt5.QtCore import Qt, QSize from PyQt5.QtGui import QIcon, QFont import os from utilities.resource_path import get_resource_path class AutoExcludeUI(QMainWindow): - def __init__(self, auto_exclude_manager, settings_manager, formatted_recommendations): + def __init__(self, auto_exclude_manager, settings_manager, formatted_recommendations, project_context): super().__init__() self.auto_exclude_manager = auto_exclude_manager self.settings_manager = settings_manager self.formatted_recommendations = formatted_recommendations - self.checkboxes = {'directories': {}, 'files': {}} - self.init_ui() - - def init_ui(self): + self.project_context = project_context + self.folder_icon = QIcon(get_resource_path("assets/images/folder_icon.png")) + self.file_icon = QIcon(get_resource_path("assets/images/file_icon.png")) self.setWindowTitle('Auto-Exclude Recommendations') - self.setWindowIcon(QIcon(get_resource_path('assets/images/GynTree_logo 64X64.ico'))) - self.setStyleSheet(""" + self.setWindowIcon(QIcon(get_resource_path('assets/images/gyntree_logo 64x64.ico'))) + self.setStyleSheet(""" QMainWindow { background-color: #f0f0f0; } - QLabel { font-size: 16px; color: #333; margin-bottom: 10px; } - QCheckBox { font-size: 14px; color: #555; padding: 2px 0; } - QCheckBox::indicator { width: 18px; height: 18px; } - QPushButton { background-color: #4CAF50; color: white; padding: 10px 20px; - font-size: 16px; margin: 4px 2px; border-radius: 8px; } + QLabel { font-size: 20px; color: #333; margin-bottom: 10px; } + QPushButton { background-color: #4caf50; color: white; padding: 8px 16px; font-size: 14px; margin: 4px 2px; border-radius: 6px; } QPushButton:hover { background-color: #45a049; } - QTextEdit { font-size: 14px; color: #333; background-color: #fff; border: 1px solid #ddd; } + QTreeWidget { font-size: 14px; color: #333; background-color: #fff; border: 1px solid #ddd; } """) + self.init_ui() - main_layout = QVBoxLayout() - title = QLabel('Auto-Exclude Recommendations') - title.setFont(QFont('Arial', 18, QFont.Bold)) - main_layout.addWidget(title) - - recommendations_text = QTextEdit() - recommendations_text.setPlainText(self.formatted_recommendations) - recommendations_text.setReadOnly(True) - recommendations_text.setFixedHeight(200) - main_layout.addWidget(recommendations_text) - - scroll_area = QScrollArea() - scroll_area.setWidgetResizable(True) - scroll_widget = QWidget() - scroll_layout = QVBoxLayout(scroll_widget) - - current_settings = { - 'excluded_dirs': self.settings_manager.get_excluded_dirs(), - 'excluded_files': self.settings_manager.get_excluded_files() - } - recommendations = self.auto_exclude_manager.get_grouped_recommendations(self.settings_manager.settings) - - for exclusion_type in ['directories', 'files']: - if recommendations[exclusion_type]: - group_widget = QWidget() - group_layout = QVBoxLayout(group_widget) - group_checkbox = QCheckBox(exclusion_type.capitalize()) - group_checkbox.setFont(QFont('Arial', 14, QFont.Bold)) - group_checkbox.setChecked(True) - group_checkbox.stateChanged.connect(lambda state, g=exclusion_type: self.toggle_group(state, g)) - self.checkboxes[exclusion_type]['group'] = group_checkbox - group_layout.addWidget(group_checkbox) - - for item in recommendations[exclusion_type]: - item_checkbox = QCheckBox(os.path.basename(item)) - item_checkbox.setChecked(True) - item_checkbox.setStyleSheet("margin-left: 20px;") - self.checkboxes[exclusion_type][item] = item_checkbox - group_layout.addWidget(item_checkbox) - scroll_layout.addWidget(group_widget) - - scroll_area.setWidget(scroll_widget) - main_layout.addWidget(scroll_area) - - apply_button = QPushButton('Apply') + def init_ui(self): + central_widget = QWidget() + main_layout = QVBoxLayout(central_widget) + main_layout.setContentsMargins(10, 10, 10, 10) + main_layout.setSpacing(10) + + header_layout = QHBoxLayout() + title_label = QLabel('Auto-Exclude Recommendations', font=QFont('Arial', 16, QFont.Bold)) + header_layout.addWidget(title_label) + collapse_btn = QPushButton('Collapse All') + expand_btn = QPushButton('Expand All') + header_layout.addWidget(collapse_btn) + header_layout.addWidget(expand_btn) + main_layout.addLayout(header_layout) + + self.tree_widget = QTreeWidget() + self.tree_widget.setHeaderLabels(['Name', 'Type']) + self.tree_widget.setColumnWidth(0, 300) + self.tree_widget.setAlternatingRowColors(True) + self.tree_widget.setIconSize(QSize(20, 20)) + self.tree_widget.header().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.tree_widget.header().setSectionResizeMode(1, QHeaderView.ResizeToContents) + main_layout.addWidget(self.tree_widget) + + self.populate_tree() + + collapse_btn.clicked.connect(self.tree_widget.collapseAll) + expand_btn.clicked.connect(self.tree_widget.expandAll) + + apply_button = QPushButton('Apply Exclusions') apply_button.clicked.connect(self.apply_exclusions) main_layout.addWidget(apply_button, alignment=Qt.AlignCenter) - central_widget = QWidget() - central_widget.setLayout(main_layout) self.setCentralWidget(central_widget) - self.setGeometry(300, 300, 400, 600) + self.setGeometry(300, 150, 800, 600) + + def populate_tree(self): + """Populates the tree with merged exclusions from both AutoExcludeManager and project folder.""" + self.tree_widget.clear() + root = self.tree_widget.invisibleRootItem() - def toggle_group(self, state, group): - is_checked = (state == Qt.Checked) - for item, checkbox in self.checkboxes[group].items(): - if item != 'group': - checkbox.setChecked(is_checked) + combined_exclusions = self.get_combined_exclusions() + + categories = ['root_exclusions', 'excluded_dirs', 'excluded_files'] + for category in categories: + category_item = QTreeWidgetItem(root, [category.replace('_', ' ').title(), '']) + category_item.setFlags(category_item.flags() & ~Qt.ItemIsUserCheckable) + + for path in sorted(combined_exclusions.get(category, [])): + item = QTreeWidgetItem(category_item, [path, category[:-1]]) + item.setIcon(0, self.folder_icon if os.path.isdir(path) else self.file_icon) + if category != 'root_exclusions': + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(0, Qt.Checked) + + self.tree_widget.expandAll() + + def get_combined_exclusions(self): + """Retrieve exclusions from AutoExcludeManager and merge with project exclusions.""" + manager_recommendations = self.auto_exclude_manager.get_recommendations() + + root_exclusions = set(self.project_context.settings_manager.get_root_exclusions()) + excluded_dirs = set(self.project_context.settings_manager.get_excluded_dirs()) + excluded_files = set(self.project_context.settings_manager.get_excluded_files()) + + combined_exclusions = { + 'root_exclusions': manager_recommendations.get('root_exclusions', set()) | root_exclusions, + 'excluded_dirs': manager_recommendations.get('excluded_dirs', set()) | excluded_dirs, + 'excluded_files': manager_recommendations.get('excluded_files', set()) | excluded_files + } + + return combined_exclusions def apply_exclusions(self): - current_settings = self.settings_manager.settings - excluded_dirs = set(current_settings.get('excluded_dirs', [])) - excluded_files = set(current_settings.get('excluded_files', [])) - - for exclusion_type, items in self.checkboxes.items(): - for item, checkbox in items.items(): - if checkbox.isChecked() and item != 'group': - if exclusion_type == 'directories': - excluded_dirs.add(item) - else: - excluded_files.add(item) - - self.settings_manager.update_settings({ - 'excluded_dirs': list(excluded_dirs), - 'excluded_files': list(excluded_files) - }) + self.auto_exclude_manager.apply_recommendations() + QMessageBox.information(self, "Exclusions Updated", "Exclusions have been successfully updated.") self.close() def update_recommendations(self, formatted_recommendations): self.formatted_recommendations = formatted_recommendations - recommendations_text = self.findChild(QTextEdit) - if recommendations_text: - recommendations_text.setPlainText(self.formatted_recommendations) \ No newline at end of file + self.populate_tree() + + def closeEvent(self, event): + super().closeEvent(event) diff --git a/src/components/UI/DashboardUI.py b/src/components/UI/DashboardUI.py index da702e5..32d0a73 100644 --- a/src/components/UI/DashboardUI.py +++ b/src/components/UI/DashboardUI.py @@ -1,55 +1,43 @@ -# GynTree: This file defines the DashboardUI class, which serves as the main interface for the GynTree application. - -from PyQt5.QtWidgets import (QMainWindow, QPushButton, QVBoxLayout, QWidget, QLabel, - QStatusBar, QHBoxLayout) +import os +from PyQt5.QtWidgets import QMainWindow, QPushButton, QVBoxLayout, QWidget, QLabel, QStatusBar, QHBoxLayout from PyQt5.QtGui import QIcon, QFont, QPixmap from PyQt5.QtCore import Qt -from components.UI.AutoExcludeUI import AutoExcludeUI from components.UI.ProjectUI import ProjectUI -from components.UI.ExclusionsManagerUI import ExclusionsManager +from components.UI.AutoExcludeUI import AutoExcludeUI from components.UI.ResultUI import ResultUI from components.UI.DirectoryTreeUI import DirectoryTreeUI -import os +from components.UI.ExclusionsManagerUI import ExclusionsManagerUI from utilities.resource_path import get_resource_path +import logging + +logger = logging.getLogger(__name__) class DashboardUI(QMainWindow): def __init__(self, controller): super().__init__() self.controller = controller - self.auto_exclude_ui_instance = None - self.exclusions_manager_instance = None - self.result_ui_instance = None - self.directory_tree_instance = None + self.project_ui = None self.initUI() def initUI(self): self.setWindowTitle('GynTree Dashboard') - self.setWindowIcon(QIcon(get_resource_path('assets/images/GynTree_logo 64X64.ico'))) + self.setWindowIcon(QIcon(get_resource_path('assets/images/gyntree_logo 64x64.ico'))) self.setStyleSheet(""" - QMainWindow { - background-color: #f0f0f0; - } - QLabel { - color: #333; - } - QPushButton { - background-color: #4CAF50; - color: white; - border: none; - padding: 15px 32px; - text-align: center; - text-decoration: none; - font-size: 16px; - margin: 4px 2px; - border-radius: 8px; - } - QPushButton:hover { - background-color: #45a049; - } - QStatusBar { - background-color: #333; - color: white; + QMainWindow { background-color: #f0f0f0; } + QLabel { color: #333; } + QPushButton { + background-color: #4CAF50; + color: white; + border: none; + padding: 15px 32px; + text-align: center; + text-decoration: none; + font-size: 16px; + margin: 4px 2px; + border-radius: 8px; } + QPushButton:hover { background-color: #45a049; } + QStatusBar { background-color: #333; color: white; } """) central_widget = QWidget(self) @@ -59,15 +47,16 @@ def initUI(self): main_layout.setSpacing(20) logo_label = QLabel() - logo_path = get_resource_path('assets/images/GynTree_logo.png') + logo_path = get_resource_path('assets/images/gyntree_logo.png') if os.path.exists(logo_path): logo_pixmap = QPixmap(logo_path) logo_label.setPixmap(logo_pixmap.scaled(64, 64, Qt.KeepAspectRatio, Qt.SmoothTransformation)) else: - print(f"Warning: Logo file not found at {logo_path}") + logger.warning(f"Logo file not found at {logo_path}") + welcome_label = QLabel('Welcome to GynTree!') welcome_label.setFont(QFont('Arial', 24, QFont.Bold)) - + header_layout = QHBoxLayout() header_layout.addWidget(logo_label) header_layout.addWidget(welcome_label) @@ -80,21 +69,21 @@ def initUI(self): self.analyze_directory_btn = self.create_styled_button('Analyze Directory') self.view_directory_tree_btn = self.create_styled_button('View Directory Tree') - for btn in [self.create_project_btn, self.load_project_btn, self.manage_exclusions_btn, + for btn in [self.create_project_btn, self.load_project_btn, self.manage_exclusions_btn, self.analyze_directory_btn, self.view_directory_tree_btn]: main_layout.addWidget(btn) - self.status_bar = QStatusBar(self) - self.setStatusBar(self.status_bar) - self.status_bar.showMessage("Ready") - - self.create_project_btn.clicked.connect(self.controller.create_project) - self.load_project_btn.clicked.connect(self.controller.load_project) + self.create_project_btn.clicked.connect(self.controller.create_project_action) + self.load_project_btn.clicked.connect(self.controller.load_project_action) self.manage_exclusions_btn.clicked.connect(self.controller.manage_exclusions) self.analyze_directory_btn.clicked.connect(self.controller.analyze_directory) self.view_directory_tree_btn.clicked.connect(self.controller.view_directory_tree) - self.setGeometry(300, 300, 600, 500) + self.status_bar = QStatusBar(self) + self.setStatusBar(self.status_bar) + self.status_bar.showMessage("Ready") + + self.setGeometry(300, 300, 800, 600) def create_styled_button(self, text): btn = QPushButton(text) @@ -104,34 +93,52 @@ def create_styled_button(self, text): def show_dashboard(self): self.show() + def show_project_ui(self): + self.project_ui = ProjectUI(self.controller) + self.project_ui.project_created.connect(self.controller.on_project_created) + self.project_ui.project_loaded.connect(self.controller.on_project_loaded) + self.project_ui.show() + return self.project_ui + def update_project_info(self, project): self.setWindowTitle(f"GynTree - {project.name}") - self.status_bar.showMessage(f"Current Project: {project.name}, Start Directory: {project.start_directory}") + self.status_bar.showMessage(f"Current project: {project.name}, Start directory: {project.start_directory}") - def show_project_ui(self): - project_ui = ProjectUI() - project_ui.show() - return project_ui - - def show_result(self, result): - if not self.result_ui_instance: - self.result_ui_instance = ResultUI() - self.result_ui_instance.update_result(result) - self.result_ui_instance.show() - - def show_auto_exclude_ui(self, auto_exclude_manager, settings_manager, formatted_recommendations): - if not self.auto_exclude_ui_instance: - self.auto_exclude_ui_instance = AutoExcludeUI(auto_exclude_manager, settings_manager, formatted_recommendations) - else: - self.auto_exclude_ui_instance.update_recommendations(formatted_recommendations) - self.auto_exclude_ui_instance.show() + def show_auto_exclude_ui(self, auto_exclude_manager, settings_manager, formatted_recommendations, project_context): + auto_exclude_ui = AutoExcludeUI(auto_exclude_manager, settings_manager, formatted_recommendations, project_context) + auto_exclude_ui.show() + return auto_exclude_ui + + def show_result(self, directory_analyzer): + result_ui = ResultUI(directory_analyzer) + result_ui.show() + return result_ui def manage_exclusions(self, settings_manager): - if not self.exclusions_manager_instance: - self.exclusions_manager_instance = ExclusionsManager(settings_manager) - self.exclusions_manager_instance.show() - - def view_directory_tree(self, directory_analyzer): - if not self.directory_tree_instance: - self.directory_tree_instance = DirectoryTreeUI(directory_analyzer) - self.directory_tree_instance.show() \ No newline at end of file + exclusions_ui = ExclusionsManagerUI(settings_manager) + exclusions_ui.show() + return exclusions_ui + + def view_directory_tree(self, result): + tree_ui = DirectoryTreeUI(result) + tree_ui.show() + return tree_ui + + def clear_directory_tree(self): + if hasattr(self, 'directory_tree_view'): + self.directory_tree_view.clear() + logger.debug("Directory tree cleared") + + def clear_analysis(self): + if hasattr(self, 'analysis_result_view'): + self.analysis_result_view.clear() + logger.debug("Analysis results cleared") + + def clear_exclusions(self): + if hasattr(self, 'exclusions_list_view'): + self.exclusions_list_view.clear() + logger.debug("Exclusions list cleared") + + def show_error_message(self, title, message): + from PyQt5.QtWidgets import QMessageBox + QMessageBox.critical(self, title, message) \ No newline at end of file diff --git a/src/components/UI/DirectoryTreeUI.py b/src/components/UI/DirectoryTreeUI.py index 3422308..7dc4b60 100644 --- a/src/components/UI/DirectoryTreeUI.py +++ b/src/components/UI/DirectoryTreeUI.py @@ -4,7 +4,8 @@ explore the structure visually. The class also provides options for collapsing/expanding nodes and exporting the tree view in different formats. """ -from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QLabel, QTreeWidget, QPushButton, QHBoxLayout, QTreeWidgetItem, QHeaderView) +from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QLabel, QTreeWidget, QPushButton, + QHBoxLayout, QTreeWidgetItem, QHeaderView) from PyQt5.QtGui import QFont, QIcon from PyQt5.QtCore import Qt, QSize from components.TreeExporter import TreeExporter diff --git a/src/components/UI/ExclusionsManagerUI.py b/src/components/UI/ExclusionsManagerUI.py index 761ec2a..ecf8a7e 100644 --- a/src/components/UI/ExclusionsManagerUI.py +++ b/src/components/UI/ExclusionsManagerUI.py @@ -1,40 +1,38 @@ -# GynTree: Provides a UI for managing user-defined file and directory exclusions. - -from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QLabel, QTreeWidget, QTreeWidgetItem, QPushButton, - QHBoxLayout, QFileDialog, QFrame, QSplitter, QTextEdit) +from PyQt5.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QTreeWidget, QTreeWidgetItem, QPushButton, QHBoxLayout, + QFileDialog, QMessageBox, QGroupBox, QHeaderView, QSplitter +) from PyQt5.QtGui import QIcon, QFont from PyQt5.QtCore import Qt -from services.ExclusionManagerService import ExclusionManagerService +from services.SettingsManager import SettingsManager from utilities.resource_path import get_resource_path +import os -class ExclusionsManager(QWidget): - def __init__(self, settings_manager): +class ExclusionsManagerUI(QWidget): + def __init__(self, settings_manager: SettingsManager): super().__init__() - self.exclusion_manager_service = ExclusionManagerService(settings_manager) - self.init_ui() - - def init_ui(self): + self.settings_manager = settings_manager self.setWindowTitle('Exclusions Manager') - self.setWindowIcon(QIcon(get_resource_path('assets/images/GynTree_logo 64X64.ico'))) + self.setWindowIcon(QIcon(get_resource_path('assets/images/gyntree_logo 64x64.ico'))) self.setStyleSheet(""" QWidget { background-color: #f0f0f0; color: #333; } QLabel { - font-size: 16px; + font-size: 18px; color: #333; } QPushButton { background-color: #4CAF50; color: white; border: none; - padding: 8px 16px; + padding: 10px 20px; text-align: center; text-decoration: none; font-size: 14px; margin: 4px 2px; - border-radius: 6px; + border-radius: 8px; } QPushButton:hover { background-color: #45a049; @@ -44,40 +42,44 @@ def init_ui(self): border-radius: 4px; font-size: 14px; } - QTextEdit { - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; - } """) + self.init_ui() + def init_ui(self): layout = QVBoxLayout() layout.setContentsMargins(20, 20, 20, 20) layout.setSpacing(15) title = QLabel('Manage Exclusions') title.setFont(QFont('Arial', 20, QFont.Bold)) - layout.addWidget(title, alignment=Qt.AlignCenter) - - splitter = QSplitter(Qt.Horizontal) - - # Aggregated view - aggregated_frame = QFrame() - aggregated_layout = QVBoxLayout(aggregated_frame) - aggregated_layout.addWidget(QLabel('Aggregated Exclusions')) - self.aggregated_text = QTextEdit() - self.aggregated_text.setReadOnly(True) - aggregated_layout.addWidget(self.aggregated_text) - splitter.addWidget(aggregated_frame) - - # Detailed view - detailed_frame = QFrame() - detailed_layout = QVBoxLayout(detailed_frame) - detailed_layout.addWidget(QLabel('Detailed Exclusions')) + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + # Splitter for Root Exclusions and Detailed Exclusions + splitter = QSplitter(Qt.Vertical) + + # Root Exclusions (Read-Only) + root_group = QGroupBox("Root Exclusions (Non-Editable)") + root_layout = QVBoxLayout() + root_tree = QTreeWidget() + root_tree.setHeaderLabels(["Excluded Paths"]) + root_tree.header().setSectionResizeMode(0, QHeaderView.Stretch) + root_exclusions = self.settings_manager.get_root_exclusions() + self.populate_root_exclusions(root_tree, root_exclusions) + root_layout.addWidget(root_tree) + root_group.setLayout(root_layout) + splitter.addWidget(root_group) + + # Detailed Exclusions (Editable) + detailed_group = QGroupBox("Detailed Exclusions") + detailed_layout = QVBoxLayout() self.exclusion_tree = QTreeWidget() self.exclusion_tree.setHeaderLabels(['Type', 'Path']) + self.exclusion_tree.header().setSectionResizeMode(0, QHeaderView.ResizeToContents) + self.exclusion_tree.header().setSectionResizeMode(1, QHeaderView.Stretch) detailed_layout.addWidget(self.exclusion_tree) - splitter.addWidget(detailed_frame) + detailed_group.setLayout(detailed_layout) + splitter.addWidget(detailed_group) layout.addWidget(splitter) @@ -85,9 +87,6 @@ def init_ui(self): add_dir_button = QPushButton('Add Directory') add_file_button = QPushButton('Add File') remove_button = QPushButton('Remove Selected') - add_dir_button.clicked.connect(self.add_directory) - add_file_button.clicked.connect(self.add_file) - remove_button.clicked.connect(self.remove_selected) buttons_layout.addWidget(add_dir_button) buttons_layout.addWidget(add_file_button) buttons_layout.addWidget(remove_button) @@ -100,39 +99,85 @@ def init_ui(self): self.setLayout(layout) self.setGeometry(400, 300, 800, 600) - self.update_exclusions_view() + add_dir_button.clicked.connect(self.add_directory) + add_file_button.clicked.connect(self.add_file) + remove_button.clicked.connect(self.remove_selected) + + self.populate_exclusion_tree() - def update_exclusions_view(self): - self.aggregated_text.setText(self.exclusion_manager_service.get_aggregated_exclusions()) + def populate_root_exclusions(self, tree, exclusions): + for path in sorted(exclusions): + item = QTreeWidgetItem(tree, [path]) + item.setFlags(item.flags() & ~Qt.ItemIsSelectable & ~Qt.ItemIsEditable) + tree.expandAll() + def populate_exclusion_tree(self): self.exclusion_tree.clear() - detailed_exclusions = self.exclusion_manager_service.get_detailed_exclusions() - dir_item = QTreeWidgetItem(self.exclusion_tree, ['Directories']) - file_item = QTreeWidgetItem(self.exclusion_tree, ['Files']) - for directory in detailed_exclusions['directories']: - QTreeWidgetItem(dir_item, ['', directory]) - for file in detailed_exclusions['files']: - QTreeWidgetItem(file_item, ['', file]) + exclusions = self.settings_manager.get_all_exclusions() + + dirs_item = QTreeWidgetItem(self.exclusion_tree, ['excluded_dirs']) + dirs_item.setFlags(dirs_item.flags() & ~Qt.ItemIsSelectable) + for directory in sorted(exclusions.get('excluded_dirs', [])): + item = QTreeWidgetItem(dirs_item, ['Directory', directory]) + item.setFlags(item.flags() | Qt.ItemIsSelectable | Qt.ItemIsEditable) + + files_item = QTreeWidgetItem(self.exclusion_tree, ['excluded_files']) + files_item.setFlags(files_item.flags() & ~Qt.ItemIsSelectable) + for file in sorted(exclusions.get('excluded_files', [])): + item = QTreeWidgetItem(files_item, ['File', file]) + item.setFlags(item.flags() | Qt.ItemIsSelectable | Qt.ItemIsEditable) + self.exclusion_tree.expandAll() def add_directory(self): - directory = QFileDialog.getExistingDirectory(self, 'Select directory to exclude') - if self.exclusion_manager_service.add_directory(directory): - self.update_exclusions_view() + directory = QFileDialog.getExistingDirectory(self, 'Select Directory to Exclude') + if directory: + relative_directory = os.path.relpath(directory, self.settings_manager.project.start_directory) + exclusions = self.settings_manager.get_all_exclusions() + if relative_directory in exclusions['excluded_dirs'] or relative_directory in exclusions['root_exclusions']: + QMessageBox.warning(self, "Duplicate Entry", f"The directory '{relative_directory}' is already excluded.") + return + exclusions['excluded_dirs'].add(relative_directory) + self.settings_manager.update_settings({'excluded_dirs': list(exclusions['excluded_dirs'])}) + self.populate_exclusion_tree() def add_file(self): - file, _ = QFileDialog.getOpenFileName(self, 'Select file to exclude') - if self.exclusion_manager_service.add_file(file): - self.update_exclusions_view() + file, _ = QFileDialog.getOpenFileName(self, 'Select File to Exclude') + if file: + relative_file = os.path.relpath(file, self.settings_manager.project.start_directory) + exclusions = self.settings_manager.get_all_exclusions() + if relative_file in exclusions['excluded_files'] or any(relative_file.startswith(root_dir) for root_dir in exclusions['root_exclusions']): + QMessageBox.warning(self, "Duplicate Entry", f"The file '{relative_file}' is already excluded or within a root exclusion.") + return + exclusions['excluded_files'].add(relative_file) + self.settings_manager.update_settings({'excluded_files': list(exclusions['excluded_files'])}) + self.populate_exclusion_tree() def remove_selected(self): selected_items = self.exclusion_tree.selectedItems() + if not selected_items: + QMessageBox.information(self, "No Selection", "Please select an exclusion to remove.") + return for item in selected_items: - if item.parent(): + parent = item.parent() + if parent: path = item.text(1) - if self.exclusion_manager_service.remove_exclusion(path): - self.update_exclusions_view() + category = parent.text(0) + exclusions = self.settings_manager.get_all_exclusions() + if category == 'excluded_dirs': + exclusions['excluded_dirs'].discard(path) + elif category == 'excluded_files': + exclusions['excluded_files'].discard(path) + self.settings_manager.update_settings({ + 'excluded_dirs': list(exclusions['excluded_dirs']), + 'excluded_files': list(exclusions['excluded_files']) + }) + self.populate_exclusion_tree() def save_and_exit(self): - self.exclusion_manager_service.save_exclusions() - self.close() \ No newline at end of file + self.settings_manager.save_settings() + QMessageBox.information(self, "Exclusions Saved", "Exclusions have been successfully saved.") + self.close() + + def closeEvent(self, event): + super().closeEvent(event) \ No newline at end of file diff --git a/src/components/UI/ProjectUI.py b/src/components/UI/ProjectUI.py index fc097d2..d7d6b01 100644 --- a/src/components/UI/ProjectUI.py +++ b/src/components/UI/ProjectUI.py @@ -1,62 +1,46 @@ -# GynTree: Defines the interface for creating and managing GynTree projects. - from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QLabel, QLineEdit, QPushButton, - QFileDialog, QListWidget, QHBoxLayout, QFrame) + QFileDialog, QListWidget, QHBoxLayout, QFrame, QMessageBox) from PyQt5.QtGui import QIcon, QFont -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, pyqtSignal from models.Project import Project -from services.ProjectManager import ProjectManager from utilities.resource_path import get_resource_path class ProjectUI(QWidget): - def __init__(self): + project_created = pyqtSignal(object) + project_loaded = pyqtSignal(object) + + def __init__(self, controller): super().__init__() - self.project_manager = ProjectManager() + self.controller = controller self.init_ui() def init_ui(self): self.setWindowTitle('Project Manager') - self.setWindowIcon(QIcon(get_resource_path('assets/images/GynTree_logo 64X64.ico'))) + self.setWindowIcon(QIcon(get_resource_path('assets/images/gyntree_logo 64x64.ico'))) self.setStyleSheet(""" - QWidget { - background-color: #f0f0f0; - color: #333; - } - QLabel { - font-size: 16px; - color: #333; - } - QLineEdit { - padding: 8px; - font-size: 14px; - border: 1px solid #ddd; - border-radius: 4px; - } - QPushButton { - background-color: #4CAF50; - color: white; - border: none; - padding: 10px 20px; - text-align: center; - text-decoration: none; - font-size: 14px; - margin: 4px 2px; - border-radius: 8px; - } - QPushButton:hover { - background-color: #45a049; - } - QListWidget { - border: 1px solid #ddd; - border-radius: 4px; - font-size: 14px; + QWidget { background-color: #f0f0f0; color: #333; } + QLabel { font-size: 16px; color: #333; } + QLineEdit { padding: 8px; font-size: 14px; border: 1px solid #ddd; border-radius: 4px; } + QPushButton { + background-color: #4CAF50; + color: white; + border: none; + padding: 10px 20px; + text-align: center; + text-decoration: none; + font-size: 14px; + margin: 4px 2px; + border-radius: 8px; } + QPushButton:hover { background-color: #45a049; } + QListWidget { border: 1px solid #ddd; border-radius: 4px; font-size: 14px; } """) layout = QVBoxLayout() layout.setContentsMargins(20, 20, 20, 20) layout.setSpacing(15) + # Create Project Section create_section = QFrame() create_section.setFrameShape(QFrame.StyledPanel) create_layout = QVBoxLayout(create_section) @@ -78,10 +62,12 @@ def init_ui(self): create_layout.addLayout(dir_layout) self.create_project_btn = QPushButton('Create Project') + self.create_project_btn.clicked.connect(self.create_project) create_layout.addWidget(self.create_project_btn) layout.addWidget(create_section) + # Load Project Section load_section = QFrame() load_section.setFrameShape(QFrame.StyledPanel) load_layout = QVBoxLayout(load_section) @@ -91,10 +77,11 @@ def init_ui(self): load_layout.addWidget(load_title) self.project_list = QListWidget() - self.project_list.addItems(self.project_manager.list_projects()) + self.project_list.addItems(self.controller.project_controller.project_manager.list_projects()) load_layout.addWidget(self.project_list) self.load_project_btn = QPushButton('Load Project') + self.load_project_btn.clicked.connect(self.load_project) load_layout.addWidget(self.load_project_btn) layout.addWidget(load_section) @@ -103,7 +90,7 @@ def init_ui(self): self.setGeometry(300, 300, 500, 600) def select_directory(self): - directory = QFileDialog.getExistingDirectory(self, 'Select Start Directory') + directory = QFileDialog.getExistingDirectory(self, "Select Start Directory") if directory: self.start_dir_label.setText(directory) @@ -111,18 +98,27 @@ def create_project(self): project_name = self.project_name_input.text() start_directory = self.start_dir_label.text() if project_name and start_directory != 'No directory selected': - project = Project(name=project_name, start_directory=start_directory) - self.project_manager.save_project(project) - self.project_list.addItem(project_name) + new_project = Project(name=project_name, start_directory=start_directory) + self.project_created.emit(new_project) self.project_name_input.clear() self.start_dir_label.setText('No directory selected') - return project - return None + self.close() + else: + QMessageBox.warning(self, "Invalid Input", "Please provide a project name and select a start directory.") def load_project(self): selected_items = self.project_list.selectedItems() if selected_items: project_name = selected_items[0].text() - project = self.project_manager.load_project(project_name) - return project - return None \ No newline at end of file + self.project_loaded.emit(Project(name=project_name, start_directory="")) + self.close() + else: + QMessageBox.warning(self, "No Selection", "Please select a project to load.") + + def get_selected_project_name(self): + selected_items = self.project_list.selectedItems() + if selected_items: + return selected_items[0].text() + else: + QMessageBox.warning(self, "No Selection", "Please select a project to load.") + return None \ No newline at end of file diff --git a/src/components/UI/ResultUI.py b/src/components/UI/ResultUI.py index 1a62817..319a9c5 100644 --- a/src/components/UI/ResultUI.py +++ b/src/components/UI/ResultUI.py @@ -1,22 +1,15 @@ -""" -GynTree: This module defines the ResultUI class, which displays the directory analysis results. -It provides a user-friendly interface for viewing, copying, and exporting analysis data. -The ResultUI class handles the presentation of analysis results in a tabular format, -offering features like adjustable columns and various export options. -""" - -from PyQt5.QtWidgets import (QMainWindow, QVBoxLayout, QLabel, QPushButton, - QFileDialog, QWidget, QHBoxLayout, QTableWidget, - QTableWidgetItem, QHeaderView, QApplication, QSplitter, - QDesktopWidget) +from PyQt5.QtWidgets import (QMainWindow, QVBoxLayout, QLabel, QPushButton, QFileDialog, QWidget, + QHBoxLayout, QTableWidget, QTableWidgetItem, QHeaderView, QApplication, + QSplitter, QDesktopWidget) from PyQt5.QtGui import QIcon, QFont, QPalette, QColor from PyQt5.QtCore import Qt, QTimer import csv from utilities.resource_path import get_resource_path class ResultUI(QMainWindow): - def __init__(self): + def __init__(self, directory_analyzer): super().__init__() + self.directory_analyzer = directory_analyzer self.result_data = None self.init_ui() @@ -92,11 +85,11 @@ def init_ui(self): height = int(screen.height() * 0.8) self.setGeometry(int(screen.width() * 0.1), int(screen.height() * 0.1), width, height) - def update_result(self, result): - self.result_data = result - self.result_table.setRowCount(len(result)) + def update_result(self): + self.result_data = self.directory_analyzer.get_flat_structure() + self.result_table.setRowCount(len(self.result_data)) max_path_width = 0 - for row, item in enumerate(result): + for row, item in enumerate(self.result_data): path_item = QTableWidgetItem(item['path']) self.result_table.setItem(row, 0, path_item) self.result_table.setItem(row, 1, QTableWidgetItem(item['description'])) @@ -148,4 +141,7 @@ def save_file(self, file_type): def resizeEvent(self, event): super().resizeEvent(event) - self.adjust_column_widths() \ No newline at end of file + self.adjust_column_widths() + + def refresh_display(self): + self.update_result() \ No newline at end of file diff --git a/src/controllers/AppController.py b/src/controllers/AppController.py index aed2c02..7964791 100644 --- a/src/controllers/AppController.py +++ b/src/controllers/AppController.py @@ -1,86 +1,178 @@ """ -GynTree: This module contains the AppController class, which serves as the main controller. -It manages the flow between different components, handles user interactions, -and coordinates various operations like project creation, analysis, and result display. -The AppController acts as the central hub for application logic and user interface updates. +GynTree: AppController orchestrates the overall application workflow. +This controller ties together the other controllers (ProjectController, ThreadController, +UIController) to ensure smooth interaction between project management, thread handling, +and user interface updates. It acts as the main coordinator for the application. + +Responsibilities: +- Delegate tasks to specialized controllers (project, thread, UI). +- Handle project creation and loading. +- Manage the overall application lifecycle, including cleanup. """ -from PyQt5.QtWidgets import QApplication +import logging +from PyQt5.QtCore import QObject, pyqtSignal, QTimer +from PyQt5.QtWidgets import QMessageBox from components.UI.DashboardUI import DashboardUI -from services.DirectoryAnalyzer import DirectoryAnalyzer -from services.ProjectManager import ProjectManager -from services.SettingsManager import SettingsManager -from services.auto_exclude.AutoExcludeManager import AutoExcludeManager +from controllers.ProjectController import ProjectController +from controllers.ThreadController import ThreadController +from controllers.UIController import UIController +from utilities.error_handler import handle_exception +from utilities.logging_decorator import log_method + +logger = logging.getLogger(__name__) + +class AppController(QObject): + project_created = pyqtSignal(object) + project_loaded = pyqtSignal(object) -class AppController: def __init__(self): - self.project_manager = ProjectManager() - self.current_project = None + super().__init__() self.main_ui = DashboardUI(self) - self.project_ui = None - self.directory_analyzer = None - self.settings_manager = None + self.project_controller = ProjectController(self) + self.thread_controller = ThreadController() + self.ui_controller = UIController(self.main_ui) + self.ui_components = [] + + # Connect thread controller signals + self.thread_controller.worker_finished.connect(self._on_auto_exclude_finished) + self.thread_controller.worker_error.connect(self._on_auto_exclude_error) + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.cleanup() + return False + + @log_method def run(self): self.main_ui.show_dashboard() - def create_project(self): - self.project_ui = self.main_ui.show_project_ui() - self.project_ui.create_project_btn.clicked.connect(self.create_project_action) - - def create_project_action(self): - project = self.project_ui.create_project() - if project: - self.current_project = project - self.settings_manager = SettingsManager(self.current_project) - self.main_ui.update_project_info(self.current_project) - self.trigger_auto_exclude() - self.initialize_directory_analyzer() - self.project_ui.close() - - def load_project(self): - self.project_ui = self.main_ui.show_project_ui() - self.project_ui.load_project_btn.clicked.connect(self.load_project_action) - - def load_project_action(self): - project = self.project_ui.load_project() - if project: - self.current_project = project - self.settings_manager = SettingsManager(self.current_project) - self.main_ui.update_project_info(self.current_project) - self.initialize_directory_analyzer() - self.project_ui.close() + @handle_exception + @log_method + def cleanup(self): + logger.debug("Starting cleanup process in AppController") + + # Clean up thread controller + self.thread_controller.cleanup_thread() + + # Close project context + if self.project_controller and self.project_controller.project_context: + self.project_controller.project_context.close() + + # Clean up UI components + for ui in self.ui_components: + if ui and not ui.isHidden(): + logger.debug(f"Closing UI: {type(ui).__name__}") + ui.close() + ui.deleteLater() + + self.ui_components.clear() + logger.debug("Cleanup process in AppController completed") + + @handle_exception + @log_method + def create_project_action(self, *args): + project_ui = self.main_ui.show_project_ui() + project_ui.project_created.connect(self.on_project_created) + self.ui_components.append(project_ui) + + @handle_exception + @log_method + def on_project_created(self, project): + success = self.project_controller.create_project(project) + if success: + self.project_created.emit(project) + self.main_ui.update_project_info(project) + self.after_project_loaded() + else: + QMessageBox.critical(self.main_ui, "Error", "Failed to create project. Please try again.") + + @handle_exception + @log_method + def load_project_action(self, *args): + project_ui = self.main_ui.show_project_ui() + project_ui.project_loaded.connect(self.on_project_loaded) + self.ui_components.append(project_ui) + + @handle_exception + @log_method + def on_project_loaded(self, project): + loaded_project = self.project_controller.load_project(project.name) + if loaded_project: + self.project_loaded.emit(loaded_project) + self.main_ui.update_project_info(loaded_project) + self.after_project_loaded() + else: + QMessageBox.critical(self.main_ui, "Error", "Failed to load project. Please try again.") + + @handle_exception + @log_method + def after_project_loaded(self): + self.ui_controller.reset_ui() + if self.project_controller.project_context: + QTimer.singleShot(0, self._start_auto_exclude) + else: + logger.error("Project context not initialized. Cannot start auto-exclude thread.") + QMessageBox.warning(self.main_ui, "Warning", "Failed to initialize project context. Some features may not work correctly.") + + @handle_exception + @log_method + def _start_auto_exclude(self): + self.thread_controller.start_auto_exclude_thread(self.project_controller.project_context) + + @handle_exception + @log_method + def _on_auto_exclude_finished(self, formatted_recommendations): + if formatted_recommendations: + auto_exclude_ui = self.main_ui.show_auto_exclude_ui( + self.project_controller.project_context.auto_exclude_manager, + self.project_controller.project_context.settings_manager, + formatted_recommendations, + self.project_controller.project_context + ) + self.ui_components.append(auto_exclude_ui) + else: + logger.info("No new exclusions suggested.") + self.main_ui.show_dashboard() + + @handle_exception + @log_method + def _on_auto_exclude_error(self, error_msg): + logger.error(f"Auto-exclude error: {error_msg}") + QMessageBox.critical(self.main_ui, "Error", f"An error occurred during auto-exclusion:\n{error_msg}") + self.main_ui.show_dashboard() + + @handle_exception + @log_method + def manage_exclusions(self, *args): + if self.project_controller.project_context: + exclusions_manager_ui = self.ui_controller.manage_exclusions(self.project_controller.project_context.settings_manager) + self.ui_components.append(exclusions_manager_ui) + else: + logger.error("No project context available.") + + @handle_exception + @log_method + def view_directory_tree(self, *args): + if self.project_controller.project_context: + result = self.project_controller.project_context.get_directory_tree() + directory_tree_ui = self.ui_controller.view_directory_tree(result) + self.ui_components.append(directory_tree_ui) else: - print("No project selected or something went wrong.") - - def trigger_auto_exclude(self): - if self.current_project: - auto_exclude_manager = AutoExcludeManager(self.current_project.start_directory) - if auto_exclude_manager.check_for_new_exclusions(self.settings_manager.settings): - formatted_recommendations = auto_exclude_manager.get_formatted_recommendations() - self.main_ui.show_auto_exclude_ui(auto_exclude_manager, self.settings_manager, formatted_recommendations) - else: - print("No new exclusions to suggest.") - - def initialize_directory_analyzer(self): - self.directory_analyzer = DirectoryAnalyzer(self.current_project.start_directory, self.settings_manager) - - def manage_exclusions(self): - if self.current_project: - self.main_ui.manage_exclusions(self.settings_manager) - - def analyze_directory(self): - if self.current_project and self.directory_analyzer: - result = self.directory_analyzer.get_flat_structure() - self.main_ui.show_result(result) - - def view_directory_tree(self): - if self.current_project and self.directory_analyzer: - result = self.directory_analyzer.analyze_directory() - self.main_ui.view_directory_tree(result) - -if __name__ == "__main__": - app = QApplication([]) - controller = AppController() - controller.run() - app.exec_() \ No newline at end of file + logger.error("Cannot view directory tree: project_context is None.") + + @handle_exception + @log_method + def analyze_directory(self, *args): + if self.project_controller.project_context: + result_ui = self.ui_controller.show_result(self.project_controller.project_context.directory_analyzer) + result_ui.update_result() + self.ui_components.append(result_ui) + else: + logger.error("Cannot analyze directory: project_context is None.") + + def __del__(self): + logger.debug("AppController destructor called") + self.cleanup() \ No newline at end of file diff --git a/src/controllers/AutoExcludeWorker.py b/src/controllers/AutoExcludeWorker.py new file mode 100644 index 0000000..fde6d8e --- /dev/null +++ b/src/controllers/AutoExcludeWorker.py @@ -0,0 +1,42 @@ +import logging +import traceback +from PyQt5.QtCore import QObject, pyqtSignal + +logger = logging.getLogger(__name__) + +class AutoExcludeWorker(QObject): + finished = pyqtSignal(list) + error = pyqtSignal(str) + + def __init__(self, project_context): + super().__init__() + self.project_context = project_context + + def run(self): + try: + logger.debug("Auto-exclusion analysis started.") + self._validate_context() + formatted_recommendations = self._perform_analysis() + logger.debug("Auto-exclusion analysis completed.") + self.finished.emit(formatted_recommendations) + return formatted_recommendations + except Exception as e: + error_msg = self._handle_error(e) + self.error.emit(error_msg) + raise Exception(error_msg) + + def _perform_analysis(self): + recommendations = self.project_context.trigger_auto_exclude() + if not recommendations: + logger.info("No new exclusions suggested.") + return [] + return recommendations.split('\n') + + def _validate_context(self): + if not self.project_context or not self.project_context.settings_manager: + raise ValueError("ProjectContext or SettingsManager not properly initialized") + + def _handle_error(self, exception): + error_msg = f"Error in auto-exclusion analysis: {str(exception)}\n{traceback.format_exc()}" + logger.error(error_msg) + return error_msg \ No newline at end of file diff --git a/src/controllers/ProjectController.py b/src/controllers/ProjectController.py new file mode 100644 index 0000000..9974290 --- /dev/null +++ b/src/controllers/ProjectController.py @@ -0,0 +1,68 @@ +""" +GynTree: ProjectController manages the loading, saving, and setting of projects. +This controller handles the main project-related operations, ensuring that the +current project is properly set up and context is established. It interacts with +the ProjectManager and ProjectContext services to manage the lifecycle of the +project within the application. + +Responsibilities: +- Load and save projects from the ProjectManager. +- Set the current project and initialize project context. +- Provide project-related information to the main UI. +""" + +import logging +from models.Project import Project +from services.ProjectManager import ProjectManager +from services.ProjectContext import ProjectContext + +logger = logging.getLogger(__name__) + +class ProjectController: + def __init__(self, app_controller): + self.app_controller = app_controller + self.project_manager = ProjectManager() + self.current_project = None + self.project_context = None + + def create_project(self, project: Project) -> bool: + try: + self.project_manager.save_project(project) + self._set_current_project(project) + return True + except Exception as e: + logger.error(f"Failed to create project: {str(e)}") + return False + + def load_project(self, project_name: str) -> Project: + try: + project = self.project_manager.load_project(project_name) + if project: + self._set_current_project(project) + return project + except Exception as e: + logger.error(f"Failed to load project: {str(e)}") + return None + + def _set_current_project(self, project: Project): + if self.project_context: + self.project_context.close() + self.project_context = ProjectContext(project) + self.current_project = project + logger.debug(f"Project '{project.name}' set as active.") + + def analyze_directory(self): + """Trigger directory analysis""" + if self.project_context: + result_ui = self.app_controller.ui_controller.show_result(self.project_context.directory_analyzer) + result_ui.update_result() + else: + logger.error("Cannot analyze directory: project_context is None.") + + def view_directory_tree(self): + """View the directory structure""" + if self.project_context: + result = self.project_context.get_directory_tree() + self.app_controller.ui_controller.view_directory_tree(result) + else: + logger.error("Cannot view directory tree: project_context is None.") diff --git a/src/controllers/ThreadController.py b/src/controllers/ThreadController.py new file mode 100644 index 0000000..7dbdce2 --- /dev/null +++ b/src/controllers/ThreadController.py @@ -0,0 +1,82 @@ +""" +GynTree: ThreadController manages the lifecycle of worker threads. +This controller is responsible for handling background tasks like auto-exclusion +analysis, ensuring the UI remains responsive. It manages starting, stopping, +and cleaning up QThreads and their associated workers. + +Responsibilities: +- Start worker threads for long-running tasks (e.g., auto-exclusion). +- Handle thread cleanup and error handling. +- Ensure proper communication between threads and the main UI. +""" + +import logging +from PyQt5.QtCore import QObject, pyqtSignal, QThread, QThreadPool, QRunnable, pyqtSlot, QTimer +from controllers.AutoExcludeWorker import AutoExcludeWorker + +logger = logging.getLogger(__name__) + +class WorkerSignals(QObject): + finished = pyqtSignal(list) + error = pyqtSignal(str) + +class AutoExcludeWorkerRunnable(QRunnable): + def __init__(self, project_context): + super().__init__() + self.worker = AutoExcludeWorker(project_context) + self.signals = WorkerSignals() + + @pyqtSlot() + def run(self): + try: + result = self.worker.run() + self.signals.finished.emit(result) + except Exception as e: + self.signals.error.emit(str(e)) + +class ThreadController(QObject): + worker_finished = pyqtSignal(list) + worker_error = pyqtSignal(str) + + def __init__(self): + super().__init__() + self.threadpool = QThreadPool() + self.active_workers = [] + logger.debug(f"Multithreading with maximum {self.threadpool.maxThreadCount()} threads") + + def start_auto_exclude_thread(self, project_context): + self.cleanup_thread() # Ensure previous threads are cleaned up + worker = AutoExcludeWorkerRunnable(project_context) + worker.signals.finished.connect(self.worker_finished.emit) + worker.signals.error.connect(self.worker_error.emit) + self.active_workers.append(worker) + self.threadpool.start(worker) + + def cleanup_thread(self): + logger.debug("Starting ThreadController cleanup process") + try: + # Clear the thread pool + self.threadpool.clear() + + # Disconnect signals and clear active workers + for worker in self.active_workers: + if hasattr(worker, 'signals'): + worker.signals.finished.disconnect() + worker.signals.error.disconnect() + self.active_workers.clear() + + # Wait for all threads to finish + if not self.threadpool.waitForDone(5000): # 5 seconds timeout + logger.warning("ThreadPool did not finish in time. Some threads may still be running.") + + except RuntimeError as e: + logger.error(f"Error during thread cleanup: {str(e)}") + + logger.debug("ThreadController cleanup process completed") + + def __del__(self): + logger.debug("ThreadController destructor called") + try: + self.cleanup_thread() + except Exception as e: + logger.error(f"Error in ThreadController destruction: {str(e)}") \ No newline at end of file diff --git a/src/controllers/UIController.py b/src/controllers/UIController.py new file mode 100644 index 0000000..c84f7fe --- /dev/null +++ b/src/controllers/UIController.py @@ -0,0 +1,54 @@ +""" +GynTree: UIController manages the interaction between the project and the UI. +This controller is responsible for updating and resetting UI components whenever +a new project is loaded or created. It ensures that the correct project information +is displayed and that the user interface reflects the current project state. + +Responsibilities: +- Reset and update UI components like the directory tree, exclusions, and analysis. +- Manage exclusion-related UI elements. +- Provide a clean interface for displaying project information in the main UI. +""" + +import logging + +logger = logging.getLogger(__name__) + +class UIController: + def __init__(self, main_ui): + self.main_ui = main_ui + + def reset_ui(self): + """Reset UI components like directory tree, exclusions, and analysis.""" + logger.debug("Resetting UI components for new project...") + self.main_ui.clear_directory_tree() + self.main_ui.clear_analysis() + self.main_ui.clear_exclusions() + + def show_auto_exclude_ui(self, auto_exclude_manager, settings_manager, formatted_recommendations, project_context): + """Show auto-exclude UI with given recommendations.""" + return self.main_ui.show_auto_exclude_ui(auto_exclude_manager, settings_manager, formatted_recommendations, project_context) + + def manage_exclusions(self, settings_manager): + """Show exclusions management UI.""" + return self.main_ui.manage_exclusions(settings_manager) + + def update_project_info(self, project): + """Update the project information displayed in the UI.""" + self.main_ui.update_project_info(project) + + def view_directory_tree(self, result): + """Show directory tree UI given result.""" + return self.main_ui.view_directory_tree(result) + + def show_result(self, directory_analyzer): + """Show result UI given directory analyzer.""" + return self.main_ui.show_result(directory_analyzer) + + def show_error_message(self, title, message): + """Display an error message to the user.""" + self.main_ui.show_error_message(title, message) + + def show_dashboard(self): + """Show the main dashboard.""" + self.main_ui.show_dashboard() \ No newline at end of file diff --git a/src/models/Project.py b/src/models/Project.py index d1298d8..31c8e7b 100644 --- a/src/models/Project.py +++ b/src/models/Project.py @@ -1,9 +1,10 @@ -# GynTree: This file defines the Project class. It stores project details like name, start directory, and excluded directories or files. - class Project: - def __init__(self, name, start_directory, excluded_dirs=None, excluded_files=None): + def __init__(self, name, start_directory, root_exclusions=None, excluded_dirs=None, excluded_files=None): + if not self.is_valid_name(name): + raise ValueError(f"Invalid project name: {name}") self.name = name self.start_directory = start_directory + self.root_exclusions = root_exclusions if root_exclusions is not None else [] self.excluded_dirs = excluded_dirs if excluded_dirs is not None else [] self.excluded_files = excluded_files if excluded_files is not None else [] @@ -12,6 +13,7 @@ def to_dict(self): return { 'name': self.name, 'start_directory': self.start_directory, + 'root_exclusions': self.root_exclusions, 'excluded_dirs': self.excluded_dirs, 'excluded_files': self.excluded_files } @@ -22,6 +24,12 @@ def from_dict(cls, data): return cls( name=data.get('name'), start_directory=data.get('start_directory'), + root_exclusions=data.get('root_exclusions', []), excluded_dirs=data.get('excluded_dirs', []), excluded_files=data.get('excluded_files', []) ) + + @staticmethod + def is_valid_name(name): + invalid_chars = set('/\\:*?"<>|') + return not any(char in invalid_chars for char in name) diff --git a/src/services/CommentParser.py b/src/services/CommentParser.py index 3ae0c1d..131fa5c 100644 --- a/src/services/CommentParser.py +++ b/src/services/CommentParser.py @@ -1,55 +1,91 @@ -# GynTree: Implements functionality for parsing and extracting comments from various file types. - +from abc import ABC, abstractmethod import os import re +from typing import Dict, Tuple, Optional +import logging -class CommentParser: - COMMENT_SYNTAX = { - '.py': ('#', '"""'), - '.js': ('//', '/*'), - '.ts': ('//', '/*'), - '.tsx': ('//', '/*'), - '.html': ('')}, + '.css': {'single': None, 'multi': ('/*', '*/')}, + '.java': {'single': '//', 'multi': ('/*', '*/')}, + '.c': {'single': '//', 'multi': ('/*', '*/')}, + '.cpp': {'single': '//', 'multi': ('/*', '*/')} } - def get_file_purpose(self, filepath): - file_extension = os.path.splitext(filepath)[1].lower() + def get_syntax(self, file_extension: str) -> Dict[str, Optional[str]]: + return self.SYNTAX.get(file_extension, {}) + +class CommentParser: + def __init__(self, file_reader: FileReader, comment_syntax: CommentSyntax): + self.file_reader = file_reader + self.comment_syntax = comment_syntax + self.gyntree_pattern = re.compile(r'gyntree:(.*)', re.IGNORECASE | re.DOTALL) - if file_extension not in self.COMMENT_SYNTAX: + def get_file_purpose(self, filepath: str) -> str: + file_extension = os.path.splitext(filepath)[1].lower() + syntax = self.comment_syntax.get_syntax(file_extension) + if not syntax: + logger.debug(f"Unsupported file type: {file_extension}") return "Unsupported file type" - try: - with open(filepath, 'r', encoding='utf-8') as file: - content = file.read(1000) - lines = content.split('\n')[:20] - - single_line, multi_line = self.COMMENT_SYNTAX[file_extension] + content = self.file_reader.read_file(filepath, 5000) + if not content: + return "File not found or empty" - multi_line_pattern = re.compile(rf'{re.escape(multi_line)}(.*?)GynTree:(.*?)({re.escape(multi_line)})', re.DOTALL) - multi_line_match = multi_line_pattern.search(content) + description = self._extract_comment(content, syntax) + return description if description else "No description available" - if multi_line_match: - return multi_line_match.group(2).strip() - - for line in lines: - if line.strip().startswith(single_line) and "GynTree:" in line: - return self._extract_gyntree_comment(line.strip().lstrip(single_line).strip()) + def _extract_comment(self, content: str, syntax: dict) -> Optional[str]: + # Check for multi-line comments first + if syntax['multi']: + start_delim, end_delim = map(re.escape, syntax['multi']) + pattern = rf'{start_delim}(.*?){end_delim}' + for match in re.finditer(pattern, content, re.DOTALL): + description = self._parse_comment_content(match.group(1)) + if description: + return description - return "No description available" + # Then check for single-line comments + if syntax['single']: + for line in content.splitlines(): + stripped_line = line.strip() + if stripped_line.startswith(syntax['single']): + description = self._parse_comment_content(stripped_line[len(syntax['single']):].strip()) + if description: + return description - except FileNotFoundError: - return "File not found" - except UnicodeDecodeError: - return "Unable to decode file" - - def _extract_gyntree_comment(self, comment): - comment = comment.strip().strip('"').strip("'") - - if "GynTree:" in comment: - return comment.split("GynTree:", 1)[1].strip() - - return "No description available" \ No newline at end of file + return None + + def _parse_comment_content(self, comment_content: str) -> Optional[str]: + match = self.gyntree_pattern.search(comment_content) + return match.group(1).strip() if match else None \ No newline at end of file diff --git a/src/services/DirectoryAnalyzer.py b/src/services/DirectoryAnalyzer.py index 7386b1c..13e6f68 100644 --- a/src/services/DirectoryAnalyzer.py +++ b/src/services/DirectoryAnalyzer.py @@ -1,58 +1,33 @@ -""" -GynTree: This module is responsible for analyzing directory structures. -It provides functionality to traverse directories, collect file information, -and generate both hierarchical and flat representations of the directory structure. -The DirectoryAnalyzer class is the core component for directory analysis operations. -""" +import logging +from typing import Dict, Any +from services.DirectoryStructureService import DirectoryStructureService +import threading -import os -from services.CommentParser import CommentParser +logging.basicConfig(level=logging.DEBUG) +logger = logging.getLogger(__name__) class DirectoryAnalyzer: - def __init__(self, start_dir, settings_manager): - self.start_dir = os.path.normpath(start_dir) - self.settings_manager = settings_manager - self.comment_parser = CommentParser() - - def analyze_directory(self): - return self._analyze_recursive(self.start_dir) - - def get_flat_structure(self): - flat_structure = [] - for root, _, files in os.walk(self.start_dir): - if self.settings_manager.is_excluded_dir(root): - continue - for file in files: - full_path = os.path.join(root, file) - if not self.settings_manager.is_excluded_file(full_path): - flat_structure.append({ - 'path': full_path, - 'description': self.comment_parser.get_file_purpose(full_path) - }) - return flat_structure - - def _analyze_recursive(self, current_dir): - structure = { - 'name': os.path.basename(current_dir), - 'type': 'Directory', - 'path': current_dir, - 'children': [] - } - - for item in os.listdir(current_dir): - full_path = os.path.join(current_dir, item) - - if self.settings_manager.is_excluded_dir(full_path) or self.settings_manager.is_excluded_file(full_path): - continue - - if os.path.isdir(full_path): - structure['children'].append(self._analyze_recursive(full_path)) - else: - file_info = { - 'name': item, - 'type': 'File', - 'path': full_path, - } - structure['children'].append(file_info) - - return structure \ No newline at end of file + def __init__(self, start_dir: str, settings_manager): + self.start_dir = start_dir + self.directory_structure_service = DirectoryStructureService(settings_manager) + self._stop_event = threading.Event() + + def analyze_directory(self) -> Dict[str, Any]: + """ + Analyze the directory and return a hierarchical structure. + """ + logger.debug(f"Analyzing directory hierarchy for: {self.start_dir}") + return self.directory_structure_service.get_hierarchical_structure(self.start_dir, self._stop_event) + + def get_flat_structure(self) -> Dict[str, Any]: + """ + Get a flat structure of the directory. + """ + logger.debug(f"Generating flat directory structure for: {self.start_dir}") + return self.directory_structure_service.get_flat_structure(self.start_dir, self._stop_event) + + def stop(self): + """ + Signal the analysis to stop. + """ + self._stop_event.set() diff --git a/src/services/DirectoryStructureService.py b/src/services/DirectoryStructureService.py index 0e65d03..60fbad0 100644 --- a/src/services/DirectoryStructureService.py +++ b/src/services/DirectoryStructureService.py @@ -1,56 +1,83 @@ import os -from services.CommentParser import CommentParser +import logging +from typing import Dict, Any, List +from services.CommentParser import CommentParser, DefaultFileReader, DefaultCommentSyntax +from services.SettingsManager import SettingsManager +import threading + +logger = logging.getLogger(__name__) class DirectoryStructureService: - def __init__(self, settings_manager): + def __init__(self, settings_manager: SettingsManager): self.settings_manager = settings_manager - self.comment_parser = CommentParser() + self.comment_parser = CommentParser(DefaultFileReader(), DefaultCommentSyntax()) - def get_hierarchical_structure(self, start_dir): - """ - Returns a hierarchical structure of the directory. - """ - return self._analyze_recursive(start_dir) + def get_hierarchical_structure(self, start_dir: str, stop_event: threading.Event) -> Dict[str, Any]: + logger.debug(f"Generating hierarchical structure for: {start_dir}") + return self._analyze_recursive(start_dir, stop_event) - def get_flat_structure(self, start_dir): - """ - Returns a flat structure of the directory. - """ + def get_flat_structure(self, start_dir: str, stop_event: threading.Event) -> List[Dict[str, Any]]: + logger.debug(f"Generating flat structure for: {start_dir}") flat_structure = [] - for root, _, files in os.walk(start_dir): - if self.settings_manager.is_excluded_dir(root): + for root, dirs, files in os.walk(start_dir): + if stop_event.is_set(): + logger.debug("Directory analysis stopped.") + return flat_structure + + dirs[:] = [d for d in dirs if not self.settings_manager.is_excluded(os.path.join(root, d))] + + if self.settings_manager.is_excluded(root): continue + for file in files: full_path = os.path.join(root, file) - if not self.settings_manager.is_excluded_file(full_path): - flat_structure.append({ - 'path': full_path, - 'type': 'File', - 'description': self.comment_parser.get_file_purpose(full_path) - }) + if self.settings_manager.is_excluded(full_path): + logger.debug(f"Excluding {full_path}") + continue + flat_structure.append({ + 'path': full_path, + 'type': 'File', + 'description': self.comment_parser.get_file_purpose(full_path) + }) return flat_structure - def _analyze_recursive(self, current_dir): + def _analyze_recursive(self, current_dir: str, stop_event: threading.Event) -> Dict[str, Any]: + if stop_event.is_set(): + logger.debug("Directory analysis stopped.") + return {} + if self.settings_manager.is_excluded(current_dir): + logger.debug(f"Skipping excluded directory: {current_dir}") + return {} structure = { 'name': os.path.basename(current_dir), 'type': 'Directory', 'path': current_dir, 'children': [] } - for item in os.listdir(current_dir): - full_path = os.path.join(current_dir, item) - - if self.settings_manager.is_excluded_dir(full_path) or self.settings_manager.is_excluded_file(full_path): - continue - - if os.path.isdir(full_path): - structure['children'].append(self._analyze_recursive(full_path)) - else: - file_info = { - 'name': item, - 'type': 'File', - 'path': full_path, - 'description': self.comment_parser.get_file_purpose(full_path) - } - structure['children'].append(file_info) + try: + for item in os.listdir(current_dir): + if stop_event.is_set(): + logger.debug("Directory analysis stopped.") + return structure + full_path = os.path.join(current_dir, item) + + if self.settings_manager.is_excluded(full_path): + logger.debug(f"Skipping excluded path: {full_path}") + continue + if os.path.isdir(full_path): + child_structure = self._analyze_recursive(full_path, stop_event) + if child_structure: + structure['children'].append(child_structure) + else: + file_info = { + 'name': item, + 'type': 'File', + 'path': full_path, + 'description': self.comment_parser.get_file_purpose(full_path) + } + structure['children'].append(file_info) + except PermissionError as e: + logger.warning(f"Permission denied: {current_dir} - {e}") + except Exception as e: + logger.error(f"Error analyzing {current_dir}: {e}") return structure \ No newline at end of file diff --git a/src/services/ExclusionAggregator.py b/src/services/ExclusionAggregator.py index 770be28..ee68bae 100644 --- a/src/services/ExclusionAggregator.py +++ b/src/services/ExclusionAggregator.py @@ -1,65 +1,89 @@ -# GynTree: This module provides utilities for aggregating and formatting file/directory exclusions. import os from collections import defaultdict +from typing import Dict, Set class ExclusionAggregator: @staticmethod - def aggregate_exclusions(exclusions): + def aggregate_exclusions(exclusions: Dict[str, Set[str]]) -> Dict[str, Dict[str, Set[str]]]: aggregated = { - 'directories': defaultdict(set), - 'files': defaultdict(set) + 'root_exclusions': set(), + 'excluded_dirs': defaultdict(set), + 'excluded_files': defaultdict(set) } + + root_exclusions = exclusions.get('root_exclusions', set()) + for item in root_exclusions: + aggregated['root_exclusions'].add(os.path.normpath(item)) + for exclusion_type, items in exclusions.items(): + if exclusion_type == 'root_exclusions': + continue + for item in items: - base_name = os.path.basename(item) - parent_dir = os.path.dirname(item) - if exclusion_type == 'directories': - if base_name in ['__pycache__', '.git', 'venv', '.venv', 'env', '.vs', '_internal']: - aggregated['directories']['common'].add(base_name) - elif base_name in ['build', 'dist']: - aggregated['directories']['build'].add(base_name) + normalized_item = os.path.normpath(item) + + if any(normalized_item.startswith(root_dir) for root_dir in root_exclusions): + continue + + base_name = os.path.basename(normalized_item) + parent_dir = os.path.dirname(normalized_item) + + if exclusion_type == 'excluded_dirs': + if base_name in ['node_modules', '__pycache__', '.git', 'venv', '.venv', 'env', '.vs', '_internal', '.next', 'public', 'dist', 'build', 'out', 'migrations']: + aggregated['excluded_dirs']['common'].add(base_name) + elif base_name in ['prisma', 'src', 'components', 'pages', 'api']: + aggregated['excluded_dirs']['app structure'].add(base_name) else: - # Check if it's not a subdirectory of an already excluded directory - if not any(item.startswith(excluded) for excluded in aggregated['directories']['common'] | aggregated['directories']['build']): - aggregated['directories']['other'].add(item) - elif exclusion_type == 'files': - if base_name.endswith('.pyc'): - aggregated['files']['pyc'].add(parent_dir) - elif base_name in ['.gitignore', '.dockerignore', '.vsignore', 'requirements.txt']: - aggregated['files']['ignore'].add(base_name) + aggregated['excluded_dirs']['other'].add(base_name) + elif exclusion_type == 'excluded_files': + if normalized_item.endswith(('.pyc', '.pyo', '.pyd')): + aggregated['excluded_files']['cache'].add(normalized_item) + elif base_name in ['.gitignore', '.dockerignore', '.eslintrc.cjs', '.npmrc', '.env', '.env.development', 'next-env.d.ts', 'next.config.js', 'postcss.config.cjs', 'prettier.config.js', 'tailwind.config.ts', 'tsconfig.json']: + aggregated['excluded_files']['config'].add(base_name) elif base_name == '__init__.py': - aggregated['files']['init'].add(parent_dir) + aggregated['excluded_files']['init'].add(parent_dir) + elif base_name.endswith(('.js', '.cjs', '.mjs', '.ts', '.tsx', '.jsx')): + aggregated['excluded_files']['script'].add(base_name) + elif base_name.endswith(('.sql', '.sqlite', '.db')): + aggregated['excluded_files']['database'].add(base_name) + elif base_name.endswith(('.ico', '.png', '.jpg', '.jpeg', '.gif', '.svg')): + aggregated['excluded_files']['asset'].add(base_name) + elif base_name in ['package.json', 'pnpm-lock.yaml', 'yarn.lock', 'package-lock.json']: + aggregated['excluded_files']['package'].add(base_name) + elif base_name.endswith(('.md', '.txt')): + aggregated['excluded_files']['document'].add(base_name) + elif base_name.endswith(('.css', '.scss', '.less')): + aggregated['excluded_files']['style'].add(base_name) else: - aggregated['files']['other'].add(item) + aggregated['excluded_files']['other'].add(base_name) + return aggregated @staticmethod - def format_aggregated_exclusions(aggregated): + def format_aggregated_exclusions(aggregated: Dict[str, Dict[str, Set[str]]]) -> str: formatted = [] - if aggregated['directories']: - formatted.append("Directories:") - for category, items in aggregated['directories'].items(): + + if aggregated['root_exclusions']: + formatted.append("Root Exclusions:") + for item in sorted(aggregated['root_exclusions']): + formatted.append(f" - {item}") + + if aggregated['excluded_dirs']: + formatted.append("\nDirectories:") + for category, items in aggregated['excluded_dirs'].items(): if items: - if category == 'common': - formatted.append(f" Common: {', '.join(sorted(items))}") - elif category == 'build': - formatted.append(f" Build: {', '.join(sorted(items))}") - elif category == 'other': - formatted.append(" Other:") - for item in sorted(items): - formatted.append(f" - {item}") - if aggregated['files']: - formatted.append("Files:") - for category, items in aggregated['files'].items(): + formatted.append(f" {category.capitalize()}: {', '.join(sorted(items))}") + + if aggregated['excluded_files']: + formatted.append("\nFiles:") + for category, items in aggregated['excluded_files'].items(): if items: - if category == 'pyc': - formatted.append(f" Python Cache: {len(items)} directories with .pyc files") - elif category == 'ignore': - formatted.append(f" Ignore Files: {', '.join(sorted(items))}") - elif category == 'init': - formatted.append(f" __init__.py: {len(items)} directories") - elif category == 'other': - formatted.append(" Other:") - for item in sorted(items): - formatted.append(f" - {item}") + if category in ['cache', 'init']: + formatted.append(f" {category.capitalize()}: {len(items)} items") + else: + formatted.append(f" {category.capitalize()}: {len(items)} items") + if category == 'other' and len(items) <= 5: + for item in sorted(items): + formatted.append(f" - {item}") + return "\n".join(formatted) \ No newline at end of file diff --git a/src/services/ExclusionManagerService.py b/src/services/ExclusionManagerService.py index 818bd34..2f0ff75 100644 --- a/src/services/ExclusionManagerService.py +++ b/src/services/ExclusionManagerService.py @@ -1,52 +1,91 @@ -from services.ExclusionAggregator import ExclusionAggregator +from typing import Dict, Set class ExclusionManagerService: def __init__(self, settings_manager): self.settings_manager = settings_manager - self.exclusion_aggregator = ExclusionAggregator() - self.excluded_dirs = self.settings_manager.get_excluded_dirs() - self.excluded_files = self.settings_manager.get_excluded_files() - - def get_excluded_dirs(self): - return self.excluded_dirs - - def get_excluded_files(self): - return self.excluded_files - - def add_directory(self, directory): - if directory and directory not in self.excluded_dirs: - self.excluded_dirs.append(directory) - return True - return False - - def add_file(self, file): - if file and file not in self.excluded_files: - self.excluded_files.append(file) - return True - return False - - def remove_exclusion(self, path): - if path in self.excluded_dirs: - self.excluded_dirs.remove(path) - return True - elif path in self.excluded_files: - self.excluded_files.remove(path) - return True - return False + + def get_aggregated_exclusions(self) -> str: + """ + Returns a formatted string of aggregated exclusions for display. + """ + exclusions = self.settings_manager.get_all_exclusions() + lines = [] + # Root Exclusions + root_exclusions = exclusions.get('root_exclusions', set()) + if root_exclusions: + lines.append("Root Exclusions:") + for path in sorted(root_exclusions): + lines.append(f" - {path}") + # Excluded Directories + excluded_dirs = exclusions.get('excluded_dirs', set()) + if excluded_dirs: + lines.append("\nExcluded Directories:") + for path in sorted(excluded_dirs): + lines.append(f" - {path}") + # Excluded Files + excluded_files = exclusions.get('excluded_files', set()) + if excluded_files: + lines.append("\nExcluded Files:") + for path in sorted(excluded_files): + lines.append(f" - {path}") + return "\n".join(lines) + + def get_detailed_exclusions(self) -> Dict[str, Set[str]]: + """ + Returns a dictionary of detailed exclusions categorized by type. + """ + return self.settings_manager.get_all_exclusions() + + def add_directory(self, directory: str) -> bool: + """ + Adds a directory to excluded_dirs if not already present. + Returns True if added, False if already exists. + """ + current_dirs = set(self.settings_manager.get_excluded_dirs()) + if directory in current_dirs: + return False + current_dirs.add(directory) + self.settings_manager.update_settings({'excluded_dirs': list(current_dirs)}) + return True + + def add_file(self, file: str) -> bool: + """ + Adds a file to excluded_files if not already present. + Returns True if added, False if already exists. + """ + current_files = set(self.settings_manager.get_excluded_files()) + if file in current_files: + return False + current_files.add(file) + self.settings_manager.update_settings({'excluded_files': list(current_files)}) + return True + + def remove_directory(self, directory: str) -> bool: + """ + Removes a directory from excluded_dirs if present. + Returns True if removed, False if not found. + """ + current_dirs = set(self.settings_manager.get_excluded_dirs()) + if directory not in current_dirs: + return False + current_dirs.remove(directory) + self.settings_manager.update_settings({'excluded_dirs': list(current_dirs)}) + return True + + def remove_file(self, file: str) -> bool: + """ + Removes a file from excluded_files if present. + Returns True if removed, False if not found. + """ + current_files = set(self.settings_manager.get_excluded_files()) + if file not in current_files: + return False + current_files.remove(file) + self.settings_manager.update_settings({'excluded_files': list(current_files)}) + return True def save_exclusions(self): - self.settings_manager.update_settings({ - 'excluded_dirs': self.excluded_dirs, - 'excluded_files': self.excluded_files - }) - - def get_aggregated_exclusions(self): - exclusions = {'directories': self.excluded_dirs, 'files': self.excluded_files} - aggregated = self.exclusion_aggregator.aggregate_exclusions(exclusions) - return self.exclusion_aggregator.format_aggregated_exclusions(aggregated) - - def get_detailed_exclusions(self): - return { - 'directories': self.excluded_dirs, - 'files': self.excluded_files - } \ No newline at end of file + """ + Saves the current settings. (Assuming SettingsManager handles persistence) + """ + self.settings_manager.save_settings() \ No newline at end of file diff --git a/src/services/ExclusionService.py b/src/services/ExclusionService.py index 53fbb56..e00f03a 100644 --- a/src/services/ExclusionService.py +++ b/src/services/ExclusionService.py @@ -1,26 +1,26 @@ -""" -GynTree: This file defines the base ExclusionService class for all exclusion services. -""" from abc import ABC, abstractmethod +from typing import Dict, Set import os +from services.ProjectTypeDetector import ProjectTypeDetector +from services.SettingsManager import SettingsManager class ExclusionService(ABC): - def __init__(self, start_directory): + def __init__(self, start_directory: str, project_type_detector: ProjectTypeDetector, settings_manager: SettingsManager): self.start_directory = start_directory + self.project_type_detector = project_type_detector + self.settings_manager = settings_manager @abstractmethod - def get_exclusions(self): + def get_exclusions(self) -> Dict[str, Set[str]]: pass - def categorize_exclusions(self, exclusions): - categorized = {'directories': {}, 'files': {}} - for category, items in exclusions.items(): - for item in items: - if self.is_directory(item): - categorized['directories'].setdefault(category, []).append(item) - else: - categorized['files'].setdefault(category, []).append(item) - return categorized + def get_relative_path(self, path: str) -> str: + return os.path.relpath(path, self.start_directory) - def is_directory(self, path): - return os.path.isdir(path) \ No newline at end of file + def should_exclude(self, path: str) -> bool: + return self.settings_manager.is_excluded(path) + + def walk_directory(self): + for root, dirs, files in os.walk(self.start_directory): + dirs[:] = [d for d in dirs if not self.should_exclude(os.path.join(root, d))] + yield root, dirs, files \ No newline at end of file diff --git a/src/services/ExclusionServiceFactory.py b/src/services/ExclusionServiceFactory.py index 9e8800c..6afde71 100644 --- a/src/services/ExclusionServiceFactory.py +++ b/src/services/ExclusionServiceFactory.py @@ -1,25 +1,29 @@ -from typing import List, Type +from typing import List, Set from services.ExclusionService import ExclusionService -from services.auto_exclude.PythonAutoExclude import PythonAutoExclude +from services.ProjectTypeDetector import ProjectTypeDetector +from services.SettingsManager import SettingsManager from services.auto_exclude.IDEandGitAutoExclude import IDEandGitAutoExclude -from services.auto_exclude.DatabaseAutoExclude import DatabaseAutoExclude -from services.auto_exclude.NextJsNodeJsAutoExclude import NextJsNodeJsAutoExclude +from services.auto_exclude.PythonAutoExclude import PythonAutoExclude from services.auto_exclude.WebAutoExclude import WebAutoExclude +from services.auto_exclude.JavaScriptNodeJsAutoExclude import JavaScriptNodeJsAutoExclude +from services.auto_exclude.DatabaseAutoExclude import DatabaseAutoExclude class ExclusionServiceFactory: @staticmethod - def create_services(project_types: set, start_directory: str) -> List[ExclusionService]: - services = [IDEandGitAutoExclude(start_directory)] # Always include IDE and Git exclusions - + def create_services(project_types: Set[str], start_directory: str, project_type_detector: ProjectTypeDetector, settings_manager: SettingsManager) -> List[ExclusionService]: + services = [IDEandGitAutoExclude(start_directory, project_type_detector, settings_manager)] + service_map = { 'python': PythonAutoExclude, 'web': WebAutoExclude, - 'nextjs': NextJsNodeJsAutoExclude, - 'database': DatabaseAutoExclude + 'javascript': JavaScriptNodeJsAutoExclude, + 'database': DatabaseAutoExclude, + 'nextjs': WebAutoExclude } - + for project_type in project_types: - if project_type in service_map: - services.append(service_map[project_type](start_directory)) - + service_class = service_map.get(project_type.lower()) + if service_class: + services.append(service_class(start_directory, project_type_detector, settings_manager)) + return services \ No newline at end of file diff --git a/src/services/ProjectContext.py b/src/services/ProjectContext.py new file mode 100644 index 0000000..1c70e6d --- /dev/null +++ b/src/services/ProjectContext.py @@ -0,0 +1,120 @@ +import logging +from models.Project import Project +from services.SettingsManager import SettingsManager +from services.DirectoryAnalyzer import DirectoryAnalyzer +from services.auto_exclude.AutoExcludeManager import AutoExcludeManager +from services.RootExclusionManager import RootExclusionManager +from services.ProjectTypeDetector import ProjectTypeDetector + +logger = logging.getLogger(__name__) + +class ProjectContext: + def __init__(self, project: Project): + self.project = project + self.settings_manager = None + self.directory_analyzer = None + self.auto_exclude_manager = None + self.root_exclusion_manager = RootExclusionManager() + self.project_types = set() + self.detected_types = {} + self.project_type_detector = None + self.initialize() + + def initialize(self): + try: + self.settings_manager = SettingsManager(self.project) + self.project_type_detector = ProjectTypeDetector(self.project.start_directory) + self.detect_project_types() + self.initialize_root_exclusions() + self.initialize_auto_exclude_manager() + self.initialize_directory_analyzer() + except Exception as e: + logger.error(f"Failed to initialize ProjectContext: {str(e)}") + raise + + def detect_project_types(self): + self.detected_types = self.project_type_detector.detect_project_types() + self.project_types = {ptype for ptype, detected in self.detected_types.items() if detected} + logger.debug(f"Detected project types: {self.project_types}") + + def initialize_root_exclusions(self): + default_root_exclusions = self.root_exclusion_manager.get_root_exclusions( + self.detected_types, self.project.start_directory + ) + current_root_exclusions = set(self.settings_manager.get_root_exclusions()) + updated_root_exclusions = self.root_exclusion_manager.merge_with_existing_exclusions( + current_root_exclusions, default_root_exclusions + ) + if updated_root_exclusions != current_root_exclusions: + logger.info(f"Updating root exclusions: {updated_root_exclusions}") + self.settings_manager.update_settings({'root_exclusions': list(updated_root_exclusions)}) + + def initialize_auto_exclude_manager(self): + try: + self.auto_exclude_manager = AutoExcludeManager( + self.project.start_directory, + self.settings_manager, + self.project_types, + self.project_type_detector + ) + logger.debug("Initialized AutoExcludeManager") + except Exception as e: + logger.error(f"Failed to initialize AutoExcludeManager: {str(e)}") + self.auto_exclude_manager = None + + def initialize_directory_analyzer(self): + self.directory_analyzer = DirectoryAnalyzer( + self.project.start_directory, + self.settings_manager + ) + logger.debug("Initialized DirectoryAnalyzer") + + def stop_analysis(self): + if self.directory_analyzer: + self.directory_analyzer.stop() + + def reinitialize_directory_analyzer(self): + self.initialize_directory_analyzer() + + def trigger_auto_exclude(self) -> str: + if not self.auto_exclude_manager: + logger.warning("AutoExcludeManager not initialized. Attempting to reinitialize.") + self.initialize_auto_exclude_manager() + + if not self.auto_exclude_manager: + logger.error("Failed to reinitialize AutoExcludeManager. Cannot perform auto-exclude.") + return "" + + if not self.settings_manager: + logger.error("SettingsManager not initialized. Cannot perform auto-exclude.") + return "" + + new_recommendations = self.auto_exclude_manager.get_recommendations() + return self.auto_exclude_manager.get_formatted_recommendations() + + def get_directory_tree(self): + return self.directory_analyzer.analyze_directory() + + def save_settings(self): + self.settings_manager.save_settings() + + def close(self): + logger.debug(f"Closing project context for project: {self.project.name}") + self.stop_analysis() + + if self.settings_manager: + self.settings_manager.save_settings() + self.settings_manager = None + + if self.directory_analyzer: + self.directory_analyzer.stop() + self.directory_analyzer = None + + if self.auto_exclude_manager: + self.auto_exclude_manager = None + + self.project_types.clear() + self.detected_types.clear() + self.project_type_detector = None + + logger.debug(f"Project context closed for project: {self.project.name}") \ No newline at end of file diff --git a/src/services/ProjectManager.py b/src/services/ProjectManager.py index 9fb3c71..6eba9f8 100644 --- a/src/services/ProjectManager.py +++ b/src/services/ProjectManager.py @@ -8,21 +8,21 @@ from models.Project import Project class ProjectManager: - PROJECTS_DIR = 'config/projects' + projects_dir = 'config/projects' def __init__(self): - if not os.path.exists(self.PROJECTS_DIR): - os.makedirs(self.PROJECTS_DIR) + if not os.path.exists(self.projects_dir): + os.makedirs(self.projects_dir) def save_project(self, project): """Save a project to a JSON file.""" - project_file = os.path.join(self.PROJECTS_DIR, f'{project.name}.json') + project_file = os.path.join(self.projects_dir, f'{project.name}.json') with open(project_file, 'w') as f: json.dump(project.to_dict(), f, indent=4) def load_project(self, project_name): """Load a project from a JSON file.""" - project_file = os.path.join(self.PROJECTS_DIR, f'{project_name}.json') + project_file = os.path.join(self.projects_dir, f'{project_name}.json') if os.path.exists(project_file): with open(project_file, 'r') as f: data = json.load(f) @@ -32,8 +32,19 @@ def load_project(self, project_name): def list_projects(self): """List all saved projects.""" projects = [] - for filename in os.listdir(self.PROJECTS_DIR): + for filename in os.listdir(self.projects_dir): if filename.endswith('.json'): project_name = filename[:-5] # Remove .json extension projects.append(project_name) return projects + + def delete_project(self, project_name): + project_file = os.path.join(self.projects_dir, f"{project_name}.json") + if os.path.exists(project_file): + os.remove(project_file) + return True + return False + + def cleanup(self): + """Perform any necessary cleanup operations.""" + pass \ No newline at end of file diff --git a/src/services/ProjectTypeDetector.py b/src/services/ProjectTypeDetector.py index f9408b6..769b874 100644 --- a/src/services/ProjectTypeDetector.py +++ b/src/services/ProjectTypeDetector.py @@ -1,37 +1,46 @@ import os +from typing import Dict class ProjectTypeDetector: def __init__(self, start_directory: str): self.start_directory = start_directory def detect_python_project(self) -> bool: - python_indicators = ['.py', 'requirements.txt', 'setup.py', 'pyproject.toml'] - return any( - any(file.endswith(indicator) for indicator in python_indicators) - for file in os.listdir(self.start_directory) - ) + for root, dirs, files in os.walk(self.start_directory): + if any(file.endswith('.py') for file in files): + return True + return False def detect_web_project(self) -> bool: web_files = ['.html', '.css', '.js', '.ts', '.jsx', '.tsx'] return any(any(file.endswith(ext) for ext in web_files) for file in os.listdir(self.start_directory)) + def detect_javascript_project(self) -> bool: + js_files = ['.js', '.ts', '.jsx', '.tsx'] + js_config_files = ['package.json', 'tsconfig.json', '.eslintrc.js', '.eslintrc.json'] + return any( + any(file.endswith(ext) for ext in js_files) or file in js_config_files + for file in os.listdir(self.start_directory) + ) + def detect_nextjs_project(self) -> bool: - return os.path.exists(os.path.join(self.start_directory, 'next.config.js')) or \ - (os.path.exists(os.path.join(self.start_directory, 'package.json')) and \ - 'next' in open(os.path.join(self.start_directory, 'package.json')).read()) + nextjs_indicators = ['next.config.js', 'pages', 'components'] + return ( + os.path.exists(os.path.join(self.start_directory, 'next.config.js')) or + (os.path.exists(os.path.join(self.start_directory, 'package.json')) and + 'next' in open(os.path.join(self.start_directory, 'package.json')).read()) or + all(os.path.exists(os.path.join(self.start_directory, ind)) for ind in nextjs_indicators) + ) def detect_database_project(self) -> bool: db_indicators = ['prisma', 'schema.prisma', 'migrations', '.sqlite', '.db'] return any(indicator in os.listdir(self.start_directory) for indicator in db_indicators) - def detect_project_types(self) -> set: - project_types = set() - if self.detect_python_project(): - project_types.add('python') - if self.detect_web_project(): - project_types.add('web') - if self.detect_nextjs_project(): - project_types.add('nextjs') - if self.detect_database_project(): - project_types.add('database') - return project_types \ No newline at end of file + def detect_project_types(self) -> Dict[str, bool]: + return { + 'python': self.detect_python_project(), + 'web': self.detect_web_project(), + 'javascript': self.detect_javascript_project(), + 'nextjs': self.detect_nextjs_project(), + 'database': self.detect_database_project(), + } \ No newline at end of file diff --git a/src/services/RootExclusionManager.py b/src/services/RootExclusionManager.py new file mode 100644 index 0000000..bdb48cd --- /dev/null +++ b/src/services/RootExclusionManager.py @@ -0,0 +1,59 @@ +import logging +import os +from typing import Set, Dict + +logger = logging.getLogger(__name__) + +class RootExclusionManager: + def __init__(self): + self.default_exclusions = {'.git'} + self.project_type_exclusions = { + 'web': {'node_modules', '.next', 'dist', 'build', 'out'}, + 'nextjs': {'node_modules', '.next', 'dist', 'build', 'out'}, + 'javascript': {'node_modules', 'dist', 'build'}, + 'python': {'venv', '__pycache__', '.pytest_cache'}, + 'database': {'prisma', 'migrations'} + } + + def get_root_exclusions(self, project_info: Dict[str, bool], start_directory: str) -> Set[str]: + exclusions = self.default_exclusions.copy() + for project_type, is_detected in project_info.items(): + if is_detected: + exclusions.update(self._get_project_type_exclusions(project_type, start_directory)) + + if self._has_init_files(start_directory): + exclusions.add('**/__init__.py') + + logger.debug(f"Root exclusions for project: {exclusions}") + return exclusions + + def _get_project_type_exclusions(self, project_type: str, start_directory: str) -> Set[str]: + exclusions = set() + for exclusion in self.project_type_exclusions.get(project_type, set()): + exclusion_path = os.path.join(start_directory, exclusion) + if os.path.exists(exclusion_path): + exclusions.add(exclusion) + return exclusions + + def _has_init_files(self, directory: str) -> bool: + for root, _, files in os.walk(directory): + if '__init__.py' in files: + return True + return False + + def merge_with_existing_exclusions(self, existing_exclusions: Set[str], new_exclusions: Set[str]) -> Set[str]: + merged_exclusions = existing_exclusions.union(new_exclusions) + logger.info(f"Merged root exclusions: {merged_exclusions}") + return merged_exclusions + + def add_project_type_exclusion(self, project_type: str, exclusions: Set[str]): + if project_type in self.project_type_exclusions: + self.project_type_exclusions[project_type].update(exclusions) + else: + self.project_type_exclusions[project_type] = exclusions + logger.info(f"Added exclusions for project type {project_type}: {exclusions}") + + def remove_project_type_exclusion(self, project_type: str, exclusions: Set[str]): + if project_type in self.project_type_exclusions: + self.project_type_exclusions[project_type] -= exclusions + logger.info(f"Removed exclusions for project type {project_type}: {exclusions}") \ No newline at end of file diff --git a/src/services/SettingsManager.py b/src/services/SettingsManager.py index b10dc4c..0034699 100644 --- a/src/services/SettingsManager.py +++ b/src/services/SettingsManager.py @@ -1,31 +1,56 @@ -# GynTree: Handles application and project settings, including reading and writing configurations. - import os import json +import fnmatch +import logging +from typing import List, Dict, Set +from models.Project import Project +from services.ExclusionAggregator import ExclusionAggregator + +logger = logging.getLogger(__name__) class SettingsManager: - def __init__(self, project): + def __init__(self, project: Project): self.project = project - self.config_path = os.path.join('config', 'projects', f'{self.project.name}.json') + self.config_path = os.path.join('config', 'projects', f"{self.project.name}.json") self.settings = self.load_settings() + self.exclusion_aggregator = ExclusionAggregator() - def load_settings(self): + def load_settings(self) -> Dict[str, List[str]]: try: with open(self.config_path, 'r') as file: - return json.load(file) + settings = json.load(file) except FileNotFoundError: - return { - 'excluded_dirs': self.project.excluded_dirs, - 'excluded_files': self.project.excluded_files - } + settings = {} + + default_settings = { + 'root_exclusions': self.project.root_exclusions or [], + 'excluded_dirs': self.project.excluded_dirs or [], + 'excluded_files': self.project.excluded_files or [] + } + + for key, value in default_settings.items(): + if key not in settings: + settings[key] = value + + return settings + + def get_root_exclusions(self) -> List[str]: + return [os.path.normpath(d) for d in self.settings.get('root_exclusions', [])] - def get_excluded_dirs(self): + def get_excluded_dirs(self) -> List[str]: return [os.path.normpath(d) for d in self.settings.get('excluded_dirs', [])] - def get_excluded_files(self): + def get_excluded_files(self) -> List[str]: return [os.path.normpath(f) for f in self.settings.get('excluded_files', [])] - def update_settings(self, new_settings): + def get_all_exclusions(self) -> Dict[str, Set[str]]: + return { + 'root_exclusions': set(self.settings.get('root_exclusions', [])), + 'excluded_dirs': set(self.settings.get('excluded_dirs', [])), + 'excluded_files': set(self.settings.get('excluded_files', [])) + } + + def update_settings(self, new_settings: Dict[str, List[str]]): self.settings.update(new_settings) self.save_settings() @@ -34,19 +59,120 @@ def save_settings(self): with open(self.config_path, 'w') as file: json.dump(self.settings, file, indent=4) - def is_excluded_dir(self, path): + def is_excluded(self, path: str) -> bool: + return (self.is_root_excluded(path) or + self.is_excluded_dir(path) or + self.is_excluded_file(path)) + + def is_root_excluded(self, path: str) -> bool: + relative_path = self._get_relative_path(path) + path_parts = relative_path.split(os.sep) + + for excluded in self.get_root_exclusions(): + if '**' in excluded: + if fnmatch.fnmatch(relative_path, excluded): + logger.debug(f"Root excluded (wildcard): {path} (matched {excluded})") + return True + elif excluded in path_parts: + logger.debug(f"Root excluded: {path} (matched {excluded})") + return True + elif fnmatch.fnmatch(relative_path, excluded): + logger.debug(f"Root excluded (pattern): {path} (matched {excluded})") + return True + return False + + def is_excluded_dir(self, path: str) -> bool: + if self.is_root_excluded(path): + return True + relative_path = self._get_relative_path(path) + for excluded_dir in self.get_excluded_dirs(): + if fnmatch.fnmatch(relative_path, excluded_dir): + logger.debug(f"Excluded directory: {path} (matched {excluded_dir})") + return True + return False + + def is_excluded_file(self, path: str) -> bool: + if self.is_root_excluded(os.path.dirname(path)): + return True + relative_path = self._get_relative_path(path) + for excluded_file in self.get_excluded_files(): + if fnmatch.fnmatch(relative_path, excluded_file): + logger.debug(f"Excluded file: {path} (matched {excluded_file})") + return True + return False + + def _get_relative_path(self, path: str) -> str: + return os.path.relpath(path, self.project.start_directory) + + + def add_excluded_dir(self, directory: str) -> bool: + """ + Adds a directory to excluded_dirs if not already present. + Returns True if added, False if already exists. + """ + current_dirs = set(self.get_excluded_dirs()) + if directory not in current_dirs: + current_dirs.add(directory) + self.update_settings({'excluded_dirs': list(current_dirs)}) + return True + return False + + def add_excluded_file(self, file: str) -> bool: + """ + Adds a file to excluded_files if not already present. + Returns True if added, False if already exists. + """ + current_files = set(self.get_excluded_files()) + if file not in current_files: + current_files.add(file) + self.update_settings({'excluded_files': list(current_files)}) + return True + return False + + def remove_excluded_dir(self, directory: str) -> bool: + """ + Removes a directory from excluded_dirs if present. + Returns True if removed, False if not found. + """ + current_dirs = set(self.get_excluded_dirs()) + if directory in current_dirs: + current_dirs.remove(directory) + self.update_settings({'excluded_dirs': list(current_dirs)}) + return True + return False + + def remove_excluded_file(self, file: str) -> bool: + """ + Removes a file from excluded_files if present. + Returns True if removed, False if not found. + """ + current_files = set(self.get_excluded_files()) + if file in current_files: + current_files.remove(file) + self.update_settings({'excluded_files': list(current_files)}) + return True + return False + + def add_root_exclusion(self, exclusion: str) -> bool: """ - Checks if the given directory path is excluded. + Adds a root exclusion if not already present. + Returns True if added, False if already exists. """ - path = os.path.normpath(path) - return any( - os.path.commonpath([path, excluded_dir]) == excluded_dir - for excluded_dir in self.get_excluded_dirs() - ) + current_root_exclusions = set(self.get_root_exclusions()) + if exclusion not in current_root_exclusions: + current_root_exclusions.add(exclusion) + self.update_settings({'root_exclusions': list(current_root_exclusions)}) + return True + return False - def is_excluded_file(self, path): + def remove_root_exclusion(self, exclusion: str) -> bool: """ - Checks if the given file path is excluded. + Removes a root exclusion if present. + Returns True if removed, False if not found. """ - path = os.path.normpath(path) - return path in self.get_excluded_files() + current_root_exclusions = set(self.get_root_exclusions()) + if exclusion in current_root_exclusions: + current_root_exclusions.remove(exclusion) + self.update_settings({'root_exclusions': list(current_root_exclusions)}) + return True + return False \ No newline at end of file diff --git a/src/services/auto_exclude/AutoExcludeManager.py b/src/services/auto_exclude/AutoExcludeManager.py index 89519c1..7ea9b7e 100644 --- a/src/services/auto_exclude/AutoExcludeManager.py +++ b/src/services/auto_exclude/AutoExcludeManager.py @@ -1,61 +1,59 @@ -# GynTree: Manages the automatic exclusion rules for files and directories during analysis. - import os import logging -from typing import List, Dict +from typing import List, Dict, Set from services.ExclusionService import ExclusionService -from services.ProjectTypeDetector import ProjectTypeDetector from services.ExclusionServiceFactory import ExclusionServiceFactory -from services.ExclusionAggregator import ExclusionAggregator +from services.ProjectTypeDetector import ProjectTypeDetector +from services.SettingsManager import SettingsManager -logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(__name__) class AutoExcludeManager: - def __init__(self, start_directory: str): - self.start_directory = start_directory - self.project_types = ProjectTypeDetector(start_directory).detect_project_types() - logger.debug(f"Detected project types: {self.project_types}") - self.exclusion_services: List[ExclusionService] = ExclusionServiceFactory.create_services(self.project_types, start_directory) + def __init__(self, start_directory: str, settings_manager: SettingsManager, project_types: Set[str], project_type_detector: ProjectTypeDetector): + self.start_directory = os.path.abspath(start_directory) + self.settings_manager = settings_manager + self.project_types = project_types + self.exclusion_services: List[ExclusionService] = ExclusionServiceFactory.create_services( + project_types, + self.start_directory, + project_type_detector, + settings_manager + ) logger.debug(f"Created exclusion services: {[type(service).__name__ for service in self.exclusion_services]}") - self.raw_recommendations = None - self.formatted_recommendations = None + self.raw_recommendations: Dict[str, Set[str]] = {'root_exclusions': set(), 'excluded_dirs': set(), 'excluded_files': set()} - def get_grouped_recommendations(self, current_settings: Dict[str, List[str]]) -> Dict[str, set]: - if self.raw_recommendations is None: - self.raw_recommendations = {'directories': set(), 'files': set()} - excluded_dirs = set(current_settings.get('excluded_dirs', [])) - excluded_files = set(current_settings.get('excluded_files', [])) - - for service in self.exclusion_services: - service_exclusions = service.get_exclusions() - logger.debug(f"Exclusions from {type(service).__name__}: {service_exclusions}") - for dir_path in service_exclusions['directories']: - if not any(os.path.normpath(dir_path).startswith(os.path.normpath(excluded_dir)) for excluded_dir in excluded_dirs): - self.raw_recommendations['directories'].add(dir_path) - for file_path in service_exclusions['files']: - if file_path not in excluded_files: - self.raw_recommendations['files'].add(file_path) - - self.formatted_recommendations = ExclusionAggregator.format_aggregated_exclusions( - ExclusionAggregator.aggregate_exclusions(self.raw_recommendations) - ) - - logger.debug(f"Formatted recommendations:\n{self.formatted_recommendations}") + def get_recommendations(self) -> Dict[str, Set[str]]: + self.raw_recommendations = {'root_exclusions': set(), 'excluded_dirs': set(), 'excluded_files': set()} + for service in self.exclusion_services: + service_exclusions = service.get_exclusions() + for category in ['root_exclusions', 'excluded_dirs', 'excluded_files']: + self.raw_recommendations[category].update(service_exclusions.get(category, set())) + + # Filter out already excluded items + for category in ['root_exclusions', 'excluded_dirs', 'excluded_files']: + self.raw_recommendations[category] = { + path for path in self.raw_recommendations[category] + if not self.settings_manager.is_excluded(os.path.join(self.start_directory, path)) + } + return self.raw_recommendations def get_formatted_recommendations(self) -> str: - if self.formatted_recommendations is None: - self.get_grouped_recommendations({}) - return self.formatted_recommendations - - def check_for_new_exclusions(self, current_settings: Dict[str, List[str]]) -> bool: - raw_recommendations = self.get_grouped_recommendations(current_settings) - excluded_dirs = set(current_settings.get('excluded_dirs', [])) - excluded_files = set(current_settings.get('excluded_files', [])) - - new_dirs = raw_recommendations['directories'] - excluded_dirs - new_files = raw_recommendations['files'] - excluded_files - - return bool(new_dirs or new_files) \ No newline at end of file + recommendations = self.get_recommendations() + lines = [] + for category in ['root_exclusions', 'excluded_dirs', 'excluded_files']: + if recommendations[category]: + lines.append(f"{category.replace('_', ' ').title()}:") + for path in sorted(recommendations[category]): + lines.append(f" - {path}") + lines.append("") + return "\n".join(lines) + + def apply_recommendations(self): + recommendations = self.get_recommendations() + current_settings = self.settings_manager.settings + current_settings['root_exclusions'] = list(set(current_settings.get('root_exclusions', [])) | recommendations['root_exclusions']) + current_settings['excluded_dirs'] = list(set(current_settings.get('excluded_dirs', [])) | recommendations['excluded_dirs']) + current_settings['excluded_files'] = list(set(current_settings.get('excluded_files', [])) | recommendations['excluded_files']) + self.settings_manager.update_settings(current_settings) \ No newline at end of file diff --git a/src/services/auto_exclude/DatabaseAutoExclude.py b/src/services/auto_exclude/DatabaseAutoExclude.py index 10174b0..186c270 100644 --- a/src/services/auto_exclude/DatabaseAutoExclude.py +++ b/src/services/auto_exclude/DatabaseAutoExclude.py @@ -1,31 +1,40 @@ -""" -GynTree: This file defines the DatabaseAutoExclude class, which identifies database-related files and directories for exclusion. -""" import os +from typing import Dict, Set from services.ExclusionService import ExclusionService +from services.ProjectTypeDetector import ProjectTypeDetector +from services.SettingsManager import SettingsManager +import logging + +logger = logging.getLogger(__name__) class DatabaseAutoExclude(ExclusionService): - def __init__(self, start_directory): - super().__init__(start_directory) + def __init__(self, start_directory: str, project_type_detector: ProjectTypeDetector, settings_manager: SettingsManager): + super().__init__(start_directory, project_type_detector, settings_manager) + + def get_exclusions(self) -> Dict[str, Set[str]]: + recommendations = {'root_exclusions': set(), 'excluded_dirs': set(), 'excluded_files': set()} - def get_exclusions(self): - recommendations = {'directories': set(), 'files': set()} + if self.project_type_detector.detect_database_project(): + recommendations['root_exclusions'].add('prisma') + logger.debug("DatabaseAutoExclude: Adding 'prisma' to root exclusions") - for root, dirs, files in os.walk(self.start_directory): + for root, dirs, files in self.walk_directory(): if 'prisma' in dirs: prisma_dir = os.path.join(root, 'prisma') - recommendations['directories'].add(os.path.join(prisma_dir, 'migrations')) - - for file in os.listdir(prisma_dir): - if file.endswith('.ts') or file.endswith('.js'): - recommendations['files'].add(os.path.join(prisma_dir, file)) - + migrations_dir = os.path.join(prisma_dir, 'migrations') + if os.path.isdir(migrations_dir): + recommendations['excluded_dirs'].add(os.path.relpath(migrations_dir, self.start_directory)) + logger.debug(f"DatabaseAutoExclude: Recommending exclusion of migrations directory {migrations_dir}") + schema_path = os.path.join(prisma_dir, 'schema.prisma') if os.path.exists(schema_path): - recommendations['files'].add(schema_path) + recommendations['excluded_files'].add(os.path.relpath(schema_path, self.start_directory)) + logger.debug(f"DatabaseAutoExclude: Recommending exclusion of schema file {schema_path}") for file in files: - if file.endswith('.sqlite') or file.endswith('.db'): - recommendations['files'].add(os.path.join(root, file)) + if file.endswith(('.sqlite', '.db', '.sqlite3', '.db3', '.sql')): + full_path = os.path.join(root, file) + recommendations['excluded_files'].add(os.path.relpath(full_path, self.start_directory)) + logger.debug(f"DatabaseAutoExclude: Recommending exclusion of database file {full_path}") return recommendations \ No newline at end of file diff --git a/src/services/auto_exclude/IDEandGitAutoExclude.py b/src/services/auto_exclude/IDEandGitAutoExclude.py index 70e3967..68f4f82 100644 --- a/src/services/auto_exclude/IDEandGitAutoExclude.py +++ b/src/services/auto_exclude/IDEandGitAutoExclude.py @@ -1,27 +1,39 @@ -""" -GynTree: This file defines the IDEandGitAutoExclude class, which identifies IDE and Git-related files and directories for exclusion. -""" import os +from typing import Dict, Set from services.ExclusionService import ExclusionService +from services.ProjectTypeDetector import ProjectTypeDetector +from services.SettingsManager import SettingsManager +import logging + +logger = logging.getLogger(__name__) class IDEandGitAutoExclude(ExclusionService): - def __init__(self, start_directory): - super().__init__(start_directory) + def __init__(self, start_directory: str, project_type_detector: ProjectTypeDetector, settings_manager: SettingsManager): + super().__init__(start_directory, project_type_detector, settings_manager) + + def get_exclusions(self) -> Dict[str, Set[str]]: + recommendations = {'root_exclusions': set(), 'excluded_dirs': set(), 'excluded_files': set()} - def get_exclusions(self): - recommendations = {'directories': set(), 'files': set()} + # Common root exclusions + common_root_exclusions = {'.git', '.vs', '.idea', '.vscode'} + recommendations['root_exclusions'].update(common_root_exclusions) + logger.debug(f"IDEandGitAutoExclude: Adding common root exclusions: {common_root_exclusions}") - for root, dirs, files in os.walk(self.start_directory): - for directory in dirs: - if directory in [".git", ".vs", "venv", "__pycache__", "build", "dist"]: - recommendations['directories'].add(os.path.join(root, directory)) + # File exclusions + common_file_exclusions = { + '.gitignore', '.vsignore', '.dockerignore', '.gitattributes', + 'Thumbs.db', '.DS_Store', '*.swp', '*~', + '.editorconfig' + } + recommendations['excluded_files'].update(common_file_exclusions) + logger.debug(f"IDEandGitAutoExclude: Recommending common file exclusions: {common_file_exclusions}") + for root, dirs, files in self.walk_directory(): for file in files: - if file in ['.gitignore', '.vsignore', 'requirements.txt', '.dockerignore', 'Thumbs.db', '.DS_Store']: - recommendations['files'].add(os.path.join(root, file)) - - # Exclude executables - if file.endswith(('.exe', '.dll', '.so', '.dylib')): - recommendations['files'].add(os.path.join(root, file)) + if file.endswith(('.log', '.tmp', '.bak', '.orig', '.user')): + full_path = os.path.join(root, file) + relative_path = os.path.relpath(full_path, self.start_directory) + recommendations['excluded_files'].add(relative_path) + logger.debug(f"IDEandGitAutoExclude: Recommending exclusion of file {relative_path}") return recommendations \ No newline at end of file diff --git a/src/services/auto_exclude/JavaScriptNodeJsAutoExclude.py b/src/services/auto_exclude/JavaScriptNodeJsAutoExclude.py new file mode 100644 index 0000000..731acb4 --- /dev/null +++ b/src/services/auto_exclude/JavaScriptNodeJsAutoExclude.py @@ -0,0 +1,38 @@ +import os +from typing import Dict, Set +from services.ExclusionService import ExclusionService +from services.ProjectTypeDetector import ProjectTypeDetector +from services.SettingsManager import SettingsManager +import logging + +logger = logging.getLogger(__name__) + +class JavaScriptNodeJsAutoExclude(ExclusionService): + def __init__(self, start_directory: str, project_type_detector: ProjectTypeDetector, settings_manager: SettingsManager): + super().__init__(start_directory, project_type_detector, settings_manager) + + def get_exclusions(self) -> Dict[str, Set[str]]: + recommendations = {'root_exclusions': set(), 'excluded_dirs': set(), 'excluded_files': set()} + + if self.project_type_detector.detect_javascript_project() or self.project_type_detector.detect_nextjs_project(): + root_exclusions = {'node_modules', '.next', 'dist', 'build', 'out', '.cache', '.tmp'} + recommendations['root_exclusions'].update(root_exclusions) + logger.debug(f"JavaScriptNodeJsAutoExclude: Adding JavaScript/Node.js related excluded_dirs to root exclusions: {root_exclusions}") + + file_exclusions = { + '.npmrc', 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', + '.eslintrc.js', '.eslintrc.cjs', 'prettier.config.js', 'next.config.js', + 'next-env.d.ts', 'postcss.config.js', 'postcss.config.cjs', 'tailwind.config.js', + 'tailwind.config.ts', 'tsconfig.json', '.babelrc', '.browserslistrc', 'package.json' + } + recommendations['excluded_files'].update(file_exclusions) + logger.debug(f"JavaScriptNodeJsAutoExclude: Recommending JavaScript/Node.js related files for exclusion: {file_exclusions}") + + for root, dirs, files in self.walk_directory(): + for file in files: + if file.endswith(('.min.js', '.min.css')): + full_path = os.path.join(root, file) + recommendations['excluded_files'].add(os.path.relpath(full_path, self.start_directory)) + logger.debug(f"JavaScriptNodeJsAutoExclude: Recommending exclusion of minified file {full_path}") + + return recommendations \ No newline at end of file diff --git a/src/services/auto_exclude/NextJsNodeJsAutoExclude.py b/src/services/auto_exclude/NextJsNodeJsAutoExclude.py deleted file mode 100644 index 4c35914..0000000 --- a/src/services/auto_exclude/NextJsNodeJsAutoExclude.py +++ /dev/null @@ -1,25 +0,0 @@ -""" -GynTree: This file defines the NextJsNodeJsAutoExclude class, which identifies Next.js and Node.js related directories for exclusion. -""" -import os -from services.ExclusionService import ExclusionService - -class NextJsNodeJsAutoExclude(ExclusionService): - def __init__(self, start_directory): - super().__init__(start_directory) - - def get_exclusions(self): - recommendations = {'directories': set(), 'files': set()} - - for root, dirs, _ in os.walk(self.start_directory): - if '.next' in dirs: - recommendations['directories'].add(os.path.join(root, '.next')) - - if 'node_modules' in dirs: - recommendations['directories'].add(os.path.join(root, 'node_modules')) - - for dir in ['out', 'build', 'dist']: - if dir in dirs: - recommendations['directories'].add(os.path.join(root, dir)) - - return recommendations \ No newline at end of file diff --git a/src/services/auto_exclude/PythonAutoExclude.py b/src/services/auto_exclude/PythonAutoExclude.py index 8fff8ef..32d3a46 100644 --- a/src/services/auto_exclude/PythonAutoExclude.py +++ b/src/services/auto_exclude/PythonAutoExclude.py @@ -1,34 +1,36 @@ -# GynTree: Defines exclusion rules specific to Python projects and environments. - import os +from typing import Dict, Set from services.ExclusionService import ExclusionService +from services.ProjectTypeDetector import ProjectTypeDetector +from services.SettingsManager import SettingsManager +import logging + +logger = logging.getLogger(__name__) class PythonAutoExclude(ExclusionService): - def __init__(self, start_directory): - super().__init__(start_directory) + def __init__(self, start_directory: str, project_type_detector: ProjectTypeDetector, settings_manager: SettingsManager): + super().__init__(start_directory, project_type_detector, settings_manager) - def get_exclusions(self): - recommendations = {'directories': set(), 'files': set()} + def get_exclusions(self) -> Dict[str, Set[str]]: + recommendations = {'root_exclusions': set(), 'excluded_dirs': set(), 'excluded_files': set()} - for root, dirs, files in os.walk(self.start_directory): - for dir in ['__pycache__', '.pytest_cache', 'build', 'dist', '.tox']: - if dir in dirs: - recommendations['directories'].add(os.path.join(root, dir)) + if self.project_type_detector.detect_python_project(): + python_root_exclusions = {'__pycache__', '.pytest_cache', 'build', 'dist', '.tox', 'venv', '.venv', 'env'} + recommendations['root_exclusions'].update(python_root_exclusions) + logger.debug(f"PythonAutoExclude: Adding Python-related excluded_dirs to root exclusions: {python_root_exclusions}") + for root, dirs, files in self.walk_directory(): for file in files: - if file.endswith(('.pyc', '.pyo', '.coverage')): - recommendations['files'].add(os.path.join(root, file)) - elif file == '__init__.py': - recommendations['files'].add(os.path.join(root, file)) - - for venv in ['venv', '.venv', 'env']: - if venv in dirs: - recommendations['directories'].add(os.path.join(root, venv)) - - if os.path.exists(os.path.join(self.start_directory, 'setup.py')) or \ - os.path.exists(os.path.join(self.start_directory, 'requirements.txt')) or \ - os.path.exists(os.path.join(self.start_directory, 'pyproject.toml')): - recommendations['directories'].add(os.path.join(self.start_directory, 'build')) - recommendations['directories'].add(os.path.join(self.start_directory, 'dist')) + if file.endswith(('.pyc', '.pyo', '.coverage', '.egg-info')): + recommendations['excluded_files'].add(os.path.relpath(os.path.join(root, file), self.start_directory)) + logger.debug(f"PythonAutoExclude: Recommending exclusion of Python-related file {file}") + elif file in ['requirements.txt', 'Pipfile', 'Pipfile.lock', 'poetry.lock', 'pyproject.toml']: + recommendations['excluded_files'].add(os.path.relpath(os.path.join(root, file), self.start_directory)) + logger.debug(f"PythonAutoExclude: Recommending exclusion of Python dependency file {file}") + + setup_files = ['setup.py', 'setup.cfg'] + if any(os.path.exists(os.path.join(self.start_directory, f)) for f in setup_files): + recommendations['excluded_dirs'].update(['build', 'dist']) + logger.debug("PythonAutoExclude: Recommending exclusion of 'build' and 'dist' excluded_dirs") return recommendations \ No newline at end of file diff --git a/src/services/auto_exclude/WebAutoExclude.py b/src/services/auto_exclude/WebAutoExclude.py index c2650aa..33f3241 100644 --- a/src/services/auto_exclude/WebAutoExclude.py +++ b/src/services/auto_exclude/WebAutoExclude.py @@ -1,27 +1,36 @@ -""" -GynTree: This file defines the WebAutoExclude class, which identifies web-related files and directories for exclusion. -""" import os +from typing import Dict, Set from services.ExclusionService import ExclusionService +from services.ProjectTypeDetector import ProjectTypeDetector +from services.SettingsManager import SettingsManager +import logging + +logger = logging.getLogger(__name__) class WebAutoExclude(ExclusionService): - def __init__(self, start_directory): - super().__init__(start_directory) + def __init__(self, start_directory: str, project_type_detector: ProjectTypeDetector, settings_manager: SettingsManager): + super().__init__(start_directory, project_type_detector, settings_manager) - def get_exclusions(self): - recommendations = {'directories': set(), 'files': set()} + def get_exclusions(self) -> Dict[str, Set[str]]: + recommendations = {'root_exclusions': set(), 'excluded_dirs': set(), 'excluded_files': set()} - for root, dirs, files in os.walk(self.start_directory): - for dir in ['dist', 'build', 'out']: - if dir in dirs: - recommendations['directories'].add(os.path.join(root, dir)) + if self.project_type_detector.detect_web_project() or self.project_type_detector.detect_nextjs_project(): + recommendations['root_exclusions'].update(['.cache', '.tmp', 'dist', 'build']) + logger.debug("WebAutoExclude: Adding web-related excluded_dirs to root exclusions") - for dir in ['.cache', '.tmp']: - if dir in dirs: - recommendations['directories'].add(os.path.join(root, dir)) + for root, dirs, files in self.walk_directory(): + if 'public' in dirs: + recommendations['excluded_dirs'].add(os.path.relpath(os.path.join(root, 'public'), self.start_directory)) + logger.debug("WebAutoExclude: Recommending exclusion of 'public' directory") for file in files: - if file in ['.eslintrc.json', '.prettierrc', 'tsconfig.json', 'tailwind.config.js']: - recommendations['files'].add(os.path.join(root, file)) + if file.endswith(('.ico', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.tiff')): + file_path = os.path.relpath(os.path.join(root, file), self.start_directory) + recommendations['excluded_files'].add(file_path) + logger.debug(f"WebAutoExclude: Recommending exclusion of asset file {file_path}") + elif file in ['robots.txt', 'sitemap.xml', 'favicon.ico']: + file_path = os.path.relpath(os.path.join(root, file), self.start_directory) + recommendations['excluded_files'].add(file_path) + logger.debug(f"WebAutoExclude: Recommending exclusion of web-related file {file_path}") return recommendations \ No newline at end of file diff --git a/src/utilities/Utilities.py b/src/utilities/clipboard_utility.py similarity index 75% rename from src/utilities/Utilities.py rename to src/utilities/clipboard_utility.py index 18aa170..20ec4a7 100644 --- a/src/utilities/Utilities.py +++ b/src/utilities/clipboard_utility.py @@ -1,5 +1,3 @@ -# GynTree: Provides various utility functions used throughout the GynTree application. - from PyQt5.QtWidgets import QApplication def copy_to_clipboard(text): diff --git a/src/utilities/error_handler.py b/src/utilities/error_handler.py new file mode 100644 index 0000000..3cb177d --- /dev/null +++ b/src/utilities/error_handler.py @@ -0,0 +1,46 @@ +import sys +import logging +import traceback +from functools import wraps +from PyQt5.QtWidgets import QMessageBox +from PyQt5.QtCore import QObject, pyqtSignal, QTimer + +logger = logging.getLogger(__name__) + +class ErrorHandler(QObject): + error_occurred = pyqtSignal(str, str) + + def __init__(self): + super().__init__() + self.error_occurred.connect(self.show_error_dialog) + + def global_exception_handler(self, exc_type, exc_value, exc_traceback): + """Handle uncaught exceptions globally""" + error_msg = f"An unexpected error occurred:\n{exc_type.__name__}: {exc_value}" + detailed_msg = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback)) + + logger.critical("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback)) + + self.error_occurred.emit("Critical Error", error_msg) + + logger.debug(f"Detailed error traceback:\n{detailed_msg}") + + def show_error_dialog(self, title, message): + """Show an error dialog with the given title and message""" + QTimer.singleShot(0, lambda: QMessageBox.critical(None, title, message)) + +def handle_exception(func): + """Decorator to handle exceptions in individual methods""" + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + logger.exception(f"Exception in {func.__name__}: {str(e)}") + error_msg = f"An error occurred in {func.__name__}:\n{str(e)}" + QMessageBox.critical(None, "Error", error_msg) + return wrapper + +error_handler = ErrorHandler() + +sys.excepthook = error_handler.global_exception_handler \ No newline at end of file diff --git a/src/utilities/logging_decorator.py b/src/utilities/logging_decorator.py new file mode 100644 index 0000000..fdfc262 --- /dev/null +++ b/src/utilities/logging_decorator.py @@ -0,0 +1,17 @@ +import logging +from functools import wraps + +logger = logging.getLogger(__name__) + +def log_method(func): + @wraps(func) + def wrapper(*args, **kwargs): + logger.debug(f"Entering {func.__name__}") + try: + result = func(*args, **kwargs) + logger.debug(f"Exiting {func.__name__}") + return result + except Exception as e: + logger.exception(f"Exception in {func.__name__}: {str(e)}") + raise + return wrapper \ No newline at end of file diff --git a/src/utilities/WindowManager.py b/src/utilities/window_manager.py similarity index 100% rename from src/utilities/WindowManager.py rename to src/utilities/window_manager.py diff --git a/tests/conftest.py b/tests/conftest.py index af8a333..2960f1d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,100 @@ # GynTree: This file configures the test environment for pytest. It adds the src directory to the Python path so that the tests can access the main modules. - import sys import os +import pytest +from PyQt5.QtWidgets import QApplication +import psutil sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../src'))) + +from models.Project import Project +from services.SettingsManager import SettingsManager +from services.ProjectTypeDetector import ProjectTypeDetector +from services.ProjectContext import ProjectContext + +@pytest.fixture(scope="session") +def qapp(): + """Create a QApplication instance for the entire test session.""" + app = QApplication([]) + yield app + app.quit() + +@pytest.fixture +def mock_project(tmpdir): + """Create a mock Project instance for testing.""" + return Project( + name="test_project", + start_directory=str(tmpdir), + root_exclusions=["node_modules"], + excluded_dirs=["dist"], + excluded_files=[".env"] + ) + +@pytest.fixture +def settings_manager(mock_project, tmpdir): + """Create a SettingsManager instance for testing.""" + SettingsManager.config_dir = str(tmpdir.mkdir("config")) + return SettingsManager(mock_project) + +@pytest.fixture +def project_type_detector(tmpdir): + """Create a ProjectTypeDetector instance for testing.""" + return ProjectTypeDetector(str(tmpdir)) + +@pytest.fixture +def project_context(mock_project): + """Create a ProjectContext instance for testing.""" + return ProjectContext(mock_project) + +@pytest.fixture +def setup_python_project(tmpdir): + """Set up a basic Python project structure for testing.""" + tmpdir.join("main.py").write("print('Hello, World!')") + tmpdir.join("requirements.txt").write("pytest\npyqt5") + return tmpdir + +@pytest.fixture +def setup_web_project(tmpdir): + """Set up a basic web project structure for testing.""" + tmpdir.join("index.html").write("Hello, World!") + tmpdir.join("styles.css").write("body { font-family: Arial, sans-serif; }") + return tmpdir + +@pytest.fixture +def setup_complex_project(tmpdir): + """Set up a complex project structure with multiple project types for testing.""" + tmpdir.join("main.py").write("print('Hello, World!')") + tmpdir.join("package.json").write('{"name": "test-project", "version": "1.0.0"}') + tmpdir.mkdir("src").join("app.js").write("console.log('Hello, World!');") + tmpdir.mkdir("public").join("index.html").write("Hello, World!") + tmpdir.mkdir("migrations") + return tmpdir + +@pytest.fixture +def create_large_directory_structure(tmpdir): + def _create_large_directory_structure(depth=5, files_per_dir=100): + def create_files(directory, num_files): + for i in range(num_files): + file_path = os.path.join(directory, f"file_{i}.txt") + with open(file_path, 'w') as f: + f.write(f"# gyntree: Test file {i}") + + def create_dirs(root, current_depth): + if current_depth > depth: + return + create_files(root, files_per_dir) + for i in range(5): # Create 5 subdirectories + subdir = os.path.join(root, f"dir_{i}") + os.mkdir(subdir) + create_dirs(subdir, current_depth + 1) + + create_dirs(str(tmpdir), 1) + return tmpdir + + return _create_large_directory_structure + +def pytest_configure(config): + """Add custom markers to the pytest configuration.""" + config.addinivalue_line("markers", "slow: marks tests as slow (deselect with '-m \"not slow\"')") + config.addinivalue_line("markers", "gui: marks tests that require a GUI (deselect with '-m \"not gui\"')") + config.addinivalue_line("markers", "memory: marks tests that perform memory profiling") \ No newline at end of file diff --git a/tests/run_tests.py b/tests/run_tests.py new file mode 100644 index 0000000..a3293fc --- /dev/null +++ b/tests/run_tests.py @@ -0,0 +1,140 @@ +import subprocess +import os +import sys +import time +from datetime import datetime +from colorama import init, Fore, Style + +init(autoreset=True) + +def clear_screen(): + os.system('cls' if os.name == 'nt' else 'clear') + +def print_colored(text, color=Fore.WHITE, style=Style.NORMAL): + print(f"{style}{color}{text}") + +def run_command(command, output_file): + process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True, shell=True) + with open(output_file, 'w', encoding='utf-8') as f: + for stdout_line in iter(process.stdout.readline, ""): + f.write(stdout_line) + yield stdout_line + process.stdout.close() + return_code = process.wait() + if return_code: + raise subprocess.CalledProcessError(return_code, command) + +def run_tests(options): + base_command = f"pytest -v --tb=short --capture=no --log-cli-level=DEBUG {options.get('extra_args', '')}" + + if options.get('parallel', False): + base_command += " -n auto" + + if options.get('html_report', False): + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + report_name = f"test_report_{timestamp}.html" + base_command += f" --html={report_name} --self-contained-html" + + if options.get('memory_test', False): + base_command += " -m memory" + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + output_file = f"test_output_{timestamp}.txt" + + print_colored(f"Running command: {base_command}", Fore.CYAN) + print_colored(f"Test output will be saved to: {output_file}", Fore.YELLOW) + print_colored("Test output:", Fore.YELLOW) + + start_time = time.time() + try: + for line in run_command(base_command, output_file): + if "PASSED" in line: + print_colored(line.strip(), Fore.GREEN) + elif "FAILED" in line: + print_colored(line.strip(), Fore.RED) + elif "SKIPPED" in line: + print_colored(line.strip(), Fore.YELLOW) + else: + print(line.strip()) + except subprocess.CalledProcessError as e: + print_colored(f"An error occurred while running the tests: {e}", Fore.RED) + + end_time = time.time() + duration = end_time - start_time + + print_colored(f"\nTests completed in {duration:.2f} seconds", Fore.CYAN) + print_colored(f"Full test output saved to: {output_file}", Fore.GREEN) + + if options.get('html_report', False): + print_colored(f"HTML report generated: {report_name}", Fore.GREEN) + +def get_user_choice(prompt, options): + while True: + print_colored(prompt, Fore.CYAN) + for i, option in enumerate(options, 1): + print_colored(f"{i}. {option}", Fore.YELLOW) + choice = input("Enter your choice (number): ") + if choice.isdigit() and 1 <= int(choice) <= len(options): + return int(choice) + print_colored("Invalid choice. Please try again.", Fore.RED) + +def main_menu(): + while True: + clear_screen() + print_colored("GynTree Interactive Test Runner", Fore.CYAN, Style.BRIGHT) + print_colored("================================\n", Fore.CYAN, Style.BRIGHT) + + options = {} + + # Test Type + test_type_choices = [ + "Run all tests", + "Run only unit tests", + "Run only integration tests", + "Run memory tests", + "Exit" + ] + test_type = get_user_choice("Select test type:", test_type_choices) + if test_type == 5: + print_colored("Exiting. Goodbye!", Fore.YELLOW) + sys.exit(0) + elif test_type == 2: + options['extra_args'] = "-m 'not integration and not memory'" + elif test_type == 3: + options['extra_args'] = "-m integration" + elif test_type == 4: + options['memory_test'] = True + + # Execution Mode + execution_mode_choices = [ + "Run tests sequentially", + "Run tests in parallel" + ] + execution_mode = get_user_choice("Select execution mode:", execution_mode_choices) + options['parallel'] = (execution_mode == 2) + + # Reporting + reporting_choices = [ + "Console output only", + "Generate HTML report" + ] + reporting = get_user_choice("Select reporting option:", reporting_choices) + options['html_report'] = (reporting == 2) + + # Confirmation + clear_screen() + print_colored("Test Run Configuration:", Fore.CYAN, Style.BRIGHT) + print_colored("-------------------------", Fore.CYAN) + print_colored(f"Test Type: {test_type_choices[test_type - 1]}", Fore.YELLOW) + print_colored(f"Execution Mode: {execution_mode_choices[execution_mode - 1]}", Fore.YELLOW) + print_colored(f"Reporting: {reporting_choices[reporting - 1]}", Fore.YELLOW) + print() + + confirm = input("Do you want to proceed with this configuration? (y/n): ") + if confirm.lower() == 'y': + run_tests(options) + + input("\nPress Enter to return to the main menu...") + +if __name__ == "__main__": + main_menu() \ No newline at end of file diff --git a/tests/test_auto_exclude_manager.py b/tests/test_auto_exclude_manager.py index b55b2f4..c3d6496 100644 --- a/tests/test_auto_exclude_manager.py +++ b/tests/test_auto_exclude_manager.py @@ -1,11 +1,7 @@ -""" -GynTree: This file contains unit tests for the AutoExcludeManager class, -ensuring proper management of auto-exclusion rules across different project types. -""" - import pytest from services.auto_exclude.AutoExcludeManager import AutoExcludeManager from services.SettingsManager import SettingsManager +from services.ProjectTypeDetector import ProjectTypeDetector from models.Project import Project @pytest.fixture @@ -13,81 +9,82 @@ def mock_project(tmpdir): return Project( name="test_project", start_directory=str(tmpdir), + root_exclusions=[], excluded_dirs=[], excluded_files=[] ) @pytest.fixture -def auto_exclude_manager(mock_project): - return AutoExcludeManager(mock_project.start_directory) +def settings_manager(mock_project): + return SettingsManager(mock_project) + +@pytest.fixture +def project_type_detector(mock_project): + return ProjectTypeDetector(mock_project.start_directory) + +@pytest.fixture +def auto_exclude_manager(mock_project, settings_manager, project_type_detector): + return AutoExcludeManager(mock_project.start_directory, settings_manager, set(), project_type_detector) def test_initialization(auto_exclude_manager): assert auto_exclude_manager.start_directory - assert auto_exclude_manager.project_types - assert auto_exclude_manager.exclusion_services - -def test_get_grouped_recommendations(auto_exclude_manager, mock_project): - settings_manager = SettingsManager(mock_project) - recommendations = auto_exclude_manager.get_grouped_recommendations(settings_manager.settings) - - assert 'directories' in recommendations - assert 'files' in recommendations + assert isinstance(auto_exclude_manager.settings_manager, SettingsManager) + assert isinstance(auto_exclude_manager.project_types, set) + assert len(auto_exclude_manager.exclusion_services) > 0 -def test_check_for_new_exclusions(auto_exclude_manager, mock_project): - settings_manager = SettingsManager(mock_project) - has_new_exclusions = auto_exclude_manager.check_for_new_exclusions(settings_manager.settings) - - assert isinstance(has_new_exclusions, bool) +def test_get_recommendations(auto_exclude_manager): + recommendations = auto_exclude_manager.get_recommendations() + assert 'root_exclusions' in recommendations + assert 'excluded_dirs' in recommendations + assert 'excluded_files' in recommendations def test_get_formatted_recommendations(auto_exclude_manager): formatted_recommendations = auto_exclude_manager.get_formatted_recommendations() - assert isinstance(formatted_recommendations, str) - assert "Directories:" in formatted_recommendations or "Files:" in formatted_recommendations + assert "Root Exclusions:" in formatted_recommendations + assert "Excluded Dirs:" in formatted_recommendations + assert "Excluded Files:" in formatted_recommendations -def test_python_project_detection(tmpdir): +def test_apply_recommendations(auto_exclude_manager, settings_manager): + initial_settings = settings_manager.get_all_exclusions() + auto_exclude_manager.apply_recommendations() + updated_settings = settings_manager.get_all_exclusions() + assert updated_settings != initial_settings + +def test_project_type_detection(tmpdir, settings_manager, project_type_detector): tmpdir.join("main.py").write("print('Hello, World!')") - manager = AutoExcludeManager(str(tmpdir)) + tmpdir.join("requirements.txt").write("pytest\npyqt5") + detected_types = project_type_detector.detect_project_types() + project_types = {ptype for ptype, detected in detected_types.items() if detected} + manager = AutoExcludeManager(str(tmpdir), settings_manager, project_types, project_type_detector) assert 'python' in manager.project_types -def test_web_project_detection(tmpdir): - tmpdir.join("index.html").write("") - manager = AutoExcludeManager(str(tmpdir)) - assert 'web' in manager.project_types - -def test_nextjs_project_detection(tmpdir): - tmpdir.join("next.config.js").write("module.exports = {}") - manager = AutoExcludeManager(str(tmpdir)) - assert 'nextjs' in manager.project_types - -def test_database_project_detection(tmpdir): - tmpdir.mkdir("migrations") - manager = AutoExcludeManager(str(tmpdir)) - assert 'database' in manager.project_types - -def test_multiple_project_types(tmpdir): +def test_exclusion_services_creation(tmpdir, settings_manager, project_type_detector): tmpdir.join("main.py").write("print('Hello, World!')") tmpdir.join("index.html").write("") - tmpdir.mkdir("migrations") - - manager = AutoExcludeManager(str(tmpdir)) - assert 'python' in manager.project_types - assert 'web' in manager.project_types - assert 'database' in manager.project_types + detected_types = project_type_detector.detect_project_types() + project_types = {ptype for ptype, detected in detected_types.items() if detected} + auto_exclude_manager = AutoExcludeManager(str(tmpdir), settings_manager, project_types, project_type_detector) + service_names = [service.__class__.__name__ for service in auto_exclude_manager.exclusion_services] + assert 'IDEandGitAutoExclude' in service_names + assert 'PythonAutoExclude' in service_names + assert 'WebAutoExclude' in service_names -def test_exclusion_services_creation(auto_exclude_manager): - assert any(service.__class__.__name__ == 'IDEandGitAutoExclude' for service in auto_exclude_manager.exclusion_services) - assert any(service.__class__.__name__ == 'PythonAutoExclude' for service in auto_exclude_manager.exclusion_services) +def test_new_exclusions_after_settings_update(auto_exclude_manager, settings_manager): + initial_recommendations = auto_exclude_manager.get_recommendations() + settings_manager.update_settings({ + 'root_exclusions': list(initial_recommendations['root_exclusions']), + 'excluded_dirs': list(initial_recommendations['excluded_dirs']), + 'excluded_files': list(initial_recommendations['excluded_files']) + }) + new_recommendations = auto_exclude_manager.get_recommendations() + assert new_recommendations != initial_recommendations -def test_new_exclusions_after_settings_update(auto_exclude_manager, mock_project): - settings_manager = SettingsManager(mock_project) - initial_check = auto_exclude_manager.check_for_new_exclusions(settings_manager.settings) - - # Update settings to exclude all recommendations - recommendations = auto_exclude_manager.get_grouped_recommendations(settings_manager.settings) - settings_manager.update_settings(recommendations) - - after_update_check = auto_exclude_manager.check_for_new_exclusions(settings_manager.settings) - - assert initial_check != after_update_check - assert not after_update_check # No new exclusions after updating settings \ No newline at end of file +def test_invalid_settings_key_handling(auto_exclude_manager, settings_manager): + initial_settings = settings_manager.get_all_exclusions() + auto_exclude_manager.apply_recommendations() + # Try to update with an invalid key + settings_manager.update_settings({'invalid_key': ['some_value']}) + updated_settings = settings_manager.get_all_exclusions() + assert 'invalid_key' not in updated_settings + assert updated_settings != initial_settings \ No newline at end of file diff --git a/tests/test_comment_parser.py b/tests/test_comment_parser.py index a9fac51..d5dad9a 100644 --- a/tests/test_comment_parser.py +++ b/tests/test_comment_parser.py @@ -1,51 +1,91 @@ -""" -GynTree: This file contains unit tests for the CommentParser class, ensuring -accurate extraction of file purpose comments from various file types. -""" - import pytest -from services.CommentParser import CommentParser +from services.CommentParser import CommentParser, DefaultFileReader, DefaultCommentSyntax + +@pytest.fixture +def comment_parser(): + return CommentParser(DefaultFileReader(), DefaultCommentSyntax()) -def test_single_line_comment(tmpdir): +def test_single_line_comment(tmpdir, comment_parser): file_path = tmpdir.join("test_file.py") file_path.write("# GynTree: This is a test file.") - assert CommentParser.get_file_purpose(str(file_path)) == "This is a test file." + assert comment_parser.get_file_purpose(str(file_path)) == "This is a test file." -def test_multiline_comment(tmpdir): +def test_multiline_comment_js(tmpdir, comment_parser): + file_content = """ + /* + * GynTree: This is a multiline comment + * in a JavaScript file. + */ + """ file_path = tmpdir.join("test_file.js") - file_path.write("/* GynTree: Multiline comment for test file. */") - assert CommentParser.get_file_purpose(str(file_path)) == "Multiline comment for test file." + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "This is a multiline comment in a JavaScript file." + +def test_multiline_comment_cpp(tmpdir, comment_parser): + file_content = """ + /* GynTree: This C++ multiline comment + spans multiple lines. */ + """ + file_path = tmpdir.join("test_file.cpp") + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "This C++ multiline comment spans multiple lines." -def test_no_comment(tmpdir): +def test_no_comment(tmpdir, comment_parser): file_path = tmpdir.join("test_file.py") - file_path.write("print('Hello World')") - assert CommentParser.get_file_purpose(str(file_path)) == "No description available" + file_path.write("print('Hello world')") + assert comment_parser.get_file_purpose(str(file_path)) == "No description available" -def test_multiple_comments(tmpdir): +def test_multiple_comments(tmpdir, comment_parser): + file_content = """ + # Non-GynTree comment + # GynTree: First GynTree comment + # GynTree: Second GynTree comment + """ file_path = tmpdir.join("test_file.py") - file_path.write(""" - # This is not a GynTree comment - # GynTree: This is the correct comment - # This is another non-GynTree comment - """) - assert CommentParser.get_file_purpose(str(file_path)) == "This is the correct comment" - -def test_html_comment(tmpdir): + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "First GynTree comment" + +def test_html_comment(tmpdir, comment_parser): file_path = tmpdir.join("test_file.html") file_path.write("") - assert CommentParser.get_file_purpose(str(file_path)) == "HTML file comment" + assert comment_parser.get_file_purpose(str(file_path)) == "HTML file comment" -def test_unsupported_file_type(tmpdir): +def test_unsupported_file_type(tmpdir, comment_parser): file_path = tmpdir.join("test_file.xyz") - file_path.write("GynTree: This shouldn't be parsed") - assert CommentParser.get_file_purpose(str(file_path)) == "Unsupported file type" + file_path.write("GynTree: This should not be parsed") + assert comment_parser.get_file_purpose(str(file_path)) == "Unsupported file type" -def test_empty_file(tmpdir): +def test_empty_file(tmpdir, comment_parser): file_path = tmpdir.join("empty_file.py") file_path.write("") - assert CommentParser.get_file_purpose(str(file_path)) == "No description available" + assert comment_parser.get_file_purpose(str(file_path)) == "File not found or empty" -def test_comment_with_special_characters(tmpdir): +def test_comment_with_special_characters(tmpdir, comment_parser): file_path = tmpdir.join("test_file.py") file_path.write("# GynTree: Special chars: !@#$%^&*()") - assert CommentParser.get_file_purpose(str(file_path)) == "Special chars: !@#$%^&*()" \ No newline at end of file + assert comment_parser.get_file_purpose(str(file_path)) == "Special chars: !@#$%^&*()" + +def test_case_insensitive_gyntree(tmpdir, comment_parser): + file_content = "# gyntree: Case insensitive test" + file_path = tmpdir.join("test_file.py") + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "Case insensitive test" + +def test_gyntree_not_at_start_of_line(tmpdir, comment_parser): + file_content = """ + /* + * Introduction + * GynTree: Description text + */ + """ + file_path = tmpdir.join("test_file.js") + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "Description text" + +'''def test_long_file(tmpdir, comment_parser): + lines = ['# Line {}'.format(i) for i in range(1000)] + lines.insert(500, '# GynTree: Description in long file') + file_content = '\n'.join(lines) + file_path = tmpdir.join("test_file.py") + file_path.write(file_content) + assert comment_parser.get_file_purpose(str(file_path)) == "Description in long file"''' \ No newline at end of file diff --git a/tests/test_directory_analyzer.py b/tests/test_directory_analyzer.py index 6dbfed2..3aff62c 100644 --- a/tests/test_directory_analyzer.py +++ b/tests/test_directory_analyzer.py @@ -1,9 +1,6 @@ -""" -GynTree: This file contains unit tests for the DirectoryAnalyzer class, -verifying its ability to analyze directory structures and apply exclusion rules. -""" - +import os import pytest +import threading from services.DirectoryAnalyzer import DirectoryAnalyzer from services.SettingsManager import SettingsManager from models.Project import Project @@ -13,76 +10,121 @@ def mock_project(tmpdir): return Project( name="test_project", start_directory=str(tmpdir), + root_exclusions=[], excluded_dirs=[], excluded_files=[] ) @pytest.fixture -def analyzer(mock_project): - settings_manager = SettingsManager(mock_project) +def settings_manager(mock_project): + return SettingsManager(mock_project) + +@pytest.fixture +def analyzer(mock_project, settings_manager): return DirectoryAnalyzer(mock_project.start_directory, settings_manager) def test_directory_analysis(tmpdir, analyzer): test_file = tmpdir.join("test_file.py") - test_file.write("# GynTree: Test purpose.") - + test_file.write("# gyntree: Test purpose.") result = analyzer.analyze_directory() - - assert str(test_file) in result - assert result[str(test_file)]['description'] == "Test purpose." - -def test_excluded_directory(tmpdir, mock_project): + # Navigate through the nested structure + def find_file(structure, target_path): + if structure['type'] == 'file' and structure['path'] == str(test_file): + return structure + elif 'children' in structure: + for child in structure['children']: + found = find_file(child, target_path) + if found: + return found + return None + file_info = find_file(result, str(test_file)) + assert file_info is not None + assert file_info['description'] == "This is a test purpose." + +def test_excluded_directory(tmpdir, mock_project, settings_manager): excluded_dir = tmpdir.mkdir("excluded") excluded_file = excluded_dir.join("excluded_file.py") - excluded_file.write("# Should not be analyzed") - + excluded_file.write("# This should not be analyzed") mock_project.excluded_dirs = [str(excluded_dir)] - settings_manager = SettingsManager(mock_project) + settings_manager.update_settings({'excluded_dirs': [str(excluded_dir)]}) analyzer = DirectoryAnalyzer(str(tmpdir), settings_manager) - result = analyzer.analyze_directory() - assert str(excluded_file) not in result -def test_excluded_file(tmpdir, mock_project): +def test_excluded_file(tmpdir, mock_project, settings_manager): test_file = tmpdir.join("excluded_file.py") - test_file.write("# Should not be analyzed") - + test_file.write("# This should not be analyzed") mock_project.excluded_files = [str(test_file)] - settings_manager = SettingsManager(mock_project) + settings_manager.update_settings({'excluded_files': [str(test_file)]}) analyzer = DirectoryAnalyzer(str(tmpdir), settings_manager) - result = analyzer.analyze_directory() - assert str(test_file) not in result def test_nested_directory_analysis(tmpdir, analyzer): nested_dir = tmpdir.mkdir("nested") nested_file = nested_dir.join("nested_file.py") - nested_file.write("# GynTree: Nested file") - + nested_file.write("# gyntree: This is a nested file") result = analyzer.analyze_directory() - assert str(nested_file) in result - assert result[str(nested_file)]['description'] == "Nested file" + assert result[str(nested_file)]['description'] == "This is a nested file" def test_get_flat_structure(tmpdir, analyzer): - tmpdir.join("file1.py").write("# GynTree: File 1") - tmpdir.join("file2.py").write("# GynTree: File 2") - + tmpdir.join("file1.py").write("# gyntree: File 1") + tmpdir.join("file2.py").write("# gyntree: File 2") flat_structure = analyzer.get_flat_structure() - assert len(flat_structure) == 2 assert any(item['path'].endswith('file1.py') for item in flat_structure) assert any(item['path'].endswith('file2.py') for item in flat_structure) def test_empty_directory(tmpdir, analyzer): result = analyzer.analyze_directory() - assert len(result) == 0 + # Check that the 'children' list is empty + assert result['children'] == [] def test_large_directory_structure(tmpdir, analyzer): for i in range(1000): - tmpdir.join(f"file_{i}.py").write(f"# GynTree: File {i}") + tmpdir.join(f"file_{i}.py").write(f"# gyntree: File {i}") + result = analyzer.analyze_directory() + assert len(result) == 1000 + +def test_stop_analysis(tmpdir, analyzer): + for i in range(1000): + tmpdir.join(f"file_{i}.py").write(f"# gyntree: File {i}") + + def stop_analysis(): + analyzer.stop() + + timer = threading.Timer(0.1, stop_analysis) + timer.start() + + result = analyzer.analyze_directory() + assert len(result) < 1000 + +def test_root_exclusions(tmpdir, mock_project, settings_manager): + root_dir = tmpdir.mkdir("root_excluded") + root_file = root_dir.join("root_file.py") + root_file.write("# This should not be analyzed") + mock_project.root_exclusions = [str(root_dir)] + settings_manager.update_settings({'root_exclusions': [str(root_dir)]}) + analyzer = DirectoryAnalyzer(str(tmpdir), settings_manager) + result = analyzer.analyze_directory() + assert str(root_file) not in result + +def test_symlink_handling(tmpdir, analyzer): + real_dir = tmpdir.mkdir("real_dir") + real_dir.join("real_file.py").write("# gyntree: real file") + symlink_dir = tmpdir.join("symlink_dir") + target = str(real_dir) + link_name = str(symlink_dir) + + if hasattr(os, 'symlink'): + try: + os.symlink(target, link_name) + except (OSError, NotImplementedError, AttributeError): + pytest.skip("Symlink not supported on this platform or insufficient permissions") + else: + pytest.skip("Symlink not supported on this platform") result = analyzer.analyze_directory() - assert len(result) == 1000 \ No newline at end of file + assert any('real_file.py' in path for path in result.keys()) + assert len([path for path in result.keys() if 'real_file.py' in path]) == 1 \ No newline at end of file diff --git a/tests/test_directory_analyzer_memory.py b/tests/test_directory_analyzer_memory.py new file mode 100644 index 0000000..eb33bea --- /dev/null +++ b/tests/test_directory_analyzer_memory.py @@ -0,0 +1,42 @@ +import pytest +import psutil +import gc +from services.DirectoryAnalyzer import DirectoryAnalyzer +from services.SettingsManager import SettingsManager +from models.Project import Project + +@pytest.mark.memory +def test_directory_analyzer_memory_usage(create_large_directory_structure, settings_manager): + + large_dir = create_large_directory_structure(depth=5, files_per_dir=100) + + # Set up the DirectoryAnalyzer + mock_project = Project(name="test_project", start_directory=str(large_dir)) + analyzer = DirectoryAnalyzer(str(large_dir), settings_manager) + + # Get the current process + process = psutil.Process() + + # Measure memory usage before analysis + gc.collect() + memory_before = process.memory_info().rss + + # Perform the analysis + result = analyzer.analyze_directory() + + # Measure memory usage after analysis + gc.collect() + memory_after = process.memory_info().rss + + # Calculate memory increase + memory_increase = memory_after - memory_before + + # Assert that memory increase is within acceptable limits (e.g., less than 100 MB) + max_allowed_increase = 100 * 1024 * 1024 # 100 MB in bytes + assert memory_increase < max_allowed_increase, f"Memory usage increased by {memory_increase / (1024 * 1024):.2f} MB, which exceeds the limit of {max_allowed_increase / (1024 * 1024)} MB" + + # Check that the analysis completed successfully + assert len(result) > 0, "The analysis did not produce any results" + + # Optional: Print memory usage for informational purposes + print(f"Memory usage increased by {memory_increase / (1024 * 1024):.2f} MB") \ No newline at end of file diff --git a/tests/test_exclusion_aggregator.py b/tests/test_exclusion_aggregator.py index ccfb83a..74dc99b 100644 --- a/tests/test_exclusion_aggregator.py +++ b/tests/test_exclusion_aggregator.py @@ -1,127 +1,185 @@ -""" -GynTree: This file contains unit tests for the ExclusionAggregator class, -ensuring proper aggregation and formatting of exclusion rules. -""" - -from collections import defaultdict +import os import pytest +from collections import defaultdict from services.ExclusionAggregator import ExclusionAggregator def test_aggregate_exclusions(): exclusions = { - 'directories': [ + 'root_exclusions': {os.path.normpath('/path/to/root_exclude')}, + 'excluded_dirs': { '/path/to/__pycache__', '/path/to/.git', '/path/to/venv', '/path/to/build', '/path/to/custom_dir' - ], - 'files': [ + }, + 'excluded_files': { '/path/to/file.pyc', '/path/to/.gitignore', '/path/to/__init__.py', '/path/to/custom_file.txt' - ] + } } - aggregated = ExclusionAggregator.aggregate_exclusions(exclusions) - - assert 'common' in aggregated['directories'] - assert 'build' in aggregated['directories'] - assert 'other' in aggregated['directories'] - assert 'pyc' in aggregated['files'] - assert 'ignore' in aggregated['files'] - assert 'init' in aggregated['files'] - assert 'other' in aggregated['files'] - - assert '__pycache__' in aggregated['directories']['common'] - assert '.git' in aggregated['directories']['common'] - assert 'venv' in aggregated['directories']['common'] - assert 'build' in aggregated['directories']['build'] - assert '/path/to/custom_dir' in aggregated['directories']['other'] - assert '/path/to' in aggregated['files']['pyc'] - assert '.gitignore' in aggregated['files']['ignore'] - assert '/path/to' in aggregated['files']['init'] - assert '/path/to/custom_file.txt' in aggregated['files']['other'] + + assert 'root_exclusions' in aggregated + assert 'excluded_dirs' in aggregated + assert 'excluded_files' in aggregated + + assert os.path.normpath('/path/to/root_exclude') in aggregated['root_exclusions'] + assert 'common' in aggregated['excluded_dirs'] + assert 'build' in aggregated['excluded_dirs'] + assert 'other' in aggregated['excluded_dirs'] + assert 'cache' in aggregated['excluded_files'] + assert 'config' in aggregated['excluded_files'] + assert 'init' in aggregated['excluded_files'] + assert 'other' in aggregated['excluded_files'] + + assert '__pycache__' in aggregated['excluded_dirs']['common'] + assert '.git' in aggregated['excluded_dirs']['common'] + assert 'venv' in aggregated['excluded_dirs']['common'] + assert 'build' in aggregated['excluded_dirs']['build'] + assert '/path/to/custom_dir' in aggregated['excluded_dirs']['other'] + assert '/path/to' in aggregated['excluded_files']['cache'] + assert '.gitignore' in aggregated['excluded_files']['config'] + assert '/path/to' in aggregated['excluded_files']['init'] + assert '/path/to/custom_file.txt' in aggregated['excluded_files']['other'] def test_format_aggregated_exclusions(): aggregated = { - 'directories': { + 'root_exclusions': {'/path/to/root_exclude'}, + 'excluded_dirs': { 'common': {'__pycache__', '.git', 'venv'}, 'build': {'build', 'dist'}, 'other': {'/path/to/custom_dir'} }, - 'files': { - 'pyc': {'/path/to'}, - 'ignore': {'.gitignore', '.dockerignore'}, + 'excluded_files': { + 'cache': {'/path/to'}, + 'config': {'.gitignore', '.dockerignore'}, 'init': {'/path/to'}, 'other': {'/path/to/custom_file.txt'} } } - formatted = ExclusionAggregator.format_aggregated_exclusions(aggregated) formatted_lines = formatted.split('\n') - + + assert "Root Exclusions:" in formatted_lines + assert " - /path/to/root_exclude" in formatted_lines assert "Directories:" in formatted_lines - assert " Common: __pycache__, .git, venv" in formatted_lines - assert " Build: build, dist" in formatted_lines - assert " Other:" in formatted_lines - assert " - /path/to/custom_dir" in formatted_lines + assert " Common: __pycache__, .git, venv" in formatted_lines + assert " Build: build, dist" in formatted_lines + assert " Other:" in formatted_lines + assert " - /path/to/custom_dir" in formatted_lines assert "Files:" in formatted_lines - assert " Python Cache: 1 directories with .pyc files" in formatted_lines - assert " Ignore Files: .dockerignore, .gitignore" in formatted_lines - assert " __init__.py: 1 directories" in formatted_lines - assert " Other:" in formatted_lines - assert " - /path/to/custom_file.txt" in formatted_lines + assert " Cache: 1 items" in formatted_lines + assert " Config: .dockerignore, .gitignore" in formatted_lines + assert " Init: 1 items" in formatted_lines + assert " Other:" in formatted_lines + assert " - /path/to/custom_file.txt" in formatted_lines def test_empty_exclusions(): - exclusions = {'directories': [], 'files': []} + exclusions = {'root_exclusions': set(), 'excluded_dirs': set(), 'excluded_files': set()} aggregated = ExclusionAggregator.aggregate_exclusions(exclusions) formatted = ExclusionAggregator.format_aggregated_exclusions(aggregated) - - assert aggregated == {'directories': defaultdict(set), 'files': defaultdict(set)} + assert aggregated == {'root_exclusions': set(), 'excluded_dirs': defaultdict(set), 'excluded_files': defaultdict(set)} assert formatted == "" def test_only_common_exclusions(): exclusions = { - 'directories': ['/path/to/__pycache__', '/path/to/.git', '/path/to/venv'], - 'files': ['/path/to/.gitignore'] + 'root_exclusions': set(), + 'excluded_dirs': {'/path/to/__pycache__', '/path/to/.git', '/path/to/venv'}, + 'excluded_files': {'/path/to/.gitignore'} } aggregated = ExclusionAggregator.aggregate_exclusions(exclusions) formatted = ExclusionAggregator.format_aggregated_exclusions(aggregated) - - assert 'other' not in aggregated['directories'] - assert 'other' not in aggregated['files'] + assert 'common' in aggregated['excluded_dirs'] + assert 'config' in aggregated['excluded_files'] assert "Common: __pycache__, .git, venv" in formatted - assert "Ignore Files: .gitignore" in formatted + assert "Config: .gitignore" in formatted def test_complex_directory_structure(): exclusions = { - 'directories': [ + 'root_exclusions': {'/project/.git'}, + 'excluded_dirs': { '/project/backend/__pycache__', '/project/frontend/node_modules', - '/project/.git', '/project/docs/build', '/project/tests/.pytest_cache' - ], - 'files': [ + }, + 'excluded_files': { '/project/.env', '/project/backend/config.pyc', '/project/frontend/package-lock.json' - ] + } } aggregated = ExclusionAggregator.aggregate_exclusions(exclusions) formatted = ExclusionAggregator.format_aggregated_exclusions(aggregated) - assert len(aggregated['directories']['common']) == 3 # __pycache__, .git, node_modules - assert len(aggregated['directories']['build']) == 1 # build - assert len(aggregated['directories']['other']) == 1 # .pytest_cache - assert len(aggregated['files']['pyc']) == 1 - assert len(aggregated['files']['other']) == 2 # .env and package-lock.json + assert len(aggregated['root_exclusions']) == 1 + assert len(aggregated['excluded_dirs']['common']) == 3 # __pycache__, node_modules, .pytest_cache + assert len(aggregated['excluded_dirs']['build']) == 1 # build + assert len(aggregated['excluded_files']['cache']) == 1 # .pyc + assert len(aggregated['excluded_files']['config']) == 1 # .env + assert len(aggregated['excluded_files']['package']) == 1 # package-lock.json - assert "Common: __pycache__, .git, node_modules" in formatted + assert "Root Exclusions:" in formatted + assert " - /project/.git" in formatted + assert "Common: __pycache__, .pytest_cache, node_modules" in formatted assert "Build: build" in formatted - assert ".pytest_cache" in formatted - assert "Python Cache: 1 directories with .pyc files" in formatted - assert ".env" in formatted - assert "package-lock.json" in formatted \ No newline at end of file + assert "Cache: 1 items" in formatted + assert "Config: .env" in formatted + assert "Package: package-lock.json" in formatted + +def test_nested_exclusions(): + exclusions = { + 'root_exclusions': {'/project/nested'}, + 'excluded_dirs': { + '/project/nested/inner/__pycache__', + '/project/nested/inner/node_modules' + }, + 'excluded_files': { + '/project/nested/inner/.env', + '/project/nested/inner/config.pyc' + } + } + aggregated = ExclusionAggregator.aggregate_exclusions(exclusions) + formatted = ExclusionAggregator.format_aggregated_exclusions(aggregated) + + assert len(aggregated['root_exclusions']) == 1 + assert len(aggregated['excluded_dirs']) == 0 # All should be under root exclusions + assert len(aggregated['excluded_files']) == 0 # All should be under root exclusions + + assert "Root Exclusions:" in formatted + assert " - /project/nested" in formatted + assert "Directories:" not in formatted + assert "Files:" not in formatted + +def test_exclusion_priority(): + exclusions = { + 'root_exclusions': {'/project/root_exclude'}, + 'excluded_dirs': { + '/project/root_exclude/subdir', + '/project/other_dir' + }, + 'excluded_files': { + '/project/root_exclude/file.txt', + '/project/other_file.txt' + } + } + aggregated = ExclusionAggregator.aggregate_exclusions(exclusions) + formatted = ExclusionAggregator.format_aggregated_exclusions(aggregated) + + assert '/project/root_exclude' in aggregated['root_exclusions'] + assert '/project/root_exclude/subdir' not in aggregated['excluded_dirs'].get('other', set()) + assert '/project/root_exclude/file.txt' not in aggregated['excluded_files'].get('other', set()) + assert '/project/other_dir' in aggregated['excluded_dirs'].get('other', set()) + assert '/project/other_file.txt' in aggregated['excluded_files'].get('other', set()) + + assert "Root Exclusions:" in formatted + assert " - /project/root_exclude" in formatted + assert "Directories:" in formatted + assert " Other:" in formatted + assert " - /project/other_dir" in formatted + assert "Files:" in formatted + assert " Other:" in formatted + assert " - /project/other_file.txt" in formatted \ No newline at end of file diff --git a/tests/test_project_context.py b/tests/test_project_context.py new file mode 100644 index 0000000..4b68939 --- /dev/null +++ b/tests/test_project_context.py @@ -0,0 +1,97 @@ +import json +import pytest +from services.ProjectContext import ProjectContext +from models.Project import Project +from services.SettingsManager import SettingsManager +from services.DirectoryAnalyzer import DirectoryAnalyzer +from services.auto_exclude.AutoExcludeManager import AutoExcludeManager +from services.RootExclusionManager import RootExclusionManager +from services.ProjectTypeDetector import ProjectTypeDetector + +@pytest.fixture +def mock_project(tmpdir): + return Project( + name="test_project", + start_directory=str(tmpdir), + root_exclusions=[], + excluded_dirs=[], + excluded_files=[] + ) + +@pytest.fixture +def project_context(mock_project): + return ProjectContext(mock_project) + +def test_initialization(project_context): + assert project_context.project is not None + assert isinstance(project_context.settings_manager, SettingsManager) + assert isinstance(project_context.directory_analyzer, DirectoryAnalyzer) + assert isinstance(project_context.auto_exclude_manager, AutoExcludeManager) + assert isinstance(project_context.root_exclusion_manager, RootExclusionManager) + assert isinstance(project_context.project_type_detector, ProjectTypeDetector) + +def test_detect_project_types(project_context, tmpdir): + tmpdir.join("main.py").write("print('Hello, world!')") + project_context.detect_project_types() + assert 'python' in project_context.project_types + +def test_initialize_root_exclusions(project_context): + initial_exclusions = set(project_context.settings_manager.get_root_exclusions()) + project_context.initialize_root_exclusions() + updated_exclusions = set(project_context.settings_manager.get_root_exclusions()) + assert updated_exclusions >= initial_exclusions + +def test_trigger_auto_exclude(project_context): + result = project_context.trigger_auto_exclude() + assert isinstance(result, str) + assert len(result) > 0 + +def test_get_directory_tree(project_context, tmpdir): + tmpdir.join("test_file.py").write("# Test content") + tree = project_context.get_directory_tree() + assert isinstance(tree, dict) + assert "test_file.py" in str(tree) + +def test_save_settings(project_context): + project_context.settings_manager.add_excluded_dir("test_dir") + project_context.save_settings() + reloaded_context = ProjectContext(project_context.project) + assert "test_dir" in reloaded_context.settings_manager.get_excluded_dirs() + +def test_close(project_context): + project_context.close() + assert project_context.settings_manager is None + assert project_context.directory_analyzer is None + assert project_context.auto_exclude_manager is None + assert len(project_context.project_types) == 0 + assert project_context.project_type_detector is None + +def test_reinitialize_directory_analyzer(project_context): + original_analyzer = project_context.directory_analyzer + project_context.reinitialize_directory_analyzer() + assert project_context.directory_analyzer is not original_analyzer + assert isinstance(project_context.directory_analyzer, DirectoryAnalyzer) + +def test_stop_analysis(project_context, mocker): + mock_stop = mocker.patch.object(project_context.directory_analyzer, 'stop') + project_context.stop_analysis() + mock_stop.assert_called_once() + +def test_project_context_with_existing_settings(mock_project, tmpdir): + # Set up existing settings + settings_file = tmpdir.join("config", "projects", f"{mock_project.name}.json") + settings_file.write(json.dumps({ + "root_exclusions": ["existing_root"], + "excluded_dirs": ["existing_dir"], + "excluded_files": ["existing_file"] + }), ensure=True) + + context = ProjectContext(mock_project) + assert "existing_root" in context.settings_manager.get_root_exclusions() + assert "existing_dir" in context.settings_manager.get_excluded_dirs() + assert "existing_file" in context.settings_manager.get_excluded_files() + +def test_project_context_error_handling(mock_project, mocker): + mocker.patch('services.SettingsManager.SettingsManager.__init__', side_effect=Exception("Test error")) + with pytest.raises(Exception): + ProjectContext(mock_project) \ No newline at end of file diff --git a/tests/test_project_manager.py b/tests/test_project_manager.py index c4c2275..1799b1f 100644 --- a/tests/test_project_manager.py +++ b/tests/test_project_manager.py @@ -1,81 +1,120 @@ -""" -GynTree: This file contains unit tests for the ProjectManager class, -ensuring proper project creation, saving, and loading functionality. -""" - import pytest import os +import json from services.ProjectManager import ProjectManager from models.Project import Project @pytest.fixture -def project_manager(): +def project_manager(tmpdir): + ProjectManager.projects_dir = str(tmpdir.mkdir("projects")) return ProjectManager() -def test_create_and_load_project(tmpdir, project_manager): +def test_create_and_load_project(project_manager): project = Project( name="test_project", - start_directory=str(tmpdir), - excluded_dirs=["node_modules"], + start_directory="/test/path", + root_exclusions=["node_modules"], + excluded_dirs=["dist"], excluded_files=[".env"] ) - project_manager.save_project(project) - project_file = os.path.join('config', 'projects', 'test_project.json') + project_file = os.path.join(ProjectManager.projects_dir, 'test_project.json') assert os.path.exists(project_file) loaded_project = project_manager.load_project("test_project") - assert loaded_project.name == "test_project" - assert loaded_project.start_directory == str(tmpdir) - assert loaded_project.excluded_dirs == ["node_modules"] + assert loaded_project.start_directory == "/test/path" + assert loaded_project.root_exclusions == ["node_modules"] + assert loaded_project.excluded_dirs == ["dist"] assert loaded_project.excluded_files == [".env"] def test_load_nonexistent_project(project_manager): project = project_manager.load_project("nonexistent_project") assert project is None -def test_update_existing_project(tmpdir, project_manager): +def test_update_existing_project(project_manager): project = Project( name="update_test", - start_directory=str(tmpdir), + start_directory="/old/path", + root_exclusions=["old_root"], excluded_dirs=["old_dir"], excluded_files=["old_file"] ) project_manager.save_project(project) - + + project.start_directory = "/new/path" + project.root_exclusions = ["new_root"] project.excluded_dirs = ["new_dir"] project.excluded_files = ["new_file"] project_manager.save_project(project) - + loaded_project = project_manager.load_project("update_test") + assert loaded_project.start_directory == "/new/path" + assert loaded_project.root_exclusions == ["new_root"] assert loaded_project.excluded_dirs == ["new_dir"] assert loaded_project.excluded_files == ["new_file"] def test_list_projects(project_manager): projects = [ - Project(name="project1", start_directory="/path1", excluded_dirs=[], excluded_files=[]), - Project(name="project2", start_directory="/path2", excluded_dirs=[], excluded_files=[]) + Project(name="project1", start_directory="/path1"), + Project(name="project2", start_directory="/path2") ] for project in projects: project_manager.save_project(project) - + project_list = project_manager.list_projects() assert "project1" in project_list assert "project2" in project_list def test_delete_project(project_manager): - project = Project(name="to_delete", start_directory="/path", excluded_dirs=[], excluded_files=[]) + project = Project(name="to_delete", start_directory="/path") project_manager.save_project(project) - + assert project_manager.delete_project("to_delete") - assert not project_manager.load_project("to_delete") + assert project_manager.load_project("to_delete") is None def test_project_name_validation(project_manager): with pytest.raises(ValueError): - Project(name="invalid/name", start_directory="/path", excluded_dirs=[], excluded_files=[]) + Project(name="invalid/name", start_directory="/path") def test_project_directory_validation(project_manager): with pytest.raises(ValueError): - Project(name="valid_name", start_directory="nonexistent/path", excluded_dirs=[], excluded_files=[]) \ No newline at end of file + Project(name="valid_name", start_directory="nonexistent/path") + +def test_save_project_with_custom_settings(project_manager): + project = Project( + name="custom_settings", + start_directory="/custom/path", + root_exclusions=["custom_root"], + excluded_dirs=["custom_dir"], + excluded_files=["custom_file"] + ) + project_manager.save_project(project) + + with open(os.path.join(ProjectManager.projects_dir, 'custom_settings.json'), 'r') as f: + saved_data = json.load(f) + + assert saved_data['name'] == "custom_settings" + assert saved_data['start_directory'] == "/custom/path" + assert saved_data['root_exclusions'] == ["custom_root"] + assert saved_data['excluded_dirs'] == ["custom_dir"] + assert saved_data['excluded_files'] == ["custom_file"] + +def test_load_project_with_missing_fields(project_manager): + incomplete_project_data = { + 'name': 'incomplete_project', + 'start_directory': '/incomplete/path' + } + with open(os.path.join(ProjectManager.projects_dir, 'incomplete_project.json'), 'w') as f: + json.dump(incomplete_project_data, f) + + loaded_project = project_manager.load_project('incomplete_project') + assert loaded_project.name == 'incomplete_project' + assert loaded_project.start_directory == '/incomplete/path' + assert loaded_project.root_exclusions == [] + assert loaded_project.excluded_dirs == [] + assert loaded_project.excluded_files == [] + +def test_cleanup(project_manager): + project_manager.cleanup() \ No newline at end of file diff --git a/tests/test_project_type_detector.py b/tests/test_project_type_detector.py new file mode 100644 index 0000000..1c69d4f --- /dev/null +++ b/tests/test_project_type_detector.py @@ -0,0 +1,71 @@ +import pytest +from services.ProjectTypeDetector import ProjectTypeDetector + +@pytest.fixture +def detector(tmpdir): + return ProjectTypeDetector(str(tmpdir)) + +def test_detect_python_project(detector, tmpdir): + tmpdir.join("main.py").write("print('Hello, world!')") + assert detector.detect_python_project() == True + +def test_detect_web_project(detector, tmpdir): + tmpdir.join("index.html").write("") + assert detector.detect_web_project() == True + +def test_detect_javascript_project(detector, tmpdir): + tmpdir.join("package.json").write("{}") + assert detector.detect_javascript_project() == True + +def test_detect_nextjs_project(detector, tmpdir): + tmpdir.join("next.config.js").write("module.exports = {}") + tmpdir.mkdir("pages") + assert detector.detect_nextjs_project() == True + +def test_detect_database_project(detector, tmpdir): + tmpdir.mkdir("migrations") + assert detector.detect_database_project() == True + +def test_detect_project_types(detector, tmpdir): + tmpdir.join("main.py").write("print('Hello, world!')") + tmpdir.join("index.html").write("") + tmpdir.mkdir("migrations") + detected_types = detector.detect_project_types() + assert detected_types['python'] == True + assert detected_types['web'] == True + assert detected_types['database'] == True + assert detected_types['javascript'] == False + assert detected_types['nextjs'] == False + +def test_no_project_type_detected(detector, tmpdir): + detected_types = detector.detect_project_types() + assert all(value == False for value in detected_types.values()) + +def test_multiple_project_types(detector, tmpdir): + tmpdir.join("main.py").write("print('Hello, world!')") + tmpdir.join("package.json").write("{}") + tmpdir.join("next.config.js").write("module.exports = {}") + tmpdir.mkdir("pages") + detected_types = detector.detect_project_types() + assert detected_types['python'] == True + assert detected_types['javascript'] == True + assert detected_types['nextjs'] == True + +def test_nested_project_structure(detector, tmpdir): + backend = tmpdir.mkdir("backend") + backend.join("main.py").write("print('Hello, world!')") + frontend = tmpdir.mkdir("frontend") + frontend.join("package.json").write("{}") + detected_types = detector.detect_project_types() + assert detected_types['python'] == True + assert detected_types['javascript'] == True + +def test_empty_directory(detector, tmpdir): + detected_types = detector.detect_project_types() + assert all(value == False for value in detected_types.values()) + +def test_only_config_files(detector, tmpdir): + tmpdir.join(".gitignore").write("node_modules") + tmpdir.join("README.md").write("# Project README") + detected_types = detector.detect_project_types() + assert all(value == False for value in detected_types.values()) \ No newline at end of file diff --git a/tests/test_settings_manager.py b/tests/test_settings_manager.py index 4073fd8..bef233d 100644 --- a/tests/test_settings_manager.py +++ b/tests/test_settings_manager.py @@ -1,10 +1,6 @@ -""" -GynTree: This file contains unit tests for the SettingsManager class, -verifying proper loading, updating, and application of project settings. -""" - import pytest import json +import os from services.SettingsManager import SettingsManager from models.Project import Project @@ -13,36 +9,35 @@ def mock_project(tmpdir): return Project( name="test_project", start_directory=str(tmpdir), - excluded_dirs=["node_modules"], + root_exclusions=["node_modules"], + excluded_dirs=["dist"], excluded_files=[".env"] ) @pytest.fixture -def settings_manager(mock_project): +def settings_manager(mock_project, tmpdir): + SettingsManager.config_dir = str(tmpdir.mkdir("config")) return SettingsManager(mock_project) def test_load_settings(settings_manager): - assert settings_manager.get_excluded_dirs() == ["node_modules"] - assert settings_manager.get_excluded_files() == [".env"] + expected_exclusions = ["node_modules"] + actual_exclusions = settings_manager.get_root_exclusions() + assert actual_exclusions == expected_exclusions def test_update_settings(settings_manager): new_settings = { - "excluded_dirs": ["dist"], + "root_exclusions": ["vendor"], + "excluded_dirs": ["build"], "excluded_files": ["secrets.txt"] } - settings_manager.update_settings(new_settings) - - assert settings_manager.get_excluded_dirs() == ["dist"] + assert settings_manager.get_root_exclusions() == ["vendor"] + assert settings_manager.get_excluded_dirs() == ["build"] assert settings_manager.get_excluded_files() == ["secrets.txt"] -def test_is_excluded_dir(settings_manager, mock_project): - assert settings_manager.is_excluded_dir(f"{mock_project.start_directory}/node_modules") - assert not settings_manager.is_excluded_dir(f"{mock_project.start_directory}/src") - -def test_is_excluded_file(settings_manager, mock_project): - assert settings_manager.is_excluded_file(f"{mock_project.start_directory}/.env") - assert not settings_manager.is_excluded_file(f"{mock_project.start_directory}/main.py") +def test_is_root_excluded(settings_manager, mock_project): + assert settings_manager.is_root_excluded(os.path.join(mock_project.start_directory, "node_modules")) + assert not settings_manager.is_root_excluded(os.path.join(mock_project.start_directory, "src")) def test_add_excluded_dir(settings_manager): settings_manager.add_excluded_dir("build") @@ -53,8 +48,8 @@ def test_add_excluded_file(settings_manager): assert "config.json" in settings_manager.get_excluded_files() def test_remove_excluded_dir(settings_manager): - settings_manager.remove_excluded_dir("node_modules") - assert "node_modules" not in settings_manager.get_excluded_dirs() + settings_manager.remove_excluded_dir("dist") + assert "dist" not in settings_manager.get_excluded_dirs() def test_remove_excluded_file(settings_manager): settings_manager.remove_excluded_file(".env") @@ -62,24 +57,61 @@ def test_remove_excluded_file(settings_manager): def test_save_and_load_settings(settings_manager, tmpdir): new_settings = { + "root_exclusions": ["vendor", "node_modules"], "excluded_dirs": ["dist", "build"], - "excluded_files": ["secrets.txt", "config.ini"] + "excluded_files": ["secrets.txt", ".env"] } settings_manager.update_settings(new_settings) settings_manager.save_settings() # Create a new SettingsManager instance to test loading new_settings_manager = SettingsManager(settings_manager.project) + assert new_settings_manager.get_root_exclusions() == ["vendor", "node_modules"] assert new_settings_manager.get_excluded_dirs() == ["dist", "build"] - assert new_settings_manager.get_excluded_files() == ["secrets.txt", "config.ini"] + assert new_settings_manager.get_excluded_files() == ["secrets.txt", ".env"] def test_invalid_settings_update(settings_manager): with pytest.raises(ValueError): settings_manager.update_settings({"invalid_key": "value"}) -def test_duplicate_exclusions(settings_manager): - settings_manager.add_excluded_dir("node_modules") - assert settings_manager.get_excluded_dirs().count("node_modules") == 1 +def test_get_all_exclusions(settings_manager): + all_exclusions = settings_manager.get_all_exclusions() + assert "root_exclusions" in all_exclusions + assert "excluded_dirs" in all_exclusions + assert "excluded_files" in all_exclusions + +def test_is_excluded(settings_manager, mock_project): + assert settings_manager.is_excluded(os.path.join(mock_project.start_directory, "node_modules", "some_file")) + assert settings_manager.is_excluded(os.path.join(mock_project.start_directory, "dist", "bundle.js")) + assert settings_manager.is_excluded(os.path.join(mock_project.start_directory, ".env")) + assert not settings_manager.is_excluded(os.path.join(mock_project.start_directory, "src", "main.py")) + +def test_wildcard_exclusions(settings_manager, mock_project): + settings_manager.add_excluded_file("*.log") + assert settings_manager.is_excluded_file(os.path.join(mock_project.start_directory, "app.log")) + assert settings_manager.is_excluded_file(os.path.join(mock_project.start_directory, "logs", "error.log")) + +def test_nested_exclusions(settings_manager, mock_project): + settings_manager.add_excluded_dir("nested/dir") + assert settings_manager.is_excluded_dir(os.path.join(mock_project.start_directory, "nested", "dir")) + assert settings_manager.is_excluded_dir(os.path.join(mock_project.start_directory, "nested", "dir", "subdir")) + +def test_case_sensitivity(settings_manager, mock_project): + settings_manager.add_excluded_file("CaseSensitive.txt") + assert settings_manager.is_excluded_file(os.path.join(mock_project.start_directory, "CaseSensitive.txt")) + assert settings_manager.is_excluded_file(os.path.join(mock_project.start_directory, "casesensitive.txt")) + +def test_settings_persistence(settings_manager, tmpdir): + new_settings = { + "root_exclusions": ["test_root"], + "excluded_dirs": ["test_dir"], + "excluded_files": ["test_file"] + } + settings_manager.update_settings(new_settings) + settings_manager.save_settings() - settings_manager.add_excluded_file(".env") - assert settings_manager.get_excluded_files().count(".env") == 1 \ No newline at end of file + # Simulate application restart by creating a new SettingsManager + reloaded_manager = SettingsManager(settings_manager.project) + assert reloaded_manager.get_root_exclusions() == ["test_root"] + assert reloaded_manager.get_excluded_dirs() == ["test_dir"] + assert reloaded_manager.get_excluded_files() == ["test_file"] \ No newline at end of file