Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11f86972c5 | ||
|
|
05eb064392 | ||
|
|
1208029c66 | ||
|
|
cf1db0db60 | ||
|
|
d5d1ed9336 |
6
.gitignore
vendored
6
.gitignore
vendored
@ -150,4 +150,8 @@ dmypy.json
|
||||
*~
|
||||
|
||||
_build/
|
||||
_dist/
|
||||
_dist/
|
||||
.venv/
|
||||
env/
|
||||
venv/
|
||||
.DS_Store
|
||||
BIN
CodeBridge.ico
BIN
CodeBridge.ico
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 15 KiB |
80
README.md
80
README.md
@ -1,16 +1,78 @@
|
||||
# SXXXXXXX_CodeBridge
|
||||
|
||||
A brief description of SXXXXXXX_CodeBridge.
|
||||
SXXXXXXX_CodeBridge is a small desktop utility that compares two directory trees, creates ZIP "export packages" containing changed files and a JSON manifest, and can apply those packages to target trees. It is intended for lightweight synchronization and code/package exchange workflows.
|
||||
|
||||
## Features
|
||||
- Feature 1
|
||||
- Feature 2
|
||||
**Supported platforms:** Windows, macOS, Linux (requires Python 3.11+ and tkinter)
|
||||
|
||||
## Getting Started
|
||||
...
|
||||
## Features / Caratteristiche
|
||||
- Compare two directory trees and show added, modified and deleted files
|
||||
- Produce a ZIP export package with a `bridge_manifest.json` describing the changes
|
||||
- Apply an export package to a destination tree (handles deletions and file extraction)
|
||||
- GUI with side-by-side diff viewer, minimap, profile-based ignore lists, and embedded logs
|
||||
|
||||
## Contributing
|
||||
...
|
||||
## Quick Start / Avvio rapido
|
||||
English
|
||||
1. Install Python 3.11+ and ensure `tkinter` is available.
|
||||
2. From the repository root run:
|
||||
|
||||
```bash
|
||||
python -m codebridge
|
||||
```
|
||||
|
||||
### Running in a local virtual environment / Eseguire in un ambiente .venv
|
||||
|
||||
It is recommended to run the application inside an isolated Python virtual environment.
|
||||
|
||||
Windows (PowerShell or Command Prompt):
|
||||
|
||||
```powershell
|
||||
# from repository root
|
||||
scripts\create_venv.bat
|
||||
# then activate
|
||||
.venv\Scripts\activate.bat
|
||||
python -m codebridge
|
||||
```
|
||||
|
||||
Unix / macOS (bash / zsh):
|
||||
|
||||
```bash
|
||||
# from repository root
|
||||
./scripts/create_venv.sh
|
||||
source .venv/bin/activate
|
||||
python -m codebridge
|
||||
```
|
||||
|
||||
Notes / Note:
|
||||
- `tkinter` is part of the Python standard library but may need the platform GUI packages installed (e.g., `sudo apt install python3-tk` on Debian/Ubuntu).
|
||||
- Add any external dependencies to `requirements.txt` and re-run the script to install them into `.venv`.
|
||||
|
||||
Italiano
|
||||
1. Installa Python 3.11+ e assicurati che `tkinter` sia disponibile.
|
||||
2. Dalla cartella del repository esegui:
|
||||
|
||||
```bash
|
||||
python -m codebridge
|
||||
```
|
||||
|
||||
## Data contract / Manifest
|
||||
Exported ZIP packages contain a `bridge_manifest.json` with the following keys:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2023-10-27T10:00:00",
|
||||
"commit_message": "...",
|
||||
"changes": {"added": [], "modified": [], "deleted": []}
|
||||
}
|
||||
```
|
||||
|
||||
This manifest is used when applying a package so deletions are handled and modified/added files are extracted.
|
||||
|
||||
## Profiles and ignore lists / Profili e liste di ignore
|
||||
The application supports named profiles that persist to `profiles.json` at the repository root. Each profile can include `ignore_extensions` so you can exclude build artifacts or temporary files from comparisons.
|
||||
|
||||
## Contributing / Contribuire
|
||||
- Please follow PEP8 style for Python changes.
|
||||
- GUI and core logic are separated under `codebridge/gui` and `codebridge/core` — keep UI-only changes in `gui` and logic changes in `core`.
|
||||
|
||||
## License
|
||||
...
|
||||
Please check repository root for license information.
|
||||
|
||||
74
codebridge/_version.py
Normal file
74
codebridge/_version.py
Normal file
@ -0,0 +1,74 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# File generated by PyInstaller GUI Wrapper. DO NOT EDIT MANUALLY.
|
||||
# Contains build-time information scraped from Git (if available)
|
||||
# and a helper function to format version strings.
|
||||
|
||||
import re
|
||||
|
||||
# --- Version Data (Generated) ---
|
||||
__version__ = "v.0.0.0.6-0-g1208029"
|
||||
GIT_COMMIT_HASH = "1208029c66239b5e9e5356f9643172941e7cfce9"
|
||||
GIT_BRANCH = "master"
|
||||
BUILD_TIMESTAMP = "2025-12-23T13:57:10.904302+00:00"
|
||||
IS_GIT_REPO = True
|
||||
|
||||
# --- Default Values (for comparison or fallback) ---
|
||||
DEFAULT_VERSION = "0.0.0+unknown"
|
||||
DEFAULT_COMMIT = "Unknown"
|
||||
DEFAULT_BRANCH = "Unknown"
|
||||
|
||||
# --- Helper Function ---
|
||||
def get_version_string(format_string=None):
|
||||
"""
|
||||
Returns a formatted string based on the build version information.
|
||||
|
||||
Args:
|
||||
format_string (str, optional): A format string using placeholders.
|
||||
Defaults to "{version} ({branch}/{commit_short})" if None.
|
||||
Placeholders:
|
||||
{{version}}: Full version string (e.g., 'v1.0.0-5-gabcdef-dirty')
|
||||
{{tag}}: Clean tag part if exists (e.g., 'v1.0.0'), else DEFAULT_VERSION.
|
||||
{{commit}}: Full Git commit hash.
|
||||
{{commit_short}}: Short Git commit hash (7 chars).
|
||||
{{branch}}: Git branch name.
|
||||
{{dirty}}: '-dirty' if the repo was dirty, empty otherwise.
|
||||
{{timestamp}}: Full build timestamp (ISO 8601 UTC).
|
||||
{{timestamp_short}}: Build date only (YYYY-MM-DD).
|
||||
{{is_git}}: 'Git' if IS_GIT_REPO is True, 'Unknown' otherwise.
|
||||
|
||||
Returns:
|
||||
str: The formatted version string, or an error message if formatting fails.
|
||||
"""
|
||||
if format_string is None:
|
||||
format_string = "{version} ({branch}/{commit_short})" # Default format
|
||||
|
||||
replacements = {}
|
||||
try:
|
||||
replacements['version'] = __version__ if __version__ else DEFAULT_VERSION
|
||||
replacements['commit'] = GIT_COMMIT_HASH if GIT_COMMIT_HASH else DEFAULT_COMMIT
|
||||
replacements['commit_short'] = GIT_COMMIT_HASH[:7] if GIT_COMMIT_HASH and len(GIT_COMMIT_HASH) >= 7 else DEFAULT_COMMIT
|
||||
replacements['branch'] = GIT_BRANCH if GIT_BRANCH else DEFAULT_BRANCH
|
||||
replacements['timestamp'] = BUILD_TIMESTAMP if BUILD_TIMESTAMP else "Unknown"
|
||||
replacements['timestamp_short'] = BUILD_TIMESTAMP.split('T')[0] if BUILD_TIMESTAMP and 'T' in BUILD_TIMESTAMP else "Unknown"
|
||||
replacements['is_git'] = "Git" if IS_GIT_REPO else "Unknown"
|
||||
replacements['dirty'] = "-dirty" if __version__ and __version__.endswith('-dirty') else ""
|
||||
|
||||
tag = DEFAULT_VERSION
|
||||
if __version__ and IS_GIT_REPO:
|
||||
match = re.match(r'^(v?([0-9]+(?:\.[0-9]+)*))', __version__)
|
||||
if match:
|
||||
tag = match.group(1)
|
||||
replacements['tag'] = tag
|
||||
|
||||
output_string = format_string
|
||||
for placeholder, value in replacements.items():
|
||||
pattern = re.compile(r'{{\s*' + re.escape(placeholder) + r'\s*}}')
|
||||
output_string = pattern.sub(str(value), output_string)
|
||||
|
||||
if re.search(r'{\s*\w+\s*}', output_string):
|
||||
pass # Or log a warning: print(f"Warning: Unreplaced placeholders found: {output_string}")
|
||||
|
||||
return output_string
|
||||
|
||||
except Exception as e:
|
||||
return f"[Formatting Error: {e}]"
|
||||
@ -1,5 +1,6 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, messagebox
|
||||
import tkinter.font as tkfont
|
||||
import difflib
|
||||
import os
|
||||
import shutil
|
||||
@ -24,30 +25,119 @@ class DiffViewer(tk.Toplevel):
|
||||
"""
|
||||
Initializes the side-by-side layout and buttons.
|
||||
"""
|
||||
# Toolbar
|
||||
toolbar = ttk.Frame(self, padding=5)
|
||||
toolbar.pack(fill="x", side="top")
|
||||
copy_all_btn = ttk.Button(toolbar, text="⤴ Copy Source to Destination", command=self._copy_source_to_dest)
|
||||
copy_all_btn.pack(side="left", padx=5)
|
||||
# Main diff container
|
||||
# Top toolbar: navigation / refresh / save
|
||||
toolbar = ttk.LabelFrame(self, text="Actions", padding=6)
|
||||
toolbar.pack(fill="x", side="top", padx=5, pady=3)
|
||||
ttk.Button(toolbar, text="➡ Next Diff", command=self._next_diff).pack(side="left", padx=4)
|
||||
ttk.Button(toolbar, text="🔁 Refresh", command=self._refresh_diff).pack(side="left", padx=4)
|
||||
ttk.Button(toolbar, text="💾 Save", command=self._save_changes).pack(side="left", padx=4)
|
||||
# status label to indicate when the two panes are identical (styled)
|
||||
style = ttk.Style(self)
|
||||
try:
|
||||
bold_font = tkfont.Font(self, family="TkDefaultFont", weight="bold")
|
||||
except Exception:
|
||||
bold_font = None
|
||||
style.configure('Identical.TLabel', foreground='#ffffff', background='#2e8b57')
|
||||
if bold_font:
|
||||
style.configure('Identical.TLabel', font=bold_font)
|
||||
self._identical_label = ttk.Label(toolbar, text="", style='Identical.TLabel')
|
||||
self._identical_label.pack(side="right", padx=8, pady=2)
|
||||
# Main diff container: left text, minimap, right text, then scrollbar
|
||||
main_frame = ttk.Frame(self)
|
||||
main_frame.pack(fill="both", expand=True, padx=5, pady=5)
|
||||
# Left pane (Source)
|
||||
self.left_text = tk.Text(main_frame, wrap="none", undo=True)
|
||||
self.left_text.pack(side="left", fill="both", expand=True)
|
||||
# Right pane (Destination)
|
||||
self.right_text = tk.Text(main_frame, wrap="none", undo=True)
|
||||
self.right_text.pack(side="left", fill="both", expand=True)
|
||||
# Scrollbar for both
|
||||
# Use grid inside main_frame so left and right panes can share space equally
|
||||
main_frame.grid_rowconfigure(0, weight=1)
|
||||
main_frame.grid_columnconfigure(0, weight=1)
|
||||
main_frame.grid_columnconfigure(1, weight=0)
|
||||
main_frame.grid_columnconfigure(2, weight=1)
|
||||
|
||||
# Left: Source frame with path and copy-buttons
|
||||
left_frame = ttk.LabelFrame(main_frame, text="Source", padding=4)
|
||||
left_frame.grid(row=0, column=0, sticky="nsew")
|
||||
# use grid inside left_frame so horizontal scrollbar spans text width
|
||||
left_frame.grid_rowconfigure(2, weight=1)
|
||||
left_frame.grid_columnconfigure(0, weight=1)
|
||||
src_label = ttk.Label(left_frame, text=f"{self.source_path}", anchor="w")
|
||||
src_label.grid(row=0, column=0, columnspan=2, sticky="ew", padx=2, pady=(2, 4))
|
||||
src_btns = ttk.Frame(left_frame)
|
||||
src_btns.grid(row=1, column=0, columnspan=2, sticky="ew", padx=2, pady=(0,4))
|
||||
ttk.Button(src_btns, text="Copy to Right →", command=lambda: self._copy_all('left','right')).pack(side="left", padx=3)
|
||||
ttk.Button(src_btns, text="Copy Selection to Right →", command=lambda: self._copy_selection('left','right')).pack(side="left", padx=3)
|
||||
# Text area with vertical scrollbar
|
||||
self.left_text = tk.Text(left_frame, wrap="none", undo=True)
|
||||
self.left_text.grid(row=2, column=0, sticky="nsew")
|
||||
self.left_scroll = ttk.Scrollbar(left_frame, orient="vertical", command=lambda *a: self._sync_scroll_from('left', *a))
|
||||
self.left_scroll.grid(row=2, column=1, sticky="ns")
|
||||
# horizontal scrollbar for left text (wire to sync handler)
|
||||
self.left_hscroll = ttk.Scrollbar(left_frame, orient="horizontal", command=lambda *a: self._sync_xscroll_from('left', *a))
|
||||
self.left_hscroll.grid(row=3, column=0, columnspan=2, sticky="ew")
|
||||
self.left_text.config(xscrollcommand=lambda *a: self._on_xscroll('left', *a))
|
||||
|
||||
# Minimap between the two text widgets
|
||||
# Center: Map frame
|
||||
center_pane = ttk.LabelFrame(main_frame, text="Map", padding=4)
|
||||
center_pane.grid(row=0, column=1, sticky="ns")
|
||||
self.minimap = tk.Canvas(center_pane, width=60, bg="#f0f0f0", highlightthickness=1, highlightbackground='black')
|
||||
self.minimap.pack(fill="both", expand=True, padx=4, pady=2)
|
||||
|
||||
# Right: Destination frame with path and copy-buttons
|
||||
right_frame = ttk.LabelFrame(main_frame, text="Destination", padding=4)
|
||||
right_frame.grid(row=0, column=2, sticky="nsew")
|
||||
# use grid inside right_frame so horizontal scrollbar spans text width
|
||||
right_frame.grid_rowconfigure(2, weight=1)
|
||||
right_frame.grid_columnconfigure(0, weight=1)
|
||||
dest_label = ttk.Label(right_frame, text=f"{self.dest_path}", anchor="w")
|
||||
dest_label.grid(row=0, column=0, columnspan=2, sticky="ew", padx=2, pady=(2, 4))
|
||||
dst_btns = ttk.Frame(right_frame)
|
||||
dst_btns.grid(row=1, column=0, columnspan=2, sticky="ew", padx=2, pady=(0,4))
|
||||
ttk.Button(dst_btns, text="← Copy to Left", command=lambda: self._copy_all('right','left')).pack(side="left", padx=3)
|
||||
ttk.Button(dst_btns, text="← Copy Selection to Left", command=lambda: self._copy_selection('right','left')).pack(side="left", padx=3)
|
||||
self.right_text = tk.Text(right_frame, wrap="none", undo=True)
|
||||
self.right_text.grid(row=2, column=0, sticky="nsew")
|
||||
self.right_scroll = ttk.Scrollbar(right_frame, orient="vertical", command=lambda *a: self._sync_scroll_from('right', *a))
|
||||
self.right_scroll.grid(row=2, column=1, sticky="ns")
|
||||
# horizontal scrollbar for right text (wire to sync handler)
|
||||
self.right_hscroll = ttk.Scrollbar(right_frame, orient="horizontal", command=lambda *a: self._sync_xscroll_from('right', *a))
|
||||
self.right_hscroll.grid(row=3, column=0, columnspan=2, sticky="ew")
|
||||
self.right_text.config(xscrollcommand=lambda *a: self._on_xscroll('right', *a))
|
||||
|
||||
# Scrollbar for both text widgets (rightmost)
|
||||
self.v_scroll = ttk.Scrollbar(main_frame, orient="vertical", command=self._sync_scroll)
|
||||
self.v_scroll.pack(side="right", fill="y")
|
||||
self.left_text.config(yscrollcommand=self.v_scroll.set)
|
||||
self.right_text.config(yscrollcommand=self.v_scroll.set)
|
||||
# Colors for diff
|
||||
self.left_text.tag_config("removed", background="#ffeef0")
|
||||
self.right_text.tag_config("added", background="#e6ffed")
|
||||
self.left_text.tag_config("changed", background="#fffbdd")
|
||||
self.right_text.tag_config("changed", background="#fffbdd")
|
||||
self.v_scroll.grid(row=0, column=3, sticky="ns")
|
||||
# Use custom yscrollcommand so we can update minimap viewport
|
||||
self.left_text.config(yscrollcommand=lambda *a: self._on_yscroll('left', *a))
|
||||
self.right_text.config(yscrollcommand=lambda *a: self._on_yscroll('right', *a))
|
||||
# Colors for diff (left: source, right: destination)
|
||||
self.left_text.tag_config("added", background="#d6ffd6")
|
||||
self.left_text.tag_config("changed", background="#fff3bf")
|
||||
# inline changed region (more accentuated)
|
||||
self.left_text.tag_config("changed_inline", background="#ffb366")
|
||||
self.right_text.tag_config("removed", background="#ffd6d6")
|
||||
self.right_text.tag_config("changed", background="#fff3bf")
|
||||
self.right_text.tag_config("changed_inline", background="#ffb366")
|
||||
# Bind mousewheel to synchronize scrolling between both panes
|
||||
self.left_text.bind("<MouseWheel>", self._on_mousewheel)
|
||||
self.right_text.bind("<MouseWheel>", self._on_mousewheel)
|
||||
# For X11 systems
|
||||
self.left_text.bind("<Button-4>", self._on_mousewheel)
|
||||
self.left_text.bind("<Button-5>", self._on_mousewheel)
|
||||
self.right_text.bind("<Button-4>", self._on_mousewheel)
|
||||
self.right_text.bind("<Button-5>", self._on_mousewheel)
|
||||
# Minimap interactions
|
||||
self.minimap.bind("<Button-1>", self._on_minimap_click)
|
||||
# rebuild minimap when resized
|
||||
self.minimap.bind("<Configure>", lambda e: self._build_minimap())
|
||||
self._minimap_marks = []
|
||||
# track encodings and dirty state
|
||||
self._left_encoding = 'utf-8'
|
||||
self._right_encoding = 'utf-8'
|
||||
self._left_dirty = False
|
||||
self._right_dirty = False
|
||||
# mark edits
|
||||
self.left_text.bind('<KeyRelease>', lambda e: self._mark_dirty('left'))
|
||||
self.right_text.bind('<KeyRelease>', lambda e: self._mark_dirty('right'))
|
||||
# intercept close
|
||||
self.protocol('WM_DELETE_WINDOW', self._on_close)
|
||||
|
||||
def _sync_scroll(self, *args) -> None:
|
||||
"""
|
||||
@ -55,58 +145,521 @@ class DiffViewer(tk.Toplevel):
|
||||
"""
|
||||
self.left_text.yview(*args)
|
||||
self.right_text.yview(*args)
|
||||
# update left/right scrollbar positions
|
||||
try:
|
||||
self.left_scroll.set(*self.left_text.yview())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.right_scroll.set(*self.right_text.yview())
|
||||
except Exception:
|
||||
pass
|
||||
# update minimap viewport
|
||||
self._update_minimap_viewport()
|
||||
|
||||
def _on_yscroll(self, *args) -> None:
|
||||
"""Callback used as yscrollcommand for text widgets to update scrollbar and minimap."""
|
||||
side = args[0] if args else None
|
||||
# args may be ('fraction1','fraction2') or ('moveto', frac)
|
||||
try:
|
||||
if side == '0.0':
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
# update both scrollbars positions from both text widgets
|
||||
try:
|
||||
self.left_scroll.set(*self.left_text.yview())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.right_scroll.set(*self.right_text.yview())
|
||||
except Exception:
|
||||
pass
|
||||
self._update_minimap_viewport()
|
||||
|
||||
def _sync_scroll_from(self, which: str, *args) -> None:
|
||||
"""Scrollbar command handler for left/right scrollbars: forward to both text widgets."""
|
||||
try:
|
||||
# args forwarded to yview
|
||||
self.left_text.yview(*args)
|
||||
self.right_text.yview(*args)
|
||||
# update both scrollbars
|
||||
self.left_scroll.set(*self.left_text.yview())
|
||||
self.right_scroll.set(*self.right_text.yview())
|
||||
self._update_minimap_viewport()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_xscroll(self, which: str, *args) -> None:
|
||||
"""Callback used as xscrollcommand for text widgets to update horizontal scrollbars and sync other pane."""
|
||||
# prevent recursion
|
||||
if getattr(self, '_xsync_in_progress', False):
|
||||
return
|
||||
try:
|
||||
self._xsync_in_progress = True
|
||||
if which == 'left':
|
||||
try:
|
||||
self.left_hscroll.set(*args)
|
||||
except Exception:
|
||||
pass
|
||||
# args are typically (first, last) fractions
|
||||
try:
|
||||
frac = float(args[0]) if args else 0.0
|
||||
self.right_text.xview_moveto(frac)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
self.right_hscroll.set(*args)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
frac = float(args[0]) if args else 0.0
|
||||
self.left_text.xview_moveto(frac)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
self._xsync_in_progress = False
|
||||
|
||||
def _sync_xscroll_from(self, which: str, *args) -> None:
|
||||
"""Scrollbar command handler for horizontal scrollbars: forward to both text widgets."""
|
||||
try:
|
||||
# args can be ('moveto', fraction) or ('scroll', number, 'units'/'pages')
|
||||
if args and args[0] == 'moveto':
|
||||
frac = float(args[1])
|
||||
self.left_text.xview_moveto(frac)
|
||||
self.right_text.xview_moveto(frac)
|
||||
else:
|
||||
# delegate to xview which handles scroll units/pages
|
||||
try:
|
||||
self.left_text.xview(*args)
|
||||
self.right_text.xview(*args)
|
||||
except Exception:
|
||||
pass
|
||||
# update scrollbar thumbs
|
||||
try:
|
||||
self.left_hscroll.set(*self.left_text.xview())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.right_hscroll.set(*self.right_text.xview())
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_mousewheel(self, event) -> str:
|
||||
"""Handle mouse wheel events to scroll both panes together."""
|
||||
try:
|
||||
if event.num == 5 or event.delta < 0:
|
||||
delta = 1
|
||||
else:
|
||||
delta = -1
|
||||
except Exception:
|
||||
# fallback
|
||||
delta = int(-1 * (event.delta / 120)) if hasattr(event, 'delta') else 0
|
||||
try:
|
||||
self.left_text.yview_scroll(delta, "units")
|
||||
self.right_text.yview_scroll(delta, "units")
|
||||
self._update_minimap_viewport()
|
||||
except Exception:
|
||||
pass
|
||||
return "break"
|
||||
|
||||
def _load_and_compare(self) -> None:
|
||||
"""
|
||||
Reads files and highlights differences.
|
||||
"""
|
||||
try:
|
||||
with open(self.source_path, "r", encoding="utf-8") as f:
|
||||
source_lines = f.readlines()
|
||||
source_lines, enc = self._read_file_with_fallback(self.source_path)
|
||||
self._left_encoding = enc or 'utf-8'
|
||||
# Handle case where destination file might not exist (Added files)
|
||||
dest_lines = []
|
||||
if os.path.exists(self.dest_path):
|
||||
with open(self.dest_path, "r", encoding="utf-8") as f:
|
||||
dest_lines = f.readlines()
|
||||
diff = difflib.ndiff(dest_lines, source_lines)
|
||||
self._display_diff(diff)
|
||||
dest_lines, enc2 = self._read_file_with_fallback(self.dest_path)
|
||||
self._right_encoding = enc2 or 'utf-8'
|
||||
# store lines for display rendering
|
||||
self._src_lines = source_lines
|
||||
self._dest_lines = dest_lines
|
||||
self._display_diff()
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Could not read files: {e}")
|
||||
messagebox.showerror("Error", f"Could not read files: {e}", parent=self)
|
||||
self.destroy()
|
||||
|
||||
def _display_diff(self, diff_generator) -> None:
|
||||
def _read_file_with_fallback(self, path: str):
|
||||
"""Read a file and try common encodings if UTF-8 fails.
|
||||
|
||||
Returns a list of lines (with line endings) or raises an Exception
|
||||
if the file appears to be binary.
|
||||
"""
|
||||
Parses the diff and populates the text widgets with highlighting.
|
||||
if not os.path.exists(path):
|
||||
return []
|
||||
# Read raw bytes first to detect NULs (binary files)
|
||||
with open(path, 'rb') as bf:
|
||||
data = bf.read()
|
||||
if b'\x00' in data:
|
||||
raise Exception('File appears to be binary (contains NUL bytes)')
|
||||
# Try decodings in order
|
||||
for enc in ('utf-8', 'cp1252', 'latin-1'):
|
||||
try:
|
||||
text = data.decode(enc)
|
||||
return text.splitlines(keepends=True), enc
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
# As a last resort, decode with replacement to avoid crashing
|
||||
text = data.decode('utf-8', errors='replace')
|
||||
return text.splitlines(keepends=True), 'utf-8'
|
||||
|
||||
def _display_diff(self) -> None:
|
||||
"""
|
||||
Show the full source and destination contents and tag differing ranges.
|
||||
Uses SequenceMatcher opcodes to compute changed/added/removed ranges so
|
||||
we do not need to insert placeholder blank lines that accumulate on refresh.
|
||||
"""
|
||||
# ensure editable while displaying
|
||||
self.left_text.config(state="normal")
|
||||
self.right_text.config(state="normal")
|
||||
for line in diff_generator:
|
||||
code = line[:2]
|
||||
content = line[2:]
|
||||
if code == " ":
|
||||
self.left_text.insert("end", content)
|
||||
self.right_text.insert("end", content)
|
||||
elif code == "- ":
|
||||
self.left_text.insert("end", content, "removed")
|
||||
self.right_text.insert("end", "\n")
|
||||
elif code == "+ ":
|
||||
self.left_text.insert("end", "\n")
|
||||
self.right_text.insert("end", content, "added")
|
||||
elif code == "? ":
|
||||
# clear widgets
|
||||
self.left_text.delete('1.0', 'end')
|
||||
self.right_text.delete('1.0', 'end')
|
||||
# source (left) and dest (right) lines
|
||||
src_lines = getattr(self, '_src_lines', [])
|
||||
dest_lines = getattr(self, '_dest_lines', [])
|
||||
# insert full contents as-is
|
||||
self.left_text.insert('1.0', ''.join(src_lines))
|
||||
self.right_text.insert('1.0', ''.join(dest_lines))
|
||||
# prepare status arrays for minimap
|
||||
left_lines = ['unchanged'] * len(src_lines)
|
||||
right_lines = ['unchanged'] * len(dest_lines)
|
||||
# compute opcodes with dest as a, source as b
|
||||
matcher = difflib.SequenceMatcher(None, dest_lines, src_lines)
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag == 'equal':
|
||||
continue
|
||||
self.left_text.config(state="disabled")
|
||||
self.right_text.config(state="disabled")
|
||||
if tag == 'replace':
|
||||
# mark changed ranges on both sides
|
||||
if j2 > j1:
|
||||
start = f"{j1+1}.0"
|
||||
end = f"{j2+1}.0"
|
||||
self.left_text.tag_add('changed', start, end)
|
||||
for k in range(j1, j2):
|
||||
left_lines[k] = 'changed'
|
||||
if i2 > i1:
|
||||
start = f"{i1+1}.0"
|
||||
end = f"{i2+1}.0"
|
||||
self.right_text.tag_add('changed', start, end)
|
||||
for k in range(i1, i2):
|
||||
right_lines[k] = 'changed'
|
||||
# Attempt intraline/highlight of exact differing character ranges
|
||||
try:
|
||||
# pair up lines in the replace block where possible
|
||||
left_block_len = j2 - j1
|
||||
right_block_len = i2 - i1
|
||||
pairs = min(left_block_len, right_block_len)
|
||||
for offset in range(pairs):
|
||||
lidx = j1 + offset
|
||||
ridx = i1 + offset
|
||||
# get raw line text without trailing newline for character offsets
|
||||
try:
|
||||
left_line = src_lines[lidx].rstrip('\n')
|
||||
except Exception:
|
||||
left_line = ''
|
||||
try:
|
||||
right_line = dest_lines[ridx].rstrip('\n')
|
||||
except Exception:
|
||||
right_line = ''
|
||||
# if either line is empty, skip detailed matching
|
||||
if not left_line and not right_line:
|
||||
continue
|
||||
sm = difflib.SequenceMatcher(None, left_line, right_line)
|
||||
for ctag, a1, a2, b1, b2 in sm.get_opcodes():
|
||||
if ctag == 'equal':
|
||||
continue
|
||||
# apply inline tags: left side chars a1..a2, right side b1..b2
|
||||
# left
|
||||
if a2 > a1:
|
||||
try:
|
||||
start_idx = f"{lidx+1}.{a1}"
|
||||
end_idx = f"{lidx+1}.{a2}"
|
||||
self.left_text.tag_add('changed_inline', start_idx, end_idx)
|
||||
except Exception:
|
||||
pass
|
||||
# right
|
||||
if b2 > b1:
|
||||
try:
|
||||
start_idx = f"{ridx+1}.{b1}"
|
||||
end_idx = f"{ridx+1}.{b2}"
|
||||
self.right_text.tag_add('changed_inline', start_idx, end_idx)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
elif tag == 'delete':
|
||||
# lines present in dest (right) but deleted from source -> removed on right
|
||||
if i2 > i1:
|
||||
start = f"{i1+1}.0"
|
||||
end = f"{i2+1}.0"
|
||||
self.right_text.tag_add('removed', start, end)
|
||||
for k in range(i1, i2):
|
||||
right_lines[k] = 'removed'
|
||||
elif tag == 'insert':
|
||||
# lines present in source (left) but not in dest -> added on left
|
||||
if j2 > j1:
|
||||
start = f"{j1+1}.0"
|
||||
end = f"{j2+1}.0"
|
||||
self.left_text.tag_add('added', start, end)
|
||||
for k in range(j1, j2):
|
||||
left_lines[k] = 'added'
|
||||
# store for minimap rendering
|
||||
self._left_lines_status = left_lines
|
||||
self._right_lines_status = right_lines
|
||||
# update identical-files indicator
|
||||
try:
|
||||
if src_lines == dest_lines:
|
||||
# show a visible green badge with checkmark
|
||||
try:
|
||||
self._identical_label.config(text="✅ Files identical")
|
||||
except Exception:
|
||||
self._identical_label.config(text="Files identical")
|
||||
else:
|
||||
self._identical_label.config(text="")
|
||||
except Exception:
|
||||
pass
|
||||
# build minimap markers
|
||||
self._build_minimap()
|
||||
|
||||
def _build_minimap(self) -> None:
|
||||
"""Draw a simple minimap showing added/removed/changed lines."""
|
||||
try:
|
||||
self.minimap.delete("all")
|
||||
height = max(100, self.minimap.winfo_height())
|
||||
width = max(30, self.minimap.winfo_width())
|
||||
# choose a unified total based on the maximum number of lines
|
||||
left_status = getattr(self, '_left_lines_status', [])
|
||||
right_status = getattr(self, '_right_lines_status', [])
|
||||
left_len = len(left_status)
|
||||
right_len = len(right_status)
|
||||
total = max(left_len, right_len, 1)
|
||||
# draw markers: additions on left (green), deletions on right (red), changes (yellow)
|
||||
for i, status in enumerate(left_status):
|
||||
if status == 'added':
|
||||
y = int((i / total) * height)
|
||||
self.minimap.create_rectangle(2, y, width-2, y+3, fill='#4dff4d', outline='')
|
||||
elif status == 'changed':
|
||||
y = int((i / total) * height)
|
||||
self.minimap.create_rectangle(2, y, width-2, y+3, fill='#ffd966', outline='')
|
||||
for i, status in enumerate(right_status):
|
||||
if status == 'removed':
|
||||
y = int((i / total) * height)
|
||||
self.minimap.create_rectangle(2, y, width-2, y+3, fill='#ff4d4d', outline='')
|
||||
elif status == 'changed':
|
||||
y = int((i / total) * height)
|
||||
self.minimap.create_rectangle(2, y, width-2, y+3, fill='#ffd966', outline='')
|
||||
# viewport rectangle
|
||||
self._update_minimap_viewport()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _update_minimap_viewport(self) -> None:
|
||||
try:
|
||||
self.minimap.delete('viewport')
|
||||
height = max(100, self.minimap.winfo_height())
|
||||
total = max(len(getattr(self, '_right_lines_status', [])), 1)
|
||||
first, last = self.right_text.yview()
|
||||
y1 = int(first * height)
|
||||
y2 = int(last * height)
|
||||
self.minimap.create_rectangle(1, y1, self.minimap.winfo_width()-1, y2, outline='#3333ff', tag='viewport')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _on_minimap_click(self, event) -> None:
|
||||
try:
|
||||
height = max(1, self.minimap.winfo_height())
|
||||
frac = event.y / height
|
||||
self.right_text.yview_moveto(frac)
|
||||
self.left_text.yview_moveto(frac)
|
||||
self._update_minimap_viewport()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _copy_source_to_dest(self) -> None:
|
||||
"""
|
||||
Overwrites the destination file with the source file content.
|
||||
"""
|
||||
confirm = messagebox.askyesno("Confirm", "Overwrite destination file with source content?")
|
||||
confirm = messagebox.askyesno("Confirm", "Overwrite destination file with source content?", parent=self)
|
||||
if confirm:
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.dest_path), exist_ok=True)
|
||||
shutil.copy2(self.source_path, self.dest_path)
|
||||
messagebox.showinfo("Success", "File copied successfully.")
|
||||
messagebox.showinfo("Success", "File copied successfully.", parent=self)
|
||||
self.destroy()
|
||||
except Exception as e:
|
||||
messagebox.showerror("Error", f"Failed to copy file: {e}")
|
||||
messagebox.showerror("Error", f"Failed to copy file: {e}", parent=self)
|
||||
|
||||
def _mark_dirty(self, side: str) -> None:
|
||||
if side == 'left':
|
||||
self._left_dirty = True
|
||||
else:
|
||||
self._right_dirty = True
|
||||
|
||||
def _next_diff(self) -> None:
|
||||
"""Scroll to the next diff marker in right (added) or left (removed)."""
|
||||
try:
|
||||
# top visible lines for each pane
|
||||
right_top = int(self.right_text.index('@0,0').split('.')[0])
|
||||
left_top = int(self.left_text.index('@0,0').split('.')[0])
|
||||
|
||||
def find_next_in(widget, tags, start_line):
|
||||
# start searching from the line after the top visible one to avoid hitting the same hunk repeatedly
|
||||
try:
|
||||
search_start = max(1, int(start_line) + 1)
|
||||
except Exception:
|
||||
search_start = 1
|
||||
start_index = f"{search_start}.0"
|
||||
candidates = []
|
||||
for tag in tags:
|
||||
try:
|
||||
rng = widget.tag_nextrange(tag, start_index)
|
||||
except Exception:
|
||||
rng = None
|
||||
if rng:
|
||||
idx = widget.index(rng[0])
|
||||
line = int(str(idx).split('.')[0])
|
||||
candidates.append((line, widget, idx))
|
||||
if candidates:
|
||||
return min(candidates, key=lambda t: t[0])
|
||||
return None
|
||||
|
||||
# search for next diff in both panes, using each pane's visible top as reference
|
||||
right_match = find_next_in(self.right_text, ['removed', 'changed'], right_top)
|
||||
left_match = find_next_in(self.left_text, ['added', 'changed'], left_top)
|
||||
|
||||
candidates = [m for m in (right_match, left_match) if m]
|
||||
target = None
|
||||
if candidates:
|
||||
target = min(candidates, key=lambda t: t[0])
|
||||
else:
|
||||
# wrap: search from start of file
|
||||
right_wrap = find_next_in(self.right_text, ['removed', 'changed'], 1)
|
||||
left_wrap = find_next_in(self.left_text, ['added', 'changed'], 1)
|
||||
candidates = [m for m in (right_wrap, left_wrap) if m]
|
||||
if candidates:
|
||||
target = min(candidates, key=lambda t: t[0])
|
||||
|
||||
if target:
|
||||
line, widget, idx = target
|
||||
# compute fraction based on the widget that contains the target to keep centering logical
|
||||
try:
|
||||
if widget is self.right_text:
|
||||
total_lines = max(1, int(self.right_text.index('end-1c').split('.')[0]))
|
||||
else:
|
||||
total_lines = max(1, int(self.left_text.index('end-1c').split('.')[0]))
|
||||
frac = (line - 1) / max(total_lines - 1, 1)
|
||||
except Exception:
|
||||
frac = 0.0
|
||||
# move both views to the same fractional position to keep panes aligned
|
||||
try:
|
||||
self.right_text.yview_moveto(frac)
|
||||
self.left_text.yview_moveto(frac)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
widget.focus_set()
|
||||
widget.mark_set('insert', idx)
|
||||
except Exception:
|
||||
pass
|
||||
self._update_minimap_viewport()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _copy_selection(self, src: str, dst: str) -> None:
|
||||
"""Copy selected text from src pane to dst pane (with confirmation)."""
|
||||
src_text = self.right_text if src == 'right' else self.left_text
|
||||
dst_text = self.left_text if dst == 'left' else self.right_text
|
||||
ranges = src_text.tag_ranges('sel')
|
||||
if not ranges:
|
||||
messagebox.showinfo('No Selection', 'Select the lines to copy first.', parent=self)
|
||||
return
|
||||
start_obj, end_obj = ranges[0], ranges[1]
|
||||
start = src_text.index(start_obj)
|
||||
end = src_text.index(end_obj)
|
||||
sel_text = src_text.get(start, end)
|
||||
if not sel_text:
|
||||
messagebox.showinfo('No Selection', 'Selected region is empty.', parent=self)
|
||||
return
|
||||
if not messagebox.askyesno('Confirm copy', f'Copy selection from {src} to {dst}?', parent=self):
|
||||
return
|
||||
# insert at corresponding line number
|
||||
line = int(str(start).split('.')[0])
|
||||
insert_index = f"{line}.0"
|
||||
dst_text.insert(insert_index, sel_text)
|
||||
self._mark_dirty(dst)
|
||||
self._refresh_diff()
|
||||
|
||||
def _copy_all(self, src: str, dst: str) -> None:
|
||||
src_text = self.right_text if src == 'right' else self.left_text
|
||||
dst_text = self.left_text if dst == 'left' else self.right_text
|
||||
if not messagebox.askyesno('Confirm copy all', f'Copy entire content from {src} to {dst}?', parent=self):
|
||||
return
|
||||
# use end-1c to avoid the Text widget's extra trailing newline
|
||||
content = src_text.get('1.0', 'end-1c')
|
||||
dst_text.delete('1.0', 'end')
|
||||
# if content is empty, ensure destination becomes empty (no extra newline)
|
||||
if content:
|
||||
dst_text.insert('1.0', content)
|
||||
else:
|
||||
# keep widget empty
|
||||
pass
|
||||
self._mark_dirty(dst)
|
||||
self._refresh_diff()
|
||||
|
||||
def _refresh_diff(self) -> None:
|
||||
"""Recompute the diff from the current in-memory texts and redraw."""
|
||||
try:
|
||||
# get current contents
|
||||
left_content = self.left_text.get('1.0', 'end-1c')
|
||||
right_content = self.right_text.get('1.0', 'end-1c')
|
||||
left_lines = left_content.splitlines(keepends=True)
|
||||
right_lines = right_content.splitlines(keepends=True)
|
||||
# update stored lines and re-render tags
|
||||
self._src_lines = left_lines
|
||||
self._dest_lines = right_lines
|
||||
self._display_diff()
|
||||
except Exception as e:
|
||||
messagebox.showerror('Error', f'Failed to refresh diff: {e}', parent=self)
|
||||
|
||||
def _save_changes(self) -> None:
|
||||
"""Save modified panes back to disk using remembered encodings."""
|
||||
try:
|
||||
if self._left_dirty:
|
||||
data = self.left_text.get('1.0', 'end-1c')
|
||||
enc = getattr(self, '_left_encoding', 'utf-8') or 'utf-8'
|
||||
with open(self.source_path, 'w', encoding=enc, errors='replace') as f:
|
||||
f.write(data)
|
||||
self._left_dirty = False
|
||||
if self._right_dirty:
|
||||
data = self.right_text.get('1.0', 'end-1c')
|
||||
enc = getattr(self, '_right_encoding', 'utf-8') or 'utf-8'
|
||||
os.makedirs(os.path.dirname(self.dest_path), exist_ok=True)
|
||||
with open(self.dest_path, 'w', encoding=enc, errors='replace') as f:
|
||||
f.write(data)
|
||||
self._right_dirty = False
|
||||
messagebox.showinfo('Saved', 'Files saved successfully.', parent=self)
|
||||
# rebuild minimap
|
||||
self._build_minimap()
|
||||
except Exception as e:
|
||||
messagebox.showerror('Save error', f'Failed to save: {e}', parent=self)
|
||||
|
||||
def _on_close(self) -> None:
|
||||
"""Prompt if there are unsaved changes, then close accordingly."""
|
||||
if self._left_dirty or self._right_dirty:
|
||||
resp = messagebox.askyesnocancel('Unsaved changes', 'Save changes before closing?\nYes=Save, No=Discard, Cancel=Keep editing', parent=self)
|
||||
if resp is True:
|
||||
self._save_changes()
|
||||
self.destroy()
|
||||
elif resp is False:
|
||||
self.destroy()
|
||||
else:
|
||||
return
|
||||
else:
|
||||
self.destroy()
|
||||
@ -1,5 +1,6 @@
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, filedialog, messagebox
|
||||
import tkinter.font as tkfont
|
||||
from tkinter.scrolledtext import ScrolledText
|
||||
import os
|
||||
import logging
|
||||
@ -24,6 +25,27 @@ from codebridge.gui.profile_dialog import ProfileManagerDialog, ProfileManagerFr
|
||||
import json
|
||||
|
||||
|
||||
# --- Import Version Info FOR THE WRAPPER ITSELF ---
|
||||
try:
|
||||
# Use absolute import based on package name
|
||||
from codebridge import _version as wrapper_version
|
||||
|
||||
WRAPPER_APP_VERSION_STRING = f"{wrapper_version.__version__} ({wrapper_version.GIT_BRANCH}/{wrapper_version.GIT_COMMIT_HASH[:7]})"
|
||||
WRAPPER_BUILD_INFO = f"Wrapper Built: {wrapper_version.BUILD_TIMESTAMP}"
|
||||
except ImportError:
|
||||
# This might happen if you run the wrapper directly from source
|
||||
# without generating its _version.py first (if you use that approach for the wrapper itself)
|
||||
WRAPPER_APP_VERSION_STRING = "(Dev Wrapper)"
|
||||
WRAPPER_BUILD_INFO = "Wrapper build time unknown"
|
||||
# --- End Import Version Info ---
|
||||
|
||||
# --- Constants for Version Generation ---
|
||||
DEFAULT_VERSION = "0.0.0+unknown"
|
||||
DEFAULT_COMMIT = "Unknown"
|
||||
DEFAULT_BRANCH = "Unknown"
|
||||
# --- End Constants --
|
||||
|
||||
|
||||
class MainWindow:
|
||||
"""
|
||||
Main GUI class for CodeBridge application.
|
||||
@ -31,7 +53,7 @@ class MainWindow:
|
||||
|
||||
def __init__(self, root: tk.Tk):
|
||||
self.root = root
|
||||
self.root.title("CodeBridge - Codebase Synchronizer")
|
||||
self.root.title(f"CodeBridge - Codebase Synchronizer - {WRAPPER_APP_VERSION_STRING}")
|
||||
# Make the window taller by default so status/log area is visible
|
||||
self.root.geometry("1000x760")
|
||||
# Enforce a reasonable minimum size so status bar remains visible
|
||||
@ -120,8 +142,15 @@ class MainWindow:
|
||||
actions_row.pack(fill="x", padx=10)
|
||||
actions_frame = ttk.LabelFrame(actions_row, text="Actions", padding=6)
|
||||
actions_frame.pack(side="left", fill="x", expand=True)
|
||||
self.compare_btn = ttk.Button(actions_frame, text="🔍 Compare Folders", command=self._run_comparison)
|
||||
self.compare_btn.pack(side="left", padx=5)
|
||||
# create a prominent style for the main Compare button
|
||||
style = ttk.Style(self.root)
|
||||
try:
|
||||
big_font = tkfont.Font(self.root, family="TkDefaultFont", size=11, weight="bold")
|
||||
style.configure('Big.TButton', font=big_font, padding=(10, 6))
|
||||
except Exception:
|
||||
style.configure('Big.TButton', padding=(8, 4))
|
||||
self.compare_btn = ttk.Button(actions_frame, text="🔍 Compare Folders", command=self._run_comparison, style='Big.TButton')
|
||||
self.compare_btn.pack(side="left", padx=8)
|
||||
self.export_btn = ttk.Button(actions_frame, text="📦 Export Changes (ZIP)", command=self._export_changes)
|
||||
self.export_btn.pack(side="left", padx=5)
|
||||
self.import_btn = ttk.Button(actions_frame, text="📥 Import Package (ZIP)", command=self._import_package)
|
||||
|
||||
@ -43,5 +43,50 @@
|
||||
".mk",
|
||||
".bak"
|
||||
]
|
||||
},
|
||||
"DevEnv da server svn": {
|
||||
"description": "DevEnv Grifo E",
|
||||
"source": "//tsclient/D/__BACKUP/GrifoE/GRIFO-E_svn/DevEnv",
|
||||
"destination": "C:/src/GRIFO-E - Copia/DevEnv",
|
||||
"ignore_extensions": [
|
||||
".o",
|
||||
".d",
|
||||
".obj",
|
||||
".class",
|
||||
".pyc",
|
||||
".pyo",
|
||||
".log",
|
||||
".tmp",
|
||||
".swp",
|
||||
".DS_Store",
|
||||
".exe",
|
||||
".a",
|
||||
".mk",
|
||||
".bak",
|
||||
".defs",
|
||||
".txt",
|
||||
".pdom"
|
||||
]
|
||||
},
|
||||
"REP da server svn": {
|
||||
"description": "REP Grifo E code base",
|
||||
"source": "//tsclient/D/__BACKUP/GrifoE/GRIFO-E_svn/REP",
|
||||
"destination": "C:/src/GRIFO-E - Copia/REP",
|
||||
"ignore_extensions": [
|
||||
".o",
|
||||
".d",
|
||||
".obj",
|
||||
".class",
|
||||
".pyc",
|
||||
".pyo",
|
||||
".log",
|
||||
".tmp",
|
||||
".swp",
|
||||
".DS_Store",
|
||||
".exe",
|
||||
".a",
|
||||
".mk",
|
||||
".bak"
|
||||
]
|
||||
}
|
||||
}
|
||||
12
requirements.txt
Normal file
12
requirements.txt
Normal file
@ -0,0 +1,12 @@
|
||||
# Project Python dependencies
|
||||
# The core application primarily uses the Python standard library (tkinter, zipfile, hashlib, json, shutil).
|
||||
# These optional runtime dependencies are recommended for full functionality of the bundled externals.
|
||||
|
||||
# Optional: resource monitoring support
|
||||
psutil>=5.9.0
|
||||
|
||||
# Optional: robust charset detection/normalization if you add encoding heuristics
|
||||
charset-normalizer>=3.0.0
|
||||
|
||||
# Dev / test dependencies (not required at runtime)
|
||||
# pytest>=7.0.0
|
||||
17
scripts/create_venv.bat
Normal file
17
scripts/create_venv.bat
Normal file
@ -0,0 +1,17 @@
|
||||
@echo off
|
||||
REM Create a local virtual environment in .venv and install dependencies
|
||||
IF EXIST .venv (
|
||||
echo .venv already exists. Skipping venv creation.
|
||||
) ELSE (
|
||||
python -m venv .venv
|
||||
)
|
||||
echo Activating and installing requirements...
|
||||
call .venv\Scripts\activate.bat
|
||||
pip install --upgrade pip
|
||||
if exist requirements.txt (
|
||||
pip install -r requirements.txt
|
||||
) else (
|
||||
echo No requirements.txt found - nothing to install.
|
||||
)
|
||||
echo Virtual environment ready. Activate it with:
|
||||
echo .venv\Scripts\activate.bat
|
||||
17
scripts/create_venv.sh
Normal file
17
scripts/create_venv.sh
Normal file
@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create a local virtual environment in .venv and install dependencies
|
||||
set -e
|
||||
if [ -d .venv ]; then
|
||||
echo ".venv already exists. Skipping venv creation."
|
||||
else
|
||||
python3 -m venv .venv
|
||||
fi
|
||||
echo "Activating and installing requirements..."
|
||||
source .venv/bin/activate
|
||||
pip install --upgrade pip
|
||||
if [ -f requirements.txt ]; then
|
||||
pip install -r requirements.txt
|
||||
else
|
||||
echo "No requirements.txt found - nothing to install."
|
||||
fi
|
||||
echo "Virtual environment ready. Activate it with: source .venv/bin/activate"
|
||||
Loading…
Reference in New Issue
Block a user