Skip to content

Commit

Permalink
Add windows compatibility
Browse files Browse the repository at this point in the history
  • Loading branch information
dormant-user committed Jan 4, 2025
1 parent 9038915 commit f90090c
Show file tree
Hide file tree
Showing 9 changed files with 406 additions and 310 deletions.
54 changes: 50 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
# PyUdisk

PyUdisk is a python module to generate a S.M.A.R.T metrics for all drives/partitions on macOS and Linux machines.
PyUdisk is a python module to generate S.M.A.R.T metrics for all drives/partitions on a host machine.

![Python][label-pyversion]

![Platform][label-platform]

[![pypi][label-actions-pypi]][gha_pypi]

[![Pypi][label-pypi]][pypi]
[![Pypi-format][label-pypi-format]][pypi-files]
[![Pypi-status][label-pypi-status]][pypi]

### Installation

Expand All @@ -25,7 +35,6 @@ pip install PyUdisk
```python
import pyudisk


if __name__ == '__main__':
pyudisk.monitor()
```
Expand All @@ -45,8 +54,8 @@ pyudisk start
> _By default, `PyUdisk` will look for a `.env` file in the current working directory._
</details>
- **SMART_LIB**: Path to the S.M.A.R.T CLI library. Uses `udisksctl` for Linux and `smartctl` for macOS.
- **DISK_LIB**: Path to disk util library. Uses `lsblk` for Linux and `diskutil` for macOS.
- **SMART_LIB**: Path to the S.M.A.R.T CLI library. Uses `udisksctl` for Linux and `smartctl` for macOS/Windows.
- **DISK_LIB**: Path to disk util library. Uses `lsblk` for Linux, `diskutil` for macOS, and `pwsh` for Windows.
- **METRICS**: List of metrics to monitor. Default: `[]`
- **GMAIL_USER**: Gmail username to authenticate SMTP library.
- **GMAIL_PASS**: Gmail password to authenticate SMTP library.
Expand All @@ -62,3 +71,40 @@ pyudisk start
- **DISK_REPORT**: Boolean flag to send disk report via email.
- **REPORT_DIR**: Directory to save disk reports. Default: `report`
- **REPORT_FILE**: Filename for disk reports. Default format: `disk_report_%m-%d-%Y_%I:%M_%p.html`

## Linting
`pre-commit` will ensure linting

**Requirement**
```shell
python -m pip install pre-commit
```

**Usage**
```shell
pre-commit run --all-files
```

## Pypi Package
[![pypi-module][label-pypi-package]][pypi-repo]

[https://pypi.org/project/PyUdisk/][pypi]

## License & copyright

&copy; Vignesh Rao

Licensed under the [MIT License][license]

[license]: https://github.com/thevickypedia/PyUdisk/blob/master/LICENSE
[label-pypi-package]: https://img.shields.io/badge/Pypi%20Package-PyUdisk-blue?style=for-the-badge&logo=Python
[label-pyversion]: https://img.shields.io/badge/python-3.10%20%7C%203.11-blue
[label-platform]: https://img.shields.io/badge/Platform-Linux|macOS|Windows-1f425f.svg
[label-actions-pypi]: https://github.com/thevickypedia/PyUdisk/actions/workflows/main.yaml/badge.svg
[label-pypi]: https://img.shields.io/pypi/v/PyUdisk
[label-pypi-format]: https://img.shields.io/pypi/format/PyUdisk
[label-pypi-status]: https://img.shields.io/pypi/status/PyUdisk
[gha_pypi]: https://github.com/thevickypedia/PyUdisk/actions/workflows/main.yaml
[pypi]: https://pypi.org/project/PyUdisk
[pypi-files]: https://pypi.org/project/PyUdisk/#files
[pypi-repo]: https://packaging.python.org/tutorials/packaging-projects/
30 changes: 25 additions & 5 deletions pyudisk/config.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import os
import platform
import shutil
from typing import Any, List, Optional

from pyarchitecture.config import default_disk_lib
from pydantic import BaseModel, DirectoryPath, Field, FilePath, HttpUrl, field_validator
from pydantic_settings import BaseSettings

from .models import linux
from .models import udisk

OPERATING_SYSTEM = platform.system()
SMARTCTL_LIB = shutil.which("smartctl")
UDISKCTL_LIB = shutil.which("udisksctl")

try:
from enum import StrEnum
Expand All @@ -28,6 +31,7 @@ class OperationSystem(StrEnum):

darwin: str = "Darwin"
linux: str = "Linux"
windows: str = "Windows"


class Metric(BaseModel):
Expand Down Expand Up @@ -62,19 +66,35 @@ def parse_match(cls, value: Any) -> float | int | bool | str:
def get_smart_lib() -> FilePath:
"""Returns filepath to the smart library as per the operating system."""
if OPERATING_SYSTEM == OperationSystem.darwin:
if SMARTCTL_LIB:
return SMARTCTL_LIB
smartctl1 = "/usr/local/bin/smartctl"
smartctl2 = "/opt/homebrew/bin/smartctl"
if os.path.isfile(smartctl1):
return FilePath(smartctl1)
return smartctl1
if os.path.isfile(smartctl2):
return FilePath(smartctl2)
return smartctl2
raise ValueError(
"\n\tsmartctl not found!!\n\tPlease install using `brew install smartmontools`\n"
)
if OPERATING_SYSTEM == OperationSystem.windows:
if SMARTCTL_LIB:
return SMARTCTL_LIB
smartctl1 = "C:\\Program Files\\smartmontools\\bin\\smartctl.EXE"
smartctl2 = "C:\\smartmontools\\bin\\smartctl.EXE"
if os.path.isfile(smartctl1):
return smartctl1
if os.path.isfile(smartctl2):
return smartctl2
raise ValueError(
"\n\tsmartctl not found!!\n\tPlease install using `winget install smartmontools.smartmontools`\n"
)
if OPERATING_SYSTEM == OperationSystem.linux:
if UDISKCTL_LIB:
return UDISKCTL_LIB
udiskctl = "/usr/bin/udisksctl"
if os.path.isfile(udiskctl):
return FilePath(udiskctl)
return udiskctl
raise ValueError(
"\n\tudisksctl not found!!\n\tPlease install using `sudo apt install udisks2`\n"
)
Expand Down Expand Up @@ -130,7 +150,7 @@ def validate_metrics(cls, value: Metric | List[Metric]) -> List[Metric]:
for dtype in dtypes.get("anyOf")
if dtype.get("type", "") != "null"
]
for name, dtypes in linux.Attributes.model_json_schema()
for name, dtypes in udisk.Attributes.model_json_schema()
.get("properties")
.items()
}
Expand Down
115 changes: 115 additions & 0 deletions pyudisk/disk_data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
from collections.abc import Generator
from typing import Dict, List

import psutil
import pyarchitecture
from psutil._common import sdiskpart

from .config import EnvConfig
from .logger import LOGGER
from .models import SystemPartitions
from .util import size_converter


def get_partitions() -> Generator[sdiskpart]:
"""Gathers disk information using the 'psutil' library.
Yields:
sdiskpart:
Yields the partition datastructure.
"""
system_partitions = SystemPartitions()
for partition in psutil.disk_partitions():
is_not_system_mount = all(
not partition.mountpoint.startswith(mnt)
for mnt in system_partitions.system_mountpoints
)
is_not_system_fstype = partition.fstype not in system_partitions.system_fstypes
is_not_recovery = "Recovery" not in partition.mountpoint
if is_not_system_mount and is_not_system_fstype and is_not_recovery:
yield partition


def get_io_counters() -> Generator[str]:
"""Gathers disk IO counters using the 'psutil' library, which have non-zero write count.
Yields:
str:
Yields the disk id.
"""
for disk_id, disk_io in psutil.disk_io_counters(perdisk=True).items():
if min(disk_io.write_bytes, disk_io.write_count, disk_io.write_time) != 0:
yield disk_id


def get_disk_io() -> Generator[Dict[str, str | List[str]]]:
"""Gathers disk IO counters using the 'psutil' library to construct a disk data.
Yields:
Dict[str, str | List[str]]:
Yields the disk data as key-value pairs.
"""
if physical_disks := list(get_io_counters()):
# If there is only one physical disk, then set the mountpoint to root (/)
if len(physical_disks) == 1:
yield dict(
size=size_converter(psutil.disk_usage("/").total),
device_id=physical_disks[0],
node=f"/dev/{physical_disks[0]}",
mountpoints=["/"],
)
else:
# If there are multiple physical disks, then set the mountpoint to disk path itself
for physical_disk in physical_disks:
yield dict(
size=size_converter(psutil.disk_usage("/").total),
device_id=physical_disk,
node=f"/dev/{physical_disk}",
mountpoints=[f"/dev/{physical_disk}"],
)


def partitions() -> Generator[Dict[str, str | List[str]]]:
"""Gathers disk partitions using the 'psutil' library.
Yields:
Dict[str, str | List[str]]:
Yields the disk data as key-value pairs.
"""
for partition in get_partitions():
yield dict(
size=size_converter(psutil.disk_usage(partition.mountpoint).total),
device_id=partition.device.lstrip("/dev/"),
node=partition.device,
mountpoints=[partition.device],
)


def get_disk_data(env: EnvConfig, posix: bool) -> List[Dict[str, str | List[str]]]:
"""Get disk information for macOS and Windows machines.
Args:
env: Environment variables configuration.
posix: If the operating system is POSIX compliant.
Returns:
List[Dict[str, str | List[str]]]:
Returns a list of dictionaries with device information as key-value pairs.
"""
if posix:
# 1: Attempt to extract physical disks from PyArchitecture
if pyarch := pyarchitecture.disks.get_all_disks(env.disk_lib):
return pyarch
# 2: Assume disks with non-zero write count as physical disks
# disk_io_counters fn will fetch disks rather than partitions (similar to output from 'diskutil list')
LOGGER.warning("Failed to load physical disk data")
if disk_io := list(get_disk_io()):
return disk_io
# The accuracy of methods 2, and 3 are questionable (for macOS) - it may vary from device to device
LOGGER.warning(
"No physical disks found through IO counters, using partitions instead"
)
# 3. If both the methods fail, then fallback to disk partitions
# For Windows, this is the only option since smartctl doesn't work for physical drive name
# Information is retrieved using the drive letter (C:, D:, etc.)
return list(partitions())
Loading

0 comments on commit f90090c

Please sign in to comment.