From 2a99d0ebb30986dfc638fce0d0c349f5eaafaf7f Mon Sep 17 00:00:00 2001 From: furkanonder Date: Mon, 9 Oct 2023 00:46:08 +0300 Subject: [PATCH] first commit --- .gitignore | 137 ++++++++++++++++++++++++++++++++++++++++ .pre-commit-config.yaml | 58 +++++++++++++++++ LICENSE.txt | 25 ++++++++ README.md | 57 +++++++++++++++++ pyproject.toml | 53 ++++++++++++++++ src/akarsu/__init__.py | 0 src/akarsu/__main__.py | 34 ++++++++++ src/akarsu/akarsu.py | 69 ++++++++++++++++++++ tests/__init__.py | 0 tests/test_akarsu.py | 114 +++++++++++++++++++++++++++++++++ 10 files changed, 547 insertions(+) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 pyproject.toml create mode 100644 src/akarsu/__init__.py create mode 100644 src/akarsu/__main__.py create mode 100644 src/akarsu/akarsu.py create mode 100644 tests/__init__.py create mode 100644 tests/test_akarsu.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fbef448 --- /dev/null +++ b/.gitignore @@ -0,0 +1,137 @@ +*.pyc +*.db +*.scssc +*.map +*.idea +media + +# Created by https://www.gitignore.io/api/python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +### Python Patch ### +.venv/ + +### Python.VirtualEnv Stack ### +# Virtualenv +# http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ +[Bb]in +[Ii]nclude +[Ll]ib +[Ll]ib64 +[Ll]ocal +[Ss]cripts +pyvenv.cfg +pip-selfcheck.json + +# End of https://www.gitignore.io/api/python diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..11cf8a8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,58 @@ +repos: + - repo: https://github.com/psf/black + rev: 23.9.1 + hooks: + - id: black + + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + args: ["--profile", "black", "--filter-files"] + + - repo: https://github.com/hakancelikdev/unimport + rev: 1.0.0 + hooks: + - id: unimport + args: [--remove, --include-star-import] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.5.1 + hooks: + - id: mypy + exclude: docs + args: + [ + --ignore-missing-imports, + --show-error-codes, + --disallow-incomplete-defs, + --explicit-package-bases, + ] + additional_dependencies: [types-toml==0.1.3] + + - repo: https://github.com/PyCQA/docformatter + rev: v1.7.5 + hooks: + - id: docformatter + args: [--in-place] + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v3.0.3 + hooks: + - id: prettier + args: [--prose-wrap=always, --print-width=88] + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: end-of-file-fixer + files: "\\.(py|.txt|.yaml|.json|.in|.md|.toml|.cfg|.html|.yml)$" + + - repo: local + hooks: + - id: unittests + name: run unit tests + entry: python -m unittest + language: system + pass_filenames: false + args: ["discover"] diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..034aef4 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,25 @@ +The MIT License (MIT) +===================== + +Copyright © `2023` `Furkan Onder` + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the “Software”), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..96213dd --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +_akarsu_ is the New Generation Profiler based on +[PEP 669](https://peps.python.org/pep-0669/). The name of the project, comes from the +surname of a minstrel named `Muhlis Akarsu`, which means `stream`. + +## Installation + +_akarsu_ can be installed by running `pip install akarsu`. It requires Python 3.12.0+ to +run. + +## Usage + +```sh +cat example.py +``` + +Output: + +```python +def foo(): + x = 1 + isinstance(x, int) + return x + + +def bar(): + foo() + + +bar() +``` + +--- + +```sh +akarsu -f example.py +``` + +Output: + +``` + Count Event Type Filename(function) + 1 PY_CALL example.py(bar) + 1 PY_START example.py(bar) + 1 PY_CALL example.py(foo) + 1 PY_START example.py(foo) + 1 C_CALL example.py() + 1 C_RETURN example.py(foo) + 1 PY_RETURN example.py(foo) + 1 PY_RETURN example.py(bar) + +Total number of events: 8 + PY_CALL = 2 + PY_START = 2 + PY_RETURN = 2 + C_CALL = 1 + C_RETURN = 1 +``` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..06e9b26 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "akarsu" +version = "0.1.0" +description = "New Generation Profiler based on PEP 669" +readme = "README.md" +requires-python = ">=3.12" +license = {file = "LICENSE.txt"} +keywords = ["profiler", "PEP669"] +authors = [ + { name = "Furkan Onder", email = "furkanonder@protonmail.com" }, +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.12", + "Topic :: Software Development :: Libraries :: Python Modules", +] + +[project.urls] +"Homepage" = "https://github.com/furkanonder/akarsu" +"Bug Reports" = "https://github.com/furkanonder/akarsu/issues" +"Source" = "https://github.com/furkanonder/akarsu" + +[project.scripts] +akarsu = "akarsu.__main__:main" + +[tool.black] +target-version = ["py312"] +preview = true + +[tool.isort] +profile = "black" + +[tool.docformatter] +recursive = true +wrap-summaries = 79 +wrap-descriptions = 79 +blank = true + +[tool.mypy] +warn_unused_configs = true +no_strict_optional = true +ignore_missing_imports = true +show_error_codes = true diff --git a/src/akarsu/__init__.py b/src/akarsu/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/akarsu/__main__.py b/src/akarsu/__main__.py new file mode 100644 index 0000000..f5bea35 --- /dev/null +++ b/src/akarsu/__main__.py @@ -0,0 +1,34 @@ +import argparse +import io +from collections import Counter + +from akarsu.akarsu import Akarsu + + +def main() -> None: + parser = argparse.ArgumentParser( + description="New Generation Profiler based on PEP 669" + ) + parser.add_argument("-v", "--version", action="version", version="0.1.0") + parser.add_argument("-f", "--file", type=str, help="Path to the file") + args = parser.parse_args() + + if file := args.file: + with io.open(file) as fp: + source = fp.read() + events = Akarsu(source, args.file).profile() + counter: Counter = Counter() + + print(f"{'Count':>10}{'Event Type':^20}{'Filename(function)':<50}") + for event, count in Counter(events).most_common(): + event_type, file_name, func_name = event + counter[event_type] += 1 + print(f"{count:>10}{event_type:^20}{f'{file_name}({func_name})':<50}") + + print(f"\nTotal number of events: {counter.total()}") + for event_type, count in counter.most_common(): + print(f" {event_type} = {count}") + + +if __name__ == "__main__": + main() diff --git a/src/akarsu/akarsu.py b/src/akarsu/akarsu.py new file mode 100644 index 0000000..005e33c --- /dev/null +++ b/src/akarsu/akarsu.py @@ -0,0 +1,69 @@ +import sys +import types +from typing import Any, Final, cast + +TOOL: Final[int] = 2 +PY_CALLABLES: Final[tuple] = (types.FunctionType, types.MethodType) +MONITOR = sys.monitoring # type:ignore + +EVENTS = MONITOR.events +TRACKED_EVENTS: Final[tuple[tuple[int, str], ...]] = ( + (EVENTS.PY_START, "PY_START"), + (EVENTS.PY_RESUME, "RESUME"), + (EVENTS.PY_THROW, "THROW"), + (EVENTS.PY_RETURN, "PY_RETURN"), + (EVENTS.PY_YIELD, "YIELD"), + (EVENTS.PY_UNWIND, "UNWIND"), + (EVENTS.C_RAISE, "C_RAISE"), + (EVENTS.C_RETURN, "C_RETURN"), + (EVENTS.EXCEPTION_HANDLED, "EXCEPTION_HANDLED"), + (EVENTS.STOP_ITERATION, "STOP ITERATION"), +) +EVENT_SET: Final[int] = EVENTS.CALL + sum(ev for ev, _ in TRACKED_EVENTS) + + +class Akarsu: + def __init__(self, code: str, file_name: str) -> None: + self.code = code + self.file_name = file_name + + def profile(self) -> list[tuple[str, str, str]]: + events = [] + + if code := self.code.strip(): + indented_code = "\n".join(f"\t{line}" for line in code.splitlines()) + source = f"def ____wrapper____():\n{indented_code}\n____wrapper____()" + code = compile(source, self.file_name, "exec") # type:ignore + + for event, event_name in TRACKED_EVENTS: + + def record( + *args: tuple[types.CodeType, int], event_name: str = event_name + ) -> None: + code = cast(types.CodeType, args[0]) + events.append((event_name, code.co_filename, code.co_name)) + + MONITOR.register_callback(TOOL, event, record) + + def record_call( + code: types.CodeType, offset: int, obj: Any, arg: Any + ) -> None: + file_name = code.co_filename + if isinstance(obj, PY_CALLABLES): + events.append(("PY_CALL", file_name, obj.__code__.co_name)) + else: + events.append(("C_CALL", file_name, str(obj))) + + MONITOR.use_tool_id(TOOL, "Akarsu") + MONITOR.register_callback(TOOL, EVENTS.CALL, record_call) + MONITOR.set_events(TOOL, EVENT_SET) + try: + exec(code) + except: + pass + MONITOR.set_events(TOOL, 0) + MONITOR.free_tool_id(TOOL) + + events = [e for e in events[2:-3] if "____wrapper____" not in e[2]] + + return events diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_akarsu.py b/tests/test_akarsu.py new file mode 100644 index 0000000..27bee8e --- /dev/null +++ b/tests/test_akarsu.py @@ -0,0 +1,114 @@ +import textwrap +import unittest + +from akarsu.akarsu import Akarsu + + +class TestNine(unittest.TestCase): + def check_events(self, events, expected_events): + for event, expected_event in zip(events, expected_events): + self.assertEqual(event, expected_event) + + def test_profile_print(self): + code = "print('Hello, world!')" + events = Akarsu(code, "").profile() + expected_events = [("C_CALL", "", "")] + self.check_events(events, expected_events) + + def test_profile_isinstance(self): + code = "isinstance(1, int)" + events = Akarsu(code, "").profile() + expected_events = [("C_CALL", "", "")] + self.check_events(events, expected_events) + + def test_profile_generator(self): + code = "list(i for i in range(5))" + events = Akarsu(code, "").profile() + expected_events = [ + ("C_CALL", "", ""), + ("PY_CALL", "", ""), + ("C_CALL", "", ""), + ("PY_START", "", ""), + ("YIELD", "", ""), + ("RESUME", "", ""), + ("YIELD", "", ""), + ("RESUME", "", ""), + ("YIELD", "", ""), + ("RESUME", "", ""), + ("YIELD", "", ""), + ("RESUME", "", ""), + ("YIELD", "", ""), + ("RESUME", "", ""), + ("PY_RETURN", "", ""), + ] + self.check_events(events, expected_events) + + def test_profile_empty_code(self): + code = "" + events = Akarsu(code, "").profile() + expected_events = [] + self.check_events(events, expected_events) + + def test_profile_nested_functions(self): + source = textwrap.dedent(""" + def foo(): + print("Hello, world!") + def bar(): + foo() + bar() + """) + events = Akarsu(source, "").profile() + expected_events = [ + ("PY_CALL", "", "bar"), + ("PY_START", "", "bar"), + ("PY_CALL", "", "foo"), + ("PY_START", "", "foo"), + ("C_CALL", "", ""), + ("C_RETURN", "", "foo"), + ("PY_RETURN", "", "foo"), + ("PY_RETURN", "", "bar"), + ] + self.check_events(events, expected_events) + + def test_profile_class(self): + source = textwrap.dedent(""" + class C: + def foo(self): + x = 1 + c = C() + c.foo() + """) + events = Akarsu(source, "").profile() + expected_events = [ + ("C_CALL", "", ""), + ("PY_START", "", "C"), + ("PY_RETURN", "", "C"), + ("PY_CALL", "", "foo"), + ("PY_START", "", "foo"), + ("PY_RETURN", "", "foo"), + ] + self.check_events(events, expected_events) + + def test_profile_class_method(self): + source = textwrap.dedent(""" + class MyClass: + @classmethod + def foo(cls): + print("Hello, world!") + my_class = MyClass() + my_class.foo() + """) + events = Akarsu(source, "").profile() + expected_events = [ + ("C_CALL", "", ""), + ("PY_START", "", "MyClass"), + ("C_CALL", "", ""), + ("C_RETURN", "", "MyClass"), + ("PY_RETURN", "", "MyClass"), + ("PY_CALL", "", "foo"), + ("PY_START", "", "foo"), + ("C_CALL", "", ""), + ("C_RETURN", "", "foo"), + ("PY_RETURN", "", "foo"), + ] + self.check_events(events, expected_events)