From ef3c1494e7ed44843ec573e324c42fc9d6e4e2e7 Mon Sep 17 00:00:00 2001 From: Phil Asmar Date: Wed, 30 Sep 2026 11:04:31 -0400 Subject: [PATCH] feat: version Python projects through pyproject.toml --- .../8b6428ea-f0a2-45cd-af3e-d765bf0d69bb.json | 16 + README.md | 37 +- src/AutoVer/Commands/VersionCommand.cs | 39 +- .../CustomServiceCollectionExtensions.cs | 1 + src/AutoVer/Models/UserConfiguration.cs | 4 +- src/AutoVer/Services/ConfigurationManager.cs | 20 +- src/AutoVer/Services/IProjectHandler.cs | 1 + .../ProjectFiles/IProjectFileHandler.cs | 10 +- .../ProjectFileHandlerResolver.cs | 2 +- .../ProjectFiles/PyprojectFileHandler.cs | 425 ++++++++++++++++++ src/AutoVer/Services/ProjectHandler.cs | 11 +- .../PyprojectVersionCommandTests.cs | 274 +++++++++++ .../ProjectFiles/PyprojectFileHandlerTest.cs | 424 +++++++++++++++++ 13 files changed, 1239 insertions(+), 25 deletions(-) create mode 100644 .autover/changes/8b6428ea-f0a2-45cd-af3e-d765bf0d69bb.json create mode 100644 src/AutoVer/Services/ProjectFiles/PyprojectFileHandler.cs create mode 100644 test/AutoVer.IntegrationTests/PyprojectVersionCommandTests.cs create mode 100644 test/AutoVer.UnitTests/ProjectFiles/PyprojectFileHandlerTest.cs diff --git a/.autover/changes/8b6428ea-f0a2-45cd-af3e-d765bf0d69bb.json b/.autover/changes/8b6428ea-f0a2-45cd-af3e-d765bf0d69bb.json new file mode 100644 index 0000000..8f89b6b --- /dev/null +++ b/.autover/changes/8b6428ea-f0a2-45cd-af3e-d765bf0d69bb.json @@ -0,0 +1,16 @@ +{ + "Projects": [ + { + "Name": "AutoVer", + "Type": "Minor", + "ChangelogMessages": [ + "Version Python projects: a pyproject.toml listed in autover.json is versioned through its [project] version (PEP 621), rewriting only that value so quoting, spacing, comments and line endings are kept as they were. A project without a version is seeded after its name, like the other project types", + "A pyproject.toml is never auto-discovered, because many repositories keep one purely for tool settings; discovering those would start versioning a lint config in repositories that rely on discovery today", + "Refused with an explanation rather than guessed at: a version listed in [project] dynamic (computed by the build backend), a [tool.poetry]-only project, a non-string version, and a version AutoVer cannot increment (a normalized spelling such as 1.2.3b1)", + "A prerelease label must be a Python pre-release (PEP 440): alpha, beta, rc (and their short forms), pre/preview or dev, optionally numbered. Anything else fails the release before any project file is written, rather than producing a package pip refuses to install (hotfix) or a post-release that pip installs ahead of the real version (post.1)", + "A version a pyproject.toml cannot take (not a valid Python version) now fails the release before any project file is written, so a repository mixing .csproj, Dockerfile and pyproject.toml projects is not left with the others bumped and staged", + "A project file that fails to load now reports why: the configuration-loading error includes the underlying cause (e.g. a missing project file, a pyproject.toml that cannot be versioned, or the JSON path of an invalid setting) instead of only naming autover.json" + ] + } + ] +} diff --git a/README.md b/README.md index d2aea2e..fa61c71 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,9 @@ Requires the .NET 10 SDK or later. The installed command is `autover`. * Versions one or many projects in a single git repository, with independent or shared version numbers -* Versions `.csproj`, `.nuspec` and `Dockerfile` projects - the latter through the - `org.opencontainers.image.version` label +* Versions `.csproj`, `.nuspec`, `Dockerfile` and Python `pyproject.toml` projects - + a Dockerfile through the `org.opencontainers.image.version` label, a Python project + through `[project] version` * Applies Patch, Minor or Major increments, chosen per release or per change * Commits the version bump and tags the release * Generates a `CHANGELOG.md` from conventional commit messages or from @@ -155,6 +156,33 @@ Each entry in `Projects` takes: | `IncrementType` | An increment specific to this project. | | `PrereleaseLabel` | A prerelease label to apply, e.g. `beta.1`. | +### Python projects + +A `pyproject.toml` is versioned through the `version` key of its `[project]` table +([PEP 621](https://peps.python.org/pep-0621/)). Only that value is rewritten: quoting, +spacing, comments, line endings and everything else in the file stay as they were (a +UTF-8 byte-order mark is not kept). + +```json +{ "Projects": [ { "Name": "my-package", "Path": "pyproject.toml" } ] } +``` + +* **It must be listed in `Projects`** - a `pyproject.toml` is never auto-discovered. + Plenty of repositories keep one purely for tool settings (black, ruff, pytest), and + discovering those would start versioning a lint config. +* **The version must be a plain, single-line string in `[project]`**, in AutoVer's + `MAJOR.MINOR.PATCH[-label]` form. A project that lists `"version"` in `dynamic` (its + build backend computes the version, e.g. from git tags) is refused, as is Poetry's + pre-2.0 `[tool.poetry]` version and a normalized spelling such as `1.2.3b1` - write + that one as `1.2.3-b1`, which is the same version. +* **Prerelease labels must be Python pre-releases** + ([PEP 440](https://peps.python.org/pep-0440/)): `alpha`/`a`, `beta`/`b`, + `rc`/`c`/`pre`/`preview` or `dev`, optionally numbered. `beta.1` writes `1.2.3-beta.1`, + which pip reads as `1.2.3b1`. Anything else fails the release before any file is + written: `hotfix` would make an uninstallable package, and `post.1` or `1` would be a + *post*-release, which pip ranks above `1.2.3` and installs by default. Note that pip + orders `dev` before `alpha`, where AutoVer's own ordering puts it after. + ## Tag and release name formats By default AutoVer tags a release by date - `release_2026-09-02`, or @@ -258,8 +286,9 @@ release and cover the wrong commit range. If no release tag is reachable at all A project that doesn't carry a version yet is **seeded** rather than rejected. AutoVer creates the version field - a `` element in an unconditioned -`PropertyGroup`, a `` in a nuspec's `metadata`, or an -`org.opencontainers.image.version` LABEL appended to a Dockerfile - and writes +`PropertyGroup`, a `` in a nuspec's `metadata`, an +`org.opencontainers.image.version` LABEL appended to a Dockerfile, or a `version` key +after `name` in a pyproject.toml's `[project]` table - and writes `InitialVersion` into it, taken as-is rather than incremented. Every release after that reads the field back and increments it in the ordinary way, so seeding only ever happens once. diff --git a/src/AutoVer/Commands/VersionCommand.cs b/src/AutoVer/Commands/VersionCommand.cs index 6c42b43..e1ddee3 100644 --- a/src/AutoVer/Commands/VersionCommand.cs +++ b/src/AutoVer/Commands/VersionCommand.cs @@ -64,6 +64,8 @@ public async Task ExecuteAsync( } var projectsIncremented = false; + var plannedUpdates = new List(); + var containersToStage = new List(); foreach (var availableProject in userConfiguration.Projects) { if (!availableProject.IncrementType.Equals(IncrementType.None)) @@ -80,11 +82,11 @@ public async Task ExecuteAsync( incrementType); foreach (var project in availableProject.Projects) { - projectHandler.UpdateVersion( + plannedUpdates.Add(new PlannedUpdate( project.ProjectDefinition, projectIncrementType, availableProject.PrereleaseLabel, - optionUseVersion ?? repositorySeedVersion ?? maxNextVersion?.ToString() ?? localMaxVersion?.ToString()); + optionUseVersion ?? repositorySeedVersion ?? maxNextVersion?.ToString() ?? localMaxVersion?.ToString())); } } else @@ -102,11 +104,11 @@ public async Task ExecuteAsync( incrementType); foreach (var project in availableProject.Projects) { - projectHandler.UpdateVersion( + plannedUpdates.Add(new PlannedUpdate( project.ProjectDefinition, projectIncrementType, availableProject.PrereleaseLabel, - optionUseVersion ?? SeedVersionFor(availableProject, userConfiguration) ?? localMaxVersion?.ToString()); + optionUseVersion ?? SeedVersionFor(availableProject, userConfiguration) ?? localMaxVersion?.ToString())); } } else @@ -120,19 +122,32 @@ public async Task ExecuteAsync( continue; foreach (var project in availableProject.Projects) { - projectHandler.UpdateVersion( + plannedUpdates.Add(new PlannedUpdate( project.ProjectDefinition, projectIncrementType, availableProject.PrereleaseLabel, - optionUseVersion ?? SeedVersionFor(availableProject, userConfiguration) ?? localMaxVersion?.ToString()); + optionUseVersion ?? SeedVersionFor(availableProject, userConfiguration) ?? localMaxVersion?.ToString())); } } } - foreach (var project in availableProject.Projects) - { + containersToStage.Add(availableProject); + } + + // Every project is checked before any is written, so a version a file type refuses up front + // (see IProjectFileHandler.ValidateVersion - today a pyproject.toml given a non-Python + // version) fails the release before any file changes. A failure only discovered while + // writing (e.g. a nuspec with no ) can still leave earlier files bumped, as before. + foreach (var update in plannedUpdates) + projectHandler.ValidateVersion(update.Definition, update.IncrementType, update.PrereleaseLabel, update.OverrideVersion); + + foreach (var update in plannedUpdates) + projectHandler.UpdateVersion(update.Definition, update.IncrementType, update.PrereleaseLabel, update.OverrideVersion); + + foreach (var container in containersToStage) + { + foreach (var project in container.Projects) gitHandler.StageChanges(userConfiguration, project.Path); - } } // When done, reset the config file if the user had one @@ -167,6 +182,12 @@ public async Task ExecuteAsync( } } + private sealed record PlannedUpdate( + ProjectDefinition Definition, + IncrementType IncrementType, + string? PrereleaseLabel, + string? OverrideVersion); + /// /// Whether nothing in the repository carries a version yet, in which case the whole repository is /// seeded from the configured initial version rather than incremented from nothing. diff --git a/src/AutoVer/Extensions/CustomServiceCollectionExtensions.cs b/src/AutoVer/Extensions/CustomServiceCollectionExtensions.cs index 112fe9c..f3e3c73 100644 --- a/src/AutoVer/Extensions/CustomServiceCollectionExtensions.cs +++ b/src/AutoVer/Extensions/CustomServiceCollectionExtensions.cs @@ -31,6 +31,7 @@ public static void AddCustomServices(this IServiceCollection serviceCollection, serviceCollection.TryAdd(new ServiceDescriptor(typeof(IVersionHandler), typeof(VersionHandler), lifetime)); serviceCollection.TryAddEnumerable(new ServiceDescriptor(typeof(IProjectFileHandler), typeof(CsprojNuspecFileHandler), lifetime)); serviceCollection.TryAddEnumerable(new ServiceDescriptor(typeof(IProjectFileHandler), typeof(DockerfileFileHandler), lifetime)); + serviceCollection.TryAddEnumerable(new ServiceDescriptor(typeof(IProjectFileHandler), typeof(PyprojectFileHandler), lifetime)); serviceCollection.TryAdd(new ServiceDescriptor(typeof(IProjectFileHandlerResolver), typeof(ProjectFileHandlerResolver), lifetime)); serviceCollection.AddSingleton(); diff --git a/src/AutoVer/Models/UserConfiguration.cs b/src/AutoVer/Models/UserConfiguration.cs index 5e2d53b..8bfbf61 100644 --- a/src/AutoVer/Models/UserConfiguration.cs +++ b/src/AutoVer/Models/UserConfiguration.cs @@ -154,7 +154,7 @@ public void OnDeserialized() { var normalizedPath = path.Replace('\\', _pathManager.DirectorySeparatorChar).Replace('/', _pathManager.DirectorySeparatorChar); if (!_fileManager.Exists(normalizedPath)) - throw new Exception($"Failed to find a valid .csproj, .nuspec, or Dockerfile file at path {normalizedPath}"); + throw new Exception($"Failed to find a valid .csproj, .nuspec, Dockerfile, or pyproject.toml file at path {normalizedPath}"); var handler = _projectFileHandlerResolver.Resolve(normalizedPath); var projectDefinition = handler.Load(_pathManager.GetFullPath(normalizedPath), _fileManager.ReadAllText(normalizedPath)); @@ -188,7 +188,7 @@ public void OnDeserialized() var normalizedPath = Path.Replace('\\', _pathManager.DirectorySeparatorChar).Replace('/', _pathManager.DirectorySeparatorChar); if (!_fileManager.Exists(normalizedPath)) - throw new Exception($"Failed to find a valid .csproj, .nuspec, or Dockerfile file at path {normalizedPath}"); + throw new Exception($"Failed to find a valid .csproj, .nuspec, Dockerfile, or pyproject.toml file at path {normalizedPath}"); var handler = _projectFileHandlerResolver.Resolve(normalizedPath); ProjectDefinition = handler.Load(_pathManager.GetFullPath(normalizedPath), _fileManager.ReadAllText(normalizedPath)); diff --git a/src/AutoVer/Services/ConfigurationManager.cs b/src/AutoVer/Services/ConfigurationManager.cs index 110afd9..5adaec8 100644 --- a/src/AutoVer/Services/ConfigurationManager.cs +++ b/src/AutoVer/Services/ConfigurationManager.cs @@ -56,12 +56,22 @@ public class ConfigurationManager( } catch (Exception ex) { - throw new InvalidUserConfigurationException( - $"There was an issue loading the user configuration at '{configPath}'.", - ex); + throw new InvalidUserConfigurationException(LoadFailureMessage(configPath, ex), ex); } } + // Saying only *where* loading failed hides the part the user can act on - e.g. a project file + // AutoVer cannot version, or a JSON syntax error - so the cause is included in the message. + private static string LoadFailureMessage(string configPath, Exception ex) + { + var cause = ex; + // Stop at a JsonException too: it carries the JSON path and line the user needs, while its + // inner exception only says which token type was wrong. + while (cause is not (AutoVerException or JsonException) && cause.InnerException is not null) + cause = cause.InnerException; + return $"There was an issue loading the user configuration at '{configPath}': {cause.Message}"; + } + public async Task RetrieveUserConfiguration(string? projectPath, IncrementType incrementType, string? tagName = null) { if (string.IsNullOrEmpty(projectPath)) @@ -149,9 +159,7 @@ public async Task RetrieveUserConfiguration(string? projectPa } catch (Exception ex) { - throw new InvalidUserConfigurationException( - $"There was an issue loading the user configuration at '{configPath}'.", - ex); + throw new InvalidUserConfigurationException(LoadFailureMessage(configPath, ex), ex); } } diff --git a/src/AutoVer/Services/IProjectHandler.cs b/src/AutoVer/Services/IProjectHandler.cs index da974cc..dac59b5 100644 --- a/src/AutoVer/Services/IProjectHandler.cs +++ b/src/AutoVer/Services/IProjectHandler.cs @@ -7,4 +7,5 @@ public interface IProjectHandler Task> GetAvailableProjects(string? projectPath); Task GetProjectDefinition(string projectPath); void UpdateVersion(ProjectDefinition projectDefinition, IncrementType incrementType, string? prereleaseLabel = null, string? overrideVersion = null); + void ValidateVersion(ProjectDefinition projectDefinition, IncrementType incrementType, string? prereleaseLabel = null, string? overrideVersion = null); } \ No newline at end of file diff --git a/src/AutoVer/Services/ProjectFiles/IProjectFileHandler.cs b/src/AutoVer/Services/ProjectFiles/IProjectFileHandler.cs index c6f5bc5..6efe9bf 100644 --- a/src/AutoVer/Services/ProjectFiles/IProjectFileHandler.cs +++ b/src/AutoVer/Services/ProjectFiles/IProjectFileHandler.cs @@ -3,7 +3,7 @@ namespace AutoVer.Services.ProjectFiles; /// -/// Handles reading and writing the version of one project file type (e.g. .csproj, .nuspec, Dockerfile). +/// Handles reading and writing the version of one project file type (e.g. .csproj, .nuspec, Dockerfile, pyproject.toml). /// public interface IProjectFileHandler { @@ -26,4 +26,12 @@ public interface IProjectFileHandler ProjectDefinition Load(string projectPath, string rawContent); void UpdateVersion(ProjectDefinition projectDefinition, IncrementType incrementType, string? prereleaseLabel = null, string? overrideVersion = null); + + /// + /// Throws if would refuse these arguments, without writing anything. + /// Called for every project before any file is written, so a release that one project file + /// cannot take fails before the others have been bumped and staged. Most file types accept any + /// version AutoVer produces, hence the no-op default. + /// + void ValidateVersion(ProjectDefinition projectDefinition, IncrementType incrementType, string? prereleaseLabel = null, string? overrideVersion = null) { } } diff --git a/src/AutoVer/Services/ProjectFiles/ProjectFileHandlerResolver.cs b/src/AutoVer/Services/ProjectFiles/ProjectFileHandlerResolver.cs index 4d8dfd3..aeeb582 100644 --- a/src/AutoVer/Services/ProjectFiles/ProjectFileHandlerResolver.cs +++ b/src/AutoVer/Services/ProjectFiles/ProjectFileHandlerResolver.cs @@ -10,7 +10,7 @@ public class ProjectFileHandlerResolver( public IProjectFileHandler Resolve(string projectPath) { if (!TryResolve(projectPath, out var handler)) - throw new InvalidProjectException($"Invalid project path {projectPath}. The project path must point to a .csproj, .nuspec, or Dockerfile file."); + throw new InvalidProjectException($"Invalid project path {projectPath}. The project path must point to a .csproj, .nuspec, Dockerfile, or pyproject.toml file."); return handler!; } diff --git a/src/AutoVer/Services/ProjectFiles/PyprojectFileHandler.cs b/src/AutoVer/Services/ProjectFiles/PyprojectFileHandler.cs new file mode 100644 index 0000000..92cba33 --- /dev/null +++ b/src/AutoVer/Services/ProjectFiles/PyprojectFileHandler.cs @@ -0,0 +1,425 @@ +using System.Text.RegularExpressions; +using AutoVer.Exceptions; +using AutoVer.Models; +using AutoVer.Services.IO; + +namespace AutoVer.Services.ProjectFiles; + +/// +/// Reads/writes the version of a Python project: the version key of the +/// [project] table in a pyproject.toml (PEP 621). +/// +/// +/// +/// Explicit-only: is empty, so a pyproject.toml is never +/// auto-discovered and is versioned only when autover.json names it. Many repositories keep a +/// pyproject.toml purely as tool configuration (black, ruff, pytest) with no [project] +/// table; discovering those would start rewriting a lint config in any repository that relies +/// on discovery today. +/// +/// +/// The file is edited line by line, not parsed and re-serialized, so everything except the +/// version value - quote style, spacing, comments, key order, line endings - is left as it was. +/// Values are tokenized well enough (strings with escapes, multi-line strings, nested +/// arrays and inline tables, comments) that a line inside a value is never mistaken for a key +/// or a table header. A version written any way other than a single-line string is refused +/// rather than guessed at. +/// +/// +public class PyprojectFileHandler( + IVersionIncrementer versionIncrementer, + IFileManager fileManager) : IProjectFileHandler +{ + public const string FileName = "pyproject.toml"; + + private const string ProjectTable = "project"; + + private const string BareKey = @"[A-Za-z0-9_-]+|""[^""]*""|'[^']*'"; + + // A key, including dotted keys (a.b = ...), so their values are tracked like any other. + private static readonly Regex KeyRegex = new( + $@"^\s*(?(?:{BareKey})(?:\s*\.\s*(?:{BareKey}))*)\s*=", + RegexOptions.Compiled); + + // `version = "1.2.3"`, `'version'='1.2.3' # comment`. Only a single-line basic or literal + // string followed by nothing but whitespace or a comment is a version AutoVer can rewrite. + private static readonly Regex VersionValueRegex = new( + @"^(?[^=]*=\s*)(?""(?!"""")|'(?!''))(?[^""'\\\r\n]*)(?\k)(?\s*(?:#.*)?)$", + RegexOptions.Compiled); + + // The Python (PEP 440) versions AutoVer can produce: MAJOR.MINOR.PATCH with an optional + // pre-release and/or dev segment, in the spellings `packaging` (what pip uses) accepts, + // e.g. 1.2.3-beta.1 (read as 1.2.3b1) or 1.2.3-rc.1.dev2. Deliberately NOT accepted: + // post-release forms (1.2.3-post.1, 1.2.3-1), which pip ranks ABOVE 1.2.3 and installs by + // default - the opposite of what a prerelease label means - and anything pip rejects. + private static readonly Regex PythonVersionRegex = new( + @"\A[0-9]+\.[0-9]+\.[0-9]+(?:[-_.]?(?:alpha|a|beta|b|preview|pre|c|rc)[-_.]?[0-9]*)?(?:[-_.]?dev[-_.]?[0-9]*)?\z", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + public IEnumerable SearchPatterns => []; + + public bool IsMatch(string projectPath) => + Path.GetFileName(projectPath).Equals(FileName, StringComparison.OrdinalIgnoreCase); + + public string GetDisplayName(string projectPath) => Path.GetFileName(projectPath); + + public ProjectDefinition Load(string projectPath, string rawContent) + { + var lines = rawContent.Split('\n').ToList(); + var layout = Scan(lines, projectPath); + + var version = layout.VersionLine is { } index ? ReadVersion(lines[index], projectPath) : null; + + // Checked here, not when writing: every caller parses a loaded version before the handler + // is asked to write one, and would otherwise fail with an unexplained parse error. + if (!string.IsNullOrEmpty(version) && !ThreePartVersion.TryParse(version, out _)) + throw new InvalidProjectException( + $"'{projectPath}' carries version '{version}', which AutoVer cannot increment: it versions " + + "MAJOR.MINOR.PATCH, optionally with a '-label' (e.g. 1.2.3 or 1.2.3-beta.1)." + SuggestSpelling(version)); + + return new ProjectDefinition(lines, projectPath) { Version = version }; + } + + // A normalized Python spelling (1.2.3b1, 1.2.3.dev4) is the same version with a '-' before its + // label, which is a form AutoVer can increment - suggest exactly that when it applies. + private static string SuggestSpelling(string version) + { + var match = Regex.Match(version, @"\A([0-9]+\.[0-9]+\.[0-9]+)[._]?([A-Za-z].*)\z"); + if (!match.Success) + return ""; + var suggestion = $"{match.Groups[1].Value}-{match.Groups[2].Value}"; + return ThreePartVersion.TryParse(suggestion, out _) && PythonVersionRegex.IsMatch(suggestion) + ? $" '{version}' is the same Python version as '{suggestion}' - write it that way." + : ""; + } + + public void ValidateVersion(ProjectDefinition projectDefinition, IncrementType incrementType, string? prereleaseLabel = null, string? overrideVersion = null) => + EnsurePythonVersion(ResolveNewVersion(projectDefinition, incrementType, prereleaseLabel, overrideVersion), projectDefinition.ProjectPath); + + public void UpdateVersion(ProjectDefinition projectDefinition, IncrementType incrementType, string? prereleaseLabel = null, string? overrideVersion = null) + { + var lines = projectDefinition.GetContents>(); + var layout = Scan(lines, projectDefinition.ProjectPath); + + var newVersion = ResolveNewVersion(projectDefinition, incrementType, prereleaseLabel, overrideVersion); + EnsurePythonVersion(newVersion, projectDefinition.ProjectPath); + + if (layout.VersionLine is { } versionLine) + { + var match = VersionValueRegex.Match(lines[versionLine]); + lines[versionLine] = + $"{match.Groups["prefix"].Value}{match.Groups["open"].Value}{newVersion}{match.Groups["close"].Value}{match.Groups["suffix"].Value}"; + } + else + { + InsertVersion(lines, layout, newVersion); + } + + // Keep the in-memory definition in step with what was just written - a version-based tag + // format is rendered from this after every project has been updated. + projectDefinition.Version = newVersion; + + fileManager.WriteAllText(projectDefinition.ProjectPath, string.Join('\n', lines)); + } + + private string ResolveNewVersion(ProjectDefinition projectDefinition, IncrementType incrementType, string? prereleaseLabel, string? overrideVersion) + { + if (!string.IsNullOrEmpty(overrideVersion)) + { + if (!ThreePartVersion.TryParse(overrideVersion, out var version)) + throw new InvalidArgumentException($"The version '{overrideVersion}' you are trying to update to is invalid."); + return version.ToString(); + } + + // Seeded, not incremented - see DockerfileFileHandler. The caller normally supplies the + // version to seed with; this is only the fallback for a direct call that didn't. + if (string.IsNullOrEmpty(projectDefinition.Version)) + return versionIncrementer.GetCurrentVersion(null).ToString(); + + return versionIncrementer.GetNextVersion(projectDefinition.Version, incrementType, prereleaseLabel).ToString(); + } + + private static void EnsurePythonVersion(string version, string projectPath) + { + if (!PythonVersionRegex.IsMatch(version)) + throw new InvalidProjectException( + $"The version '{version}' cannot be written to '{projectPath}': it is not a Python pre-release or " + + "release version (PEP 440), so pip would refuse it or misorder it. A prerelease label must be one of " + + "alpha/a, beta/b, rc/c/pre/preview or dev, optionally followed by a number - e.g. 'beta.1' or 'rc.2'. " + + "Change the prerelease label: the project's PrereleaseLabel in autover.json, or the version given to --use-version."); + } + + private sealed record Layout(int HeaderLine, int? VersionLine, int? NameLine, int LastValueLine); + + /// + /// Finds the [project] table and, within it, the version and name keys - and rejects + /// the layouts AutoVer cannot version: no [project] table, a dynamic version, + /// or a version that is not a plain single-line string. + /// + private static Layout Scan(List lines, string projectPath) + { + int? headerLine = null, versionLine = null, nameLine = null; + var lastValueLine = -1; + var dynamicVersion = false; + var hasPoetryTable = false; + + string? table = null; + var value = new ValueState(); + var collectingDynamic = false; + var nameValueOpen = false; + var valueOpenedAt = -1; + + for (var i = 0; i < lines.Count; i++) + { + var line = lines[i].TrimEnd('\r'); + + // A continuation line of a multi-line value belongs to the key that opened it: it moves + // the end of [project]'s content (so a seeded version never lands inside the value) but + // is never a key or a header itself. + if (value.Open) + { + if (table == ProjectTable) + lastValueLine = i; + // A seeded version goes after the END of name's value, never inside it. + if (nameValueOpen) + nameLine = i; + if (collectingDynamic && MentionsVersion(line, value)) + dynamicVersion = true; + value.Advance(line, 0); + if (!value.Open) + { + collectingDynamic = false; + nameValueOpen = false; + } + continue; + } + + var trimmed = line.Trim(); + if (trimmed.Length == 0 || trimmed.StartsWith('#')) + continue; + + if (trimmed.StartsWith('[')) + { + // [[array.of.tables]] is never the [project] table. + table = trimmed.StartsWith("[[") ? null : NormalizeTableName(trimmed); + if (table == ProjectTable) + { + if (headerLine is not null) + throw new InvalidProjectException($"'{projectPath}' defines the [project] table twice, which is not valid TOML."); + headerLine = i; + lastValueLine = i; + } + if (table == "tool.poetry") + hasPoetryTable = true; + continue; + } + + var key = KeyRegex.Match(line); + if (!key.Success) + { + // Not a key or a header: still tokenized, so a stray """ or [ is tracked rather + // than letting the next line be read out of context. + value.Advance(line, 0); + if (value.Open) + valueOpenedAt = i; + continue; + } + + var valueStart = line.IndexOf('=', key.Groups["key"].Index + key.Groups["key"].Length) + 1; + var isDynamic = table == ProjectTable && KeyName(key) == "dynamic"; + if (isDynamic && MentionsVersion(line[valueStart..], new ValueState())) + dynamicVersion = true; + value.Advance(line, valueStart); + collectingDynamic = isDynamic && value.Open; + if (value.Open) + valueOpenedAt = i; + + if (table != ProjectTable) + continue; + + lastValueLine = i; + switch (KeyName(key)) + { + case "version": + if (versionLine is not null) + throw new InvalidProjectException($"'{projectPath}' sets [project] version twice, which is not valid TOML."); + versionLine = i; + break; + case "name": + nameLine = i; + nameValueOpen = value.Open; + break; + } + } + + // An unclosed array, inline table or string swallows everything after it; say so, rather + // than report whatever the swallowed part happened to contain (or lack). + if (value.Open) + throw new InvalidProjectException( + $"'{projectPath}' is not valid TOML: the value starting on line {valueOpenedAt + 1} is never closed."); + + if (headerLine is null) + throw new InvalidProjectException( + $"'{projectPath}' has no [project] table, so there is no version for AutoVer to manage. " + + (hasPoetryTable + ? "Poetry's [tool.poetry] version is not supported: move the metadata to the standard [project] table, which Poetry 2 reads." + : "Add a [project] table with name and version (PEP 621).")); + + if (dynamicVersion) + throw new InvalidProjectException( + $"'{projectPath}' lists \"version\" in [project] dynamic, so the build backend computes the version " + + "(from git tags, a file, ...) and there is nothing for AutoVer to write. Remove \"version\" from dynamic " + + "and add version = \"x.y.z\" to [project], or let AutoVer take the version from tags instead (VersionFromTag)."); + + return new Layout(headerLine.Value, versionLine, nameLine, lastValueLine); + } + + // The key's name when it is a single segment; a dotted key (version.x) is never "version". + private static string? KeyName(Match key) + { + var text = key.Groups["key"].Value.Trim(); + char? quote = null; + foreach (var c in text) + { + if (quote is null && c == '.') return null; + if (c is '"' or '\'') quote = quote == c ? null : quote ?? c; + } + return text.Trim('"', '\''); + } + + private static string ReadVersion(string line, string projectPath) + { + var match = VersionValueRegex.Match(line.TrimEnd('\r')); + if (!match.Success) + { + var rest = line[(line.IndexOf('=') + 1)..].Trim(); + throw new InvalidProjectException( + $"'{projectPath}' sets [project] version to {rest}, which is not a plain single-line string. " + + "AutoVer can only manage a version written as version = \"x.y.z\"."); + } + return match.Groups["value"].Value; + } + + /// + /// Adds version = "..." to a [project] table that has none: right after name, + /// where PEP 621 examples and most tools put it, else after the end of the table's last value. + /// + private static void InsertVersion(List lines, Layout layout, string version) + { + var line = $"version = \"{version}\""; + // Lines keep their own trailing '\r'; match the file rather than mixing endings. + var crlf = lines.Any(existing => existing.EndsWith('\r')); + var after = layout.NameLine ?? layout.LastValueLine; + + if (after + 1 < lines.Count) + { + lines.Insert(after + 1, crlf ? line + '\r' : line); + return; + } + + // Inserting after the last line of a file with no trailing newline: the previous line gains + // the terminator it lacked and the new last line has none, as the file had. + if (crlf) + lines[after] += '\r'; + lines.Add(line); + } + + // Whether this stretch of a `dynamic` array names "version" as an element (not in a comment). + private static bool MentionsVersion(string text, ValueState state) + { + var code = state.CodeOnly(text); + return Regex.IsMatch(code, @"(?:""version""|'version')"); + } + + // `[ "project" ] # comment` -> project. Whitespace around the name and quoted segments are + // both valid TOML for the same table. + private static string NormalizeTableName(string header) + { + var end = header.IndexOf(']'); + var inner = end > 0 ? header[1..end] : header[1..]; + var segments = inner.Split('.').Select(segment => segment.Trim().Trim('"', '\'')); + return string.Join('.', segments); + } + + /// + /// Tracks, across lines, whether a TOML value is still open: an unclosed multi-line string, or + /// unbalanced [ ] / { } - counted outside strings and comments only, so the + /// ] in "requests[socks]>=2" does not close an array. + /// + private sealed class ValueState + { + private int _depth; + private string? _multiline; + + public bool Open => _depth > 0 || _multiline is not null; + + public void Advance(string line, int start) => Walk(line, start, collect: null); + + // The part of the line outside strings is irrelevant to MentionsVersion; the part inside + // them is what it looks at. Returns the text with comments removed. + public string CodeOnly(string line) + { + var copy = new ValueState { _depth = _depth, _multiline = _multiline }; + var kept = new System.Text.StringBuilder(); + copy.Walk(line, 0, kept); + return kept.ToString(); + } + + private void Walk(string line, int start, System.Text.StringBuilder? collect) + { + var i = start; + while (i < line.Length) + { + if (_multiline is not null) + { + // Basic multi-line strings honour escapes: \""" is an escaped quote followed by + // two quotes, not the end. A closing run may be 3-5 quotes; the last 3 close it. + var quote = _multiline[0]; + if (quote == '"' && line[i] == '\\') + { + i += 2; + continue; + } + if (line[i] == quote && string.CompareOrdinal(line, i, _multiline, 0, 3) == 0) + { + var run = 0; + while (i + run < line.Length && line[i + run] == quote && run < 5) + run++; + i += run; + _multiline = null; + continue; + } + i++; + continue; + } + + var c = line[i]; + if (c == '#') + return; + + if (c is '"' or '\'') + { + var triple = new string(c, 3); + if (string.CompareOrdinal(line, i, triple, 0, 3) == 0) + { + _multiline = triple; + i += 3; + continue; + } + + var end = i + 1; + while (end < line.Length && line[end] != c) + end += c == '"' && line[end] == '\\' ? 2 : 1; + collect?.Append(line, i, Math.Min(end + 1, line.Length) - i); + i = end + 1; + continue; + } + + if (c is '[' or '{') _depth++; + else if (c is ']' or '}') _depth = Math.Max(0, _depth - 1); + collect?.Append(c); + i++; + } + } + } +} diff --git a/src/AutoVer/Services/ProjectHandler.cs b/src/AutoVer/Services/ProjectHandler.cs index f6d7828..d3a3032 100644 --- a/src/AutoVer/Services/ProjectHandler.cs +++ b/src/AutoVer/Services/ProjectHandler.cs @@ -45,7 +45,8 @@ public async Task> GetAvailableProjects(string? projectP if (projectPaths.Count == 0) { - throw new InvalidProjectException($"Failed to find a valid .csproj, .nuspec, or Dockerfile file at path {projectPath}"); + // pyproject.toml is deliberately not discovered (see PyprojectFileHandler), so say how to use one. + throw new InvalidProjectException($"Failed to find a valid .csproj, .nuspec, or Dockerfile file at path {projectPath}. A pyproject.toml is only versioned when listed in .autover/autover.json."); } var projectDefinitions = new List(); @@ -64,7 +65,7 @@ public async Task GetProjectDefinition(string projectPath) { var normalizedPath = projectPath.Replace('\\', pathManager.DirectorySeparatorChar).Replace('/', pathManager.DirectorySeparatorChar); if (!fileManager.Exists(normalizedPath)) - throw new InvalidProjectException($"Failed to find a valid .csproj, .nuspec, or Dockerfile file at path {normalizedPath}"); + throw new InvalidProjectException($"Failed to find a valid .csproj, .nuspec, Dockerfile, or pyproject.toml file at path {normalizedPath}"); var handler = projectFileHandlerResolver.Resolve(normalizedPath); var fullPath = pathManager.GetFullPath(normalizedPath); @@ -77,4 +78,10 @@ public void UpdateVersion(ProjectDefinition projectDefinition, IncrementType inc var handler = projectFileHandlerResolver.Resolve(projectDefinition.ProjectPath); handler.UpdateVersion(projectDefinition, incrementType, prereleaseLabel, overrideVersion); } + + public void ValidateVersion(ProjectDefinition projectDefinition, IncrementType incrementType, string? prereleaseLabel = null, string? overrideVersion = null) + { + var handler = projectFileHandlerResolver.Resolve(projectDefinition.ProjectPath); + handler.ValidateVersion(projectDefinition, incrementType, prereleaseLabel, overrideVersion); + } } diff --git a/test/AutoVer.IntegrationTests/PyprojectVersionCommandTests.cs b/test/AutoVer.IntegrationTests/PyprojectVersionCommandTests.cs new file mode 100644 index 0000000..5544665 --- /dev/null +++ b/test/AutoVer.IntegrationTests/PyprojectVersionCommandTests.cs @@ -0,0 +1,274 @@ +using AutoVer.Constants; +using AutoVer.IntegrationTests.Utilities; +using AutoVer.Models; +using LibGit2Sharp; + +namespace AutoVer.IntegrationTests; + +/// +/// The pyproject.toml handler through the full CLI: `autover version` bumps, commits and tags a +/// Python project listed in autover.json, seeds one that has no version, and never auto-discovers a +/// pyproject.toml on its own. +/// +[Retry(3)] +public class PyprojectVersionCommandTests +{ + private const string Pyproject = +""" +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "walleye-datalake" +version = "1.0.0" +dependencies = [ + "requests>=2.28", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +"""; + + private string _tempDir = string.Empty; + + [Before(Test)] + public void Before() + { + _tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_tempDir); + Repository.Init(_tempDir); + using var repo = new Repository(_tempDir); + _tempDir = repo.Info.WorkingDirectory; + IOUtilities.AddGitignore(_tempDir); + } + + [After(Test)] + public void After() + { + try + { + if (!string.IsNullOrEmpty(_tempDir) && Directory.Exists(_tempDir)) + { + IOUtilities.RemoveReadOnly(_tempDir); + Directory.Delete(_tempDir, true); + } + } + catch (Exception ex) + { + Assert.Fail(ex.Message); + } + } + + private async Task SetUpRepo(string pyproject, string extraConfig = "") + { + var path = Path.Combine(_tempDir, "pyproject.toml"); + await File.WriteAllTextAsync(path, pyproject); + await IOUtilities.AddAutoVerFile(_tempDir, +$@"{{ + ""Projects"": [ {{ ""Name"": ""walleye-datalake"", ""Path"": ""pyproject.toml"" }} ], + ""UseCommitsForChangelog"": false, + ""ChangeFilesDetermineIncrementType"": true{extraConfig} +}}"); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + return path; + } + + [Test] + public async Task ChangeFile_BumpsTheVersionOnly_CommitsAndTags() + { + var path = await SetUpRepo(Pyproject); + await IOUtilities.AddChangeFile("walleye-datalake", IncrementType.Minor, "A feature", _tempDir); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Change"); + + var app = AutoVerUtilities.InitializeApp(); + var exitCode = await app.Run(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await File.ReadAllTextAsync(path)).IsEqualTo(Pyproject.Replace("version = \"1.0.0\"", "version = \"1.1.0\"")); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).Count().IsEqualTo(1); + using var repo = new Repository(_tempDir); + await Assert.That(repo.RetrieveStatus().IsDirty).IsFalse(); + } + + [Test] + public async Task VersionCurrent_PrintsThePyprojectVersion() + { + await SetUpRepo(Pyproject); + + var (exitCode, output, _) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir, "--current"]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(output.Trim()).IsEqualTo("1.0.0"); + } + + [Test] + public async Task NoVersion_IsSeededWithInitialVersion() + { + var path = await SetUpRepo(Pyproject.Replace("version = \"1.0.0\"\n", ""), ",\n \"InitialVersion\": \"0.3.0\""); + await IOUtilities.AddChangeFile("walleye-datalake", IncrementType.Patch, "First release", _tempDir); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Change"); + + var app = AutoVerUtilities.InitializeApp(); + var exitCode = await app.Run(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await File.ReadAllTextAsync(path)).Contains("name = \"walleye-datalake\"\nversion = \"0.3.0\"\n"); + } + + [Test] + public async Task DynamicVersion_FailsWithoutTouchingTheRepository() + { + var dynamic = Pyproject.Replace("version = \"1.0.0\"", "dynamic = [\"version\"]"); + var path = await SetUpRepo(dynamic); + + var (exitCode, output, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsNotEqualTo(CommandReturnCodes.Success); + await Assert.That(output + error).Contains("dynamic"); + await Assert.That(await File.ReadAllTextAsync(path)).IsEqualTo(dynamic); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).IsEmpty(); + } + + // Many non-Python repositories keep a tool-config-only pyproject.toml; relying on discovery must + // never start versioning it. + [Test] + public async Task NoConfig_PyprojectIsNotAutoDiscovered() + { + var path = Path.Combine(_tempDir, "pyproject.toml"); + await File.WriteAllTextAsync(path, Pyproject); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + + var (exitCode, output, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsNotEqualTo(CommandReturnCodes.Success); + await Assert.That(output + error).Contains("only versioned when listed in .autover/autover.json"); + await Assert.That(await File.ReadAllTextAsync(path)).IsEqualTo(Pyproject); + } + + [Test] + public async Task SharedVersion_WithACsproj_MovesBothTogether() + { + await IOUtilities.CreateProject(_tempDir, "src", "Project1"); + var csprojPath = Path.Combine(_tempDir, "src", "Project1", "Project1.csproj"); + await IOUtilities.SetProjectVersion(csprojPath, "1.0.0"); + var pyprojectPath = Path.Combine(_tempDir, "pyproject.toml"); + await File.WriteAllTextAsync(pyprojectPath, Pyproject); + await IOUtilities.AddAutoVerFile(_tempDir, +""" +{ + "Projects": [ + { "Name": "Project1", "Path": "src/Project1/Project1.csproj" }, + { "Name": "walleye-datalake", "Path": "pyproject.toml" } + ], + "UseCommitsForChangelog": false, + "UseSameVersionForAllProjects": true, + "ChangeFilesDetermineIncrementType": true +} +"""); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + await IOUtilities.AddChangeFile("walleye-datalake", IncrementType.Major, "Breaking", _tempDir); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Change"); + + var app = AutoVerUtilities.InitializeApp(); + var exitCode = await app.Run(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await IOUtilities.GetProjectVersion(csprojPath)).IsEqualTo("2.0.0"); + await Assert.That(await File.ReadAllTextAsync(pyprojectPath)).Contains("version = \"2.0.0\""); + } + + [Test] + public async Task Changelog_AfterVersion_RecordsThePythonRelease() + { + await SetUpRepo(Pyproject); + await IOUtilities.AddChangeFile("walleye-datalake", IncrementType.Patch, "Fix the thing", _tempDir); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Change"); + + // A fresh app per command, as a CLI invocation is. + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["version", "--project-path", _tempDir])).IsEqualTo(CommandReturnCodes.Success); + await Assert.That(await AutoVerUtilities.InitializeApp().Run(["changelog", "--project-path", _tempDir])).IsEqualTo(CommandReturnCodes.Success); + + var changelog = await IOUtilities.GetChangelog(_tempDir); + await Assert.That(changelog).Contains("walleye-datalake (1.0.1)"); + await Assert.That(changelog).Contains("Fix the thing"); + } + + // Review finding: a label pip rejects used to fail only when the pyproject's turn came, + // leaving the .csproj listed before it bumped and staged. + [Test] + public async Task VersionPipRejects_FailsBeforeAnyFileIsWritten() + { + await IOUtilities.CreateProject(_tempDir, "src", "Project1"); + var csprojPath = Path.Combine(_tempDir, "src", "Project1", "Project1.csproj"); + await IOUtilities.SetProjectVersion(csprojPath, "1.0.0"); + var pyprojectPath = Path.Combine(_tempDir, "pyproject.toml"); + await File.WriteAllTextAsync(pyprojectPath, Pyproject); + await IOUtilities.AddAutoVerFile(_tempDir, +""" +{ + "Projects": [ + { "Name": "Project1", "Path": "src/Project1/Project1.csproj" }, + { "Name": "walleye-datalake", "Path": "pyproject.toml" } + ], + "UseCommitsForChangelog": false, + "ChangeFilesDetermineIncrementType": false +} +"""); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + + var (exitCode, output, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir, "--use-version", "2.0.0-hotfix"]); + + await Assert.That(exitCode).IsNotEqualTo(CommandReturnCodes.Success); + await Assert.That(output + error).Contains("PEP 440"); + await Assert.That(await IOUtilities.GetProjectVersion(csprojPath)).IsEqualTo("1.0.0"); + await Assert.That(await File.ReadAllTextAsync(pyprojectPath)).IsEqualTo(Pyproject); + using var repo = new Repository(_tempDir); + await Assert.That(repo.RetrieveStatus().IsDirty).IsFalse(); + await Assert.That(GitUtilities.GetAllTags(_tempDir)).IsEmpty(); + } + + // Review finding: this used to crash with "Unhandled exception. This is a bug." because the + // version is parsed before the handler is ever asked to write it. + [Test] + public async Task NormalizedPythonSpelling_IsAnExplainedErrorNotACrash() + { + await SetUpRepo(Pyproject.Replace("version = \"1.0.0\"", "version = \"1.0.0b1\"")); + await IOUtilities.AddChangeFile("walleye-datalake", IncrementType.Patch, "x", _tempDir); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Change"); + + var (exitCode, output, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsNotEqualTo(CommandReturnCodes.Success); + await Assert.That(exitCode).IsNotEqualTo(CommandReturnCodes.UnhandledException); + await Assert.That(output + error).Contains("1.0.0-b1"); + } + + // The configuration-loading error names the cause - and, for a bad setting, keeps the JSON + // path System.Text.Json reports rather than only its innermost token-type complaint. + [Test] + public async Task InvalidSetting_ErrorKeepsTheJsonPath() + { + await File.WriteAllTextAsync(Path.Combine(_tempDir, "pyproject.toml"), Pyproject); + await IOUtilities.AddAutoVerFile(_tempDir, +""" +{ "Projects": [ { "Name": "walleye-datalake", "Path": "pyproject.toml" } ], "UseSameVersionForAllProjects": "yes" } +"""); + GitUtilities.StageChanges(_tempDir, "*"); + GitUtilities.CommitChanges(_tempDir, "Initial Commit"); + + var (exitCode, output, error) = await AutoVerUtilities.RunCapturingOutput(["version", "--project-path", _tempDir]); + + await Assert.That(exitCode).IsNotEqualTo(CommandReturnCodes.Success); + await Assert.That(output + error).Contains("UseSameVersionForAllProjects"); + } +} diff --git a/test/AutoVer.UnitTests/ProjectFiles/PyprojectFileHandlerTest.cs b/test/AutoVer.UnitTests/ProjectFiles/PyprojectFileHandlerTest.cs new file mode 100644 index 0000000..b191d93 --- /dev/null +++ b/test/AutoVer.UnitTests/ProjectFiles/PyprojectFileHandlerTest.cs @@ -0,0 +1,424 @@ +using AutoVer.Exceptions; +using AutoVer.Models; +using AutoVer.Services; +using AutoVer.Services.IO; +using AutoVer.Services.ProjectFiles; + +namespace AutoVer.UnitTests.ProjectFiles; + +public class PyprojectFileHandlerTest +{ + private const string Basic = +""" +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "walleye-datalake" +version = "1.2.3" +description = "A package" +dependencies = ["requests>=2.28"] + +[project.urls] +Homepage = "https://example.invalid" + +[tool.pytest.ini_options] +testpaths = ["tests"] +"""; + + private string _tempDir = string.Empty; + + [Before(Test)] + public void Before() + { + _tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(_tempDir); + } + + [After(Test)] + public void After() + { + if (Directory.Exists(_tempDir)) + Directory.Delete(_tempDir, true); + } + + private static PyprojectFileHandler CreateHandler() => new(new ThreePartVersionIncrementer(), new FileManager(new CurrentDirectoryContext())); + + private async Task Bump(string content, IncrementType increment = IncrementType.Patch, string? prereleaseLabel = null, string? overrideVersion = null) + { + var handler = CreateHandler(); + var path = Path.Combine(_tempDir, "pyproject.toml"); + await File.WriteAllTextAsync(path, content); + var definition = handler.Load(path, await File.ReadAllTextAsync(path)); + handler.UpdateVersion(definition, increment, prereleaseLabel, overrideVersion); + return await File.ReadAllTextAsync(path); + } + + // ── matching ───────────────────────────────────────────────────────────────────────── + + [Test] + [Arguments("pyproject.toml", true)] + [Arguments("PyProject.toml", true)] + [Arguments("src/pkg/pyproject.toml", true)] + [Arguments("setup.cfg", false)] + [Arguments("Cargo.toml", false)] + [Arguments("pyproject.toml.bak", false)] + [Arguments("my-pyproject.toml", false)] + public async Task IsMatch_OnlyPyprojectToml(string fileName, bool expected) + { + await Assert.That(CreateHandler().IsMatch(fileName)).IsEqualTo(expected); + } + + // A tool-config-only pyproject.toml is common in repositories that are not Python packages; + // discovering it would start versioning it in every repository that relies on discovery. + [Test] + public async Task SearchPatterns_IsEmpty_SoPyprojectIsNeverAutoDiscovered() + { + await Assert.That(CreateHandler().SearchPatterns).IsEmpty(); + } + + // ── reading ────────────────────────────────────────────────────────────────────────── + + [Test] + public async Task Load_ReadsProjectVersion() + { + var definition = CreateHandler().Load("pyproject.toml", Basic); + await Assert.That(definition.Version).IsEqualTo("1.2.3"); + await Assert.That(definition.Contents).IsTypeOf>(); + } + + [Test] + [Arguments("version = '1.2.3'")] + [Arguments("version=\"1.2.3\"")] + [Arguments("\"version\" = \"1.2.3\"")] + [Arguments("'version' = '1.2.3'")] + [Arguments(" version = \"1.2.3\" # managed by AutoVer")] + public async Task Load_AcceptsTomlSpellings(string versionLine) + { + var definition = CreateHandler().Load("pyproject.toml", $"[project]\nname = \"x\"\n{versionLine}\n"); + await Assert.That(definition.Version).IsEqualTo("1.2.3"); + } + + [Test] + [Arguments("[ project ]")] + [Arguments("[\"project\"]")] + [Arguments("[project] # metadata")] + public async Task Load_AcceptsHeaderSpellings(string header) + { + var definition = CreateHandler().Load("pyproject.toml", $"{header}\nname = \"x\"\nversion = \"1.2.3\"\n"); + await Assert.That(definition.Version).IsEqualTo("1.2.3"); + } + + [Test] + public async Task Load_IgnoresVersionKeysOutsideTheProjectTable() + { + const string content = +""" +[tool.poetry] +version = "9.9.9" + +[project] +name = "x" +version = "1.2.3" + +[project.optional-dependencies] +version = ["9.9.9"] + +[[tool.mytool.items]] +version = "9.9.9" +"""; + await Assert.That(CreateHandler().Load("pyproject.toml", content).Version).IsEqualTo("1.2.3"); + } + + [Test] + public async Task Load_IgnoresVersionTextInsideMultilineValues() + { + const string content = +""" +[project] +name = "x" +description = ''' +version = "9.9.9" +[project] +''' +classifiers = [ + "version = 9.9.9", +] +version = "1.2.3" +"""; + await Assert.That(CreateHandler().Load("pyproject.toml", content).Version).IsEqualTo("1.2.3"); + } + + [Test] + public async Task Load_NoVersionKey_VersionIsNull() + { + await Assert.That(CreateHandler().Load("pyproject.toml", "[project]\nname = \"x\"\n").Version).IsNull(); + } + + // ── refusals ───────────────────────────────────────────────────────────────────────── + + [Test] + public async Task Load_NoProjectTable_Throws() + { + var ex = Assert.Throws(() => CreateHandler().Load("pyproject.toml", "[tool.ruff]\nline-length = 100\n")); + await Assert.That(ex!.Message).Contains("no [project] table"); + } + + [Test] + public async Task Load_PoetryOnly_ExplainsPoetryIsNotSupported() + { + var ex = Assert.Throws(() => CreateHandler().Load("pyproject.toml", "[tool.poetry]\nname = \"x\"\nversion = \"1.2.3\"\n")); + await Assert.That(ex!.Message).Contains("Poetry"); + } + + [Test] + [Arguments("dynamic = [\"version\"]")] + [Arguments("dynamic = ['readme', 'version']")] + [Arguments("dynamic = [\n \"readme\",\n \"version\", # from git\n]")] + public async Task Load_DynamicVersion_Throws(string dynamic) + { + var ex = Assert.Throws(() => CreateHandler().Load("pyproject.toml", $"[project]\nname = \"x\"\n{dynamic}\n")); + await Assert.That(ex!.Message).Contains("dynamic"); + } + + [Test] + public async Task Load_DynamicWithoutVersion_IsFine() + { + var definition = CreateHandler().Load("pyproject.toml", "[project]\nname = \"x\"\nversion = \"1.2.3\"\ndynamic = [\"readme\"]\n"); + await Assert.That(definition.Version).IsEqualTo("1.2.3"); + } + + [Test] + [Arguments("version = 1.2")] + [Arguments("version = { attr = \"pkg.__version__\" }")] + [Arguments("version = \"\"\"1.0.0\"\"\"")] // was read as "" and overwritten into invalid TOML + [Arguments("version = '''1.0.0'''")] + [Arguments("version = \"1.0.0\" junk")] + public async Task Load_NonStringVersion_Throws(string versionLine) + { + var ex = Assert.Throws(() => CreateHandler().Load("pyproject.toml", $"[project]\nname = \"x\"\n{versionLine}\n")); + await Assert.That(ex!.Message).Contains("not a plain single-line string"); + } + + [Test] + public async Task Load_DuplicateVersionOrTable_Throws() + { + Assert.Throws(() => CreateHandler().Load("pyproject.toml", "[project]\nversion = \"1\"\nversion = \"2\"\n")); + Assert.Throws(() => CreateHandler().Load("pyproject.toml", "[project]\nname = \"a\"\n[project]\nname = \"b\"\n")); + await Task.CompletedTask; + } + + // ── writing ────────────────────────────────────────────────────────────────────────── + + [Test] + [Arguments(IncrementType.Patch, "1.2.4")] + [Arguments(IncrementType.Minor, "1.3.0")] + [Arguments(IncrementType.Major, "2.0.0")] + public async Task UpdateVersion_ChangesOnlyTheVersionValue(IncrementType increment, string expected) + { + var written = await Bump(Basic, increment); + await Assert.That(written).IsEqualTo(Basic.Replace("version = \"1.2.3\"", $"version = \"{expected}\"")); + } + + [Test] + public async Task UpdateVersion_KeepsQuoteStyleSpacingAndComment() + { + var written = await Bump("[project]\nname = 'x'\n 'version' = '1.2.3' # bumped by release\n"); + await Assert.That(written).IsEqualTo("[project]\nname = 'x'\n 'version' = '1.2.4' # bumped by release\n"); + } + + [Test] + public async Task UpdateVersion_KeepsCrlfLineEndings() + { + var written = await Bump("[project]\r\nname = \"x\"\r\nversion = \"1.2.3\"\r\n"); + await Assert.That(written).IsEqualTo("[project]\r\nname = \"x\"\r\nversion = \"1.2.4\"\r\n"); + } + + [Test] + public async Task UpdateVersion_Override_WritesThatVersion() + { + var written = await Bump(Basic, overrideVersion: "3.0.0"); + await Assert.That(written).Contains("version = \"3.0.0\""); + } + + [Test] + [Arguments("beta.1", "1.2.4-beta.1")] + [Arguments("rc.2", "1.2.4-rc.2")] + [Arguments("dev.1", "1.2.4-dev.1")] + public async Task UpdateVersion_Pep440PrereleaseLabels_AreWritten(string label, string expected) + { + var written = await Bump(Basic, prereleaseLabel: label); + await Assert.That(written).Contains($"version = \"{expected}\""); + } + + [Test] + [Arguments("hotfix")] + [Arguments("feature-x")] + [Arguments("beta.1.2")] + [Arguments("post.1")] // a post-release: pip ranks it ABOVE 1.2.4 and installs it by default + [Arguments("rev.2")] + [Arguments("1")] // 1.2.4-1 is 1.2.4.post1 to pip + [Arguments("rc.1\n")] // would write a raw newline into the TOML string + public async Task UpdateVersion_LabelPipWouldReject_ThrowsAndLeavesFileUntouched(string label) + { + var handler = CreateHandler(); + var path = Path.Combine(_tempDir, "pyproject.toml"); + await File.WriteAllTextAsync(path, Basic); + var definition = handler.Load(path, Basic); + + var ex = Assert.Throws(() => handler.UpdateVersion(definition, IncrementType.Patch, label)); + await Assert.That(ex!.Message).Contains("PEP 440"); + await Assert.That(await File.ReadAllTextAsync(path)).IsEqualTo(Basic); + Assert.Throws(() => handler.ValidateVersion(definition, IncrementType.Patch, label)); + } + + // Refused at Load: every caller parses a loaded version before asking the handler to write, + // so a check at write time would never be reached (review finding). + [Test] + public async Task Load_NormalizedPythonSpelling_ExplainsHowToWriteIt() + { + var ex = Assert.Throws(() => CreateHandler().Load("pyproject.toml", "[project]\nname = \"x\"\nversion = \"1.2.3b1\"\n")); + await Assert.That(ex!.Message).Contains("1.2.3-b1"); + } + + [Test] + public async Task UpdateVersion_UpdatesTheInMemoryDefinition() + { + var handler = CreateHandler(); + var path = Path.Combine(_tempDir, "pyproject.toml"); + await File.WriteAllTextAsync(path, Basic); + var definition = handler.Load(path, Basic); + handler.UpdateVersion(definition, IncrementType.Minor); + await Assert.That(definition.Version).IsEqualTo("1.3.0"); + } + + // ── seeding ────────────────────────────────────────────────────────────────────────── + + [Test] + public async Task UpdateVersion_NoVersion_IsSeededRightAfterName() + { + var written = await Bump("[project]\nname = \"x\"\ndescription = \"d\"\n", overrideVersion: "0.1.0"); + await Assert.That(written).IsEqualTo("[project]\nname = \"x\"\nversion = \"0.1.0\"\ndescription = \"d\"\n"); + } + + // Without a name key the version goes after the table's last value - which must be where + // that value ENDS, never inside a multi-line array or string. + [Test] + public async Task UpdateVersion_NoVersionNoName_IsSeededAfterTheLastValueNotInsideIt() + { + const string content = "[project]\ndependencies = [\n \"requests\",\n]\n\n[tool.x]\na = 1\n"; + var written = await Bump(content, overrideVersion: "0.1.0"); + await Assert.That(written).IsEqualTo("[project]\ndependencies = [\n \"requests\",\n]\nversion = \"0.1.0\"\n\n[tool.x]\na = 1\n"); + await Assert.That(CreateHandler().Load("pyproject.toml", written).Version).IsEqualTo("0.1.0"); + } + + [Test] + public async Task UpdateVersion_SeedKeepsCrlf() + { + var written = await Bump("[project]\r\nname = \"x\"\r\n", overrideVersion: "0.1.0"); + await Assert.That(written).IsEqualTo("[project]\r\nname = \"x\"\r\nversion = \"0.1.0\"\r\n"); + } + + // ── review findings ──────────────────────────────────────────────────────────────────── + + [Test] + public async Task UpdateVersion_EmptyVersion_IsSeeded() + { + var written = await Bump("[project]\nname = \"x\"\nversion = \"\"\n", overrideVersion: "0.1.0"); + await Assert.That(written).IsEqualTo("[project]\nname = \"x\"\nversion = \"0.1.0\"\n"); + } + + // A ']' inside a string (dependency extras) or a nested array must not end the value early. + [Test] + public async Task UpdateVersion_NoName_SeedsAfterArraysWithBracketsInStringsAndNesting() + { + const string content = "[project]\ndependencies = [\n \"requests[socks]>=2\",\n \"b\",\n]\nmatrix = [\n [1, 2],\n [3],\n]\n\n[tool.x]\na = 1\n"; + var written = await Bump(content, overrideVersion: "0.1.0"); + await Assert.That(written).IsEqualTo(content.Replace(" [3],\n]\n", " [3],\n]\nversion = \"0.1.0\"\n")); + } + + // Unbalanced brackets and '#' inside strings: only string-aware scanning keeps the array open. + [Test] + public async Task UpdateVersion_NoName_UnbalancedBracketsInStringsDoNotCloseTheArray() + { + const string content = "[project]\nkeywords = [\n \"weird]\",\n \"odd[\", \"x#y\",\n \"b\",\n]\n"; + var written = await Bump(content, overrideVersion: "0.1.0"); + await Assert.That(written).IsEqualTo(content + "version = \"0.1.0\"\n"); + } + + [Test] + public async Task Load_DottedKeyMultilineString_IsNotMistakenForAHeader() + { + const string content = "[tool.x]\ntemplates.header = \"\"\"\n[project]\n\"\"\"\n[project]\nname = \"x\"\nversion = \"1.0.0\"\n"; + await Assert.That(CreateHandler().Load("pyproject.toml", content).Version).IsEqualTo("1.0.0"); + } + + [Test] + public async Task Load_DottedVersionKey_IsNotTheVersion() + { + await Assert.That(CreateHandler().Load("pyproject.toml", "[project]\nname = \"x\"\nversion.extra = \"9\"\n").Version).IsNull(); + } + + [Test] + public async Task Load_EscapedQuotesAndHashesInStrings_DoNotConfuseTheScanner() + { + const string content = "[project]\nname = \"x\"\ndescription = \"a \\\" [x] # not a comment\"\nversion = \"1.0.0\"\n"; + await Assert.That(CreateHandler().Load("pyproject.toml", content).Version).IsEqualTo("1.0.0"); + } + + [Test] + public async Task UpdateVersion_SeedAtEndOfCrlfFileWithoutTrailingNewline_KeepsCrlf() + { + var written = await Bump("[project]\r\nname = \"x\"", overrideVersion: "0.1.0"); + await Assert.That(written).IsEqualTo("[project]\r\nname = \"x\"\r\nversion = \"0.1.0\""); + } + + [Test] + public async Task UpdateVersion_SeedAtEndOfLfFileWithoutTrailingNewline() + { + var written = await Bump("[project]\nname = \"x\"", overrideVersion: "0.1.0"); + await Assert.That(written).IsEqualTo("[project]\nname = \"x\"\nversion = \"0.1.0\""); + } + + // ── second review ────────────────────────────────────────────────────────────────────── + + // \""" inside a basic multi-line string is an escaped quote plus two quotes, not its end. + [Test] + public async Task Load_EscapedQuoteInMultilineString_DoesNotEndIt() + { + const string content = "[project]\nname = \"x\"\ndescription = \"\"\"a \\\"\"\" [\nversion = \"9.9.9\"\n\"\"\"\nversion = \"1.0.0\"\n"; + await Assert.That(CreateHandler().Load("pyproject.toml", content).Version).IsEqualTo("1.0.0"); + var written = await Bump(content); + await Assert.That(written).IsEqualTo(content.Replace("version = \"1.0.0\"", "version = \"1.0.1\"")); + } + + [Test] + public async Task Load_ClosingRunOfFiveQuotes_ClosesTheString() + { + const string content = "[project]\nname = \"x\"\ndescription = \"\"\"ends with two quotes\"\"\"\"\"\nversion = \"1.0.0\"\n"; + await Assert.That(CreateHandler().Load("pyproject.toml", content).Version).IsEqualTo("1.0.0"); + } + + // A seeded version goes after the END of a multi-line name, never inside it. + [Test] + public async Task UpdateVersion_MultilineName_SeedsAfterItsEnd() + { + var written = await Bump("[project]\nname = \"\"\"\nx\"\"\"\ndescription = \"d\"\n", overrideVersion: "0.1.0"); + await Assert.That(written).IsEqualTo("[project]\nname = \"\"\"\nx\"\"\"\nversion = \"0.1.0\"\ndescription = \"d\"\n"); + } + + [Test] + public async Task Load_UnclosedValue_IsReportedAsInvalidToml() + { + var ex = Assert.Throws(() => CreateHandler().Load("pyproject.toml", "[tool.x]\na = [1,\n[project]\nversion = \"1.0.0\"\n")); + await Assert.That(ex!.Message).Contains("line 2 is never closed"); + } + + [Test] + public async Task Load_StrayTripleQuoteLine_IsTrackedNotIgnored() + { + var ex = Assert.Throws(() => CreateHandler().Load("pyproject.toml", "[project]\nname = \"x\"\n\"\"\"\nversion = \"1.0.0\"\n")); + await Assert.That(ex!.Message).Contains("never closed"); + } +}