article-cli 1.0.0__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.
@@ -0,0 +1,20 @@
1
+ """
2
+ Article CLI - A command-line tool for managing LaTeX articles
3
+
4
+ This package provides tools for:
5
+ - Git release management with gitinfo2 support
6
+ - Zotero bibliography synchronization
7
+ - LaTeX build file cleanup
8
+ - Git hooks setup
9
+ """
10
+
11
+ __version__ = "1.0.0"
12
+ __author__ = "Christophe Prud'homme"
13
+ __email__ = "prudhomm@cemosis.fr"
14
+
15
+ from .cli import main
16
+ from .config import Config
17
+ from .zotero import ZoteroBibTexUpdater
18
+ from .git_manager import GitManager
19
+
20
+ __all__ = ["main", "Config", "ZoteroBibTexUpdater", "GitManager"]
@@ -0,0 +1,8 @@
1
+ """
2
+ Allow running article-cli as a module with python -m article_cli
3
+ """
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ main()
article_cli/cli.py ADDED
@@ -0,0 +1,308 @@
1
+ """
2
+ Main CLI interface for article-cli
3
+
4
+ Provides command-line interface for managing LaTeX articles with git and Zotero integration.
5
+ """
6
+
7
+ import argparse
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from . import __version__
13
+ from .config import Config
14
+ from .zotero import ZoteroBibTexUpdater, print_error, print_info
15
+ from .git_manager import GitManager
16
+
17
+
18
+ def create_parser() -> argparse.ArgumentParser:
19
+ """Create and configure the argument parser"""
20
+ parser = argparse.ArgumentParser(
21
+ prog="article-cli",
22
+ description="CLI tool for managing LaTeX articles with git and Zotero integration",
23
+ formatter_class=argparse.RawDescriptionHelpFormatter,
24
+ epilog="""
25
+ Examples:
26
+ %(prog)s setup # Setup git hooks
27
+ %(prog)s clean # Clean build files
28
+ %(prog)s create v1.0.0 # Create release v1.0.0
29
+ %(prog)s list --count 10 # List 10 recent releases
30
+ %(prog)s delete v1.0.0 # Delete release
31
+ %(prog)s update-bibtex # Update from Zotero
32
+ %(prog)s config create # Create sample config file
33
+
34
+ Environment variables:
35
+ ZOTERO_API_KEY : Your Zotero API key (required for update-bibtex)
36
+ ZOTERO_USER_ID : Your Zotero user ID
37
+ ZOTERO_GROUP_ID : Your Zotero group ID
38
+ """,
39
+ )
40
+
41
+ parser.add_argument(
42
+ "--version", action="version", version=f"%(prog)s {__version__}"
43
+ )
44
+
45
+ parser.add_argument("--config", type=Path, help="Path to configuration file")
46
+
47
+ subparsers = parser.add_subparsers(dest="command", help="Command to execute")
48
+
49
+ # Setup command
50
+ subparsers.add_parser("setup", help="Setup git hooks for gitinfo2")
51
+
52
+ # Clean command
53
+ subparsers.add_parser("clean", help="Clean LaTeX build files")
54
+
55
+ # Create command
56
+ create_parser = subparsers.add_parser("create", help="Create a new release")
57
+ create_parser.add_argument("version", help="Version tag (e.g., v1.0.0)")
58
+ create_parser.add_argument(
59
+ "--push", action="store_true", help="Automatically push the release"
60
+ )
61
+
62
+ # List command
63
+ list_parser = subparsers.add_parser("list", help="List releases")
64
+ list_parser.add_argument(
65
+ "--count", type=int, default=5, help="Number of releases to show"
66
+ )
67
+
68
+ # Delete command
69
+ delete_parser = subparsers.add_parser("delete", help="Delete a release")
70
+ delete_parser.add_argument("version", help="Version tag to delete")
71
+ delete_parser.add_argument(
72
+ "--remote", action="store_true", help="Also delete from remote"
73
+ )
74
+
75
+ # Update-bibtex command
76
+ bibtex_parser = subparsers.add_parser(
77
+ "update-bibtex", help="Update BibTeX from Zotero"
78
+ )
79
+ bibtex_parser.add_argument("--api-key", help="Zotero API key")
80
+ bibtex_parser.add_argument("--user-id", help="Zotero user ID")
81
+ bibtex_parser.add_argument("--group-id", help="Zotero group ID")
82
+ bibtex_parser.add_argument(
83
+ "--output", default="references.bib", help="Output BibTeX file"
84
+ )
85
+ bibtex_parser.add_argument(
86
+ "--no-backup", action="store_true", help="Skip backup creation"
87
+ )
88
+
89
+ # Config command
90
+ config_parser = subparsers.add_parser("config", help="Configuration management")
91
+ config_subparsers = config_parser.add_subparsers(
92
+ dest="config_command", help="Config subcommands"
93
+ )
94
+
95
+ config_create_parser = config_subparsers.add_parser(
96
+ "create", help="Create sample configuration file"
97
+ )
98
+ config_create_parser.add_argument("--path", type=Path, help="Path for config file")
99
+
100
+ config_subparsers.add_parser("show", help="Show current configuration")
101
+
102
+ return parser
103
+
104
+
105
+ def handle_setup_command(config: Config) -> int:
106
+ """Handle the setup command"""
107
+ try:
108
+ git_manager = GitManager()
109
+ return 0 if git_manager.setup_hooks() else 1
110
+ except ValueError as e:
111
+ print_error(str(e))
112
+ return 1
113
+
114
+
115
+ def handle_clean_command(config: Config) -> int:
116
+ """Handle the clean command"""
117
+ try:
118
+ git_manager = GitManager()
119
+ latex_config = config.get_latex_config()
120
+ return (
121
+ 0 if git_manager.clean_latex_files(latex_config["clean_extensions"]) else 1
122
+ )
123
+ except ValueError as e:
124
+ print_error(str(e))
125
+ return 1
126
+
127
+
128
+ def handle_create_command(args: argparse.Namespace, config: Config) -> int:
129
+ """Handle the create command"""
130
+ try:
131
+ git_manager = GitManager()
132
+ git_config = config.get_git_config()
133
+ auto_push = args.push or git_config.get("auto_push", False)
134
+ return 0 if git_manager.create_release(args.version, auto_push) else 1
135
+ except ValueError as e:
136
+ print_error(str(e))
137
+ return 1
138
+
139
+
140
+ def handle_list_command(args: argparse.Namespace, config: Config) -> int:
141
+ """Handle the list command"""
142
+ try:
143
+ git_manager = GitManager()
144
+ return 0 if git_manager.list_releases(args.count) else 1
145
+ except ValueError as e:
146
+ print_error(str(e))
147
+ return 1
148
+
149
+
150
+ def handle_delete_command(args: argparse.Namespace, config: Config) -> int:
151
+ """Handle the delete command"""
152
+ try:
153
+ git_manager = GitManager()
154
+ return 0 if git_manager.delete_release(args.version, args.remote) else 1
155
+ except ValueError as e:
156
+ print_error(str(e))
157
+ return 1
158
+
159
+
160
+ def handle_update_bibtex_command(args: argparse.Namespace, config: Config) -> int:
161
+ """Handle the update-bibtex command"""
162
+ try:
163
+ # Get and validate Zotero configuration
164
+ zotero_config = config.validate_zotero_config(args)
165
+
166
+ updater = ZoteroBibTexUpdater(
167
+ api_key=zotero_config["api_key"],
168
+ user_id=zotero_config["user_id"],
169
+ group_id=zotero_config["group_id"],
170
+ output_file=zotero_config["output_file"],
171
+ )
172
+
173
+ return 0 if updater.update(backup=not args.no_backup) else 1
174
+
175
+ except ValueError as e:
176
+ print_error(str(e))
177
+ return 1
178
+
179
+
180
+ def handle_config_command(args: argparse.Namespace, config: Config) -> int:
181
+ """Handle config subcommands"""
182
+ if args.config_command == "create":
183
+ try:
184
+ path = args.path or Path.cwd() / ".article-cli.toml"
185
+ config.create_sample_config(path)
186
+ return 0
187
+ except Exception as e:
188
+ print_error(f"Failed to create config file: {e}")
189
+ return 1
190
+
191
+ elif args.config_command == "show":
192
+ try:
193
+ print_info("Current configuration:")
194
+ zotero_config = config.get_zotero_config()
195
+ git_config = config.get_git_config()
196
+ latex_config = config.get_latex_config()
197
+
198
+ print("\n[Zotero]")
199
+ print(f" API Key: {'***' if zotero_config['api_key'] else 'Not set'}")
200
+ print(f" User ID: {zotero_config['user_id'] or 'Not set'}")
201
+
202
+ # Show group ID with name if available
203
+ if zotero_config["group_id"]:
204
+ group_display = zotero_config["group_id"]
205
+
206
+ # Try to get group name if API key is available
207
+ if zotero_config["api_key"]:
208
+ try:
209
+ from .zotero import ZoteroBibTexUpdater
210
+
211
+ updater = ZoteroBibTexUpdater(
212
+ api_key=zotero_config["api_key"],
213
+ group_id=zotero_config["group_id"],
214
+ )
215
+ group_name = updater.get_group_name()
216
+ if group_name:
217
+ group_display = (
218
+ f"{zotero_config['group_id']} ({group_name})"
219
+ )
220
+ except Exception:
221
+ pass # Silently fall back to just showing ID
222
+
223
+ print(f" Group ID: {group_display}")
224
+ else:
225
+ print(f" Group ID: Not set")
226
+
227
+ print(f" Output File: {zotero_config['output_file']}")
228
+
229
+ print("\n[Git]")
230
+ print(f" Auto Push: {git_config['auto_push']}")
231
+ print(f" Default Branch: {git_config['default_branch']}")
232
+
233
+ print("\n[LaTeX]")
234
+ print(
235
+ f" Clean Extensions: {len(latex_config['clean_extensions'])} extensions"
236
+ )
237
+ print(f" Build Directory: {latex_config['build_dir']}")
238
+
239
+ return 0
240
+ except Exception as e:
241
+ print_error(f"Failed to show configuration: {e}")
242
+ return 1
243
+ else:
244
+ print_error("Unknown config command")
245
+ return 1
246
+
247
+
248
+ def main(argv: Optional[list] = None) -> int:
249
+ """
250
+ Main entry point for article-cli
251
+
252
+ Args:
253
+ argv: Command line arguments (defaults to sys.argv)
254
+
255
+ Returns:
256
+ Exit code (0 for success, non-zero for error)
257
+ """
258
+ parser = create_parser()
259
+ args = parser.parse_args(argv)
260
+
261
+ if not args.command:
262
+ parser.print_help()
263
+ return 1
264
+
265
+ # Initialize configuration
266
+ try:
267
+ config = Config(args.config)
268
+ except Exception as e:
269
+ print_error(f"Configuration error: {e}")
270
+ return 1
271
+
272
+ # Route to appropriate command handler
273
+ try:
274
+ if args.command == "setup":
275
+ return handle_setup_command(config)
276
+
277
+ elif args.command == "clean":
278
+ return handle_clean_command(config)
279
+
280
+ elif args.command == "create":
281
+ return handle_create_command(args, config)
282
+
283
+ elif args.command == "list":
284
+ return handle_list_command(args, config)
285
+
286
+ elif args.command == "delete":
287
+ return handle_delete_command(args, config)
288
+
289
+ elif args.command == "update-bibtex":
290
+ return handle_update_bibtex_command(args, config)
291
+
292
+ elif args.command == "config":
293
+ return handle_config_command(args, config)
294
+
295
+ else:
296
+ print_error(f"Unknown command: {args.command}")
297
+ return 1
298
+
299
+ except KeyboardInterrupt:
300
+ print("\n\nInterrupted by user")
301
+ return 130
302
+ except Exception as e:
303
+ print_error(f"Unexpected error: {e}")
304
+ return 1
305
+
306
+
307
+ if __name__ == "__main__":
308
+ sys.exit(main())
article_cli/config.py ADDED
@@ -0,0 +1,275 @@
1
+ """
2
+ Configuration management for article-cli
3
+
4
+ Supports both environment variables and TOML configuration files.
5
+ """
6
+
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Dict, Any, Optional, Union
10
+ import argparse
11
+
12
+ try:
13
+ import tomllib # Python 3.11+
14
+ except ImportError:
15
+ try:
16
+ import tomli as tomllib # fallback for older Python versions
17
+ except ImportError:
18
+ tomllib = None
19
+
20
+
21
+ class Config:
22
+ """Configuration manager for article-cli"""
23
+
24
+ def __init__(self, config_file: Optional[Union[str, Path]] = None):
25
+ """
26
+ Initialize configuration manager
27
+
28
+ Args:
29
+ config_file: Optional path to configuration file
30
+ """
31
+ self.config_file = config_file
32
+ self._config_data: Dict[str, Any] = {}
33
+ self._load_config()
34
+
35
+ def _find_config_file(self) -> Optional[Path]:
36
+ """Find configuration file in project directory"""
37
+ if self.config_file:
38
+ config_path = Path(self.config_file)
39
+ if config_path.exists():
40
+ return config_path
41
+ else:
42
+ raise FileNotFoundError(f"Configuration file not found: {config_path}")
43
+
44
+ # Look for default config files (in priority order)
45
+ search_paths = [
46
+ Path.cwd()
47
+ / ".article-cli.toml", # Dedicated config file (highest priority)
48
+ Path.cwd() / "pyproject.toml", # Project config file
49
+ Path.cwd() / "article-cli.toml", # Alternative dedicated file
50
+ Path.home() / ".config" / "article-cli" / "config.toml", # XDG config
51
+ Path.home() / ".article-cli.toml", # User config (lowest priority)
52
+ ]
53
+
54
+ for path in search_paths:
55
+ if path.exists():
56
+ return path
57
+
58
+ return None
59
+
60
+ def _load_config(self) -> None:
61
+ """Load configuration from file if it exists"""
62
+ config_path = self._find_config_file()
63
+
64
+ if config_path and tomllib:
65
+ try:
66
+ with open(config_path, "rb") as f:
67
+ full_config = tomllib.load(f)
68
+
69
+ # Handle pyproject.toml vs dedicated config file
70
+ if config_path.name == "pyproject.toml":
71
+ # Extract article-cli config section from pyproject.toml
72
+ self._config_data = full_config.get("tool", {}).get(
73
+ "article-cli", {}
74
+ )
75
+ if self._config_data:
76
+ print(
77
+ f"Loaded configuration from: {config_path} [tool.article-cli]"
78
+ )
79
+ else:
80
+ # Fallback: look for legacy sections at root level
81
+ legacy_sections = ["zotero", "git", "latex"]
82
+ self._config_data = {
83
+ k: v for k, v in full_config.items() if k in legacy_sections
84
+ }
85
+ if self._config_data:
86
+ print(f"Loaded legacy configuration from: {config_path}")
87
+ else:
88
+ # Dedicated config file - use as-is
89
+ self._config_data = full_config
90
+ print(f"Loaded configuration from: {config_path}")
91
+
92
+ except Exception as e:
93
+ print(f"Warning: Could not load config file {config_path}: {e}")
94
+ self._config_data = {}
95
+ elif config_path and not tomllib:
96
+ print(
97
+ "Warning: TOML support not available. Install with: pip install tomli"
98
+ )
99
+ self._config_data = {}
100
+ else:
101
+ self._config_data = {}
102
+
103
+ def get(
104
+ self, section: str, key: str, default: Any = None, env_var: Optional[str] = None
105
+ ) -> Any:
106
+ """
107
+ Get configuration value with priority: CLI args > env vars > config file > default
108
+
109
+ Args:
110
+ section: Configuration section name
111
+ key: Configuration key name
112
+ default: Default value if not found
113
+ env_var: Environment variable name to check
114
+
115
+ Returns:
116
+ Configuration value
117
+ """
118
+ # Check environment variable first
119
+ if env_var and env_var in os.environ:
120
+ return os.environ[env_var]
121
+
122
+ # Check config file
123
+ if section in self._config_data and key in self._config_data[section]:
124
+ return self._config_data[section][key]
125
+
126
+ return default
127
+
128
+ def get_zotero_config(self) -> Dict[str, Optional[str]]:
129
+ """Get Zotero-specific configuration"""
130
+ return {
131
+ "api_key": self.get("zotero", "api_key", env_var="ZOTERO_API_KEY"),
132
+ "user_id": self.get("zotero", "user_id", env_var="ZOTERO_USER_ID"),
133
+ "group_id": self.get("zotero", "group_id", env_var="ZOTERO_GROUP_ID"),
134
+ "output_file": self.get(
135
+ "zotero", "output_file", "references.bib", env_var="BIBTEX_FILE"
136
+ ),
137
+ }
138
+
139
+ def get_git_config(self) -> Dict[str, Any]:
140
+ """Get Git-specific configuration"""
141
+ return {
142
+ "auto_push": self.get("git", "auto_push", False),
143
+ "default_branch": self.get("git", "default_branch", "main"),
144
+ }
145
+
146
+ def get_latex_config(self) -> Dict[str, Any]:
147
+ """Get LaTeX-specific configuration"""
148
+ default_extensions = [
149
+ ".aux",
150
+ ".bbl",
151
+ ".blg",
152
+ ".log",
153
+ ".out",
154
+ ".pyg",
155
+ ".fls",
156
+ ".synctex.gz",
157
+ ".toc",
158
+ ".fdb_latexmk",
159
+ ".idx",
160
+ ".ilg",
161
+ ".ind",
162
+ ".chl",
163
+ ".lof",
164
+ ".lot",
165
+ ]
166
+
167
+ return {
168
+ "clean_extensions": self.get(
169
+ "latex", "clean_extensions", default_extensions
170
+ ),
171
+ "build_dir": self.get("latex", "build_dir", "."),
172
+ }
173
+
174
+ def validate_zotero_config(
175
+ self, args: argparse.Namespace
176
+ ) -> Dict[str, Optional[str]]:
177
+ """
178
+ Validate and merge Zotero configuration from args and config
179
+
180
+ Args:
181
+ args: Parsed command line arguments
182
+
183
+ Returns:
184
+ Dict with validated Zotero configuration
185
+
186
+ Raises:
187
+ ValueError: If required configuration is missing
188
+ """
189
+ config = self.get_zotero_config()
190
+
191
+ # Override with command line arguments
192
+ if hasattr(args, "api_key") and args.api_key:
193
+ config["api_key"] = args.api_key
194
+ if hasattr(args, "user_id") and args.user_id:
195
+ config["user_id"] = args.user_id
196
+ if hasattr(args, "group_id") and args.group_id:
197
+ config["group_id"] = args.group_id
198
+ if hasattr(args, "output") and args.output:
199
+ config["output_file"] = args.output
200
+
201
+ # Validate required fields
202
+ if not config["api_key"]:
203
+ raise ValueError(
204
+ "Zotero API key is required. Set via:\n"
205
+ " - Command line: --api-key YOUR_KEY\n"
206
+ " - Environment: export ZOTERO_API_KEY=YOUR_KEY\n"
207
+ ' - Config file: [zotero] api_key = "YOUR_KEY"'
208
+ )
209
+
210
+ if not config["user_id"] and not config["group_id"]:
211
+ raise ValueError(
212
+ "Either Zotero user ID or group ID is required. Set via:\n"
213
+ " - Command line: --user-id ID or --group-id ID\n"
214
+ " - Environment: export ZOTERO_USER_ID=ID or ZOTERO_GROUP_ID=ID\n"
215
+ ' - Config file: [zotero] user_id = "ID" or group_id = "ID"'
216
+ )
217
+
218
+ return config
219
+
220
+ def create_sample_config(self, path: Optional[Path] = None) -> Path:
221
+ """
222
+ Create a sample configuration file
223
+
224
+ Args:
225
+ path: Optional path for config file
226
+
227
+ Returns:
228
+ Path to created config file
229
+ """
230
+ if path is None:
231
+ path = Path.cwd() / ".article-cli.toml"
232
+
233
+ sample_config = """# Article CLI Configuration File
234
+ # Copy this file to your project root as .article-cli.toml
235
+
236
+ [zotero]
237
+ # Your Zotero API key (get from https://www.zotero.org/settings/keys)
238
+ api_key = "your_api_key_here"
239
+
240
+ # Either user_id OR group_id (not both)
241
+ # user_id = "your_user_id"
242
+ group_id = "6219333"
243
+
244
+ # Output file for bibliography
245
+ output_file = "references.bib"
246
+
247
+ [git]
248
+ # Automatically push after creating releases
249
+ auto_push = true
250
+
251
+ # Default branch name
252
+ default_branch = "main"
253
+
254
+ [latex]
255
+ # File extensions to clean
256
+ clean_extensions = [
257
+ ".aux", ".bbl", ".blg", ".log", ".out", ".pyg",
258
+ ".fls", ".synctex.gz", ".toc", ".fdb_latexmk",
259
+ ".idx", ".ilg", ".ind", ".chl", ".lof", ".lot"
260
+ ]
261
+
262
+ # Build directory (relative to project root)
263
+ build_dir = "."
264
+ """
265
+
266
+ try:
267
+ with open(path, "w") as f:
268
+ f.write(sample_config)
269
+ print(f"Created sample configuration file: {path}")
270
+ return path
271
+ except Exception as e:
272
+ raise RuntimeError(f"Could not create config file {path}: {e}")
273
+
274
+ def __repr__(self) -> str:
275
+ return f"Config(config_file={self.config_file}, sections={list(self._config_data.keys())})"