Skip to content

Commit 7eecf6a

Browse files
nikolay-eclaude
andcommitted
fix: resolve bugs and improve CI/CD reliability
- writer.py: add try-finally for stdout wrapper (resource leak) - cli.py: validate --max-depth >= 0 - tree.py: fix symlink check order, change UnicodeDecodeError to ERROR - ci.yml: replace flake8 with ruff, remove duplicate test-coverage job - cd.yml: remove always() race condition, quote git config, add version validation - pyproject.toml: add version bounds, replace flake8 with ruff, add isort 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 1ceac1c commit 7eecf6a

File tree

6 files changed

+70
-110
lines changed

6 files changed

+70
-110
lines changed

.github/workflows/cd.yml

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,15 @@ jobs:
4646
exit 1
4747
fi
4848
49+
- name: Validate version format
50+
run: |
51+
VERSION="${{ github.event.inputs.version }}"
52+
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9]+)?$ ]]; then
53+
echo "Error: Invalid version format '$VERSION'. Expected semver like 1.0.0 or 1.0.0-rc1"
54+
exit 1
55+
fi
56+
echo "Version format valid: $VERSION"
57+
4958
- name: Set version in version.py
5059
env:
5160
VERSION: ${{ github.event.inputs.version }}
@@ -66,8 +75,8 @@ jobs:
6675
- name: Commit version bump (locally only, no push yet)
6776
id: commit_version
6877
run: |
69-
git config user.name github-actions[bot]
70-
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
78+
git config user.name "github-actions[bot]"
79+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
7180
git add src/treemapper/version.py
7281
if ! git diff --staged --quiet; then
7382
git commit -m "Release version ${{ github.event.inputs.version }}"
@@ -282,7 +291,6 @@ jobs:
282291
name: Push Release and Create GitHub Release
283292
needs: [prepare-version, build-assets, publish-to-pypi]
284293
if: |
285-
always() &&
286294
needs.prepare-version.result == 'success' &&
287295
needs.build-assets.result == 'success' &&
288296
(needs.publish-to-pypi.result == 'success' ||
@@ -313,8 +321,8 @@ jobs:
313321
- name: Push commit and tag to main
314322
working-directory: ./repo
315323
run: |
316-
git config user.name github-actions[bot]
317-
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
324+
git config user.name "github-actions[bot]"
325+
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
318326
319327
# Push the version bump commit to main
320328
git push origin HEAD:main

.github/workflows/ci.yml

Lines changed: 20 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ jobs:
7171
7272
- name: Run Linters and Formatters Check
7373
run: |
74-
flake8 src tests
74+
ruff check src tests
7575
black --check src tests
7676
7777
- name: Run Type Checker (Mypy)
@@ -116,17 +116,33 @@ jobs:
116116
117117
- name: Run Tests with Coverage
118118
run: |
119-
pytest -v --cov=src/treemapper --cov-report=xml
119+
pytest -v --cov=src/treemapper --cov-report=xml --cov-report=term-missing --cov-branch --junitxml=test-results.xml
120+
121+
- name: Coverage report with threshold
122+
if: runner.os == 'Linux' && matrix.python-version == '3.12'
123+
run: |
124+
coverage report --fail-under=80 --skip-covered --show-missing
120125
121126
- name: Upload coverage reports to Codecov
122-
if: runner.os == 'Linux' && matrix.python-version == '3.11'
127+
if: runner.os == 'Linux' && matrix.python-version == '3.12'
123128
uses: codecov/codecov-action@v5
124129
with:
125130
token: ${{ secrets.CODECOV_TOKEN }}
126131
files: ./coverage.xml
132+
flags: integration
127133
fail_ci_if_error: false
128134
verbose: true
129135

136+
- name: Upload coverage for SonarCloud
137+
uses: actions/upload-artifact@v5
138+
if: runner.os == 'Linux' && matrix.python-version == '3.12'
139+
with:
140+
name: coverage-report
141+
path: |
142+
coverage.xml
143+
test-results.xml
144+
retention-days: 1
145+
130146
# ============================================================================
131147
# PyPy Compatibility Testing
132148
# ============================================================================
@@ -164,72 +180,6 @@ jobs:
164180
run: |
165181
pytest -v
166182
167-
# ============================================================================
168-
# Test Coverage with Quality Gates
169-
# Evidence: Branch coverage correlates with defect detection
170-
# ============================================================================
171-
test-coverage:
172-
name: Test Coverage & Quality Gates
173-
runs-on: ubuntu-latest
174-
strategy:
175-
matrix:
176-
python-version: ['3.10', '3.11', '3.12']
177-
178-
steps:
179-
- uses: actions/checkout@v6
180-
181-
- name: Set up Python ${{ matrix.python-version }}
182-
uses: actions/setup-python@v6
183-
with:
184-
python-version: ${{ matrix.python-version }}
185-
186-
- name: Cache dependencies
187-
uses: actions/cache@v4
188-
with:
189-
path: ~/.cache/pip
190-
key: ${{ runner.os }}-pip-cov-${{ hashFiles('**/pyproject.toml') }}
191-
restore-keys: |
192-
${{ runner.os }}-pip-cov-
193-
194-
- name: Install dependencies
195-
run: |
196-
python -m pip install --upgrade pip
197-
pip install -e .[dev]
198-
199-
- name: Run tests with branch coverage
200-
run: |
201-
pytest \
202-
--cov=src/treemapper \
203-
--cov-report=xml \
204-
--cov-report=term-missing \
205-
--cov-branch \
206-
--junitxml=test-results.xml \
207-
-v
208-
209-
- name: Coverage report with threshold
210-
run: |
211-
coverage report --fail-under=80 --skip-covered --show-missing
212-
213-
- name: Upload coverage to Codecov
214-
uses: codecov/codecov-action@v5
215-
with:
216-
token: ${{ secrets.CODECOV_TOKEN }}
217-
files: ./coverage.xml
218-
flags: unittests
219-
name: codecov-${{ matrix.python-version }}
220-
fail_ci_if_error: false
221-
verbose: true
222-
223-
- name: Upload coverage for SonarCloud
224-
uses: actions/upload-artifact@v5
225-
if: matrix.python-version == '3.12'
226-
with:
227-
name: coverage-report
228-
path: |
229-
coverage.xml
230-
test-results.xml
231-
retention-days: 1
232-
233183
# ============================================================================
234184
# Mutation Testing (test effectiveness validation)
235185
# Evidence: Mutation score correlates with real fault detection
@@ -353,7 +303,7 @@ jobs:
353303
sonarcloud:
354304
name: SonarCloud Analysis
355305
runs-on: ubuntu-latest
356-
needs: test-coverage
306+
needs: test
357307
if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main'
358308

359309
steps:
@@ -384,7 +334,6 @@ jobs:
384334
sonar.python.coverage.reportPaths=coverage.xml
385335
sonar.python.xunit.reportPath=test-results.xml
386336
sonar.python.version=3.9,3.10,3.11,3.12
387-
sonar.exclusions=**/*_test.py,**/test_*.py
388337
EOF
389338
390339
- name: SonarCloud Scan

pyproject.toml

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ classifiers = [
2727
"Operating System :: OS Independent",
2828
]
2929
dependencies = [
30-
"pathspec>=0.9",
31-
"pyyaml>=5.4",
30+
"pathspec>=0.9,<1.0",
31+
"pyyaml>=5.4,<7.0",
3232
]
3333

3434
[project.urls]
@@ -40,25 +40,26 @@ treemapper = "treemapper.treemapper:main"
4040
[project.optional-dependencies]
4141
dev = [
4242
# Testing
43-
"pytest>=7.0",
44-
"pytest-cov>=3.0",
45-
"hypothesis>=6.0",
46-
"mutmut>=2.0",
47-
"coverage>=7.0",
43+
"pytest>=7.0,<9.0",
44+
"pytest-cov>=3.0,<6.0",
45+
"hypothesis>=6.0,<7.0",
46+
"mutmut>=2.0,<3.0",
47+
"coverage>=7.0,<8.0",
4848
# Quality checks
49-
"flake8>=5.0",
50-
"black>=23.0.0",
51-
"mypy>=1.0",
52-
"radon>=6.0",
53-
"import-linter>=2.0",
54-
"pre-commit>=3.0",
55-
"autoflake",
49+
"ruff>=0.4,<1.0",
50+
"black>=23.0.0,<26.0",
51+
"isort>=5.12,<6.0",
52+
"mypy>=1.0,<2.0",
53+
"radon>=6.0,<7.0",
54+
"import-linter>=2.0,<3.0",
55+
"pre-commit>=3.0,<4.0",
56+
"autoflake>=2.0,<3.0",
5657
# Type stubs
57-
"types-PyYAML",
58+
"types-PyYAML>=6.0,<7.0",
5859
# Build and release
59-
"build>=0.10",
60-
"twine>=4.0",
61-
"pyinstaller>=5.0",
60+
"build>=0.10,<2.0",
61+
"twine>=4.0,<6.0",
62+
"pyinstaller>=5.0,<7.0",
6263
]
6364

6465
[tool.setuptools.dynamic]

src/treemapper/cli.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,11 @@ def parse_args() -> Tuple[Path, Optional[Path], Optional[Path], bool, int, str,
6868

6969
args = parser.parse_args()
7070

71+
if args.max_depth is not None and args.max_depth < 0:
72+
print(f"Error: --max-depth must be non-negative, got {args.max_depth}", file=sys.stderr)
73+
sys.exit(1)
74+
7175
try:
72-
# The target directory to be read. This is correct as is.
7376
root_dir = Path(args.directory).resolve(strict=True)
7477
if not root_dir.is_dir():
7578
print(f"Error: The path '{root_dir}' is not a valid directory.", file=sys.stderr)

src/treemapper/tree.py

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ def build_tree(
5353
if should_ignore(relative_path_check, combined_spec):
5454
continue
5555

56-
if not entry.exists() or entry.is_symlink():
57-
logging.debug(f"Skipping '{relative_path_check}': not exists or is symlink")
56+
if entry.is_symlink() or not entry.exists():
57+
logging.debug(f"Skipping '{relative_path_check}': symlink or not exists")
5858
continue
5959

6060
node = create_node(entry, base_dir, combined_spec, output_file, max_depth, current_depth, no_content, max_file_bytes)
@@ -123,21 +123,18 @@ def create_node(
123123
node_content = f"<binary file: {file_size} bytes>"
124124
logging.debug(f"Detected binary file {entry.name}")
125125
else:
126-
# Try to read the file directly and handle all possible errors
127126
node_content = entry.read_text(encoding="utf-8")
128-
if isinstance(node_content, str):
129-
cleaned_content = node_content.replace("\x00", "")
130-
if cleaned_content != node_content:
131-
logging.warning(f"Removed NULL bytes from content of {entry.name}")
132-
node_content = cleaned_content
133-
# Ensure content always ends with a newline for consistency with old behavior
134-
if node_content and not node_content.endswith("\n"):
135-
node_content = node_content + "\n"
127+
cleaned_content = node_content.replace("\x00", "")
128+
if cleaned_content != node_content:
129+
logging.warning(f"Removed NULL bytes from content of {entry.name}")
130+
node_content = cleaned_content
131+
if node_content and not node_content.endswith("\n"):
132+
node_content = node_content + "\n"
136133
except PermissionError:
137134
logging.error(f"Could not read {entry.name}: Permission denied")
138135
node_content = "<unreadable content>\n"
139136
except UnicodeDecodeError:
140-
logging.warning(f"Cannot decode {entry.name} as UTF-8. Marking as unreadable.")
137+
logging.error(f"Cannot decode {entry.name} as UTF-8. Marking as unreadable.")
141138
node_content = "<unreadable content: not utf-8>\n"
142139
except IOError as e_read:
143140
logging.error(f"Could not read {entry.name}: {e_read}")

src/treemapper/writer.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,12 @@ def write_tree_content(f: TextIO) -> None:
113113
buf = None
114114

115115
if buf:
116-
# Use TextIOWrapper to ensure UTF-8 encoding for stdout
117116
utf8_stdout = io.TextIOWrapper(buf, encoding="utf-8", newline="")
118-
write_tree_content(utf8_stdout)
119-
utf8_stdout.flush()
117+
try:
118+
write_tree_content(utf8_stdout)
119+
utf8_stdout.flush()
120+
finally:
121+
utf8_stdout.detach()
120122
else:
121123
# Fallback: write directly to sys.stdout
122124
write_tree_content(sys.stdout)

0 commit comments

Comments
 (0)