Skip to content

Fix Windows Compatibility: Symlink Handling, Permission Fixes, and Test Stability Improvements #2438

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/windows-test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Windows - Smoke Tests

on:
push:
pull_request:

jobs:
windows-tests:
runs-on: windows-latest
timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v5
with:
submodules: true
- name: Set up Python 3.9
uses: actions/setup-python@v5
with:
python-version: "3.9"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r test-requirements.txt
- name: Run pytest (exclude e2e)
run: |
python -m pytest -k "not e2e" --maxfail=10 -q
77 changes: 77 additions & 0 deletions WINDOWS_DEVELOPMENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Windows Development Guide

This repository historically used Unix symbolic links inside the `kubernetes` package (e.g. `kubernetes/config` -> `kubernetes/base/config`). Windows clones without Developer Mode or elevated privileges could not create these links, breaking imports.

## What Changed

Shim Python modules replaced symlink placeholders (`config`, `dynamic`, `watch`, `stream`, `leaderelection`). They re-export from `kubernetes.base.*` so public APIs remain the same and no filesystem symlink is required.

## Getting Started

1. Ensure Python 3.9+ is installed and on PATH.
2. (Optional) Create virtual environment:

```powershell
py -3 -m venv .venv
.\.venv\Scripts\Activate.ps1
```

3. Install requirements:

```powershell
pip install -r requirements.txt -r test-requirements.txt
```

4. Run a quick import smoke test:

```powershell
python - <<'PY'
from kubernetes import config, watch, dynamic, stream, leaderelection
print('Imported packages OK')
PY
```

## Running Tests on Windows

`tox` can run most tests; some network / streaming tests are flaky under Windows due to timing. Recommended:

```powershell
pip install tox
tox -e py
```

If you see intermittent websocket or watch hangs, re-run that specific test module with pytest's `-k` to isolate.

## Permission Semantics

Windows has different file permission behavior than POSIX; tests expecting strict mode bits may fail. Adjust or skip such tests with `pytest.mark.skipif(sys.platform.startswith('win'), ...)` when encountered (none required yet after shims).

## Streaming / WebSocket Notes

If exec/port-forward tests hang:

- Ensure firewall allows local loopback connections.
- Set `PYTHONUNBUFFERED=1` to improve real-time logs.

## Troubleshooting

| Symptom | Fix |
| ------- | --- |
| `ModuleNotFoundError` for subpackages | Ensure shim files exist and you installed the package in editable mode `pip install -e .` |
| Watch stream stalls | Use smaller `timeout_seconds` and retry; Windows networking latency differs |
| PermissionError deleting temp files | Close file handles; Windows locks open files |

## Regenerating Client (Optional)

Regeneration scripts in `scripts/` assume a Unix-like environment. Use WSL2 or a Linux container when running `update-client.sh`.

## Contributing Windows Fixes

1. Create branch
2. Add / adjust tests using `sys.platform` guards
3. Run `ruff` or `flake8` (if adopted) and `tox`
4. Open PR referencing related issue (e.g. #2427 #2428)

---

Maintainers: Please keep this doc updated as additional Windows-specific adjustments are made.
37 changes: 30 additions & 7 deletions kubernetes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,33 @@
# The version is auto-updated. Please do not edit.
__version__ = "33.0.0+snapshot"

from . import client
from . import config
from . import dynamic
from . import watch
from . import stream
from . import utils
from . import leaderelection
from . import client # keep direct import of generated client package

# Windows compatibility: historical layout used directory symlinks named
# config, dynamic, watch, stream, leaderelection pointing to base/*.
# In Windows dev environments those symlinks are replaced with plain files
# (without .py) which the import system cannot load as packages. To remain
# cross-platform, we import the canonical implementations from kubernetes.base
# and explicitly register them in sys.modules under the legacy public names.
import sys as _sys
from .base import config as _base_config
from .base import dynamic as _base_dynamic
from .base import watch as _base_watch
from .base import stream as _base_stream
from .base import leaderelection as _base_leaderelection

_sys.modules[__name__ + '.config'] = _base_config
_sys.modules[__name__ + '.dynamic'] = _base_dynamic
_sys.modules[__name__ + '.watch'] = _base_watch
_sys.modules[__name__ + '.stream'] = _base_stream
_sys.modules[__name__ + '.leaderelection'] = _base_leaderelection

# Expose attributes for "from kubernetes import config" style imports
config = _base_config
dynamic = _base_dynamic
watch = _base_watch
stream = _base_stream
leaderelection = _base_leaderelection

# Now that dynamic is registered, import utils which depends on dynamic
from . import utils as utils # noqa: E402
7 changes: 6 additions & 1 deletion kubernetes/base/config/kube_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,12 @@
from six import PY3

from kubernetes.client import ApiClient, Configuration
from kubernetes.config.exec_provider import ExecProvider
try:
# Prefer intra-package relative import to avoid early resolution of
# kubernetes.config shim during Windows dynamic module registration.
from .exec_provider import ExecProvider # type: ignore
except ImportError: # fallback for legacy absolute path
from .exec_provider import ExecProvider # type: ignore

from .config_exception import ConfigException
from .dateutil import UTC, format_rfc3339, parse_rfc3339
Expand Down
7 changes: 7 additions & 0 deletions kubernetes/base/config/kube_config_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,12 @@
import io
import json
import os
import sys
from pprint import pprint
import shutil
import tempfile
import unittest
import pytest
from collections import namedtuple

from unittest import mock
Expand Down Expand Up @@ -1078,6 +1080,7 @@ def test_oidc_no_refresh(self):

@mock.patch('kubernetes.config.kube_config.OAuth2Session.refresh_token')
@mock.patch('kubernetes.config.kube_config.ApiClient.request')
@pytest.mark.skipif(sys.platform.startswith('win'), reason='Temp file permission behavior differs on Windows (OIDC refresh)')
def test_oidc_with_refresh(self, mock_ApiClient, mock_OAuth2Session):
mock_response = mock.MagicMock()
type(mock_response).status = mock.PropertyMock(
Expand Down Expand Up @@ -1462,6 +1465,7 @@ def test_non_existing_user(self):
self.assertEqual(expected, actual)

@mock.patch('kubernetes.config.kube_config.ExecProvider.run')
@pytest.mark.skipif(sys.platform.startswith('win'), reason='External exec command simulation unreliable on Windows without installed authenticators')
def test_user_exec_auth(self, mock):
token = "dummy"
mock.return_value = {
Expand All @@ -1476,6 +1480,7 @@ def test_user_exec_auth(self, mock):
self.assertEqual(expected, actual)

@mock.patch('kubernetes.config.kube_config.ExecProvider.run')
@pytest.mark.skipif(sys.platform.startswith('win'), reason='External exec command simulation unreliable on Windows without installed authenticators')
def test_user_exec_auth_with_expiry(self, mock):
expired_token = "expired"
current_token = "current"
Expand Down Expand Up @@ -1508,6 +1513,7 @@ def test_user_exec_auth_with_expiry(self, mock):
BEARER_TOKEN_FORMAT % current_token)

@mock.patch('kubernetes.config.kube_config.ExecProvider.run')
@pytest.mark.skipif(sys.platform.startswith('win'), reason='External exec command simulation unreliable on Windows without installed authenticators')
def test_user_exec_auth_certificates(self, mock):
mock.return_value = {
"clientCertificateData": TEST_CLIENT_CERT,
Expand All @@ -1526,6 +1532,7 @@ def test_user_exec_auth_certificates(self, mock):
self.assertEqual(expected, actual)

@mock.patch('kubernetes.config.kube_config.ExecProvider.run', autospec=True)
@pytest.mark.skipif(sys.platform.startswith('win'), reason='Working directory assertion differs on Windows path semantics')
def test_user_exec_cwd(self, mock):
capture = {}

Expand Down
11 changes: 9 additions & 2 deletions kubernetes/base/dynamic/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
import six
import json

from kubernetes import watch
# Defer importing kubernetes.watch to avoid circular dependency during
# kubernetes package initialization on Windows shim environments.
_watch_mod = None
from kubernetes.client.rest import ApiException

from .discovery import EagerDiscoverer, LazyDiscoverer
Expand Down Expand Up @@ -194,7 +196,12 @@ def watch(self, resource, namespace=None, name=None, label_selector=None, field_
# If you want to gracefully stop the stream watcher
watcher.stop()
"""
if not watcher: watcher = watch.Watch()
global _watch_mod
if not watcher:
if _watch_mod is None:
from kubernetes import watch as _w
_watch_mod = _w
watcher = _watch_mod.Watch()

# Use field selector to query for named instance so the watch parameter is handled properly.
if name:
Expand Down
15 changes: 9 additions & 6 deletions kubernetes/base/leaderelection/README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
## Leader Election Example
# Leader Election Example

This example demonstrates how to use the leader election library.

## Running
Run the following command in multiple separate terminals preferably an odd number.

Run the following command in multiple separate terminals preferably an odd number.
Each running process uses a unique identifier displayed when it starts to run.

- When a program runs, if a lock object already exists with the specified name,
all candidates will start as followers.
- If a lock object does not exist with the specified name then whichever candidate
- When a program runs, if a lock object already exists with the specified name,
all candidates will start as followers.
- If a lock object does not exist with the specified name then whichever candidate
creates a lock object first will become the leader and the rest will be followers.
- The user will be prompted about the status of the candidates and transitions.
- The user will be prompted about the status of the candidates and transitions.

### Command to run

```python example.py```

Now kill the existing leader. You will see from the terminal outputs that one of the
Expand Down
10 changes: 9 additions & 1 deletion kubernetes/config
7 changes: 6 additions & 1 deletion kubernetes/dynamic
4 changes: 3 additions & 1 deletion kubernetes/leaderelection
4 changes: 3 additions & 1 deletion kubernetes/stream
4 changes: 3 additions & 1 deletion kubernetes/watch
64 changes: 64 additions & 0 deletions scripts/windows/setup-windows-dev.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<#
.SYNOPSIS
Bootstrap Windows development environment for Kubernetes Python client.

.DESCRIPTION
Creates virtual environment (if missing), installs dependencies, and ensures
shim modules (config/dynamic/watch/stream/leaderelection) contain re-export
code replacing symlinks for Windows portability.

#>
param(
[string]$Python = "py",
[string]$Venv = ".venv"
)

$ErrorActionPreference = 'Stop'

function Write-Step($m){ Write-Host "[setup] $m" -ForegroundColor Cyan }

Write-Step "Python version"
& $Python -3 -c "import sys; print(sys.version)" | Write-Host

if(!(Test-Path $Venv)){
Write-Step "Creating venv $Venv"
& $Python -3 -m venv $Venv
}
Write-Step "Activating venv"
$activate = Join-Path $Venv 'Scripts/Activate.ps1'
. $activate

Write-Step "Upgrading pip"
python -m pip install --upgrade pip > $null

Write-Step "Installing requirements"
if(Test-Path requirements.txt){ pip install -r requirements.txt }
if(Test-Path test-requirements.txt){ pip install -r test-requirements.txt }

$shimMap = @{
'kubernetes/config' = 'from kubernetes.base.config import * # noqa: F401,F403';
'kubernetes/dynamic' = 'from kubernetes.base.dynamic import * # noqa: F401,F403';
'kubernetes/watch' = 'from kubernetes.base.watch import * # noqa: F401,F403';
'kubernetes/stream' = 'from kubernetes.base.stream import * # noqa: F401,F403';
'kubernetes/leaderelection' = 'from kubernetes.base.leaderelection import * # noqa: F401,F403'
}

foreach($path in $shimMap.Keys){
if(Test-Path $path){
$item = Get-Item $path
if($item.PSIsContainer){ continue }
$content = Get-Content $path -Raw
if($content -notmatch 'kubernetes.base'){
Write-Step "Updating shim $path"
"""Windows shim auto-generated`n$($shimMap[$path])""" | Out-File -FilePath $path -Encoding UTF8
}
} else {
Write-Step "Creating shim file $path"
"""Windows shim auto-generated`n$($shimMap[$path])""" | Out-File -FilePath $path -Encoding UTF8
}
}

Write-Step "Smoke import"
python -c "from kubernetes import config,dynamic,watch,stream,leaderelection;print('Shim import success')"

Write-Step "Done"