├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── codeql.yml │ └── validate.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── SECURITY.md ├── SUPPORT.md ├── cspell.config.yaml ├── dev_requirements.txt ├── docs ├── conf.py ├── featuremanagement.aio.rst └── index.rst ├── featuremanagement ├── __init__.py ├── _defaultfilters.py ├── _featurefilters.py ├── _featuremanager.py ├── _featuremanagerbase.py ├── _models │ ├── __init__.py │ ├── _allocation.py │ ├── _constants.py │ ├── _evaluation_event.py │ ├── _feature_conditions.py │ ├── _feature_flag.py │ ├── _targeting_context.py │ ├── _telemetry.py │ ├── _variant.py │ ├── _variant_assignment_reason.py │ └── _variant_reference.py ├── _time_window_filter │ ├── __init__.py │ ├── _models.py │ ├── _recurrence_evaluator.py │ └── _recurrence_validator.py ├── _version.py ├── aio │ ├── __init__.py │ ├── _defaultfilters.py │ ├── _featurefilters.py │ └── _featuremanager.py ├── azuremonitor │ ├── __init__.py │ └── _send_telemetry.py └── py.typed ├── mypy.ini ├── project-words.txt ├── pyproject.toml ├── samples ├── feature_flag_sample.py ├── feature_flag_with_azure_app_configuration_sample.py ├── feature_variant_sample.py ├── feature_variant_sample_with_targeting_accessor.py ├── feature_variant_sample_with_telemetry.py ├── formatted_feature_flags.json ├── quarty_sample.py ├── random_filter.py └── requirements.txt ├── setup.py └── tests ├── __init__.py ├── requirements.txt ├── test_default_feature_flags.py ├── test_default_feature_flags_async.py ├── test_feature_manager.py ├── test_feature_manager_async.py ├── test_feature_manager_refresh.py ├── test_feature_variants.py ├── test_feature_variants_async.py ├── test_send_telemetry_appinsights.py ├── time_window_filter ├── test_recurrence_evaluator.py ├── test_recurrence_validator.py └── test_time_window_filter_models.py └── validation_tests ├── NoFilters.sample.json ├── NoFilters.tests.json ├── RequirementType.sample.json ├── RequirementType.tests.json ├── TargetingFilter.modified.sample.json ├── TargetingFilter.modified.tests.json ├── TargetingFilter.sample.json ├── TargetingFilter.tests.json ├── TimeWindowFilter.sample.json ├── TimeWindowFilter.tests.json └── test_json_validations.py /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | @mrm9084 @avanigupta @albertofori @rossgrambo 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | - **Package Version**: 11 | - **Operating System**: 12 | - **Python Version**: 13 | 14 | **Describe the bug** 15 | A clear and concise description of what the bug is. 16 | 17 | **To Reproduce** 18 | Steps to reproduce the behavior: 19 | 1. 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | Please add an informative description that covers that changes made by the pull request and link all relevant issues. 4 | 5 | # Contribution checklist: 6 | - [ ] **The pull request does not introduce [breaking changes]** 7 | - [ ] **CHANGELOG is updated for new features, bug fixes or other significant changes.** 8 | - [ ] **I have read the [contribution guidelines](https://github.com/microsoft/FeatureManagement-Python/blob/main/CONTRIBUTING.md).** 9 | - [ ] Pull request includes test coverage for the included changes. 10 | 11 | ## General Guidelines and Best Practices 12 | - [ ] Title of the pull request is clear and informative. 13 | - [ ] There are a small number of commits, each of which have an informative message. This means that previously merged commits do not appear in the history of the PR. 14 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL Advanced" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | branches: [ "main" ] 19 | schedule: 20 | - cron: '32 23 * * 5' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze (${{ matrix.language }}) 25 | # Runner size impacts CodeQL analysis time. To learn more, please see: 26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 27 | # - https://gh.io/supported-runners-and-hardware-resources 28 | # - https://gh.io/using-larger-runners (GitHub.com only) 29 | # Consider using larger runners or machines with greater resources for possible analysis time improvements. 30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 31 | permissions: 32 | # required for all workflows 33 | security-events: write 34 | 35 | # required to fetch internal or private CodeQL packs 36 | packages: read 37 | 38 | # only required for workflows in private repositories 39 | actions: read 40 | contents: read 41 | 42 | strategy: 43 | fail-fast: false 44 | matrix: 45 | include: 46 | - language: python 47 | build-mode: none 48 | # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' 49 | # Use `c-cpp` to analyze code written in C, C++ or both 50 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both 51 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 52 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 53 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 54 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 55 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 56 | steps: 57 | - name: Checkout repository 58 | uses: actions/checkout@v4 59 | 60 | # Initializes the CodeQL tools for scanning. 61 | - name: Initialize CodeQL 62 | uses: github/codeql-action/init@v3 63 | with: 64 | languages: ${{ matrix.language }} 65 | build-mode: ${{ matrix.build-mode }} 66 | # If you wish to specify custom queries, you can do so here or in a config file. 67 | # By default, queries listed here will override any specified in a config file. 68 | # Prefix the list here with "+" to use these queries and those in the config file. 69 | 70 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 71 | # queries: security-extended,security-and-quality 72 | 73 | # If the analyze step fails for one of the languages you are analyzing with 74 | # "We were unable to automatically build your code", modify the matrix above 75 | # to set the build mode to "manual" for that language. Then modify this step 76 | # to build your code. 77 | # ℹ️ Command-line programs to run using the OS shell. 78 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 79 | - if: matrix.build-mode == 'manual' 80 | shell: bash 81 | run: | 82 | echo 'If you are using a "manual" build mode for one or more of the' \ 83 | 'languages you are analyzing, replace this with the commands to build' \ 84 | 'your code, for example:' 85 | echo ' make bootstrap' 86 | echo ' make release' 87 | exit 1 88 | 89 | - name: Perform CodeQL Analysis 90 | uses: github/codeql-action/analyze@v3 91 | with: 92 | category: "/language:${{matrix.language}}" 93 | -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | name: Validate 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | python-version: ["3.8", "3.9", "3.10"] 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v3 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install -r dev_requirements.txt 21 | pip install . 22 | - name: Analysing the code with pylint 23 | run: | 24 | pylint featuremanagement 25 | - uses: psf/black@24.8.0 26 | - name: Run mypy 27 | run: | 28 | mypy featuremanagement 29 | - name: cspell-action 30 | uses: streetsidesoftware/cspell-action@v6.8.0 31 | - name: Test with pytest 32 | run: | 33 | pip install -r tests/requirements.txt 34 | pytest tests --doctest-modules --cov-report=xml --cov-report=html 35 | - name: Analysing the samples with pylint 36 | run: | 37 | pip install -r samples/requirements.txt 38 | pylint --disable=missing-function-docstring,missing-class-docstring samples tests -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Ll]og/ 33 | [Ll]ogs/ 34 | 35 | # Visual Studio 2015/2017 cache/options directory 36 | .vs/ 37 | # Uncomment if you have tasks that create the project's static files in wwwroot 38 | #wwwroot/ 39 | 40 | # Visual Studio 2017 auto generated files 41 | Generated\ Files/ 42 | 43 | # MSTest test Results 44 | [Tt]est[Rr]esult*/ 45 | [Bb]uild[Ll]og.* 46 | 47 | # NUnit 48 | *.VisualState.xml 49 | TestResult.xml 50 | nunit-*.xml 51 | 52 | # Build Results of an ATL Project 53 | [Dd]ebugPS/ 54 | [Rr]eleasePS/ 55 | dlldata.c 56 | 57 | # Benchmark Results 58 | BenchmarkDotNet.Artifacts/ 59 | 60 | # .NET Core 61 | project.lock.json 62 | project.fragment.lock.json 63 | artifacts/ 64 | 65 | # ASP.NET Scaffolding 66 | ScaffoldingReadMe.txt 67 | 68 | # StyleCop 69 | StyleCopReport.xml 70 | 71 | # Files built by Visual Studio 72 | *_i.c 73 | *_p.c 74 | *_h.h 75 | *.ilk 76 | *.meta 77 | *.obj 78 | *.iobj 79 | *.pch 80 | *.pdb 81 | *.ipdb 82 | *.pgc 83 | *.pgd 84 | *.rsp 85 | *.sbr 86 | *.tlb 87 | *.tli 88 | *.tlh 89 | *.tmp 90 | *.tmp_proj 91 | *_wpftmp.csproj 92 | *.log 93 | *.tlog 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio 6 auto-generated project file (contains which files were open etc.) 298 | *.vbp 299 | 300 | # Visual Studio 6 workspace and project file (working project files containing files to include in project) 301 | *.dsw 302 | *.dsp 303 | 304 | # Visual Studio 6 technical files 305 | *.ncb 306 | *.aps 307 | 308 | # Visual Studio LightSwitch build output 309 | **/*.HTMLClient/GeneratedArtifacts 310 | **/*.DesktopClient/GeneratedArtifacts 311 | **/*.DesktopClient/ModelManifest.xml 312 | **/*.Server/GeneratedArtifacts 313 | **/*.Server/ModelManifest.xml 314 | _Pvt_Extensions 315 | 316 | # Paket dependency manager 317 | .paket/paket.exe 318 | paket-files/ 319 | 320 | # FAKE - F# Make 321 | .fake/ 322 | 323 | # CodeRush personal settings 324 | .cr/personal 325 | 326 | # Python Tools for Visual Studio (PTVS) 327 | __pycache__/ 328 | *.pyc 329 | 330 | # Cake - Uncomment if you are using it 331 | # tools/** 332 | # !tools/packages.config 333 | 334 | # Tabs Studio 335 | *.tss 336 | 337 | # Telerik's JustMock configuration file 338 | *.jmconfig 339 | 340 | # BizTalk build output 341 | *.btp.cs 342 | *.btm.cs 343 | *.odx.cs 344 | *.xsd.cs 345 | 346 | # OpenCover UI analysis results 347 | OpenCover/ 348 | 349 | # Azure Stream Analytics local run output 350 | ASALocalRun/ 351 | 352 | # MSBuild Binary and Structured Log 353 | *.binlog 354 | 355 | # NVidia Nsight GPU debugger configuration file 356 | *.nvuser 357 | 358 | # MFractors (Xamarin productivity tool) working folder 359 | .mfractor/ 360 | 361 | # Local History for Visual Studio 362 | .localhistory/ 363 | 364 | # Visual Studio History (VSHistory) files 365 | .vshistory/ 366 | 367 | # BeatPulse healthcheck temp database 368 | healthchecksdb 369 | 370 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 371 | MigrationBackup/ 372 | 373 | # Ionide (cross platform F# VS Code tools) working folder 374 | .ionide/ 375 | 376 | # Fody - auto-generated XML schema 377 | FodyWeavers.xsd 378 | 379 | # VS Code files for those working on multiple tools 380 | .vscode/* 381 | !.vscode/settings.json 382 | !.vscode/tasks.json 383 | !.vscode/launch.json 384 | !.vscode/extensions.json 385 | *.code-workspace 386 | 387 | # Local History for Visual Studio Code 388 | .history/ 389 | 390 | # Windows Installer files from build outputs 391 | *.cab 392 | *.msi 393 | *.msix 394 | *.msm 395 | *.msp 396 | 397 | # JetBrains Rider 398 | *.sln.iml 399 | 400 | env/ 401 | build/ 402 | .DS_Store 403 | *.egg-info/ 404 | .vscode/** 405 | 406 | dist/* 407 | 408 | # Sphinx 409 | docs/_build/ 410 | docs/_static 411 | docs/_templates 412 | docs/doctrees 413 | docs/html 414 | package-lock.json 415 | package.json 416 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Release History 2 | 3 | See [Release Notes](https://github.com/Azure/AppConfiguration/blob/main/releaseNotes/PythonFeatureManagement.md) for the full release history. -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include tests *.py 2 | include *.md 3 | include LICENSE 4 | include featuremanagement/*.py 5 | include featuremanagement/aio/*.py 6 | recursive-include samples *.py *.json 7 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Microsoft Feature Management for Python 2 | 3 | [![FeatureManagement](https://img.shields.io/pypi/v/FeatureManagement?label=FeatureManagement)](https://pypi.org/project/FeatureManagement/) 4 | 5 | Feature management provides a way to develop and expose application functionality based on features. Many applications have special requirements when a new feature is developed such as when the feature should be enabled and under what conditions. This library provides a way to define these relationships, and also integrates into common Python code patterns to make exposing these features possible. 6 | 7 | ## Get Started 8 | 9 | [Quickstart](https://learn.microsoft.com/azure/azure-app-configuration/quickstart-feature-flag-python): A quickstart guide is available to learn how to integrate feature flags from Azure App Configuration into your Python applications. 10 | 11 | [API Reference](https://microsoft.github.io/FeatureManagement-Python/): This API reference details the API surface of the libraries contained within this repository. 12 | 13 | ## Examples 14 | 15 | * [Python Application](https://github.com/microsoft/FeatureManagement-Python/blob/main/samples/feature_flag_sample.py) 16 | * [Python Application with Feature Variants](https://github.com/microsoft/FeatureManagement-Python/blob/main/samples/feature_variant_sample.py) 17 | * [Python Application with Azure App Configuration](https://github.com/microsoft/FeatureManagement-Python/blob/main/samples/feature_flag_with_azure_app_configuration_sample.py) 18 | * [Django Application](https://github.com/Azure/AppConfiguration/tree/main/examples/Python/python-django-webapp-sample) 19 | * [Flask Application](https://github.com/Azure/AppConfiguration/tree/main/examples/Python/python-flask-webapp-sample) 20 | 21 | ## Contributing 22 | 23 | This project welcomes contributions and suggestions. Most contributions require you to agree to a 24 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 25 | the rights to use your contribution. For details, visit https://cla.opensource.microsoft.com. 26 | 27 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 28 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 29 | provided by the bot. You will only need to do this once across all repos using our CLA. 30 | 31 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 32 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 33 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 34 | 35 | ## Trademarks 36 | 37 | This project may contain trademarks or logos for projects, products, or services. Authorized use of Microsoft 38 | trademarks or logos is subject to and must follow 39 | [Microsoft's Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks/usage/general). 40 | Use of Microsoft trademarks or logos in modified versions of this project must not cause confusion or imply Microsoft sponsorship. 41 | Any use of third-party trademarks or logos are subject to those third-party's policies. 42 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet) and [Xamarin](https://github.com/xamarin). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # TODO: The maintainer of this repo has not yet edited this file 2 | 3 | **REPO OWNER**: Do you want Customer Service & Support (CSS) support for this product/project? 4 | 5 | - **No CSS support:** Fill out this template with information about how to file issues and get help. 6 | - **Yes CSS support:** Fill out an intake form at [aka.ms/onboardsupport](https://aka.ms/onboardsupport). CSS will work with/help you to determine next steps. 7 | - **Not sure?** Fill out an intake as though the answer were "Yes". CSS will help you decide. 8 | 9 | *Then remove this first heading from this SUPPORT.MD file before publishing your repo.* 10 | 11 | # Support 12 | 13 | ## How to file issues and get help 14 | 15 | This project uses GitHub Issues to track bugs and feature requests. Please search the existing 16 | issues before filing new issues to avoid duplicates. For new issues, file your bug or 17 | feature request as a new Issue. 18 | 19 | For help and questions about using this project, please **REPO MAINTAINER: INSERT INSTRUCTIONS HERE 20 | FOR HOW TO ENGAGE REPO OWNERS OR COMMUNITY FOR HELP. COULD BE A STACK OVERFLOW TAG OR OTHER 21 | CHANNEL. WHERE WILL YOU HELP PEOPLE?**. 22 | 23 | ## Microsoft Support Policy 24 | 25 | Support for this **PROJECT or PRODUCT** is limited to the resources listed above. 26 | -------------------------------------------------------------------------------- /cspell.config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | $schema: https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json 3 | version: '0.2' 4 | dictionaryDefinitions: 5 | - name: project-words 6 | path: './project-words.txt' 7 | addWords: true 8 | dictionaries: 9 | - project-words 10 | ignorePaths: 11 | - 'env' 12 | - '.*' 13 | - 'build' 14 | - 'docs' 15 | - 'dev_requirements.txt' 16 | - '*.egg-info' 17 | - '*.ini' 18 | - '*.toml' 19 | - 'SECURITY.md' 20 | - 'SUPPORT.md' -------------------------------------------------------------------------------- /dev_requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | pytest-cov 3 | pytest-asyncio 4 | black 5 | pylint 6 | mypy 7 | sphinx 8 | sphinx_rtd_theme 9 | sphinx-toolbox 10 | myst_parser 11 | opentelemetry-api 12 | opentelemetry-sdk 13 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | 4 | import os 5 | import sys 6 | import sphinx_rtd_theme 7 | 8 | sys.path.insert(0, os.path.abspath("../featuremanagement")) 9 | 10 | project = "FeatureManagement" 11 | copyright = "2024, Microsoft" 12 | author = "Microsoft" 13 | release = "2.0.0b3" 14 | 15 | # -- General configuration --------------------------------------------------- 16 | 17 | extensions = [ 18 | "myst_parser", 19 | "sphinx.ext.autodoc", 20 | "sphinx.ext.coverage", 21 | "sphinx.ext.napoleon", 22 | "sphinx_toolbox.more_autodoc.autonamedtuple", 23 | ] 24 | 25 | templates_path = ["_templates"] 26 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 27 | 28 | # -- Options for HTML output -------------------------------------------------\ 29 | 30 | html_theme = "sphinx_rtd_theme" 31 | html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] 32 | html_static_path = ["_static"] 33 | -------------------------------------------------------------------------------- /docs/featuremanagement.aio.rst: -------------------------------------------------------------------------------- 1 | featuremanagement.aio package 2 | ======================================= 3 | 4 | Module contents 5 | --------------- 6 | 7 | .. automodule:: featuremanagement.aio 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../README.md 2 | :parser: myst_parser.sphinx_ 3 | 4 | Subpackages 5 | ----------- 6 | 7 | .. toctree:: 8 | :maxdepth: 4 9 | 10 | featuremanagement.aio 11 | 12 | Module contents 13 | --------------- 14 | 15 | .. automodule:: featuremanagement 16 | :members: 17 | :undoc-members: 18 | -------------------------------------------------------------------------------- /featuremanagement/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from ._featuremanager import FeatureManager 7 | from ._featurefilters import FeatureFilter 8 | from ._defaultfilters import TimeWindowFilter, TargetingFilter 9 | from ._models import FeatureFlag, Variant, EvaluationEvent, VariantAssignmentReason, TargetingContext 10 | 11 | from ._version import VERSION 12 | 13 | __version__ = VERSION 14 | __all__ = [ 15 | "FeatureManager", 16 | "TimeWindowFilter", 17 | "TargetingFilter", 18 | "FeatureFilter", 19 | "FeatureFlag", 20 | "Variant", 21 | "EvaluationEvent", 22 | "VariantAssignmentReason", 23 | "TargetingContext", 24 | ] 25 | -------------------------------------------------------------------------------- /featuremanagement/_defaultfilters.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | import logging 7 | import hashlib 8 | from datetime import datetime, timezone 9 | from email.utils import parsedate_to_datetime 10 | from typing import cast, List, Mapping, Optional, Dict, Any 11 | from ._featurefilters import FeatureFilter 12 | from ._time_window_filter import Recurrence, is_match, TimeWindowFilterSettings 13 | 14 | FEATURE_FLAG_NAME_KEY = "feature_name" 15 | ROLLOUT_PERCENTAGE_KEY = "RolloutPercentage" 16 | DEFAULT_ROLLOUT_PERCENTAGE_KEY = "DefaultRolloutPercentage" 17 | PARAMETERS_KEY = "parameters" 18 | 19 | # Time Window Constants 20 | START_KEY = "Start" 21 | END_KEY = "End" 22 | TIME_WINDOW_FILTER_SETTING_RECURRENCE = "Recurrence" 23 | 24 | # Time Window Exceptions 25 | TIME_WINDOW_FILTER_INVALID = ( 26 | "{}: The {} feature filter is not valid for feature {}. It must specify either {}, {}, or both." 27 | ) 28 | TIME_WINDOW_FILTER_INVALID_RECURRENCE = ( 29 | "{}: The {} feature filter is not valid for feature {}. It must specify both {} and {} when Recurrence is not None." 30 | ) 31 | 32 | # Targeting kwargs 33 | TARGETED_USER_KEY = "user" 34 | TARGETED_GROUPS_KEY = "groups" 35 | 36 | # Targeting Constants 37 | AUDIENCE_KEY = "Audience" 38 | USERS_KEY = "Users" 39 | GROUPS_KEY = "Groups" 40 | EXCLUSION_KEY = "Exclusion" 41 | FEATURE_FILTER_NAME_KEY = "Name" 42 | IGNORE_CASE_KEY = "ignore_case" 43 | 44 | logger = logging.getLogger(__name__) 45 | 46 | 47 | class TargetingException(Exception): 48 | """ 49 | Exception raised when the targeting filter is not configured correctly. 50 | """ 51 | 52 | 53 | @FeatureFilter.alias("Microsoft.TimeWindow") 54 | class TimeWindowFilter(FeatureFilter): 55 | """ 56 | Feature Filter that determines if the current time is within the time window. 57 | """ 58 | 59 | def evaluate(self, context: Mapping[Any, Any], **kwargs: Any) -> bool: 60 | """ 61 | Determine if the feature flag is enabled for the given context. 62 | 63 | :keyword Mapping context: Mapping with the Start and End time for the feature flag. 64 | :return: True if the current time is within the time window. 65 | :rtype: bool 66 | """ 67 | start = context.get(PARAMETERS_KEY, {}).get(START_KEY, None) 68 | end = context.get(PARAMETERS_KEY, {}).get(END_KEY, None) 69 | recurrence_data = context.get(PARAMETERS_KEY, {}).get(TIME_WINDOW_FILTER_SETTING_RECURRENCE, None) 70 | recurrence = None 71 | 72 | current_time = datetime.now(timezone.utc) 73 | 74 | if not start and not end: 75 | logger.warning( 76 | TIME_WINDOW_FILTER_INVALID, 77 | TimeWindowFilter.__name__, 78 | context.get(FEATURE_FLAG_NAME_KEY), 79 | START_KEY, 80 | END_KEY, 81 | ) 82 | return False 83 | 84 | start_time: Optional[datetime] = parsedate_to_datetime(start) if start else None 85 | end_time: Optional[datetime] = parsedate_to_datetime(end) if end else None 86 | 87 | if (start_time is None or start_time <= current_time) and (end_time is None or current_time < end_time): 88 | return True 89 | 90 | if recurrence_data: 91 | recurrence = Recurrence(recurrence_data) 92 | settings = TimeWindowFilterSettings(start_time, end_time, recurrence) 93 | return is_match(settings, current_time) 94 | 95 | return False 96 | 97 | 98 | @FeatureFilter.alias("Microsoft.Targeting") 99 | class TargetingFilter(FeatureFilter): 100 | """ 101 | Feature Filter that determines if the user is targeted for the feature flag. 102 | """ 103 | 104 | @staticmethod 105 | def _is_targeted(context_id: str, rollout_percentage: int) -> bool: 106 | """Determine if the user is targeted for the given context""" 107 | # Always return true if rollout percentage is 100 108 | if rollout_percentage == 100: 109 | return True 110 | 111 | hashed_context_id = hashlib.sha256(context_id.encode()).digest() 112 | context_marker = int.from_bytes(hashed_context_id[:4], byteorder="little", signed=False) 113 | 114 | percentage = (context_marker / (2**32 - 1)) * 100 115 | return percentage < rollout_percentage 116 | 117 | def _target_group( 118 | self, target_user: Optional[str], target_group: str, group: Mapping[str, Any], feature_flag_name: str 119 | ) -> bool: 120 | group_rollout_percentage = group.get(ROLLOUT_PERCENTAGE_KEY, 0) 121 | if not target_user: 122 | target_user = "" 123 | audience_context_id = target_user + "\n" + feature_flag_name + "\n" + target_group 124 | 125 | return self._is_targeted(audience_context_id, group_rollout_percentage) 126 | 127 | def evaluate(self, context: Mapping[Any, Any], **kwargs: Any) -> bool: 128 | """ 129 | Determine if the feature flag is enabled for the given context. 130 | 131 | :keyword Mapping context: Context for evaluating the user/group. 132 | :return: True if the user is targeted for the feature flag. 133 | :rtype: bool 134 | """ 135 | target_user: Optional[str] = cast( 136 | str, 137 | kwargs.get(TARGETED_USER_KEY, None), 138 | ) 139 | target_groups: List[str] = cast(List[str], kwargs.get(TARGETED_GROUPS_KEY, [])) 140 | 141 | if not target_user and not (target_groups and len(target_groups) > 0): 142 | logging.warning("%s: Name or Groups are required parameters", TargetingFilter.__name__) 143 | return False 144 | 145 | audience = context.get(PARAMETERS_KEY, {}).get(AUDIENCE_KEY, None) 146 | feature_flag_name = context.get(FEATURE_FLAG_NAME_KEY, None) 147 | 148 | if not audience: 149 | raise TargetingException("Audience is required for " + TargetingFilter.__name__) 150 | 151 | groups = audience.get(GROUPS_KEY, []) 152 | default_rollout_percentage = audience.get(DEFAULT_ROLLOUT_PERCENTAGE_KEY, 0) 153 | 154 | self._validate(groups, default_rollout_percentage) 155 | 156 | # Check if the user is excluded 157 | if target_user in audience.get(EXCLUSION_KEY, {}).get(USERS_KEY, []): 158 | return False 159 | 160 | # Check if the user is in an excluded group 161 | for group in audience.get(EXCLUSION_KEY, {}).get(GROUPS_KEY, []): 162 | if group in target_groups: 163 | return False 164 | 165 | # Check if the user is targeted 166 | if target_user in audience.get(USERS_KEY, []): 167 | return True 168 | 169 | # Check if the user is in a targeted group 170 | for group in groups: 171 | for target_group in target_groups: 172 | group_name = group.get(FEATURE_FILTER_NAME_KEY, "") 173 | if kwargs.get(IGNORE_CASE_KEY, False): 174 | target_group = target_group.lower() 175 | group_name = group_name.lower() 176 | if group_name == target_group: 177 | if self._target_group(target_user, target_group, group, feature_flag_name): 178 | return True 179 | 180 | if not target_user: 181 | target_user = "" 182 | # Check if the user is in the default rollout 183 | context_id = target_user + "\n" + feature_flag_name 184 | return self._is_targeted(context_id, default_rollout_percentage) 185 | 186 | @staticmethod 187 | def _validate(groups: List[Dict[str, Any]], default_rollout_percentage: int) -> None: 188 | # Validate the audience settings 189 | if default_rollout_percentage < 0 or default_rollout_percentage > 100: 190 | raise TargetingException("DefaultRolloutPercentage must be between 0 and 100") 191 | 192 | for group in groups: 193 | if group.get(ROLLOUT_PERCENTAGE_KEY, 0) < 0 or group.get(ROLLOUT_PERCENTAGE_KEY, 100) > 100: 194 | raise TargetingException("RolloutPercentage must be between 0 and 100") 195 | -------------------------------------------------------------------------------- /featuremanagement/_featurefilters.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from abc import ABC, abstractmethod 7 | from typing import Mapping, Callable, Any, Optional 8 | 9 | 10 | class FeatureFilter(ABC): 11 | """ 12 | Parent class for all feature filters. 13 | """ 14 | 15 | _alias: Optional[str] = None 16 | 17 | @abstractmethod 18 | def evaluate(self, context: Mapping[Any, Any], **kwargs: Any) -> bool: 19 | """ 20 | Determine if the feature flag is enabled for the given context. 21 | 22 | :param Mapping context: Context for the feature flag. 23 | """ 24 | 25 | @property 26 | def name(self) -> str: 27 | """ 28 | Get the name of the filter. 29 | 30 | :return: Name of the filter, or alias if it exists. 31 | :rtype: str 32 | """ 33 | if hasattr(self, "_alias") and self._alias: 34 | return self._alias 35 | return self.__class__.__name__ 36 | 37 | @staticmethod 38 | def alias(alias: str) -> Callable[..., Any]: 39 | """ 40 | Decorator to set the alias for the filter. 41 | 42 | :param str alias: Alias for the filter. 43 | :return: Decorator. 44 | :rtype: Callable 45 | """ 46 | 47 | def wrapper(cls: "FeatureFilter") -> Any: 48 | cls._alias = alias # pylint: disable=protected-access 49 | return cls 50 | 51 | return wrapper 52 | -------------------------------------------------------------------------------- /featuremanagement/_featuremanager.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | import logging 7 | from typing import cast, overload, Any, Optional, Dict, Mapping, List, Tuple 8 | from ._defaultfilters import TimeWindowFilter, TargetingFilter 9 | from ._featurefilters import FeatureFilter 10 | from ._models import EvaluationEvent, Variant, TargetingContext 11 | from ._featuremanagerbase import ( 12 | FeatureManagerBase, 13 | PROVIDED_FEATURE_FILTERS, 14 | REQUIREMENT_TYPE_ALL, 15 | FEATURE_FILTER_NAME, 16 | ) 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class FeatureManager(FeatureManagerBase): 22 | """ 23 | Feature Manager that determines if a feature flag is enabled for the given context. 24 | 25 | :param Mapping configuration: Configuration object. 26 | :keyword list[FeatureFilter] feature_filters: Custom filters to be used for evaluating feature flags. 27 | :keyword Callable[EvaluationEvent] on_feature_evaluated: Callback function to be called when a feature flag is 28 | evaluated. 29 | :keyword Callable[[], TargetingContext] targeting_context_accessor: Callback function to get the current targeting 30 | context if one isn't provided. 31 | """ 32 | 33 | def __init__(self, configuration: Mapping[str, Any], **kwargs: Any): 34 | super().__init__(configuration, **kwargs) 35 | self._filters: Dict[str, FeatureFilter] = {} 36 | filters = [TimeWindowFilter(), TargetingFilter()] + cast( 37 | List[FeatureFilter], kwargs.pop(PROVIDED_FEATURE_FILTERS, []) 38 | ) 39 | 40 | for feature_filter in filters: 41 | if not isinstance(feature_filter, FeatureFilter): 42 | raise ValueError("Custom filter must be a subclass of FeatureFilter") 43 | self._filters[feature_filter.name] = feature_filter 44 | 45 | @overload # type: ignore 46 | def is_enabled(self, feature_flag_id: str, user_id: str, **kwargs: Any) -> bool: 47 | """ 48 | Determine if the feature flag is enabled for the given context. 49 | 50 | :param str feature_flag_id: Name of the feature flag. 51 | :param str user_id: User identifier. 52 | :return: True if the feature flag is enabled for the given context. 53 | :rtype: bool 54 | """ 55 | 56 | def is_enabled(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> bool: 57 | """ 58 | Determine if the feature flag is enabled for the given context. 59 | 60 | :param str feature_flag_id: Name of the feature flag. 61 | :return: True if the feature flag is enabled for the given context. 62 | :rtype: bool 63 | """ 64 | targeting_context: TargetingContext = self._build_targeting_context(args) 65 | 66 | result = self._check_feature(feature_flag_id, targeting_context, **kwargs) 67 | if ( 68 | self._on_feature_evaluated 69 | and result.feature 70 | and result.feature.telemetry.enabled 71 | and callable(self._on_feature_evaluated) 72 | ): 73 | result.user = targeting_context.user_id 74 | self._on_feature_evaluated(result) 75 | return result.enabled 76 | 77 | @overload # type: ignore 78 | def get_variant(self, feature_flag_id: str, user_id: str, **kwargs: Any) -> Optional[Variant]: 79 | """ 80 | Determine the variant for the given context. 81 | 82 | :param str feature_flag_id: Name of the feature flag. 83 | :param str user_id: User identifier. 84 | :return: return: Variant instance. 85 | :rtype: Variant 86 | """ 87 | 88 | def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> Optional[Variant]: 89 | """ 90 | Determine the variant for the given context. 91 | 92 | :param str feature_flag_id: Name of the feature flag 93 | :keyword TargetingContext targeting_context: Targeting context. 94 | :return: Variant instance. 95 | :rtype: Variant 96 | """ 97 | targeting_context: TargetingContext = self._build_targeting_context(args) 98 | 99 | result = self._check_feature(feature_flag_id, targeting_context, **kwargs) 100 | if ( 101 | self._on_feature_evaluated 102 | and result.feature 103 | and result.feature.telemetry.enabled 104 | and callable(self._on_feature_evaluated) 105 | ): 106 | result.user = targeting_context.user_id 107 | self._on_feature_evaluated(result) 108 | return result.variant 109 | 110 | def _build_targeting_context(self, args: Tuple[Any]) -> TargetingContext: 111 | targeting_context = super()._build_targeting_context(args) 112 | if targeting_context: 113 | return targeting_context 114 | if not targeting_context and self._targeting_context_accessor and callable(self._targeting_context_accessor): 115 | targeting_context = self._targeting_context_accessor() 116 | if targeting_context and isinstance(targeting_context, TargetingContext): 117 | return targeting_context 118 | logger.warning( 119 | "targeting_context_accessor did not return a TargetingContext. Received type %s.", 120 | type(targeting_context), 121 | ) 122 | 123 | return TargetingContext() 124 | 125 | def _check_feature_filters( 126 | self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext, **kwargs: Any 127 | ) -> None: 128 | feature_flag = evaluation_event.feature 129 | if not feature_flag: 130 | return 131 | feature_conditions = feature_flag.conditions 132 | feature_filters = feature_conditions.client_filters 133 | 134 | if len(feature_filters) == 0: 135 | # Feature flags without any filters return evaluate 136 | evaluation_event.enabled = True 137 | else: 138 | # The assumed value is no filters is based on the requirement type. 139 | # Requirement type Any assumes false until proven true, All assumes true until proven false 140 | evaluation_event.enabled = feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL 141 | 142 | for feature_filter in feature_filters: 143 | filter_name = feature_filter[FEATURE_FILTER_NAME] 144 | kwargs["user"] = targeting_context.user_id 145 | kwargs["groups"] = targeting_context.groups 146 | if filter_name not in self._filters: 147 | raise ValueError(f"Feature flag {feature_flag.name} has unknown filter {filter_name}") 148 | if feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL: 149 | if not self._filters[filter_name].evaluate(feature_filter, **kwargs): 150 | evaluation_event.enabled = False 151 | break 152 | elif self._filters[filter_name].evaluate(feature_filter, **kwargs): 153 | evaluation_event.enabled = True 154 | break 155 | 156 | def _check_feature( 157 | self, feature_flag_id: str, targeting_context: TargetingContext, **kwargs: Any 158 | ) -> EvaluationEvent: 159 | """ 160 | Determine if the feature flag is enabled for the given context. 161 | 162 | :param str feature_flag_id: Name of the feature flag. 163 | :param TargetingContext targeting_context: Targeting context. 164 | :return: EvaluationEvent for the given context. 165 | :rtype: EvaluationEvent 166 | """ 167 | evaluation_event, done = super()._check_feature_base(feature_flag_id) 168 | 169 | if done: 170 | return evaluation_event 171 | 172 | self._check_feature_filters(evaluation_event, targeting_context, **kwargs) 173 | 174 | self._assign_allocation(evaluation_event, targeting_context) 175 | return evaluation_event 176 | -------------------------------------------------------------------------------- /featuremanagement/_models/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from ._feature_flag import FeatureFlag 7 | from ._variant import Variant 8 | from ._evaluation_event import EvaluationEvent 9 | from ._variant_assignment_reason import VariantAssignmentReason 10 | from ._targeting_context import TargetingContext 11 | from ._variant_reference import VariantReference 12 | 13 | __path__ = __import__("pkgutil").extend_path(__path__, __name__) 14 | 15 | __all__ = [ 16 | "FeatureFlag", 17 | "Variant", 18 | "EvaluationEvent", 19 | "VariantAssignmentReason", 20 | "TargetingContext", 21 | "VariantReference", 22 | ] 23 | -------------------------------------------------------------------------------- /featuremanagement/_models/_allocation.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from typing import cast, List, Optional, Mapping, Dict, Any, Union 7 | from dataclasses import dataclass 8 | from ._constants import DEFAULT_WHEN_ENABLED, DEFAULT_WHEN_DISABLED, USER, GROUP, PERCENTILE, SEED 9 | 10 | 11 | @dataclass 12 | class UserAllocation: 13 | """ 14 | Represents a user allocation. 15 | """ 16 | 17 | variant: str 18 | users: List[str] 19 | 20 | 21 | @dataclass 22 | class GroupAllocation: 23 | """ 24 | Represents a group allocation. 25 | """ 26 | 27 | variant: str 28 | groups: List[str] 29 | 30 | 31 | class PercentileAllocation: 32 | """ 33 | Represents a percentile allocation. 34 | """ 35 | 36 | def __init__(self) -> None: 37 | self._variant: Optional[str] = None 38 | self._percentile_from: int = 0 39 | self._percentile_to: int = 0 40 | 41 | @classmethod 42 | def convert_from_json(cls, json: Mapping[str, Union[str, int]]) -> "PercentileAllocation": 43 | """ 44 | Convert a JSON object to PercentileAllocation. 45 | 46 | :param dict json: JSON object. 47 | :return: PercentileAllocation 48 | :rtype: PercentileAllocation 49 | """ 50 | if not json: 51 | raise ValueError("Percentile allocation is not valid.") 52 | user_allocation = cls() 53 | 54 | variant = json.get("variant") 55 | if not variant or not isinstance(variant, str): 56 | raise ValueError("Percentile allocation does not have a valid assigned variant.") 57 | user_allocation._variant = variant 58 | 59 | percentile_from = json.get("from", 0) 60 | if not isinstance(percentile_from, int): 61 | raise ValueError("Percentile allocation does not have a valid starting percentile.") 62 | user_allocation._percentile_from = percentile_from 63 | 64 | percentile_to = json.get("to") 65 | if not percentile_to or not isinstance(percentile_to, int): 66 | raise ValueError("Percentile allocation does not have a valid ending percentile.") 67 | user_allocation._percentile_to = percentile_to 68 | return user_allocation 69 | 70 | @property 71 | def variant(self) -> Optional[str]: 72 | """ 73 | Get the variant for the allocation. 74 | 75 | :return: Variant for the allocation. 76 | :rtype: str 77 | """ 78 | return self._variant 79 | 80 | @property 81 | def percentile_from(self) -> int: 82 | """ 83 | Get the starting percentile for the allocation. 84 | 85 | :return: Starting percentile for the allocation. 86 | :rtype: int 87 | """ 88 | return self._percentile_from 89 | 90 | @property 91 | def percentile_to(self) -> int: 92 | """ 93 | Get the ending percentile for the allocation. 94 | 95 | :return: Ending percentile for the allocation. 96 | :rtype: int 97 | """ 98 | return self._percentile_to 99 | 100 | 101 | class Allocation: 102 | """ 103 | Represents an allocation configuration for a feature flag. 104 | """ 105 | 106 | def __init__(self) -> None: 107 | self._default_when_enabled = None 108 | self._default_when_disabled = None 109 | self._user: List[UserAllocation] = [] 110 | self._group: List[GroupAllocation] = [] 111 | self._percentile: List[PercentileAllocation] = [] 112 | self._seed = None 113 | 114 | @classmethod 115 | def convert_from_json(cls, json: Dict[str, Any]) -> Optional["Allocation"]: 116 | """ 117 | Convert a JSON object to Allocation. 118 | 119 | :param json: JSON object 120 | :type json: dict 121 | :return: Allocation 122 | :rtype: Allocation 123 | """ 124 | if not json: 125 | return None 126 | allocation = cls() 127 | allocation._default_when_enabled = json.get(DEFAULT_WHEN_ENABLED) 128 | allocation._default_when_disabled = json.get(DEFAULT_WHEN_DISABLED) 129 | allocation._user = [] 130 | allocation._group = [] 131 | allocation._percentile = [] 132 | 133 | allocations: List[Any] = [] 134 | if USER in json: 135 | allocations = cast(List[Any], json.get(USER, [])) 136 | for user_allocation in allocations: 137 | allocation._user.append(UserAllocation(**user_allocation)) 138 | if GROUP in json: 139 | allocations = cast(List[Any], json.get(GROUP, [])) 140 | for group_allocation in allocations: 141 | allocation._group.append(GroupAllocation(**group_allocation)) 142 | if PERCENTILE in json: 143 | allocations = cast(List[Any], json.get(PERCENTILE, [])) 144 | for percentile_allocation in allocations: 145 | allocation._percentile.append(PercentileAllocation.convert_from_json(percentile_allocation)) 146 | allocation._seed = json.get(SEED, allocation._seed) 147 | return allocation 148 | 149 | @property 150 | def default_when_enabled(self) -> Optional[str]: 151 | """ 152 | Get the default variant when the feature flag is enabled. 153 | 154 | :return: Default variant when the feature flag is enabled. 155 | :rtype: str 156 | """ 157 | return self._default_when_enabled 158 | 159 | @property 160 | def default_when_disabled(self) -> Optional[str]: 161 | """ 162 | Get the default variant when the feature flag is disabled. 163 | 164 | :return: Default variant when the feature flag is disabled. 165 | :rtype: str 166 | """ 167 | return self._default_when_disabled 168 | 169 | @property 170 | def user(self) -> List[UserAllocation]: 171 | """ 172 | Get the user allocations. 173 | 174 | :return: User allocations. 175 | :rtype: list[UserAllocation] 176 | """ 177 | return self._user 178 | 179 | @property 180 | def group(self) -> List[GroupAllocation]: 181 | """ 182 | Get the group allocations. 183 | 184 | :return: Group allocations. 185 | :rtype: list[GroupAllocation] 186 | """ 187 | return self._group 188 | 189 | @property 190 | def percentile(self) -> List[PercentileAllocation]: 191 | """ 192 | Get the percentile allocations. 193 | 194 | :return: Percentile allocations. 195 | :rtype: list[PercentileAllocation] 196 | """ 197 | return self._percentile 198 | 199 | @property 200 | def seed(self) -> Optional[str]: 201 | """ 202 | Get the seed for the allocation. 203 | 204 | :return: Seed for the allocation. 205 | :rtype: str 206 | """ 207 | return self._seed 208 | -------------------------------------------------------------------------------- /featuremanagement/_models/_constants.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | 7 | # Feature Flag 8 | FEATURE_FLAG_ID = "id" 9 | FEATURE_FLAG_ENABLED = "enabled" 10 | FEATURE_FLAG_CONDITIONS = "conditions" 11 | FEATURE_FLAG_ALLOCATION = "allocation" 12 | FEATURE_FLAG_VARIANTS = "variants" 13 | 14 | 15 | # Conditions 16 | FEATURE_FILTER_REQUIREMENT_TYPE = "requirement_type" 17 | REQUIREMENT_TYPE_ALL = "All" 18 | REQUIREMENT_TYPE_ANY = "Any" 19 | FEATURE_FLAG_CLIENT_FILTERS = "client_filters" 20 | FEATURE_FILTER_NAME = "name" 21 | 22 | # Allocation 23 | DEFAULT_WHEN_ENABLED = "default_when_enabled" 24 | DEFAULT_WHEN_DISABLED = "default_when_disabled" 25 | USER = "user" 26 | GROUP = "group" 27 | PERCENTILE = "percentile" 28 | SEED = "seed" 29 | 30 | # Variant Reference 31 | VARIANT_REFERENCE_NAME = "name" 32 | CONFIGURATION_VALUE = "configuration_value" 33 | STATUS_OVERRIDE = "status_override" 34 | -------------------------------------------------------------------------------- /featuremanagement/_models/_evaluation_event.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from dataclasses import dataclass 7 | from typing import Optional 8 | from ._feature_flag import FeatureFlag 9 | from ._variant_assignment_reason import VariantAssignmentReason 10 | from ._variant import Variant 11 | 12 | 13 | @dataclass 14 | class EvaluationEvent: 15 | """ 16 | Represents a feature flag evaluation event. 17 | """ 18 | 19 | def __init__(self, feature_flag: Optional[FeatureFlag]): 20 | """ 21 | Initialize the EvaluationEvent. 22 | """ 23 | self.feature = feature_flag 24 | self.user = "" 25 | self.enabled = False 26 | self.variant: Optional[Variant] = None 27 | self.reason: VariantAssignmentReason = VariantAssignmentReason.NONE 28 | -------------------------------------------------------------------------------- /featuremanagement/_models/_feature_conditions.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from collections.abc import Mapping 7 | from typing import Any, Dict, List 8 | from ._constants import ( 9 | FEATURE_FLAG_CLIENT_FILTERS, 10 | FEATURE_FILTER_NAME, 11 | FEATURE_FILTER_REQUIREMENT_TYPE, 12 | REQUIREMENT_TYPE_ALL, 13 | REQUIREMENT_TYPE_ANY, 14 | ) 15 | 16 | 17 | class FeatureConditions: 18 | """ 19 | Represents the conditions for a feature flag. 20 | """ 21 | 22 | def __init__(self) -> None: 23 | self._requirement_type = REQUIREMENT_TYPE_ANY 24 | self._client_filters: List[Dict[str, Any]] = [] 25 | 26 | @classmethod 27 | def convert_from_json(cls, feature_name: str, json_value: str) -> "FeatureConditions": 28 | """ 29 | Convert a JSON object to FeatureConditions. 30 | 31 | :param dict json: JSON object. 32 | :return: FeatureConditions. 33 | :rtype: FeatureConditions 34 | """ 35 | conditions = cls() 36 | if json_value is not None and not isinstance(json_value, Mapping): 37 | raise AttributeError("Feature flag conditions must be a dictionary") 38 | conditions._requirement_type = json_value.get(FEATURE_FILTER_REQUIREMENT_TYPE, REQUIREMENT_TYPE_ANY) 39 | conditions._client_filters = json_value.get(FEATURE_FLAG_CLIENT_FILTERS, []) 40 | if not isinstance(conditions._client_filters, list): 41 | conditions._client_filters = [] 42 | for feature_filter in conditions._client_filters: 43 | feature_filter["feature_name"] = feature_name 44 | return conditions 45 | 46 | @property 47 | def requirement_type(self) -> str: 48 | """ 49 | Get the requirement type for the feature flag. 50 | 51 | :return: Requirement type. 52 | :rtype: str 53 | """ 54 | return self._requirement_type 55 | 56 | @property 57 | def client_filters(self) -> List[Dict[str, Any]]: 58 | """ 59 | Get the client filters for the feature flag. 60 | 61 | :return: Client filters. 62 | :rtype: list[dict] 63 | """ 64 | return self._client_filters 65 | 66 | def _validate(self, feature_flag_id: str) -> None: 67 | if self._requirement_type not in [REQUIREMENT_TYPE_ALL, REQUIREMENT_TYPE_ANY]: 68 | raise ValueError(f"Feature flag {feature_flag_id} has invalid requirement type.") 69 | for feature_filter in self._client_filters: 70 | if feature_filter.get(FEATURE_FILTER_NAME) is None: 71 | raise ValueError(f"Feature flag {feature_flag_id} is missing filter name.") 72 | -------------------------------------------------------------------------------- /featuremanagement/_models/_feature_flag.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from typing import cast, List, Union, Optional, Mapping, Any 7 | from ._feature_conditions import FeatureConditions 8 | from ._allocation import Allocation 9 | from ._variant_reference import VariantReference 10 | from ._telemetry import Telemetry 11 | from ._constants import ( 12 | FEATURE_FLAG_ID, 13 | FEATURE_FLAG_ENABLED, 14 | FEATURE_FLAG_CONDITIONS, 15 | FEATURE_FLAG_ALLOCATION, 16 | FEATURE_FLAG_VARIANTS, 17 | ) 18 | 19 | 20 | class FeatureFlag: 21 | """ 22 | Represents a feature flag. 23 | """ 24 | 25 | def __init__(self) -> None: 26 | self._id: str = "" 27 | self._enabled = False 28 | self._conditions: FeatureConditions = FeatureConditions() 29 | self._allocation: Optional[Allocation] = None 30 | self._variants: Optional[List[VariantReference]] = None 31 | self._telemetry: Telemetry = Telemetry() 32 | 33 | @classmethod 34 | def convert_from_json(cls, json_value: Mapping[str, Any]) -> "FeatureFlag": 35 | """ 36 | Convert a JSON object to FeatureFlag. 37 | 38 | :param dict json_value: JSON object 39 | :return: FeatureFlag. 40 | :rtype: FeatureFlag 41 | """ 42 | feature_flag = cls() 43 | feature_flag._id = json_value.get(FEATURE_FLAG_ID, "") 44 | feature_flag._enabled = _convert_boolean_value(json_value.get(FEATURE_FLAG_ENABLED, False), feature_flag._id) 45 | feature_flag._conditions = FeatureConditions.convert_from_json( 46 | feature_flag._id, json_value.get(FEATURE_FLAG_CONDITIONS, {}) 47 | ) 48 | if FEATURE_FLAG_CONDITIONS in json_value: 49 | feature_flag._conditions = FeatureConditions.convert_from_json( 50 | feature_flag._id, json_value.get(FEATURE_FLAG_CONDITIONS, {}) 51 | ) 52 | else: 53 | feature_flag._conditions = FeatureConditions() 54 | feature_flag._allocation = Allocation.convert_from_json(json_value.get(FEATURE_FLAG_ALLOCATION, None)) 55 | if FEATURE_FLAG_VARIANTS in json_value: 56 | variants: List[Mapping[str, Any]] = json_value.get(FEATURE_FLAG_VARIANTS, []) 57 | feature_flag._variants = [] 58 | for variant in variants: 59 | if variant: 60 | feature_flag._variants.append(VariantReference.convert_from_json(variant)) 61 | if "telemetry" in json_value: 62 | feature_flag._telemetry = Telemetry(**cast(Any, json_value.get("telemetry"))) 63 | feature_flag._validate() 64 | return feature_flag 65 | 66 | @property 67 | def name(self) -> str: 68 | """ 69 | Get the name of the feature flag. 70 | 71 | :return: Name of the feature flag. 72 | :rtype: str 73 | """ 74 | return self._id 75 | 76 | @property 77 | def enabled(self) -> bool: 78 | """ 79 | Get the status of the feature flag. 80 | 81 | :return: Status of the feature flag. 82 | :rtype: bool 83 | """ 84 | return self._enabled 85 | 86 | @property 87 | def conditions(self) -> FeatureConditions: 88 | """ 89 | Get the conditions for the feature flag. 90 | 91 | :return: Conditions for the feature flag. 92 | :rtype: FeatureConditions 93 | """ 94 | return self._conditions 95 | 96 | @property 97 | def allocation(self) -> Optional[Allocation]: 98 | """ 99 | Get the allocation for the feature flag. 100 | 101 | :return: Allocation for the feature flag. 102 | :rtype: Allocation 103 | """ 104 | return self._allocation 105 | 106 | @property 107 | def variants(self) -> Optional[List[VariantReference]]: 108 | """ 109 | Get the variants for the feature flag. 110 | 111 | :return: Variants for the feature flag. 112 | :rtype: list[VariantReference] 113 | """ 114 | return self._variants 115 | 116 | @property 117 | def telemetry(self) -> Telemetry: 118 | """ 119 | Get the telemetry configuration for the feature flag. 120 | 121 | :return: Telemetry for the feature flag. 122 | :rtype: Telemetry 123 | """ 124 | return self._telemetry 125 | 126 | def _validate(self) -> None: 127 | if not isinstance(self._id, str): 128 | raise ValueError(f"Invalid setting 'id' with value '{self._id}' for feature '{self._id}'.") 129 | if not isinstance(self._enabled, bool): 130 | raise ValueError(f"Invalid setting 'enabled' with value '{self._enabled}' for feature '{self._id}'.") 131 | self.conditions._validate(self._id) # pylint: disable=protected-access 132 | 133 | 134 | def _convert_boolean_value(enabled: Union[str, bool], feature_name: str) -> bool: 135 | """ 136 | Convert the value to a boolean if it is a string. 137 | 138 | :param Union[str, bool] enabled: Value to be converted. 139 | :return: Converted value. 140 | :rtype: bool 141 | """ 142 | if isinstance(enabled, bool): 143 | return enabled 144 | if enabled.lower() == "true": 145 | return True 146 | if enabled.lower() == "false": 147 | return False 148 | raise ValueError(f"Invalid setting 'enabled' with value '{enabled}' for feature '{feature_name}'.") 149 | -------------------------------------------------------------------------------- /featuremanagement/_models/_targeting_context.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # -------------------------------------------------------------------------- 6 | 7 | from typing import NamedTuple, List 8 | 9 | 10 | class TargetingContext(NamedTuple): 11 | """ 12 | Represents the context for targeting a feature flag. 13 | """ 14 | 15 | user_id: str = "" 16 | """ 17 | The user ID. 18 | 19 | :type: str 20 | """ 21 | 22 | groups: List[str] = [] 23 | """ 24 | The users groups. 25 | 26 | :type: List[str] 27 | """ 28 | -------------------------------------------------------------------------------- /featuremanagement/_models/_telemetry.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from typing import Dict 7 | from dataclasses import dataclass, field 8 | 9 | 10 | @dataclass 11 | class Telemetry: 12 | """ 13 | Represents the telemetry configuration for a feature flag. 14 | """ 15 | 16 | enabled: bool = False 17 | metadata: Dict[str, str] = field(default_factory=dict) 18 | -------------------------------------------------------------------------------- /featuremanagement/_models/_variant.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from typing import Any 7 | 8 | 9 | class Variant: 10 | """ 11 | A class representing a variant configuration assigned by a feature flag. 12 | 13 | :param str name: The name of the variant 14 | :param dict configuration: The configuration of the variant. 15 | """ 16 | 17 | def __init__(self, name: str, configuration: Any) -> None: 18 | self._name = name 19 | self._configuration = configuration 20 | 21 | @property 22 | def name(self) -> str: 23 | """ 24 | The name of the variant. 25 | :rtype: str 26 | """ 27 | return self._name 28 | 29 | @property 30 | def configuration(self) -> Any: 31 | """ 32 | The configuration of the variant. 33 | :rtype: Any 34 | """ 35 | return self._configuration 36 | -------------------------------------------------------------------------------- /featuremanagement/_models/_variant_assignment_reason.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from enum import Enum 7 | 8 | 9 | class VariantAssignmentReason(Enum): 10 | """ 11 | Represents an assignment reason. 12 | """ 13 | 14 | NONE = "None" 15 | DEFAULT_WHEN_DISABLED = "DefaultWhenDisabled" 16 | DEFAULT_WHEN_ENABLED = "DefaultWhenEnabled" 17 | USER = "User" 18 | GROUP = "Group" 19 | PERCENTILE = "Percentile" 20 | -------------------------------------------------------------------------------- /featuremanagement/_models/_variant_reference.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from dataclasses import dataclass 7 | from typing import Optional, Mapping, Any 8 | from ._constants import VARIANT_REFERENCE_NAME, CONFIGURATION_VALUE, STATUS_OVERRIDE 9 | 10 | 11 | @dataclass 12 | class VariantReference: 13 | """ 14 | Represents a variant reference. 15 | """ 16 | 17 | def __init__(self) -> None: 18 | self._name = None 19 | self._configuration_value = None 20 | self._status_override = None 21 | 22 | @classmethod 23 | def convert_from_json(cls, json: Mapping[str, Any]) -> "VariantReference": 24 | """ 25 | Convert a JSON object to VariantReference. 26 | 27 | :param dict json: JSON object 28 | :return: VariantReference 29 | :rtype: VariantReference 30 | """ 31 | variant_reference = cls() 32 | variant_reference._name = json.get(VARIANT_REFERENCE_NAME) 33 | variant_reference._configuration_value = json.get(CONFIGURATION_VALUE) 34 | variant_reference._status_override = json.get(STATUS_OVERRIDE, None) 35 | return variant_reference 36 | 37 | @property 38 | def name(self) -> Optional[str]: 39 | """ 40 | Get the name of the variant. 41 | 42 | :return: Name of the variant 43 | :rtype: str 44 | """ 45 | return self._name 46 | 47 | @property 48 | def configuration_value(self) -> Optional[str]: 49 | """ 50 | Get the configuration value for the variant. 51 | 52 | :return: Configuration value for the variant. 53 | :rtype: str 54 | """ 55 | return self._configuration_value 56 | 57 | @property 58 | def status_override(self) -> Optional[str]: 59 | """ 60 | Get the status override for the variant. 61 | 62 | :return: Status override for the variant. 63 | :rtype: str 64 | """ 65 | return self._status_override 66 | -------------------------------------------------------------------------------- /featuremanagement/_time_window_filter/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from ._recurrence_evaluator import is_match 7 | from ._models import Recurrence, TimeWindowFilterSettings 8 | 9 | __all__ = ["is_match", "Recurrence", "TimeWindowFilterSettings"] 10 | -------------------------------------------------------------------------------- /featuremanagement/_time_window_filter/_models.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from enum import Enum 7 | from typing import Dict, Any, Optional, List 8 | from datetime import datetime 9 | from dataclasses import dataclass 10 | from email.utils import parsedate_to_datetime 11 | 12 | 13 | class RecurrencePatternType(str, Enum): 14 | """ 15 | The recurrence pattern type. 16 | """ 17 | 18 | DAILY = "Daily" 19 | WEEKLY = "Weekly" 20 | 21 | @staticmethod 22 | def from_str(value: str) -> "RecurrencePatternType": 23 | """ 24 | Get the RecurrencePatternType from the string value. 25 | 26 | :param value: The string value. 27 | :type value: str 28 | :return: The RecurrencePatternType. 29 | :rtype: RecurrencePatternType 30 | """ 31 | if value == "Daily": 32 | return RecurrencePatternType.DAILY 33 | if value == "Weekly": 34 | return RecurrencePatternType.WEEKLY 35 | raise ValueError(f"Invalid value: {value}") 36 | 37 | 38 | class RecurrenceRangeType(str, Enum): 39 | """ 40 | The recurrence range type. 41 | """ 42 | 43 | NO_END = "NoEnd" 44 | END_DATE = "EndDate" 45 | NUMBERED = "Numbered" 46 | 47 | @staticmethod 48 | def from_str(value: str) -> "RecurrenceRangeType": 49 | """ 50 | Get the RecurrenceRangeType from the string value. 51 | 52 | :param value: The string value. 53 | :type value: str 54 | :return: The RecurrenceRangeType. 55 | :rtype: RecurrenceRangeType 56 | """ 57 | if value == "NoEnd": 58 | return RecurrenceRangeType.NO_END 59 | if value == "EndDate": 60 | return RecurrenceRangeType.END_DATE 61 | if value == "Numbered": 62 | return RecurrenceRangeType.NUMBERED 63 | raise ValueError(f"Invalid value: {value}") 64 | 65 | 66 | class RecurrencePattern: # pylint: disable=too-few-public-methods 67 | """ 68 | The recurrence pattern settings. 69 | """ 70 | 71 | days: List[str] = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] 72 | 73 | def __init__(self, pattern_data: Dict[str, Any]): 74 | self.type = RecurrencePatternType.from_str(pattern_data.get("Type", "Daily")) 75 | self.interval = pattern_data.get("Interval", 1) 76 | if self.interval <= 0: 77 | raise ValueError("The interval must be greater than 0.") 78 | # Days of the week are represented as a list of strings of their names. 79 | days_of_week_str = pattern_data.get("DaysOfWeek", []) 80 | 81 | # Days of the week are represented as a list of integers from 0 to 6. 82 | self.days_of_week: List[int] = [] 83 | for day in days_of_week_str: 84 | if day not in self.days: 85 | raise ValueError(f"Invalid value for DaysOfWeek: {day}") 86 | if self.days.index(day) in self.days_of_week: 87 | raise ValueError(f"Duplicate day of the week found: {day}") 88 | self.days_of_week.append(self.days.index(day)) 89 | if pattern_data.get("FirstDayOfWeek") and pattern_data.get("FirstDayOfWeek") not in self.days: 90 | raise ValueError(f"Invalid value for FirstDayOfWeek: {pattern_data.get('FirstDayOfWeek')}") 91 | self.first_day_of_week = self.days.index(pattern_data.get("FirstDayOfWeek", "Sunday")) 92 | 93 | 94 | class RecurrenceRange: # pylint: disable=too-few-public-methods 95 | """ 96 | The recurrence range settings. 97 | """ 98 | 99 | type: RecurrenceRangeType 100 | end_date: Optional[datetime] = None 101 | 102 | def __init__(self, range_data: Dict[str, Any]): 103 | self.type = RecurrenceRangeType.from_str(range_data.get("Type", "NoEnd")) 104 | if range_data.get("EndDate") and isinstance(range_data.get("EndDate"), str): 105 | end_date_str = range_data.get("EndDate", "") 106 | try: 107 | self.end_date = parsedate_to_datetime(end_date_str) if end_date_str else None 108 | except ValueError as e: 109 | raise ValueError(f"Invalid value for EndDate: {end_date_str}") from e 110 | except TypeError as e: 111 | # Python 3.9 and earlier throw TypeError if the string is not in RFC 2822 format. 112 | raise ValueError(f"Invalid value for EndDate: {end_date_str}") from e 113 | self.num_of_occurrences = range_data.get("NumberOfOccurrences", 2**63 - 1) 114 | if self.num_of_occurrences <= 0: 115 | raise ValueError("The number of occurrences must be greater than 0.") 116 | 117 | 118 | class Recurrence: # pylint: disable=too-few-public-methods 119 | """ 120 | The recurrence settings. 121 | """ 122 | 123 | pattern: RecurrencePattern 124 | range: RecurrenceRange 125 | 126 | def __init__(self, recurrence_data: Dict[str, Any]): 127 | self.pattern = RecurrencePattern(recurrence_data.get("Pattern", {})) 128 | self.range = RecurrenceRange(recurrence_data.get("Range", {})) 129 | 130 | 131 | @dataclass 132 | class TimeWindowFilterSettings: 133 | """ 134 | The settings for the time window filter. 135 | """ 136 | 137 | start: Optional[datetime] 138 | end: Optional[datetime] 139 | recurrence: Optional[Recurrence] 140 | 141 | 142 | @dataclass 143 | class OccurrenceInfo: 144 | """ 145 | The information of the previous occurrence. 146 | """ 147 | 148 | previous_occurrence: datetime 149 | num_of_occurrences: int 150 | -------------------------------------------------------------------------------- /featuremanagement/_time_window_filter/_recurrence_evaluator.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from datetime import datetime, timedelta 7 | from typing import Optional 8 | from ._models import RecurrencePatternType, RecurrenceRangeType, TimeWindowFilterSettings, OccurrenceInfo, Recurrence 9 | from ._recurrence_validator import validate_settings, _get_passed_week_days, _sort_days_of_week 10 | 11 | DAYS_PER_WEEK = 7 12 | REQUIRED_PARAMETER = "Required parameter: %s" 13 | 14 | 15 | def is_match(settings: TimeWindowFilterSettings, now: datetime) -> bool: 16 | """ 17 | Check if the current time is within the time window filter settings. 18 | 19 | :param TimeWindowFilterSettings settings: The settings for the time window filter. 20 | :param datetime now: The current time. 21 | :return: True if the current time is within the time window filter settings, otherwise False. 22 | :rtype: bool 23 | """ 24 | recurrence = settings.recurrence 25 | if recurrence is None: 26 | raise ValueError(REQUIRED_PARAMETER % "Recurrence") 27 | 28 | start = settings.start 29 | end = settings.end 30 | if start is None or end is None: 31 | raise ValueError(REQUIRED_PARAMETER % "Start or End") 32 | 33 | validate_settings(recurrence, start, end) 34 | 35 | previous_occurrence = _get_previous_occurrence(recurrence, start, now) 36 | if previous_occurrence is None: 37 | return False 38 | 39 | occurrence_end_date = previous_occurrence + (end - start) 40 | return now < occurrence_end_date 41 | 42 | 43 | def _get_previous_occurrence(recurrence: Recurrence, start: datetime, now: datetime) -> Optional[datetime]: 44 | if now < start: 45 | return None 46 | 47 | pattern_type = recurrence.pattern.type 48 | if pattern_type == RecurrencePatternType.DAILY: 49 | occurrence_info = _get_daily_previous_occurrence(recurrence, start, now) 50 | elif pattern_type == RecurrencePatternType.WEEKLY: 51 | occurrence_info = _get_weekly_previous_occurrence(recurrence, start, now) 52 | else: 53 | raise ValueError(f"Unsupported recurrence pattern type: {pattern_type}") 54 | 55 | recurrence_range = recurrence.range 56 | range_type = recurrence_range.type 57 | previous_occurrence = occurrence_info.previous_occurrence 58 | end_date = recurrence_range.end_date 59 | if ( 60 | range_type == RecurrenceRangeType.END_DATE 61 | and previous_occurrence is not None 62 | and end_date is not None 63 | and previous_occurrence > end_date 64 | ): 65 | return None 66 | if ( 67 | range_type == RecurrenceRangeType.NUMBERED 68 | and recurrence_range.num_of_occurrences is not None 69 | and occurrence_info.num_of_occurrences > recurrence_range.num_of_occurrences 70 | ): 71 | return None 72 | 73 | return occurrence_info.previous_occurrence 74 | 75 | 76 | def _get_daily_previous_occurrence(recurrence: Recurrence, start: datetime, now: datetime) -> OccurrenceInfo: 77 | interval = recurrence.pattern.interval 78 | num_of_occurrences = (now - start).days // interval 79 | previous_occurrence = start + timedelta(days=num_of_occurrences * interval) 80 | return OccurrenceInfo(previous_occurrence, num_of_occurrences + 1) 81 | 82 | 83 | def _get_weekly_previous_occurrence(recurrence: Recurrence, start: datetime, now: datetime) -> OccurrenceInfo: 84 | pattern = recurrence.pattern 85 | interval = pattern.interval 86 | first_day_of_first_week = start - timedelta(days=_get_passed_week_days(start.weekday(), pattern.first_day_of_week)) 87 | 88 | number_of_interval = (now - first_day_of_first_week).days // (interval * DAYS_PER_WEEK) 89 | first_day_of_most_recent_occurring_week = first_day_of_first_week + timedelta( 90 | days=number_of_interval * (interval * DAYS_PER_WEEK) 91 | ) 92 | sorted_days_of_week = _sort_days_of_week(pattern.days_of_week, pattern.first_day_of_week) 93 | max_day_offset = _get_passed_week_days(sorted_days_of_week[-1], pattern.first_day_of_week) 94 | min_day_offset = _get_passed_week_days(sorted_days_of_week[0], pattern.first_day_of_week) 95 | num_of_occurrences = number_of_interval * len(sorted_days_of_week) - sorted_days_of_week.index(start.weekday()) 96 | 97 | if now > first_day_of_most_recent_occurring_week + timedelta(days=DAYS_PER_WEEK): 98 | num_of_occurrences += len(sorted_days_of_week) 99 | most_recent_occurrence = first_day_of_most_recent_occurring_week + timedelta(days=max_day_offset) 100 | return OccurrenceInfo(most_recent_occurrence, num_of_occurrences) 101 | 102 | day_with_min_offset = first_day_of_most_recent_occurring_week + timedelta(days=min_day_offset) 103 | if start > day_with_min_offset: 104 | num_of_occurrences = 0 105 | day_with_min_offset = start 106 | if now < day_with_min_offset: 107 | most_recent_occurrence = ( 108 | first_day_of_most_recent_occurring_week 109 | - timedelta(days=interval * DAYS_PER_WEEK) 110 | + timedelta(days=max_day_offset) 111 | ) 112 | else: 113 | most_recent_occurrence = day_with_min_offset 114 | num_of_occurrences += 1 115 | 116 | for day in sorted_days_of_week[sorted_days_of_week.index(day_with_min_offset.weekday()) + 1 :]: 117 | day_with_min_offset = first_day_of_most_recent_occurring_week + timedelta( 118 | days=_get_passed_week_days(day, pattern.first_day_of_week) 119 | ) 120 | if now < day_with_min_offset: 121 | break 122 | most_recent_occurrence = day_with_min_offset 123 | num_of_occurrences += 1 124 | 125 | return OccurrenceInfo(most_recent_occurrence, num_of_occurrences) 126 | -------------------------------------------------------------------------------- /featuremanagement/_time_window_filter/_recurrence_validator.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from datetime import datetime, timedelta 7 | from typing import List 8 | from ._models import RecurrencePatternType, RecurrenceRangeType, Recurrence, RecurrencePattern, RecurrenceRange 9 | 10 | 11 | DAYS_PER_WEEK = 7 12 | TEN_YEARS = 3650 13 | RECURRENCE_PATTERN = "Pattern" 14 | RECURRENCE_PATTERN_DAYS_OF_WEEK = "DaysOfWeek" 15 | RECURRENCE_RANGE = "Range" 16 | REQUIRED_PARAMETER = "Required parameter: %s" 17 | OUT_OF_RANGE = "Out of range: %s" 18 | TIME_WINDOW_DURATION_TEN_YEARS = "Time window duration exceeds ten years: %s" 19 | NOT_MATCHED = "Start day does not match any day of the week: %s" 20 | TIME_WINDOW_DURATION_OUT_OF_RANGE = "Time window duration is out of range: %s" 21 | 22 | 23 | def validate_settings(recurrence: Recurrence, start: datetime, end: datetime) -> None: 24 | """ 25 | Validate the settings for the time window filter. 26 | 27 | :param TimeWindowFilterSettings settings: The settings for the time window filter. 28 | :raises ValueError: If the settings are invalid. 29 | """ 30 | if not recurrence: 31 | raise ValueError("Recurrence is required") 32 | 33 | _validate_start_end_parameter(start, end) 34 | _validate_recurrence_pattern(recurrence.pattern, start, end) 35 | _validate_recurrence_range(recurrence.range, start) 36 | 37 | 38 | def _validate_start_end_parameter(start: datetime, end: datetime) -> None: 39 | param_name = "end" 40 | if end > start + timedelta(days=TEN_YEARS): 41 | raise ValueError(TIME_WINDOW_DURATION_TEN_YEARS % param_name) 42 | 43 | 44 | def _validate_recurrence_pattern(pattern: RecurrencePattern, start: datetime, end: datetime) -> None: 45 | pattern_type = pattern.type 46 | 47 | if pattern_type == RecurrencePatternType.DAILY: 48 | _validate_daily_recurrence_pattern(pattern, start, end) 49 | else: 50 | _validate_weekly_recurrence_pattern(pattern, start, end) 51 | 52 | 53 | def _validate_recurrence_range(recurrence_range: RecurrenceRange, start: datetime) -> None: 54 | range_type = recurrence_range.type 55 | if range_type == RecurrenceRangeType.END_DATE: 56 | _validate_end_date(recurrence_range, start) 57 | 58 | 59 | def _validate_daily_recurrence_pattern(pattern: RecurrencePattern, start: datetime, end: datetime) -> None: 60 | # "Start" is always a valid first occurrence for "Daily" pattern. 61 | # Only need to check if time window validated 62 | _validate_time_window_duration(pattern, start, end) 63 | 64 | 65 | def _validate_weekly_recurrence_pattern(pattern: RecurrencePattern, start: datetime, end: datetime) -> None: 66 | _validate_days_of_week(pattern) 67 | 68 | # Check whether "Start" is a valid first occurrence 69 | if start.weekday() not in pattern.days_of_week: 70 | raise ValueError(NOT_MATCHED % start.strftime("%A")) 71 | 72 | # Time window duration must be shorter than how frequently it occurs 73 | _validate_time_window_duration(pattern, start, end) 74 | 75 | # Check whether the time window duration is shorter than the minimum gap between days of week 76 | if not _is_duration_compliant_with_days_of_week(pattern, start, end): 77 | raise ValueError(TIME_WINDOW_DURATION_OUT_OF_RANGE % "Recurrence.Pattern.DaysOfWeek") 78 | 79 | 80 | def _validate_time_window_duration(pattern: RecurrencePattern, start: datetime, end: datetime) -> None: 81 | interval_duration = ( 82 | timedelta(days=pattern.interval) 83 | if pattern.type == RecurrencePatternType.DAILY 84 | else timedelta(days=pattern.interval * DAYS_PER_WEEK) 85 | ) 86 | time_window_duration = end - start 87 | if start > end: 88 | raise ValueError(OUT_OF_RANGE % "The filter start date Start needs to before the End date.") 89 | 90 | if time_window_duration > interval_duration: 91 | raise ValueError(TIME_WINDOW_DURATION_OUT_OF_RANGE % "Recurrence.Pattern.Interval") 92 | 93 | 94 | def _validate_days_of_week(pattern: RecurrencePattern) -> None: 95 | days_of_week = pattern.days_of_week 96 | if not days_of_week: 97 | raise ValueError(REQUIRED_PARAMETER % "Recurrence.Pattern.DaysOfWeek") 98 | 99 | 100 | def _validate_end_date(recurrence_range: RecurrenceRange, start: datetime) -> None: 101 | end_date = recurrence_range.end_date 102 | if end_date and end_date < start: 103 | raise ValueError("The Recurrence.Range.EndDate should be after the Start") 104 | 105 | 106 | def _is_duration_compliant_with_days_of_week(pattern: RecurrencePattern, start: datetime, end: datetime) -> bool: 107 | days_of_week = pattern.days_of_week 108 | if len(days_of_week) == 1: 109 | return True 110 | 111 | # Get the date of first day of the week 112 | today = datetime.now() 113 | first_day_of_week = pattern.first_day_of_week 114 | offset = _get_passed_week_days((today.weekday() + 1) % 7, first_day_of_week) 115 | first_date_of_week = today - timedelta(days=offset) 116 | sorted_days_of_week = _sort_days_of_week(days_of_week, first_day_of_week) 117 | 118 | # Loop the whole week to get the min gap between the two consecutive recurrences 119 | prev_occurrence = first_date_of_week + timedelta( 120 | days=_get_passed_week_days(sorted_days_of_week[0], first_day_of_week) 121 | ) 122 | min_gap = timedelta(days=DAYS_PER_WEEK) 123 | 124 | for day in sorted_days_of_week[1:]: 125 | date = first_date_of_week + timedelta(days=_get_passed_week_days(day, first_day_of_week)) 126 | if prev_occurrence is not None: 127 | current_gap = date - prev_occurrence 128 | min_gap = min(min_gap, current_gap) 129 | prev_occurrence = date 130 | 131 | if pattern.interval == 1: 132 | # It may cross weeks. Check the adjacent week 133 | date = first_date_of_week + timedelta( 134 | days=DAYS_PER_WEEK + _get_passed_week_days(sorted_days_of_week[0], first_day_of_week) 135 | ) 136 | 137 | current_gap = date - prev_occurrence 138 | min_gap = min(min_gap, current_gap) 139 | 140 | time_window_duration = end - start 141 | return min_gap >= time_window_duration 142 | 143 | 144 | def _get_passed_week_days(current_day: int, first_day_of_week: int) -> int: 145 | """ 146 | Get the number of days passed since the first day of the week. 147 | :param int current_day: The current day of the week, where Sunday == 0 ... Saturday == 6. 148 | :param int first_day_of_week: The first day of the week (0-6), where Sunday == 0 ... Saturday == 6. 149 | :return: The number of days passed since the first day of the week. 150 | :rtype: int 151 | """ 152 | return (current_day - first_day_of_week + DAYS_PER_WEEK) % DAYS_PER_WEEK 153 | 154 | 155 | def _sort_days_of_week(days_of_week: List[int], first_day_of_week: int) -> List[int]: 156 | sorted_days = sorted(days_of_week) 157 | if first_day_of_week in sorted_days: 158 | return sorted_days[sorted_days.index(first_day_of_week) :] + sorted_days[: sorted_days.index(first_day_of_week)] 159 | next_closest_day = first_day_of_week 160 | for i in range(7): 161 | if (first_day_of_week + i) % 7 in sorted_days: 162 | next_closest_day = (first_day_of_week + i) % 7 163 | break 164 | return sorted_days[sorted_days.index(next_closest_day) :] + sorted_days[: sorted_days.index(next_closest_day)] 165 | -------------------------------------------------------------------------------- /featuremanagement/_version.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | 7 | VERSION = "2.1.0" 8 | -------------------------------------------------------------------------------- /featuremanagement/aio/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from ._featuremanager import FeatureManager 7 | from ._featurefilters import FeatureFilter 8 | from ._defaultfilters import TimeWindowFilter, TargetingFilter 9 | 10 | __all__ = ["FeatureManager", "TimeWindowFilter", "TargetingFilter", "FeatureFilter"] 11 | -------------------------------------------------------------------------------- /featuremanagement/aio/_defaultfilters.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from typing import Mapping, Any 7 | from ._featurefilters import FeatureFilter 8 | from .._defaultfilters import ( 9 | TargetingFilter as SyncTargetingFilter, 10 | TimeWindowFilter as SyncTimeWindowFilter, 11 | ) 12 | 13 | 14 | @FeatureFilter.alias("Microsoft.TimeWindow") 15 | class TimeWindowFilter(FeatureFilter): 16 | """ 17 | Feature Filter that determines if the current time is within the time window. 18 | """ 19 | 20 | def __init__(self) -> None: 21 | self._filter = SyncTimeWindowFilter() 22 | 23 | async def evaluate(self, context: Mapping[Any, Any], **kwargs: Any) -> bool: 24 | """ 25 | Determine if the feature flag is enabled for the given context. 26 | 27 | :keyword Mapping context: Mapping with the Start and End time for the feature flag. 28 | :return: True if the current time is within the time window. 29 | :rtype: bool 30 | """ 31 | return self._filter.evaluate(context, **kwargs) 32 | 33 | 34 | @FeatureFilter.alias("Microsoft.Targeting") 35 | class TargetingFilter(FeatureFilter): 36 | """ 37 | Feature Filter that determines if the user is targeted for the feature flag. 38 | """ 39 | 40 | def __init__(self) -> None: 41 | self._filter = SyncTargetingFilter() 42 | 43 | async def evaluate(self, context: Mapping[Any, Any], **kwargs: Any) -> bool: 44 | """ 45 | Determine if the feature flag is enabled for the given context. 46 | 47 | :keyword Mapping context: Context for evaluating the user/group. 48 | :return: True if the user is targeted for the feature flag. 49 | :rtype: bool 50 | """ 51 | return self._filter.evaluate(context, **kwargs) 52 | -------------------------------------------------------------------------------- /featuremanagement/aio/_featurefilters.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from abc import ABC, abstractmethod 7 | from typing import Mapping, Callable, Any, Optional 8 | 9 | 10 | class FeatureFilter(ABC): 11 | """ 12 | Parent class for all async feature filters. 13 | """ 14 | 15 | _alias: Optional[str] = None 16 | 17 | @abstractmethod 18 | async def evaluate(self, context: Mapping[Any, Any], **kwargs: Any) -> bool: 19 | """ 20 | Determine if the feature flag is enabled for the given context. 21 | 22 | :param Mapping context: Context for the feature flag. 23 | """ 24 | 25 | @property 26 | def name(self) -> str: 27 | """ 28 | Get the name of the filter. 29 | 30 | :return: Name of the filter, or alias if it exists. 31 | :rtype: str 32 | """ 33 | if hasattr(self, "_alias") and self._alias: 34 | return self._alias 35 | return self.__class__.__name__ 36 | 37 | @staticmethod 38 | def alias(alias: str) -> Callable[..., Any]: 39 | """ 40 | Decorator to set the alias for the filter. 41 | 42 | :param str alias: Alias for the filter. 43 | :return: Decorator 44 | :rtype: Callable 45 | """ 46 | 47 | def wrapper(cls: "FeatureFilter") -> Any: 48 | cls._alias = alias # pylint: disable=protected-access 49 | return cls 50 | 51 | return wrapper 52 | -------------------------------------------------------------------------------- /featuremanagement/aio/_featuremanager.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | import inspect 7 | import logging 8 | from typing import cast, overload, Any, Optional, Dict, Mapping, List, Tuple 9 | from ._defaultfilters import TimeWindowFilter, TargetingFilter 10 | from ._featurefilters import FeatureFilter 11 | from .._models import EvaluationEvent, Variant, TargetingContext 12 | from .._featuremanagerbase import ( 13 | FeatureManagerBase, 14 | PROVIDED_FEATURE_FILTERS, 15 | REQUIREMENT_TYPE_ALL, 16 | FEATURE_FILTER_NAME, 17 | ) 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | class FeatureManager(FeatureManagerBase): 23 | """ 24 | Feature Manager that determines if a feature flag is enabled for the given context. 25 | 26 | :param Mapping configuration: Configuration object. 27 | :keyword list[FeatureFilter] feature_filters: Custom filters to be used for evaluating feature flags. 28 | :keyword Callable[EvaluationEvent] on_feature_evaluated: Callback function to be called when a feature flag is 29 | evaluated. 30 | :keyword Callable[[], TargetingContext] targeting_context_accessor: Callback function to get the current targeting 31 | context if one isn't provided. 32 | """ 33 | 34 | def __init__(self, configuration: Mapping[str, Any], **kwargs: Any): 35 | super().__init__(configuration, **kwargs) 36 | self._filters: Dict[str, FeatureFilter] = {} 37 | filters = [TimeWindowFilter(), TargetingFilter()] + cast( 38 | List[FeatureFilter], kwargs.pop(PROVIDED_FEATURE_FILTERS, []) 39 | ) 40 | 41 | for feature_filter in filters: 42 | if not isinstance(feature_filter, FeatureFilter): 43 | raise ValueError("Custom filter must be a subclass of FeatureFilter") 44 | self._filters[feature_filter.name] = feature_filter 45 | 46 | @overload # type: ignore 47 | async def is_enabled(self, feature_flag_id: str, user_id: str, **kwargs: Any) -> bool: 48 | """ 49 | Determine if the feature flag is enabled for the given context. 50 | 51 | :param str feature_flag_id: Name of the feature flag. 52 | :param str user_id: User identifier. 53 | :return: True if the feature flag is enabled for the given context. 54 | :rtype: bool 55 | """ 56 | 57 | async def is_enabled(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> bool: 58 | """ 59 | Determine if the feature flag is enabled for the given context. 60 | 61 | :param str feature_flag_id: Name of the feature flag. 62 | :return: True if the feature flag is enabled for the given context. 63 | :rtype: bool 64 | """ 65 | targeting_context: TargetingContext = await self._build_targeting_context_async(args) 66 | 67 | result = await self._check_feature(feature_flag_id, targeting_context, **kwargs) 68 | if ( 69 | self._on_feature_evaluated 70 | and result.feature 71 | and result.feature.telemetry.enabled 72 | and callable(self._on_feature_evaluated) 73 | ): 74 | result.user = targeting_context.user_id 75 | if inspect.iscoroutinefunction(self._on_feature_evaluated): 76 | await self._on_feature_evaluated(result) 77 | else: 78 | self._on_feature_evaluated(result) 79 | return result.enabled 80 | 81 | @overload # type: ignore 82 | async def get_variant(self, feature_flag_id: str, user_id: str, **kwargs: Any) -> Optional[Variant]: 83 | """ 84 | Determine the variant for the given context. 85 | 86 | :param str feature_flag_id: Name of the feature flag. 87 | :param str user_id: User identifier. 88 | :return: return: Variant instance. 89 | :rtype: Variant 90 | """ 91 | 92 | async def get_variant(self, feature_flag_id: str, *args: Any, **kwargs: Any) -> Optional[Variant]: 93 | """ 94 | Determine the variant for the given context. 95 | 96 | :param str feature_flag_id: Name of the feature flag 97 | :keyword TargetingContext targeting_context: Targeting context. 98 | :return: Variant instance. 99 | :rtype: Variant 100 | """ 101 | targeting_context: TargetingContext = await self._build_targeting_context_async(args) 102 | 103 | result = await self._check_feature(feature_flag_id, targeting_context, **kwargs) 104 | if ( 105 | self._on_feature_evaluated 106 | and result.feature 107 | and result.feature.telemetry.enabled 108 | and callable(self._on_feature_evaluated) 109 | ): 110 | result.user = targeting_context.user_id 111 | if inspect.iscoroutinefunction(self._on_feature_evaluated): 112 | await self._on_feature_evaluated(result) 113 | else: 114 | self._on_feature_evaluated(result) 115 | return result.variant 116 | 117 | async def _build_targeting_context_async(self, args: Tuple[Any]) -> TargetingContext: 118 | targeting_context = super()._build_targeting_context(args) 119 | if targeting_context: 120 | return targeting_context 121 | if not targeting_context and self._targeting_context_accessor and callable(self._targeting_context_accessor): 122 | 123 | if inspect.iscoroutinefunction(self._targeting_context_accessor): 124 | # If a targeting_context_accessor is provided, return the TargetingContext from it 125 | targeting_context = await self._targeting_context_accessor() 126 | else: 127 | targeting_context = self._targeting_context_accessor() 128 | if targeting_context and isinstance(targeting_context, TargetingContext): 129 | return targeting_context 130 | logger.warning( 131 | "targeting_context_accessor did not return a TargetingContext. Received type %s.", 132 | type(targeting_context), 133 | ) 134 | return TargetingContext() 135 | 136 | async def _check_feature_filters( 137 | self, evaluation_event: EvaluationEvent, targeting_context: TargetingContext, **kwargs: Any 138 | ) -> None: 139 | feature_flag = evaluation_event.feature 140 | if not feature_flag: 141 | return 142 | feature_conditions = feature_flag.conditions 143 | feature_filters = feature_conditions.client_filters 144 | 145 | if len(feature_filters) == 0: 146 | # Feature flags without any filters return evaluate 147 | evaluation_event.enabled = True 148 | else: 149 | # The assumed value is no filters is based on the requirement type. 150 | # Requirement type Any assumes false until proven true, All assumes true until proven false 151 | evaluation_event.enabled = feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL 152 | 153 | for feature_filter in feature_filters: 154 | filter_name = feature_filter[FEATURE_FILTER_NAME] 155 | kwargs["user"] = targeting_context.user_id 156 | kwargs["groups"] = targeting_context.groups 157 | if filter_name not in self._filters: 158 | raise ValueError(f"Feature flag {feature_flag.name} has unknown filter {filter_name}") 159 | if feature_conditions.requirement_type == REQUIREMENT_TYPE_ALL: 160 | if not await self._filters[filter_name].evaluate(feature_filter, **kwargs): 161 | evaluation_event.enabled = False 162 | break 163 | elif await self._filters[filter_name].evaluate(feature_filter, **kwargs): 164 | evaluation_event.enabled = True 165 | break 166 | 167 | async def _check_feature( 168 | self, feature_flag_id: str, targeting_context: TargetingContext, **kwargs: Any 169 | ) -> EvaluationEvent: 170 | """ 171 | Determine if the feature flag is enabled for the given context. 172 | 173 | :param str feature_flag_id: Name of the feature flag. 174 | :param TargetingContext targeting_context: Targeting context. 175 | :return: EvaluationEvent for the given context. 176 | :rtype: EvaluationEvent 177 | """ 178 | evaluation_event, done = super()._check_feature_base(feature_flag_id) 179 | 180 | if done: 181 | return evaluation_event 182 | 183 | await self._check_feature_filters(evaluation_event, targeting_context, **kwargs) 184 | 185 | self._assign_allocation(evaluation_event, targeting_context) 186 | return evaluation_event 187 | -------------------------------------------------------------------------------- /featuremanagement/azuremonitor/__init__.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from ._send_telemetry import publish_telemetry, track_event, TargetingSpanProcessor 7 | 8 | 9 | __all__ = [ 10 | "publish_telemetry", 11 | "track_event", 12 | "TargetingSpanProcessor", 13 | ] 14 | -------------------------------------------------------------------------------- /featuremanagement/azuremonitor/_send_telemetry.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # -------------------------------------------------------------------------- 6 | import logging 7 | import inspect 8 | from typing import Any, Callable, Dict, Optional 9 | from .._models import VariantAssignmentReason, EvaluationEvent, TargetingContext 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | try: 14 | from azure.monitor.events.extension import track_event as azure_monitor_track_event # type: ignore 15 | from opentelemetry.context.context import Context 16 | from opentelemetry.sdk.trace import Span, SpanProcessor 17 | 18 | HAS_AZURE_MONITOR_EVENTS_EXTENSION = True 19 | except ImportError: 20 | HAS_AZURE_MONITOR_EVENTS_EXTENSION = False 21 | logger.warning( 22 | "azure-monitor-events-extension is not installed. Telemetry will not be sent to Application Insights." 23 | ) 24 | SpanProcessor = object # type: ignore 25 | Span = object # type: ignore 26 | Context = object # type: ignore 27 | 28 | FEATURE_NAME = "FeatureName" 29 | ENABLED = "Enabled" 30 | TARGETING_ID = "TargetingId" 31 | VARIANT = "Variant" 32 | REASON = "VariantAssignmentReason" 33 | 34 | DEFAULT_WHEN_ENABLED = "DefaultWhenEnabled" 35 | VERSION = "Version" 36 | VARIANT_ASSIGNMENT_PERCENTAGE = "VariantAssignmentPercentage" 37 | MICROSOFT_TARGETING_ID = "Microsoft.TargetingId" 38 | 39 | EVENT_NAME = "FeatureEvaluation" 40 | 41 | EVALUATION_EVENT_VERSION = "1.0.0" 42 | 43 | 44 | def track_event(event_name: str, user: str, event_properties: Optional[Dict[str, Optional[str]]] = None) -> None: 45 | """ 46 | Tracks an event with the specified name and properties. 47 | 48 | :param str event_name: The name of the event. 49 | :param str user: The user ID to associate with the event. 50 | :param dict[str, str] event_properties: A dictionary of named string properties. 51 | """ 52 | if not HAS_AZURE_MONITOR_EVENTS_EXTENSION: 53 | return 54 | 55 | event_properties = event_properties or {} 56 | 57 | if user: 58 | event_properties[TARGETING_ID] = user 59 | 60 | azure_monitor_track_event(event_name, event_properties) 61 | 62 | 63 | def publish_telemetry(evaluation_event: EvaluationEvent) -> None: 64 | """ 65 | Publishes the telemetry for a feature's evaluation event. 66 | 67 | :param EvaluationEvent evaluation_event: The evaluation event to publish telemetry for. 68 | """ 69 | if not HAS_AZURE_MONITOR_EVENTS_EXTENSION: 70 | return 71 | 72 | feature = evaluation_event.feature 73 | 74 | if not feature: 75 | return 76 | 77 | event: Dict[str, Optional[str]] = { 78 | FEATURE_NAME: feature.name, 79 | ENABLED: str(evaluation_event.enabled), 80 | VERSION: EVALUATION_EVENT_VERSION, 81 | } 82 | 83 | reason = evaluation_event.reason 84 | variant = evaluation_event.variant 85 | 86 | event[REASON] = reason.value 87 | 88 | if variant: 89 | event[VARIANT] = variant.name 90 | 91 | # VariantAllocationPercentage 92 | allocation_percentage = 0 93 | if reason == VariantAssignmentReason.DEFAULT_WHEN_ENABLED: 94 | event[VARIANT_ASSIGNMENT_PERCENTAGE] = str(100) 95 | if feature.allocation: 96 | for allocation in feature.allocation.percentile: 97 | allocation_percentage += allocation.percentile_to - allocation.percentile_from 98 | event[VARIANT_ASSIGNMENT_PERCENTAGE] = str(100 - allocation_percentage) 99 | elif reason == VariantAssignmentReason.PERCENTILE: 100 | if feature.allocation and feature.allocation.percentile: 101 | for allocation in feature.allocation.percentile: 102 | if variant and allocation.variant == variant.name: 103 | allocation_percentage += allocation.percentile_to - allocation.percentile_from 104 | event[VARIANT_ASSIGNMENT_PERCENTAGE] = str(allocation_percentage) 105 | 106 | # DefaultWhenEnabled 107 | if feature.allocation and feature.allocation.default_when_enabled: 108 | event[DEFAULT_WHEN_ENABLED] = feature.allocation.default_when_enabled 109 | 110 | if feature.telemetry: 111 | for metadata_key, metadata_value in feature.telemetry.metadata.items(): 112 | if metadata_key not in event: 113 | event[metadata_key] = metadata_value 114 | 115 | track_event(EVENT_NAME, evaluation_event.user, event_properties=event) 116 | 117 | 118 | class TargetingSpanProcessor(SpanProcessor): 119 | """ 120 | A custom SpanProcessor that attaches the targeting ID to the span and baggage when a new span is started. 121 | :keyword Callable[[], TargetingContext] targeting_context_accessor: Callback function to get the current targeting 122 | context if one isn't provided. 123 | """ 124 | 125 | def __init__(self, **kwargs: Any) -> None: 126 | self._targeting_context_accessor: Optional[Callable[[], TargetingContext]] = kwargs.pop( 127 | "targeting_context_accessor", None 128 | ) 129 | 130 | def on_start(self, span: Span, parent_context: Optional[Context] = None) -> None: 131 | """ 132 | Attaches the targeting ID to the span and baggage when a new span is started. 133 | 134 | :param Span span: The span that was started. 135 | :param parent_context: The parent context of the span. 136 | """ 137 | if not HAS_AZURE_MONITOR_EVENTS_EXTENSION: 138 | logger.warning("Azure Monitor Events Extension is not installed.") 139 | return 140 | if self._targeting_context_accessor and callable(self._targeting_context_accessor): 141 | if inspect.iscoroutinefunction(self._targeting_context_accessor): 142 | logger.warning("Async targeting_context_accessor is not supported.") 143 | return 144 | targeting_context = self._targeting_context_accessor() 145 | if not targeting_context or not isinstance(targeting_context, TargetingContext): 146 | logger.warning( 147 | "targeting_context_accessor did not return a TargetingContext. Received type %s.", 148 | type(targeting_context), 149 | ) 150 | return 151 | if not targeting_context.user_id: 152 | logger.debug("TargetingContext does not have a user ID.") 153 | return 154 | span.set_attribute(TARGETING_ID, targeting_context.user_id) 155 | -------------------------------------------------------------------------------- /featuremanagement/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/FeatureManagement-Python/4a88b06efe493c7de95a9a29d82b72231397452a/featuremanagement/py.typed -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.8 3 | # Start off with these 4 | warn_unused_configs = True 5 | warn_redundant_casts = True 6 | warn_unused_ignores = True 7 | 8 | # Getting these passing should be easy 9 | strict_equality = True 10 | extra_checks = True 11 | 12 | # Strongly recommend enabling this one as soon as you can 13 | check_untyped_defs = True 14 | 15 | # These shouldn't be too much additional work, but may be tricky to 16 | # get passing if you use a lot of untyped libraries 17 | disallow_subclassing_any = True 18 | disallow_untyped_decorators = True 19 | disallow_any_generics = True 20 | 21 | # These next few are various gradations of forcing use of type annotations 22 | disallow_untyped_calls = True 23 | disallow_incomplete_defs = True 24 | disallow_untyped_defs = True 25 | 26 | # This one isn't too hard to get passing, but return on investment is lower 27 | no_implicit_reexport = True 28 | 29 | # This one can be tricky to get passing if you use a lot of untyped libraries 30 | warn_return_any = True 31 | 32 | -------------------------------------------------------------------------------- /project-words.txt: -------------------------------------------------------------------------------- 1 | Aiden 2 | APPCONFIGURATION 3 | appinsights 4 | azuremonitor 5 | caplog 6 | Cass 7 | featurefilters 8 | featureflag 9 | featuremanagement 10 | featuremanager 11 | featuremanagerbase 12 | quickstart 13 | rtype 14 | usefixtures 15 | urandom 16 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pytest.ini_options] 2 | pythonpath = [ 3 | "." 4 | ] 5 | 6 | [tool.black] 7 | line-length = 120 8 | 9 | [tool.pylint] 10 | max-line-length = 120 11 | min-public-methods = 1 12 | max-branches = 20 13 | max-returns = 7 14 | disable = ["missing-module-docstring", "duplicate-code"] 15 | 16 | [build-system] 17 | requires = ["setuptools>=61.0", "pylint", "pytest-asyncio", "mypy", "black"] 18 | build-backend = "setuptools.build_meta" 19 | 20 | [project] 21 | name = "FeatureManagement" 22 | version = "2.1.0" 23 | authors = [ 24 | { name="Microsoft Corporation", email="appconfig@microsoft.com" }, 25 | ] 26 | description = "A library for enabling/disabling features at runtime." 27 | readme = "README.md" 28 | license.file = "LICENSE" 29 | requires-python = ">=3.8" 30 | classifiers = [ 31 | "Development Status :: 5 - Production/Stable", 32 | "Programming Language :: Python", 33 | "Programming Language :: Python :: 3 :: Only", 34 | "Programming Language :: Python :: 3", 35 | "Programming Language :: Python :: 3.8", 36 | "Programming Language :: Python :: 3.9", 37 | "Programming Language :: Python :: 3.10", 38 | "Programming Language :: Python :: 3.11", 39 | "License :: OSI Approved :: MIT License", 40 | ] 41 | 42 | [project.urls] 43 | Homepage = "https://github.com/microsoft/FeatureManagement-Python" 44 | Issues = "https://github.com/microsoft/FeatureManagement-Python/issues" 45 | 46 | [project.optional-dependencies] 47 | AzureMonitor = ["azure-monitor-events-extension<2.0.0"] 48 | -------------------------------------------------------------------------------- /samples/feature_flag_sample.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | 7 | import json 8 | import os 9 | import sys 10 | from random_filter import RandomFilter 11 | from featuremanagement import FeatureManager, TargetingContext 12 | 13 | script_directory = os.path.dirname(os.path.abspath(sys.argv[0])) 14 | 15 | with open(script_directory + "/formatted_feature_flags.json", "r", encoding="utf-8") as f: 16 | feature_flags = json.load(f) 17 | 18 | feature_manager = FeatureManager(feature_flags, feature_filters=[RandomFilter()]) 19 | 20 | # Is always true 21 | print("Alpha is ", feature_manager.is_enabled("Alpha")) 22 | # Is always false 23 | print("Beta is ", feature_manager.is_enabled("Beta")) 24 | # Is false 50% of the time 25 | print("Gamma is ", feature_manager.is_enabled("Gamma")) 26 | # Is true between two dates 27 | print("Delta is ", feature_manager.is_enabled("Delta")) 28 | # Is true After 06-27-2023 29 | print("Sigma is ", feature_manager.is_enabled("Sigma")) 30 | # Is true Before 06-28-2023 31 | print("Epsilon is ", feature_manager.is_enabled("Epsilon")) 32 | # Target is true for Adam, group Stage 1, and 50% of users 33 | print("Target is ", feature_manager.is_enabled("Target", TargetingContext(user_id="Adam"))) 34 | print("Target is ", feature_manager.is_enabled("Target", TargetingContext(user_id="Brian"))) 35 | -------------------------------------------------------------------------------- /samples/feature_flag_with_azure_app_configuration_sample.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # -------------------------------------------------------------------------- 6 | 7 | from time import sleep 8 | import os 9 | from azure.appconfiguration.provider import load 10 | from random_filter import RandomFilter 11 | from featuremanagement import FeatureManager, TargetingContext 12 | 13 | connection_string = os.environ["APPCONFIGURATION_CONNECTION_STRING"] 14 | 15 | # Connecting to Azure App Configuration using AAD 16 | config = load(connection_string=connection_string, feature_flag_enabled=True, feature_flag_refresh_enabled=True) 17 | 18 | feature_manager = FeatureManager(config, feature_filters=[RandomFilter()]) 19 | 20 | 21 | def check_for_changes(): 22 | alpha = feature_manager.is_enabled("Alpha") 23 | # Is always true 24 | print("Alpha is ", alpha is True) 25 | while feature_manager.is_enabled("Alpha") == alpha: 26 | sleep(5) 27 | config.refresh() 28 | 29 | alpha = feature_manager.is_enabled("Alpha") 30 | print("Alpha is ", feature_manager.is_enabled("Alpha")) 31 | 32 | 33 | # Is always false 34 | print("Beta is ", feature_manager.is_enabled("Beta")) 35 | # Is false 50% of the time 36 | print("Gamma is ", feature_manager.is_enabled("Gamma")) 37 | # Is true between two dates 38 | print("Delta is ", feature_manager.is_enabled("Delta")) 39 | # Is true After 06-27-2023 40 | print("Sigma is ", feature_manager.is_enabled("Sigma")) 41 | # Is true Before 06-28-2023 42 | print("Epsilon is ", feature_manager.is_enabled("Epsilon")) 43 | # Target is true for Adam, group Stage 1, and 50% of users 44 | print("Target is ", feature_manager.is_enabled("Target", TargetingContext(user_id="Adam"))) 45 | print("Target is ", feature_manager.is_enabled("Target", TargetingContext(user_id="Brian"))) 46 | check_for_changes() 47 | -------------------------------------------------------------------------------- /samples/feature_variant_sample.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # -------------------------------------------------------------------------- 6 | 7 | import json 8 | import os 9 | import sys 10 | from random_filter import RandomFilter 11 | from featuremanagement import FeatureManager, TargetingContext 12 | 13 | 14 | script_directory = os.path.dirname(os.path.abspath(sys.argv[0])) 15 | 16 | with open(script_directory + "/formatted_feature_flags.json", "r", encoding="utf-8") as f: 17 | feature_flags = json.load(f) 18 | 19 | feature_manager = FeatureManager(feature_flags, feature_filters=[RandomFilter()]) 20 | 21 | print(feature_manager.is_enabled("TestVariants", TargetingContext(user_id="Adam"))) 22 | print(feature_manager.get_variant("TestVariants", TargetingContext(user_id="Adam")).configuration) 23 | 24 | print(feature_manager.is_enabled("TestVariants", TargetingContext(user_id="Cass"))) 25 | print(feature_manager.get_variant("TestVariants", TargetingContext(user_id="Cass")).configuration) 26 | -------------------------------------------------------------------------------- /samples/feature_variant_sample_with_targeting_accessor.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # -------------------------------------------------------------------------- 6 | 7 | import json 8 | import os 9 | import sys 10 | from random_filter import RandomFilter 11 | from featuremanagement import FeatureManager, TargetingContext 12 | 13 | 14 | script_directory = os.path.dirname(os.path.abspath(sys.argv[0])) 15 | 16 | with open(script_directory + "/formatted_feature_flags.json", "r", encoding="utf-8") as f: 17 | feature_flags = json.load(f) 18 | 19 | USER_ID = "Adam" 20 | 21 | 22 | def my_targeting_accessor() -> TargetingContext: 23 | return TargetingContext(user_id=USER_ID) 24 | 25 | 26 | feature_manager = FeatureManager( 27 | feature_flags, feature_filters=[RandomFilter()], targeting_context_accessor=my_targeting_accessor 28 | ) 29 | 30 | print(feature_manager.is_enabled("TestVariants")) 31 | print(feature_manager.get_variant("TestVariants").configuration) 32 | 33 | USER_ID = "Ellie" 34 | 35 | print(feature_manager.is_enabled("TestVariants")) 36 | print(feature_manager.get_variant("TestVariants").configuration) 37 | -------------------------------------------------------------------------------- /samples/feature_variant_sample_with_telemetry.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # -------------------------------------------------------------------------- 6 | import json 7 | import os 8 | import sys 9 | from random_filter import RandomFilter 10 | from featuremanagement import FeatureManager 11 | from featuremanagement.azuremonitor import publish_telemetry, track_event 12 | 13 | 14 | try: 15 | from azure.monitor.opentelemetry import configure_azure_monitor 16 | 17 | # Configure Azure Monitor 18 | configure_azure_monitor(connection_string=os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING")) 19 | except ImportError: 20 | pass 21 | 22 | script_directory = os.path.dirname(os.path.abspath(sys.argv[0])) 23 | 24 | with open(script_directory + "/formatted_feature_flags.json", "r", encoding="utf-8") as f: 25 | feature_flags = json.load(f) 26 | 27 | # Initialize the feature manager with telemetry callback 28 | feature_manager = FeatureManager( 29 | feature_flags, feature_filters=[RandomFilter()], on_feature_evaluated=publish_telemetry 30 | ) 31 | 32 | # Evaluate the feature flag for the user 33 | print(feature_manager.get_variant("TestVariants", "Adam").configuration) 34 | 35 | # Track an event 36 | track_event("TestEvent", "Adam") 37 | -------------------------------------------------------------------------------- /samples/formatted_feature_flags.json: -------------------------------------------------------------------------------- 1 | { 2 | "feature_management": { 3 | "feature_flags": [ 4 | { 5 | "id": "Alpha", 6 | "description": "", 7 | "enabled": "true", 8 | "conditions": { 9 | "client_filters": [] 10 | } 11 | }, 12 | { 13 | "id": "Beta", 14 | "description": "", 15 | "enabled": "false", 16 | "conditions": { 17 | "client_filters": [] 18 | } 19 | }, 20 | { 21 | "id": "Gamma", 22 | "description": "", 23 | "enabled": "true", 24 | "conditions": { 25 | "client_filters": [ 26 | { 27 | "name": "Sample.Random", 28 | "parameters": { 29 | "Value": 50 30 | } 31 | } 32 | ] 33 | } 34 | }, 35 | { 36 | "id": "Delta", 37 | "description": "", 38 | "enabled": "true", 39 | "conditions": { 40 | "client_filters": [ 41 | { 42 | "name": "Microsoft.TimeWindow", 43 | "parameters": { 44 | "Start": "Thu, 29 Jun 2023 07:00:00 GMT", 45 | "End": "Wed, 30 Aug 2023 07:00:00 GMT" 46 | } 47 | } 48 | ] 49 | } 50 | }, 51 | { 52 | "id": "Sigma", 53 | "description": "", 54 | "enabled": "true", 55 | "conditions": { 56 | "client_filters": [ 57 | { 58 | "name": "Microsoft.TimeWindow", 59 | "parameters": { 60 | "Start": "Tue, 27 Jun 2023 06:00:00 GMT", 61 | "End": "Wen, 28 Jun 2023 06:05:00 GMT" 62 | } 63 | } 64 | ] 65 | } 66 | }, 67 | { 68 | "id": "Epsilon", 69 | "description": "", 70 | "enabled": "true", 71 | "conditions": { 72 | "client_filters": [ 73 | { 74 | "name": "Microsoft.TimeWindow", 75 | "parameters": { 76 | "Start": "Tue, 27 Jun 2023 06:00:00 GMT" 77 | } 78 | } 79 | ] 80 | } 81 | }, 82 | { 83 | "id": "Zeta", 84 | "description": "", 85 | "enabled": "true", 86 | "conditions": { 87 | "client_filters": [ 88 | { 89 | "name": "Microsoft.TimeWindow", 90 | "parameters": { 91 | "End": "Tue, 28 Jun 2024 06:00:00 GMT" 92 | } 93 | } 94 | ] 95 | } 96 | }, 97 | { 98 | "id": "Target", 99 | "description": "", 100 | "enabled": "true", 101 | "conditions": { 102 | "client_filters": [ 103 | { 104 | "name": "Microsoft.Targeting", 105 | "parameters": { 106 | "Audience": { 107 | "Users": [ 108 | "Adam" 109 | ], 110 | "Groups": [ 111 | { 112 | "Name": "Stage1", 113 | "RolloutPercentage": 100 114 | } 115 | ], 116 | "DefaultRolloutPercentage": 50, 117 | "Exclusion": { 118 | "Users": [], 119 | "Groups": [] 120 | } 121 | } 122 | } 123 | } 124 | ] 125 | } 126 | }, 127 | { 128 | "id": "Override_True", 129 | "description": "", 130 | "enabled": "true", 131 | "conditions": { 132 | "client_filters": [] 133 | }, 134 | "allocation": { 135 | "default_when_enabled": "True_Override" 136 | }, 137 | "variants": [ 138 | { 139 | "name": "True_Override", 140 | "status_override": "False" 141 | } 142 | ] 143 | 144 | }, 145 | { 146 | "id": "Override_False", 147 | "description": "", 148 | "enabled": "false", 149 | "conditions": { 150 | "client_filters": [] 151 | }, 152 | "allocation": { 153 | "default_when_disabled": "False_Override" 154 | }, 155 | "variants": [ 156 | { 157 | "name": "False_Override", 158 | "status_override": "True" 159 | } 160 | ] 161 | 162 | }, 163 | { 164 | "id": "TestVariants", 165 | "description": "", 166 | "enabled": "true", 167 | "telemetry": { 168 | "enabled": "true", 169 | "metadata": { 170 | "etag": "my-fake-etag" 171 | } 172 | }, 173 | "conditions": { 174 | "client_filters": [ 175 | { 176 | "name": "Microsoft.Targeting", 177 | "parameters": { 178 | "Audience": { 179 | "Users": [], 180 | "Groups": [], 181 | "DefaultRolloutPercentage": 100, 182 | "Exclusion": { 183 | "Users": [], 184 | "Groups": [] 185 | } 186 | } 187 | } 188 | } 189 | ] 190 | }, 191 | "allocation": { 192 | "users": [ 193 | { 194 | "variant": "True_Override", 195 | "users": [ 196 | "Adam" 197 | ] 198 | } 199 | ], 200 | "percentile": [ 201 | { 202 | "variant": "True_Override", 203 | "from": 0, 204 | "to": 50 205 | }, 206 | { 207 | "variant": "False_Override", 208 | "from": 50, 209 | "to": 100 210 | } 211 | ] 212 | }, 213 | "variants": [ 214 | { 215 | "name": "True_Override", 216 | "configuration_value": "The Variant True_Override overrides to False", 217 | "status_override": "False" 218 | }, 219 | { 220 | "name": "False_Override", 221 | "configuration_value": "The Variant False_Override overrides to True", 222 | "status_override": "True" 223 | } 224 | ] 225 | 226 | } 227 | ] 228 | }, 229 | "false-override": "The Variant False_Override overrides to True" 230 | } -------------------------------------------------------------------------------- /samples/quarty_sample.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | 7 | import uuid 8 | import os 9 | from quart import Quart, request, session 10 | from quart.sessions import SecureCookieSessionInterface 11 | from azure.appconfiguration.provider import load 12 | from azure.identity import DefaultAzureCredential 13 | from azure.monitor.opentelemetry import configure_azure_monitor 14 | from featuremanagement.aio import FeatureManager 15 | from featuremanagement import TargetingContext 16 | from featuremanagement.azuremonitor import TargetingSpanProcessor 17 | 18 | 19 | # A callback for assigning a TargetingContext for both Telemetry logs and Feature Flag evaluation 20 | async def my_targeting_accessor() -> TargetingContext: 21 | session_id = "" 22 | if "Session-ID" in request.headers: 23 | session_id = request.headers["Session-ID"] 24 | return TargetingContext(user_id=session_id) 25 | 26 | 27 | # Configure Azure Monitor 28 | configure_azure_monitor( 29 | connection_string=os.getenv("APPLICATIONINSIGHTS_CONNECTION_STRING"), 30 | span_processors=[TargetingSpanProcessor(targeting_context_accessor=my_targeting_accessor)], 31 | ) 32 | 33 | app = Quart(__name__) 34 | app.session_interface = SecureCookieSessionInterface() 35 | app.secret_key = os.urandom(24) 36 | 37 | endpoint = os.environ.get("APPCONFIGURATION_ENDPOINT_STRING") 38 | credential = DefaultAzureCredential() 39 | 40 | # Connecting to Azure App Configuration using AAD 41 | config = load(endpoint=endpoint, credential=credential, feature_flag_enabled=True, feature_flag_refresh_enabled=True) 42 | 43 | # Load feature flags and set up targeting context accessor 44 | feature_manager = FeatureManager(config, targeting_context_accessor=my_targeting_accessor) 45 | 46 | 47 | @app.before_request 48 | async def before_request(): 49 | if "session_id" not in session: 50 | session["session_id"] = str(uuid.uuid4()) # Generate a new session ID 51 | request.headers["Session-ID"] = session["session_id"] 52 | 53 | 54 | @app.route("/") 55 | async def hello(): 56 | variant = await feature_manager.get_variant("Message") 57 | return str(variant.configuration if variant else "No variant found") 58 | 59 | 60 | app.run() 61 | -------------------------------------------------------------------------------- /samples/random_filter.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | import random 7 | from featuremanagement import FeatureFilter 8 | 9 | 10 | @FeatureFilter.alias("Sample.Random") 11 | class RandomFilter(FeatureFilter): 12 | """ 13 | A sample feature filter that enables the feature for a random percentage of users. 14 | """ 15 | 16 | def evaluate(self, context, **kwargs): 17 | """Determine if the feature flag is enabled for the given context""" 18 | value = context.get("parameters", {}).get("Value", 0) 19 | if value < random.randint(0, 100): 20 | return True 21 | return False 22 | -------------------------------------------------------------------------------- /samples/requirements.txt: -------------------------------------------------------------------------------- 1 | featuremanagement 2 | azure-appconfiguration-provider 3 | azure-monitor-opentelemetry 4 | azure-monitor-events-extension 5 | quart 6 | azure-identity 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # ------------------------------------------------------------------------- 4 | # Copyright (c) Microsoft Corporation. All rights reserved. 5 | # Licensed under the MIT License. See License.txt in the project root for 6 | # license information. 7 | # -------------------------------------------------------------------------- 8 | 9 | import re 10 | import os.path 11 | from io import open 12 | from setuptools import find_packages, setup 13 | 14 | # Change the PACKAGE_NAME only to change folder and different name 15 | PACKAGE_NAME = "featuremanagement" 16 | PACKAGE_PPRINT_NAME = "Feature Management" 17 | 18 | # a-b-c => a/b/c 19 | package_folder_path = PACKAGE_NAME.replace("-", "/") 20 | # a-b-c => a.b.c 21 | namespace_name = PACKAGE_NAME.replace("-", ".") 22 | 23 | # Version extraction inspired from 'requests' 24 | with open(os.path.join(package_folder_path, "_version.py"), "r") as fd: 25 | version = re.search(r'^VERSION\s*=\s*[\'"]([^\'"]*)[\'"]', fd.read(), re.MULTILINE).group(1) 26 | 27 | if not version: 28 | raise RuntimeError("Cannot find version information") 29 | 30 | with open("README.md", encoding="utf-8") as f: 31 | readme = f.read() 32 | with open("CHANGELOG.md", encoding="utf-8") as f: 33 | changelog = f.read() 34 | 35 | setup( 36 | name=PACKAGE_NAME, 37 | version=version, 38 | include_package_data=True, 39 | description="Microsoft {} Library for Python".format(PACKAGE_PPRINT_NAME), 40 | long_description=readme + "\n\n" + changelog, 41 | long_description_content_type="text/markdown", 42 | author="Microsoft Corporation", 43 | author_email="appconfig@microsoft.com", 44 | url="https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/appconfiguration/feature-management", 45 | classifiers=[ 46 | "Development Status :: 5 - Production/Stable", 47 | "Programming Language :: Python", 48 | "Programming Language :: Python :: 3 :: Only", 49 | "Programming Language :: Python :: 3", 50 | "Programming Language :: Python :: 3.7", 51 | "Programming Language :: Python :: 3.8", 52 | "Programming Language :: Python :: 3.9", 53 | "Programming Language :: Python :: 3.10", 54 | "Programming Language :: Python :: 3.11", 55 | "License :: OSI Approved :: MIT License", 56 | ], 57 | zip_safe=False, 58 | packages=find_packages(), 59 | python_requires=">=3.6", 60 | install_requires=[], 61 | extras_require={ 62 | "AzureMonitor": ["azure-monitor-events-extension<2.0.0"], 63 | }, 64 | ) 65 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/FeatureManagement-Python/4a88b06efe493c7de95a9a29d82b72231397452a/tests/__init__.py -------------------------------------------------------------------------------- /tests/requirements.txt: -------------------------------------------------------------------------------- 1 | azure-monitor-opentelemetry 2 | azure-monitor-events-extension -------------------------------------------------------------------------------- /tests/test_feature_manager.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # -------------------------------------------------------------------------- 6 | import unittest 7 | import pytest 8 | from featuremanagement import FeatureManager, FeatureFilter 9 | 10 | 11 | class TestFeatureManager(unittest.TestCase): 12 | 13 | def __init__(self, methodName="runTest"): 14 | super().__init__(methodName=methodName) 15 | self.called_telemetry = False 16 | 17 | # method: feature_manager_creation 18 | def test_empty_feature_manager_creation(self): 19 | feature_manager = FeatureManager({}) 20 | assert feature_manager is not None 21 | assert not feature_manager.is_enabled("Alpha") 22 | 23 | # method: feature_manager_creation 24 | def test_basic_feature_manager_creation(self): 25 | feature_flags = { 26 | "feature_management": { 27 | "feature_flags": [ 28 | {"id": "Alpha", "description": "", "enabled": "true", "conditions": {"client_filters": []}}, 29 | {"id": "Beta", "description": "", "enabled": "false", "conditions": {"client_filters": []}}, 30 | ] 31 | } 32 | } 33 | feature_manager = FeatureManager(feature_flags) 34 | assert feature_manager is not None 35 | assert feature_manager.is_enabled("Alpha") 36 | assert not feature_manager.is_enabled("Beta") 37 | 38 | # method: feature_manager_creation 39 | def test_feature_manager_creation_invalid_feature_filter(self): 40 | feature_flags = {"feature_management": {"feature_flags": []}} 41 | with self.assertRaises(ValueError): 42 | FeatureManager(feature_flags, feature_filters=["invalid_filter"]) 43 | 44 | # method: feature_manager_creation 45 | def test_feature_manager_creation_with_filters(self): 46 | feature_flags = { 47 | "feature_management": { 48 | "feature_flags": [ 49 | { 50 | "id": "Alpha", 51 | "description": "", 52 | "enabled": "true", 53 | "conditions": {"client_filters": [{"name": "AlwaysOn", "parameters": {}}]}, 54 | }, 55 | { 56 | "id": "Beta", 57 | "description": "", 58 | "enabled": "false", 59 | "conditions": {"client_filters": [{"name": "AlwaysOn", "parameters": {}}]}, 60 | }, 61 | { 62 | "id": "Gamma", 63 | "description": "", 64 | "enabled": "True", 65 | "conditions": {"client_filters": [{"name": "AlwaysOff", "parameters": {}}]}, 66 | }, 67 | { 68 | "id": "Delta", 69 | "description": "", 70 | "enabled": "False", 71 | "conditions": {"client_filters": [{"name": "AlwaysOff", "parameters": {}}]}, 72 | }, 73 | ] 74 | } 75 | } 76 | feature_manager = FeatureManager(feature_flags, feature_filters=[AlwaysOn(), AlwaysOff()]) 77 | assert feature_manager is not None 78 | assert len(feature_manager._filters) == 4 # pylint: disable=protected-access 79 | assert feature_manager.is_enabled("Alpha") 80 | assert not feature_manager.is_enabled("Beta") 81 | assert not feature_manager.is_enabled("Gamma") 82 | assert not feature_manager.is_enabled("Delta") 83 | assert not feature_manager.is_enabled("Epsilon") 84 | 85 | # method: feature_manager_creation 86 | def test_feature_manager_creation_with_override_default(self): 87 | feature_manager = FeatureManager({}, feature_filters=[AlwaysOn(), AlwaysOff(), FakeTimeWindowFilter()]) 88 | assert feature_manager is not None 89 | 90 | # The fake time window should override the default one 91 | assert len(feature_manager._filters) == 4 # pylint: disable=protected-access 92 | 93 | # method: list_feature_flags 94 | def test_list_feature_flags(self): 95 | feature_manager = FeatureManager({}) 96 | assert feature_manager is not None 97 | assert len(feature_manager.list_feature_flag_names()) == 0 98 | 99 | feature_flags = { 100 | "feature_management": { 101 | "feature_flags": [ 102 | {"id": "Alpha", "description": "", "enabled": "true", "conditions": {"client_filters": []}}, 103 | {"id": "Beta", "description": "", "enabled": "false", "conditions": {"client_filters": []}}, 104 | ] 105 | } 106 | } 107 | feature_manager = FeatureManager(feature_flags) 108 | assert feature_manager is not None 109 | assert feature_manager.is_enabled("Alpha") 110 | assert not feature_manager.is_enabled("Beta") 111 | assert len(feature_manager.list_feature_flag_names()) == 2 112 | 113 | # method: is_enabled 114 | def test_unknown_feature_filter(self): 115 | feature_flags = { 116 | "feature_management": { 117 | "feature_flags": [ 118 | { 119 | "id": "Alpha", 120 | "description": "", 121 | "enabled": "true", 122 | "conditions": {"client_filters": [{"name": "UnknownFilter", "parameters": {}}]}, 123 | }, 124 | ] 125 | } 126 | } 127 | feature_manager = FeatureManager(feature_flags, feature_filters=[AlwaysOn(), AlwaysOff()]) 128 | assert feature_manager is not None 129 | with pytest.raises(ValueError) as e_info: 130 | feature_manager.is_enabled("Alpha") 131 | assert e_info.type == ValueError 132 | assert e_info.value.args[0] == "Feature flag Alpha has unknown filter UnknownFilter" 133 | 134 | # method: feature_manager_creation 135 | def test_feature_with_telemetry(self): 136 | self.called_telemetry = False 137 | feature_flags = { 138 | "feature_management": { 139 | "feature_flags": [ 140 | {"id": "Alpha", "description": "", "enabled": "true", "telemetry": {"enabled": "true"}}, 141 | ] 142 | } 143 | } 144 | feature_manager = FeatureManager(feature_flags, on_feature_evaluated=self.fake_telemetry_callback) 145 | assert feature_manager is not None 146 | assert feature_manager.is_enabled("Alpha") 147 | assert self.called_telemetry 148 | 149 | def fake_telemetry_callback(self, evaluation_event): 150 | assert evaluation_event 151 | self.called_telemetry = True 152 | 153 | 154 | class AlwaysOn(FeatureFilter): 155 | def evaluate(self, context, **kwargs): 156 | return True 157 | 158 | 159 | class AlwaysOff(FeatureFilter): 160 | def evaluate(self, context, **kwargs): 161 | return False 162 | 163 | 164 | @FeatureFilter.alias("Microsoft.TimeWindow") 165 | class FakeTimeWindowFilter(FeatureFilter): 166 | def evaluate(self, context, **kwargs): 167 | return True 168 | -------------------------------------------------------------------------------- /tests/test_feature_manager_async.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # -------------------------------------------------------------------------- 6 | import unittest 7 | import pytest 8 | from featuremanagement.aio import FeatureManager, FeatureFilter 9 | 10 | 11 | class TestFeatureManager(unittest.IsolatedAsyncioTestCase): 12 | 13 | def __init__(self, methodName="runTest"): 14 | super().__init__(methodName=methodName) 15 | self.called_telemetry = False 16 | 17 | # method: feature_manager_creation 18 | @pytest.mark.asyncio 19 | async def test_empty_feature_manager_creation(self): 20 | feature_manager = FeatureManager({}) 21 | assert feature_manager is not None 22 | assert not await feature_manager.is_enabled("Alpha") 23 | 24 | # method: feature_manager_creation 25 | @pytest.mark.asyncio 26 | async def test_basic_feature_manager_creation(self): 27 | feature_flags = { 28 | "feature_management": { 29 | "feature_flags": [ 30 | {"id": "Alpha", "description": "", "enabled": "true", "conditions": {"client_filters": []}}, 31 | {"id": "Beta", "description": "", "enabled": "false", "conditions": {"client_filters": []}}, 32 | ] 33 | } 34 | } 35 | 36 | feature_manager = FeatureManager(feature_flags) 37 | assert feature_manager is not None 38 | assert await feature_manager.is_enabled("Alpha") 39 | assert not await feature_manager.is_enabled("Beta") 40 | 41 | # method: feature_manager_creation 42 | @pytest.mark.asyncio 43 | def test_feature_manager_creation_invalid_feature_filter(self): 44 | feature_flags = {"feature_management": {"feature_flags": []}} 45 | with self.assertRaises(ValueError): 46 | FeatureManager(feature_flags, feature_filters=["invalid_filter"]) 47 | 48 | # method: feature_manager_creation 49 | @pytest.mark.asyncio 50 | async def test_feature_manager_creation_with_filters(self): 51 | feature_flags = { 52 | "feature_management": { 53 | "feature_flags": [ 54 | { 55 | "id": "Alpha", 56 | "description": "", 57 | "enabled": "true", 58 | "conditions": {"client_filters": [{"name": "AlwaysOn", "parameters": {}}]}, 59 | }, 60 | { 61 | "id": "Beta", 62 | "description": "", 63 | "enabled": "false", 64 | "conditions": {"client_filters": [{"name": "AlwaysOn", "parameters": {}}]}, 65 | }, 66 | { 67 | "id": "Gamma", 68 | "description": "", 69 | "enabled": "True", 70 | "conditions": {"client_filters": [{"name": "AlwaysOff", "parameters": {}}]}, 71 | }, 72 | { 73 | "id": "Delta", 74 | "description": "", 75 | "enabled": "False", 76 | "conditions": {"client_filters": [{"name": "AlwaysOff", "parameters": {}}]}, 77 | }, 78 | ] 79 | } 80 | } 81 | feature_manager = FeatureManager(feature_flags, feature_filters=[AlwaysOn(), AlwaysOff()]) 82 | assert feature_manager is not None 83 | assert len(feature_manager._filters) == 4 # pylint: disable=protected-access 84 | assert await feature_manager.is_enabled("Alpha") 85 | assert not await feature_manager.is_enabled("Beta") 86 | assert not await feature_manager.is_enabled("Gamma") 87 | assert not await feature_manager.is_enabled("Delta") 88 | assert not await feature_manager.is_enabled("Epsilon") 89 | 90 | # method: feature_manager_creation 91 | @pytest.mark.asyncio 92 | async def test_feature_manager_creation_with_override_default(self): 93 | feature_manager = FeatureManager({}, feature_filters=[AlwaysOn(), AlwaysOff(), FakeTimeWindowFilter()]) 94 | assert feature_manager is not None 95 | 96 | # The fake time window should override the default one 97 | assert len(feature_manager._filters) == 4 # pylint: disable=protected-access 98 | 99 | # method: list_feature_flags 100 | @pytest.mark.asyncio 101 | async def test_list_feature_flags(self): 102 | feature_manager = FeatureManager({}) 103 | assert feature_manager is not None 104 | assert len(feature_manager.list_feature_flag_names()) == 0 105 | 106 | feature_flags = { 107 | "feature_management": { 108 | "feature_flags": [ 109 | {"id": "Alpha", "description": "", "enabled": "true", "conditions": {"client_filters": []}}, 110 | {"id": "Beta", "description": "", "enabled": "false", "conditions": {"client_filters": []}}, 111 | ] 112 | } 113 | } 114 | feature_manager = FeatureManager(feature_flags) 115 | assert feature_manager is not None 116 | assert await feature_manager.is_enabled("Alpha") 117 | assert not await feature_manager.is_enabled("Beta") 118 | assert len(feature_manager.list_feature_flag_names()) == 2 119 | 120 | # method: is_enabled 121 | @pytest.mark.asyncio 122 | async def test_unknown_feature_filter(self): 123 | feature_flags = { 124 | "feature_management": { 125 | "feature_flags": [ 126 | { 127 | "id": "Alpha", 128 | "description": "", 129 | "enabled": "true", 130 | "conditions": {"client_filters": [{"name": "UnknownFilter", "parameters": {}}]}, 131 | }, 132 | ] 133 | } 134 | } 135 | feature_manager = FeatureManager(feature_flags, feature_filters=[AlwaysOn(), AlwaysOff()]) 136 | assert feature_manager is not None 137 | with pytest.raises(ValueError) as e_info: 138 | await feature_manager.is_enabled("Alpha") 139 | assert e_info.type == ValueError 140 | assert e_info.value.args[0] == "Feature flag Alpha has unknown filter UnknownFilter" 141 | 142 | # method: feature_manager_creation 143 | @pytest.mark.asyncio 144 | async def test_feature_with_telemetry(self): 145 | self.called_telemetry = False 146 | feature_flags = { 147 | "feature_management": { 148 | "feature_flags": [ 149 | {"id": "Alpha", "description": "", "enabled": "true", "telemetry": {"enabled": "true"}}, 150 | ] 151 | } 152 | } 153 | feature_manager = FeatureManager(feature_flags, on_feature_evaluated=self.fake_telemetry_callback) 154 | assert feature_manager is not None 155 | assert await feature_manager.is_enabled("Alpha") 156 | assert self.called_telemetry 157 | 158 | # method: feature_manager_creation 159 | @pytest.mark.asyncio 160 | async def test_feature_with_telemetry_async(self): 161 | self.called_telemetry = False 162 | feature_flags = { 163 | "feature_management": { 164 | "feature_flags": [ 165 | {"id": "Alpha", "description": "", "enabled": "true", "telemetry": {"enabled": "true"}}, 166 | ] 167 | } 168 | } 169 | feature_manager = FeatureManager(feature_flags, on_feature_evaluated=self.fake_telemetry_callback_async) 170 | assert feature_manager is not None 171 | assert await feature_manager.is_enabled("Alpha") 172 | assert self.called_telemetry 173 | 174 | def fake_telemetry_callback(self, evaluation_event): 175 | assert evaluation_event 176 | self.called_telemetry = True 177 | 178 | async def fake_telemetry_callback_async(self, evaluation_event): 179 | assert evaluation_event 180 | self.called_telemetry = True 181 | 182 | 183 | class AlwaysOn(FeatureFilter): 184 | async def evaluate(self, context, **kwargs): 185 | return True 186 | 187 | 188 | class AlwaysOff(FeatureFilter): 189 | async def evaluate(self, context, **kwargs): 190 | return False 191 | 192 | 193 | @FeatureFilter.alias("Microsoft.TimeWindow") 194 | class FakeTimeWindowFilter(FeatureFilter): 195 | async def evaluate(self, context, **kwargs): 196 | return True 197 | -------------------------------------------------------------------------------- /tests/test_feature_manager_refresh.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # -------------------------------------------------------------------------- 6 | import pytest 7 | from featuremanagement import FeatureManager 8 | from featuremanagement.aio import FeatureManager as AsyncFeatureManager 9 | 10 | 11 | class TestFeatureManagerRefresh: 12 | # method: feature_manager_creation 13 | def test_refresh(self): 14 | feature_flags = { 15 | "feature_management": { 16 | "feature_flags": [ 17 | {"id": "Alpha", "description": "", "enabled": "true", "conditions": {"client_filters": []}}, 18 | ] 19 | } 20 | } 21 | feature_manager = FeatureManager(feature_flags) 22 | assert feature_manager is not None 23 | assert feature_manager.is_enabled("Alpha") 24 | assert "Alpha" in feature_manager._cache # pylint: disable=protected-access 25 | assert feature_manager.is_enabled("Alpha") # test cache 26 | assert "Alpha" in feature_manager._cache # pylint: disable=protected-access 27 | 28 | feature_flags["feature_management"] = { 29 | "feature_flags": [ 30 | {"id": "Alpha", "description": "", "enabled": "false", "conditions": {"client_filters": []}}, 31 | ] 32 | } 33 | 34 | assert not feature_manager.is_enabled("Beta") # resets cache 35 | assert "Alpha" not in feature_manager._cache # pylint: disable=protected-access 36 | assert not feature_manager.is_enabled("Alpha") 37 | assert "Alpha" in feature_manager._cache # pylint: disable=protected-access 38 | 39 | # method: feature_manager_creation 40 | @pytest.mark.asyncio 41 | async def test_refresh_async(self): 42 | feature_flags = { 43 | "feature_management": { 44 | "feature_flags": [ 45 | {"id": "Alpha", "description": "", "enabled": "true", "conditions": {"client_filters": []}}, 46 | ] 47 | } 48 | } 49 | feature_manager = AsyncFeatureManager(feature_flags) 50 | assert feature_manager is not None 51 | assert await feature_manager.is_enabled("Alpha") 52 | assert "Alpha" in feature_manager._cache # pylint: disable=protected-access 53 | assert await feature_manager.is_enabled("Alpha") # test cache 54 | assert "Alpha" in feature_manager._cache # pylint: disable=protected-access 55 | 56 | feature_flags["feature_management"] = { 57 | "feature_flags": [ 58 | {"id": "Alpha", "description": "", "enabled": "false", "conditions": {"client_filters": []}}, 59 | ] 60 | } 61 | 62 | assert not await feature_manager.is_enabled("Beta") # resets cache 63 | assert "Alpha" not in feature_manager._cache # pylint: disable=protected-access 64 | assert not await feature_manager.is_enabled("Alpha") 65 | assert "Alpha" in feature_manager._cache # pylint: disable=protected-access 66 | -------------------------------------------------------------------------------- /tests/time_window_filter/test_recurrence_evaluator.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from datetime import datetime 7 | import pytest 8 | from featuremanagement._time_window_filter._recurrence_evaluator import is_match 9 | from featuremanagement._time_window_filter._models import TimeWindowFilterSettings, Recurrence 10 | 11 | 12 | def test_is_match_within_time_window(): 13 | start = datetime(2025, 4, 7, 9, 0, 0) 14 | end = datetime(2025, 4, 7, 17, 0, 0) 15 | now = datetime(2025, 4, 8, 10, 0, 0) 16 | 17 | recurrence = Recurrence( 18 | { 19 | "Pattern": {"Type": "Daily", "Interval": 1}, 20 | "Range": {"Type": "NoEnd"}, 21 | } 22 | ) 23 | 24 | settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) 25 | 26 | assert is_match(settings, now) is True 27 | 28 | 29 | def test_is_match_outside_time_window(): 30 | start = datetime(2025, 4, 7, 9, 0, 0) 31 | end = datetime(2025, 4, 7, 17, 0, 0) 32 | now = datetime(2025, 4, 7, 18, 0, 0) 33 | 34 | recurrence = Recurrence( 35 | { 36 | "Pattern": {"Type": "Daily", "Interval": 1}, 37 | "Range": {"Type": "NoEnd"}, 38 | } 39 | ) 40 | 41 | settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) 42 | 43 | assert is_match(settings, now) is False 44 | 45 | 46 | def test_is_match_no_previous_occurrence(): 47 | start = datetime(2025, 4, 7, 9, 0, 0) 48 | end = datetime(2025, 4, 7, 17, 0, 0) 49 | now = datetime(2025, 4, 6, 10, 0, 0) # Before the start time 50 | 51 | recurrence = Recurrence( 52 | { 53 | "Pattern": {"Type": "Daily", "Interval": 1}, 54 | "Range": {"Type": "NoEnd"}, 55 | } 56 | ) 57 | 58 | settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) 59 | 60 | assert is_match(settings, now) is False 61 | 62 | 63 | def test_is_match_no_recurrence(): 64 | start = datetime(2025, 4, 7, 9, 0, 0) 65 | end = datetime(2025, 4, 7, 17, 0, 0) 66 | now = datetime(2025, 4, 7, 10, 0, 0) 67 | 68 | settings = TimeWindowFilterSettings(start=start, end=end, recurrence=None) 69 | 70 | with pytest.raises(ValueError, match="Required parameter: Recurrence"): 71 | is_match(settings, now) 72 | 73 | 74 | def test_is_match_missing_start(): 75 | end = datetime(2025, 4, 7, 17, 0, 0) 76 | now = datetime(2025, 4, 7, 10, 0, 0) 77 | 78 | recurrence = Recurrence( 79 | { 80 | "Pattern": {"Type": "Daily", "Interval": 1}, 81 | "Range": {"Type": "NoEnd"}, 82 | } 83 | ) 84 | 85 | settings = TimeWindowFilterSettings(start=None, end=end, recurrence=recurrence) 86 | 87 | with pytest.raises(ValueError, match="Required parameter: Start or End"): 88 | is_match(settings, now) 89 | 90 | 91 | def test_is_match_missing_end(): 92 | start = datetime(2025, 4, 7, 9, 0, 0) 93 | now = datetime(2025, 4, 7, 10, 0, 0) 94 | 95 | recurrence = Recurrence( 96 | { 97 | "Pattern": {"Type": "Daily", "Interval": 1}, 98 | "Range": {"Type": "NoEnd"}, 99 | } 100 | ) 101 | 102 | settings = TimeWindowFilterSettings(start=start, end=None, recurrence=recurrence) 103 | 104 | with pytest.raises(ValueError, match="Required parameter: Start or End"): 105 | is_match(settings, now) 106 | 107 | 108 | def test_is_match_weekly_recurrence(): 109 | start = datetime(2025, 4, 7, 9, 0, 0) # Monday 110 | end = datetime(2025, 4, 7, 17, 0, 0) # Monday 111 | now = datetime(2025, 4, 14, 10, 0, 0) # Next Monday 112 | 113 | recurrence = Recurrence( 114 | { 115 | "Pattern": {"Type": "Weekly", "Interval": 1, "DaysOfWeek": ["Monday"], "FirstDayOfWeek": "Monday"}, 116 | "Range": {"Type": "NoEnd"}, 117 | } 118 | ) 119 | 120 | settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) 121 | 122 | assert is_match(settings, now) is True 123 | 124 | 125 | def test_is_match_end_date_has_passed(): 126 | start = datetime(2025, 4, 7, 9, 0, 0) 127 | end = datetime(2025, 4, 7, 17, 0, 0) 128 | now = datetime(2025, 4, 9, 10, 0, 0) # After the end date 129 | 130 | recurrence = Recurrence( 131 | { 132 | "Pattern": {"Type": "Daily", "Interval": 1}, 133 | "Range": {"Type": "EndDate", "EndDate": "Tue, 8 Apr 2025 10:00:00"}, 134 | } 135 | ) 136 | 137 | settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) 138 | 139 | assert is_match(settings, now) is False 140 | 141 | 142 | def test_is_match_numbered_recurrence(): 143 | start = datetime(2025, 4, 7, 9, 0, 0) 144 | end = datetime(2025, 4, 7, 17, 0, 0) 145 | now = datetime(2025, 4, 8, 10, 0, 0) 146 | 147 | recurrence = Recurrence( 148 | { 149 | "Pattern": {"Type": "Daily", "Interval": 1}, 150 | "Range": {"Type": "Numbered", "NumberOfOccurrences": 2}, 151 | } 152 | ) 153 | 154 | settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) 155 | 156 | assert is_match(settings, now) is True 157 | now = datetime(2025, 4, 15, 10, 0, 0) 158 | assert is_match(settings, now) is False 159 | 160 | 161 | def test_is_match_weekly_recurrence_with_occurrences_single_day(): 162 | start = datetime(2025, 4, 7, 9, 0, 0) # Monday 163 | end = datetime(2025, 4, 7, 17, 0, 0) # Monday 164 | 165 | recurrence = Recurrence( 166 | { 167 | "Pattern": { 168 | "Type": "Weekly", 169 | "Interval": 2, 170 | "DaysOfWeek": ["Monday"], 171 | "FirstDayOfWeek": "Monday", 172 | }, 173 | "Range": {"Type": "Numbered", "NumberOfOccurrences": 2}, 174 | } 175 | ) 176 | 177 | settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) 178 | 179 | # First occurrence should match 180 | assert is_match(settings, datetime(2025, 4, 7, 10, 0, 0)) is True 181 | 182 | # Second week occurrence shouldn't match 183 | assert is_match(settings, datetime(2025, 4, 14, 10, 0, 0)) is False 184 | 185 | # Third week occurrence should match 186 | assert is_match(settings, datetime(2025, 4, 21, 10, 0, 0)) is True 187 | 188 | # Fourth week occurrence shouldn't match 189 | assert is_match(settings, datetime(2025, 4, 28, 10, 0, 0)) is False 190 | 191 | # Fifth week occurrence shouldn't match, passed the range 192 | assert is_match(settings, datetime(2025, 5, 5, 10, 0, 0)) is False 193 | 194 | 195 | def test_is_match_weekly_recurrence_with_occurrences_multi_day(): 196 | start = datetime(2025, 4, 7, 9, 0, 0) # Monday 197 | end = datetime(2025, 4, 7, 17, 0, 0) # Monday 198 | 199 | recurrence = Recurrence( 200 | { 201 | "Pattern": { 202 | "Type": "Weekly", 203 | "Interval": 2, 204 | "DaysOfWeek": ["Monday", "Tuesday"], 205 | "FirstDayOfWeek": "Monday", 206 | }, 207 | "Range": {"Type": "Numbered", "NumberOfOccurrences": 4}, 208 | } 209 | ) 210 | 211 | settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) 212 | 213 | # Before the start time, should not match 214 | assert is_match(settings, datetime(2025, 4, 7, 8, 0, 0)) is False # Monday 215 | 216 | # First occurrence should match 217 | assert is_match(settings, datetime(2025, 4, 7, 10, 0, 0)) is True # Monday 218 | assert is_match(settings, datetime(2025, 4, 8, 10, 0, 0)) is True # Tuesday 219 | 220 | # Second week occurrence shouldn't match 221 | assert is_match(settings, datetime(2025, 4, 14, 10, 0, 0)) is False # Monday 222 | assert is_match(settings, datetime(2025, 4, 15, 10, 0, 0)) is False # Tuesday 223 | 224 | # Third week occurrence should match 225 | assert is_match(settings, datetime(2025, 4, 21, 10, 0, 0)) is True # Monday 226 | assert is_match(settings, datetime(2025, 4, 22, 10, 0, 0)) is True # Tuesday 227 | 228 | # Fourth week occurrence shouldn't match 229 | assert is_match(settings, datetime(2025, 4, 28, 10, 0, 0)) is False # Monday 230 | assert is_match(settings, datetime(2025, 4, 29, 10, 0, 0)) is False # Tuesday 231 | 232 | # Fifth week occurrence shouldn't match 233 | assert is_match(settings, datetime(2025, 5, 5, 10, 0, 0)) is False # Monday 234 | assert is_match(settings, datetime(2025, 5, 6, 10, 0, 0)) is False # Tuesday 235 | 236 | 237 | def test_weekly_recurrence_start_after_min_offset(): 238 | start = datetime(2025, 4, 9, 9, 0, 0) # Monday 239 | end = datetime(2025, 4, 9, 17, 0, 0) # Monday 240 | now = datetime(2025, 4, 12, 10, 0, 0) # Saturday 241 | 242 | recurrence = Recurrence( 243 | { 244 | "Pattern": { 245 | "Type": "Weekly", 246 | "Interval": 1, 247 | "DaysOfWeek": ["Monday", "Wednesday"], 248 | "FirstDayOfWeek": "Monday", 249 | }, 250 | "Range": {"Type": "NoEnd"}, 251 | } 252 | ) 253 | 254 | settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) 255 | 256 | # Verify that the main method is_match correctly handles the scenario 257 | assert is_match(settings, now) is False 258 | assert is_match(settings, start) is True 259 | 260 | 261 | def test_weekly_recurrence_now_before_min_offset(): 262 | start = datetime(2025, 4, 9, 9, 0, 0) # Monday 263 | end = datetime(2025, 4, 9, 17, 0, 0) # Monday 264 | now = datetime(2025, 4, 16, 8, 0, 0) # Saturday 265 | 266 | recurrence = Recurrence( 267 | { 268 | "Pattern": { 269 | "Type": "Weekly", 270 | "Interval": 1, 271 | "DaysOfWeek": ["Wednesday", "Friday"], 272 | "FirstDayOfWeek": "Monday", 273 | }, 274 | "Range": {"Type": "NoEnd"}, 275 | } 276 | ) 277 | 278 | settings = TimeWindowFilterSettings(start=start, end=end, recurrence=recurrence) 279 | 280 | # Verify that the main method is_match correctly handles the scenario 281 | assert is_match(settings, now) is False 282 | -------------------------------------------------------------------------------- /tests/time_window_filter/test_time_window_filter_models.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------ 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # ------------------------------------------------------------------------- 6 | from featuremanagement._time_window_filter._models import ( 7 | RecurrencePatternType, 8 | RecurrenceRangeType, 9 | RecurrencePattern, 10 | RecurrenceRange, 11 | Recurrence, 12 | ) 13 | from datetime import datetime 14 | import math 15 | 16 | 17 | def test_recurrence_pattern_type(): 18 | assert RecurrencePatternType.from_str("Daily") == RecurrencePatternType.DAILY 19 | assert RecurrencePatternType.from_str("Weekly") == RecurrencePatternType.WEEKLY 20 | try: 21 | RecurrencePatternType.from_str("Invalid") 22 | except ValueError as e: 23 | assert str(e) == "Invalid value: Invalid" 24 | 25 | 26 | def test_recurrence_range_type(): 27 | assert RecurrenceRangeType.from_str("NoEnd") == RecurrenceRangeType.NO_END 28 | assert RecurrenceRangeType.from_str("EndDate") == RecurrenceRangeType.END_DATE 29 | assert RecurrenceRangeType.from_str("Numbered") == RecurrenceRangeType.NUMBERED 30 | try: 31 | RecurrenceRangeType.from_str("Invalid") 32 | except ValueError as e: 33 | assert str(e) == "Invalid value: Invalid" 34 | 35 | 36 | def test_recurrence_pattern(): 37 | pattern = RecurrencePattern({"Type": "Daily", "Interval": 1, "DaysOfWeek": ["Monday", "Tuesday"]}) 38 | assert pattern.type == RecurrencePatternType.DAILY 39 | assert pattern.interval == 1 40 | assert pattern.days_of_week == [0, 1] 41 | assert pattern.first_day_of_week == 6 42 | 43 | pattern = RecurrencePattern( 44 | {"Type": "Daily", "Interval": 1, "DaysOfWeek": ["Monday", "Tuesday"], "FirstDayOfWeek": "Monday"} 45 | ) 46 | assert pattern.type == RecurrencePatternType.DAILY 47 | assert pattern.interval == 1 48 | assert pattern.days_of_week == [0, 1] 49 | assert pattern.first_day_of_week == 0 50 | 51 | try: 52 | pattern = RecurrencePattern( 53 | {"Type": "Daily", "Interval": 1, "DaysOfWeek": ["Monday", "Tuesday"], "FirstDayOfWeek": "Thor's day"} 54 | ) 55 | except ValueError as e: 56 | assert str(e) == "Invalid value for FirstDayOfWeek: Thor's day" 57 | 58 | pattern = RecurrencePattern({"Type": "Weekly", "Interval": 2, "DaysOfWeek": ["Wednesday"]}) 59 | assert pattern.type == RecurrencePatternType.WEEKLY 60 | assert pattern.interval == 2 61 | assert pattern.days_of_week == [2] 62 | assert pattern.first_day_of_week == 6 63 | 64 | try: 65 | pattern = RecurrencePattern({"Type": "Daily", "Interval": 0, "DaysOfWeek": ["Monday", "Tuesday"]}) 66 | except ValueError as e: 67 | assert str(e) == "The interval must be greater than 0." 68 | 69 | try: 70 | pattern = RecurrencePattern({"Type": "Daily", "Interval": 1, "DaysOfWeek": ["Monday", "Thor's day"]}) 71 | except ValueError as e: 72 | assert str(e) == "Invalid value for DaysOfWeek: Thor's day" 73 | 74 | 75 | def test_recurrence_range(): 76 | max_occurrences = 2**63 - 1 77 | 78 | range = RecurrenceRange({"Type": "NoEnd"}) 79 | assert range.type == RecurrenceRangeType.NO_END 80 | assert range.end_date is None 81 | assert range.num_of_occurrences == max_occurrences 82 | 83 | range = RecurrenceRange({"Type": "EndDate", "EndDate": "Mon, 31 Mar 2025 00:00:00"}) 84 | assert range.type == RecurrenceRangeType.END_DATE 85 | assert range.end_date == datetime(2025, 3, 31, 0, 0, 0) 86 | assert range.num_of_occurrences == max_occurrences 87 | 88 | range = RecurrenceRange({"Type": "Numbered", "NumberOfOccurrences": 10}) 89 | assert range.type == RecurrenceRangeType.NUMBERED 90 | assert range.end_date is None 91 | assert range.num_of_occurrences == 10 92 | 93 | try: 94 | range = RecurrenceRange({"Type": "NoEnd", "NumberOfOccurrences": -1}) 95 | except ValueError as e: 96 | assert str(e) == "The number of occurrences must be greater than 0." 97 | 98 | try: 99 | range = RecurrenceRange({"Type": "NoEnd", "NumberOfOccurrences": 0}) 100 | except ValueError as e: 101 | assert str(e) == "The number of occurrences must be greater than 0." 102 | 103 | try: 104 | range = RecurrenceRange({"Type": "EndDate", "EndDate": "InvalidDate"}) 105 | except ValueError as e: 106 | assert str(e) == "Invalid value for EndDate: InvalidDate" 107 | 108 | 109 | def test_recurrence(): 110 | recurrence = Recurrence( 111 | {"Pattern": {"Type": "Daily", "Interval": 1, "DaysOfWeek": ["Monday"]}, "Range": {"Type": "NoEnd"}} 112 | ) 113 | assert recurrence.pattern.type == RecurrencePatternType.DAILY 114 | assert recurrence.range.type == RecurrenceRangeType.NO_END 115 | 116 | try: 117 | recurrence = Recurrence({"Pattern": {"Type": "Invalid"}, "Range": {"Type": "NoEnd"}}) 118 | except ValueError as e: 119 | assert str(e) == "Invalid value: Invalid" 120 | 121 | try: 122 | recurrence = Recurrence( 123 | {"Pattern": {"Type": "Daily", "Interval": 0, "DaysOfWeek": ["Monday"]}, "Range": {"Type": "NoEnd"}} 124 | ) 125 | except ValueError as e: 126 | assert str(e) == "The interval must be greater than 0." 127 | 128 | try: 129 | recurrence = Recurrence( 130 | {"Pattern": {"Type": "Daily", "Interval": 1, "DaysOfWeek": ["Invalid"]}, "Range": {"Type": "NoEnd"}} 131 | ) 132 | except ValueError as e: 133 | assert str(e) == "Invalid value for DaysOfWeek: Invalid" 134 | -------------------------------------------------------------------------------- /tests/validation_tests/NoFilters.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "feature_management": { 3 | "feature_flags": [ 4 | { 5 | "id": "BooleanTrue", 6 | "description": "A feature flag with no Filters, that returns true.", 7 | "enabled": true, 8 | "conditions": { 9 | "client_filters": [] 10 | } 11 | }, 12 | { 13 | "id": "BooleanFalse", 14 | "description": "A feature flag with no Filters, that returns false.", 15 | "enabled": false, 16 | "conditions": { 17 | "client_filters": [] 18 | } 19 | }, 20 | { 21 | "id": "StringTrue", 22 | "description": "A feature flag with no Filters where enabled is a string, that returns true.", 23 | "enabled": "true", 24 | "conditions": { 25 | "client_filters": [] 26 | } 27 | }, 28 | { 29 | "id": "StringFalse", 30 | "description": "A feature flag with no Filters where enabled is a string, that returns false.", 31 | "enabled": "false", 32 | "conditions": { 33 | "client_filters": [] 34 | } 35 | }, 36 | { 37 | "id": "InvalidEnabled", 38 | "description": "A feature flag with an invalid 'enabled' value, that returns false.", 39 | "enabled": "invalid", 40 | "conditions": { 41 | "client_filters": [] 42 | } 43 | }, 44 | { 45 | "id": "Minimal", 46 | "enabled": true 47 | }, 48 | { 49 | "id": "NoEnabled" 50 | }, 51 | { 52 | "id": "EmptyConditions", 53 | "description": "A feature flag with no values in conditions, that returns true.", 54 | "enabled": true, 55 | "conditions": { 56 | } 57 | } 58 | ] 59 | } 60 | } -------------------------------------------------------------------------------- /tests/validation_tests/NoFilters.tests.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "FeatureFlagName": "BooleanTrue", 4 | "Inputs": {}, 5 | "IsEnabled": { 6 | "Result": "true" 7 | }, 8 | "Variant": { 9 | "Result": null 10 | }, 11 | "Description": "An Enabled Feature Flag with no Filters." 12 | }, 13 | { 14 | "FeatureFlagName": "BooleanFalse", 15 | "Inputs": {}, 16 | "IsEnabled": { 17 | "Result": "false" 18 | }, 19 | "Variant": { 20 | "Result": null 21 | }, 22 | "Description": "A Disabled Feature Flag with no Filters." 23 | }, 24 | { 25 | "FeatureFlagName": "StringTrue", 26 | "Inputs": {}, 27 | "IsEnabled": { 28 | "Result": "true" 29 | }, 30 | "Variant": { 31 | "Result": null 32 | }, 33 | "Description": "An Enabled Feature Flag with no Filters." 34 | }, 35 | { 36 | "FeatureFlagName": "StringFalse", 37 | "Inputs": {}, 38 | "IsEnabled": { 39 | "Result": "false" 40 | }, 41 | "Variant": { 42 | "Result": null 43 | }, 44 | "Description": "A Disabled Feature Flag with no Filters." 45 | }, 46 | { 47 | "FeatureFlagName": "InvalidEnabled", 48 | "Inputs": {}, 49 | "IsEnabled": { 50 | "Exception": "Invalid setting 'enabled' with value 'invalid' for feature 'InvalidEnabled'." 51 | }, 52 | "Variant": { 53 | "Result": null 54 | }, 55 | "Description": "A Feature Flag with an invalid Enabled Value." 56 | }, 57 | { 58 | "FeatureFlagName": "Minimal", 59 | "Inputs": {}, 60 | "IsEnabled": { 61 | "Result": "true" 62 | }, 63 | "Variant": { 64 | "Result": null 65 | }, 66 | "Description": "A Feature Flag with just a key and enabled." 67 | }, 68 | { 69 | "FeatureFlagName": "NoEnabled", 70 | "Inputs": {}, 71 | "IsEnabled": { 72 | "Result": "false" 73 | }, 74 | "Variant": { 75 | "Result": null 76 | }, 77 | "Description": "Validates that the default value of enabled is False." 78 | }, 79 | { 80 | "FeatureFlagName": "EmptyConditions", 81 | "Inputs": {}, 82 | "IsEnabled": { 83 | "Result": "true" 84 | }, 85 | "Variant": { 86 | "Result": null 87 | }, 88 | "Description": "A feature flag with no Conditions, returns true as it's enabled." 89 | } 90 | ] -------------------------------------------------------------------------------- /tests/validation_tests/RequirementType.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "feature_management": { 3 | "feature_flags": [ 4 | { 5 | "id": "DefaultRequirementTypeFirstFilterPassed", 6 | "description": "A feature flag that has multiple filters, but doesn't specify any requirement type, which is the default. Will always return true.", 7 | "enabled": true, 8 | "conditions": { 9 | "client_filters": [ 10 | { 11 | "name": "Microsoft.TimeWindow", 12 | "parameters": { 13 | "Start": "Tue, 27 Jun 2023 06:00:00 GMT" 14 | } 15 | }, 16 | { 17 | "name": "Microsoft.TimeWindow", 18 | "parameters": { 19 | "Start": "Thu, 29 Jun 2023 07:00:00 GMT", 20 | "End": "Wed, 30 Aug 2023 07:00:00 GMT" 21 | } 22 | } 23 | ] 24 | } 25 | }, 26 | { 27 | "id": "DefaultRequirementTypeLastFilterPassed", 28 | "description": "Same as DefaultRequirementTypeFirstFilterPassed, but filter order is switched. Will always return true.", 29 | "enabled": true, 30 | "conditions": { 31 | "client_filters": [ 32 | { 33 | "name": "Microsoft.TimeWindow", 34 | "parameters": { 35 | "Start": "Thu, 29 Jun 2023 07:00:00 GMT", 36 | "End": "Wed, 30 Aug 2023 07:00:00 GMT" 37 | } 38 | }, 39 | { 40 | "name": "Microsoft.TimeWindow", 41 | "parameters": { 42 | "Start": "Tue, 27 Jun 2023 06:00:00 GMT" 43 | } 44 | } 45 | ] 46 | } 47 | }, 48 | { 49 | "id": "RequirementTypeAnyFirstFilterPassed", 50 | "description": "Same as DefaultRequirementTypeFirstFilterPassed, but requirement type is specified. Will always return true.", 51 | "enabled": true, 52 | "conditions": { 53 | "client_filters": [ 54 | { 55 | "name": "Microsoft.TimeWindow", 56 | "parameters": { 57 | "Start": "Tue, 27 Jun 2023 06:00:00 GMT" 58 | } 59 | }, 60 | { 61 | "name": "Microsoft.TimeWindow", 62 | "parameters": { 63 | "Start": "Thu, 29 Jun 2023 07:00:00 GMT", 64 | "End": "Wed, 30 Aug 2023 07:00:00 GMT" 65 | } 66 | } 67 | ], 68 | "requirement_type": "Any" 69 | } 70 | }, 71 | { 72 | "id": "RequirementTypeAnyLastFilterPassed", 73 | "description": "Same as DefaultRequirementTypeLastFilterPassed, but requirement type is specified. Will always return true.", 74 | "enabled": true, 75 | "conditions": { 76 | "client_filters": [ 77 | { 78 | "name": "Microsoft.TimeWindow", 79 | "parameters": { 80 | "Start": "Thu, 29 Jun 2023 07:00:00 GMT", 81 | "End": "Wed, 30 Aug 2023 07:00:00 GMT" 82 | } 83 | }, 84 | { 85 | "name": "Microsoft.TimeWindow", 86 | "parameters": { 87 | "Start": "Tue, 27 Jun 2023 06:00:00 GMT" 88 | } 89 | } 90 | ], 91 | "requirement_type": "Any" 92 | } 93 | }, 94 | { 95 | "id": "RequirementTypeAllPassed", 96 | "description": "Requirement type All. Will always return true.", 97 | "enabled": true, 98 | "conditions": { 99 | "client_filters": [ 100 | { 101 | "name": "Microsoft.Targeting", 102 | "parameters": { 103 | "Audience": { 104 | "DefaultRolloutPercentage": 100 105 | } 106 | } 107 | }, 108 | { 109 | "name": "Microsoft.TimeWindow", 110 | "parameters": { 111 | "Start": "Tue, 27 Jun 2023 06:00:00 GMT" 112 | } 113 | } 114 | ], 115 | "requirement_type": "All" 116 | } 117 | }, 118 | { 119 | "id": "RequirementTypeAllLastFilterFailed", 120 | "description": "Requirement type All. Will always return false.", 121 | "enabled": true, 122 | "conditions": { 123 | "client_filters": [ 124 | { 125 | "name": "Microsoft.Targeting", 126 | "parameters": { 127 | "Audience": { 128 | "DefaultRolloutPercentage": 100 129 | } 130 | } 131 | }, 132 | { 133 | "name": "Microsoft.TimeWindow", 134 | "parameters": { 135 | "End": "Tue, 27 Jun 2023 06:00:00 GMT" 136 | } 137 | } 138 | ], 139 | "requirement_type": "All" 140 | } 141 | } 142 | ] 143 | } 144 | } -------------------------------------------------------------------------------- /tests/validation_tests/RequirementType.tests.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "FeatureFlagName": "DefaultRequirementTypeFirstFilterPassed", 4 | "Inputs": {}, 5 | "IsEnabled": { 6 | "Result": "true" 7 | }, 8 | "Variant": { 9 | "Result": null 10 | }, 11 | "Description": "Feature Flag with two feature filters, first returns true, so it's enabled." 12 | }, 13 | { 14 | "FeatureFlagName": "DefaultRequirementTypeLastFilterPassed", 15 | "Inputs": {}, 16 | "IsEnabled": { 17 | "Result": "true" 18 | }, 19 | "Variant": { 20 | "Result": null 21 | }, 22 | "Description": "Feature Flag with two feature filters, second returns true, so it's enabled." 23 | }, 24 | { 25 | "FeatureFlagName": "RequirementTypeAnyFirstFilterPassed", 26 | "Inputs": {}, 27 | "IsEnabled": { 28 | "Result": "true" 29 | }, 30 | "Variant": { 31 | "Result": null 32 | }, 33 | "Description": "Feature Flag with two feature filters and requirement type specified as Any. Second filter returns true." 34 | }, 35 | { 36 | "FeatureFlagName": "RequirementTypeAnyLastFilterPassed", 37 | "Inputs": {}, 38 | "IsEnabled": { 39 | "Result": "true" 40 | }, 41 | "Variant": { 42 | "Result": null 43 | }, 44 | "Description": "Feature Flag with two feature filters and requirement type specified as Any. Neither filter returns true." 45 | }, 46 | { 47 | "FeatureFlagName": "RequirementTypeAllPassed", 48 | "Inputs": {"user":"Adam"}, 49 | "IsEnabled": { 50 | "Result": "true" 51 | }, 52 | "Variant": { 53 | "Result": null 54 | }, 55 | "Description": "Feature Flag with two feature filters and requirement type specified as All. Both filters return true." 56 | }, 57 | { 58 | "FeatureFlagName": "RequirementTypeAllLastFilterFailed", 59 | "Inputs": {"user":"Adam"}, 60 | "IsEnabled": { 61 | "Result": "false" 62 | }, 63 | "Variant": { 64 | "Result": null 65 | }, 66 | "Description": "Feature Flag with two feature filters and requirement type specified as All. Only the first filter returns true." 67 | } 68 | ] 69 | -------------------------------------------------------------------------------- /tests/validation_tests/TargetingFilter.modified.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "feature_management": { 3 | "feature_flags": [ 4 | { 5 | "id": "ComplexTargeting", 6 | "description": "A feature flag using a targeting filter, that will return true for Alice, Stage1, and 50% of Stage2, and false for Dave and Stage3. The default rollout percentage is 25%.", 7 | "enabled": true, 8 | "conditions": { 9 | "client_filters": [ 10 | { 11 | "name": "Microsoft.Targeting", 12 | "parameters": { 13 | "Audience": { 14 | "Users": [ 15 | "Alice" 16 | ], 17 | "Groups": [ 18 | { 19 | "Name": "Stage1", 20 | "RolloutPercentage": 100 21 | }, 22 | { 23 | "Name": "Stage2", 24 | "RolloutPercentage": 50 25 | } 26 | ], 27 | "DefaultRolloutPercentage": 25, 28 | "Exclusion": { 29 | "Users": ["Dave"], 30 | "Groups": ["Stage3"] 31 | } 32 | } 33 | } 34 | } 35 | ] 36 | } 37 | }, 38 | { 39 | "id": "RolloutPercentageUpdate", 40 | "description": "A feature flag using a targeting filter, that will return true 62% of the time.", 41 | "enabled": true, 42 | "conditions": { 43 | "client_filters": [ 44 | { 45 | "name": "Microsoft.Targeting", 46 | "parameters": { 47 | "Audience": { 48 | "Users": [], 49 | "Groups": [], 50 | "DefaultRolloutPercentage": 62, 51 | "Exclusion": {} 52 | } 53 | } 54 | } 55 | ] 56 | } 57 | } 58 | ] 59 | } 60 | } -------------------------------------------------------------------------------- /tests/validation_tests/TargetingFilter.modified.tests.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "FriendlyName": "Aiden62", 4 | "FeatureFlagName": "RolloutPercentageUpdate", 5 | "Inputs": {"user":"Aiden"}, 6 | "IsEnabled": { 7 | "Result": "true" 8 | }, 9 | "Variant": { 10 | "Result": null 11 | }, 12 | "Description": "Targeting Filter, 62% default rollout Aiden is part of it." 13 | }, 14 | { 15 | "FriendlyName": "Aiden62 - Stage1", 16 | "FeatureFlagName": "RolloutPercentageUpdate", 17 | "Inputs": {"user":"Aiden", "groups":["Stage1"]}, 18 | "IsEnabled": { 19 | "Result": "true" 20 | }, 21 | "Variant": { 22 | "Result": null 23 | }, 24 | "Description": "Targeting Filter, group is not part of default rollout calculation, no change." 25 | }, 26 | { 27 | "FriendlyName": "Aiden62 - Stage2", 28 | "FeatureFlagName": "RolloutPercentageUpdate", 29 | "Inputs": {"user":"Aiden", "groups":["Stage2"]}, 30 | "IsEnabled": { 31 | "Result": "true" 32 | }, 33 | "Variant": { 34 | "Result": null 35 | }, 36 | "Description": "Targeting Filter, group is not part of default rollout calculation, no change." 37 | }, 38 | { 39 | "FriendlyName": "Aiden62 - Stage3", 40 | "FeatureFlagName": "RolloutPercentageUpdate", 41 | "Inputs": {"user":"Aiden", "groups":["Stage3"]}, 42 | "IsEnabled": { 43 | "Result": "true" 44 | }, 45 | "Variant": { 46 | "Result": null 47 | }, 48 | "Description": "Targeting Filter, group is not part of default rollout calculation, no change." 49 | }, 50 | { 51 | "FriendlyName": "Brittney62", 52 | "FeatureFlagName": "RolloutPercentageUpdate", 53 | "Inputs": {"user":"Brittney"}, 54 | "IsEnabled": { 55 | "Result": "true" 56 | }, 57 | "Variant": { 58 | "Result": null 59 | }, 60 | "Description": "Targeting Filter, 62% default rollout Brittney is part of it." 61 | }, 62 | { 63 | "FriendlyName": "Brittney62 - Stage1", 64 | "FeatureFlagName": "RolloutPercentageUpdate", 65 | "Inputs": {"user":"Brittney", "groups":["Stage1"]}, 66 | "IsEnabled": { 67 | "Result": "true" 68 | }, 69 | "Variant": { 70 | "Result": null 71 | }, 72 | "Description": "Targeting Filter, group is not part of default rollout calculation, no change." 73 | }, 74 | { 75 | "FriendlyName": "Brittney62 - Stage2", 76 | "FeatureFlagName": "RolloutPercentageUpdate", 77 | "Inputs": {"user":"Brittney", "groups":["Stage2"]}, 78 | "IsEnabled": { 79 | "Result": "true" 80 | }, 81 | "Variant": { 82 | "Result": null 83 | }, 84 | "Description": "Targeting Filter, group is not part of default rollout calculation, no change." 85 | }, 86 | { 87 | "FriendlyName": "Brittney62 - Stage3", 88 | "FeatureFlagName": "RolloutPercentageUpdate", 89 | "Inputs": {"user":"Brittney", "groups":["Stage3"]}, 90 | "IsEnabled": { 91 | "Result": "true" 92 | }, 93 | "Variant": { 94 | "Result": null 95 | }, 96 | "Description": "Targeting Filter, group is not part of default rollout calculation, no change." 97 | } 98 | ] -------------------------------------------------------------------------------- /tests/validation_tests/TargetingFilter.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "feature_management": { 3 | "feature_flags": [ 4 | { 5 | "id": "ComplexTargeting", 6 | "description": "A feature flag using a targeting filter, that will return true for Alice, Stage1, and 50% of Stage2. Dave and Stage3 are excluded. The default rollout percentage is 25%.", 7 | "enabled": true, 8 | "conditions": { 9 | "client_filters": [ 10 | { 11 | "name": "Microsoft.Targeting", 12 | "parameters": { 13 | "Audience": { 14 | "Users": [ 15 | "Alice" 16 | ], 17 | "Groups": [ 18 | { 19 | "Name": "Stage1", 20 | "RolloutPercentage": 100 21 | }, 22 | { 23 | "Name": "Stage2", 24 | "RolloutPercentage": 50 25 | } 26 | ], 27 | "DefaultRolloutPercentage": 25, 28 | "Exclusion": { 29 | "Users": ["Dave"], 30 | "Groups": ["Stage3"] 31 | } 32 | } 33 | } 34 | } 35 | ] 36 | } 37 | }, 38 | { 39 | "id": "RolloutPercentageUpdate", 40 | "description": "A feature flag using a targeting filter, that will return true 61% of the time. Changing to 62% makes the user Brittney true.", 41 | "enabled": true, 42 | "conditions": { 43 | "client_filters": [ 44 | { 45 | "name": "Microsoft.Targeting", 46 | "parameters": { 47 | "Audience": { 48 | "Users": [], 49 | "Groups": [], 50 | "DefaultRolloutPercentage": 61, 51 | "Exclusion": {} 52 | } 53 | } 54 | } 55 | ] 56 | } 57 | } 58 | ] 59 | } 60 | } -------------------------------------------------------------------------------- /tests/validation_tests/TargetingFilter.tests.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "FriendlyName": "DisabledDefaultRollout", 4 | "FeatureFlagName": "ComplexTargeting", 5 | "Inputs": {"user":"Aiden"}, 6 | "IsEnabled": { 7 | "Result": "false" 8 | }, 9 | "Variant": { 10 | "Result": null 11 | }, 12 | "Description": "Targeting Filter, Aiden is not part of the default rollout." 13 | }, 14 | { 15 | "FriendlyName": "EnabledDefaultRollout", 16 | "FeatureFlagName": "ComplexTargeting", 17 | "Inputs": {"user":"Blossom"}, 18 | "IsEnabled": { 19 | "Result": "true" 20 | }, 21 | "Variant": { 22 | "Result": null 23 | }, 24 | "Description": "Targeting Filter, Blossom is part of the default rollout." 25 | }, 26 | { 27 | "FriendlyName": "TargetedUser", 28 | "FeatureFlagName": "ComplexTargeting", 29 | "Inputs": {"user":"Alice"}, 30 | "IsEnabled": { 31 | "Result": "true" 32 | }, 33 | "Variant": { 34 | "Result": null 35 | }, 36 | "Description": "Targeting Filter, Alice is a targeted user." 37 | }, 38 | { 39 | "FriendlyName": "TargetedGroup", 40 | "FeatureFlagName": "ComplexTargeting", 41 | "Inputs": {"user":"Aiden", "groups":["Stage1"]}, 42 | "IsEnabled": { 43 | "Result": "true" 44 | }, 45 | "Variant": { 46 | "Result": null 47 | }, 48 | "Description": "Targeting Filter, Aiden is now targeted because Stage1 is 100% rolled out." 49 | }, 50 | { 51 | "FriendlyName": "DisabledTargetedGroup", 52 | "FeatureFlagName": "ComplexTargeting", 53 | "Inputs": {"groups":["Stage2"]}, 54 | "IsEnabled": { 55 | "Result": "false" 56 | }, 57 | "Variant": { 58 | "Result": null 59 | }, 60 | "Description": "empty/no user will hit the 50% rollout of group stage 2, so it is targeted." 61 | }, 62 | { 63 | "FriendlyName": "EnabledTargetedGroup50", 64 | "FeatureFlagName": "ComplexTargeting", 65 | "Inputs": {"user":"Aiden", "groups":["Stage2"]}, 66 | "IsEnabled": { 67 | "Result": "true" 68 | }, 69 | "Variant": { 70 | "Result": null 71 | }, 72 | "Description": "Targeting Filter, Aiden who is not part of the default rollout is part of the first 50% of Stage 2." 73 | }, 74 | { 75 | "FriendlyName": "DisabledTargetedGroup50", 76 | "FeatureFlagName": "ComplexTargeting", 77 | "Inputs": {"user":"Chris", "groups":["Stage2"]}, 78 | "IsEnabled": { 79 | "Result": "false" 80 | }, 81 | "Variant": { 82 | "Result": null 83 | }, 84 | "Description": "Targeting Filter, Chris is neither part of the default rollout nor part of the first 50% of Stage 2." 85 | }, 86 | { 87 | "FriendlyName": "ExcludedGroup", 88 | "FeatureFlagName": "ComplexTargeting", 89 | "Inputs": {"groups":["Stage3"]}, 90 | "IsEnabled": { 91 | "Result": "false" 92 | }, 93 | "Variant": { 94 | "Result": null 95 | }, 96 | "Description": "Targeting Filter, the Stage 3 is the group on the exclusion list." 97 | }, 98 | { 99 | "FriendlyName": "ExcludedGroupTargetedUser", 100 | "FeatureFlagName": "ComplexTargeting", 101 | "Inputs": {"user":"Alice", "groups":["Stage3"]}, 102 | "IsEnabled": { 103 | "Result": "false" 104 | }, 105 | "Variant": { 106 | "Result": null 107 | }, 108 | "Description": "Alice is excluded because she is part of the Stage 3 group, even if she is an included user. " 109 | }, 110 | { 111 | "FriendlyName": "ExcludedGroupDefaultRollout", 112 | "FeatureFlagName": "ComplexTargeting", 113 | "Inputs": {"user":"Blossom", "groups":["Stage3"]}, 114 | "IsEnabled": { 115 | "Result": "false" 116 | }, 117 | "Variant": { 118 | "Result": null 119 | }, 120 | "Description": "Targeting Filter, Blossom who was Expected Result by the default rollout is now excluded as part of the Stage 3 group." 121 | }, 122 | { 123 | "FriendlyName": "ExcludedUser", 124 | "FeatureFlagName": "ComplexTargeting", 125 | "Inputs": {"user":"Dave", "groups":["Stage1"]}, 126 | "IsEnabled": { 127 | "Result": "false" 128 | }, 129 | "Variant": { 130 | "Result": null 131 | }, 132 | "Description": "Targeting Filter, Dave is on the exclusion list, is still excluded even though he is part of the 100% rolled out Stage 1." 133 | }, 134 | { 135 | "FriendlyName": "Aiden61", 136 | "FeatureFlagName": "RolloutPercentageUpdate", 137 | "Inputs": {"user":"Aiden"}, 138 | "IsEnabled": { 139 | "Result": "true" 140 | }, 141 | "Variant": { 142 | "Result": null 143 | }, 144 | "Description": "Targeting Filter, 62% default rollout Aiden is part of it." 145 | }, 146 | { 147 | "FriendlyName": "Aiden61 - Stage1", 148 | "FeatureFlagName": "RolloutPercentageUpdate", 149 | "Inputs": {"user":"Aiden", "groups":["Stage1"]}, 150 | "IsEnabled": { 151 | "Result": "true" 152 | }, 153 | "Variant": { 154 | "Result": null 155 | }, 156 | "Description": "Targeting Filter, group is not part of default rollout calculation, no change." 157 | }, 158 | { 159 | "FriendlyName": "Aiden61 - Stage2", 160 | "FeatureFlagName": "RolloutPercentageUpdate", 161 | "Inputs": {"user":"Aiden", "groups":["Stage2"]}, 162 | "IsEnabled": { 163 | "Result": "true" 164 | }, 165 | "Variant": { 166 | "Result": null 167 | }, 168 | "Description": "Targeting Filter, group is not part of default rollout calculation, no change." 169 | }, 170 | { 171 | "FriendlyName": "Aiden61 - Stage3", 172 | "FeatureFlagName": "RolloutPercentageUpdate", 173 | "Inputs": {"user":"Aiden", "groups":["Stage3"]}, 174 | "IsEnabled": { 175 | "Result": "true" 176 | }, 177 | "Variant": { 178 | "Result": null 179 | }, 180 | "Description": "Targeting Filter, group is not part of default rollout calculation, no change." 181 | }, 182 | { 183 | "FriendlyName": "Brittney61", 184 | "FeatureFlagName": "RolloutPercentageUpdate", 185 | "Inputs": {"user":"Brittney"}, 186 | "IsEnabled": { 187 | "Result": "false" 188 | }, 189 | "Variant": { 190 | "Result": null 191 | }, 192 | "Description": "Targeting Filter, 62% default rollout Brittney is not part of it." 193 | }, 194 | { 195 | "FriendlyName": "Brittney61 - Stage1", 196 | "FeatureFlagName": "RolloutPercentageUpdate", 197 | "Inputs": {"user":"Brittney", "groups":["Stage1"]}, 198 | "IsEnabled": { 199 | "Result": "false" 200 | }, 201 | "Variant": { 202 | "Result": null 203 | }, 204 | "Description": "Targeting Filter, group is not part of default rollout calculation, no change." 205 | }, 206 | { 207 | "FriendlyName": "Brittney61 - Stage2", 208 | "FeatureFlagName": "RolloutPercentageUpdate", 209 | "Inputs": {"user":"Brittney", "groups":["Stage2"]}, 210 | "IsEnabled": { 211 | "Result": "false" 212 | }, 213 | "Variant": { 214 | "Result": null 215 | }, 216 | "Description": "Targeting Filter, group is not part of default rollout calculation, no change." 217 | }, 218 | { 219 | "FriendlyName": "Brittney61 - Stage3", 220 | "FeatureFlagName": "RolloutPercentageUpdate", 221 | "Inputs": {"user":"Brittney", "groups":["Stage3"]}, 222 | "IsEnabled": { 223 | "Result": "false" 224 | }, 225 | "Variant": { 226 | "Result": null 227 | }, 228 | "Description": "Targeting Filter, group is not part of default rollout calculation, no change." 229 | } 230 | ] -------------------------------------------------------------------------------- /tests/validation_tests/TimeWindowFilter.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "feature_management": { 3 | "feature_flags": [ 4 | { 5 | "id": "PastTimeWindow", 6 | "description": "A feature flag using a time window filter, that is active from 2023-06-29 07:00:00 to 2023-08-30 07:00:00. Will always return false as the current time is outside the time window.", 7 | "enabled": true, 8 | "conditions": { 9 | "client_filters": [ 10 | { 11 | "name": "Microsoft.TimeWindow", 12 | "parameters": { 13 | "Start": "Thu, 29 Jun 2023 07:00:00 GMT", 14 | "End": "Wed, 30 Aug 2023 07:00:00 GMT" 15 | } 16 | } 17 | ] 18 | } 19 | }, 20 | { 21 | "id": "FutureTimeWindow", 22 | "description": "A feature flag using a time window filter, that is active from 3023-06-27 06:00:00 to 3023-06-28 06:05:00. Will always return false as the time window has yet been reached.", 23 | "enabled": true, 24 | "conditions": { 25 | "client_filters": [ 26 | { 27 | "name": "Microsoft.TimeWindow", 28 | "parameters": { 29 | "Start": "Fri, 27 Jun 3023 06:00:00 GMT", 30 | "End": "Sat, 28 Jun 3023 06:05:00 GMT" 31 | } 32 | } 33 | ] 34 | } 35 | }, 36 | { 37 | "id": "PresentTimeWindow", 38 | "description": "A feature flag using a time window filter, that is active from 2023-06-27 06:00:00 to 3023-06-28 06:05:00. Will always return true as we are in the time window.", 39 | "enabled": true, 40 | "conditions": { 41 | "client_filters": [ 42 | { 43 | "name": "Microsoft.TimeWindow", 44 | "parameters": { 45 | "Start": "Thu, 29 Jun 2023 07:00:00 GMT", 46 | "End": "Sat, 28 Jun 3023 06:05:00 GMT" 47 | } 48 | } 49 | ] 50 | } 51 | }, 52 | { 53 | "id": "StartedTimeWindow", 54 | "description": "A feature flag using a time window filter, that will always return true as the current time is within the time window.", 55 | "enabled": true, 56 | "conditions": { 57 | "client_filters": [ 58 | { 59 | "name": "Microsoft.TimeWindow", 60 | "parameters": { 61 | "Start": "Tue, 27 Jun 2023 06:00:00 GMT" 62 | } 63 | } 64 | ] 65 | } 66 | }, 67 | { 68 | "id": "WillEndTimeWindow", 69 | "description": "A feature flag using a time window filter, that will always return true as the current time is within the time window.", 70 | "enabled": true, 71 | "conditions": { 72 | "client_filters": [ 73 | { 74 | "name": "Microsoft.TimeWindow", 75 | "parameters": { 76 | "End": "Sat, 28 Jun 3023 06:05:00 GMT" 77 | } 78 | } 79 | ] 80 | } 81 | } 82 | ] 83 | } 84 | } -------------------------------------------------------------------------------- /tests/validation_tests/TimeWindowFilter.tests.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "FeatureFlagName": "PastTimeWindow", 4 | "Inputs": {}, 5 | "IsEnabled": { 6 | "Result": "false" 7 | }, 8 | "Variant": { 9 | "Result": null 10 | }, 11 | "Description": "Time Window filter where both Start and End have already passed." 12 | }, 13 | { 14 | "FeatureFlagName": "FutureTimeWindow", 15 | "Inputs": {}, 16 | "IsEnabled": { 17 | "Result": "false" 18 | }, 19 | "Variant": { 20 | "Result": null 21 | }, 22 | "Description": "Time Window filter where neither Start nor End have happened." 23 | }, 24 | { 25 | "FeatureFlagName": "PresentTimeWindow", 26 | "Inputs": {}, 27 | "IsEnabled": { 28 | "Result": "true" 29 | }, 30 | "Variant": { 31 | "Result": null 32 | }, 33 | "Description": "Time Window filter where Start has happened but End hasn't happened." 34 | }, 35 | { 36 | "FeatureFlagName": "StartedTimeWindow", 37 | "Inputs": {}, 38 | "IsEnabled": { 39 | "Result": "true" 40 | }, 41 | "Variant": { 42 | "Result": null 43 | }, 44 | "Description": "Time Window filter with a Start that has passed." 45 | }, 46 | { 47 | "FeatureFlagName": "WillEndTimeWindow", 48 | "Inputs": {}, 49 | "IsEnabled": { 50 | "Result": "true" 51 | }, 52 | "Variant": { 53 | "Result": null 54 | }, 55 | "Description": "Time Window filter where the End hasn't passed." 56 | } 57 | ] 58 | -------------------------------------------------------------------------------- /tests/validation_tests/test_json_validations.py: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------------------------- 2 | # Copyright (c) Microsoft Corporation. All rights reserved. 3 | # Licensed under the MIT License. See License.txt in the project root for 4 | # license information. 5 | # -------------------------------------------------------------------------- 6 | 7 | import json 8 | import unittest 9 | from pytest import raises 10 | from featuremanagement import FeatureManager, TargetingContext 11 | 12 | FILE_PATH = "tests/validation_tests/" 13 | SAMPLE_JSON_KEY = ".sample.json" 14 | TESTS_JSON_KEY = ".tests.json" 15 | FRIENDLY_NAME_KEY = "FriendlyName" 16 | IS_ENABLED_KEY = "IsEnabled" 17 | RESULT_KEY = "Result" 18 | FEATURE_FLAG_NAME_KEY = "FeatureFlagName" 19 | INPUTS_KEY = "Inputs" 20 | USER_KEY = "user" 21 | GROUPS_KEY = "groups" 22 | EXCEPTION_KEY = "Exception" 23 | DESCRIPTION_KEY = "Description" 24 | 25 | 26 | def convert_boolean_value(enabled): 27 | if enabled is None: 28 | return None 29 | if isinstance(enabled, bool): 30 | return enabled 31 | if enabled.lower() == "true": 32 | return True 33 | if enabled.lower() == "false": 34 | return False 35 | return enabled 36 | 37 | 38 | class TestNoFiltersFromFile(unittest.TestCase): 39 | # method: is_enabled 40 | def test_no_filters(self): 41 | test_key = "NoFilters" 42 | self.run_tests(test_key) 43 | 44 | # method: is_enabled 45 | def test_time_window_filter(self): 46 | test_key = "TimeWindowFilter" 47 | self.run_tests(test_key) 48 | 49 | # method: is_enabled 50 | def test_targeting_filter(self): 51 | test_key = "TargetingFilter" 52 | self.run_tests(test_key) 53 | 54 | # method: is_enabled 55 | def test_targeting_filter_modified(self): 56 | test_key = "TargetingFilter.modified" 57 | self.run_tests(test_key) 58 | 59 | # method: is_enabled 60 | def test_requirement_type(self): 61 | test_key = "RequirementType" 62 | self.run_tests(test_key) 63 | 64 | @staticmethod 65 | def load_from_file(file): 66 | with open(FILE_PATH + file, "r", encoding="utf-8") as feature_flags_file: 67 | feature_flags = json.load(feature_flags_file) 68 | 69 | feature_manager = FeatureManager(feature_flags) 70 | assert feature_manager is not None 71 | 72 | return feature_manager 73 | 74 | # method: is_enabled 75 | def run_tests(self, test_key): 76 | feature_manager = self.load_from_file(test_key + SAMPLE_JSON_KEY) 77 | 78 | with open(FILE_PATH + test_key + TESTS_JSON_KEY, "r", encoding="utf-8") as feature_flag_test_file: 79 | feature_flag_tests = json.load(feature_flag_test_file) 80 | 81 | for feature_flag_test in feature_flag_tests: 82 | is_enabled = feature_flag_test[IS_ENABLED_KEY] 83 | expected_is_enabled_result = convert_boolean_value(is_enabled.get(RESULT_KEY)) 84 | feature_flag_id = test_key + "." + feature_flag_test[FEATURE_FLAG_NAME_KEY] 85 | 86 | failed_description = f"Test {feature_flag_id} failed. Description: {feature_flag_test[DESCRIPTION_KEY]}" 87 | 88 | if isinstance(expected_is_enabled_result, bool): 89 | user = feature_flag_test[INPUTS_KEY].get(USER_KEY, None) 90 | groups = feature_flag_test[INPUTS_KEY].get(GROUPS_KEY, []) 91 | assert ( 92 | feature_manager.is_enabled( 93 | feature_flag_test[FEATURE_FLAG_NAME_KEY], TargetingContext(user_id=user, groups=groups) 94 | ) 95 | == expected_is_enabled_result 96 | ), failed_description 97 | else: 98 | with raises(ValueError) as ex_info: 99 | feature_manager.is_enabled(feature_flag_test[FEATURE_FLAG_NAME_KEY]) 100 | expected_message = is_enabled.get(EXCEPTION_KEY) 101 | assert str(ex_info.value) == expected_message, failed_description 102 | --------------------------------------------------------------------------------