gptcomet 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gptcomet/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ __version__ = "0.0.2"
2
+
3
+ from gptcomet.cli import cli
4
+
5
+ if __name__ == "__main__":
6
+ cli()
gptcomet/_types.py ADDED
@@ -0,0 +1,24 @@
1
+ import typing as t
2
+
3
+ import tomlkit
4
+
5
+ # Config class types
6
+ CacheKeys = t.Literal["default_config", "config"]
7
+
8
+
9
+ class CacheType(t.TypedDict):
10
+ default_config: t.Optional[tomlkit.TOMLDocument]
11
+ config: t.Optional[tomlkit.TOMLDocument]
12
+
13
+
14
+ class CompleteParams(t.TypedDict):
15
+ api_base: str
16
+ api_key: str
17
+ model: str
18
+ retries: int
19
+ messages: t.Union[list[dict], None, list]
20
+ max_tokens: t.Optional[int]
21
+ temperature: t.Optional[float]
22
+ top_p: t.Optional[float]
23
+ frequency_penalty: t.Optional[float]
24
+ extra_headers: t.Optional[dict]
gptcomet/cli.py ADDED
@@ -0,0 +1,300 @@
1
+ import logging
2
+ import sys
3
+
4
+ import click
5
+ from click import Context
6
+ from git import InvalidGitRepositoryError, NoSuchPathError
7
+
8
+ import gptcomet
9
+ from gptcomet.config_manager import ConfigManager
10
+ from gptcomet.exceptions import ConfigKeyError, GitNoStagedChanges, KeyNotFound
11
+ from gptcomet.hook import AICommitHook
12
+ from gptcomet.message_generator import MessageGenerator
13
+ from gptcomet.utils import common_options
14
+
15
+ logger = logging.getLogger(__name__)
16
+ logging.basicConfig(
17
+ format="%(asctime)s [%(levelname)s] %(filename)s::%(funcName)s: %(message)s",
18
+ datefmt="%Y-%m-%d %H:%M:%S",
19
+ level=logging.WARNING,
20
+ )
21
+
22
+
23
+ @click.group(
24
+ name="gptcomet",
25
+ help="AI-Powered Git Commit Message Generator",
26
+ context_settings={"help_option_names": ["-h", "--help"]},
27
+ )
28
+ @click.pass_context
29
+ @common_options
30
+ @click.version_option(
31
+ gptcomet.__version__,
32
+ "--version",
33
+ "-v",
34
+ prog_name="gptcomet",
35
+ message="%(prog)s: %(version)s",
36
+ )
37
+ def cli(ctx: Context, debug, local):
38
+ """GPTComet CLI"""
39
+ if debug:
40
+ logger.setLevel(logging.DEBUG)
41
+ else:
42
+ logger.setLevel(logging.INFO)
43
+ logger.debug(f"Running with debug={debug}, local={local}")
44
+
45
+ ctx.obj["debug"] = debug
46
+ ctx.obj["local"] = local
47
+ ctx.obj["config_manager"] = None
48
+
49
+
50
+ config: click.Group # type hints for IDE
51
+
52
+ @cli.group(name="config")
53
+ @click.pass_context
54
+ @common_options
55
+ def config(ctx, debug, local):
56
+ """Manage gptcomet configuration."""
57
+ logger.debug(f"Config manage, local={local}")
58
+ if not ctx.obj["config_manager"]:
59
+ ctx.obj["config_manager"] = ConfigManager(ctx.obj["local"])
60
+
61
+
62
+ @config.command("set", help="Set a configuration value.")
63
+ @click.argument("key")
64
+ @click.argument("value")
65
+ @click.pass_context
66
+ @common_options
67
+ def config_set(ctx: Context, key, value, debug, local):
68
+ """Set a configuration value."""
69
+ try:
70
+ ctx.obj["config_manager"].set(key, value)
71
+ click.echo(
72
+ f"[GPTComet] Set {click.style(key, fg='green')} to {click.style(value, fg='green')}."
73
+ )
74
+ except ConfigKeyError as e:
75
+ click.echo(f"[GPTComet] Error: {e!s}")
76
+
77
+
78
+ @config.command("get")
79
+ @click.argument("key")
80
+ @click.pass_context
81
+ @common_options
82
+ def config_get(ctx, key, debug, local):
83
+ """Get a configuration value."""
84
+ try:
85
+ value = ctx.obj["config_manager"].get(key)
86
+ click.echo(f"{key}: {value}")
87
+ except ValueError as e:
88
+ click.echo(f"Error: {e!s}")
89
+ except ConfigKeyError as e:
90
+ click.echo(str(e))
91
+
92
+
93
+ @config.command("list")
94
+ @click.pass_context
95
+ @common_options
96
+ def config_list(ctx: Context, debug, local):
97
+ """List all configuration values."""
98
+ click.echo(
99
+ click.style("Current configuration:\n", fg="green") + ctx.obj["config_manager"].list()
100
+ )
101
+
102
+
103
+ @config.command("reset")
104
+ @click.pass_context
105
+ @common_options
106
+ def reset(ctx: Context, debug, local):
107
+ """Reset configuration to default values."""
108
+ ctx.obj["config_manager"].reset()
109
+ click.echo("Configuration reset to default values")
110
+
111
+
112
+ @config.command(name="keys")
113
+ @click.pass_context
114
+ @common_options
115
+ def config_keys(ctx, debug, local):
116
+ cfg_manager: ConfigManager = ctx.obj["config_manager"]
117
+ keys = cfg_manager.list_keys()
118
+ click.echo(click.style("Supported keys:\n", fg="green") + keys)
119
+
120
+
121
+ @config.command("path")
122
+ @click.pass_context
123
+ @common_options
124
+ def config_path(ctx: Context, debug, local):
125
+ """Get the path to the configuration file."""
126
+ click.echo(
127
+ click.style("Current configuration path:\n", fg="green")
128
+ + ctx.obj["config_manager"].current_config_path.as_posix()
129
+ )
130
+
131
+
132
+ hook: click.Group # type hints for IDE
133
+
134
+
135
+ @cli.group("hook", help="Manage GPTComet prepare-commit-msg hook.")
136
+ @common_options
137
+ @click.pass_context
138
+ def hook(ctx: click.Context, debug, local):
139
+ pass
140
+
141
+
142
+ @hook.command("install", help="Install GPTComet prepare-commit-msg hook to current repository.")
143
+ @click.option(
144
+ "--force/--no-force",
145
+ "-f/-nf",
146
+ default=False,
147
+ help="Force installation or not.",
148
+ show_default=True,
149
+ )
150
+ @common_options
151
+ @click.pass_context
152
+ def install_hook(ctx, debug, local, force=False):
153
+ """Install GPTComet prepare-commit-msg hook."""
154
+ try:
155
+ comet_hook = AICommitHook()
156
+ if comet_hook.is_hook_installed() and not force:
157
+ click.echo(
158
+ "GPTComet prepare-commit-msg hook is already installed, use --force to force installation."
159
+ )
160
+ return
161
+ comet_hook.install_hook()
162
+ click.echo("GPTComet prepare-commit-msg hook has been installed successfully.")
163
+ except InvalidGitRepositoryError as e:
164
+ click.echo(f"Error: {e!s}")
165
+ except Exception as e:
166
+ click.echo(f"An error occurred while installing the hook: {e!s}")
167
+
168
+
169
+ @hook.command("uninstall", help="Uninstall GPTComet prepare-commit-msg hook from current repository.")
170
+ @common_options
171
+ @click.pass_context
172
+ def uninstall_hook(ctx: click.Context, debug, local, **kwargs):
173
+ """Uninstall GPTComet prepare-commit-msg hook."""
174
+ try:
175
+ comet_hook = AICommitHook()
176
+ comet_hook.uninstall_hook()
177
+ except (InvalidGitRepositoryError, NoSuchPathError) as e:
178
+ click.echo(f"Error: {e!s}")
179
+ except Exception as e:
180
+ click.echo(f"An error occurred while uninstalling the hook: {e!s}")
181
+
182
+
183
+ @hook.command("status")
184
+ def hook_status():
185
+ """Check if GPTComet prepare-commit-msg hook is installed."""
186
+ try:
187
+ comet_hook = AICommitHook()
188
+ if comet_hook.is_hook_installed():
189
+ click.echo("GPTComet prepare-commit-msg hook is installed.")
190
+ else:
191
+ click.echo(
192
+ f"GPTComet prepare-commit-msg hook is {click.style('not', fg='yellow')} installed."
193
+ )
194
+ except InvalidGitRepositoryError as e:
195
+ click.echo(f"Error: {e!s}")
196
+ except Exception as e:
197
+ click.echo(f"An error occurred while checking the hook status: {e!s}")
198
+
199
+
200
+ generate: click.Group # type hints for IDE
201
+
202
+
203
+ @cli.group("generate", help="Generate a commit message based on `git diff --staged`.")
204
+ @click.pass_context
205
+ @common_options
206
+ def generate(ctx: click.Context, debug, local):
207
+ ctx.obj["config_manager"] = ConfigManager(ctx.obj["local"])
208
+
209
+
210
+ @generate.command("commit")
211
+ # @click.option("--rich", is_flag=True, default=False, help="Generate rich commit message")
212
+ @common_options
213
+ @click.pass_context
214
+ def generate_commit(ctx: click.Context, debug, local, rich=False, **kwargs):
215
+ """Generate a commit message based on git diff"""
216
+ click.echo(
217
+ click.style(
218
+ "🤖 Hang tight! I'm having a chat with the AI to craft your commit message...",
219
+ fg="green",
220
+ )
221
+ )
222
+ config_manager = ctx.obj["config_manager"]
223
+ message_generator = MessageGenerator(config_manager)
224
+ retry = True
225
+ commit_msg = None
226
+ while retry:
227
+ try:
228
+ commit_msg = message_generator.generate_commit_message(rich)
229
+ except KeyNotFound as e:
230
+ click.echo(f"Error: {e!s}, please check your configuration.")
231
+ raise click.Abort() from None
232
+ except GitNoStagedChanges as e:
233
+ click.echo(str(e))
234
+ raise click.Abort() from None
235
+
236
+ if commit_msg is None:
237
+ click.echo(click.style("No commit message generated.", fg="magenta"))
238
+ return
239
+
240
+ click.echo(click.style("Generated commit message:", fg="green"))
241
+ click.echo(commit_msg)
242
+
243
+ if sys.stdin.isatty():
244
+ # Interactive mode will ask for confirmation
245
+ char = click.prompt(
246
+ click.style(
247
+ "Do you want to use this commit message? y: yes, n: no, r: retry.", fg="green"
248
+ ),
249
+ default="y",
250
+ type=click.Choice(["y", "n", "r"]),
251
+ )
252
+ else:
253
+ click.echo(
254
+ click.style(
255
+ "Non-interactive mode detected, using the generated commit message directly.",
256
+ fg="yellow",
257
+ )
258
+ )
259
+ char = "y"
260
+
261
+ if char == "n":
262
+ click.echo(click.style("Commit message discarded.", fg="yellow"))
263
+ return
264
+ elif char == "y":
265
+ retry = False
266
+ logger.debug(f"Input: {char}")
267
+ if char == "n":
268
+ click.echo(click.style("Commit message discarded.", fg="yellow"))
269
+ return
270
+ elif char == "y":
271
+ retry = False
272
+
273
+ message_generator.repo.index.commit(commit_msg)
274
+ click.echo(click.style("Commit message saved.", fg="green"))
275
+
276
+
277
+ @click.command()
278
+ @click.pass_context
279
+ def generate_prmsg(ctx: click.Context, debug, local):
280
+ """Generate a pull request message based on changes compared to master"""
281
+ config_manager = ctx.obj["config_manager"]
282
+ message_generator = MessageGenerator(config_manager)
283
+
284
+ pr_msg = message_generator.generate_pr_message()
285
+
286
+ click.echo("Generated pull request message:")
287
+ click.echo(pr_msg)
288
+
289
+ if click.confirm("Do you want to use this pull request message?"):
290
+ # Here you could integrate with your Git hosting platform's API
291
+ # to create a pull request with this message
292
+ click.echo(
293
+ "Pull request message saved. You can now use it to create a PR on your Git hosting platform."
294
+ )
295
+ else:
296
+ click.echo("Pull request message discarded.")
297
+
298
+
299
+ if __name__ == "__main__":
300
+ cli()
gptcomet/cmd.py ADDED
File without changes
@@ -0,0 +1,296 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from typing import Any, Optional, Union
4
+
5
+ import click
6
+ import tomlkit as toml
7
+ from tomlkit import TOMLDocument, boolean, float_, integer, string
8
+ from tomlkit.items import Item
9
+
10
+ from gptcomet._types import CacheType
11
+ from gptcomet.exceptions import KeyNotFound
12
+ from gptcomet.support_keys import SUPPORT_KEYS
13
+ from gptcomet.utils import is_float
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class ConfigManager:
19
+ __slots__ = (
20
+ "local",
21
+ "global_config_file",
22
+ "default_config_file",
23
+ "current_config_path",
24
+ "_cache",
25
+ "_valid_keys"
26
+ )
27
+ local: bool
28
+ global_config_file: Path
29
+ default_config_file: Path
30
+ config: TOMLDocument
31
+ current_config_path: Path
32
+ _cache: CacheType
33
+ _valid_keys: set[str]
34
+
35
+ def __init__(self, local: bool = False):
36
+ self.local: bool = local
37
+ self.global_config_file: Path = Path.home() / ".local" / "gptcomet" / "gptcomet.toml"
38
+ self.default_config_file: Path = Path(__file__).parent / "gptcomet.toml"
39
+ # runtime config file
40
+ self.current_config_path = self.get_config_file()
41
+
42
+ self._cache: CacheType = {"config": None, "default_config": None}
43
+ self._valid_keys: set[str] = set(SUPPORT_KEYS.splitlines())
44
+
45
+ @property
46
+ def config(self):
47
+ """
48
+ Returns the configuration stored in the cache.
49
+
50
+ If the configuration is not already loaded, it is loaded by calling the
51
+ `load_config` method and stored in the cache.
52
+
53
+ Returns:
54
+ The configuration stored in the cache.
55
+ """
56
+ if "config" not in self._cache or self._cache["config"] is None:
57
+ self._cache["config"] = self.load_config()
58
+ return self._cache["config"]
59
+
60
+ @property
61
+ def default_config(self) -> toml.TOMLDocument:
62
+ """
63
+ Returns the default configuration stored in the cache.
64
+
65
+ If the default configuration is not already loaded, it is loaded by calling the
66
+ `load_default_config` method and stored in the cache.
67
+
68
+ Returns:
69
+ The default configuration stored in the cache.
70
+ """
71
+ if "default_config" not in self._cache or self._cache["default_config"] is None:
72
+ self._cache["default_config"] = self.load_default_config()
73
+ return self._cache["default_config"]
74
+
75
+ def get_config_file(self) -> Path:
76
+ """
77
+ Retrieves the path to the configuration file.
78
+
79
+ If local is True, checks if the current directory is a git repository
80
+ and returns the path to .git/gptcomet.toml if it is, otherwise returns
81
+ the global configuration file path.
82
+
83
+ Returns:
84
+ Path: The path to the configuration file.
85
+ """
86
+ config_path = self.global_config_file
87
+ if self.local:
88
+ cwd = Path.cwd()
89
+ if not (cwd / ".git").exists():
90
+ click.echo(f"[{click.style('GPTComet', fg='green')}] Not a git repository. Using global config.")
91
+ else:
92
+ config_path = cwd / ".git" / "gptcomet.toml"
93
+ # click.echo(f"[GPTComet] Using config file: {config_path}")
94
+ return config_path
95
+
96
+ def ensure_config_file(self):
97
+ """
98
+ Ensures that the configuration file exists by creating it with default values if it does not already exist.
99
+ """
100
+ config_file = self.current_config_path
101
+ if not config_file.exists():
102
+ config_file.parent.mkdir(parents=True, exist_ok=True)
103
+ with open(self.default_config_file) as default, open(config_file, "w") as target:
104
+ target.write(default.read())
105
+ click.echo(f"[{click.style('GPTComet', fg='green')}] Created default config file at {config_file}")
106
+
107
+ def load_config(self) -> toml.TOMLDocument:
108
+ """
109
+ Load the configuration from the current configuration file.
110
+
111
+ Returns:
112
+ toml.TOMLDocument: The configuration loaded from the current configuration file.
113
+
114
+ """
115
+ self.ensure_config_file()
116
+ with open(self.current_config_path) as f:
117
+ return toml.load(f)
118
+
119
+ def load_default_config(self) -> toml.TOMLDocument:
120
+ """
121
+ Load the default configuration from the default configuration file.
122
+
123
+ Returns:
124
+ toml.TOMLDocument: The default configuration loaded from the file.
125
+ """
126
+ with open(self.default_config_file) as f:
127
+ return toml.load(f)
128
+
129
+ def save_config(self):
130
+ """
131
+ Saves the current configuration to a file.
132
+ Returns:
133
+ None
134
+ """
135
+ with self.current_config_path.open("w") as f:
136
+ f.write(self.config.as_string())
137
+
138
+ def is_valid_key(self, key: str) -> bool:
139
+ """
140
+ Check if the given key is a valid key in the current configuration.
141
+
142
+ Args:
143
+ key (str): The key to be checked.
144
+
145
+ Returns:
146
+ bool: True if the key is valid, False otherwise.
147
+ """
148
+ return key in self._valid_keys
149
+
150
+ def get_nested_value(self, doc: Union[dict, toml.TOMLDocument], keys: Union[str, list[str]]) -> Any:
151
+ """
152
+ Get the nested value from a dictionary or TOML document using a list of keys.
153
+
154
+ Args:
155
+ doc (Union[dict, toml.TOMLDocument]): The dictionary or TOML document to retrieve the nested value from.
156
+ keys (Union[str, List[str]]): The list of keys to navigate through the document.
157
+
158
+ Returns:
159
+ Any: The nested value if found, None otherwise.
160
+ """
161
+ if isinstance(keys, str):
162
+ keys = keys.split(".")
163
+ for key in keys:
164
+ if isinstance(doc, dict):
165
+ doc = doc.get(key)
166
+ else:
167
+ raise KeyNotFound(key)
168
+ return doc
169
+
170
+ def set_nested_value(self, doc: TOMLDocument, keys: Union[str, list[str]], value: Any):
171
+ """
172
+ Set a nested value in a dictionary using a list of keys.
173
+
174
+ Args:
175
+ doc (TOMLDocument): The dictionary to modify.
176
+ keys (Union[str, List[str]]): The list of keys to navigate through the dictionary.
177
+ value (Any): The value to set at the end of the nested keys.
178
+
179
+ It navigates through the dictionary using the keys and sets the value at the end of the nested keys.
180
+ If any key in the path does not exist, it creates a new dictionary at that key.
181
+
182
+ Example:
183
+ >>> config = ConfigManager()
184
+ >>> d = {'a': {'b': {'c': 1}}}
185
+ >>> config.set_nested_value(doc, ['a', 'b', 'c'], 2)
186
+ >>> doc
187
+ {'a': {'b': {'c': 2}}}
188
+
189
+ Note:
190
+ This function modifies the input dictionary `d` in-place.
191
+ """
192
+ if isinstance(keys, str):
193
+ keys = keys.split(".")
194
+ for key in keys[:-1]:
195
+ doc = doc.setdefault(key, {})
196
+ doc[keys[-1]] = value
197
+
198
+ def set(self, key: str, value: str):
199
+ """
200
+ Set the value of a configuration key.
201
+
202
+ Args:
203
+ key (str): The key to set the value for.
204
+ value (str): The value to set.
205
+
206
+ Raises:
207
+ ConfigKeyError: If the key is not supported.
208
+
209
+ This method sets the value of a configuration key in the `config` attribute of the `ConfigManager` object.
210
+ It first checks if the key is valid using the `is_valid_key` method. If the key is not valid, it raises a
211
+ `ConfigKeyError` with the invalid key.
212
+
213
+ Note:
214
+ This method modifies the toml `config` attribute in-place.
215
+ """
216
+ toml_value: Item = self.convert2toml_value(value)
217
+ self.set_nested_value(self.config, key, toml_value)
218
+ self.save_config()
219
+
220
+ def get(self, key: str, default: Optional[Any] = None) -> Any:
221
+ """
222
+ Retrieves the value associated with the given key from the configuration.
223
+
224
+ Args:
225
+ key (str): The key to retrieve the value for.
226
+ default (Optional[str]): The default value to return if the key is not found.
227
+
228
+ Returns:
229
+ Any: The value associated with the given key.
230
+
231
+ Raises:
232
+ KeyNotSupportError: If the key is not supported.
233
+ ConfigKeyError: If the key does not exist in the configuration.
234
+
235
+ Note:
236
+ This method assumes that the `config` attribute of the `ConfigManager` object is a nested dictionary
237
+ structure with keys separated by dots.
238
+ """
239
+ try:
240
+ return self.get_nested_value(self.config, key)
241
+ except KeyNotFound:
242
+ return default
243
+
244
+ def list(self) -> str:
245
+ """
246
+ Reads the contents of the current configuration file and returns them as a string.
247
+
248
+ Returns:
249
+ str: The contents of the current configuration file.
250
+
251
+ Raises:
252
+ FileNotFoundError: If the current configuration file does not exist.
253
+ Note:
254
+ This method assumes that `self.current_config_path` is a valid file path.
255
+
256
+ """
257
+ with self.current_config_path.open() as f:
258
+ return f.read()
259
+
260
+ def reset(self):
261
+ """
262
+ Resets the current configuration to its default state.
263
+ """
264
+ with open(self.default_config_file) as default, open(self.current_config_path, "w") as target:
265
+ content = default.read()
266
+ target.write(content)
267
+ self._cache["config"] = toml.loads(content)
268
+
269
+ def list_keys(self) -> str:
270
+ """
271
+ Returns a list of supported keys.
272
+ Returns:
273
+ str: A list of supported keys.
274
+ """
275
+ return SUPPORT_KEYS
276
+
277
+ def convert2toml_value(self, value: str) -> Item:
278
+ """
279
+ Converts a string value to a TOML-compatible value.
280
+
281
+ Args:
282
+ value (str): The string value to be converted.
283
+
284
+ Returns:
285
+ Item: The converted TOML value or the original string value.
286
+ """
287
+ if value.lower() in ("true", "false"):
288
+ return boolean(value.lower())
289
+ elif value.isnumeric():
290
+ return integer(value)
291
+ elif value.lower() in ("none", "null"):
292
+ return string(value, literal=True)
293
+ elif is_float(value):
294
+ return float_(value)
295
+ else:
296
+ return string(value)
gptcomet/const.py ADDED
@@ -0,0 +1,37 @@
1
+ DEFAULT_API_BASE = "https://api.openai.com/v1"
2
+ DEFAULT_MODEL = "gpt-3.5-turbo"
3
+ DEFAULT_RETRIES = 2
4
+
5
+ SUPPORTED_LANG = ["en", "zh"]
6
+
7
+ SHORT_PREPARE_COMMIT_MSG = """\
8
+ #!/bin/sh
9
+ # GPTComet pre-commit hook
10
+
11
+ # Run GPTComet commit
12
+ gptcomet generate commit
13
+
14
+ # If GPTComet commit was successful, exit with 0
15
+ if [ $? -eq 0 ]; then
16
+ exit 0
17
+ else
18
+ echo "GPTComet commit failed. Please review your changes and try again."
19
+ exit 1
20
+ fi
21
+ """
22
+
23
+ RICH_PREPARE_COMMIT_MSG = """\
24
+ #!/bin/sh
25
+ # GPTComet pre-commit hook
26
+
27
+ # Run GPTComet commit
28
+ gptcomet generate commit --rich
29
+
30
+ # If GPTComet commit was successful, exit with 0
31
+ if [ $? -eq 0 ]; then
32
+ exit 0
33
+ else
34
+ echo "GPTComet commit failed. Please review your changes and try again."
35
+ exit 1
36
+ fi
37
+ """