Repository: dbt-labs/dbt-core

Issue:
[CT-589] [Bug] `dbt deps` does not precisely match the regex for SemVer
### Is there an existing issue for this?

- [X] I have searched the existing issues

### TL;DR
There's probably not a very impactful issue here -- opening up this issue at risk of being very nit picky.

### Current Behavior

[This regular expression](https://github.com/dbt-labs/dbt-core/blob/28c44a9be733491314be692e93f6f15b11a91be9/core/dbt/semver.py#L70) is used within `dbt deps` to determine valid installable versions. It might also be utilized during `dbt --version` to [determine if the installed adapters are up-to-date](https://docs.getdbt.com/docs/core-versions#how-we-version-adapter-plugins) with the latest versions available.

If I did the string substitution correctly, it produces these results for a range of input strings:
https://regex101.com/r/YyaxyD/1

### Expected Behavior

There is a [suggested regular expression](https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string)  (RegEx) to check a SemVer string.

It produces these results:
https://regex101.com/r/Ly7O1x/3/

### Steps To Reproduce

1. Compile the regex [here](https://github.com/dbt-labs/dbt-core/blob/28c44a9be733491314be692e93f6f15b11a91be9/core/dbt/semver.py#L70) (manually or via Python)
    - `^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`
2. Drop it into [regex101.com](https://regex101.com/)
3. Re-use the test cases from [here](https://regex101.com/r/Ly7O1x/3/)
4. Browse through the output of valid semantic versions (for false negatives) and invalid semantic versions (for false positives)

### Relevant log output

<img width="588" alt="image" src="https://user-images.githubusercontent.com/44704949/166113865-80664355-b87d-4c1b-86d5-63de61cb30f9.png">

<img width="581" alt="image" src="https://user-images.githubusercontent.com/44704949/166113921-57e394c0-308c-4dac-86bd-173cc78c3c89.png">

### Additional Context

## Summary

### Why it's not that impactful

There's two cases of errors ([false negatives and false positives](https://en.wikipedia.org/wiki/Type_I_and_type_II_errors)). The former is more impactful to users; there are very few known _theoretical_ cases (and zero known _actual_ cases in the wild).

#### False negative - a valid semantic version doesn't match the regex
This is the case that would impact users the most.
There are only a couple of these, and it's easy to change to one that is recognized:
- 1.0.0-alpha.0valid
- 1.0.0-0A.is.legal

#### False positive - an invalid semantic version does matches the regex
There are quite a few examples of these, but maybe many/most wouldn't be seen in the wild. If so, maybe "no harm, no foul?"

See "Current Behavior" section for examples of invalid semantic versions.

Disclosure: I regularly flip-flop my false negatives with my false positives 😅 So sorry if I got it backwards.

### What is impactful though...
An example error message is: 
> Could not parse version "1.0.3.post1"

[One user got this message](https://getdbt.slack.com/archives/C030A0UF5LM/p1649275178019809
) and didn't know what action they should take. The error message should be more clear about what the user should do to fix the problem.


Discussion:
@dbeatty10 Thanks for opening! You're right, that `dbt.semver._VERSION_REGEX` is used for both `dbt deps` version resolution and for `dbt --version` version reporting on adapter plugins.

I'd be very supportive of taking two steps here:
1. Updating our regex to use one of the [official/recommended semver regexes](https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string) ([regices?](https://en.wiktionary.org/wiki/Talk:regex))
2. Updating this error message to be clearer. Rather than `Could not parse version "{}"`, something more like `"{}" is not a valid semantic version.` Does that sound right to you?
https://github.com/dbt-labs/dbt-core/blob/28c44a9be733491314be692e93f6f15b11a91be9/core/dbt/semver.py#L97-L99

Going to mark this a `good first issue`!
Yes, `"{}" is not a valid semantic version.` would be a nice upgrade in clarity! 🏆 

💡 As part of implementing #1, one suggestion would be to create unit tests for each of the cases listed [here](https://regex101.com/r/Ly7O1x/3/). That would serve to help catch any accidental regressions.
Right on! We've got a bunch already over in [`test/unit/test_semver.py`](https://github.com/dbt-labs/dbt-core/blob/main/test/unit/test_semver.py). We should have 'em all :)
Hey @jtcohen6 and @dbeatty10 I see this is marked as a `good_first_issue` 👀.

I have been wanting to pick up a good first issue and was wondering if you would be open if I attempt a PR for this issue? 

To confirm my understanding of what is ultimately desired here:
- Someone can include an improper semantic version of a package in their `packages.yml`
```yml
packages:
  - package: fivetran/fivetran_log
    version: 0.5.0.b1
```
- The error message returns `Could not parse version "{0.5.0.b1}"` which is not very descriptive of the issue at hand. Rather, it isn't a valid semantic version and should provide that context to help users understand that they may need to provide closer inspection on the version provided. (ie. `0.5.0-b1` is the correct version).
- Because of the above, we want to provide `"0.5.0.b1" is not a valid semantic version.` to aid users.

Therefore to address this, we want to apply to following adjustments:
1. Leverage (RegEx) to check a SemVer string within the `packages.yml` (and maybe in the `dbt --version` command execution? Or it is already addressed there?)
2. Update the error message to be more descriptive of a SemVer error if that truly is the case on `dbt deps`
3. Add unit testing to help catch any accidental regressions.

Let me know if you would mind if I tried to tackle this. Full disclosure, I would definitely need require support with the unit testing, but happy to give it a go! 😄 
Yep, you pretty much nailed it, @fivetran-joemarkiewicz . Would definitely welcome a PR for this issue, and I'd be happy to provide assistance for the unit testing and anything else.

Here's my recommended order:
1. Follow [these](https://github.com/dbt-labs/dbt-core/blob/main/CONTRIBUTING.md) instructions to run all the unit tests locally (everything should pass ✅ ).
2. Add each of [these](https://regex101.com/r/Ly7O1x/3/) cases to the SemVer unit tests [here](https://github.com/dbt-labs/dbt-core/blob/main/test/unit/test_semver.py). Re-run the tests. There should be some failures now ❌  (to be fixed by your PR).
3.  Update the RegEx [here](https://github.com/dbt-labs/dbt-core/blob/28c44a9be733491314be692e93f6f15b11a91be9/core/dbt/semver.py#L70) to utilize the logic from the [recommended SemVer RegEx](https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string). You'll probably want to break the SemVer RegEx into chunks to fit it into the [existing code](https://github.com/dbt-labs/dbt-core/blob/28c44a9be733491314be692e93f6f15b11a91be9/core/dbt/semver.py#L33-L55).
4. Re-run the tests and iterate until they all pass again ✅
5. Follow the rest of the contributing guidelines to finalize the PR

I'll try to write up an example of editing the RegEx unit tests and re-running just the one you want (rather than the entire suite).

Resolving patch:
diff --git a/core/dbt/semver.py b/core/dbt/semver.py
--- a/core/dbt/semver.py
+++ b/core/dbt/semver.py
@@ -31,9 +31,9 @@ class VersionSpecification(dbtClassMixin):
 
 
 _MATCHERS = r"(?P<matcher>\>=|\>|\<|\<=|=)?"
-_NUM_NO_LEADING_ZEROS = r"(0|[1-9][0-9]*)"
+_NUM_NO_LEADING_ZEROS = r"(0|[1-9]\d*)"
 _ALPHA = r"[0-9A-Za-z-]*"
-_ALPHA_NO_LEADING_ZEROS = r"(0|[1-9A-Za-z-][0-9A-Za-z-]*)"
+_ALPHA_NO_LEADING_ZEROS = r"(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)"
 
 _BASE_VERSION_REGEX = r"""
 (?P<major>{num_no_leading_zeros})\.
@@ -95,7 +95,7 @@ def from_version_string(cls, version_string):
 
         if not match:
             raise dbt.exceptions.SemverException(
-                'Could not parse version "{}"'.format(version_string)
+                f'"{version_string}" is not a valid semantic version.'
             )
 
         matched = {k: v for k, v in match.groupdict().items() if v is not None}


Test patch:
diff --git a/test/unit/test_semver.py b/test/unit/test_semver.py
--- a/test/unit/test_semver.py
+++ b/test/unit/test_semver.py
@@ -1,11 +1,19 @@
 import unittest
 import itertools
 
+from typing import List
 from dbt.exceptions import VersionsNotCompatibleException
 from dbt.semver import VersionSpecifier, UnboundedVersionSpecifier, \
     VersionRange, reduce_versions, versions_compatible, \
     resolve_to_specific_version, filter_installable
 
+def semver_regex_versioning(versions: List[str]) -> bool:
+    for version_string in versions:
+        try:
+            VersionSpecifier.from_version_string(version_string)
+        except Exception:
+            return False
+    return True
 
 def create_range(start_version_string, end_version_string):
     start = UnboundedVersionSpecifier()
@@ -45,6 +53,25 @@ def test__versions_compatible(self):
         self.assertFalse(
             versions_compatible('0.4.5a1', '0.4.5a2'))
 
+    def test__semver_regex_versions(self):
+        self.assertTrue(semver_regex_versioning(
+            ['0.0.4','1.2.3','10.20.30','1.1.2-prerelease+meta','1.1.2+meta','1.1.2+meta-valid',
+            '1.0.0-alpha','1.0.0-beta','1.0.0-alpha.beta','1.0.0-alpha.beta.1','1.0.0-alpha.1',
+            '1.0.0-alpha0.valid','1.0.0-alpha.0valid','1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay',
+            '1.0.0-rc.1+build.1','2.0.0-rc.1+build.123','1.2.3-beta','10.2.3-DEV-SNAPSHOT','1.2.3-SNAPSHOT-123',
+            '1.0.0','2.0.0','1.1.7','2.0.0+build.1848','2.0.1-alpha.1227','1.0.0-alpha+beta','1.2.3----RC-SNAPSHOT.12.9.1--.12+788',
+            '1.2.3----R-S.12.9.1--.12+meta','1.2.3----RC-SNAPSHOT.12.9.1--.12','1.0.0+0.build.1-rc.10000aaa-kk-0.1',
+            '99999999999999999999999.999999999999999999.99999999999999999','1.0.0-0A.is.legal']))
+
+        self.assertFalse(semver_regex_versioning(
+            ['1','1.2','1.2.3-0123','1.2.3-0123.0123','1.1.2+.123','+invalid','-invalid','-invalid+invalid',
+            '-invalid.01','alpha','alpha.beta','alpha.beta.1','alpha.1','alpha+beta','alpha_beta','alpha.',
+            'alpha..','beta','1.0.0-alpha_beta','-alpha.','1.0.0-alpha..','1.0.0-alpha..1','1.0.0-alpha...1',
+            '1.0.0-alpha....1','1.0.0-alpha.....1','1.0.0-alpha......1','1.0.0-alpha.......1','01.1.1','1.01.1',
+            '1.1.01','1.2','1.2.3.DEV','1.2-SNAPSHOT','1.2.31.2.3----RC-SNAPSHOT.12.09.1--..12+788','1.2-RC-SNAPSHOT',
+            '-1.0.3-gamma+b7718','+justmeta','9.8.7+meta+meta','9.8.7-whatever+meta+meta',
+            '99999999999999999999999.999999999999999999.99999999999999999----RC-SNAPSHOT.12.09.1--------------------------------..12']))
+
     def test__reduce_versions(self):
         self.assertVersionSetResult(
             ['0.0.1', '0.0.1'],