From 9d831b4613dda180592c5902cca9aded6f2a7d4d Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Thu, 31 Jan 2019 09:20:30 +0200 Subject: [PATCH 01/75] Default transformation (#94) * This commit introduce the support of default transformation for undeclared fields in the anonymizer, which can be overrided by the user if supplied another default within the anonymizer template. --- Gopkg.lock | 7 +- presidio-analyzer/analyzer/__main__.py | 4 +- .../analyzer/field_types/globally/domain.py | 2 +- .../analyzer/field_types/globally/email.py | 2 +- .../analyzer/field_types/uk/nhs.py | 2 +- .../anonymizer/anonymizer.go | 69 +++++++---- .../anonymizer/anonymizer_test.go | 109 ++++++++++++++++++ 7 files changed, 163 insertions(+), 32 deletions(-) diff --git a/Gopkg.lock b/Gopkg.lock index 41b05feb3..5ffd1e91b 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -81,7 +81,7 @@ version = "0.3.0" [[projects]] - digest = "1:8824d5e809d68fd509eef8d4e78e5e4472bd42d173ab6c4941ceb0d5c6b550aa" + digest = "1:ef17fa8a0edc01cb33eefed09d6865064ebdcc74ceef1637693bc466c708deac" name = "github.com/Azure/go-autorest" packages = [ "autorest", @@ -92,7 +92,6 @@ "autorest/validation", "logger", "tracing", - "version", ] pruneopts = "UT" revision = "f401b1ccc8eb505927fae7a0c7f6406d37ca1c7e" @@ -108,11 +107,11 @@ [[projects]] branch = "master" - digest = "1:aadb1ac57ed6201de4f898495fd0791c0ed6c8e8d652d8b8b49f4d1f1c6d2eae" + digest = "1:09d02863ffab900bdd5433a84148021ffdb309eda6949e111ae9fd3fd31985e3" name = "github.com/Microsoft/presidio-genproto" packages = ["golang"] pruneopts = "UT" - revision = "03f9193b772ccd3b4bfe3ad4cdc6155748fcdb8d" + revision = "fb5d00d4a6df7f7e468b2cb1b9375f614d41a7b7" [[projects]] digest = "1:a59a467c541a1bf8b06e4fad6113028c959be6573b78ceca9f8020cd0d2127fc" diff --git a/presidio-analyzer/analyzer/__main__.py b/presidio-analyzer/analyzer/__main__.py index bd23ed07a..2ea13d1f4 100644 --- a/presidio-analyzer/analyzer/__main__.py +++ b/presidio-analyzer/analyzer/__main__.py @@ -72,7 +72,7 @@ def serve_command_handler(env_grpc_port=False, grpc_port=3000): if env_grpc_port: port = os.environ.get('GRPC_PORT') - if port is not None or port is not '': + if port is not None or port != '': grpc_port = int(port) server.add_insecure_port('[::]:' + str(grpc_port)) @@ -89,7 +89,7 @@ def analyze_command_handler(text, fields, env_grpc_port=False, grpc_port=3001): if env_grpc_port: port = os.environ.get('GRPC_PORT') - if port is not None or port is not '': + if port is not None or port != '': grpc_port = int(port) channel = grpc.insecure_channel('localhost:' + str(grpc_port)) diff --git a/presidio-analyzer/analyzer/field_types/globally/domain.py b/presidio-analyzer/analyzer/field_types/globally/domain.py index 63a01c243..e26c9bfac 100644 --- a/presidio-analyzer/analyzer/field_types/globally/domain.py +++ b/presidio-analyzer/analyzer/field_types/globally/domain.py @@ -19,7 +19,7 @@ class Domain(field_type.FieldType): def check_checksum(self): result = tldextract.extract(self.text) - if result.fqdn is not '': + if result.fqdn != '': return True else: return False diff --git a/presidio-analyzer/analyzer/field_types/globally/email.py b/presidio-analyzer/analyzer/field_types/globally/email.py index 441426a6d..75a486a69 100644 --- a/presidio-analyzer/analyzer/field_types/globally/email.py +++ b/presidio-analyzer/analyzer/field_types/globally/email.py @@ -17,7 +17,7 @@ class Email(field_type.FieldType): def check_checksum(self): result = tldextract.extract(self.text) - if result.fqdn is not '': + if result.fqdn != '': return True else: return False diff --git a/presidio-analyzer/analyzer/field_types/uk/nhs.py b/presidio-analyzer/analyzer/field_types/uk/nhs.py index 12ab01029..1ead85dbf 100644 --- a/presidio-analyzer/analyzer/field_types/uk/nhs.py +++ b/presidio-analyzer/analyzer/field_types/uk/nhs.py @@ -34,7 +34,7 @@ def check_checksum(self): remainder = total % 11 check_digit = 11 - remainder - if check_digit is 11: + if check_digit == 11: return True return False diff --git a/presidio-anonymizer/cmd/presidio-anonymizer/anonymizer/anonymizer.go b/presidio-anonymizer/cmd/presidio-anonymizer/anonymizer/anonymizer.go index eec0f9131..374573ff3 100644 --- a/presidio-anonymizer/cmd/presidio-anonymizer/anonymizer/anonymizer.go +++ b/presidio-anonymizer/cmd/presidio-anonymizer/anonymizer/anonymizer.go @@ -3,6 +3,7 @@ package anonymizer import ( "fmt" "sort" + "strings" types "github.com/Microsoft/presidio-genproto/golang" @@ -11,6 +12,32 @@ import ( type sortedResults []*types.AnalyzeResult +// transformSingleField +func transformSingleField(transformation *types.Transformation, result *types.AnalyzeResult, text string) (bool, string, error) { + newtext, err := transformField(transformation, result, text) + if err != nil { + return false, "", err + } + return true, newtext, nil +} + +// anonymizeSingleResult anonymize a single analyze result +func anonymizeSingleResult(result *types.AnalyzeResult, transformations []*types.FieldTypeTransformation, text string) (bool, string, error) { + for _, transformation := range transformations { + if transformation.Fields == nil { + return transformSingleField(transformation.Transformation, result, text) + } + + for _, fieldType := range transformation.Fields { + if fieldType.Name == result.Field.Name { + return transformSingleField(transformation.Transformation, result, text) + } + } + } + + return false, "", nil +} + func (a sortedResults) Len() int { return len(a) } func (a sortedResults) Swap(i, j int) { a[i], a[j] = a[j], a[i] } func (a sortedResults) Less(i, j int) bool { @@ -35,33 +62,29 @@ func AnonymizeText(text string, results []*types.AnalyzeResult, template *types. } //Apply new values - var err error for i := len(results) - 1; i >= 0; i-- { result := results[i] - transformed := false - for _, transformations := range template.FieldTypeTransformations { - if transformed { - break - } - if transformations.Fields == nil { - text, err = transformField(transformations.Transformation, result, text) - if err != nil { - return "", err - } - break - } + transformed, transformedText, err := anonymizeSingleResult(result, template.FieldTypeTransformations, text) + if err != nil { + return "", err + } - for _, fieldType := range transformations.Fields { - if fieldType.Name == result.Field.Name { - text, err = transformField(transformations.Transformation, result, text) - if err != nil { - return "", err - } - transformed = true - break - } - } + if transformed { + text = transformedText + continue + } + + // Now, for any analyzer result which wasn't transformed, either + // transform using the default transformation, as described in the + // template, or fallback to default transformation + if template.DefaultTransformation != nil { + text, err = transformField(template.DefaultTransformation, result, text) + } else { + text, err = methods.ReplaceValue(text, *result.Location, "<"+strings.ToUpper(result.Field.Name)+">") + } + if err != nil { + return "", err } } diff --git a/presidio-anonymizer/cmd/presidio-anonymizer/anonymizer/anonymizer_test.go b/presidio-anonymizer/cmd/presidio-anonymizer/anonymizer/anonymizer_test.go index c73320197..f797377f6 100644 --- a/presidio-anonymizer/cmd/presidio-anonymizer/anonymizer/anonymizer_test.go +++ b/presidio-anonymizer/cmd/presidio-anonymizer/anonymizer/anonymizer_test.go @@ -16,6 +16,7 @@ var testPlans = []struct { expected string analyzeResults []*types.AnalyzeResult fieldTypeTransformation []*types.FieldTypeTransformation + defaultTransformation *types.Transformation }{{ desc: "Replace 1 Element", text: "My phone number is 058-5559943", @@ -254,6 +255,113 @@ var testPlans = []struct { }, }}, }, + // Replace 1 custom field with a specific transformation for this kind of field + { + desc: "Replace 1 custom field", + text: "My custom field is myvalue", + expected: "My custom field is ", + analyzeResults: []*types.AnalyzeResult{{ + Location: &types.Location{ + Start: 19, + End: 26, + }, + Field: &types.FieldTypes{ + Name: "customtype", + }, + }}, + fieldTypeTransformation: []*types.FieldTypeTransformation{{ + Fields: []*types.FieldTypes{{ + Name: "customtype", + }}, + Transformation: &types.Transformation{ + ReplaceValue: &types.ReplaceValue{ + NewValue: "", + }, + }, + }}, + }, + // Replace 1 custom field with the basic default transformation + { + desc: "Replace 1 undeclared field using default transformation", + text: "My undeclared field is myvalue", + expected: "My undeclared field is ", + analyzeResults: []*types.AnalyzeResult{{ + Location: &types.Location{ + Start: 23, + End: 30, + }, + Field: &types.FieldTypes{ + Name: "customtype", + }, + }}, + }, + // Replace two custom fields with a transformation which was declared for ALL fields + { + desc: "Replace 2 custom fields", + text: "My custom field is myvalue and myvalue2", + expected: "My custom field is and ", + analyzeResults: []*types.AnalyzeResult{{ + Location: &types.Location{ + Start: 19, + End: 26, + }, + Field: &types.FieldTypes{ + Name: "customtype", + }, + }, + { + Location: &types.Location{ + Start: 31, + End: 39, + }, + Field: &types.FieldTypes{ + Name: "customtype2", + }, + }}, + fieldTypeTransformation: []*types.FieldTypeTransformation{{ + Transformation: &types.Transformation{ + ReplaceValue: &types.ReplaceValue{ + NewValue: "", + }, + }, + }}, + }, + // Replace 1 custom field with a specific transformation. Replace the second with a default transformation + { + desc: "Replace 2 custom fields, some with provided default transformation", + text: "My custom field is myvalue and myvalue2", + expected: "My custom field is and ", + analyzeResults: []*types.AnalyzeResult{{ + Location: &types.Location{ + Start: 19, + End: 26, + }, + Field: &types.FieldTypes{ + Name: "customtype", + }, + }, + { + Location: &types.Location{ + Start: 31, + End: 39, + }, + Field: &types.FieldTypes{ + Name: "customtype2", + }, + }}, + defaultTransformation: &types.Transformation{ + RedactValue: &types.RedactValue{}}, + fieldTypeTransformation: []*types.FieldTypeTransformation{{ + Fields: []*types.FieldTypes{{ + Name: "customtype2", + }}, + Transformation: &types.Transformation{ + ReplaceValue: &types.ReplaceValue{ + NewValue: "", + }, + }, + }}, + }, } func TestPlan(t *testing.T) { @@ -262,6 +370,7 @@ func TestPlan(t *testing.T) { anonymizerTemplate := types.AnonymizeTemplate{ FieldTypeTransformations: plan.fieldTypeTransformation, + DefaultTransformation: plan.defaultTransformation, } output, err := AnonymizeText(plan.text, plan.analyzeResults, &anonymizerTemplate) assert.NoError(t, err) From 1e93ad7cf4f4279690d83efaaade6a616cb92624 Mon Sep 17 00:00:00 2001 From: Tomer Rosenthal Date: Thu, 31 Jan 2019 10:05:04 +0200 Subject: [PATCH 02/75] Streams bug fixes (#92) --- Gopkg.lock | 171 +++++++++++------- Gopkg.toml | 17 +- README.MD | 4 +- docs/index.md | 2 +- ...duler_cronjob.md => tutorial_scheduler.md} | 12 +- pkg/platform/kube/cronjob_test.go | 8 +- pkg/platform/kube/job_test.go | 8 +- pkg/platform/kube/secret_test.go | 16 +- pkg/stream/eventhubs/eventhubs.go | 19 +- presidio-api/cmd/presidio-api/methods.go | 3 + tests/integration_eventhub_test.go | 42 +++++ 11 files changed, 192 insertions(+), 110 deletions(-) rename docs/{tutorial_scheduler_cronjob.md => tutorial_scheduler.md} (92%) create mode 100644 tests/integration_eventhub_test.go diff --git a/Gopkg.lock b/Gopkg.lock index 5ffd1e91b..14d77ff02 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -18,7 +18,7 @@ version = "v0.2.0" [[projects]] - digest = "1:026f7e25abb46746307d35dcd55aa307e77b972867d741c23b1aec677d0ad4ce" + digest = "1:487dc37a77bbba996bf4ddae0bff1c69fde98027d507e75eca317ca7c94483c3" name = "github.com/Azure/azure-amqp-common-go" packages = [ ".", @@ -35,11 +35,11 @@ "uuid", ] pruneopts = "UT" - revision = "12877250384ded92c1b1a2affe969579aaef97d4" - version = "v1.1.3" + revision = "b5ea4829bce0d96bc7b4d11bfc8ccd2afdf0ec4a" + version = "v1.1.4" [[projects]] - digest = "1:ced435a7600ec61f81056fe3b8f6f51e773ef3cd9caf6f14564b3d81951e803d" + digest = "1:b7ae7b7962d3c2656f3eb7d8543932de1cb31ba28dc1433b5ce74613f9694318" name = "github.com/Azure/azure-event-hubs-go" packages = [ ".", @@ -48,8 +48,8 @@ "storage", ] pruneopts = "UT" - revision = "804d4a4235136f100951fde308046bedabe2d7c6" - version = "v1.1.1" + revision = "aca3e9cfe138951ffb815665621095482a674ee9" + version = "v1.1.2" [[projects]] digest = "1:d2ccb697dc13c8fbffafa37baae97594d5592ae8f7e113471084137315536e2b" @@ -73,12 +73,12 @@ version = "v23.2.0" [[projects]] - digest = "1:c4a5edf3b0f38e709a78dcc945997678a364c2b5adfd48842a3dd349c352f833" + digest = "1:b8ac7e4464ce21f7487c663aa69b1b3437740bb10ab12d4dc7aa9b02422571a1" name = "github.com/Azure/azure-storage-blob-go" packages = ["azblob"] pruneopts = "UT" - revision = "5152f14ace1c6db66bd9cb57840703a8358fa7bc" - version = "0.3.0" + revision = "45d0c5e3638e2b539942f93c48e419f4f2fc62e4" + version = "0.4.0" [[projects]] digest = "1:ef17fa8a0edc01cb33eefed09d6865064ebdcc74ceef1637693bc466c708deac" @@ -94,8 +94,8 @@ "tracing", ] pruneopts = "UT" - revision = "f401b1ccc8eb505927fae7a0c7f6406d37ca1c7e" - version = "v11.2.8" + revision = "be17756531f50014397912b7aa557ec335e39b98" + version = "v11.3.0" [[projects]] digest = "1:ed77032e4241e3b8329c9304d66452ed196e795876e14be677a546f36b94e67a" @@ -122,7 +122,7 @@ version = "v1.20.0" [[projects]] - digest = "1:35564f1cd08e1163bf327e6f595b9e596c4470a77f7f13fe6421236ea99e5966" + digest = "1:355da6e69ecab4bb211ddd598a3c26d4c156802dac22722953734b7f792289e6" name = "github.com/aws/aws-sdk-go" packages = [ "aws", @@ -161,8 +161,8 @@ "service/sts", ] pruneopts = "UT" - revision = "3991042237b45cf58c9d5f34295942d5533c28c6" - version = "v1.16.11" + revision = "1f8a24693bc965514ee0d7aadbabe0ceed184a88" + version = "v1.16.16" [[projects]] digest = "1:526d64d0a3ac6c24875724a9355895be56a21f89a5d3ab5ba88d91244269a7d8" @@ -253,28 +253,28 @@ version = "v1.1.0" [[projects]] - digest = "1:abeb38ade3f32a92943e5be54f55ed6d6e3b6602761d74b4aab4c9dd45c18abd" - name = "github.com/fsnotify/fsnotify" + digest = "1:f1f2bd73c025d24c3b93abf6364bccb802cf2fdedaa44360804c67800e8fab8d" + name = "github.com/evanphx/json-patch" packages = ["."] pruneopts = "UT" - revision = "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9" - version = "v1.4.7" + revision = "72bf35d0ff611848c1dc9df0f976c81192392fa5" + version = "v4.1.0" [[projects]] - digest = "1:2cd7915ab26ede7d95b8749e6b1f933f1c6d5398030684e6505940a10f31cfda" - name = "github.com/ghodss/yaml" + digest = "1:abeb38ade3f32a92943e5be54f55ed6d6e3b6602761d74b4aab4c9dd45c18abd" + name = "github.com/fsnotify/fsnotify" packages = ["."] pruneopts = "UT" - revision = "0ca9ea5df5451ffdf184b4428c902747c2c11cd7" - version = "v1.0.0" + revision = "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9" + version = "v1.4.7" [[projects]] branch = "master" - digest = "1:81f09a221428ff497df5140de49753a024541b630348644814d8204c39da0ff2" + digest = "1:237e20f314113702902d275bf57103693f8a4d3bfcf43a4cd02163ee3430c90e" name = "github.com/gin-contrib/cors" packages = ["."] pruneopts = "UT" - revision = "7c641a7a7dc5548100d5749436059b022de56075" + revision = "5e7acb10687f94a88d0d8e96297818fff2da8f88" [[projects]] branch = "master" @@ -306,7 +306,7 @@ version = "v1.3.0" [[projects]] - digest = "1:424f6593024cdf0f6f90cba81bc69ca98df3758525e6fb248198ef15ead603a9" + digest = "1:ad53d1f710522a38d1f0e5e0a55a194b1c6b2cd8e84313568e43523271f0cf62" name = "github.com/go-redis/redis" packages = [ ".", @@ -315,12 +315,11 @@ "internal/hashtag", "internal/pool", "internal/proto", - "internal/singleflight", "internal/util", ] pruneopts = "UT" - revision = "7f89fbac80bcc62ce920b6dbc6ca60238d7725d1" - version = "v6.15.0" + revision = "22be8a3eaf992c828cecb69dc07348313bf08d2e" + version = "v6.15.1" [[projects]] branch = "master" @@ -348,11 +347,11 @@ [[projects]] branch = "master" - digest = "1:07af58eca86d0e46804aa8eda58b6cf9ecb3110783d2fec55a32cd64639198ea" + digest = "1:683ffbf5c4f58c718a45c517884bf34110d8ddcb0f5a2b8309ce1630215fb5b3" name = "github.com/go-xorm/xorm" packages = ["."] pruneopts = "UT" - revision = "a8f0a7110a8049c79069836b420538f1964e6339" + revision = "1cd2662be938bfee0e34af92fe448513e0560fb1" [[projects]] digest = "1:b402bb9a24d108a9405a6f34675091b036c8b056aac843bf6ef2389a65c5cf48" @@ -365,14 +364,6 @@ revision = "4cbf7e384e768b4e01799441fdf2a706a5635ae7" version = "v1.2.0" -[[projects]] - branch = "master" - digest = "1:1ba1d79f2810270045c328ae5d674321db34e3aae468eb4233883b473c5c0467" - name = "github.com/golang/glog" - packages = ["."] - pruneopts = "UT" - revision = "23def4e6c14b4da8ac2ed8007337bc5eb5007998" - [[projects]] branch = "master" digest = "1:97239b8255df64c18138842365b135975e7402112beb593e139de1b91303d5bc" @@ -387,7 +378,7 @@ "ptypes/wrappers", ] pruneopts = "UT" - revision = "1d3f30b51784bec5aad268e59fd3c2fc1c2fe73f" + revision = "347cf4a86c1cb8d262994d8ef5924d4576c5b331" [[projects]] branch = "master" @@ -438,7 +429,7 @@ [[projects]] branch = "master" - digest = "1:b1a14ae21c8293ce886f6a0f69148785ab17458e50a8ae1fe49c56b4da9e3c03" + digest = "1:81100583200fd95e87d312b8b82681b88e048f063fdb2fbc0ae63dfc8bfa29d1" name = "github.com/grpc-ecosystem/go-grpc-middleware" packages = [ "retry", @@ -446,7 +437,7 @@ "util/metautils", ] pruneopts = "UT" - revision = "3304cc8863525cd0b328fbfd5bf745bbd38e7106" + revision = "4832df01553a810b8e3404b95743d01c9ab5313f" [[projects]] digest = "1:c0d19ab64b32ce9fe5cf4ddceba78d5bc9807f0016db6b1183599da3dcc24d10" @@ -617,12 +608,12 @@ version = "v2.0.7" [[projects]] - digest = "1:40e195917a951a8bf867cd05de2a46aaf1806c50cf92eebf4c16f78cd196f747" + digest = "1:cf31692c14422fa27c83a05292eb5cbe0fb2775972e8f1f8446a71549bd8980b" name = "github.com/pkg/errors" packages = ["."] pruneopts = "UT" - revision = "645ef00459ed84a119197bfb8d8205042c6df63d" - version = "v0.8.0" + revision = "ba968bfe8b2f7e042a574c888954fccecfa385b4" + version = "v0.8.1" [[projects]] digest = "1:0028cb19b2e4c3112225cd871870f2d9cf49b9b4276531f03438a88e94be86fe" @@ -720,15 +711,15 @@ version = "v0.1.1" [[projects]] - digest = "1:15a4a7e5afac3cea801fa24831fce3bf3b5bd3620cbf8355a07b7dbf06877883" + digest = "1:0bcc464dabcfad5393daf87c3f8142911d0f6c52569b837e91a1c15e890265f3" name = "github.com/stretchr/testify" packages = [ "assert", "mock", ] pruneopts = "UT" - revision = "f35b8ab0b5a2cef36673838d662e249dd9c94686" - version = "v1.2.2" + revision = "ffdc059bfe9ce6a4e144ba849dbedead332c6053" + version = "v1.3.0" [[projects]] digest = "1:03aa6e485e528acb119fb32901cf99582c380225fc7d5a02758e08b180cb56c3" @@ -805,7 +796,7 @@ "ssh/terminal", ] pruneopts = "UT" - revision = "505ab145d0a99da450461ae2c1a9f6cd10d1f447" + revision = "ff983b9c42bc9fbf91556e191cc8efb585c16908" [[projects]] branch = "master" @@ -821,10 +812,11 @@ [[projects]] branch = "master" - digest = "1:89a0cb976397aa9157a45bb2b896d0bcd07ee095ac975e0f03c53250c402265e" + digest = "1:8ecb828bb550a8c6b7d75b8261a42c369461311616ebe5451966d067f5f993bf" name = "golang.org/x/net" packages = [ "context", + "context/ctxhttp", "http/httpguts", "http2", "http2/hpack", @@ -833,7 +825,18 @@ "trace", ] pruneopts = "UT" - revision = "927f97764cc334a6575f4b7a1584a147864d5723" + revision = "be1c187aa6c66b9daa1d9461c228d17e9dd2cab7" + +[[projects]] + branch = "master" + digest = "1:5276e08fe6a1dfdb65b4f46a2e5d5c9e00be6e499105e441049c3c04a0c83b36" + name = "golang.org/x/oauth2" + packages = [ + ".", + "internal", + ] + pruneopts = "UT" + revision = "d668ce993890a79bda886613ee587a69dd5da7a6" [[projects]] branch = "master" @@ -848,14 +851,14 @@ [[projects]] branch = "master" - digest = "1:10405139b45e3a97a3842c93984710e30466eb933545f219ad3f5e45246973b4" + digest = "1:5ee4df7ab18e945607ac822de8d10b180baea263b5e8676a1041727543b9c1e4" name = "golang.org/x/sys" packages = [ "unix", "windows", ] pruneopts = "UT" - revision = "9a3f9b0469bbc6b8802087ae5c0af9f61502de01" + revision = "48ac38b7c8cbedd50b1613c0fccacfc7d88dfcdf" [[projects]] digest = "1:a2ab62866c75542dd18d2b069fec854577a20211d7c0ea6ae746072a1dccdd18" @@ -889,17 +892,26 @@ revision = "85acf8d2951cb2a3bde7632f9ff273ef0379bcbd" [[projects]] - branch = "master" digest = "1:5f003878aabe31d7f6b842d4de32b41c46c214bb629bb485387dbcce1edf5643" name = "google.golang.org/api" packages = ["support/bundler"] pruneopts = "UT" - revision = "f26a60c56f148a32e87f3f4591c8ebf834b5561f" + revision = "19e022d8cf43ce81f046bae8cc18c5397cc7732f" + version = "v0.1.0" [[projects]] - digest = "1:c25289f43ac4a68d88b02245742347c94f1e108c534dda442188015ff80669b3" + digest = "1:9e29a0ec029d012437d88da3ccccf18adcdce069cab08d462056c2c6bb006505" name = "google.golang.org/appengine" - packages = ["cloudsql"] + packages = [ + "cloudsql", + "internal", + "internal/base", + "internal/datastore", + "internal/log", + "internal/remote_api", + "internal/urlfetch", + "urlfetch", + ] pruneopts = "UT" revision = "e9657d882bb81064595ca3b56cbe2546bbabf7b1" version = "v1.4.0" @@ -910,7 +922,7 @@ name = "google.golang.org/genproto" packages = ["googleapis/rpc/status"] pruneopts = "UT" - revision = "bd9b4fb69e2ffd37621a6caa54dcbead29b546f2" + revision = "ae2f86662275e140f395167f1dab7081a5bd5fa8" [[projects]] digest = "1:8c8ed249fa6a8db070bf2082f02052c697695fa5e1558b4e28dd0fb5f15f70a2" @@ -979,7 +991,7 @@ version = "v2.2.2" [[projects]] - digest = "1:74142cd2275f77547c35ac51514108d9798a09aa0cf377a5c1084718ef7aa225" + digest = "1:0d299a04c6472e4458461d7034c76d014cc6f632a3262cbf21d123b19ce13e65" name = "k8s.io/api" packages = [ "admissionregistration/v1alpha1", @@ -987,16 +999,19 @@ "apps/v1", "apps/v1beta1", "apps/v1beta2", + "auditregistration/v1alpha1", "authentication/v1", "authentication/v1beta1", "authorization/v1", "authorization/v1beta1", "autoscaling/v1", "autoscaling/v2beta1", + "autoscaling/v2beta2", "batch/v1", "batch/v1beta1", "batch/v2alpha1", "certificates/v1beta1", + "coordination/v1beta1", "core/v1", "events/v1beta1", "extensions/v1beta1", @@ -1013,12 +1028,12 @@ "storage/v1beta1", ] pruneopts = "UT" - revision = "072894a440bdee3a891dea811fe42902311cd2a3" - version = "kubernetes-1.11.0" + revision = "89a74a8d264df0e993299876a8cde88379b940ee" + version = "kubernetes-1.13.0" [[projects]] - branch = "release-1.11" - digest = "1:8f90d4f2241d20ea6a1b8a60452d290157914b6bae4d3876802dd20acf03df34" + branch = "release-1.13" + digest = "1:1ff3647c207e3f7a6b96f2669f4dbab7b7ce8dc4c0a5371dff0b634143ac28df" name = "k8s.io/apimachinery" packages = [ "pkg/api/errors", @@ -1047,13 +1062,13 @@ "pkg/util/intstr", "pkg/util/json", "pkg/util/mergepatch", + "pkg/util/naming", "pkg/util/net", "pkg/util/runtime", "pkg/util/sets", "pkg/util/strategicpatch", "pkg/util/validation", "pkg/util/validation/field", - "pkg/util/wait", "pkg/util/yaml", "pkg/version", "pkg/watch", @@ -1061,10 +1076,10 @@ "third_party/forked/golang/reflect", ] pruneopts = "UT" - revision = "3d8ee2261517413977a62256b7d79644d7ffdc43" + revision = "2b1284ed4c93a43499e781493253e2ac5959c4fd" [[projects]] - digest = "1:a4c040bbe135dd100bc07ce53ebe9f6be54468f33d78b304feacb6e98814db47" + digest = "1:509f442b58ab9907cb05c7410f48f9ee6795402caef5dd53d19ad493543593d2" name = "k8s.io/client-go" packages = [ "discovery", @@ -1082,6 +1097,8 @@ "kubernetes/typed/apps/v1beta1/fake", "kubernetes/typed/apps/v1beta2", "kubernetes/typed/apps/v1beta2/fake", + "kubernetes/typed/auditregistration/v1alpha1", + "kubernetes/typed/auditregistration/v1alpha1/fake", "kubernetes/typed/authentication/v1", "kubernetes/typed/authentication/v1/fake", "kubernetes/typed/authentication/v1beta1", @@ -1094,6 +1111,8 @@ "kubernetes/typed/autoscaling/v1/fake", "kubernetes/typed/autoscaling/v2beta1", "kubernetes/typed/autoscaling/v2beta1/fake", + "kubernetes/typed/autoscaling/v2beta2", + "kubernetes/typed/autoscaling/v2beta2/fake", "kubernetes/typed/batch/v1", "kubernetes/typed/batch/v1/fake", "kubernetes/typed/batch/v1beta1", @@ -1102,6 +1121,8 @@ "kubernetes/typed/batch/v2alpha1/fake", "kubernetes/typed/certificates/v1beta1", "kubernetes/typed/certificates/v1beta1/fake", + "kubernetes/typed/coordination/v1beta1", + "kubernetes/typed/coordination/v1beta1/fake", "kubernetes/typed/core/v1", "kubernetes/typed/core/v1/fake", "kubernetes/typed/events/v1beta1", @@ -1153,8 +1174,16 @@ "util/integer", ] pruneopts = "UT" - revision = "7d04d0e2a0a1a4d4a1cd6baa432a2301492e4e65" - version = "v8.0.0" + revision = "e64494209f554a6723674bd494d69445fb76a1d4" + version = "v10.0.0" + +[[projects]] + digest = "1:e2999bf1bb6eddc2a6aa03fe5e6629120a53088926520ca3b4765f77d7ff7eab" + name = "k8s.io/klog" + packages = ["."] + pruneopts = "UT" + revision = "a5bc97fbc634d635061f3146511332c7e313a55a" + version = "v0.1.0" [[projects]] branch = "master" @@ -1175,6 +1204,14 @@ revision = "a77984cb83aafae2bc3fcdf6f0ef75c93b87eea5" version = "v0.10.2" +[[projects]] + digest = "1:7719608fe0b52a4ece56c2dde37bedd95b938677d1ab0f84b8a7852e4c59f849" + name = "sigs.k8s.io/yaml" + packages = ["."] + pruneopts = "UT" + revision = "fd68e9863619f6ec2fdd8625fe1f02e7c877e480" + version = "v1.1.0" + [solve-meta] analyzer-name = "dep" analyzer-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index e36d20538..272f9c82e 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -2,9 +2,14 @@ name = "github.com/golang/protobuf" branch = "master" +[[constraint]] + name = "github.com/Azure/azure-amqp-common-go" + version = "1.1.4" + [[constraint]] name = "github.com/Azure/azure-event-hubs-go" - version = "1.1.0" + version = "1.1.2" + [[constraint]] name = "github.com/Shopify/sarama" @@ -28,7 +33,7 @@ [[constraint]] name = "github.com/gin-gonic/gin" - version = "1.2.0" + version = "1.3.0" [[constraint]] name = "github.com/spf13/viper" @@ -40,7 +45,7 @@ [[constraint]] name = "github.com/go-redis/redis" - version = "6.13.2" + version = "6.15.1" [[constraint]] branch = "master" @@ -92,15 +97,15 @@ [[constraint]] name = "k8s.io/api" - version = "kubernetes-1.11.0" + version = "kubernetes-1.13.0" [[constraint]] - branch = "release-1.11" + branch = "release-1.13" name = "k8s.io/apimachinery" [[constraint]] name = "k8s.io/client-go" - version = "8.0.0" + version = "10.0.0" [prune] go-tests = true diff --git a/README.MD b/README.MD index 35eb8a89f..5810786fa 100644 --- a/README.MD +++ b/README.MD @@ -138,8 +138,8 @@ You can also create reusable templates | Scanner | Azure Blob Storage | :white_check_mark: | | Scanner | S3 | :white_check_mark: | | Scanner | Google Cloud Storage | :x: | -| Streams | Kafka | :large_orange_diamond: | -| Streams | Azure Event Hub | :large_orange_diamond: | +| Streams | Kafka | :white_check_mark: | +| Streams | Azure Event Hub | :white_check_mark: | | Datasink (output) | MySQL | :white_check_mark: | | Datasink (output) | MSSQL | :white_check_mark: | | Datasink (output) | Oracle | :x: | diff --git a/docs/index.md b/docs/index.md index d835b297d..366b7121f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,6 +14,6 @@ New to Presidio or to DLP systems in general? Well, you came to the right place: - [Supported Field Types](field_types.md) - [Framework Tutorial](tutorial_framework.md) - [Service Tutorial](tutorial_service.md) -- [Database and Storage scanner](tutorial_scheduler_cronjob.md) +- [Database and Storage scanner](tutorial_scheduler.md) - [Design](design.md) - [Develop Presidio](development.md) \ No newline at end of file diff --git a/docs/tutorial_scheduler_cronjob.md b/docs/tutorial_scheduler.md similarity index 92% rename from docs/tutorial_scheduler_cronjob.md rename to docs/tutorial_scheduler.md index cd6317656..9faec2952 100644 --- a/docs/tutorial_scheduler_cronjob.md +++ b/docs/tutorial_scheduler.md @@ -210,20 +210,20 @@ odbc:server=.database.windows.net;user id=;password=", - "ehName": "", - "ehConnectionString": "", - "ehKeyName": "", - "ehKeyValue": "" + "ehConnectionString": "", // EH connection string. It is recommended to generate a connection string from EH and NOT from EH namespace. + "storageAccountName": "", // Storage account name for Azure EH EPH pattern + "storageAccountKeyValue": "", // Storage account key for Azure EH EPH pattern + "containerValue": "" // Storage container name for Azure EH EPH pattern } } ``` For Kafka use the following configuration: + ```json "streamConfig": { "kafkaConfig": { diff --git a/pkg/platform/kube/cronjob_test.go b/pkg/platform/kube/cronjob_test.go index 34b455c62..330a3a80a 100644 --- a/pkg/platform/kube/cronjob_test.go +++ b/pkg/platform/kube/cronjob_test.go @@ -27,9 +27,7 @@ func TestCreateAndDeleteCronJob(t *testing.T) { // Create job err := store.CreateCronJob(jobName, schedule, containerDetails) - if err != nil { - t.Fatal(err) - } + assert.NoError(t, err) // List jobs jobs, _ := store.ListCronJobs() @@ -38,9 +36,7 @@ func TestCreateAndDeleteCronJob(t *testing.T) { // Delete job err = store.DeleteCronJob(jobName) - if err != nil { - t.Fatal(err) - } + assert.NoError(t, err) // List jobs jobs, _ = store.ListCronJobs() diff --git a/pkg/platform/kube/job_test.go b/pkg/platform/kube/job_test.go index 7420df484..ac4c3b42b 100644 --- a/pkg/platform/kube/job_test.go +++ b/pkg/platform/kube/job_test.go @@ -28,9 +28,7 @@ func TestCreateAndDeleteJob(t *testing.T) { // Create job err := store.CreateJob(name, containerDetails) - if err != nil { - t.Fatal(err) - } + assert.NoError(t, err) // List jobs jobs, _ := store.ListJobs() @@ -39,9 +37,7 @@ func TestCreateAndDeleteJob(t *testing.T) { // Delete job err = store.DeleteJob(name) - if err != nil { - t.Fatal(err) - } + assert.NoError(t, err) // List jobs jobs, _ = store.ListJobs() diff --git a/pkg/platform/kube/secret_test.go b/pkg/platform/kube/secret_test.go index f6d31d4ba..52d689a79 100644 --- a/pkg/platform/kube/secret_test.go +++ b/pkg/platform/kube/secret_test.go @@ -3,6 +3,8 @@ package kube import ( "testing" "time" + + "github.com/stretchr/testify/assert" ) func TestPutKVPair(t *testing.T) { @@ -12,9 +14,7 @@ func TestPutKVPair(t *testing.T) { value := "value1" key := "key@key@key" err := store.PutKVPair(key, value) - if err != nil { - t.Fatal(err) - } + assert.NoError(t, err) time.Sleep(time.Millisecond) @@ -30,13 +30,11 @@ func TestDeleteKVPair(t *testing.T) { key := "key@key@key" err := store.PutKVPair(key, "somevalue") - if err != nil { - t.Fatal(err) - } + assert.NoError(t, err) + err = store.DeleteKVPair(key) - if err != nil { - t.Fatal(err) - } + assert.NoError(t, err) + _, err = store.GetKVPair(key) if err == nil { t.Fatal("Key wasn't deleted") diff --git a/pkg/stream/eventhubs/eventhubs.go b/pkg/stream/eventhubs/eventhubs.go index 9e7dcb286..5c65a4465 100644 --- a/pkg/stream/eventhubs/eventhubs.go +++ b/pkg/stream/eventhubs/eventhubs.go @@ -48,7 +48,7 @@ func NewConsumer(ctx context.Context, eventHubConnStr string, storageAccountName // SAS token provider for Azure Event Hubs provider, err := sas.NewTokenProvider(sas.TokenProviderWithKey(parsed.KeyName, parsed.Key)) if err != nil { - // handle error + log.Fatal(err.Error()) } // create a new Azure Storage Leaser / Checkpointer cred, err := azblob.NewSharedKeyCredential(storageAccountName, storageAccountKey) @@ -76,21 +76,26 @@ func NewConsumer(ctx context.Context, eventHubConnStr string, storageAccountName //Receive message from eventhub partition. func (e *eventhubs) Receive(receiveFunc stream.ReceiveFunc) error { - e.eph.StartNonBlocking(e.ctx) e.receiveFunc = receiveFunc _, err := e.eph.RegisterHandler(e.ctx, e.handleEvent) - return err + if err != nil { + return err + } + + return e.eph.Start(e.ctx) } func (e *eventhubs) handleEvent(ctx context.Context, event *eh.Event) error { - err := e.receiveFunc(ctx, *event.PartitionKey, event.ID, string(event.Data)) - return err + key := "-1" + if event.PartitionKey != nil { + key = *event.PartitionKey + } + return e.receiveFunc(ctx, key, event.ID, string(event.Data)) } //Send message to eventhub func (e *eventhubs) Send(message string) error { ctx, cancel := context.WithTimeout(e.ctx, 10*time.Second) defer cancel() - err := e.hub.Send(ctx, eh.NewEventFromString(message)) - return err + return e.hub.Send(ctx, eh.NewEventFromString(message)) } diff --git a/presidio-api/cmd/presidio-api/methods.go b/presidio-api/cmd/presidio-api/methods.go index eb878b1ce..9a7c29153 100644 --- a/presidio-api/cmd/presidio-api/methods.go +++ b/presidio-api/cmd/presidio-api/methods.go @@ -210,6 +210,9 @@ func validateTemplate(action string, c *gin.Context) (string, error) { case store.ScheduleStreamsJob: var streamsJobTemplate types.StreamsJobTemplate return bindAndConvert(streamsJobTemplate, c) + case store.Stream: + var streamTemplate types.StreamTemplate + return bindAndConvert(streamTemplate, c) } return "", fmt.Errorf("No template found") diff --git a/tests/integration_eventhub_test.go b/tests/integration_eventhub_test.go new file mode 100644 index 000000000..65581bc0e --- /dev/null +++ b/tests/integration_eventhub_test.go @@ -0,0 +1,42 @@ +// +build integration + +package tests + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + log "github.com/Microsoft/presidio/pkg/logger" + "github.com/Microsoft/presidio/pkg/stream/eventhubs" +) + +func TestEventHub(t *testing.T) { + + eventHubConnStr := "" + storageAccountName := "" + storageAccountKey := "" + storageContainerName := "" + + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(time.Second*10)) + defer cancel() + + p := eventhubs.NewProducer(ctx, eventHubConnStr) + msg := "I live in Zurich and my account number is 2854567876542111" + err := p.Send(msg) + + assert.NoError(t, err) + + c := eventhubs.NewConsumer(ctx, eventHubConnStr, storageAccountName, storageAccountKey, storageContainerName) + + r := func(ctx context.Context, partition string, seq string, data string) error { + log.Info("Received: %s,%s,%s", partition, seq, data) + assert.Equal(t, msg, data) + return nil + } + + err = c.Receive(r) + assert.NoError(t, err) +} From be170c725ca1867258a929550ff4151fd5e56694 Mon Sep 17 00:00:00 2001 From: Itye Richter Date: Thu, 7 Feb 2019 10:47:22 +0200 Subject: [PATCH 03/75] Deployment script (#95) New simplified deployment scripts + unified tags --- Makefile | 24 +++++++++++++ README.MD | 34 +++++++++++++++++++ charts/presidio/Chart.yaml | 4 +-- charts/presidio/templates/NOTES.txt | 6 +++- .../templates/analyzer-deployment.yaml | 2 +- .../templates/anonymizer-deployment.yaml | 2 +- .../anonymizer-image-deployment.yaml | 2 +- charts/presidio/templates/api-deployment.yaml | 2 +- charts/presidio/templates/ocr-deployment.yaml | 2 +- .../templates/scheduler-deployment.yaml | 6 ++-- charts/presidio/values.yaml | 11 ++---- deployment/deploy-helm.sh | 5 +++ deployment/deploy-presidio.sh | 5 +++ docs/install.md | 2 +- 14 files changed, 86 insertions(+), 21 deletions(-) create mode 100755 deployment/deploy-helm.sh create mode 100755 deployment/deploy-presidio.sh diff --git a/Makefile b/Makefile index 5db4b021e..0bdc4adba 100644 --- a/Makefile +++ b/Makefile @@ -52,12 +52,36 @@ docker-push-deps: docker push $(DOCKER_REGISTRY)/$(PYTHON_DEPS):latest docker push $(DOCKER_REGISTRY)/$(GOLANG_DEPS):latest +# push with the given label .PHONY: docker-push docker-push: $(addsuffix -push,$(IMAGES)) %-push: docker push $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) +# push docker images twice, once with new tag and once with latest-dev tag +.PHONY: docker-push-latest-dev +docker-push-latest-dev: $(addsuffix -push-latest-dev,$(IMAGES)) + +%-push-latest-dev: + docker push $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) + docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/$*:latest-dev + docker push $(DOCKER_REGISTRY)/$*:latest-dev + +# pull an existing image tag, tag it again with a provided release tag and 'latest' tag +.PHONY: docker-push-release +docker-push-release: $(addsuffix -push-release,$(IMAGES)) + +%-push-release: +ifeq ($(RELEASE_VERSION),) + $(error RELEASE_VERSION is not set) +endif + docker pull $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) + docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/$*:$(RELEASE_VERSION) + docker push $(DOCKER_REGISTRY)/$*:$(RELEASE_VERSION) + docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/$*:latest + docker push $(DOCKER_REGISTRY)/$*:latest + # All non-functional tests .PHONY: test test: python-test diff --git a/README.MD b/README.MD index 5810786fa..8c1cc84d4 100644 --- a/README.MD +++ b/README.MD @@ -67,6 +67,40 @@ The [design document](https://microsoft.github.io/presidio/design.html) introduc 2. Create a Presidio project 3. Start using the Presidio analyze and anonymize services +## Single click deployment using the default values + +The script will install Presidio on your Kubenetes cluster. Prerequesites: + +1. A Kubernetes cluster. +2. `kubectl` installed + * verify you can communicate with the cluster by running: + ``` sh + kubectl version + ``` +3. Local `helm` client. +4. Recent presidio repo is cloned on your local machine. + +### Installation Steps + +1. Navigate into `\deployment` from command line. + +2. If You have helm installed, but havn't run `helm init`, execute [deploy-helm.sh](deploy-helm.sh) in the command line. It will install `tiller` (helm server side) on your cluster. + +3. Grant the Kubernetes cluster access to the container registry + * If using Azure Kubernetes Service, follow these instructions to [grant the AKS cluster access to the ACR.](https://docs.microsoft.com/en-us/azure/container-registry/container-registry-auth-aks) + +4. If you already have `helm` and `tiller` configured, or if you installed it in the previous step, execute [deploy-presidio.sh](deploy-presidio.sh) in the command line as follows: + +```sh +deploy-presidio.sh +``` + +The script will install Presidio on your cluster using the default values. +>Note: You can edit the file to use your own container registry and image. + +## Samples + + **Note:** Examples are made with [HTTPie](https://httpie.org/) ***Sample 1*** diff --git a/charts/presidio/Chart.yaml b/charts/presidio/Chart.yaml index 10c05a385..68e10360c 100644 --- a/charts/presidio/Chart.yaml +++ b/charts/presidio/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v1 description: A context aware, born to the cloud, customizable data loss prevention service name: presidio -version: unstable -appVersion: unstable \ No newline at end of file +version: 1.0 +appVersion: latest \ No newline at end of file diff --git a/charts/presidio/templates/NOTES.txt b/charts/presidio/templates/NOTES.txt index 2dc6f74e2..99087eab4 100644 --- a/charts/presidio/templates/NOTES.txt +++ b/charts/presidio/templates/NOTES.txt @@ -2,4 +2,8 @@ presidio is now installed! To find out about your newly configured system, run: - $ helm status {{ .Release.Name }} \ No newline at end of file + $ helm status {{ .Release.Name }} + +To test your new Presidio service, forward the API port: the API publishes a service at port 8080. To use the API, you first need to forward this port to your local IP address: + + $ kubectl port-forward -n presidio $(kubectl get pod -n presidio -l app=presidio-demo-presidio-api -o jsonpath='{.items[0].metadata.name}') 8080:8080 \ No newline at end of file diff --git a/charts/presidio/templates/analyzer-deployment.yaml b/charts/presidio/templates/analyzer-deployment.yaml index eb27b5718..96b2586ef 100644 --- a/charts/presidio/templates/analyzer-deployment.yaml +++ b/charts/presidio/templates/analyzer-deployment.yaml @@ -20,7 +20,7 @@ spec: spec: containers: - name: {{ .Chart.Name }} - image: "{{ .Values.registry }}/{{ .Values.analyzer.name }}:{{ default .Chart.AppVersion .Values.analyzer.tag }}" + image: "{{ .Values.registry }}/{{ .Values.analyzer.name }}:{{ default .Chart.AppVersion .Values.tag }}" imagePullPolicy: {{ default "IfNotPresent" .Values.analyzer.imagePullPolicy }} ports: - containerPort: {{ .Values.analyzer.service.internalPort }} diff --git a/charts/presidio/templates/anonymizer-deployment.yaml b/charts/presidio/templates/anonymizer-deployment.yaml index 168a0a578..230ef4d60 100644 --- a/charts/presidio/templates/anonymizer-deployment.yaml +++ b/charts/presidio/templates/anonymizer-deployment.yaml @@ -20,7 +20,7 @@ spec: spec: containers: - name: {{ .Chart.Name }} - image: "{{ .Values.registry }}/{{ .Values.anonymizer.name }}:{{ default .Chart.AppVersion .Values.anonymizer.tag }}" + image: "{{ .Values.registry }}/{{ .Values.anonymizer.name }}:{{ default .Chart.AppVersion .Values.tag }}" imagePullPolicy: {{ default "IfNotPresent" .Values.anonymizer.imagePullPolicy }} ports: - containerPort: {{ .Values.anonymizer.service.internalPort }} diff --git a/charts/presidio/templates/anonymizer-image-deployment.yaml b/charts/presidio/templates/anonymizer-image-deployment.yaml index 0d6f48a6b..fcb307179 100644 --- a/charts/presidio/templates/anonymizer-image-deployment.yaml +++ b/charts/presidio/templates/anonymizer-image-deployment.yaml @@ -20,7 +20,7 @@ spec: spec: containers: - name: {{ .Chart.Name }} - image: "{{ .Values.registry }}/{{ .Values.anonymizerimage.name }}:{{ default .Chart.AppVersion .Values.anonymizerimage.tag }}" + image: "{{ .Values.registry }}/{{ .Values.anonymizerimage.name }}:{{ default .Chart.AppVersion .Values.tag }}" imagePullPolicy: {{ default "IfNotPresent" .Values.anonymizerimage.imagePullPolicy }} ports: - containerPort: {{ .Values.anonymizerimage.service.internalPort }} diff --git a/charts/presidio/templates/api-deployment.yaml b/charts/presidio/templates/api-deployment.yaml index 1f67feeec..75f7dd0d5 100644 --- a/charts/presidio/templates/api-deployment.yaml +++ b/charts/presidio/templates/api-deployment.yaml @@ -21,7 +21,7 @@ spec: serviceAccountName: {{ $fullname }} containers: - name: {{ .Chart.Name }} - image: "{{ .Values.registry }}/{{ .Values.api.name }}:{{ default .Chart.AppVersion .Values.api.tag }}" + image: "{{ .Values.registry }}/{{ .Values.api.name }}:{{ default .Chart.AppVersion .Values.tag }}" imagePullPolicy: {{ default "IfNotPresent" .Values.api.imagePullPolicy }} ports: - containerPort: {{ .Values.api.service.internalPort }} diff --git a/charts/presidio/templates/ocr-deployment.yaml b/charts/presidio/templates/ocr-deployment.yaml index 70eb0ed32..c557539cc 100644 --- a/charts/presidio/templates/ocr-deployment.yaml +++ b/charts/presidio/templates/ocr-deployment.yaml @@ -20,7 +20,7 @@ spec: spec: containers: - name: {{ .Chart.Name }} - image: "{{ .Values.registry }}/{{ .Values.ocr.name }}:{{ default .Chart.AppVersion .Values.ocr.tag }}" + image: "{{ .Values.registry }}/{{ .Values.ocr.name }}:{{ default .Chart.AppVersion .Values.tag }}" imagePullPolicy: {{ default "IfNotPresent" .Values.ocr.imagePullPolicy }} ports: - containerPort: {{ .Values.ocr.service.internalPort }} diff --git a/charts/presidio/templates/scheduler-deployment.yaml b/charts/presidio/templates/scheduler-deployment.yaml index 9a5a8a581..c3158e41f 100644 --- a/charts/presidio/templates/scheduler-deployment.yaml +++ b/charts/presidio/templates/scheduler-deployment.yaml @@ -22,7 +22,7 @@ spec: serviceAccountName: {{ $fullname }} containers: - name: {{ .Chart.Name }} - image: "{{ .Values.registry }}/{{ .Values.scheduler.name }}:{{ default .Chart.AppVersion .Values.scheduler.tag }}" + image: "{{ .Values.registry }}/{{ .Values.scheduler.name }}:{{ default .Chart.AppVersion .Values.tag }}" imagePullPolicy: {{ default "IfNotPresent" .Values.scheduler.imagePullPolicy }} ports: - containerPort: {{ .Values.scheduler.service.internalPort }} @@ -52,11 +52,11 @@ spec: - name: DATASINK_GRPC_PORT value: "5000" - name: DATASINK_IMAGE_NAME - value: {{ .Values.registry }}/{{ .Values.datasink.name }}:{{ default .Chart.AppVersion .Values.datasink.tag }} + value: {{ .Values.registry }}/{{ .Values.datasink.name }}:{{ default .Chart.AppVersion .Values.tag }} - name: DATASINK_PULL_POLICY value: {{ default "IfNotPresent" .Values.datasink.imagePullPolicy }} - name: COLLECTOR_IMAGE_NAME - value: {{ .Values.registry }}/{{ .Values.collector.name }}:{{ default .Chart.AppVersion .Values.collector.tag }} + value: {{ .Values.registry }}/{{ .Values.collector.name }}:{{ default .Chart.AppVersion .Values.tag }} - name: COLLECTOR_IMAGE_PULL_POLICY value: {{ default "IfNotPresent" .Values.collector.imagePullPolicy }} {{ if .Values.privateRegistry }}imagePullSecrets: diff --git a/charts/presidio/values.yaml b/charts/presidio/values.yaml index 842b8ee1e..073ab42f5 100644 --- a/charts/presidio/values.yaml +++ b/charts/presidio/values.yaml @@ -2,6 +2,7 @@ registry: presidio.azurecr.io # Image pull secret #privateRegistry: acr-auth +tag: latest redis: url: redis-master.presidio-system.svc.cluster.local:6379 @@ -11,7 +12,6 @@ redis: api: name: presidio-api - # tag: imagePullPolicy: Always service: type: ClusterIP @@ -30,7 +30,6 @@ api: analyzer: name: presidio-analyzer - # tag: imagePullPolicy: Always service: type: ClusterIP @@ -39,7 +38,6 @@ analyzer: anonymizer: name: presidio-anonymizer - # tag: imagePullPolicy: Always service: type: ClusterIP @@ -48,7 +46,6 @@ anonymizer: anonymizerimage: name: presidio-anonymizer-image - # tag: imagePullPolicy: Always service: type: ClusterIP @@ -57,7 +54,6 @@ anonymizerimage: ocr: name: presidio-ocr - # tag: imagePullPolicy: Always service: type: ClusterIP @@ -67,7 +63,6 @@ ocr: scheduler: name: presidio-scheduler imagePullPolicy: Always - # tag: service: type: ClusterIP externalPort: 3001 @@ -76,9 +71,7 @@ scheduler: collector: name: presidio-collector imagePullPolicy: Always - # tag: datasink: name: presidio-datasink - imagePullPolicy: Always - # tag: \ No newline at end of file + imagePullPolicy: Always \ No newline at end of file diff --git a/deployment/deploy-helm.sh b/deployment/deploy-helm.sh new file mode 100755 index 000000000..e586f7799 --- /dev/null +++ b/deployment/deploy-helm.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +kubectl create serviceaccount --namespace kube-system tiller +kubectl create clusterrolebinding tiller-cluster-rule --clusterrole=cluster-admin --serviceaccount=kube-system:tiller +helm init --service-account tiller --wait \ No newline at end of file diff --git a/deployment/deploy-presidio.sh b/deployment/deploy-presidio.sh new file mode 100755 index 000000000..021a9998a --- /dev/null +++ b/deployment/deploy-presidio.sh @@ -0,0 +1,5 @@ +#!/bin/bash +REGISTRY=${1:-presidio.azurecr.io} +TAG=${2:-latest} +helm install --name redis stable/redis --set usePassword=false,rbac.create=true --namespace presidio-system --wait +helm install --name presidio-demo --set registry=$REGISTRY,tag=$TAG ../charts/presidio --namespace presidio diff --git a/docs/install.md b/docs/install.md index 5d4038658..fd9aa5631 100644 --- a/docs/install.md +++ b/docs/install.md @@ -50,7 +50,7 @@ $ docker run --rm --name presidio-api --network mynetwork -d -p 8080:8080 -e WEB ```sh # Based on the DOCKER_REGISTRY and PRESIDIO_LABEL from the previous steps - $ helm install --name presidio-demo --set registry=${DOCKER_REGISTRY},analyzer.tag=${PRESIDIO_LABEL},anonymizer.tag=${PRESIDIO_LABEL},anonymizerimage.tag=${PRESIDIO_LABEL},ocr.tag=${PRESIDIO_LABEL},scheduler.tag=${PRESIDIO_LABEL},api.tag=${PRESIDIO_LABEL},collector.tag=${PRESIDIO_LABEL},datasink.tag=${PRESIDIO_LABEL} . --namespace presidio + $ helm install --name presidio-demo --set registry=${DOCKER_REGISTRY},tag=${PRESIDIO_LABEL} . --namespace presidio ``` --- From 1df7fdefe78bf711560c319121366b7064cdc601 Mon Sep 17 00:00:00 2001 From: Ilana Kantorov Date: Tue, 12 Feb 2019 15:23:24 +0200 Subject: [PATCH 04/75] Analyzer redesign + supporting custom recognizers This commit is the first part of the redesign of the analyzer service, and contains the following: 1. Separates spacy and recognizers logic to different files. 2. Implements a base class for all the recognizers,(which in future custom recognizers will inherit) 3. Moves the analyzer logic from main to analyzer_engine class 4. Removes the detected text from the analyzer result. Future commits will contain the following: 1. Dynamic loading of the pre-defined recognizers. [link](https://dev.azure.com/csedevil/Presidio-internal/_sprints/taskboard/Presidio%20Crew/Presidio-internal/02%20-%20Testable%20custom%20models) 2. Add new pattern recognizer via api call, [work item](https://dev.azure.com/csedevil/Presidio-internal/_sprints/taskboard/Presidio%20Crew/Presidio-internal/02%20-%20Testable%20custom%20models) 3. Improve remove duplicates logic [bug](https://dev.azure.com/csedevil/Presidio-internal/_workitems/edit/597) and [bug](https://dev.azure.com/csedevil/Presidio-internal/_workitems/edit/596/) 4. Re-support context model. [work item](https://dev.azure.com/csedevil/Presidio-internal/_sprints/taskboard/Presidio%20Crew/Presidio-internal/02%20-%20Testable%20custom%20models) Current Design: ![image](https://user-images.githubusercontent.com/13463870/52433948-edc69480-2b16-11e9-98d7-8923fdc9fb8a.png) --- .editorconfig | 7 +- .gitignore | 6 +- gometalinter.json | 2 - presidio-analyzer/analyzer/__init__.py | 11 + presidio-analyzer/analyzer/__main__.py | 32 +- presidio-analyzer/analyzer/analyzer_engine.py | 140 +++++++ .../analyzer/entity_recognizer.py | 85 +++++ .../analyzer/field_types/field_factory.py | 79 ---- .../field_types/field_regex_pattern.py | 4 - .../analyzer/field_types/field_type.py | 16 - .../analyzer/field_types/globally/__init__.py | 0 .../field_types/globally/credit_card.py | 48 --- .../analyzer/field_types/globally/crypto.py | 35 -- .../analyzer/field_types/globally/domain.py | 25 -- .../analyzer/field_types/globally/email.py | 23 -- .../analyzer/field_types/globally/iban.py | 35 -- .../analyzer/field_types/globally/ip.py | 22 -- .../analyzer/field_types/globally/ner.py | 35 -- .../analyzer/field_types/uk/__init__.py | 0 .../analyzer/field_types/uk/nhs.py | 40 -- .../analyzer/field_types/us/__init__.py | 0 .../analyzer/field_types/us/bank.py | 24 -- .../analyzer/field_types/us/driver_license.py | 102 ----- .../analyzer/field_types/us/itin.py | 30 -- .../analyzer/field_types/us/passport.py | 18 - .../analyzer/field_types/us/phone.py | 30 -- .../analyzer/field_types/us/ssn.py | 38 -- .../analyzer/local_recognizer.py | 10 + presidio-analyzer/analyzer/matcher.py | 347 ------------------ presidio-analyzer/analyzer/pattern.py | 33 ++ .../analyzer/pattern_recognizer.py | 146 ++++++++ .../predefined_recognizers/__init__.py | 14 + .../credit_card_recognizer.py | 55 +++ .../crypto_recognizer.py | 35 ++ .../domain_recognizer.py | 22 ++ .../email_recognizer.py | 23 ++ .../predefined_recognizers/iban_recognizer.py | 41 +++ .../predefined_recognizers/ip_recognizer.py | 19 + .../spacy_recognizer.py | 53 +++ .../uk_nhs_recognizer.py | 39 ++ .../us_bank_recognizer.py | 28 ++ .../us_driver_license_recognizer.py | 40 ++ .../us_itin_recognizer.py | 23 ++ .../us_passport_recognizer.py | 21 ++ .../us_phone_recognizer.py | 32 ++ .../us_ssn_recognizer.py | 41 +++ presidio-analyzer/analyzer/presidio-analyzer | 0 .../__init__.py | 0 .../recognizer_registry.py | 103 ++++++ .../analyzer/recognizer_result.py | 15 + .../analyzer/remote_recognizer.py | 24 ++ presidio-analyzer/setup.py | 6 +- presidio-analyzer/tests/__init__.py | 7 - .../tests/data/synthetic_json.json | 172 --------- presidio-analyzer/tests/test_all_fields.py | 114 ------ presidio-analyzer/tests/test_analyze_perf.py | 162 -------- .../tests/test_analyzer_engine.py | 141 +++++++ presidio-analyzer/tests/test_credit_card.py | 184 ---------- .../tests/test_credit_card_recognizer.py | 196 ++++++++++ presidio-analyzer/tests/test_crypto.py | 34 -- .../tests/test_crypto_recognizer.py | 38 ++ presidio-analyzer/tests/test_date_time.py | 15 - presidio-analyzer/tests/test_domain.py | 39 -- .../tests/test_domain_recognizer.py | 52 +++ presidio-analyzer/tests/test_email.py | 44 --- .../tests/test_email_recognizer.py | 52 +++ .../tests/test_entity_recognizer.py | 25 ++ presidio-analyzer/tests/test_iban.py | 33 -- .../tests/test_iban_recognizer.py | 43 +++ presidio-analyzer/tests/test_ip.py | 66 ---- presidio-analyzer/tests/test_ip_recognizer.py | 71 ++++ .../tests/test_multiple_files.py | 23 -- presidio-analyzer/tests/test_pattern.py | 20 + .../tests/test_pattern_recognizer.py | 85 +++++ presidio-analyzer/tests/test_person.py | 101 ----- presidio-analyzer/tests/test_phone_number.py | 138 ------- .../tests/test_recognizer_registry.py | 93 +++++ .../tests/test_spacy_recognizer.py | 93 +++++ presidio-analyzer/tests/test_uk_nhs.py | 30 -- .../tests/test_uk_nhs_recognizer.py | 45 +++ .../tests/test_us_bank_number.py | 52 --- .../tests/test_us_bank_recognizer.py | 53 +++ .../tests/test_us_driver_license.py | 141 ------- .../test_us_driver_license_recognizer.py | 134 +++++++ presidio-analyzer/tests/test_us_itin.py | 84 ----- .../tests/test_us_itin_recognizer.py | 87 +++++ presidio-analyzer/tests/test_us_passport.py | 36 -- .../tests/test_us_passport_recognizer.py | 40 ++ .../tests/test_us_phone_recognizer.py | 138 +++++++ presidio-analyzer/tests/test_us_ssn.py | 84 ----- .../tests/test_us_ssn_recognizer.py | 92 +++++ 91 files changed, 2576 insertions(+), 2538 deletions(-) create mode 100644 presidio-analyzer/analyzer/analyzer_engine.py create mode 100644 presidio-analyzer/analyzer/entity_recognizer.py delete mode 100644 presidio-analyzer/analyzer/field_types/field_factory.py delete mode 100644 presidio-analyzer/analyzer/field_types/field_regex_pattern.py delete mode 100644 presidio-analyzer/analyzer/field_types/field_type.py delete mode 100644 presidio-analyzer/analyzer/field_types/globally/__init__.py delete mode 100644 presidio-analyzer/analyzer/field_types/globally/credit_card.py delete mode 100644 presidio-analyzer/analyzer/field_types/globally/crypto.py delete mode 100644 presidio-analyzer/analyzer/field_types/globally/domain.py delete mode 100644 presidio-analyzer/analyzer/field_types/globally/email.py delete mode 100644 presidio-analyzer/analyzer/field_types/globally/iban.py delete mode 100644 presidio-analyzer/analyzer/field_types/globally/ip.py delete mode 100644 presidio-analyzer/analyzer/field_types/globally/ner.py delete mode 100644 presidio-analyzer/analyzer/field_types/uk/__init__.py delete mode 100644 presidio-analyzer/analyzer/field_types/uk/nhs.py delete mode 100644 presidio-analyzer/analyzer/field_types/us/__init__.py delete mode 100644 presidio-analyzer/analyzer/field_types/us/bank.py delete mode 100644 presidio-analyzer/analyzer/field_types/us/driver_license.py delete mode 100644 presidio-analyzer/analyzer/field_types/us/itin.py delete mode 100644 presidio-analyzer/analyzer/field_types/us/passport.py delete mode 100644 presidio-analyzer/analyzer/field_types/us/phone.py delete mode 100644 presidio-analyzer/analyzer/field_types/us/ssn.py create mode 100644 presidio-analyzer/analyzer/local_recognizer.py delete mode 100644 presidio-analyzer/analyzer/matcher.py create mode 100644 presidio-analyzer/analyzer/pattern.py create mode 100644 presidio-analyzer/analyzer/pattern_recognizer.py create mode 100644 presidio-analyzer/analyzer/predefined_recognizers/__init__.py create mode 100644 presidio-analyzer/analyzer/predefined_recognizers/credit_card_recognizer.py create mode 100644 presidio-analyzer/analyzer/predefined_recognizers/crypto_recognizer.py create mode 100644 presidio-analyzer/analyzer/predefined_recognizers/domain_recognizer.py create mode 100644 presidio-analyzer/analyzer/predefined_recognizers/email_recognizer.py create mode 100644 presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py create mode 100644 presidio-analyzer/analyzer/predefined_recognizers/ip_recognizer.py create mode 100644 presidio-analyzer/analyzer/predefined_recognizers/spacy_recognizer.py create mode 100644 presidio-analyzer/analyzer/predefined_recognizers/uk_nhs_recognizer.py create mode 100644 presidio-analyzer/analyzer/predefined_recognizers/us_bank_recognizer.py create mode 100644 presidio-analyzer/analyzer/predefined_recognizers/us_driver_license_recognizer.py create mode 100644 presidio-analyzer/analyzer/predefined_recognizers/us_itin_recognizer.py create mode 100644 presidio-analyzer/analyzer/predefined_recognizers/us_passport_recognizer.py create mode 100644 presidio-analyzer/analyzer/predefined_recognizers/us_phone_recognizer.py create mode 100644 presidio-analyzer/analyzer/predefined_recognizers/us_ssn_recognizer.py mode change 100755 => 100644 presidio-analyzer/analyzer/presidio-analyzer rename presidio-analyzer/analyzer/{field_types => recognizer_registry}/__init__.py (100%) create mode 100644 presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py create mode 100644 presidio-analyzer/analyzer/recognizer_result.py create mode 100644 presidio-analyzer/analyzer/remote_recognizer.py delete mode 100644 presidio-analyzer/tests/data/synthetic_json.json delete mode 100644 presidio-analyzer/tests/test_all_fields.py delete mode 100644 presidio-analyzer/tests/test_analyze_perf.py create mode 100644 presidio-analyzer/tests/test_analyzer_engine.py delete mode 100644 presidio-analyzer/tests/test_credit_card.py create mode 100644 presidio-analyzer/tests/test_credit_card_recognizer.py delete mode 100644 presidio-analyzer/tests/test_crypto.py create mode 100644 presidio-analyzer/tests/test_crypto_recognizer.py delete mode 100644 presidio-analyzer/tests/test_date_time.py delete mode 100644 presidio-analyzer/tests/test_domain.py create mode 100644 presidio-analyzer/tests/test_domain_recognizer.py delete mode 100644 presidio-analyzer/tests/test_email.py create mode 100644 presidio-analyzer/tests/test_email_recognizer.py create mode 100644 presidio-analyzer/tests/test_entity_recognizer.py delete mode 100644 presidio-analyzer/tests/test_iban.py create mode 100644 presidio-analyzer/tests/test_iban_recognizer.py delete mode 100644 presidio-analyzer/tests/test_ip.py create mode 100644 presidio-analyzer/tests/test_ip_recognizer.py delete mode 100644 presidio-analyzer/tests/test_multiple_files.py create mode 100644 presidio-analyzer/tests/test_pattern.py create mode 100644 presidio-analyzer/tests/test_pattern_recognizer.py delete mode 100644 presidio-analyzer/tests/test_person.py delete mode 100644 presidio-analyzer/tests/test_phone_number.py create mode 100644 presidio-analyzer/tests/test_recognizer_registry.py create mode 100644 presidio-analyzer/tests/test_spacy_recognizer.py delete mode 100644 presidio-analyzer/tests/test_uk_nhs.py create mode 100644 presidio-analyzer/tests/test_uk_nhs_recognizer.py delete mode 100644 presidio-analyzer/tests/test_us_bank_number.py create mode 100644 presidio-analyzer/tests/test_us_bank_recognizer.py delete mode 100644 presidio-analyzer/tests/test_us_driver_license.py create mode 100644 presidio-analyzer/tests/test_us_driver_license_recognizer.py delete mode 100644 presidio-analyzer/tests/test_us_itin.py create mode 100644 presidio-analyzer/tests/test_us_itin_recognizer.py delete mode 100644 presidio-analyzer/tests/test_us_passport.py create mode 100644 presidio-analyzer/tests/test_us_passport_recognizer.py create mode 100644 presidio-analyzer/tests/test_us_phone_recognizer.py delete mode 100644 presidio-analyzer/tests/test_us_ssn.py create mode 100644 presidio-analyzer/tests/test_us_ssn_recognizer.py diff --git a/.editorconfig b/.editorconfig index 98320ff87..f20c0b49b 100644 --- a/.editorconfig +++ b/.editorconfig @@ -9,4 +9,9 @@ indent_style = space indent_size = 2 end_of_line = lf insert_final_newline = true -trim_trailing_whitespace = true \ No newline at end of file +trim_trailing_whitespace = true + +[*.py] +max_line_length = 119 +indent_style = space +indent_size = 4 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 67b7376dc..2ebfd0e14 100644 --- a/.gitignore +++ b/.gitignore @@ -126,4 +126,8 @@ debug /tests/testdata/*-generated.* .DS_Store vendor/ -*.db \ No newline at end of file +*.db + +#pycharm +.idea/* +.idea \ No newline at end of file diff --git a/gometalinter.json b/gometalinter.json index 35310c8b6..31ee07686 100644 --- a/gometalinter.json +++ b/gometalinter.json @@ -16,10 +16,8 @@ "gocyclo", "goimports", "golint", - "gosimple", "ineffassign", "misspell", - "unused", "vet" ] } diff --git a/presidio-analyzer/analyzer/__init__.py b/presidio-analyzer/analyzer/__init__.py index 3838c56e4..c7cbb4d83 100644 --- a/presidio-analyzer/analyzer/__init__.py +++ b/presidio-analyzer/analyzer/__init__.py @@ -1,4 +1,15 @@ import os import sys + +# bug #602: Fix imports issue in python sys.path.append(os.path.dirname(os.path.dirname( os.path.abspath(__file__))) + "/analyzer") + +from analyzer.pattern import Pattern # noqa: F401 +from analyzer.entity_recognizer import EntityRecognizer # noqa: F401 +from analyzer.local_recognizer import LocalRecognizer # noqa: F401 +from analyzer.recognizer_result import RecognizerResult # noqa: F401 +from analyzer.pattern_recognizer import PatternRecognizer # noqa: F401 +from analyzer.remote_recognizer import RemoteRecognizer # noqa: F401 +from analyzer.recognizer_registry.recognizer_registry import RecognizerRegistry # noqa +from analyzer.analyzer_engine import AnalyzerEngine # noqa diff --git a/presidio-analyzer/analyzer/__main__.py b/presidio-analyzer/analyzer/__main__.py index 2ea13d1f4..dbcc1f4a8 100644 --- a/presidio-analyzer/analyzer/__main__.py +++ b/presidio-analyzer/analyzer/__main__.py @@ -1,11 +1,10 @@ import logging -import matcher import grpc import analyze_pb2 import analyze_pb2_grpc from concurrent import futures import time -import sys +from os import sys, path import os from google.protobuf.json_format import MessageToJson from knack import CLI @@ -14,6 +13,11 @@ from knack.help import CLIHelp from knack.help_files import helps +# bug #602: Fix imports issue in python +sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) + +from analyzer_engine import AnalyzerEngine # noqa + WELCOME_MESSAGE = r""" _______ _______ _______ _______ _________ ______ _________ _______ @@ -27,7 +31,7 @@ """ -cli_name = "presidio-analyzer" +CLI_NAME = "presidio-analyzer" helps['serve'] = """ short-summary: Create a GRPC server @@ -53,22 +57,12 @@ def __init__(self, cli_ctx=None): welcome_message=WELCOME_MESSAGE) -class Analyzer(analyze_pb2_grpc.AnalyzeServiceServicer): - def __init__(self): - self.match = matcher.Matcher() - - def Apply(self, request, context): - response = analyze_pb2.AnalyzeResponse() - results = self.match.analyze_text(request.text, - request.analyzeTemplate.fields) - response.analyzeResults.extend(results) - return response - - def serve_command_handler(env_grpc_port=False, grpc_port=3000): server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) - analyze_pb2_grpc.add_AnalyzeServiceServicer_to_server(Analyzer(), server) + + analyze_pb2_grpc.add_AnalyzeServiceServicer_to_server( + AnalyzerEngine(), server) if env_grpc_port: port = os.environ.get('GRPC_PORT') @@ -124,9 +118,9 @@ def load_arguments(self, command): presidio_cli = CLI( - cli_name=cli_name, - config_dir=os.path.join('~', '.{}'.format(cli_name)), - config_env_var_prefix=cli_name, + cli_name=CLI_NAME, + config_dir=os.path.join('~', '.{}'.format(CLI_NAME)), + config_env_var_prefix=CLI_NAME, commands_loader_cls=CommandsLoader, help_cls=PresidioCLIHelp) exit_code = presidio_cli.invoke(sys.argv[1:]) diff --git a/presidio-analyzer/analyzer/analyzer_engine.py b/presidio-analyzer/analyzer/analyzer_engine.py new file mode 100644 index 000000000..b5daf25e9 --- /dev/null +++ b/presidio-analyzer/analyzer/analyzer_engine.py @@ -0,0 +1,140 @@ +import logging +import os + +import analyze_pb2 +import analyze_pb2_grpc +import common_pb2 + +from analyzer import RecognizerRegistry # noqa: F401 + +loglevel = os.environ.get("LOG_LEVEL", "INFO") +logging.basicConfig( + format='%(asctime)s:%(levelname)s:%(message)s', level=loglevel) + +DEFAULT_LANGUAGE = "en" + + +class AnalyzerEngine(analyze_pb2_grpc.AnalyzeServiceServicer): + + def __init__(self, registry=RecognizerRegistry()): + # load all recognizers + self.registry = registry + registry.load_recognizers("predefined-recognizers") + + @staticmethod + def __remove_duplicates(results): + # bug# 597: Analyzer remove duplicates doesn't handle all cases of one + # result as a substring of the other + results = sorted(results, + key=lambda x: (-x.score, x.start, x.end - x.start)) + filtered_results = [] + + for result in results: + if result.score == 0: + continue + + valid_result = True + if result not in filtered_results: + for filtered in filtered_results: + # If result is equal to or substring of + # one of the other results + if result.start >= filtered.start \ + and result.end <= filtered.end: + valid_result = False + break + + if valid_result: + filtered_results.append(result) + + return filtered_results + + def Apply(self, request, context): + logging.info("Starting Apply ") + entities = self.__convert_fields_to_entities( + request.analyzeTemplate.fields) + language = self.__get_language(request.analyzeTemplate.fields) + + results = self.analyze(request.text, entities, language) + + # Create Analyze Response Object + response = analyze_pb2.AnalyzeResponse() + + response.analyzeResults.extend( + self.__convert_results_to_proto(results)) + logging.info("Found {} results".format(len(results))) + return response + + def analyze(self, text, entities, language): + """ + analyzes the requested text, searching for the given entities + in the given language + :param text: the text to analyze + :param entities: the text to search + :param language: the language of the text + :return: an array of the found entities in the text + """ + recognizers = self.registry.get_recognizers(language=language, + entities=entities) + results = [] + + for recognizer in recognizers: + # Lazy loading of the relevant recognizers + if not recognizer.is_loaded: + recognizer.load() + recognizer.is_loaded = True + + r = recognizer.analyze(text, entities) + if r is not None: + results.extend(r) + + return AnalyzerEngine.__remove_duplicates(results) + + def add_pattern_recognizer(self, pattern_recognizer_dict): + """ + Adds a new recognizer + :param pattern_recognizer_dict: a dictionary representation + of a pattern recognizer + """ + self.registry.add_pattern_recognizer_from_dict(pattern_recognizer_dict) + + def remove_recognizer(self, name): + """ + Removes an existing recognizer, throws an exception if not found + :param name: name of recognizer to be removed + """ + self.registry.remove_recognizer(name) + + # These 3 methods below, should be removed as part of the work in: + # Task #543 implement redesigned templates and + # Task #580: API support for multiple languages + # input language text to specific recognizers + def __get_language(self, fields): + # Currently each field hold its own language code + # we are going to change it so we will get only one language + # per request -> current logic: take the first language + if not fields or len(fields) == 0 or fields[0].languageCode is None \ + or fields[0].languageCode == "": + return DEFAULT_LANGUAGE + + return fields[0].languageCode + + def __convert_fields_to_entities(self, fields): + # Convert fields to entities - will be changed once the API + # will be changed + entities = [] + for field in fields: + entities.append(field.name) + return entities + + def __convert_results_to_proto(self, results): + proto_results = [] + for result in results: + res = common_pb2.AnalyzeResult() + res.field.name = result.entity_type + res.score = result.score + res.location.start = result.start + res.location.end = result.end + res.location.length = result.end - result.start + proto_results.append(res) + + return proto_results diff --git a/presidio-analyzer/analyzer/entity_recognizer.py b/presidio-analyzer/analyzer/entity_recognizer.py new file mode 100644 index 000000000..5fe63569d --- /dev/null +++ b/presidio-analyzer/analyzer/entity_recognizer.py @@ -0,0 +1,85 @@ +import logging +import os +from abc import abstractmethod + + +class EntityRecognizer: + + def __init__(self, supported_entities, name=None, supported_language="en", + version="0.0.1"): + """ + An abstract class to be inherited by Recognizers which hold the logic + for recognizing specific PII entities. + :param supported_entities: the entities supported by this recognizer + (for example, phone number, address, etc.) + :param supported_language: the language supported by this recognizer. + The supported langauge code is iso6391Name + :param name: the name of this recognizer (optional) + :param version: the recognizer current version + """ + self.supported_entities = supported_entities + + if name is None: + self.name = self.__class__.__name__ # assign class name as name + else: + self.name = name + + self.supported_language = supported_language + self.version = version + self.is_loaded = False + + loglevel = os.environ.get("LOG_LEVEL", "INFO") + self.logger = logging.getLogger(__name__) + self.logger.setLevel(loglevel) + self.load() + + @abstractmethod + def load(self): + """ + Initialize the recognizer assets if needed + (e.g. machine learning models) + """ + pass + + @abstractmethod + def analyze(self, text, entities): + """ + This is the core method for analyzing text, assuming entities are + the subset of the supported entities types. + + :param text: The text to be analyzed + :param entities: The list of entities to be detected + :return: list of RecognizerResult + :rtype: [RecognizerResult] + """ + + return None + + def get_supported_entities(self): + """ + :return: A list of the supported entities by this recognizer + """ + return self.supported_entities + + def get_supported_language(self): + """ + :return: A list of the supported language by this recognizer + """ + return self.supported_language + + def get_version(self): + """ + :return: The current version of this recognizer + """ + return self.version + + def to_dict(self): + return_dict = {"supported_entities": self.supported_entities, + "supported_language": self.supported_language, + "name": self.name, + "version": self.version} + return return_dict + + @classmethod + def from_dict(cls, entity_recognizer_dict): + return cls(**entity_recognizer_dict) diff --git a/presidio-analyzer/analyzer/field_types/field_factory.py b/presidio-analyzer/analyzer/field_types/field_factory.py deleted file mode 100644 index 9718b8486..000000000 --- a/presidio-analyzer/analyzer/field_types/field_factory.py +++ /dev/null @@ -1,79 +0,0 @@ -from field_types.globally import credit_card, crypto, email, ip, iban, domain, ner # noqa: E501 -from field_types.us import bank as usbank -from field_types.us import driver_license as usdriver -from field_types.us import itin as usitin -from field_types.us import passport as uspassport -from field_types.us import phone as usphone -from field_types.us import ssn as usssn -from field_types.uk import nhs as uknhs - -import os -import sys - -parentPath = os.path.abspath("..") -if parentPath not in sys.path: - sys.path.insert(0, parentPath) - -from analyzer import common_pb2 # noqa: E402 - - -class FieldFactory(object): - def create(type): - if type == common_pb2.FieldTypesEnum.Name(common_pb2.CREDIT_CARD): - return credit_card.CreditCard() - if type == common_pb2.FieldTypesEnum.Name(common_pb2.CRYPTO): - return crypto.Crypto() - if type == common_pb2.FieldTypesEnum.Name(common_pb2.DOMAIN_NAME): - return domain.Domain() - if type == common_pb2.FieldTypesEnum.Name(common_pb2.EMAIL_ADDRESS): - return email.Email() - if type == common_pb2.FieldTypesEnum.Name(common_pb2.IBAN_CODE): - return iban.Iban() - if type == common_pb2.FieldTypesEnum.Name(common_pb2.IP_ADDRESS): - return ip.Ip() - if type == common_pb2.FieldTypesEnum.Name(common_pb2.US_BANK_NUMBER): - return usbank.UsBank() - if type == common_pb2.FieldTypesEnum.Name( - common_pb2.US_DRIVER_LICENSE): - return usdriver.UsDriverLicense() - if type == common_pb2.FieldTypesEnum.Name(common_pb2.US_ITIN): - return usitin.UsItin() - if type == common_pb2.FieldTypesEnum.Name(common_pb2.US_PASSPORT): - return uspassport.UsPassport() - if type == common_pb2.FieldTypesEnum.Name(common_pb2.PHONE_NUMBER): - return usphone.Phone() - if type == common_pb2.FieldTypesEnum.Name(common_pb2.US_SSN): - return usssn.UsSsn() - if type == common_pb2.FieldTypesEnum.Name(common_pb2.UK_NHS): - return uknhs.UkNhs() - if type == common_pb2.FieldTypesEnum.Name(common_pb2.DATE_TIME): - return ner.Ner() - if type == common_pb2.FieldTypesEnum.Name(common_pb2.NRP): - return ner.Ner() - if type == common_pb2.FieldTypesEnum.Name(common_pb2.LOCATION): - return ner.Ner() - if type == common_pb2.FieldTypesEnum.Name(common_pb2.PERSON): - return ner.Ner() - - create = staticmethod(create) - - -types_refs = { - common_pb2.FieldTypesEnum.Name(common_pb2.CREDIT_CARD), - common_pb2.FieldTypesEnum.Name(common_pb2.CRYPTO), - common_pb2.FieldTypesEnum.Name(common_pb2.DATE_TIME), - common_pb2.FieldTypesEnum.Name(common_pb2.DOMAIN_NAME), - common_pb2.FieldTypesEnum.Name(common_pb2.EMAIL_ADDRESS), - common_pb2.FieldTypesEnum.Name(common_pb2.IBAN_CODE), - common_pb2.FieldTypesEnum.Name(common_pb2.IP_ADDRESS), - common_pb2.FieldTypesEnum.Name(common_pb2.NRP), - common_pb2.FieldTypesEnum.Name(common_pb2.LOCATION), - common_pb2.FieldTypesEnum.Name(common_pb2.PERSON), - common_pb2.FieldTypesEnum.Name(common_pb2.US_BANK_NUMBER), - common_pb2.FieldTypesEnum.Name(common_pb2.US_DRIVER_LICENSE), - common_pb2.FieldTypesEnum.Name(common_pb2.US_ITIN), - common_pb2.FieldTypesEnum.Name(common_pb2.US_PASSPORT), - common_pb2.FieldTypesEnum.Name(common_pb2.PHONE_NUMBER), - common_pb2.FieldTypesEnum.Name(common_pb2.US_SSN), - common_pb2.FieldTypesEnum.Name(common_pb2.UK_NHS), -} diff --git a/presidio-analyzer/analyzer/field_types/field_regex_pattern.py b/presidio-analyzer/analyzer/field_types/field_regex_pattern.py deleted file mode 100644 index 9440eee50..000000000 --- a/presidio-analyzer/analyzer/field_types/field_regex_pattern.py +++ /dev/null @@ -1,4 +0,0 @@ -class RegexFieldPattern(object): - name = "Regex Field Pattern" - regex = None - strength = 0.0 diff --git a/presidio-analyzer/analyzer/field_types/field_type.py b/presidio-analyzer/analyzer/field_types/field_type.py deleted file mode 100644 index 42d0f9b4c..000000000 --- a/presidio-analyzer/analyzer/field_types/field_type.py +++ /dev/null @@ -1,16 +0,0 @@ -class FieldType(object): - - name = "Field Type Name" - text = "" - patterns = [] - contexts = [] - should_check_checksum = False - - def check_checksum(self): - return False - - def validate_result(self): - return False - - def check_label(self): - return False diff --git a/presidio-analyzer/analyzer/field_types/globally/__init__.py b/presidio-analyzer/analyzer/field_types/globally/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/presidio-analyzer/analyzer/field_types/globally/credit_card.py b/presidio-analyzer/analyzer/field_types/globally/credit_card.py deleted file mode 100644 index d0e19957f..000000000 --- a/presidio-analyzer/analyzer/field_types/globally/credit_card.py +++ /dev/null @@ -1,48 +0,0 @@ -from field_types import field_type, field_regex_pattern - - -class CreditCard(field_type.FieldType): - name = "CREDIT_CARD" - should_check_checksum = True - context = [ - "credit", - "card", - "visa", - "mastercard", - # "american express" #TODO: add after adding keyphrase support - "amex", - "discover", - "jcb", - "diners", - "maestro", - "instapayment" - ] - - patterns = [] - - # All credit cards - weak pattern is used, since credit cards has checksum - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\b((4\d{3})|(5[0-5]\d{2})|(6\d{3})|(1\d{3})|(3\d{3}))[- ]?(\d{3,4})[- ]?(\d{3,4})[- ]?(\d{3,5})\b' # noqa: E501 - pattern.name = 'All Credit Cards (weak)' - pattern.strength = 0.3 - patterns.append(pattern) - - def __luhn_checksum(self): - def digits_of(n): - return [int(d) for d in str(n)] - - digits = digits_of(self.sanitized_value) - odd_digits = digits[-1::-2] - even_digits = digits[-2::-2] - checksum = 0 - checksum += sum(odd_digits) - for d in even_digits: - checksum += sum(digits_of(d * 2)) - return checksum % 10 - - def __sanitize_value(self): - self.sanitized_value = self.text.replace('-', '').replace(' ', '') - - def check_checksum(self): - self.__sanitize_value() - return self.__luhn_checksum() == 0 diff --git a/presidio-analyzer/analyzer/field_types/globally/crypto.py b/presidio-analyzer/analyzer/field_types/globally/crypto.py deleted file mode 100644 index 87b2620f5..000000000 --- a/presidio-analyzer/analyzer/field_types/globally/crypto.py +++ /dev/null @@ -1,35 +0,0 @@ -from hashlib import sha256 -from field_types import field_type, field_regex_pattern - - -class Crypto(field_type.FieldType): - name = "CRYPTO" - context = ["wallet", "btc", "bitcoin", "crypto"] - - should_check_checksum = True - - patterns = [] - - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\b[13][a-km-zA-HJ-NP-Z0-9]{26,33}\b' - pattern.name = 'Crypto (Medium)' - pattern.strength = 0.5 - patterns.append(pattern) - """Copied from: - http://rosettacode.org/wiki/Bitcoin/address_validation#Python - """ - - def __decode_base58(self, bc, length): - digits58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' - n = 0 - for char in bc: - n = n * 58 + digits58.index(char) - return n.to_bytes(length, 'big') - - def check_checksum(self): - # try: - bcbytes = self.__decode_base58(self.text, 25) - return bcbytes[-4:] == sha256(sha256( - bcbytes[:-4]).digest()).digest()[:4] - # except Exception: - # return False diff --git a/presidio-analyzer/analyzer/field_types/globally/domain.py b/presidio-analyzer/analyzer/field_types/globally/domain.py deleted file mode 100644 index e26c9bfac..000000000 --- a/presidio-analyzer/analyzer/field_types/globally/domain.py +++ /dev/null @@ -1,25 +0,0 @@ -import tldextract -from field_types import field_type, field_regex_pattern - - -class Domain(field_type.FieldType): - name = "DOMAIN_NAME" - should_check_checksum = True - context = ["domain", "ip"] - - patterns = [] - - # Basic pattern, since domain has a checksum function - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\b(((([a-zA-Z0-9])|([a-zA-Z0-9][a-zA-Z0-9\-]{0,86}[a-zA-Z0-9]))\.(([a-zA-Z0-9])|([a-zA-Z0-9][a-zA-Z0-9\-]{0,73}[a-zA-Z0-9]))\.(([a-zA-Z0-9]{2,12}\.[a-zA-Z0-9]{2,12})|([a-zA-Z0-9]{2,25})))|((([a-zA-Z0-9])|([a-zA-Z0-9][a-zA-Z0-9\-]{0,162}[a-zA-Z0-9]))\.(([a-zA-Z0-9]{2,12}\.[a-zA-Z0-9]{2,12})|([a-zA-Z0-9]{2,25}))))\b' # noqa: E501 - pattern.name = 'Domain ()' - pattern.strength = 0.5 - - patterns.append(pattern) - - def check_checksum(self): - result = tldextract.extract(self.text) - if result.fqdn != '': - return True - else: - return False diff --git a/presidio-analyzer/analyzer/field_types/globally/email.py b/presidio-analyzer/analyzer/field_types/globally/email.py deleted file mode 100644 index 75a486a69..000000000 --- a/presidio-analyzer/analyzer/field_types/globally/email.py +++ /dev/null @@ -1,23 +0,0 @@ -import tldextract -from field_types import field_type, field_regex_pattern - - -class Email(field_type.FieldType): - name = "EMAIL_ADDRESS" - should_check_checksum = True - context = ["email"] - - patterns = [] - - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r"\b((([!#$%&'*+\-/=?^_`{|}~\w])|([!#$%&'*+\-/=?^_`{|}~\w][!#$%&'*+\-/=?^_`{|}~\.\w]{0,}[!#$%&'*+\-/=?^_`{|}~\w]))[@]\w+([-.]\w+)*\.\w+([-.]\w+)*)\b" # noqa: E501 - pattern.name = 'Email (Medium)' - pattern.strength = 0.5 - patterns.append(pattern) - - def check_checksum(self): - result = tldextract.extract(self.text) - if result.fqdn != '': - return True - else: - return False diff --git a/presidio-analyzer/analyzer/field_types/globally/iban.py b/presidio-analyzer/analyzer/field_types/globally/iban.py deleted file mode 100644 index 3bdad80f8..000000000 --- a/presidio-analyzer/analyzer/field_types/globally/iban.py +++ /dev/null @@ -1,35 +0,0 @@ -import string -from field_types import field_type, field_regex_pattern - - -class Iban(field_type.FieldType): - name = "IBAN_CODE" - context = ["iban"] - should_check_checksum = True - - patterns = [] - - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = u'[a-zA-Z]{2}[0-9]{2}[a-zA-Z0-9]{4}[0-9]{7}([a-zA-Z0-9]?){0,16}' # noqa: E501 - pattern.name = 'Iban (Medium)' - pattern.strength = 0.5 - patterns.append(pattern) - - def check_checksum(self): - LETTERS = { - ord(d): str(i) - for i, d in enumerate(string.digits + string.ascii_uppercase) - } - - def __number_iban(iban): - return (iban[4:] + iban[:4]).translate(LETTERS) - - def __generate_iban_check_digits(iban): - number_iban = __number_iban(iban[:2] + '00' + iban[4:]) - return '{:0>2}'.format(98 - (int(number_iban) % 97)) - - def __valid_iban(iban): - return int(__number_iban(iban)) % 97 == 1 - - return __generate_iban_check_digits( - self.text) == self.text[2:4] and __valid_iban(self.text) diff --git a/presidio-analyzer/analyzer/field_types/globally/ip.py b/presidio-analyzer/analyzer/field_types/globally/ip.py deleted file mode 100644 index 4455576fd..000000000 --- a/presidio-analyzer/analyzer/field_types/globally/ip.py +++ /dev/null @@ -1,22 +0,0 @@ -from field_types import field_type, field_regex_pattern - - -class Ip(field_type.FieldType): - name = "IP_ADDRESS" - context = ["ip", "ipv4", "ipv6"] - - patterns = [] - - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\b(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b' # noqa: E501 - pattern.name = 'IPv4' - pattern.strength = 0.6 - patterns.append(pattern) - - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\s*(?!.*::.*::)(?:(?!:)|:(?=:))(?:[0-9a-f]{0,4}(?:(?<=::)|(? 16: - if re.match(guid_pattern, self.text, - re.IGNORECASE | re.UNICODE) is not None: - return False - return True - - return False - - def check_label(self, label): - if self.name == "LOCATION" and (label == 'GPE' or label == 'LOC'): - return True - - if self.name == "PERSON" and label == 'PERSON': - return True - - if self.name == "DATE_TIME" and (label == 'DATE' or label == 'TIME'): - return True - - if self.name == "NRP" and label == 'NORP': - return True - - return False diff --git a/presidio-analyzer/analyzer/field_types/uk/__init__.py b/presidio-analyzer/analyzer/field_types/uk/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/presidio-analyzer/analyzer/field_types/uk/nhs.py b/presidio-analyzer/analyzer/field_types/uk/nhs.py deleted file mode 100644 index 1ead85dbf..000000000 --- a/presidio-analyzer/analyzer/field_types/uk/nhs.py +++ /dev/null @@ -1,40 +0,0 @@ -from field_types import field_type, field_regex_pattern - - -class UkNhs(field_type.FieldType): - name = "UK_NHS" - should_check_checksum = True - context = [ - "national health service", "nhs", "health services authority", - "health authority" - ] - - patterns = [] - - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\b([0-9]{3})[- ]?([0-9]{3})[- ]?([0-9]{4})\b' - pattern.name = 'NHS (medium)' - pattern.strength = 0.5 - patterns.append(pattern) - - patterns.sort(key=lambda p: p.strength, reverse=True) - - def __sanitize_value(self): - self.sanitized_value = self.text.replace('-', '').replace(' ', '') - - def check_checksum(self): - self.__sanitize_value() - - multiplier = 10 - total = 0 - for c in self.sanitized_value: - val = int(c) - total = total + val * multiplier - multiplier = multiplier - 1 - - remainder = total % 11 - check_digit = 11 - remainder - if check_digit == 11: - return True - - return False diff --git a/presidio-analyzer/analyzer/field_types/us/__init__.py b/presidio-analyzer/analyzer/field_types/us/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/presidio-analyzer/analyzer/field_types/us/bank.py b/presidio-analyzer/analyzer/field_types/us/bank.py deleted file mode 100644 index f21c50f2a..000000000 --- a/presidio-analyzer/analyzer/field_types/us/bank.py +++ /dev/null @@ -1,24 +0,0 @@ -from field_types import field_type, field_regex_pattern - - -class UsBank(field_type.FieldType): - name = "US_BANK_NUMBER" - context = [ - "bank" - # TODO: change to "checking account" as part of keyphrase change - "checking", - "account", - "account#", - "acct", - "saving", - "debit" - ] - - patterns = [] - - # Weak pattern: all passport numbers are a weak match, e.g., 14019033 - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\b[0-9]{8,17}\b' - pattern.name = 'Bank Account (weak)' - pattern.strength = 0.05 - patterns.append(pattern) diff --git a/presidio-analyzer/analyzer/field_types/us/driver_license.py b/presidio-analyzer/analyzer/field_types/us/driver_license.py deleted file mode 100644 index b92512c25..000000000 --- a/presidio-analyzer/analyzer/field_types/us/driver_license.py +++ /dev/null @@ -1,102 +0,0 @@ -from field_types import field_type, field_regex_pattern - - -class UsDriverLicense(field_type.FieldType): - - name = "US_DRIVER_LICENSE" - context = [ - "driver", "license", "permit", "id", "lic", "identification", "card", - "cards", "dl", "dls", "cdls", "id", "lic#" - ] - - # List from https://ntsi.com/drivers-license-format/ - # --------------- - patterns = [] - - # WA Driver License number is relatively unique as it also - # includes '*' chars. - # However it can also be 12 letters which makes every 12 letter' - # word a match. Therefore we split WA driver license - # regex: r'\b([A-Z][A-Z0-9*]{11})\b' into two regexes - # With different weights, one to indicate letters only and - # one to indicate at least one digit or one '*' - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\b((?=.*\d)([A-Z][A-Z0-9*]{11})|(?=.*\*)([A-Z][A-Z0-9*]{11}))\b' # noqa: E501 - pattern.name = 'Driver License - WA (weak) ' - pattern.strength = 0.4 - patterns.append(pattern) - - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\b([A-Z]{12})\b' - pattern.name = 'Driver License - WA (very weak) ' - pattern.strength = 0.0 - patterns.append(pattern) - - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\b([A-Z][0-9]{3,6}|[A-Z][0-9]{5,9}|[A-Z][0-9]{6,8}|[A-Z][0-9]{4,8}|[A-Z][0-9]{9,11}|[A-Z]{1,2}[0-9]{5,6}|H[0-9]{8}|V[0-9]{6}|X[0-9]{8}|A-Z]{2}[0-9]{2,5}|[A-Z]{2}[0-9]{3,7}|[0-9]{2}[A-Z]{3}[0-9]{5,6}|[A-Z][0-9]{13,14}|[A-Z][0-9]{18}|[A-Z][0-9]{6}R|[A-Z][0-9]{9}|[A-Z][0-9]{1,12}|[0-9]{9}[A-Z]|[A-Z]{2}[0-9]{6}[A-Z]|[0-9]{8}[A-Z]{2}|[0-9]{3}[A-Z]{2}[0-9]{4}|[A-Z][0-9][A-Z][0-9][A-Z]|[0-9]{7,8}[A-Z])\b' # noqa: E501 - pattern.name = 'Driver License - Alphanumeric (weak) ' - pattern.strength = 0.3 - patterns.append(pattern) - - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\b([0-9]{1,9}|[0-9]{4,10}|[0-9]{6,10}|[0-9]{1,12}|[0-9]{12,14}|[0-9]{16})\b' # noqa: E501 - pattern.name = 'Driver License - Digits (very weak)' - pattern.strength = 0.05 - patterns.append(pattern) - patterns.sort(key=lambda p: p.strength, reverse=True) - ''' - # Regex per state - regexes = { - 'AL': r'^[0-9]{1,7}\b', - 'AK': r'^[0-9]{1,7}\b', - 'AZ': r'\b[A-Z][0-9]{8}\b|[0-9]{9}\b', - 'AR': r'^[0-9]{4,9}\b', - 'CA': r'\b[A-Z][0-9]{7}\b', - 'CO': r'^[0-9]{9}\b|[A-Z][0-9]{3,6}\b|[A-Z]{2}[0-9]{2,5}\b', - 'CT': r'^[0-9]{9}\b', - 'DE': r'^[0-9]{1,7}\b', - 'DC': r'^[0-9]{7}\b|[0-9]{9}\b', - 'FL': r'\b[A-Z][0-9]{1,12}\b', - 'GA': r'^[0-9]{7,9}\b', - 'HI': r'^[0-9]{9}\b|H[0-9]{8}\b', - 'ID': r'^[0-9]{9}\b|[A-Z]{2}[0-9]{6}[A-Z]\b', - 'IL': r'\b[A-Z][0-9]{11,12}', - 'IN': r'\b[A-Z][0-9]{9}\b|[0-9]{9,10}\b', - 'IA': r'^[0-9]{9}\b|[0-9]{3}[A-Z]{2}[0-9]{4}\b', - 'KS': r'\b[A-Z][0-9][A-Z][0-9][A-Z]\b|[A-Z][0-9]{8}\b|[0-9]{9}\b', - 'KY': r'\b[A-Z][0-9]{8}\b|[0-9]{9}\b', - 'LA': r'^[0-9]{9}\b', - 'ME': r'^[0-9]{7}\b|[0-9]{8}\b|[0-9]{8}[A-Z]\b', - 'MD': r'\b[A-Z][0-9]{12}\b', - 'MA': r'\b[A-Z][0-9]{8}\b|[0-9]{9}\b', - 'MI': r'\b[A-Z][0-9]{12}\b|[A-Z][0-9]{10}\b', - 'MN': r'\b[A-Z][0-9]{12}\b', - 'MS': r'^[0-9]{9}\b', - 'MO': r'\b[A-Z][0-9]{5,9}\b|[A-Z][0-9]{6}R\b|[0-9]{9}\b|[0-9]{8}[A-Z]{2}\b|[0-9]{9}[A-Z]\b', # noqa: E501 - 'MT': r'^[0-9]{13,14}\b|[A-Z]{9}\b|[A-Z][0-9]{8}\b', - 'NE': r'\b[A-Z][0-9]{6,8}\b', - 'NV': r'^[0-9]{9,10}\b|[0-9]{12}\b|x[0-9]{8}\b', - 'NH': r'^[0-9]{2}[A-Z]{3}[0-9]{5}b', - 'NJ': r'\b[A-Z][0-9]{14}\b', - 'NM': r'^[0-9]{8,9}\b', - 'NY': r'\b[A-Z][0-9]{7}\b|[A-Z][0-9]{18}\b|[0-9]{8,9}\b|[0-9]{16}\b|[A-Z]{8}\b', - 'NC': r'^[0-9]{1,12}\b', - 'ND': r'\b[A-Z]{3}[0-9]{6}\b|[0-9]{9}\b', - 'OH': r'\b[A-Z][0-9]{4,8}\b|[A-Z]{2}[0-9]{3,7}\b|[0-9]{8}\b', - 'OK': r'\b[A-Z][0-9]{9}\b|[0-9]{9}\b', - 'OR': r'^[0-9]{1,9}\b', - 'PA': r'^[0-9]{8}\b', - 'RI': r'^[0-9]{7}\b|v[0-9]{6}\b', - 'SC': r'^[0-9]{5,11}\b', - 'SD': r'^[0-9]{6,10}\b|[0-9]{12}', - 'TN': r'^[0-9]{7,9}\b', - 'TX': r'^[0-9]{7-8}\b', - 'UT': r'^[0-9]{4,10}\b', - 'VT': r'^[0-9]{8}\b|[0-9]{7}[A-Z]\b', - 'VA': r'\b[A-Z][0-9]{9,11}\b|[0-9]{9}\b', - 'WA': r'^[a-z*]{7}[0-9]{3}[0-9a-z]{2}\b', - 'WV': r'^[0-9]{7}\b|[A-Z]{1,2}[0-9]{5,6}\b', - 'WI': r'\b[A-Z][0-9]{13}\b', - 'WY': r'^[0-9]{9-10}\b' - } - ''' diff --git a/presidio-analyzer/analyzer/field_types/us/itin.py b/presidio-analyzer/analyzer/field_types/us/itin.py deleted file mode 100644 index 242701476..000000000 --- a/presidio-analyzer/analyzer/field_types/us/itin.py +++ /dev/null @@ -1,30 +0,0 @@ -from field_types import field_type, field_regex_pattern - - -class UsItin(field_type.FieldType): - name = "US_ITIN" - context = [ - "individual", "taxpayer", "itin", "tax", "payer", "taxid", "tin" - ] - - patterns = [] - - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'(\b(9\d{2})[- ]{1}((7[0-9]{1}|8[0-8]{1})|(9[0-2]{1})|(9[4-9]{1}))(\d{4})\b)|(\b(9\d{2})((7[0-9]{1}|8[0-8]{1})|(9[0-2]{1})|(9[4-9]{1}))[- ]{1}(\d{4})\b)' # noqa: E501 - pattern.name = 'Itin (very weak)' - pattern.strength = 0.05 - patterns.append(pattern) - - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\b(9\d{2})((7[0-9]{1}|8[0-8]{1})|(9[0-2]{1})|(9[4-9]{1}))(\d{4})\b' # noqa: E501 - pattern.name = 'Itin (weak)' - pattern.strength = 0.3 - patterns.append(pattern) - - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\b(9\d{2})[- ]{1}((7[0-9]{1}|8[0-8]{1})|(9[0-2]{1})|(9[4-9]{1}))[- ]{1}(\d{4})\b' # noqa: E501 - pattern.name = 'Itin (medium)' - pattern.strength = 0.5 - patterns.append(pattern) - - patterns.sort(key=lambda p: p.strength, reverse=True) diff --git a/presidio-analyzer/analyzer/field_types/us/passport.py b/presidio-analyzer/analyzer/field_types/us/passport.py deleted file mode 100644 index 194362e4c..000000000 --- a/presidio-analyzer/analyzer/field_types/us/passport.py +++ /dev/null @@ -1,18 +0,0 @@ -from field_types import field_type, field_regex_pattern - - -class UsPassport(field_type.FieldType): - name = "US_PASSPORT" - context = [ - "us", "united", "states", "passport", "number", "passport#", "travel", - "document" - ] - - patterns = [] - - # Weak pattern: all passport numbers are a weak match, e.g., 14019033 - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'(\b[0-9]{9}\b)' - pattern.name = 'Passport (very weak)' - pattern.strength = 0.05 - patterns.append(pattern) diff --git a/presidio-analyzer/analyzer/field_types/us/phone.py b/presidio-analyzer/analyzer/field_types/us/phone.py deleted file mode 100644 index d60397e8f..000000000 --- a/presidio-analyzer/analyzer/field_types/us/phone.py +++ /dev/null @@ -1,30 +0,0 @@ -from field_types import field_type, field_regex_pattern - - -class Phone(field_type.FieldType): - name = "PHONE_NUMBER" - context = ["phone", "number", "telephone", "cell", "mobile", "call"] - patterns = [] - - # Strong pattern: e.g., (425) 882 8080, 425 882-8080, 425.882.8080 - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'(\(\d{3}\)\s*\d{3}[-\.\s]??\d{4}|d{3}[-\.\s]\d{3}[-\.\s]\d{4})' # noqa: E501 - pattern.name = 'Phone (strong)' - pattern.strength = 0.7 - patterns.append(pattern) - - # Medium pattern: e.g., 425 8828080 - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\b(\d{3}[-\.\s]\d{3}[-\.\s]??\d{4})\b' - pattern.name = 'Phone (medium)' - pattern.strength = 0.5 - patterns.append(pattern) - - # Weak pattern: e.g., 4258828080 - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'(\b\d{10}\b)' - pattern.name = 'Phone (weak)' - pattern.strength = 0.05 - patterns.append(pattern) - - patterns.sort(key=lambda p: p.strength, reverse=True) diff --git a/presidio-analyzer/analyzer/field_types/us/ssn.py b/presidio-analyzer/analyzer/field_types/us/ssn.py deleted file mode 100644 index 0a5cdf75e..000000000 --- a/presidio-analyzer/analyzer/field_types/us/ssn.py +++ /dev/null @@ -1,38 +0,0 @@ -from field_types import field_type, field_regex_pattern - - -class UsSsn(field_type.FieldType): - name = "US_SSN" - context = [ - "social", - "security", - # "sec", TODO: add keyphrase support in "social sec" - "ssn", - "ssns", - "ssn#", - "ss#", - "ssid" - ] - - # Master Regex: r'\b([0-9]{3})-?([0-9]{2})-?([0-9]{4})\b' - patterns = [] - - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\b(([0-9]{5})-([0-9]{4})|([0-9]{3})-([0-9]{6}))\b' - pattern.name = 'SSN (very weak)' - pattern.strength = 0.05 - patterns.append(pattern) - - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\b[0-9]{9}\b' - pattern.name = 'SSN (weak)' - pattern.strength = 0.3 - patterns.append(pattern) - - pattern = field_regex_pattern.RegexFieldPattern() - pattern.regex = r'\b([0-9]{3})-([0-9]{2})-([0-9]{4})\b' - pattern.name = 'SSN (medium)' - pattern.strength = 0.5 - patterns.append(pattern) - - patterns.sort(key=lambda p: p.strength, reverse=True) diff --git a/presidio-analyzer/analyzer/local_recognizer.py b/presidio-analyzer/analyzer/local_recognizer.py new file mode 100644 index 000000000..7de4c25d9 --- /dev/null +++ b/presidio-analyzer/analyzer/local_recognizer.py @@ -0,0 +1,10 @@ +from analyzer import EntityRecognizer + + +class LocalRecognizer(EntityRecognizer): + + def __init__(self, supported_entities, supported_language, name=None, + version=None, **kwargs): + super().__init__(supported_entities=supported_entities, + supported_language=supported_language, name=name, + version=version) diff --git a/presidio-analyzer/analyzer/matcher.py b/presidio-analyzer/analyzer/matcher.py deleted file mode 100644 index d7b9d9412..000000000 --- a/presidio-analyzer/analyzer/matcher.py +++ /dev/null @@ -1,347 +0,0 @@ -import datetime -import logging -import os -import en_core_web_lg -import common_pb2 -import tldextract -from field_types import field_factory -from field_types.globally import ner -import re2 as re - -CONTEXT_SIMILARITY_THRESHOLD = 0.65 -CONTEXT_SIMILARITY_FACTOR = 0.35 -MIN_SCORE_WITH_CONTEXT_SIMILARITY = 0.6 -NER_STRENGTH = 0.85 -CONTEXT_PREFIX_COUNT = 5 -CONTEXT_SUFFIX_COUNT = 0 - - -class Matcher(object): - """Search for patterns and NER in text""" - - def __init__(self): - """Constructor - Load spacy model once - """ - - # Set log level - loglevel = os.environ.get("LOG_LEVEL", "INFO") - self.logger = logging.getLogger(__name__) - self.logger.setLevel(loglevel) - logging.getLogger('tldextract').setLevel(loglevel) - - # Caching top level domains - tldextract.extract("") - - # Load spaCy lg model - self.logger.info("Loading NLP model...") - self.nlp = en_core_web_lg.load(disable=['parser', 'tagger']) - - def __context_to_keywords(self, context): - """Convert context text to relevant keywords - - Args: - context: words prefix of specified pattern - """ - - nlp_context = self.nlp(context) - - # Remove punctuation, stop words and take lemma form and remove - # duplicates - keywords = list( - filter( - lambda k: not self.nlp.vocab[k.text].is_stop and not k.is_punct and k.lemma_ != '-PRON-' and k.lemma_ != 'be', # noqa: E501 - nlp_context)) - keywords = list(set(map(lambda k: k.lemma_.lower(), keywords))) - - return keywords - - def __calculate_context_similarity(self, context, field): - """Context similarity is 1 if there's exact match between a keyword in - context and any keyword in field.context - - Args: - context: words prefix of specified pattern - field: current field type (pattern) - """ - - context_keywords = self.__context_to_keywords(context) - - # TODO: remove after supporting keyphrases (instead of keywords) - if 'card' in field.context: - field.context.remove('card') - if 'number' in field.context: - field.context.remove('number') - - similarity = 0.0 - for context_keyword in context_keywords: - if context_keyword in field.context: - similarity = 1 - break - - return similarity - - def __calculate_score(self, doc, match_strength, field, start, end): - """Calculate score of match by context - - Args: - doc: spacy document to analyze - match_strength: Base score according to the pattern strength - field: current field type (pattern) - start: match start offset - end: match end offset - """ - - if field.should_check_checksum: - if field.check_checksum() is not True: - self.logger.debug('Checksum failed for %s', field.text) - return 0 - else: - return 1.0 - - score = match_strength - - # Add context similarity - context = self.__extract_context(doc, start, end) - context_similarity = self.__calculate_context_similarity( - context, field) - if context_similarity >= CONTEXT_SIMILARITY_THRESHOLD: - score += context_similarity * CONTEXT_SIMILARITY_FACTOR - score = max(score, MIN_SCORE_WITH_CONTEXT_SIMILARITY) - - return min(score, 1) - - def __create_result(self, doc, match_strength, field, start, end): - """Create analyze result - - Args: - doc: spacy document to analyze - match_strength: Base score according to the pattern strength - field: current field type (pattern) - start: match start offset - end: match end offset - """ - - res = common_pb2.AnalyzeResult() - res.field.name = field.name - res.text = field.text - - # check score - calc_score_start_time = datetime.datetime.now() - if isinstance(field, type(ner.Ner())): - res.score = NER_STRENGTH - else: - res.score = self.__calculate_score(doc, match_strength, field, - start, end) - calc_score_time = datetime.datetime.now() - calc_score_start_time - - self.logger.debug('--- calc_prob_time[{}]: {}.{} seconds'.format( - field.name, calc_score_time.seconds, calc_score_time.microseconds)) - - res.location.start = start - res.location.end = end - res.location.length = end - start - - self.logger.debug("field: %s Value: %s Span: '%s:%s' Score: %.2f", - res.field, res.text, start, end, res.score) - return res - - def __extract_context(self, doc, start, end): - """Extract context for a specified match - - Args: - doc: spacy document to analyze - start: match start offset - end: match end offset - """ - - prefix = doc.text[0:start].split() - suffix = doc.text[end + 1:].split() - context = '' - - context += ' '.join( - prefix[max(0, - len(prefix) - CONTEXT_PREFIX_COUNT):len(prefix)]) - context += ' ' - context += ' '.join(suffix[0:min(CONTEXT_SUFFIX_COUNT, len(suffix))]) - - return context - - def __check_pattern(self, doc, results, field): - """Check for specific pattern in text - - Args: - doc: spacy document to analyze - results: array containing the created results - field: current field type (pattern) - """ - - max_matched_strength = -1.0 - for pattern in field.patterns: - if pattern.strength <= max_matched_strength: - break - result_found = False - - match_start_time = datetime.datetime.now() - matches = re.finditer( - pattern.regex, - doc.text, - flags=re.IGNORECASE | re.DOTALL | re.MULTILINE) - match_time = datetime.datetime.now() - match_start_time - self.logger.debug('--- match_time[{}]: {}.{} seconds'.format( - field.name, match_time.seconds, match_time.microseconds)) - - for match in matches: - start, end = match.span() - field.text = doc.text[start:end] - - # Skip empty results - if field.text == '': - continue - - # Don't add duplicate - if len(field.patterns) > 1 and any( - ((x.location.start == start) or (x.location.end == end)) - and ((x.field.name == field.name)) for x in results): - continue - - res = self.__create_result(doc, pattern.strength, field, start, - end) - - if res is None or res.score == 0: - continue - - # Don't add overlap - # if any(x.location.end >= start and x.score == 1.0 - # for x in results): - # continue - - results.append(res) - result_found = True - - if result_found: - max_matched_strength = pattern.strength - - def __check_ner(self, doc, results, field): - """Check for specific NER in text - - Args: - doc: spacy document to analyze - results: array containing the created results - field: current field type (NER) - """ - - for ent in doc.ents: - if field.check_label(ent.label_) is False: - continue - field.text = ent.text - - if field.validate_result(): - res = self.__create_result(doc, NER_STRENGTH, field, - ent.start_char, ent.end_char) - - if res is not None: - results.append(res) - - return results - - def __sanitize_text(self, text): - """Replace newline with whitespace to ease spacy analyze process - - Args: - text: document text - """ - - text = text.replace('\n', ' ') - text = text.replace('\r', ' ') - return text - - def __analyze_field_type(self, doc, field_type_string_filter, results): - """Analyze specific field type (NER/Pattern) - - Args: - doc: spacy document to analyze - field_type_string_filter: field type descriptor - results: array containing the created results - """ - - current_field = field_factory.FieldFactory.create( - field_type_string_filter) - - if current_field is None: - return - - # Check for ner field - analyze_start_time = datetime.datetime.now() - if isinstance(current_field, type(ner.Ner())): - current_field.name = field_type_string_filter - self.__check_ner(doc, results, current_field) - else: - self.__check_pattern(doc, results, current_field) - - analyze_time = datetime.datetime.now() - analyze_start_time - self.logger.debug('--- analyze_time[{}]: {}.{} seconds'.format( - field_type_string_filter, analyze_time.seconds, - analyze_time.microseconds)) - - def __is_checksum_result(self, result): - if result.score == 1.0: - result_field = field_factory.FieldFactory.create(result.field.name) - return result_field.should_check_checksum - return False - - def __remove_checksum_duplicates(self, results): - results_with_checksum = list( - filter(lambda r: self.__is_checksum_result(r), results)) - - # Remove matches of the same text, if there's a match with checksum and - # score = 1 - filtered_results = [] - - for result in results: - valid_result = True - if result not in results_with_checksum: - for result_with_checksum in results_with_checksum: - # If result is equal to or substring of a checksum result - if (result.text == result_with_checksum.text - or (result.text in result_with_checksum.text - and result.location.start >= - result_with_checksum.location.start - and result.location.end <= - result_with_checksum.location.end)): - valid_result = False - break - - if valid_result: - filtered_results.append(result) - - return filtered_results - - def analyze_text(self, text, field_type_filters): - """Analyze text. - - Args: - text: text to analyze - field_type_filters: filters array such as [{"name":PERSON"}, - {"name": "LOCATION"}] - """ - - results = [] - field_type_string_filters = [] - - if field_type_filters is None or not field_type_filters: - field_type_string_filters = field_factory.types_refs - else: - for field_type in field_type_filters: - field_type_string_filters.append(field_type.name) - - sanitized_text = self.__sanitize_text(text) - doc = self.nlp(sanitized_text) - - for field_type_string_filter in field_type_string_filters: - self.__analyze_field_type(doc, field_type_string_filter, results) - - results = self.__remove_checksum_duplicates(results) - results.sort(key=lambda x: x.location.start, reverse=False) - - return results diff --git a/presidio-analyzer/analyzer/pattern.py b/presidio-analyzer/analyzer/pattern.py new file mode 100644 index 000000000..c14f6a482 --- /dev/null +++ b/presidio-analyzer/analyzer/pattern.py @@ -0,0 +1,33 @@ +class Pattern: + + def __init__(self, name, pattern, strength): + """ + A class that represents a regex pattern. + :param name: the name of the pattern + :param pattern: the regex pattern to detect + :param strength: the pattern's strength (values varies 0-1) + """ + self.name = name + self.pattern = pattern + self.strength = strength + + def to_dict(self): + """ + Turns this instance into a dictionary + :return: a dictionary + """ + + return_dict = {"name": self.name, + "strength": self.strength, + "pattern": self.pattern + } + return return_dict + + @classmethod + def from_dict(cls, pattern_dict): + """ + Loads an instance from a dictionary + :param pattern_dict: a dictionary holding the pattern's parameters + :return: a Pattern instance + """ + return cls(**pattern_dict) diff --git a/presidio-analyzer/analyzer/pattern_recognizer.py b/presidio-analyzer/analyzer/pattern_recognizer.py new file mode 100644 index 000000000..f30d5750a --- /dev/null +++ b/presidio-analyzer/analyzer/pattern_recognizer.py @@ -0,0 +1,146 @@ +import datetime +from abc import abstractmethod + +from analyzer import LocalRecognizer, Pattern, RecognizerResult + +# Import 're2' regex engine if installed, if not- import 'regex' +try: + import re2 as re +except ImportError: + import regex as re + + +class PatternRecognizer(LocalRecognizer): + + def __init__(self, supported_entity, name=None, supported_language='en', + patterns=None, + black_list=None, context=None, version="0.0.1"): + """ + :param patterns: the list of patterns to detect + :param black_list: the list of words to detect + :param context: list of context words + """ + if not supported_entity: + raise ValueError( + "Pattern recognizer should be initialized with entity") + + if not patterns and not black_list: + raise ValueError( + "Pattern recognizer should be initialized with patterns" + " or with black list") + + super().__init__(supported_entities=[supported_entity], + supported_language=supported_language, + name=name, + version=version) + if patterns is None: + self.patterns = [] + else: + self.patterns = patterns + self.context = context + + if black_list: + black_list_pattern = PatternRecognizer.__black_list_to_regex( + black_list) + self.patterns.append(black_list_pattern) + self.black_list = black_list + else: + self.black_list = [] + + def load(self): + pass + + def analyze(self, text, entities): + results = [] + + if len(self.patterns) > 0: + pattern_result = self.__analyze_patterns(text) + + if pattern_result: + results.extend(pattern_result) + + return results + + @staticmethod + def __black_list_to_regex(black_list): + """ + Converts a list of word to a matching regex, to be analyzed by the + regex engine as a part of the analyze logic + + :param black_list: the list of words to detect + :return:the regex of the words for detection + """ + regex = r"(?:^|(?<= ))(" + '|'.join(black_list) + r")(?:(?= )|$)" + return Pattern(name="black_list", pattern=regex, strength=1.0) + + @abstractmethod + def validate_result(self, pattern_text, pattern_result): + """ + Validates the pattern logic, for example by running + checksum on a detected pattern. + + :param pattern_text: the text to validated. + Only the part in text that was detected by the regex engine + :param pattern_result: The output of a specific pattern + detector that needs to be validated + :return: the updated result of the pattern. + For example, if a validation logic increased or decreased the score + that was given by a regex pattern. + """ + return pattern_result + + def __analyze_patterns(self, text): + """ + Evaluates all patterns in the provided text, including words in + the provided blacklist + + :param text: text to analyze + :return: A list of RecognizerResult + """ + results = [] + for pattern in self.patterns: + match_start_time = datetime.datetime.now() + matches = re.finditer( + pattern.pattern, + text, + flags=re.IGNORECASE | re.DOTALL | re.MULTILINE) + match_time = datetime.datetime.now() - match_start_time + self.logger.debug('--- match_time[{}]: {}.{} seconds'.format( + pattern.name, match_time.seconds, match_time.microseconds)) + + for match in matches: + start, end = match.span() + current_match = text[start:end] + + # Skip empty results + if current_match == '': + continue + + res = RecognizerResult(self.supported_entities[0], start, end, + pattern.strength) + res = self.validate_result(current_match, res) + + if res: + results.append(res) + + return results + + def to_dict(self): + return_dict = super().to_dict() + + return_dict["patterns"] = [pat.to_dict() for pat in self.patterns] + return_dict["black_list"] = self.black_list + return_dict["context"] = self.context + return_dict["supported_entity"] = return_dict["supported_entities"][0] + del (return_dict["supported_entities"]) + + return return_dict + + @classmethod + def from_dict(cls, pattern_recognizer_dict): + patterns = pattern_recognizer_dict.get("patterns") + if patterns: + patterns_list = [Pattern.from_dict(pat) for pat in patterns] + pattern_recognizer_dict['patterns'] = patterns_list + + return cls(**pattern_recognizer_dict) diff --git a/presidio-analyzer/analyzer/predefined_recognizers/__init__.py b/presidio-analyzer/analyzer/predefined_recognizers/__init__.py new file mode 100644 index 000000000..e8c95e69b --- /dev/null +++ b/presidio-analyzer/analyzer/predefined_recognizers/__init__.py @@ -0,0 +1,14 @@ +from .credit_card_recognizer import CreditCardRecognizer # noqa: F401 +from .spacy_recognizer import SpacyRecognizer # noqa: F401 +from .crypto_recognizer import CryptoRecognizer # noqa: F401 +from .domain_recognizer import DomainRecognizer # noqa: F401 +from .email_recognizer import EmailRecognizer # noqa: F401 +from .iban_recognizer import IbanRecognizer # noqa: F401 +from .ip_recognizer import IpRecognizer # noqa: F401 +from .uk_nhs_recognizer import NhsRecognizer # noqa: F401 +from .us_bank_recognizer import UsBankRecognizer # noqa: F401 +from .us_driver_license_recognizer import UsLicenseRecognizer # noqa: F401 +from .us_itin_recognizer import UsItinRecognizer # noqa: F401 +from .us_passport_recognizer import UsPassportRecognizer # noqa: F401 +from .us_phone_recognizer import UsPhoneRecognizer # noqa: F401 +from .us_ssn_recognizer import UsSsnRecognizer # noqa: F401 diff --git a/presidio-analyzer/analyzer/predefined_recognizers/credit_card_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/credit_card_recognizer.py new file mode 100644 index 000000000..fe7cdcf4d --- /dev/null +++ b/presidio-analyzer/analyzer/predefined_recognizers/credit_card_recognizer.py @@ -0,0 +1,55 @@ +from analyzer import Pattern +from analyzer import PatternRecognizer + +REGEX = r'\b((4\d{3})|(5[0-5]\d{2})|(6\d{3})|(1\d{3})|(3\d{3}))[- ]?(\d{3,4})[- ]?(\d{3,4})[- ]?(\d{3,5})\b' # noqa: E501 +CONTEXT = [ + "credit", + "card", + "visa", + "mastercard", + "cc ", + # "american express" #Task #603: Support keyphrases + "amex", + "discover", + "jcb", + "diners", + "maestro", + "instapayment" +] + + +class CreditCardRecognizer(PatternRecognizer): + """ + Recognizes common credit card numbers using regex + checksum + """ + + def __init__(self): + patterns = [Pattern('All Credit Cards (weak)', REGEX, 0.3)] + super().__init__(supported_entity="CREDIT_CARD", patterns=patterns, + context=CONTEXT) + + def validate_result(self, text, pattern_result): + self.__sanitize_value(text) + res = self.__luhn_checksum() + if res == 0: + pattern_result.score = 1 + else: + pattern_result.score = 0 + + return pattern_result + + def __luhn_checksum(self): + def digits_of(n): + return [int(d) for d in str(n)] + + digits = digits_of(self.sanitized_value) + odd_digits = digits[-1::-2] + even_digits = digits[-2::-2] + checksum = 0 + checksum += sum(odd_digits) + for d in even_digits: + checksum += sum(digits_of(d * 2)) + return checksum % 10 + + def __sanitize_value(self, text): + self.sanitized_value = text.replace('-', '').replace(' ', '') diff --git a/presidio-analyzer/analyzer/predefined_recognizers/crypto_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/crypto_recognizer.py new file mode 100644 index 000000000..c20893ad2 --- /dev/null +++ b/presidio-analyzer/analyzer/predefined_recognizers/crypto_recognizer.py @@ -0,0 +1,35 @@ +from analyzer import Pattern +from analyzer import PatternRecognizer +from hashlib import sha256 + +"""Copied from: + http://rosettacode.org/wiki/Bitcoin/address_validation#Python + """ +REGEX = r'\b[13][a-km-zA-HJ-NP-Z0-9]{26,33}\b' +CONTEXT = ["wallet", "btc", "bitcoin", "crypto"] + + +class CryptoRecognizer(PatternRecognizer): + """ + Recognizes common crypto account numbers using regex + checksum + """ + + def __init__(self): + patterns = [Pattern('Crypto (Medium)', REGEX, 0.5)] + super().__init__(supported_entity="CRYPTO", patterns=patterns, + context=CONTEXT) + + def validate_result(self, text, pattern_result): + # try: + bcbytes = CryptoRecognizer.__decode_base58(text, 25) + if bcbytes[-4:] == sha256(sha256(bcbytes[:-4]).digest()).digest()[:4]: + pattern_result.score = 1.0 + return pattern_result + + @staticmethod + def __decode_base58(bc, length): + digits58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + n = 0 + for char in bc: + n = n * 58 + digits58.index(char) + return n.to_bytes(length, 'big') diff --git a/presidio-analyzer/analyzer/predefined_recognizers/domain_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/domain_recognizer.py new file mode 100644 index 000000000..d94e4f9ea --- /dev/null +++ b/presidio-analyzer/analyzer/predefined_recognizers/domain_recognizer.py @@ -0,0 +1,22 @@ +from analyzer import Pattern +from analyzer import PatternRecognizer +import tldextract + +REGEX = r'\b(((([a-zA-Z0-9])|([a-zA-Z0-9][a-zA-Z0-9\-]{0,86}[a-zA-Z0-9]))\.(([a-zA-Z0-9])|([a-zA-Z0-9][a-zA-Z0-9\-]{0,73}[a-zA-Z0-9]))\.(([a-zA-Z0-9]{2,12}\.[a-zA-Z0-9]{2,12})|([a-zA-Z0-9]{2,25})))|((([a-zA-Z0-9])|([a-zA-Z0-9][a-zA-Z0-9\-]{0,162}[a-zA-Z0-9]))\.(([a-zA-Z0-9]{2,12}\.[a-zA-Z0-9]{2,12})|([a-zA-Z0-9]{2,25}))))\b' # noqa: E501' # noqa: E501 +CONTEXT = ["domain", "ip"] + + +class DomainRecognizer(PatternRecognizer): + """ + Recognizes domain names using regex + """ + + def __init__(self): + patterns = [Pattern('Domain ()', REGEX, 0.5)] + super().__init__(supported_entity="DOMAIN_NAME", patterns=patterns, + context=CONTEXT) + + def validate_result(self, text, pattern_result): + result = tldextract.extract(text) + pattern_result.score = 1.0 if result.fqdn != '' else 0 + return pattern_result diff --git a/presidio-analyzer/analyzer/predefined_recognizers/email_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/email_recognizer.py new file mode 100644 index 000000000..e990aaf3b --- /dev/null +++ b/presidio-analyzer/analyzer/predefined_recognizers/email_recognizer.py @@ -0,0 +1,23 @@ +from analyzer import Pattern +from analyzer import PatternRecognizer +import tldextract + +REGEX = r"\b((([!#$%&'*+\-/=?^_`{|}~\w])|([!#$%&'*+\-/=?^_`{|}~\w][!#$%&'*+\-/=?^_`{|}~\.\w]{0,}[!#$%&'*+\-/=?^_`{|}~\w]))[@]\w+([-.]\w+)*\.\w+([-.]\w+)*)\b" # noqa: E501 +CONTEXT = ["email"] + + +class EmailRecognizer(PatternRecognizer): + """ + Recognizes email addresses using regex + """ + + def __init__(self): + patterns = [Pattern('Email (Medium)', REGEX, 0.5)] + super().__init__(supported_entity="EMAIL_ADDRESS", + patterns=patterns, context=CONTEXT) + + def validate_result(self, text, pattern_result): + result = tldextract.extract(text) + + pattern_result.score = 1.0 if result.fqdn != '' else 0 + return pattern_result diff --git a/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py new file mode 100644 index 000000000..2847f10ac --- /dev/null +++ b/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py @@ -0,0 +1,41 @@ +from analyzer import Pattern +from analyzer import PatternRecognizer +import string + +REGEX = u'[a-zA-Z]{2}[0-9]{2}[a-zA-Z0-9]{4}[0-9]{7}([a-zA-Z0-9]?){0,16}' +CONTEXT = ["iban"] +LETTERS = { + ord(d): str(i) + for i, d in enumerate(string.digits + string.ascii_uppercase) +} + + +class IbanRecognizer(PatternRecognizer): + """ + Recognizes IBAN code using regex and checksum + """ + + def __init__(self): + patterns = [Pattern('Iban (Medium)', REGEX, 0.5)] + super().__init__(supported_entity="IBAN_CODE", patterns=patterns, + context=CONTEXT) + + def validate_result(self, text, pattern_result): + is_valid_iban = IbanRecognizer.__generate_iban_check_digits( + text) == text[2:4] and IbanRecognizer.__valid_iban(text) + + pattern_result.score = 1.0 if is_valid_iban else 0 + return pattern_result + + @staticmethod + def __number_iban(iban): + return (iban[4:] + iban[:4]).translate(LETTERS) + + @staticmethod + def __generate_iban_check_digits(iban): + number_iban = IbanRecognizer.__number_iban(iban[:2] + '00' + iban[4:]) + return '{:0>2}'.format(98 - (int(number_iban) % 97)) + + @staticmethod + def __valid_iban(iban): + return int(IbanRecognizer.__number_iban(iban)) % 97 == 1 diff --git a/presidio-analyzer/analyzer/predefined_recognizers/ip_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/ip_recognizer.py new file mode 100644 index 000000000..d8d702ec8 --- /dev/null +++ b/presidio-analyzer/analyzer/predefined_recognizers/ip_recognizer.py @@ -0,0 +1,19 @@ +from analyzer import Pattern +from analyzer import PatternRecognizer + +IP_V4_REGEX = r'\b(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b' # noqa: E501 +IP_V6_REGEX = r'\s*(?!.*::.*::)(?:(?!:)|:(?=:))(?:[0-9a-f]{0,4}(?:(?<=::)|(?=1.13.0', 'cython>=0.28.5', 'protobuf>=3.6.0', diff --git a/presidio-analyzer/tests/__init__.py b/presidio-analyzer/tests/__init__.py index 339523f27..e69de29bb 100644 --- a/presidio-analyzer/tests/__init__.py +++ b/presidio-analyzer/tests/__init__.py @@ -1,7 +0,0 @@ -import os -import sys -sys.path.append(os.path.dirname(os.path.dirname( - os.path.abspath(__file__)))+"/analyzer") - -from analyzer import matcher -match = matcher.Matcher() diff --git a/presidio-analyzer/tests/data/synthetic_json.json b/presidio-analyzer/tests/data/synthetic_json.json deleted file mode 100644 index 304bd0233..000000000 --- a/presidio-analyzer/tests/data/synthetic_json.json +++ /dev/null @@ -1,172 +0,0 @@ -{ - "resourceType": "Patient", - "id": "110bcd25-a55d-453a-8046-1297901ea002", - "meta": { - "versionId": "1", - "lastUpdated": "2018-10-02T06:22:28.747-07:00", - "profile": ["http://standardhealthrecord.org/fhir/StructureDefinition/shr-entity-Patient"] - }, - "text": { - "status": "generated", - "div": "
Generated by Synthea.Version identifier: v2.0.0-97-ge2f9927f\n . Person seed: -4725837394105254742 Population seed: 1538484577457
" - }, - "extension": [{ - "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-race", - "extension": [{ - "url": "ombCategory", - "valueCoding": { - "system": "urn:oid:2.16.840.1.113883.6.238", - "code": "2106-3", - "display": "White" - } - }, { - "url": "text", - "valueString": "White" - }] - }, { - "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-ethnicity", - "extension": [{ - "url": "ombCategory", - "valueCoding": { - "system": "urn:oid:2.16.840.1.113883.6.238", - "code": "2186-5", - "display": "Not Hispanic or Latino" - } - }, { - "url": "text", - "valueString": "Not Hispanic or Latino" - }] - }, { - "url": "http://hl7.org/fhir/StructureDefinition/patient-mothersMaidenName", - "valueString": "Valencia279 Reinger292" - }, { - "url": "http://hl7.org/fhir/us/core/StructureDefinition/us-core-birthsex", - "valueCode": "M" - }, { - "url": "http://hl7.org/fhir/StructureDefinition/birthPlace", - "valueAddress": { - "city": "Andover", - "state": "Massachusetts", - "country": "US" - } - }, { - "url": "http://standardhealthrecord.org/fhir/StructureDefinition/shr-actor-FictionalPerson-extension", - "valueBoolean": true - }, { - "url": "http://standardhealthrecord.org/fhir/StructureDefinition/shr-entity-FathersName-extension", - "valueHumanName": { - "text": "Roscoe437 Walsh511" - } - }, { - "url": "http://standardhealthrecord.org/fhir/StructureDefinition/shr-demographics-SocialSecurityNumber-extension", - "valueString": "999-90-5728" - }, { - "url": "http://standardhealthrecord.org/fhir/StructureDefinition/shr-entity-Person-extension", - "valueReference": { - "reference": "urn:uuid:bdc4b430-2945-4bcd-b30f-91aae91693a1" - } - }, { - "url": "http://synthetichealth.github.io/synthea/disability-adjusted-life-years", - "valueDecimal": 0.11297837126046426 - }, { - "url": "http://synthetichealth.github.io/synthea/quality-adjusted-life-years", - "valueDecimal": 51.887021628739532 - }], - "identifier": [{ - "system": "https://github.com/synthetichealth/synthea", - "value": "b1b1c937-adf9-4c5e-987c-8f88e9c7121f" - }, { - "type": { - "coding": [{ - "system": "http://hl7.org/fhir/v2/0203", - "code": "MR", - "display": "Medical Record Number" - }], - "text": "Medical Record Number" - }, - "system": "http://hospital.smarthealthit.org", - "value": "b1b1c937-adf9-4c5e-987c-8f88e9c7121f" - }, { - "type": { - "coding": [{ - "system": "http://hl7.org/fhir/identifier-type", - "code": "SB", - "display": "Social Security Number" - }], - "text": "Social Security Number" - }, - "system": "http://hl7.org/fhir/sid/us-ssn", - "value": "999-90-5728" - }, { - "type": { - "coding": [{ - "system": "http://hl7.org/fhir/v2/0203", - "code": "DL", - "display": "Driver's License" - }], - "text": "Driver's License" - }, - "system": "urn:oid:2.16.840.1.113883.4.3.25", - "value": "S99966334" - }, { - "type": { - "coding": [{ - "system": "http://hl7.org/fhir/v2/0203", - "code": "PPN", - "display": "Passport Number" - }], - "text": "Passport Number" - }, - "system": "http://standardhealthrecord.org/fhir/StructureDefinition/passportNumber", - "value": "X52092396X" - }], - "name": [{ - "use": "official", - "family": "Walsh511", - "given": ["Roberto515"], - "prefix": ["Mr."] - }], - "telecom": [{ - "system": "phone", - "value": "555-780-8673", - "use": "home" - }], - "gender": "male", - "birthDate": "1965-09-03", - "address": [{ - "extension": [{ - "url": "http://hl7.org/fhir/StructureDefinition/geolocation", - "extension": [{ - "url": "latitude", - "valueDecimal": -73.26054 - }, { - "url": "longitude", - "valueDecimal": 42.452045 - }] - }], - "line": ["791 Upton Mall Unit 35"], - "city": "Pittsfield", - "state": "Massachusetts", - "postalCode": "01201", - "country": "US" - }], - "maritalStatus": { - "coding": [{ - "system": "http://hl7.org/fhir/v3/MaritalStatus", - "code": "M", - "display": "M" - }], - "text": "M" - }, - "multipleBirthBoolean": false, - "communication": [{ - "language": { - "coding": [{ - "system": "urn:ietf:bcp:47", - "code": "en-US", - "display": "English" - }], - "text": "English" - } - }] -} \ No newline at end of file diff --git a/presidio-analyzer/tests/test_all_fields.py b/presidio-analyzer/tests/test_all_fields.py deleted file mode 100644 index f6c854eef..000000000 --- a/presidio-analyzer/tests/test_all_fields.py +++ /dev/null @@ -1,114 +0,0 @@ -from analyzer import matcher -from tests import * -import datetime -import os -import logging - -types = [] - - -def test_all_fields_demo_file(): - start_time = datetime.datetime.now() - path = os.path.dirname(__file__) + '/data/demo.txt' - text_file = open(path, 'r') - text = text_file.read() - results = match.analyze_text(text, types) - test_time = datetime.datetime.now() - start_time - - assert len(results) == 20 - # assert test_time.seconds < 1 - # assert test_time.microseconds < 400000 - logging.info('test_all_fields_demo runtime: {}.{} seconds'.format( - test_time.seconds, test_time.microseconds)) - - -def test_all_fields_enron_file(): - start_time = datetime.datetime.now() - path = os.path.dirname(__file__) + '/data/enron.txt' - text_file = open(path, 'r') - text = text_file.read() - results = match.analyze_text(text, types) - test_time = datetime.datetime.now() - start_time - - assert len(results) > 30 - # assert test_time.seconds < 1 - # assert test_time.microseconds < 500000 - logging.info('test_all_fields_enron runtime: {}.{} seconds'.format( - test_time.seconds, test_time.microseconds)) - - -def test_synthetic_json(): - start_time = datetime.datetime.now() - path = os.path.dirname(__file__) + '/data/synthetic.json' - text_file = open(path, 'r') - text = text_file.read() - results = match.analyze_text(text, types) - test_time = datetime.datetime.now() - start_time - - assert len(results) > 30 - # assert test_time.seconds < 1 - # assert test_time.microseconds < 500000 - logging.info('test_all_fields_json runtime: {}.{} seconds'.format( - test_time.seconds, test_time.microseconds)) - - -# Test no duplicates of matches with checksum matches -def test_no_duplicates_of_checksum_credit_card_no_dashes(): - number = '6011577631711174' - results = match.analyze_text(number, []) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 - - -def test_no_duplicates_of_checksum_credit_card_with_dashes(): - number = '6011-5776-3171-1174' - results = match.analyze_text(number, []) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 - - -def test_no_duplicates_of_checksum_crypto(): - wallet = '16Yeky6GMjeNkAiNcBY7ZhrLoMSgg1BoyZ' - results = match.analyze_text(wallet, []) - - assert len(results) == 1 - assert results[0].text == wallet - assert results[0].score == 1.0 - - -def test_no_duplicates_of_checksum_domain(): - domain = 'microsoft.com' - results = match.analyze_text(domain, types) - - assert len(results) == 1 - assert results[0].text == domain - assert results[0].score == 1 - - -def test_no_duplicates_of_checksum_email(): - email = 'my email is info@presidio.site' - domain = 'presidio.site' - results = match.analyze_text('the email is ' + email, types) - results_text = map(lambda r: r.text, results) - - # In email, the domain is also detected with checksum and score = 1 - assert len(results) == 2 - - assert results[0].text in results_text - assert results[0].score == 1 - assert results[1].text in results_text - assert results[0].score == 1 - assert results[0].text != results[1].text - - -def test_no_duplicates_of_checksum_iban(): - number = 'IL150120690000003111111' - results = match.analyze_text(number, []) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 diff --git a/presidio-analyzer/tests/test_analyze_perf.py b/presidio-analyzer/tests/test_analyze_perf.py deleted file mode 100644 index 1f615ddb3..000000000 --- a/presidio-analyzer/tests/test_analyze_perf.py +++ /dev/null @@ -1,162 +0,0 @@ -from analyzer import matcher, common_pb2 -from tests import * -import datetime -import os -import logging -import cProfile, pstats, io -from pstats import SortKey - -context = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean commodo dictum est, fringilla congue ex malesuada quis. Phasellus at posuere erat. Quisque blandit tristique lacus ut aliquam. Donec at maximus nisi. Quisque dapibus eros enim, quis tincidunt leo vehicula at. Maecenas suscipit nec ante pretium ornare. Nulla at dui vel mi blandit scelerisque. Phasellus vehicula vel nunc et convallis. Pellentesque nibh elit, molestie a lectus vitae, luctus fringilla quam. ' - -PERF_MICROSECS_THRESHOLD_ENTITY = 300000 -PERF_MICROSECS_THRESHOLD_CHECKSUM_ENTITY = 350000 -PERF_MICROSECS_THRESHOLD_NER = 200000 - - -def analyze_perf(field_name, entity, runtime_threshold_micros): - fieldType = common_pb2.FieldTypes() - fieldType.name = field_name - types = [fieldType] - - start_time = datetime.datetime.now() - results = match.analyze_text(context + entity, types) - analyze_time = datetime.datetime.now() - start_time - - print('--- analyze_time[{}]: {}.{} seconds'.format( - types[0].name, analyze_time.seconds, analyze_time.microseconds)) - - assert analyze_time.seconds < 1 - assert analyze_time.microseconds < runtime_threshold_micros - - -# Credit Card -def test_analyze_perf_credit_card_no_dashes(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.CREDIT_CARD) - entity = '4012888888881881' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_CHECKSUM_ENTITY) - - -def test_analyze_perf_credit_card_with_dashes(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.CREDIT_CARD) - entity = '4012-8888-8888-1881' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_CHECKSUM_ENTITY) - - -def test_analyze_perf_credit_card_diners_no_dashes(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.CREDIT_CARD) - entity = '30569309025904' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_CHECKSUM_ENTITY) - - -# Crypto -def test_analyze_perf_btc(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.CREDIT_CARD) - entity = '16Yeky6GMjeNkAiNcBY7ZhrLoMSgg1BoyZ' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_CHECKSUM_ENTITY) - - -# Domain -def test_analyze_perf_domain(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.DOMAIN_NAME) - entity = 'microsoft.com' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_CHECKSUM_ENTITY) - - -# Email -def test_analyze_perf_email(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.EMAIL_ADDRESS) - entity = 'info@presidio.site' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_CHECKSUM_ENTITY) - - -# IBAN -def test_analyze_perf_iban(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.IBAN_CODE) - entity = 'IL150120690000003111111' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_CHECKSUM_ENTITY) - - -# IP -def test_analyze_perf_ipv4(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.IP_ADDRESS) - entity = '192.168.0.1' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_ENTITY) - - -# NER -def test_analyze_perf_person(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.PERSON) - entity = 'John Oliver' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_NER) - - -def test_analyze_perf_person(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.DATE_TIME) - entity = 'May 1st' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_NER) - - -# US - Bank Account -def test_analyze_perf_us_bank(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.US_BANK_NUMBER) - entity = '945456787654' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_ENTITY) - - -# US - Driver License -def test_analyze_perf_us_driver_license_very_weak_digits(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.US_DRIVER_LICENSE) - entity = '123456789' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_ENTITY) - - -def test_analyze_perf_us_driver_license_very_weak_letters(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.US_DRIVER_LICENSE) - entity = 'ABCDEFGHI' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_ENTITY) - - -# US - Itin -def test_analyze_perf_us_itin(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.US_ITIN) - entity = '911-701234' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_ENTITY) - - -# US - Passport -def test_analyze_perf_us_passport(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.US_PASSPORT) - entity = '912803456' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_ENTITY) - - -# US - Phone -def test_analyze_perf_phone(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.PHONE_NUMBER) - entity = '(425) 882-9090' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_ENTITY) - - -# US - SSN -def test_analyze_perf_us_ssn(): - field_name = common_pb2.FieldTypesEnum.Name(common_pb2.US_SSN) - entity = '078-05-1120' - analyze_perf(field_name, entity, PERF_MICROSECS_THRESHOLD_ENTITY) - - -def test_profile_synthetic_json(): - path = os.path.dirname(__file__) + '/data/synthetic.json' - text_file = open(path, 'r') - text = text_file.read() - pr = cProfile.Profile() - pr.enable() - results = match.analyze_text(text, []) - pr.disable() - - s = io.StringIO() - sortby = SortKey.CUMULATIVE - ps = pstats.Stats(pr, stream=s).sort_stats(sortby) - ps.print_stats() - val = s.getvalue() - logging.info(val) - assert len(results) > 30 \ No newline at end of file diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py new file mode 100644 index 000000000..d5b411375 --- /dev/null +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -0,0 +1,141 @@ +from unittest import TestCase + +import pytest + +from analyzer import AnalyzerEngine, PatternRecognizer, Pattern, \ + RecognizerResult, RecognizerRegistry +from analyzer.predefined_recognizers import CreditCardRecognizer, \ + UsPhoneRecognizer + + +class MockRecognizerRegistry(RecognizerRegistry): + def load_recognizers(self, path): + # TODO: Change the code to dynamic loading - + # Task #598: Support loading of the pre-defined recognizers + # from the given path. + self.recognizers.extend([CreditCardRecognizer(), + UsPhoneRecognizer()]) + + +class TestAnalyzerEngine(TestCase): + + def test_analyze_with_predefined_recognizers_return_results(self): + analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) + text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090" + langauge = "en" + entities = ["CREDIT_CARD", "PHONE_NUMBER"] + results = analyze_engine.analyze(text, entities, langauge) + assert len(results) == 2 + assert results[0].entity_type == "CREDIT_CARD" + assert results[0].score == 1.0 + assert results[0].start == 14 + assert results[0].end == 33 + + assert results[1].entity_type == "PHONE_NUMBER" + assert results[1].score == 0.5 + assert results[1].start == 48 + assert results[1].end == 59 + + entities = ["CREDIT_CARD"] + results = analyze_engine.analyze(text, entities, langauge) + assert len(results) == 1 + assert results[0].entity_type == "CREDIT_CARD" + assert results[0].score == 1.0 + assert results[0].start == 14 + assert results[0].end == 33 + + def test_analyze_without_entities(self): + with pytest.raises(ValueError): + langauge = "en" + analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) + text = " Credit card: 4095-2609-9393-4932, my name is John Oliver, DateTime: September 18 " \ + "Domain: microsoft.com" + entities = [] + analyze_engine.analyze(text, entities, langauge) + + def test_analyze_with_empty_text(self): + analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) + langauge = "en" + text = "" + entities = ["CREDIT_CARD", "PHONE_NUMBER"] + results = analyze_engine.analyze(text, entities, langauge) + assert len(results) == 0 + + def test_analyze_with_unsupported_language(self): + with pytest.raises(ValueError): + langauge = "de" + analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) + text = "" + entities = ["CREDIT_CARD", "PHONE_NUMBER"] + analyze_engine.analyze(text, entities, "de") + + def test_remove_duplicates(self): + # test same result with different score will return only the highest + arr = [RecognizerResult(start=0, end=5, score=0.1, entity_type="x"), + RecognizerResult(start=0, end=5, score=0.5, entity_type="x")] + results = AnalyzerEngine._AnalyzerEngine__remove_duplicates(arr) + + assert len(results) == 1 + assert results[0].score == 0.5 + + # TODO: add more cases with bug: + # bug# 597: Analyzer remove duplicates doesn't handle all cases of one result as a substring of the other + + def test_add_pattern_recognizer_from_dict(self): + pattern = Pattern("rocket pattern", r'\W*(rocket)\W*', 0.8) + pattern_recognizer = PatternRecognizer("ROCKET", + name="Rocket recognizer", + patterns=[pattern]) + + # Make sure the analyzer doesn't get this entity + analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) + text = "rocket is my favorite transportation" + entities = ["CREDIT_CARD", "ROCKET"] + + res = analyze_engine.analyze(text=text, entities=entities, + language='en') + + assert len(res) == 0 + + # Add a new recognizer for the word "rocket" (case insensitive) + analyze_engine.add_pattern_recognizer(pattern_recognizer.to_dict()) + + # Check that the entity is recognized: + res = analyze_engine.analyze(text=text, entities=entities, + language='en') + assert res[0].start == 0 + assert res[0].end == 7 + + def test_remove_analyzer(self): + pattern = Pattern("spaceship pattern", r'\W*(spaceship)\W*', 0.8) + pattern_recognizer = PatternRecognizer("SPACESHIP", + name="Spaceship recognizer", + patterns=[pattern]) + + # Make sure the analyzer doesn't get this entity + analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) + text = "spaceship is my favorite transportation" + entities = ["CREDIT_CARD", "SPACESHIP"] + + res = analyze_engine.analyze(text=text, entities=entities, + language='en') + + assert len(res) == 0 + + # Add a new recognizer for the word "rocket" (case insensitive) + analyze_engine.add_pattern_recognizer(pattern_recognizer.to_dict()) + + # Check that the entity is recognized: + res = analyze_engine.analyze(text=text, entities=entities, + language='en') + assert res[0].start == 0 + assert res[0].end == 10 + + # Remove recognizer + analyze_engine.remove_recognizer("Spaceship recognizer") + + # Test again to see we didn't get any results + res = analyze_engine.analyze(text=text, entities=entities, + language='en') + + assert len(res) == 0 diff --git a/presidio-analyzer/tests/test_credit_card.py b/presidio-analyzer/tests/test_credit_card.py deleted file mode 100644 index ce9c3c9ac..000000000 --- a/presidio-analyzer/tests/test_credit_card.py +++ /dev/null @@ -1,184 +0,0 @@ -from analyzer import matcher, common_pb2 -from tests import * -import os - -# https://www.datatrans.ch/showcase/test-cc-numbers -# https://www.freeformatter.com/credit-card-number-generator-validator.html - -fieldType = common_pb2.FieldTypes() -fieldType.name = common_pb2.FieldTypesEnum.Name(common_pb2.CREDIT_CARD) -types = [fieldType] - - -def test_valid_credit_cards(): - number1 = '4012888888881881' - number2 = '4012-8888-8888-1881' - number3 = '4012 8888 8888 1881' - results = match.analyze_text('{} {} {}'.format(number1, number2, number3), - types) - - assert len(results) == 3 - assert results[0].text == number1 - assert results[0].score == 1.0 - assert results[1].text == number2 - assert results[1].score == 1.0 - assert results[2].text == number3 - assert results[2].score == 1.0 - - -def test_valid_credit_cards_with_lemmatized_context(): - number1 = '4012888888881881' - number2 = '4012-8888-8888-1881' - number3 = '4012 8888 8888 1881' - context = 'my credit cards are:' - results = match.analyze_text( - '{}{} {} {}'.format(context, number1, number2, number3), types) - - assert len(results) == 3 - assert results[0].text == number1 - assert results[0].score == 1.0 - assert results[1].text == number2 - assert results[1].score == 1.0 - assert results[2].text == number3 - assert results[2].score == 1.0 - - -def test_valid_airplus_credit_card(): - number = '122000000000003' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 - - -def test_valid_airplus_credit_card_with_extact_context(): - number = '122000000000003' - context = 'my credit card:' - results = match.analyze_text(context + number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 - - -def test_valid_amex_credit_card(): - number = '371449635398431' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 - - -def test_valid_cartebleue_credit_card(): - number = '5555555555554444' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 - - -def test_valid_dankort_credit_card(): - number = '5019717010103742' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 - - -def test_valid_diners_credit_card(): - number = '30569309025904' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 - - -def test_valid_discover_credit_card(): - number = '6011000400000000' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 - - -def test_valid_jcb_credit_card(): - number = '3528000700000000' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 - - -def test_valid_maestro_credit_card(): - number = '6759649826438453' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 - - -def test_valid_mastercard_credit_card(): - number = '5555555555554444' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 - - -def test_valid_visa_credit_card(): - number = '4111111111111111' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 - - -def test_valid_visa_debit_credit_card(): - number = '4111111111111111' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 - - -def test_valid_visa_electron_credit_card(): - number = '4917300800000000' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 - - -def test_valid_visa_purchasing_credit_card(): - number = '4484070000000000' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1.0 - - -def test_invalid_credit_card(): - number = '4012-8888-8888-1882' - results = match.analyze_text('my credit card number is ' + number, types) - - assert len(results) == 0 - - -def test_invalid_diners_card(): - number = '36168002586008' - results = match.analyze_text('my credit card number is ' + number, types) - - assert len(results) == 0 diff --git a/presidio-analyzer/tests/test_credit_card_recognizer.py b/presidio-analyzer/tests/test_credit_card_recognizer.py new file mode 100644 index 000000000..6e2cb1b26 --- /dev/null +++ b/presidio-analyzer/tests/test_credit_card_recognizer.py @@ -0,0 +1,196 @@ +# https://www.datatrans.ch/showcase/test-cc-numbers +# https://www.freeformatter.com/credit-card-number-generator-validator.html +from unittest import TestCase + +from analyzer.predefined_recognizers import CreditCardRecognizer + +entities = ["CREDIT_CARD"] +credit_card_recognizer = CreditCardRecognizer() + + +class TestCreditCardRecognizer(TestCase): + + def test_valid_credit_cards(self): + # init + number1 = '4012888888881881' + number2 = '4012-8888-8888-1881' + number3 = '4012 8888 8888 1881' + + results = credit_card_recognizer.analyze('{} {} {}'.format(number1, number2, number3), entities) + + assert len(results) == 3 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 16 + + assert results[1].score == 1.0 + assert results[1].entity_type == entities[0] + assert results[1].start == 17 + assert results[1].end == 36 + + assert results[2].score == 1.0 + assert results[2].entity_type == entities[0] + assert results[2].start == 37 + assert results[2].end == 56 + + def test_valid_airplus_credit_card(self): + number = '122000000000003' + results = credit_card_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 15 + + def test_valid_airplus_credit_card_with_extact_context(self): + number = '122000000000003' + context = 'my credit card: ' + results = credit_card_recognizer.analyze(context + number, entities) + + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 16 + assert results[0].end == 31 + + def test_valid_amex_credit_card(self): + number = '371449635398431' + results = credit_card_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 15 + + def test_valid_cartebleue_credit_card(self): + number = '5555555555554444' + results = credit_card_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 16 + + def test_valid_dankort_credit_card(self): + number = '5019717010103742' + results = credit_card_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 16 + + def test_valid_diners_credit_card(self): + number = '30569309025904' + results = credit_card_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 14 + + def test_valid_discover_credit_card(self): + number = '6011000400000000' + results = credit_card_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 16 + + def test_valid_jcb_credit_card(self): + number = '3528000700000000' + results = credit_card_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 16 + + def test_valid_maestro_credit_card(self): + number = '6759649826438453' + results = credit_card_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 16 + + def test_valid_mastercard_credit_card(self): + number = '5555555555554444' + results = credit_card_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 16 + + def test_valid_visa_credit_card(self): + number = '4111111111111111' + results = credit_card_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 16 + + def test_valid_visa_debit_credit_card(self): + number = '4111111111111111' + results = credit_card_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 16 + + def test_valid_visa_electron_credit_card(self): + number = '4917300800000000' + results = credit_card_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 16 + + def test_valid_visa_purchasing_credit_card(self): + number = '4484070000000000' + results = credit_card_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 16 + + def test_invalid_credit_card(self): + number = '4012-8888-8888-1882' + results = credit_card_recognizer.analyze('my credit card number is ' + number, entities) + + assert len(results) == 1 + assert results[0].entity_type == entities[0] + assert results[0].start == 25 + assert results[0].end == 44 + assert results[0].score == 0 + + def test_invalid_diners_card(self): + number = '36168002586008' + results = credit_card_recognizer.analyze('my credit card number is ' + number, entities) + + assert len(results) == 1 + assert results[0].entity_type == entities[0] + assert results[0].start == 25 + assert results[0].end == 39 + assert results[0].score == 0 diff --git a/presidio-analyzer/tests/test_crypto.py b/presidio-analyzer/tests/test_crypto.py deleted file mode 100644 index 73bbcd4ce..000000000 --- a/presidio-analyzer/tests/test_crypto.py +++ /dev/null @@ -1,34 +0,0 @@ -from analyzer import matcher, common_pb2 -from tests import * - -fieldType = common_pb2.FieldTypes() -fieldType.name = common_pb2.FieldTypesEnum.Name(common_pb2.CRYPTO) -types = [fieldType] - -# Generate random address https://www.bitaddress.org/ - - -def test_valid_btc(): - wallet = '16Yeky6GMjeNkAiNcBY7ZhrLoMSgg1BoyZ' - results = match.analyze_text(wallet, types) - - assert len(results) == 1 - assert results[0].text == wallet - assert results[0].score == 1 - - -def test_valid_btc_with_exact_context(): - wallet = '16Yeky6GMjeNkAiNcBY7ZhrLoMSgg1BoyZ' - context = 'my wallet address is: ' - results = match.analyze_text(context + wallet, types) - - assert len(results) == 1 - assert results[0].text == wallet - assert results[0].score == 1 - - -def test_invalid_btc(): - wallet = '16Yeky6GMjeNkAiNcBY7ZhrLoMSgg1BoyZ2' - results = match.analyze_text('my wallet address is ' + wallet, types) - - assert len(results) == 0 diff --git a/presidio-analyzer/tests/test_crypto_recognizer.py b/presidio-analyzer/tests/test_crypto_recognizer.py new file mode 100644 index 000000000..994fd365c --- /dev/null +++ b/presidio-analyzer/tests/test_crypto_recognizer.py @@ -0,0 +1,38 @@ +from unittest import TestCase + +from analyzer.predefined_recognizers import CryptoRecognizer + +crypto_recognizer = CryptoRecognizer() +entities = ["CRYPTO"] + + +# Generate random address https://www.bitaddress.org/ + +class TestCreditCardRecognizer(TestCase): + + def test_valid_btc(self): + wallet = '16Yeky6GMjeNkAiNcBY7ZhrLoMSgg1BoyZ' + results = crypto_recognizer.analyze(wallet, entities) + + assert len(results) == 1 + assert results[0].score == 1 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 34 + + def test_valid_btc_with_exact_context(self): + wallet = '16Yeky6GMjeNkAiNcBY7ZhrLoMSgg1BoyZ' + context = 'my wallet address is: ' + results = crypto_recognizer.analyze(context + wallet, entities) + + assert len(results) == 1 + assert results[0].score == 1 + assert results[0].entity_type == entities[0] + assert results[0].start == 22 + assert results[0].end == 56 + + def test_invalid_btc(self): + wallet = '16Yeky6GMjeNkAiNcBY7ZhrLoMSgg1BoyZ2' + results = crypto_recognizer.analyze('my wallet address is ' + wallet, entities) + + assert len(results) == 0 diff --git a/presidio-analyzer/tests/test_date_time.py b/presidio-analyzer/tests/test_date_time.py deleted file mode 100644 index f5ea81101..000000000 --- a/presidio-analyzer/tests/test_date_time.py +++ /dev/null @@ -1,15 +0,0 @@ -from analyzer import matcher, common_pb2 -from tests import * -import logging -import os - - -fieldType = common_pb2.FieldTypes() -fieldType.name = common_pb2.FieldTypesEnum.Name(common_pb2.DATE_TIME) -types = [fieldType] - - -def test_date_time_simple(): - name = 'May 1st' - results = match.analyze_text(name + " is the workers holiday", types) - assert results[0].text == name diff --git a/presidio-analyzer/tests/test_domain.py b/presidio-analyzer/tests/test_domain.py deleted file mode 100644 index 55aba4a87..000000000 --- a/presidio-analyzer/tests/test_domain.py +++ /dev/null @@ -1,39 +0,0 @@ -from analyzer import matcher, common_pb2 -from tests import * - -fieldType = common_pb2.FieldTypes() -fieldType.name = common_pb2.FieldTypesEnum.Name(common_pb2.DOMAIN_NAME) -types = [fieldType] - - -def test_invalid_domain(): - domain = 'microsoft.' - results = match.analyze_text(domain, types) - - assert len(results) == 0 - - -def test_invalid_domain_with_exact_context(): - domain = 'microsoft.' - context = 'my domain is ' - results = match.analyze_text(context + domain, types) - - assert len(results) == 0 - - -def test_valid_domain(): - domain = 'microsoft.com' - results = match.analyze_text(domain, types) - - assert len(results) == 1 - assert results[0].text == domain - assert results[0].score == 1 - - -def test_valid_domains_lemmatized_text(): - domain1 = 'microsoft.com' - domain2 = '192.168.0.1' - results = match.analyze_text('my domains: {} {}'.format(domain1, domain2), - types) - - assert len(results) == 1 diff --git a/presidio-analyzer/tests/test_domain_recognizer.py b/presidio-analyzer/tests/test_domain_recognizer.py new file mode 100644 index 000000000..d671eef82 --- /dev/null +++ b/presidio-analyzer/tests/test_domain_recognizer.py @@ -0,0 +1,52 @@ +from unittest import TestCase + +from analyzer.predefined_recognizers import DomainRecognizer + +domain_recognizer = DomainRecognizer() +entities = ["DOMAIN_NAME"] + + +class TestDomainRecognizer(TestCase): + + def test_invalid_domain(self): + domain = 'microsoft.' + results = domain_recognizer.analyze(domain, entities) + + assert len(results) == 0 + + + def test_invalid_domain_with_exact_context(self): + domain = 'microsoft.' + context = 'my domain is ' + results = domain_recognizer.analyze(context + domain, entities) + + assert len(results) == 0 + + + def test_valid_domain(self): + domain = 'microsoft.com' + results = domain_recognizer.analyze(domain, entities) + + assert len(results) == 1 + assert results[0].score == 1 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 13 + + + def test_valid_domains_lemmatized_text(self): + domain1 = 'microsoft.com' + domain2 = '192.168.0.1' + results = domain_recognizer.analyze('my domains: {} {}'.format(domain1, domain2), entities) + + assert len(results) == 2 + assert results[0].entity_type == entities[0] + assert results[0].start == 12 + assert results[0].end == 25 + assert results[0].score == 1.0 + + assert results[1].entity_type == entities[0] + assert results[1].start == 26 + assert results[1].end == 33 + assert results[1].score == 0 + diff --git a/presidio-analyzer/tests/test_email.py b/presidio-analyzer/tests/test_email.py deleted file mode 100644 index df2d79f25..000000000 --- a/presidio-analyzer/tests/test_email.py +++ /dev/null @@ -1,44 +0,0 @@ -from analyzer import matcher, common_pb2 -from tests import * - -fieldType = common_pb2.FieldTypes() -fieldType.name = common_pb2.FieldTypesEnum.Name(common_pb2.EMAIL_ADDRESS) -types = [fieldType] - - -def test_valid_email_no_context(): - email = 'info@presidio.site' - results = match.analyze_text(email, types) - - assert len(results) == 1 - assert results[0].text == email - assert results[0].score == 1.0 - - -def test_valid_email_with_context(): - email = 'info@presidio.site' - results = match.analyze_text('my email is {}'.format(email), types) - - assert len(results) == 1 - assert results[0].text == email - assert results[0].score == 1.0 - - -def test_multiple_emails_with_lemmatized_context(): - email1 = 'info@presidio.site' - email2 = 'anotherinfo@presidio.site' - results = match.analyze_text( - 'try one of thie emails: {} or {}'.format(email1, email2), types) - - assert len(results) == 2 - assert results[0].text == email1 - assert results[0].score == 1.0 - assert results[1].text == email2 - assert results[1].score == 1.0 - - -def test_invalid_email(): - email = 'my email is info@presidio.' - results = match.analyze_text('the email is ' + email, types) - - assert len(results) == 0 diff --git a/presidio-analyzer/tests/test_email_recognizer.py b/presidio-analyzer/tests/test_email_recognizer.py new file mode 100644 index 000000000..827d6566b --- /dev/null +++ b/presidio-analyzer/tests/test_email_recognizer.py @@ -0,0 +1,52 @@ +from unittest import TestCase + +from analyzer.predefined_recognizers import EmailRecognizer + +email_recognizer = EmailRecognizer() +entities = ["EMAIL_ADDRESS"] + + +class TestEmailRecognizer(TestCase): + + def test_valid_email_no_context(self): + email = 'info@presidio.site' + results = email_recognizer.analyze(email, entities) + + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 18 + + def test_valid_email_with_context(self): + email = 'info@presidio.site' + results = email_recognizer.analyze('my email is {}'.format(email), entities) + + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 12 + assert results[0].end == 30 + + def test_multiple_emails_with_lemmatized_context(self): + email1 = 'info@presidio.site' + email2 = 'anotherinfo@presidio.site' + results = email_recognizer.analyze( + 'try one of this emails: {} or {}'.format(email1, email2), entities) + + assert len(results) == 2 + assert results[0].score == 1.0 + assert results[0].entity_type == entities[0] + assert results[0].start == 24 + assert results[0].end == 42 + + assert results[1].score == 1.0 + assert results[1].entity_type == entities[0] + assert results[1].start == 46 + assert results[1].end == 71 + + def test_invalid_email(self): + email = 'my email is info@presidio.' + results = email_recognizer.analyze('the email is ' + email, entities) + + assert len(results) == 0 diff --git a/presidio-analyzer/tests/test_entity_recognizer.py b/presidio-analyzer/tests/test_entity_recognizer.py new file mode 100644 index 000000000..165cc2ce1 --- /dev/null +++ b/presidio-analyzer/tests/test_entity_recognizer.py @@ -0,0 +1,25 @@ +from unittest import TestCase + +from analyzer import EntityRecognizer + +LANGUAGE = "en" + + +class TestEntityRecognizer(TestCase): + + def test_to_dict_correct_dictionary(self): + ent_recognizer = EntityRecognizer(["ENTITY"]) + entity_rec_dict = ent_recognizer.to_dict() + assert entity_rec_dict is not None + assert entity_rec_dict['supported_entities'] == ['ENTITY'] + assert entity_rec_dict['supported_language'] == 'en' + + def test_from_dict_returns_instance(self): + ent_rec_dict = {"supported_entities": ["A", "B", "C"], + "supported_language": "he" + } + entity_rec = EntityRecognizer.from_dict(ent_rec_dict) + + assert entity_rec.supported_entities == ["A", "B", "C"] + assert entity_rec.supported_language == "he" + assert entity_rec.version == "0.0.1" diff --git a/presidio-analyzer/tests/test_iban.py b/presidio-analyzer/tests/test_iban.py deleted file mode 100644 index ec0602003..000000000 --- a/presidio-analyzer/tests/test_iban.py +++ /dev/null @@ -1,33 +0,0 @@ -from analyzer import matcher, common_pb2 -from tests import * -import logging -import os - -fieldType = common_pb2.FieldTypes() -fieldType.name = common_pb2.FieldTypesEnum.Name(common_pb2.IBAN_CODE) -types = [fieldType] - - -def test_valid_iban(): - number = 'IL150120690000003111111' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1 - - -def test_invalid_iban(): - number = 'IL150120690000003111141' - results = match.analyze_text(number, types) - - assert len(results) == 0 - - -# Context should not change the result if the checksum fails -def test_invalid_iban_with_exact_context(): - number = 'IL150120690000003111141' - context = 'my iban number is ' - results = match.analyze_text(context + number, types) - - assert len(results) == 0 diff --git a/presidio-analyzer/tests/test_iban_recognizer.py b/presidio-analyzer/tests/test_iban_recognizer.py new file mode 100644 index 000000000..263760c6c --- /dev/null +++ b/presidio-analyzer/tests/test_iban_recognizer.py @@ -0,0 +1,43 @@ +from unittest import TestCase + +from analyzer.predefined_recognizers import IbanRecognizer + +iban_recognizer = IbanRecognizer() +entities = ["IBAN_CODE"] + + +class TestIbanRecognizer(TestCase): + + def test_valid_iban(self): + number = 'IL150120690000003111111' + results = iban_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert results[0].score == 1 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 23 + + + def test_invalid_iban(self): + number = 'IL150120690000003111141' + results = iban_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert results[0].score == 0 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 23 + + + # Context should not change the result if the checksum fails + def test_invalid_iban_with_exact_context(self): + number = 'IL150120690000003111141' + context = 'my iban number is ' + results = iban_recognizer.analyze(context + number, entities) + + assert len(results) == 1 + assert results[0].score == 0 + assert results[0].entity_type == entities[0] + assert results[0].start == 18 + assert results[0].end == 41 diff --git a/presidio-analyzer/tests/test_ip.py b/presidio-analyzer/tests/test_ip.py deleted file mode 100644 index 6fb31a8dc..000000000 --- a/presidio-analyzer/tests/test_ip.py +++ /dev/null @@ -1,66 +0,0 @@ -from analyzer import matcher, common_pb2 -from tests import * -import logging -import os - -fieldType = common_pb2.FieldTypes() -fieldType.name = common_pb2.FieldTypesEnum.Name(common_pb2.IP_ADDRESS) -types = [fieldType] - - -def test_valid_ipv4(): - ip = '192.168.0.1' - context = 'microsoft.com ' - results = match.analyze_text(context + ip, types) - - assert len(results) == 1 - assert results[0].text == ip - assert results[0].score > 0.59 and results[0].score < 0.8 - - -def test_valid_ipv4_with_exact_context(): - ip = '192.168.0.1' - context = 'my ip: ' - results = match.analyze_text(context + ip, types) - - assert len(results) == 1 - assert results[0].text == ip - assert results[0].score > 0.79 and results[0].score < 1 - - -def test_invalid_ipv4(): - ip = '192.168.0' - context = 'my ip: ' - results = match.analyze_text(context + ip, types) - - assert len(results) == 0 - - -''' -TODO: fix ipv6 regex -def test_valid_ipv6(): - ip = '684D:1111:222:3333:4444:5555:6:77' - context = 'microsoft.com ' - results = match.analyze_text(context + ip, types) - - assert len(results) == 1 - assert results[0].text == ip - assert results[0].score > 0.59 and results[0].score < 0.8 - - -def test_valid_ipv6_with_exact_context(): - ip = '684D:1111:222:3333:4444:5555:6:77' - context = 'my ip: ' - results = match.analyze_text(context + ip, types) - - assert len(results) == 1 - assert results[0].text == ip - assert results[0].score > 0.79 and results[0].score < 1 -''' - - -def test_invalid_ipv6(): - ip = '684D:1111:222:3333:4444:5555:77' - results = match.analyze_text('the ip is ' + ip, types) - - assert len(results) == 0 diff --git a/presidio-analyzer/tests/test_ip_recognizer.py b/presidio-analyzer/tests/test_ip_recognizer.py new file mode 100644 index 000000000..489f5229c --- /dev/null +++ b/presidio-analyzer/tests/test_ip_recognizer.py @@ -0,0 +1,71 @@ +from unittest import TestCase + +from analyzer.predefined_recognizers import IpRecognizer + +ip_recognizer = IpRecognizer() +entities = ["IP_ADDRESS"] + + +class TestIpRecognizer(TestCase): + + def test_valid_ipv4(self): + ip = '192.168.0.1' + context = 'microsoft.com ' + results = ip_recognizer.analyze(context + ip, entities) + + assert len(results) == 1 + assert 0.59 < results[0].score < 0.8 + assert results[0].entity_type == entities[0] + assert results[0].start == 14 + assert results[0].end == 25 + + + ''' + TODO: enable with task #582 re-support context model in analyzer + + def test_valid_ipv4_with_exact_context(self): + ip = '192.168.0.1' + context = 'my ip: ' + results = ip_recognizer.analyze(context + ip, entities) + + assert len(results) == 1 + assert 0.79 < results[0].score < 1 + ''' + + + def test_invalid_ipv4(self): + ip = '192.168.0' + context = 'my ip: ' + results = ip_recognizer.analyze(context + ip, entities) + + assert len(results) == 0 + + + ''' + TODO: fix ipv6 regex + def test_valid_ipv6(self): + ip = '684D:1111:222:3333:4444:5555:6:77' + context = 'microsoft.com ' + results = ip_recognizer.analyze(context + ip, entities) + + assert len(results) == 1 + assert results[0].text == ip + assert results[0].score > 0.59 and results[0].score < 0.8 + + + def test_valid_ipv6_with_exact_context(self): + ip = '684D:1111:222:3333:4444:5555:6:77' + context = 'my ip: ' + results = ip_recognizer.analyze(context + ip, entities) + + assert len(results) == 1 + assert results[0].text == ip + assert results[0].score > 0.79 and results[0].score < 1 + ''' + + + def test_invalid_ipv6(self): + ip = '684D:1111:222:3333:4444:5555:77' + results = ip_recognizer.analyze('the ip is ' + ip, entities) + + assert len(results) == 0 diff --git a/presidio-analyzer/tests/test_multiple_files.py b/presidio-analyzer/tests/test_multiple_files.py deleted file mode 100644 index 165e77ed9..000000000 --- a/presidio-analyzer/tests/test_multiple_files.py +++ /dev/null @@ -1,23 +0,0 @@ -# from analyzer import matcher -# import logging -# import os -# import pathlib -# types = ["PHONE_NUMBER", "CREDIT_CARD", "EMAIL"] - - -# def test_multiple_files(): -# match = matcher.Matcher() -# dirpath = "" -# output_file = open(enron.json”, ”w”) -# for path, subdirs, files in os.walk(dirpath): -# for name in files: -# if name == ".DS_Store": -# continue - -# file = pathlib.PurePath(path, name) -# size = os.stat(file).st_size -# logging.info(f"{file} size: {size}") -# text_file = open(file, 'r') -# text = text_file.read() -# results = match.analyze_text(text, types) -# text_file.close() diff --git a/presidio-analyzer/tests/test_pattern.py b/presidio-analyzer/tests/test_pattern.py new file mode 100644 index 000000000..66d091593 --- /dev/null +++ b/presidio-analyzer/tests/test_pattern.py @@ -0,0 +1,20 @@ +from unittest import TestCase + +from analyzer import Pattern + + +class TestPattern(TestCase): + + def test_to_dict(self): + expected = {"name": "my pattern", "pattern": "[pat]", "strength": 0.9} + pat = Pattern(name="my pattern", strength=0.9, pattern="[pat]") + actual = pat.to_dict() + + assert expected == actual + + def test_from_dict(self): + expected = {"name": "my pattern", "pattern": "[pat]", "strength": 0.9} + pat = Pattern.from_dict(expected) + actual = pat.to_dict() + + assert expected == actual diff --git a/presidio-analyzer/tests/test_pattern_recognizer.py b/presidio-analyzer/tests/test_pattern_recognizer.py new file mode 100644 index 000000000..e9f7de3b1 --- /dev/null +++ b/presidio-analyzer/tests/test_pattern_recognizer.py @@ -0,0 +1,85 @@ +from unittest import TestCase + +import pytest + +# https://www.datatrans.ch/showcase/test-cc-numbers +# https://www.freeformatter.com/credit-card-number-generator-validator.html +from analyzer import Pattern +from analyzer import PatternRecognizer + + +class MockRecognizer(PatternRecognizer): + def validate_result(self, pattern_text, pattern_result): + return pattern_result + + def __init__(self, entity, patterns, black_list, name, context): + super().__init__(supported_entity=entity, + name=name, + patterns=patterns, + black_list=black_list, + context=context) + + +class TestPatternRecognizer(TestCase): + + def test_no_entity_for_pattern_recognizer(self): + with pytest.raises(ValueError): + patterns = [Pattern("p1", "someregex", 1.0), Pattern("p1", "someregex", 0.5)] + MockRecognizer(entity=[], patterns=patterns, + black_list=[], name=None, context=None) + + def test_black_list_works(self): + test_recognizer = MockRecognizer(patterns=[], + entity="ENTITY_1", + black_list=["phone", "name"], context=None, name=None) + + results = test_recognizer.analyze("my phone number is 555-1234, and my name is John", ["ENTITY_1"]) + + assert len(results) == 2 + assert results[0].entity_type == "ENTITY_1" + assert results[0].score == 1.0 + assert results[0].start == 3 + assert results[0].end == 8 + + assert results[1].entity_type == "ENTITY_1" + assert results[1].score == 1.0 + assert results[1].start == 36 + assert results[1].end == 40 + + def test_from_dict(self): + json = {'supported_entity': 'ENTITY_1', + 'supported_language': 'en', + 'patterns': [{'name': 'p1', 'strength': 0.5, 'pattern': '([0-9]{1,9})'}], + 'context': ['w1', 'w2', 'w3'], + 'version': "1.0"} + + new_recognizer = PatternRecognizer.from_dict(json) + assert new_recognizer.supported_entities == ['ENTITY_1'] + assert new_recognizer.supported_language == 'en' + assert new_recognizer.patterns[0].name == 'p1' + assert new_recognizer.patterns[0].strength == 0.5 + assert new_recognizer.patterns[0].pattern == '([0-9]{1,9})' + assert new_recognizer.context == ['w1', 'w2', 'w3'] + assert new_recognizer.version == "1.0" + + def test_from_dict_returns_instance(self): + pattern1_dict = {'name': 'p1', 'strength': 0.5, 'pattern': '([0-9]{1,9})'} + pattern2_dict = {'name': 'p2', 'strength': 0.8, 'pattern': '([0-9]{1,9})'} + + ent_rec_dict = {"supported_entity": "A", + "supported_language": "he", + "patterns": [pattern1_dict, pattern2_dict] + } + pattern_recognizer = PatternRecognizer.from_dict(ent_rec_dict) + + assert pattern_recognizer.supported_entities == ["A"] + assert pattern_recognizer.supported_language == "he" + assert pattern_recognizer.version == "0.0.1" + + assert pattern_recognizer.patterns[0].name == "p1" + assert pattern_recognizer.patterns[0].strength == 0.5 + assert pattern_recognizer.patterns[0].pattern == '([0-9]{1,9})' + + assert pattern_recognizer.patterns[1].name == "p2" + assert pattern_recognizer.patterns[1].strength == 0.8 + assert pattern_recognizer.patterns[1].pattern == '([0-9]{1,9})' diff --git a/presidio-analyzer/tests/test_person.py b/presidio-analyzer/tests/test_person.py deleted file mode 100644 index 0e46333a7..000000000 --- a/presidio-analyzer/tests/test_person.py +++ /dev/null @@ -1,101 +0,0 @@ -from analyzer import matcher, common_pb2 -from tests import * - -fieldType = common_pb2.FieldTypes() -fieldType.name = common_pb2.FieldTypesEnum.Name(common_pb2.PERSON) -types = [fieldType] - - -def test_person_first_name(): - name = 'Dan' - results = match.analyze_text(name, types) - - assert len(results) == 0 - - -def test_person_first_name_with_context(): - name = 'Dan' - context = 'my name is ' - results = match.analyze_text(context + name, types) - - assert len(results) == 1 - assert results[0].text == name - assert results[0].score >= matcher.NER_STRENGTH - - -def test_person_full_name(): - name = 'Dan Tailor' - results = match.analyze_text(name, types) - - assert len(results) == 1 - assert results[0].text == name - assert results[0].score >= matcher.NER_STRENGTH - - -def test_person_full_name_with_context(): - name = 'John Oliver' - results = match.analyze_text(name + " is the funniest comedian", types) - - assert len(results) == 1 - assert results[0].text == name - assert results[0].score >= matcher.NER_STRENGTH - - -def test_person_last_name(): - name = 'Tailor' - results = match.analyze_text(name, types) - - assert len(results) == 0 - - -''' -Issue #40 - NER context is limitted -def test_person_last_name_with_context(): - name = 'Tailor' - context = 'Mr. ' - results = match.analyze_text(context + name, types) - - assert len(results) == 1 - assert results[0].text == name - assert results[0].score >= matcher.NER_STRENGTH -''' - - -def test_person_full_middle_name(): - name = 'Richard Milhous Nixon' - results = match.analyze_text(name, types) - - assert len(results) == 1 - assert results[0].text == name - assert results[0].score >= matcher.NER_STRENGTH - - -def test_person_full_middle_letter_name(): - name = 'Richard M. Nixon' - results = match.analyze_text(name, types) - - assert len(results) == 1 - assert results[0].text == name - assert results[0].score >= matcher.NER_STRENGTH - - -def test_person_full_name_complex(): - name = 'Richard (Ric) C. Henderson' - results = match.analyze_text(name, types) - - assert len(results) == 1 - assert results[0].text == name - assert results[0].score >= matcher.NER_STRENGTH - - -''' -Spacy bug: identifies the name as 3 PERSON entities, instead of one, when adding context -def test_person_full_name_complex_with_context(): - name = 'Richard (Ric) C. Henderson' - context = 'yesterday I had a meeting with ' - results = match.analyze_text(context + name, types) - - assert len(results) == 1 - assert results[0].text == name - assert results[0].score >= matcher.NER_STRENGTH -''' \ No newline at end of file diff --git a/presidio-analyzer/tests/test_phone_number.py b/presidio-analyzer/tests/test_phone_number.py deleted file mode 100644 index d81639077..000000000 --- a/presidio-analyzer/tests/test_phone_number.py +++ /dev/null @@ -1,138 +0,0 @@ -from analyzer import matcher, common_pb2 -from tests import * - -fieldType = common_pb2.FieldTypes() -fieldType.name = common_pb2.FieldTypesEnum.Name(common_pb2.PHONE_NUMBER) -types = [fieldType] - - -def test_phone_number_strong_match_no_context(): - number = '(425) 882 9090' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score > 0.69 and results[0].score < 1 - - -def test_phone_number_strong_match_with_phone_context(): - number = '(425) 882-9090' - context = 'my phone number is ' - results = match.analyze_text(context + number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1 - - -def test_phone_number_strong_match_with_phone_context_no_space(): - number = '(425) 882-9090' - context = 'my phone number is:' - results = match.analyze_text(context + number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score == 1 - - -def test_phone_in_guid(): - number = '110bcd25-a55d-453a-8046-1297901ea002' - context = 'my phone number is:' - results = match.analyze_text(context + number, types) - - assert len(results) == 0 - - -def test_phone_number_strong_match_with_similar_context(): - number = '(425) 882-9090' - context = 'I am available at ' - results = match.analyze_text(context + number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score > 0.69 - - -def test_phone_number_strong_match_with_irrelevant_context(): - number = '(425) 882-9090' - context = 'This is just a sentence ' - results = match.analyze_text(context + number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score > 0.69 and results[0].score < 1 - - -def test_phone_number_medium_match_no_context(): - number = '425 8829090' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score > 0.45 and results[0].score < 0.6 - - -def test_phone_number_medium_match_with_phone_context(): - number = '425 8829090' - context = 'my phone number is ' - results = match.analyze_text(context + number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score > 0.75 and results[0].score < 0.9 - - -''' This test fails since available is not close enough to phone --> requires experimentation with language model - -def test_phone_number_medium_match_with_similar_context(): - number = '425 8829090' - context = 'I am available at ' - results = match.analyze_text(context + number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score > 0.59 and results[0].score < 0.8 -''' - - -def test_phone_number_medium_match_with_irrelevant_context(): - number = '425 8829090' - context = 'This is just a sentence ' - results = match.analyze_text(context + number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score > 0.29 and results[0].score < 0.51 - - -def test_phone_number_weak_match_no_context(): - - number = '4258829090' - results = match.analyze_text(number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score > 0 and results[0].score < 0.3 - - -def test_phone_number_weak_match_with_phone_context(): - number = '4258829090' - context = 'my phone number is ' - results = match.analyze_text(context + number, types) - - assert len(results) == 1 - assert results[0].text == number - assert results[0].score > 0.59 and results[0].score < 0.81 - - -def test_phone_numbers_lemmatized_context_phones(): - number1 = '052 5552606' - number2 = '074-7111234' - results = match.analyze_text( - 'try one of these phones ' + number1 + ' ' + number2, types) - - assert len(results) == 2 - assert results[0].text == number1 - assert results[0].score > 0.75 and results[0].score < 0.9 - assert results[1].text == number2 - assert results[0].score > 0.75 and results[0].score < 0.9 diff --git a/presidio-analyzer/tests/test_recognizer_registry.py b/presidio-analyzer/tests/test_recognizer_registry.py new file mode 100644 index 000000000..8c2d79f7e --- /dev/null +++ b/presidio-analyzer/tests/test_recognizer_registry.py @@ -0,0 +1,93 @@ +from unittest import TestCase + +import pytest + +from analyzer import RecognizerRegistry, PatternRecognizer, EntityRecognizer, Pattern + + +class TestRecognizerRegistry(TestCase): + + def get_mock_pattern_recognizer(self, lang, entity, name): + return PatternRecognizer(supported_entity=entity, + supported_language=lang, name=name, + patterns=[Pattern("pat", pattern="REGEX", strength=1.0)]) + + def get_mock_custom_recognizer(self, lang, entities,name): + return EntityRecognizer(supported_entities=entities, name=name,supported_language=lang) + + def get_mock_recognizer_registry(self): + pattern_recognizer1 = self.get_mock_pattern_recognizer("en", "PERSON", "1") + pattern_recognizer2 = self.get_mock_pattern_recognizer("de", "PERSON", "2") + pattern_recognizer3 = self.get_mock_pattern_recognizer("de", "ADDRESS", "3") + pattern_recognizer4 = self.get_mock_pattern_recognizer("he", "ADDRESS", "4") + pattern_recognizer5 = self.get_mock_custom_recognizer("he", ["PERSON", "ADDRESS"], "5") + return RecognizerRegistry([pattern_recognizer1, pattern_recognizer2, + pattern_recognizer3, pattern_recognizer4, + pattern_recognizer5]) + + def test_get_recognizers_all(self): + registry = self.get_mock_recognizer_registry() + recognizers = registry.get_recognizers() + assert len(recognizers) == 5 + + def test_get_recognizers_one_language_one_entity(self): + registry = self.get_mock_recognizer_registry() + recognizers = registry.get_recognizers(language='de', entities=["PERSON"]) + assert len(recognizers) == 1 + + def test_get_recognizers_unsupported_language(self): + with pytest.raises(ValueError): + registry = self.get_mock_recognizer_registry() + registry.get_recognizers(language='brrrr', entities=["PERSON"]) + + def test_get_recognizers_specific_language_and_entity(self): + registry = self.get_mock_recognizer_registry() + recognizers = registry.get_recognizers(language='he', entities=["PERSON"]) + assert len(recognizers) == 1 + + def test_load_pattern_recognizer_from_dict(self): + pattern_recognizer = self.get_mock_pattern_recognizer("ar", "ENTITY", "a") + pattern_recognizer.name = "123" + registry = self.get_mock_recognizer_registry() + registry.add_pattern_recognizer_from_dict(pattern_recognizer.to_dict()) + + recognizers = registry.get_recognizers(entities=["ENTITY"], language="ar") + + assert recognizers[0].to_dict() == pattern_recognizer.to_dict() + + def test_load_pattern_recognizer_from_dict_already_defined_throws_exception(self): + pattern_recognizer1 = self.get_mock_pattern_recognizer("ar", "ENTITY", "a") + pattern_recognizer1.name = "MyRecognizer" + registry = self.get_mock_recognizer_registry() + registry.add_pattern_recognizer_from_dict(pattern_recognizer1.to_dict()) + + pattern_recognizer2 = self.get_mock_pattern_recognizer("em", "ENTITY3", "a") + pattern_recognizer2.name = "MyRecognizer" + with pytest.raises(ValueError): + registry.add_pattern_recognizer_from_dict(pattern_recognizer2.to_dict()) + + def test_remove_pattern_recognizer_not_found_exception(self): + pattern_recognizer1 = self.get_mock_pattern_recognizer("ar", "ENTITY", "a") + pattern_recognizer1.name = "MyRecognizer" + registry = self.get_mock_recognizer_registry() + registry.add_pattern_recognizer_from_dict(pattern_recognizer1.to_dict()) + + with pytest.raises(ValueError): + registry.remove_recognizer("NumeroUnoRecognizer") + + def test_remove_pattern_recognizer_removed(self): + pattern_recognizer1 = self.get_mock_pattern_recognizer("ar", "ENTITY", "MyRecognizer") + registry = self.get_mock_recognizer_registry() + registry.add_pattern_recognizer_from_dict(pattern_recognizer1.to_dict()) + + assert len(registry.recognizers) == 6 + + registry.remove_recognizer("MyRecognizer") + + assert len(registry.recognizers) == 5 + + for recognizer in registry.recognizers: + if recognizer.name == "MyRecognizer": + assert False + + assert True diff --git a/presidio-analyzer/tests/test_spacy_recognizer.py b/presidio-analyzer/tests/test_spacy_recognizer.py new file mode 100644 index 000000000..4722e50f3 --- /dev/null +++ b/presidio-analyzer/tests/test_spacy_recognizer.py @@ -0,0 +1,93 @@ +from unittest import TestCase + +from analyzer.predefined_recognizers import SpacyRecognizer + +NER_STRENGTH = 0.85 +spacy_recognizer = SpacyRecognizer() +entities = ["PERSON", "DATE_TIME"] + + +class TestSpacyRecognizer(TestCase): + + def test_person_first_name(self): + name = 'Dan' + results = spacy_recognizer.analyze(name, entities) + + assert len(results) == 0 + + def test_person_first_name_with_context(self): + name = 'Dan' + context = 'my name is ' + results = spacy_recognizer.analyze(context + name, entities) + + assert len(results) == 1 + assert results[0].score >= NER_STRENGTH + assert results[0].entity_type == entities[0] + assert results[0].start == 11 + assert results[0].end == 14 + + def test_person_full_name(self): + name = 'Dan Tailor' + results = spacy_recognizer.analyze(name, entities) + + assert len(results) == 1 + assert results[0].score >= NER_STRENGTH + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 10 + + def test_person_full_name_with_context(self): + name = 'John Oliver' + results = spacy_recognizer.analyze(name + " is the funniest comedian", entities) + + assert len(results) == 1 + assert results[0].score >= NER_STRENGTH + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 11 + + def test_person_last_name(self): + name = 'Tailor' + results = spacy_recognizer.analyze(name, entities) + + assert len(results) == 0 + + def test_person_full_middle_name(self): + name = 'Richard Milhous Nixon' + results = spacy_recognizer.analyze(name, entities) + + assert len(results) == 1 + assert results[0].score >= NER_STRENGTH + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 21 + + def test_person_full_middle_letter_name(self): + name = 'Richard M. Nixon' + results = spacy_recognizer.analyze(name, entities) + + assert len(results) == 1 + assert results[0].score >= NER_STRENGTH + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 16 + + def test_person_full_name_complex(self): + name = 'Richard (Ric) C. Henderson' + results = spacy_recognizer.analyze(name, entities) + + assert len(results) == 1 + assert results[0].score >= NER_STRENGTH + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 26 + + def test_date_time_simple(self): + name = 'May 1st' + results = spacy_recognizer.analyze(name + " is the workers holiday", ["DATE_TIME"]) + + assert len(results) == 1 + assert results[0].score >= NER_STRENGTH + assert results[0].entity_type == entities[1] + assert results[0].start == 0 + assert results[0].end == 7 diff --git a/presidio-analyzer/tests/test_uk_nhs.py b/presidio-analyzer/tests/test_uk_nhs.py deleted file mode 100644 index 3c635ef81..000000000 --- a/presidio-analyzer/tests/test_uk_nhs.py +++ /dev/null @@ -1,30 +0,0 @@ -from analyzer import matcher, common_pb2 -from tests import * - -fieldType = common_pb2.FieldTypes() -fieldType.name = common_pb2.FieldTypesEnum.Name(common_pb2.UK_NHS) -types = [fieldType] - - -def test_valid_uk_nhs(): - num = '401-023-2137' - results = match.analyze_text(num, types) - assert len(results) == 1 - assert results[0].text == num - - num = '221 395 1837' - results = match.analyze_text(num, types) - assert len(results) == 1 - assert results[0].text == num - - num = '0032698674' - results = match.analyze_text(num, types) - assert len(results) == 1 - assert results[0].text == num - - -def test_invalid_uk_nhs(): - num = '401-023-2138' - results = match.analyze_text(num, types) - - assert len(results) == 0 \ No newline at end of file diff --git a/presidio-analyzer/tests/test_uk_nhs_recognizer.py b/presidio-analyzer/tests/test_uk_nhs_recognizer.py new file mode 100644 index 000000000..acf50ebfb --- /dev/null +++ b/presidio-analyzer/tests/test_uk_nhs_recognizer.py @@ -0,0 +1,45 @@ +from unittest import TestCase + +from analyzer.predefined_recognizers import NhsRecognizer + +nhs_recognizer = NhsRecognizer() +entities = ["UK_NHS"] + + +class TestNhsRecognizer(TestCase): + + def test_valid_uk_nhs(self): + num = '401-023-2137' + results = nhs_recognizer.analyze(num, entities) + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].start == 0 + assert results[0].end == 12 + assert results[0].entity_type == entities[0] + + num = '221 395 1837' + results = nhs_recognizer.analyze(num, entities) + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].start == 0 + assert results[0].end == 12 + assert results[0].entity_type == entities[0] + + num = '0032698674' + results = nhs_recognizer.analyze(num, entities) + assert len(results) == 1 + assert results[0].score == 1.0 + assert results[0].start == 0 + assert results[0].end == 10 + assert results[0].entity_type == entities[0] + + + def test_invalid_uk_nhs(self): + num = '401-023-2138' + results = nhs_recognizer.analyze(num, entities) + + assert len(results) == 1 + assert results[0].score == 0 + assert results[0].start == 0 + assert results[0].end == 12 + assert results[0].entity_type == entities[0] diff --git a/presidio-analyzer/tests/test_us_bank_number.py b/presidio-analyzer/tests/test_us_bank_number.py deleted file mode 100644 index 34dbbec88..000000000 --- a/presidio-analyzer/tests/test_us_bank_number.py +++ /dev/null @@ -1,52 +0,0 @@ -from analyzer import matcher, common_pb2 -from tests import * - -fieldType = common_pb2.FieldTypes() -fieldType.name = common_pb2.FieldTypesEnum.Name(common_pb2.US_BANK_NUMBER) -types = [fieldType] - - -def test_us_bank_account_invalid_number(): - num = '1234567' - results = match.analyze_text(num, types) - - assert len(results) == 0 - - -def test_us_bank_account_no_context(): - num = '945456787654' - results = match.analyze_text(num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0 and results[0].score < 0.1 - - -def test_us_passport_with_exact_context(): - num = '912803456' - context = 'my banck account number is ' - results = match.analyze_text(context + num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.49 and results[0].score < 0.61 - - -def test_us_passport_with_exact_context_no_space(): - num = '912803456' - context = 'my banck account number is:' - results = match.analyze_text(context + num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.49 and results[0].score < 0.61 - - -def test_us_passport_with_lemmatized_context(): - num = '912803456' - context = 'my banking account number is ' - results = match.analyze_text(context + num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.49 and results[0].score < 0.61 diff --git a/presidio-analyzer/tests/test_us_bank_recognizer.py b/presidio-analyzer/tests/test_us_bank_recognizer.py new file mode 100644 index 000000000..f86098218 --- /dev/null +++ b/presidio-analyzer/tests/test_us_bank_recognizer.py @@ -0,0 +1,53 @@ +from unittest import TestCase + +from analyzer.predefined_recognizers import UsBankRecognizer + +us_bank_recognizer = UsBankRecognizer() +entities = ["US_BANK_NUMBER"] + + +class TestUsBankRecognizer(TestCase): + + def test_us_bank_account_invalid_number(self): + num = '1234567' + results = us_bank_recognizer.analyze(num, entities) + + assert len(results) == 0 + + + def test_us_bank_account_no_context(self): + num = '945456787654' + results = us_bank_recognizer.analyze(num, entities) + + assert len(results) == 1 + assert 0 < results[0].score < 0.1 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 12 + + # TODO: enable with task #582 re-support context model in analyzer + # def test_us_passport_with_exact_context(self): + # num = '912803456' + # context = 'my banck account number is ' + # results = us_bank_recognizer.analyze(context + num, entities) + # + # assert len(results) == 1 + # assert 0.49 < results[0].score < 0.61 + # + # + # def test_us_passport_with_exact_context_no_space(self): + # num = '912803456' + # context = 'my banck account number is:' + # results = us_bank_recognizer.analyze(context + num, entities) + # + # assert len(results) == 1 + # assert 0.49 < results[0].score < 0.61 + # + # + # def test_us_passport_with_lemmatized_context(self): + # num = '912803456' + # context = 'my banking account number is ' + # results = us_bank_recognizer.analyze(context + num, entities) + # + # assert len(results) == 1 + # assert 0.49 < results[0].score < 0.61 diff --git a/presidio-analyzer/tests/test_us_driver_license.py b/presidio-analyzer/tests/test_us_driver_license.py deleted file mode 100644 index ef2b1aa68..000000000 --- a/presidio-analyzer/tests/test_us_driver_license.py +++ /dev/null @@ -1,141 +0,0 @@ -from analyzer import matcher, common_pb2 -from tests import * -import os - -fieldType = common_pb2.FieldTypes() -fieldType.name = common_pb2.FieldTypesEnum.Name(common_pb2.US_DRIVER_LICENSE) -types = [fieldType] - -# Driver License - WA (weak) - 0.3 -# Regex: '\b([A-Z][A-Z0-9*]{11})\b' - - -def test_valid_us_driver_license_weak_WA(): - num1 = 'AA1B2**9ABA7' - num2 = 'A*1234AB*CD9' - results = match.analyze_text('{} {}'.format(num1, num2), types) - - assert len(results) == 2 - assert results[0].text == num1 - assert results[0].score > 0.29 and results[0].score < 0.41 - assert results[1].text == num2 - assert results[1].score > 0.29 and results[1].score < 0.41 - - -def test_valid_us_driver_license_weak_WA_exact_Context(): - num = 'AA1B2**9ABA7' - context = 'my driver license number: ' - results = match.analyze_text(context + num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.55 and results[0].score < 0.91 - - -def test_invalid_us_driver_license_weak_WA(): - num = '3A1B2**9ABA7' - results = match.analyze_text(num, types) - - assert len(results) == 0 - - -# Driver License - Alphanumeric (weak) - 0.3 -# Regex:r'\b([A-Z][0-9]{3,6}|[A-Z][0-9]{5,9}|[A-Z][0-9]{6,8}|[A-Z][0-9]{4,8}|[A-Z][0-9]{9,11}|[A-Z]{1,2}[0-9]{5,6}|H[0-9]{8}|V[0-9]{6}|X[0-9]{8}|A-Z]{2}[0-9]{2,5}|[A-Z]{2}[0-9]{3,7}|[0-9]{2}[A-Z]{3}[0-9]{5,6}|[A-Z][0-9]{13,14}|[A-Z][0-9]{18}|[A-Z][0-9]{6}R|[A-Z][0-9]{9}|[A-Z][0-9]{1,12}|[0-9]{9}[A-Z]|[A-Z]{2}[0-9]{6}[A-Z]|[0-9]{8}[A-Z]{2}|[0-9]{3}[A-Z]{2}[0-9]{4}|[A-Z][0-9][A-Z][0-9][A-Z]|[0-9]{7,8}[A-Z])\b' - - -def test_valid_us_driver_license_weak_lphanumeric(): - num = 'H12234567' - results = match.analyze_text(num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.29 and results[0].score < 0.49 - - -def test_valid_us_driver_license_weak_lphanumeric_exact_context(): - num = 'H12234567' - context = 'my driver license is ' - results = match.analyze_text(context + num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.59 and results[0].score < 0.91 - - -''' This test fails, since 'license' is a match and driver is a context. - It should be fixed after adding support in keyphrase instead of keywords (context) -def test_invalid_us_driver_license(): - num = 'C12T345672' - results = match.analyze_text('my driver license is ' + num, types) - - assert len(results) == 0 -''' - - -def test_invalid_us_driver_license(): - num = 'C12T345672' - results = match.analyze_text(num, types) - - assert len(results) == 0 - - -# Driver License - Digits (very weak) - 0.05 -# Regex: r'\b([0-9]{1,9}|[0-9]{4,10}|[0-9]{6,10}|[0-9]{1,12}|[0-9]{12,14}|[0-9]{16})\b' - - -def test_valid_us_driver_license_very_weak_digits(): - num = '123456789 1234567890 12345679012 123456790123 1234567901234' - results = match.analyze_text(num, types) - - assert len(results) == 5 - for result in results: - assert result.score > 0 and result.score < 0.1 - - -def test_valid_us_driver_license_very_weak_digits_exact_context(): - num = '1234567901234' - context = 'my driver license is: ' - results = match.analyze_text(context + num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.55 and results[0].score < 0.91 - - -# Driver License - Letters (very weak) - 0.00 -# Regex: r'\b([A-Z]{7,9}\b' - - -def test_valid_us_driver_license_very_weak_letters(): - num = 'ABCDEFG ABCDEFGH ABCDEFGHI' - results = match.analyze_text(num, types) - - assert len(results) == 0 - - -''' This test fails, since 'license' is a match and driver is a context. - It should be fixed after adding support in keyphrase instead of keywords (context) -def test_valid_us_driver_license_very_weak_letters_exact_context(): - num = 'ABCDEFG' - context = 'my driver license: ' - results = match.analyze_text(context + num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.55 and results[0].score < 0.91 -''' - - -def test_invalid_us_driver_license_very_weak_letters(): - num = 'ABCD ABCDEFGHIJ' - results = match.analyze_text(num, types) - - assert len(results) == 0 - - -def test_load_from_file(): - path = os.path.dirname(__file__) + '/data/demo.txt' - text_file = open(path, 'r') - text = text_file.read() - results = match.analyze_text(text, types) - assert len(results) == 1 diff --git a/presidio-analyzer/tests/test_us_driver_license_recognizer.py b/presidio-analyzer/tests/test_us_driver_license_recognizer.py new file mode 100644 index 000000000..0c1507757 --- /dev/null +++ b/presidio-analyzer/tests/test_us_driver_license_recognizer.py @@ -0,0 +1,134 @@ +from unittest import TestCase + +from analyzer.predefined_recognizers import UsLicenseRecognizer + +us_license_recognizer = UsLicenseRecognizer() +entities = ["US_DRIVER_LICENSE"] + + +class TestUsLicenseRecognizer(TestCase): + + def test_valid_us_driver_license_weak_WA(self): + num1 = 'AA1B2**9ABA7' + num2 = 'A*1234AB*CD9' + results = us_license_recognizer.analyze('{} {}'.format(num1, num2), entities) + + assert len(results) == 2 + assert 0.29 < results[0].score < 0.41 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 12 + + assert 0.29 < results[1].score < 0.41 + assert results[1].entity_type == entities[0] + assert results[1].start == 13 + assert results[1].end == 25 + + # TODO: enable with task #582 re-support context model in analyzer + # def test_valid_us_driver_license_weak_WA_exact_context(self): + # num = 'AA1B2**9ABA7' + # context = 'my driver license number: ' + # results = us_license_recognizer.analyze(context + num, entities) + # + # assert len(results) == 2 + # assert 0.55 < results[0].score < 0.91 + # # These is a duplicate result that will be removed by the analyzer + # assert 0 < results[1].score < 0.1 + + def test_invalid_us_driver_license_weak_WA(self): + num = '3A1B2**9ABA7' + results = us_license_recognizer.analyze(num, entities) + + assert len(results) == 0 + + # Driver License - Alphanumeric (weak) - 0.3 + # Regex:r'\b([A-Z][0-9]{3,6}|[A-Z][0-9]{5,9}|[A-Z][0-9]{6,8}|[A-Z][0-9]{4,8}|[A-Z][0-9]{9,11}|[A-Z]{1,2}[0-9]{5, + # 6}|H[0-9]{8}|V[0-9]{6}|X[0-9]{8}|A-Z]{2}[0-9]{2,5}|[A-Z]{2}[0-9]{3,7}|[0-9]{2}[A-Z]{3}[0-9]{5,6}|[A-Z][0-9]{13, + # 14}|[A-Z][0-9]{18}|[A-Z][0-9]{6}R|[A-Z][0-9]{9}|[A-Z][0-9]{1,12}|[0-9]{9}[A-Z]|[A-Z]{2}[0-9]{6}[A-Z]|[0-9]{8}[ + # A-Z]{2}|[0-9]{3}[A-Z]{2}[0-9]{4}|[A-Z][0-9][A-Z][0-9][A-Z]|[0-9]{7,8}[A-Z])\b' + + # TODO: enable with task #582 re-support context model in analyzer + # def test_valid_us_driver_license_weak_alphanumeric(self): + # num = 'H12234567' + # results = us_license_recognizer.analyze(num, entities) + # + # assert len(results) == 1 + # assert 0.29 < results[0].score < 0.49 + # + # def test_valid_us_driver_license_weak_alphanumeric_exact_context(self): + # num = 'H12234567' + # context = 'my driver license is ' + # results = us_license_recognizer.analyze(context + num, entities) + # + # assert len(results) == 1 + # assert 0.59 < results[0].score < 0.91 + + # Task #603: Support keyphrases + ''' This test fails, since 'license' is a match and driver is a context. + It should be fixed after adding support in keyphrase instead of keywords (context) + def test_invalid_us_driver_license(self): + num = 'C12T345672' + results = us_license_recognizer.analyze('my driver license is ' + num, entities) + + assert len(results) == 0 + ''' + + def test_invalid_us_driver_license(self): + num = 'C12T345672' + results = us_license_recognizer.analyze(num, entities) + + assert len(results) == 0 + + # Driver License - Digits (very weak) - 0.05 + # Regex: r'\b([0-9]{1,9}|[0-9]{4,10}|[0-9]{6,10}|[0-9]{1,12}|[0-9]{12,14}|[0-9]{16})\b' + + # TODO: enable with task #582 re-support context model in analyzer + # def test_valid_us_driver_license_very_weak_digits(self): + # num = '123456789 1234567890 12345679012 123456790123 1234567901234' + # results = us_license_recognizer.analyze(num, entities) + # + # assert len(results) == 5 + # for result in results: + # assert 0 < result.score < 0.1 + + # def test_valid_us_driver_license_very_weak_digits_exact_context(self): + # num = '1234567901234' + # context = 'my driver license is: ' + # results = us_license_recognizer.analyze(context + num, entities) + # + # assert len(results) == 1 + # assert 0.55 < results[0].score < 0.91 + # def test_load_from_file(self): + # path = os.path.dirname(__file__) + '/data/demo.txt' + # text_file = open(path, 'r') + # text = text_file.read() + # results = us_license_recognizer.analyze(text, entities) + # assert len(results) == 1 + + # Driver License - Letters (very weak) - 0.00 + # Regex: r'\b([A-Z]{7,9}\b' + + def test_valid_us_driver_license_very_weak_letters(self): + num = 'ABCDEFG ABCDEFGH ABCDEFGHI' + results = us_license_recognizer.analyze(num, entities) + + assert len(results) == 0 + + # Task #603: Support keyphrases + ''' This test fails, since 'license' is a match and driver is a context. + It should be fixed after adding support in keyphrase instead of keywords (context) + def test_valid_us_driver_license_very_weak_letters_exact_context(self): + num = 'ABCDEFG' + context = 'my driver license: ' + results = us_license_recognizer.analyze(context + num, entities) + + assert len(results) == 1 + assert results[0].text == num + assert results[0].score > 0.55 and results[0].score < 0.91 + ''' + + def test_invalid_us_driver_license_very_weak_letters(self): + num = 'ABCD ABCDEFGHIJ' + results = us_license_recognizer.analyze(num, entities) + + assert len(results) == 0 diff --git a/presidio-analyzer/tests/test_us_itin.py b/presidio-analyzer/tests/test_us_itin.py deleted file mode 100644 index 1bcd242de..000000000 --- a/presidio-analyzer/tests/test_us_itin.py +++ /dev/null @@ -1,84 +0,0 @@ -from analyzer import matcher, common_pb2 -from tests import * - -fieldType = common_pb2.FieldTypes() -fieldType.name = common_pb2.FieldTypesEnum.Name(common_pb2.US_ITIN) -types = [fieldType] - - -def test_valid_us_itin_very_weak_match(): - num1 = '911-701234' - num2 = '91170-1234' - results = match.analyze_text('{} {}'.format(num1, num2), types) - - assert len(results) == 2 - assert results[0].text == num1 - assert results[0].score > 0 and results[0].score < 0.31 - assert results[1].text == num2 - assert results[1].score > 0 and results[0].score < 0.31 - - -def test_valid_us_itin_weak_match(): - num = '911701234' - results = match.analyze_text(num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.29 and results[0].score < 0.41 - - -def test_valid_us_itin_medium_match(): - num = '911-70-1234' - results = match.analyze_text(num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.49 and results[0].score < 0.6 - - -def test_valid_us_itin_very_weak_match_exact_context(): - num1 = '911-701234' - num2 = '91170-1234' - context = "my taxpayer id is" - results = match.analyze_text('{} {} {}'.format(context, num1, num2), types) - - assert len(results) == 2 - assert results[0].text == num1 - assert results[0].score > 0.59 and results[0].score < 0.7 - assert results[1].text == num2 - assert results[1].score > 0.50 and results[0].score < 0.7 - - -def test_valid_us_itin_weak_match_exact_context(): - num = '911701234' - context = "my itin:" - results = match.analyze_text('{} {}'.format(context, num), types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.5 and results[0].score < 0.65 - - -def test_valid_us_itin_medium_match_exact_context(): - num = '911-70-1234' - context = "my itin is" - results = match.analyze_text('{} {}'.format(context, num), types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.6 and results[0].score < 0.9 - - -def test_invalid_us_itin(): - num = '911-89-1234' - results = match.analyze_text(num, types) - - assert len(results) == 0 - - -def test_invalid_us_itin_exact_context(): - num = '911-89-1234' - context = "my taxpayer id" - results = match.analyze_text('{} {}'.format(context, num), types) - - assert len(results) == 0 diff --git a/presidio-analyzer/tests/test_us_itin_recognizer.py b/presidio-analyzer/tests/test_us_itin_recognizer.py new file mode 100644 index 000000000..ce278bc99 --- /dev/null +++ b/presidio-analyzer/tests/test_us_itin_recognizer.py @@ -0,0 +1,87 @@ +from unittest import TestCase + +from analyzer.predefined_recognizers import UsItinRecognizer + +us_itin_recognizer = UsItinRecognizer() +entities = ["US_ITIN"] + + +class TestUsItinRecognizer(TestCase): + + def test_valid_us_itin_very_weak_match(self): + num1 = '911-701234' + num2 = '91170-1234' + results = us_itin_recognizer.analyze('{} {}'.format(num1, num2), entities) + + assert len(results) == 2 + assert 0 < results[0].score < 0.31 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 10 + + assert 0 < results[1].score < 0.31 + assert results[1].entity_type == entities[0] + assert results[1].start == 11 + assert results[1].end == 21 + + def test_valid_us_itin_weak_match(self): + num = '911701234' + results = us_itin_recognizer.analyze(num, entities) + + assert len(results) == 1 + assert 0.29 < results[0].score < 0.41 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 9 + + def test_valid_us_itin_medium_match(self): + num = '911-70-1234' + results = us_itin_recognizer.analyze(num, entities) + + assert len(results) == 1 + assert 0.49 < results[0].score < 0.6 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 11 + + # TODO: enable with task #582 re-support context model in analyzer + # def test_valid_us_itin_very_weak_match_exact_context(self): + # num1 = '911-701234' + # num2 = '91170-1234' + # context = "my taxpayer id is" + # results = us_itin_recognizer.analyze('{} {} {}'.format(context, num1, num2), entities) + # + # assert len(results) == 2 + # assert 0.59 < results[0].score < 0.7 + # assert 0.50 < results[1].score < 0.7 + # + # + # def test_valid_us_itin_weak_match_exact_context(self): + # num = '911701234' + # context = "my itin:" + # results = us_itin_recognizer.analyze('{} {}'.format(context, num), entities) + # + # assert len(results) == 1 + # assert 0.5 < results[0].score < 0.65 + # + # + # def test_valid_us_itin_medium_match_exact_context(self): + # num = '911-70-1234' + # context = "my itin is" + # results = us_itin_recognizer.analyze('{} {}'.format(context, num), entities) + # + # assert len(results) == 1 + # assert 0.6 < results[0].score < 0.9 + + def test_invalid_us_itin(self): + num = '911-89-1234' + results = us_itin_recognizer.analyze(num, entities) + + assert len(results) == 0 + + def test_invalid_us_itin_exact_context(self): + num = '911-89-1234' + context = "my taxpayer id" + results = us_itin_recognizer.analyze('{} {}'.format(context, num), entities) + + assert len(results) == 0 diff --git a/presidio-analyzer/tests/test_us_passport.py b/presidio-analyzer/tests/test_us_passport.py deleted file mode 100644 index bba84abc8..000000000 --- a/presidio-analyzer/tests/test_us_passport.py +++ /dev/null @@ -1,36 +0,0 @@ -from analyzer import matcher, common_pb2 -from tests import * - -fieldType = common_pb2.FieldTypes() -fieldType.name = common_pb2.FieldTypesEnum.Name(common_pb2.US_PASSPORT) -types = [fieldType] - - -def test_valid_us_passport_no_context(): - num = '912803456' - results = match.analyze_text(num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0 and results[0].score < 0.1 - - -def test_valid_us_passport_with_exact_context(): - num = '912803456' - context = 'my passport number is ' - results = match.analyze_text(context + num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.49 and results[0].score < 0.71 - ''' Should pass after handling keyphrases, e.g. "travel document" or "travel permit" - - def test_valid_us_passport_with_exact_context_phrase(): - num = '912803456' - context = 'my travel document number is ' - results = match.analyze_text(context + num, types) - - assert len(results) == 1 - assert results[0].text = num - assert results[0].score - ''' diff --git a/presidio-analyzer/tests/test_us_passport_recognizer.py b/presidio-analyzer/tests/test_us_passport_recognizer.py new file mode 100644 index 000000000..795efe5ed --- /dev/null +++ b/presidio-analyzer/tests/test_us_passport_recognizer.py @@ -0,0 +1,40 @@ +from unittest import TestCase + +from analyzer.predefined_recognizers import UsPassportRecognizer + +us_passport_recognizer = UsPassportRecognizer() +entities = ["US_PASSPORT"] + + +class TestUsPassportRecognizer(TestCase): + + def test_valid_us_passport_no_context(self): + num = '912803456' + results = us_passport_recognizer.analyze(num, entities) + + assert len(results) == 1 + assert 0 < results[0].score < 0.1 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 9 + + # Task #582 re-support context model in analyzer + # def test_valid_us_passport_with_exact_context(self): + # num = '912803456' + # context = 'my passport number is ' + # results = us_passport_recognizer.analyze(context + num, entities) + # + # assert len(results) == 1 + # assert 0.49 < results[0].score < 0.71 + + # Task #603: Support keyphrases: Should pass after handling keyphrases, e.g. "travel document" or "travel permit" + + # def test_valid_us_passport_with_exact_context_phrase(): + # num = '912803456' + # context = 'my travel document number is ' + # results = us_passport_recognizer.analyze(context + num, entities) + # + # assert len(results) == 1 + # assert results[0].text = num + # assert results[0].score + # diff --git a/presidio-analyzer/tests/test_us_phone_recognizer.py b/presidio-analyzer/tests/test_us_phone_recognizer.py new file mode 100644 index 000000000..54d1dc047 --- /dev/null +++ b/presidio-analyzer/tests/test_us_phone_recognizer.py @@ -0,0 +1,138 @@ +from unittest import TestCase + +from analyzer.predefined_recognizers import UsPhoneRecognizer + +phone_recognizer = UsPhoneRecognizer() +entities = ["PHONE_NUMBER"] + + +class UsPhoneRecognizer(TestCase): + + def test_phone_number_strong_match_no_context(self): + number = '(425) 882 9090' + results = phone_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert 0.69 < results[0].score < 1 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 14 + + # TODO: enable with task #582 re-support context model in analyzer + # def test_phone_number_strong_match_with_phone_context(self): + # number = '(425) 882-9090' + # context = 'my phone number is ' + # results = phone_recognizer.analyze(context + number, entities) + # + # assert len(results) == 1 + # assert results[0].score == 1 + # + # + # def test_phone_number_strong_match_with_phone_context_no_space(self): + # number = '(425) 882-9090' + # context = 'my phone number is:' + # results = phone_recognizer.analyze(context + number, entities) + # + # assert len(results) == 1 + # assert results[0].score == 1 + + def test_phone_in_guid(self): + number = '110bcd25-a55d-453a-8046-1297901ea002' + context = 'my phone number is:' + results = phone_recognizer.analyze(context + number, entities) + + assert len(results) == 0 + + def test_phone_number_strong_match_with_similar_context(self): + number = '(425) 882-9090' + context = 'I am available at ' + results = phone_recognizer.analyze(context + number, entities) + + assert len(results) == 1 + assert results[0].score > 0.69 + assert results[0].entity_type == entities[0] + assert results[0].start == 18 + assert results[0].end == 32 + + def test_phone_number_strong_match_with_irrelevant_context(self): + number = '(425) 882-9090' + context = 'This is just a sentence ' + results = phone_recognizer.analyze(context + number, entities) + + assert len(results) == 1 + assert 0.69 < results[0].score < 1 + assert results[0].entity_type == entities[0] + assert results[0].start == 24 + assert results[0].end == 38 + + def test_phone_number_medium_match_no_context(self): + number = '425 8829090' + results = phone_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert 0.45 < results[0].score < 0.6 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 11 + + # # TODO: enable with task #582 re-support context model in analyzer + # def test_phone_number_medium_match_with_phone_context(self): + # number = '425 8829090' + # context = 'my phone number is ' + # results = phone_recognizer.analyze(context + number, entities) + # + # assert len(results) == 1 + # assert 0.75 < results[0].score < 0.9 + # + # + # def test_phone_number_weak_match_with_phone_context(self): + # number = '4258829090' + # context = 'my phone number is ' + # results = phone_recognizer.analyze(context + number, entities) + # + # assert len(results) == 1 + # assert 0.59 < results[0].score < 0.81 + # + # + # def test_phone_numbers_lemmatized_context_phones(self): + # number1 = '052 5552606' + # number2 = '074-7111234' + # results = phone_recognizer.analyze( + # 'try one of these phones ' + number1 + ' ' + number2, entities) + # + # assert len(results) == 2 + # assert 0.75 < results[0].score < 0.9 + # assert 0.75 < results[0].score < 0.9 + + ''' This test fails since available is not close enough to phone --> requires experimentation with language model + + def test_phone_number_medium_match_with_similar_context(self): + number = '425 8829090' + context = 'I am available at ' + results = phone_recognizer.analyze(context + number, entities) + + assert len(results) == 1 + assert results[0].text == number + assert results[0].score > 0.59 and results[0].score < 0.8 + ''' + + def test_phone_number_medium_match_with_irrelevant_context(self): + number = '425 8829090' + context = 'This is just a sentence ' + results = phone_recognizer.analyze(context + number, entities) + + assert len(results) == 1 + assert 0.29 < results[0].score < 0.51 + assert results[0].entity_type == entities[0] + assert results[0].start == 24 + assert results[0].end == 35 + + def test_phone_number_weak_match_no_context(self): + number = '4258829090' + results = phone_recognizer.analyze(number, entities) + + assert len(results) == 1 + assert 0 < results[0].score < 0.3 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 10 diff --git a/presidio-analyzer/tests/test_us_ssn.py b/presidio-analyzer/tests/test_us_ssn.py deleted file mode 100644 index 5ec009419..000000000 --- a/presidio-analyzer/tests/test_us_ssn.py +++ /dev/null @@ -1,84 +0,0 @@ -from analyzer import matcher, common_pb2 -from tests import * - -fieldType = common_pb2.FieldTypes() -fieldType.name = common_pb2.FieldTypesEnum.Name(common_pb2.US_SSN) -types = [fieldType] - - -def test_valid_us_ssn_very_weak_match(): - num1 = '078-051120' - num2 = '07805-1120' - results = match.analyze_text('{} {}'.format(num1, num2), types) - - assert len(results) == 2 - assert results[0].text == num1 - assert results[0].score > 0.01 and results[0].score < 0.31 - assert results[1].text == num2 - assert results[1].score > 0.01 and results[0].score < 0.31 - - -def test_valid_us_ssn_weak_match(): - num = '078051120' - results = match.analyze_text(num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.29 and results[0].score < 0.41 - - -def test_valid_us_ssn_medium_match(): - num = '078-05-1120' - results = match.analyze_text(num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.49 and results[0].score < 0.6 - - -def test_valid_us_ssn_very_weak_match_exact_context(): - num1 = '078-051120' - num2 = '07805-1120' - context = "my ssn is " - results = match.analyze_text('{} {} {}'.format(context, num1, num2), types) - - assert len(results) == 2 - assert results[0].text == num1 - assert results[0].score > 0.59 and results[0].score < 0.7 - assert results[1].text == num2 - assert results[1].score > 0.59 and results[0].score < 0.7 - - -def test_valid_us_ssn_weak_match_exact_context(): - num = '078051120' - context = "my social security number is " - results = match.analyze_text(context + num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.5 and results[0].score < 0.65 - - -def test_valid_us_ssn_medium_match_exact_context(): - num = '078-05-1120' - context = "my social security number is " - results = match.analyze_text(context + num, types) - - assert len(results) == 1 - assert results[0].text == num - assert results[0].score > 0.6 and results[0].score < 0.9 - - -def test_invalid_us_ssn(): - num = '078-05-11201' - results = match.analyze_text(num, types) - - assert len(results) == 0 - - -def test_invalid_us_ssn_exact_context(): - num = '078-05-11201' - context = "my ssn is " - results = match.analyze_text(context + num, types) - - assert len(results) == 0 diff --git a/presidio-analyzer/tests/test_us_ssn_recognizer.py b/presidio-analyzer/tests/test_us_ssn_recognizer.py new file mode 100644 index 000000000..d64d54ce0 --- /dev/null +++ b/presidio-analyzer/tests/test_us_ssn_recognizer.py @@ -0,0 +1,92 @@ +from unittest import TestCase + +from analyzer.predefined_recognizers import UsSsnRecognizer + +us_ssn_recognizer = UsSsnRecognizer() +entities = ["US_SSN"] + + +class TestUsSsnRecognizer(TestCase): + + def test_valid_us_ssn_very_weak_match(self): + num1 = '078-051120' + num2 = '07805-1120' + results = us_ssn_recognizer.analyze('{} {}'.format(num1, num2), entities) + + assert len(results) == 2 + assert 0.01 < results[0].score < 0.31 + assert results[0].entity_type == entities[0] + assert results[0].start == 0 + assert results[0].end == 10 + + assert 0.01 < results[1].score < 0.31 + assert results[1].start == 11 + assert results[1].end == 21 + assert results[1].entity_type == entities[0] + + + def test_valid_us_ssn_weak_match(self): + num = '078051120' + results = us_ssn_recognizer.analyze(num, entities) + + assert len(results) == 1 + assert 0.29 < results[0].score < 0.41 + assert results[0].start == 0 + assert results[0].end == 9 + assert results[0].entity_type == entities[0] + + + def test_valid_us_ssn_medium_match(self): + num = '078-05-1120' + results = us_ssn_recognizer.analyze(num, entities) + + assert len(results) == 1 + assert 0.49 < results[0].score < 0.6 + assert results[0].start == 0 + assert results[0].end == 11 + assert results[0].entity_type == entities[0] + + + # # TODO: enable with task #582 re-support context model in analyzer + # def test_valid_us_ssn_very_weak_match_exact_context(self): + # num1 = '078-051120' + # num2 = '07805-1120' + # context = "my ssn is " + # results = us_ssn_recognizer.analyze('{} {} {}'.format(context, num1, num2), entities) + # + # assert len(results) == 2 + # assert 0.59 < results[0].score < 0.7 + # assert 0.59 < results[1].score < 0.7 + # + # + # def test_valid_us_ssn_weak_match_exact_context(self): + # num = '078051120' + # context = "my social security number is " + # results = us_ssn_recognizer.analyze(context + num, entities) + # + # assert len(results) == 1 + # assert 0.5 < results[0].score < 0.65 + # + # + # def test_valid_us_ssn_medium_match_exact_context(self): + # num = '078-05-1120' + # context = "my social security number is " + # results = us_ssn_recognizer.analyze(context + num, entities) + # + # assert len(results) == 1 + # assert 0.6 < results[0].score < 0.9 + + + def test_invalid_us_ssn(self): + num = '078-05-11201' + results = us_ssn_recognizer.analyze(num, entities) + + assert len(results) == 0 + + + def test_invalid_us_ssn_exact_context(self): + num = '078-05-11201' + context = "my ssn is " + results = us_ssn_recognizer.analyze(context + num, entities) + + assert len(results) == 0 From 5ed4a1375d685778e52e5dce2b5900e1cf5076cb Mon Sep 17 00:00:00 2001 From: Itye Richter Date: Sun, 17 Feb 2019 23:06:31 +0200 Subject: [PATCH 05/75] Presidio support for language code in template (#98) Configure a language code on the request level and not the field level. All requests should have one language --- Gopkg.toml | 2 +- presidio-analyzer/analyzer/analyzer_engine.py | 25 +- presidio-analyzer/analyzer/common_pb2.py | 198 ++++++++- presidio-analyzer/analyzer/template_pb2.py | 379 +++++++++++++++--- .../tests/test_analyzer_engine.py | 30 ++ .../presidio-api/api/analyze/analyze_test.go | 14 + .../cmd/presidio-api/api/mocks/mocks.go | 2 +- 7 files changed, 564 insertions(+), 86 deletions(-) diff --git a/Gopkg.toml b/Gopkg.toml index 272f9c82e..61662a565 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -52,7 +52,7 @@ name = "github.com/korovkin/limiter" [[constraint]] - branch = "master" + branch = "development" name = "github.com/Microsoft/presidio-genproto" [[constraint]] diff --git a/presidio-analyzer/analyzer/analyzer_engine.py b/presidio-analyzer/analyzer/analyzer_engine.py index b5daf25e9..790b4f325 100644 --- a/presidio-analyzer/analyzer/analyzer_engine.py +++ b/presidio-analyzer/analyzer/analyzer_engine.py @@ -49,11 +49,10 @@ def __remove_duplicates(results): return filtered_results def Apply(self, request, context): - logging.info("Starting Apply ") + logging.info("Starting Apply") entities = self.__convert_fields_to_entities( request.analyzeTemplate.fields) - language = self.__get_language(request.analyzeTemplate.fields) - + language = self.get_language_from_request(request) results = self.analyze(request.text, entities, language) # Create Analyze Response Object @@ -64,6 +63,12 @@ def Apply(self, request, context): logging.info("Found {} results".format(len(results))) return response + def get_language_from_request(self, request): + language = request.analyzeTemplate.languageCode + if language is None or language == "": + language = DEFAULT_LANGUAGE + return language + def analyze(self, text, entities, language): """ analyzes the requested text, searching for the given entities @@ -104,20 +109,6 @@ def remove_recognizer(self, name): """ self.registry.remove_recognizer(name) - # These 3 methods below, should be removed as part of the work in: - # Task #543 implement redesigned templates and - # Task #580: API support for multiple languages - # input language text to specific recognizers - def __get_language(self, fields): - # Currently each field hold its own language code - # we are going to change it so we will get only one language - # per request -> current logic: take the first language - if not fields or len(fields) == 0 or fields[0].languageCode is None \ - or fields[0].languageCode == "": - return DEFAULT_LANGUAGE - - return fields[0].languageCode - def __convert_fields_to_entities(self, fields): # Convert fields to entities - will be changed once the API # will be changed diff --git a/presidio-analyzer/analyzer/common_pb2.py b/presidio-analyzer/analyzer/common_pb2.py index 1482d1d8c..ded4d9583 100644 --- a/presidio-analyzer/analyzer/common_pb2.py +++ b/presidio-analyzer/analyzer/common_pb2.py @@ -20,7 +20,7 @@ name='common.proto', package='types', syntax='proto3', - serialized_pb=_b('\n\x0c\x63ommon.proto\x12\x05types\"B\n\nFieldTypes\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x14\n\x0clanguageCode\x18\x02 \x01(\t\x12\x10\n\x08minScore\x18\x03 \x01(\t\"q\n\rAnalyzeResult\x12\x0c\n\x04text\x18\x01 \x01(\t\x12 \n\x05\x66ield\x18\x02 \x01(\x0b\x32\x11.types.FieldTypes\x12\r\n\x05score\x18\x03 \x01(\x02\x12!\n\x08location\x18\x04 \x01(\x0b\x32\x0f.types.Location\"6\n\x08Location\x12\r\n\x05start\x18\x01 \x01(\x11\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x11\x12\x0e\n\x06length\x18\x03 \x01(\x11*\x95\x02\n\x0e\x46ieldTypesEnum\x12\x0f\n\x0b\x43REDIT_CARD\x10\x00\x12\n\n\x06\x43RYPTO\x10\x01\x12\r\n\tDATE_TIME\x10\x02\x12\x0f\n\x0b\x44OMAIN_NAME\x10\x03\x12\x11\n\rEMAIL_ADDRESS\x10\x04\x12\r\n\tIBAN_CODE\x10\x05\x12\x0e\n\nIP_ADDRESS\x10\x06\x12\x07\n\x03NRP\x10\x07\x12\x0c\n\x08LOCATION\x10\x08\x12\n\n\x06PERSON\x10\t\x12\x10\n\x0cPHONE_NUMBER\x10\n\x12\x12\n\x0eUS_BANK_NUMBER\x10\x0b\x12\x15\n\x11US_DRIVER_LICENSE\x10\x0c\x12\x0b\n\x07US_ITIN\x10\r\x12\x0f\n\x0bUS_PASSPORT\x10\x0e\x12\n\n\x06US_SSN\x10\x0f\x12\n\n\x06UK_NHS\x10\x10\x62\x06proto3') + serialized_pb=_b('\n\x0c\x63ommon.proto\x12\x05types\",\n\nFieldTypes\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x10\n\x08minScore\x18\x02 \x01(\t\"q\n\rAnalyzeResult\x12\x0c\n\x04text\x18\x01 \x01(\t\x12 \n\x05\x66ield\x18\x02 \x01(\x0b\x32\x11.types.FieldTypes\x12\r\n\x05score\x18\x03 \x01(\x02\x12!\n\x08location\x18\x04 \x01(\x0b\x32\x0f.types.Location\"6\n\x08Location\x12\r\n\x05start\x18\x01 \x01(\x11\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x11\x12\x0e\n\x06length\x18\x03 \x01(\x11\"a\n\x05Image\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\x12\x11\n\timageType\x18\x02 \x01(\t\x12)\n\rBoundingboxes\x18\x03 \x03(\x0b\x32\x12.types.Boundingbox\x12\x0c\n\x04text\x18\x04 \x01(\t\"\x8c\x01\n\x0b\x42oundingbox\x12\x11\n\txLocation\x18\x01 \x01(\x02\x12\r\n\x05width\x18\x02 \x01(\x02\x12\x11\n\tyLocation\x18\x03 \x01(\x02\x12\x0e\n\x06height\x18\x04 \x01(\x02\x12\x0c\n\x04text\x18\x05 \x01(\t\x12\x15\n\rstartPosition\x18\x06 \x01(\x11\x12\x13\n\x0b\x65ndPosition\x18\x07 \x01(\x11*\x95\x02\n\x0e\x46ieldTypesEnum\x12\x0f\n\x0b\x43REDIT_CARD\x10\x00\x12\n\n\x06\x43RYPTO\x10\x01\x12\r\n\tDATE_TIME\x10\x02\x12\x0f\n\x0b\x44OMAIN_NAME\x10\x03\x12\x11\n\rEMAIL_ADDRESS\x10\x04\x12\r\n\tIBAN_CODE\x10\x05\x12\x0e\n\nIP_ADDRESS\x10\x06\x12\x07\n\x03NRP\x10\x07\x12\x0c\n\x08LOCATION\x10\x08\x12\n\n\x06PERSON\x10\t\x12\x10\n\x0cPHONE_NUMBER\x10\n\x12\x12\n\x0eUS_BANK_NUMBER\x10\x0b\x12\x15\n\x11US_DRIVER_LICENSE\x10\x0c\x12\x0b\n\x07US_ITIN\x10\r\x12\x0f\n\x0bUS_PASSPORT\x10\x0e\x12\n\n\x06US_SSN\x10\x0f\x12\n\n\x06UK_NHS\x10\x10*;\n\x11\x44\x65tectionTypeEnum\x12\x07\n\x03OCR\x10\x00\x12\r\n\tAZURE_OCR\x10\x01\x12\x0e\n\nAZURE_FACE\x10\x02\x62\x06proto3') ) _FIELDTYPESENUM = _descriptor.EnumDescriptor( @@ -100,12 +100,39 @@ ], containing_type=None, options=None, - serialized_start=263, - serialized_end=540, + serialized_start=483, + serialized_end=760, ) _sym_db.RegisterEnumDescriptor(_FIELDTYPESENUM) FieldTypesEnum = enum_type_wrapper.EnumTypeWrapper(_FIELDTYPESENUM) +_DETECTIONTYPEENUM = _descriptor.EnumDescriptor( + name='DetectionTypeEnum', + full_name='types.DetectionTypeEnum', + filename=None, + file=DESCRIPTOR, + values=[ + _descriptor.EnumValueDescriptor( + name='OCR', index=0, number=0, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='AZURE_OCR', index=1, number=1, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='AZURE_FACE', index=2, number=2, + options=None, + type=None), + ], + containing_type=None, + options=None, + serialized_start=762, + serialized_end=821, +) +_sym_db.RegisterEnumDescriptor(_DETECTIONTYPEENUM) + +DetectionTypeEnum = enum_type_wrapper.EnumTypeWrapper(_DETECTIONTYPEENUM) CREDIT_CARD = 0 CRYPTO = 1 DATE_TIME = 2 @@ -123,6 +150,9 @@ US_PASSPORT = 14 US_SSN = 15 UK_NHS = 16 +OCR = 0 +AZURE_OCR = 1 +AZURE_FACE = 2 @@ -141,19 +171,12 @@ is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( - name='languageCode', full_name='types.FieldTypes.languageCode', index=1, + name='minScore', full_name='types.FieldTypes.minScore', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), - _descriptor.FieldDescriptor( - name='minScore', full_name='types.FieldTypes.minScore', index=2, - number=3, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=_b("").decode('utf-8'), - message_type=None, enum_type=None, containing_type=None, - is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -167,7 +190,7 @@ oneofs=[ ], serialized_start=23, - serialized_end=89, + serialized_end=67, ) @@ -218,8 +241,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=91, - serialized_end=204, + serialized_start=69, + serialized_end=182, ) @@ -263,16 +286,145 @@ extension_ranges=[], oneofs=[ ], - serialized_start=206, - serialized_end=260, + serialized_start=184, + serialized_end=238, +) + + +_IMAGE = _descriptor.Descriptor( + name='Image', + full_name='types.Image', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='data', full_name='types.Image.data', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='imageType', full_name='types.Image.imageType', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='Boundingboxes', full_name='types.Image.Boundingboxes', index=2, + number=3, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='text', full_name='types.Image.text', index=3, + number=4, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=240, + serialized_end=337, +) + + +_BOUNDINGBOX = _descriptor.Descriptor( + name='Boundingbox', + full_name='types.Boundingbox', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='xLocation', full_name='types.Boundingbox.xLocation', index=0, + number=1, type=2, cpp_type=6, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='width', full_name='types.Boundingbox.width', index=1, + number=2, type=2, cpp_type=6, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='yLocation', full_name='types.Boundingbox.yLocation', index=2, + number=3, type=2, cpp_type=6, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='height', full_name='types.Boundingbox.height', index=3, + number=4, type=2, cpp_type=6, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='text', full_name='types.Boundingbox.text', index=4, + number=5, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='startPosition', full_name='types.Boundingbox.startPosition', index=5, + number=6, type=17, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='endPosition', full_name='types.Boundingbox.endPosition', index=6, + number=7, type=17, cpp_type=1, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=340, + serialized_end=480, ) _ANALYZERESULT.fields_by_name['field'].message_type = _FIELDTYPES _ANALYZERESULT.fields_by_name['location'].message_type = _LOCATION +_IMAGE.fields_by_name['Boundingboxes'].message_type = _BOUNDINGBOX DESCRIPTOR.message_types_by_name['FieldTypes'] = _FIELDTYPES DESCRIPTOR.message_types_by_name['AnalyzeResult'] = _ANALYZERESULT DESCRIPTOR.message_types_by_name['Location'] = _LOCATION +DESCRIPTOR.message_types_by_name['Image'] = _IMAGE +DESCRIPTOR.message_types_by_name['Boundingbox'] = _BOUNDINGBOX DESCRIPTOR.enum_types_by_name['FieldTypesEnum'] = _FIELDTYPESENUM +DESCRIPTOR.enum_types_by_name['DetectionTypeEnum'] = _DETECTIONTYPEENUM _sym_db.RegisterFileDescriptor(DESCRIPTOR) FieldTypes = _reflection.GeneratedProtocolMessageType('FieldTypes', (_message.Message,), dict( @@ -296,5 +448,19 @@ )) _sym_db.RegisterMessage(Location) +Image = _reflection.GeneratedProtocolMessageType('Image', (_message.Message,), dict( + DESCRIPTOR = _IMAGE, + __module__ = 'common_pb2' + # @@protoc_insertion_point(class_scope:types.Image) + )) +_sym_db.RegisterMessage(Image) + +Boundingbox = _reflection.GeneratedProtocolMessageType('Boundingbox', (_message.Message,), dict( + DESCRIPTOR = _BOUNDINGBOX, + __module__ = 'common_pb2' + # @@protoc_insertion_point(class_scope:types.Boundingbox) + )) +_sym_db.RegisterMessage(Boundingbox) + # @@protoc_insertion_point(module_scope) diff --git a/presidio-analyzer/analyzer/template_pb2.py b/presidio-analyzer/analyzer/template_pb2.py index a40a2febe..4f9309f7c 100644 --- a/presidio-analyzer/analyzer/template_pb2.py +++ b/presidio-analyzer/analyzer/template_pb2.py @@ -20,7 +20,7 @@ name='template.proto', package='types', syntax='proto3', - serialized_pb=_b('\n\x0etemplate.proto\x12\x05types\x1a\x0c\x63ommon.proto\"s\n\x0f\x41nalyzeTemplate\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x12\n\ncreateTime\x18\x03 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x04 \x01(\t\"\x94\x01\n\x11\x41nonymizeTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12@\n\x18\x66ieldTypeTransformations\x18\x04 \x03(\x0b\x32\x1e.types.FieldTypeTransformation\"k\n\x17\x46ieldTypeTransformation\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12-\n\x0etransformation\x18\x02 \x01(\x0b\x32\x15.types.Transformation\"\xd1\x01\n\x0eTransformation\x12)\n\x0creplaceValue\x18\x02 \x01(\x0b\x32\x13.types.ReplaceValue\x12\'\n\x0bredactValue\x18\x03 \x01(\x0b\x32\x12.types.RedactValue\x12#\n\thashValue\x18\x04 \x01(\x0b\x32\x10.types.HashValue\x12#\n\tmaskValue\x18\x05 \x01(\x0b\x32\x10.types.MaskValue\x12!\n\x08\x66PEValue\x18\x06 \x01(\x0b\x32\x0f.types.FPEValue\" \n\x0cReplaceValue\x12\x10\n\x08newValue\x18\x01 \x01(\t\"\r\n\x0bRedactValue\"\x0b\n\tHashValue\"K\n\tMaskValue\x12\x18\n\x10maskingCharacter\x18\x01 \x01(\t\x12\x13\n\x0b\x63harsToMask\x18\x02 \x01(\x05\x12\x0f\n\x07\x66romEnd\x18\x03 \x01(\x08\"7\n\x08\x46PEValue\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05tweak\x18\x02 \x01(\t\x12\x0f\n\x07\x64\x65\x63rypt\x18\x03 \x01(\x08\"E\n\x08\x44\x42\x43onfig\x12\x18\n\x10\x63onnectionString\x18\x01 \x01(\t\x12\x11\n\ttableName\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\"\x8f\x01\n\x08\x44\x61tasink\x12!\n\x08\x64\x62\x43onfig\x18\x01 \x01(\x0b\x32\x0f.types.DBConfig\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\"}\n\x10\x44\x61tasinkTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12(\n\x0f\x61nalyzeDatasink\x18\x02 \x03(\x0b\x32\x0f.types.Datasink\x12*\n\x11\x61nonymizeDatasink\x18\x03 \x03(\x0b\x32\x0f.types.Datasink\"S\n\x11\x42lobStorageConfig\x12\x13\n\x0b\x61\x63\x63ountName\x18\x01 \x01(\t\x12\x12\n\naccountKey\x18\x02 \x01(\t\x12\x15\n\rcontainerName\x18\x03 \x01(\t\"e\n\x08S3Config\x12\x10\n\x08\x61\x63\x63\x65ssId\x18\x01 \x01(\t\x12\x11\n\taccessKey\x18\x02 \x01(\t\x12\x0e\n\x06region\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\x12\x10\n\x08\x65ndpoint\x18\x05 \x01(\t\"Z\n\x13GoogleStorageConfig\x12\x0c\n\x04json\x18\x01 \x01(\t\x12\x11\n\tprojectId\x18\x02 \x01(\t\x12\x0e\n\x06scopes\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\"\xa5\x01\n\x12\x43loudStorageConfig\x12\x33\n\x11\x62lobStorageConfig\x18\x01 \x01(\x0b\x32\x18.types.BlobStorageConfig\x12!\n\x08s3Config\x18\x02 \x01(\x0b\x32\x0f.types.S3Config\x12\x37\n\x13GoogleStorageConfig\x18\x03 \x01(\x0b\x32\x1a.types.GoogleStorageConfig\"r\n\x0cStreamConfig\x12\'\n\x0bkafkaConfig\x18\x01 \x01(\x0b\x32\x12.types.KafkaConfig\x12!\n\x08\x65hConfig\x18\x02 \x01(\x0b\x32\x0f.types.EHConfig\x12\x16\n\x0epartitionCount\x18\x03 \x01(\x05\"Y\n\x0bKafkaConfig\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x14\n\x0csaslUsername\x18\x03 \x01(\t\x12\x14\n\x0csaslPassword\x18\x04 \x01(\t\"\xcb\x01\n\x08\x45HConfig\x12\x13\n\x0b\x65hNamespace\x18\x01 \x01(\t\x12\x0e\n\x06\x65hName\x18\x02 \x01(\t\x12\x1a\n\x12\x65hConnectionString\x18\x03 \x01(\t\x12\x11\n\tehKeyName\x18\x04 \x01(\t\x12\x12\n\nehKeyValue\x18\x05 \x01(\t\x12\x1f\n\x17storageAccountNameValue\x18\x06 \x01(\t\x12\x1e\n\x16storageAccountKeyValue\x18\x07 \x01(\t\x12\x16\n\x0e\x63ontainerValue\x18\x08 \x01(\t\"\xb2\x01\n\x0eStreamTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\"Z\n\x0cScanTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\"\xc8\x01\n\x16ScannerCronJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x1f\n\x07trigger\x18\x03 \x01(\x0b\x32\x0e.types.Trigger\x12\x16\n\x0escanTemplateId\x18\x04 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x05 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x06 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x07 \x01(\t\"\xa6\x01\n\x12StreamsJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x19\n\x11streamsTemplateId\x18\x03 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\",\n\x07Trigger\x12!\n\x08schedule\x18\x01 \x01(\x0b\x32\x0f.types.Schedule\"$\n\x08Schedule\x12\x18\n\x10recurrencePeriod\x18\x01 \x01(\tb\x06proto3') + serialized_pb=_b('\n\x0etemplate.proto\x12\x05types\x1a\x0c\x63ommon.proto\"\x89\x01\n\x0f\x41nalyzeTemplate\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x12\n\ncreateTime\x18\x03 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x04 \x01(\t\x12\x14\n\x0clanguageCode\x18\x05 \x01(\t\"\xca\x01\n\x11\x41nonymizeTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12@\n\x18\x66ieldTypeTransformations\x18\x04 \x03(\x0b\x32\x1e.types.FieldTypeTransformation\x12\x34\n\x15\x64\x65\x66\x61ultTransformation\x18\x05 \x01(\x0b\x32\x15.types.Transformation\"g\n\x12JsonSchemaTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12\x12\n\njsonSchema\x18\x04 \x01(\t\"k\n\x17\x46ieldTypeTransformation\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12-\n\x0etransformation\x18\x02 \x01(\x0b\x32\x15.types.Transformation\"\xd1\x01\n\x0eTransformation\x12)\n\x0creplaceValue\x18\x02 \x01(\x0b\x32\x13.types.ReplaceValue\x12\'\n\x0bredactValue\x18\x03 \x01(\x0b\x32\x12.types.RedactValue\x12#\n\thashValue\x18\x04 \x01(\x0b\x32\x10.types.HashValue\x12#\n\tmaskValue\x18\x05 \x01(\x0b\x32\x10.types.MaskValue\x12!\n\x08\x66PEValue\x18\x06 \x01(\x0b\x32\x0f.types.FPEValue\" \n\x0cReplaceValue\x12\x10\n\x08newValue\x18\x01 \x01(\t\"\r\n\x0bRedactValue\"\x0b\n\tHashValue\"K\n\tMaskValue\x12\x18\n\x10maskingCharacter\x18\x01 \x01(\t\x12\x13\n\x0b\x63harsToMask\x18\x02 \x01(\x05\x12\x0f\n\x07\x66romEnd\x18\x03 \x01(\x08\"7\n\x08\x46PEValue\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05tweak\x18\x02 \x01(\t\x12\x0f\n\x07\x64\x65\x63rypt\x18\x03 \x01(\x08\"E\n\x08\x44\x42\x43onfig\x12\x18\n\x10\x63onnectionString\x18\x01 \x01(\t\x12\x11\n\ttableName\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\"\x8f\x01\n\x08\x44\x61tasink\x12!\n\x08\x64\x62\x43onfig\x18\x01 \x01(\x0b\x32\x0f.types.DBConfig\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\"}\n\x10\x44\x61tasinkTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12(\n\x0f\x61nalyzeDatasink\x18\x02 \x03(\x0b\x32\x0f.types.Datasink\x12*\n\x11\x61nonymizeDatasink\x18\x03 \x03(\x0b\x32\x0f.types.Datasink\"S\n\x11\x42lobStorageConfig\x12\x13\n\x0b\x61\x63\x63ountName\x18\x01 \x01(\t\x12\x12\n\naccountKey\x18\x02 \x01(\t\x12\x15\n\rcontainerName\x18\x03 \x01(\t\"e\n\x08S3Config\x12\x10\n\x08\x61\x63\x63\x65ssId\x18\x01 \x01(\t\x12\x11\n\taccessKey\x18\x02 \x01(\t\x12\x0e\n\x06region\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\x12\x10\n\x08\x65ndpoint\x18\x05 \x01(\t\"Z\n\x13GoogleStorageConfig\x12\x0c\n\x04json\x18\x01 \x01(\t\x12\x11\n\tprojectId\x18\x02 \x01(\t\x12\x0e\n\x06scopes\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\"\xa5\x01\n\x12\x43loudStorageConfig\x12\x33\n\x11\x62lobStorageConfig\x18\x01 \x01(\x0b\x32\x18.types.BlobStorageConfig\x12!\n\x08s3Config\x18\x02 \x01(\x0b\x32\x0f.types.S3Config\x12\x37\n\x13GoogleStorageConfig\x18\x03 \x01(\x0b\x32\x1a.types.GoogleStorageConfig\"r\n\x0cStreamConfig\x12\'\n\x0bkafkaConfig\x18\x01 \x01(\x0b\x32\x12.types.KafkaConfig\x12!\n\x08\x65hConfig\x18\x02 \x01(\x0b\x32\x0f.types.EHConfig\x12\x16\n\x0epartitionCount\x18\x03 \x01(\x05\"Y\n\x0bKafkaConfig\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x14\n\x0csaslUsername\x18\x03 \x01(\t\x12\x14\n\x0csaslPassword\x18\x04 \x01(\t\"\xcb\x01\n\x08\x45HConfig\x12\x13\n\x0b\x65hNamespace\x18\x01 \x01(\t\x12\x0e\n\x06\x65hName\x18\x02 \x01(\t\x12\x1a\n\x12\x65hConnectionString\x18\x03 \x01(\t\x12\x11\n\tehKeyName\x18\x04 \x01(\t\x12\x12\n\nehKeyValue\x18\x05 \x01(\t\x12\x1f\n\x17storageAccountNameValue\x18\x06 \x01(\t\x12\x1e\n\x16storageAccountKeyValue\x18\x07 \x01(\t\x12\x16\n\x0e\x63ontainerValue\x18\x08 \x01(\t\"\xb2\x01\n\x0eStreamTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\"Z\n\x0cScanTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\"\xc8\x01\n\x16ScannerCronJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x1f\n\x07trigger\x18\x03 \x01(\x0b\x32\x0e.types.Trigger\x12\x16\n\x0escanTemplateId\x18\x04 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x05 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x06 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x07 \x01(\t\"\xa6\x01\n\x12StreamsJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x19\n\x11streamsTemplateId\x18\x03 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\",\n\x07Trigger\x12!\n\x08schedule\x18\x01 \x01(\x0b\x32\x0f.types.Schedule\"$\n\x08Schedule\x12\x18\n\x10recurrencePeriod\x18\x01 \x01(\t\"\x8b\x01\n\x16\x41nonymizeImageTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12\x32\n\x11\x66ieldTypeGraphics\x18\x04 \x03(\x0b\x32\x17.types.FieldTypeGraphic\"V\n\x10\x46ieldTypeGraphic\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12\x1f\n\x07graphic\x18\x02 \x01(\x0b\x32\x0e.types.Graphic\"8\n\x07Graphic\x12-\n\x0e\x66illColorValue\x18\x01 \x01(\x0b\x32\x15.types.FillColorValue\":\n\x0e\x46illColorValue\x12\x0b\n\x03red\x18\x01 \x01(\x01\x12\r\n\x05green\x18\x02 \x01(\x01\x12\x0c\n\x04\x62lue\x18\x03 \x01(\x01\x62\x06proto3') , dependencies=[common__pb2.DESCRIPTOR,]) @@ -62,6 +62,13 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='languageCode', full_name='types.AnalyzeTemplate.languageCode', index=4, + number=5, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -74,8 +81,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=39, - serialized_end=154, + serialized_start=40, + serialized_end=177, ) @@ -114,6 +121,65 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='defaultTransformation', full_name='types.AnonymizeTemplate.defaultTransformation', index=4, + number=5, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=180, + serialized_end=382, +) + + +_JSONSCHEMATEMPLATE = _descriptor.Descriptor( + name='JsonSchemaTemplate', + full_name='types.JsonSchemaTemplate', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='description', full_name='types.JsonSchemaTemplate.description', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='createTime', full_name='types.JsonSchemaTemplate.createTime', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='modifiedTime', full_name='types.JsonSchemaTemplate.modifiedTime', index=2, + number=3, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='jsonSchema', full_name='types.JsonSchemaTemplate.jsonSchema', index=3, + number=4, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -126,8 +192,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=157, - serialized_end=305, + serialized_start=384, + serialized_end=487, ) @@ -164,8 +230,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=307, - serialized_end=414, + serialized_start=489, + serialized_end=596, ) @@ -223,8 +289,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=417, - serialized_end=626, + serialized_start=599, + serialized_end=808, ) @@ -254,8 +320,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=628, - serialized_end=660, + serialized_start=810, + serialized_end=842, ) @@ -278,8 +344,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=662, - serialized_end=675, + serialized_start=844, + serialized_end=857, ) @@ -302,8 +368,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=677, - serialized_end=688, + serialized_start=859, + serialized_end=870, ) @@ -347,8 +413,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=690, - serialized_end=765, + serialized_start=872, + serialized_end=947, ) @@ -392,8 +458,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=767, - serialized_end=822, + serialized_start=949, + serialized_end=1004, ) @@ -437,8 +503,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=824, - serialized_end=893, + serialized_start=1006, + serialized_end=1075, ) @@ -482,8 +548,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=896, - serialized_end=1039, + serialized_start=1078, + serialized_end=1221, ) @@ -527,8 +593,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1041, - serialized_end=1166, + serialized_start=1223, + serialized_end=1348, ) @@ -572,8 +638,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1168, - serialized_end=1251, + serialized_start=1350, + serialized_end=1433, ) @@ -631,8 +697,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1253, - serialized_end=1354, + serialized_start=1435, + serialized_end=1536, ) @@ -683,8 +749,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1356, - serialized_end=1446, + serialized_start=1538, + serialized_end=1628, ) @@ -728,8 +794,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1449, - serialized_end=1614, + serialized_start=1631, + serialized_end=1796, ) @@ -773,8 +839,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1616, - serialized_end=1730, + serialized_start=1798, + serialized_end=1912, ) @@ -825,8 +891,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1732, - serialized_end=1821, + serialized_start=1914, + serialized_end=2003, ) @@ -905,8 +971,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1824, - serialized_end=2027, + serialized_start=2006, + serialized_end=2209, ) @@ -971,8 +1037,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2030, - serialized_end=2208, + serialized_start=2212, + serialized_end=2390, ) @@ -1009,8 +1075,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2210, - serialized_end=2300, + serialized_start=2392, + serialized_end=2482, ) @@ -1082,8 +1148,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2303, - serialized_end=2503, + serialized_start=2485, + serialized_end=2685, ) @@ -1148,8 +1214,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2506, - serialized_end=2672, + serialized_start=2688, + serialized_end=2854, ) @@ -1179,8 +1245,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2674, - serialized_end=2718, + serialized_start=2856, + serialized_end=2900, ) @@ -1210,12 +1276,179 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2720, - serialized_end=2756, + serialized_start=2902, + serialized_end=2938, +) + + +_ANONYMIZEIMAGETEMPLATE = _descriptor.Descriptor( + name='AnonymizeImageTemplate', + full_name='types.AnonymizeImageTemplate', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='description', full_name='types.AnonymizeImageTemplate.description', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='createTime', full_name='types.AnonymizeImageTemplate.createTime', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='modifiedTime', full_name='types.AnonymizeImageTemplate.modifiedTime', index=2, + number=3, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='fieldTypeGraphics', full_name='types.AnonymizeImageTemplate.fieldTypeGraphics', index=3, + number=4, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=2941, + serialized_end=3080, +) + + +_FIELDTYPEGRAPHIC = _descriptor.Descriptor( + name='FieldTypeGraphic', + full_name='types.FieldTypeGraphic', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='fields', full_name='types.FieldTypeGraphic.fields', index=0, + number=1, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='graphic', full_name='types.FieldTypeGraphic.graphic', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=3082, + serialized_end=3168, +) + + +_GRAPHIC = _descriptor.Descriptor( + name='Graphic', + full_name='types.Graphic', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='fillColorValue', full_name='types.Graphic.fillColorValue', index=0, + number=1, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=3170, + serialized_end=3226, +) + + +_FILLCOLORVALUE = _descriptor.Descriptor( + name='FillColorValue', + full_name='types.FillColorValue', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='red', full_name='types.FillColorValue.red', index=0, + number=1, type=1, cpp_type=5, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='green', full_name='types.FillColorValue.green', index=1, + number=2, type=1, cpp_type=5, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='blue', full_name='types.FillColorValue.blue', index=2, + number=3, type=1, cpp_type=5, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=3228, + serialized_end=3286, ) _ANALYZETEMPLATE.fields_by_name['fields'].message_type = common__pb2._FIELDTYPES _ANONYMIZETEMPLATE.fields_by_name['fieldTypeTransformations'].message_type = _FIELDTYPETRANSFORMATION +_ANONYMIZETEMPLATE.fields_by_name['defaultTransformation'].message_type = _TRANSFORMATION _FIELDTYPETRANSFORMATION.fields_by_name['fields'].message_type = common__pb2._FIELDTYPES _FIELDTYPETRANSFORMATION.fields_by_name['transformation'].message_type = _TRANSFORMATION _TRANSFORMATION.fields_by_name['replaceValue'].message_type = _REPLACEVALUE @@ -1237,8 +1470,13 @@ _SCANTEMPLATE.fields_by_name['cloudStorageConfig'].message_type = _CLOUDSTORAGECONFIG _SCANNERCRONJOBTEMPLATE.fields_by_name['trigger'].message_type = _TRIGGER _TRIGGER.fields_by_name['schedule'].message_type = _SCHEDULE +_ANONYMIZEIMAGETEMPLATE.fields_by_name['fieldTypeGraphics'].message_type = _FIELDTYPEGRAPHIC +_FIELDTYPEGRAPHIC.fields_by_name['fields'].message_type = common__pb2._FIELDTYPES +_FIELDTYPEGRAPHIC.fields_by_name['graphic'].message_type = _GRAPHIC +_GRAPHIC.fields_by_name['fillColorValue'].message_type = _FILLCOLORVALUE DESCRIPTOR.message_types_by_name['AnalyzeTemplate'] = _ANALYZETEMPLATE DESCRIPTOR.message_types_by_name['AnonymizeTemplate'] = _ANONYMIZETEMPLATE +DESCRIPTOR.message_types_by_name['JsonSchemaTemplate'] = _JSONSCHEMATEMPLATE DESCRIPTOR.message_types_by_name['FieldTypeTransformation'] = _FIELDTYPETRANSFORMATION DESCRIPTOR.message_types_by_name['Transformation'] = _TRANSFORMATION DESCRIPTOR.message_types_by_name['ReplaceValue'] = _REPLACEVALUE @@ -1262,6 +1500,10 @@ DESCRIPTOR.message_types_by_name['StreamsJobTemplate'] = _STREAMSJOBTEMPLATE DESCRIPTOR.message_types_by_name['Trigger'] = _TRIGGER DESCRIPTOR.message_types_by_name['Schedule'] = _SCHEDULE +DESCRIPTOR.message_types_by_name['AnonymizeImageTemplate'] = _ANONYMIZEIMAGETEMPLATE +DESCRIPTOR.message_types_by_name['FieldTypeGraphic'] = _FIELDTYPEGRAPHIC +DESCRIPTOR.message_types_by_name['Graphic'] = _GRAPHIC +DESCRIPTOR.message_types_by_name['FillColorValue'] = _FILLCOLORVALUE _sym_db.RegisterFileDescriptor(DESCRIPTOR) AnalyzeTemplate = _reflection.GeneratedProtocolMessageType('AnalyzeTemplate', (_message.Message,), dict( @@ -1278,6 +1520,13 @@ )) _sym_db.RegisterMessage(AnonymizeTemplate) +JsonSchemaTemplate = _reflection.GeneratedProtocolMessageType('JsonSchemaTemplate', (_message.Message,), dict( + DESCRIPTOR = _JSONSCHEMATEMPLATE, + __module__ = 'template_pb2' + # @@protoc_insertion_point(class_scope:types.JsonSchemaTemplate) + )) +_sym_db.RegisterMessage(JsonSchemaTemplate) + FieldTypeTransformation = _reflection.GeneratedProtocolMessageType('FieldTypeTransformation', (_message.Message,), dict( DESCRIPTOR = _FIELDTYPETRANSFORMATION, __module__ = 'template_pb2' @@ -1439,5 +1688,33 @@ )) _sym_db.RegisterMessage(Schedule) +AnonymizeImageTemplate = _reflection.GeneratedProtocolMessageType('AnonymizeImageTemplate', (_message.Message,), dict( + DESCRIPTOR = _ANONYMIZEIMAGETEMPLATE, + __module__ = 'template_pb2' + # @@protoc_insertion_point(class_scope:types.AnonymizeImageTemplate) + )) +_sym_db.RegisterMessage(AnonymizeImageTemplate) + +FieldTypeGraphic = _reflection.GeneratedProtocolMessageType('FieldTypeGraphic', (_message.Message,), dict( + DESCRIPTOR = _FIELDTYPEGRAPHIC, + __module__ = 'template_pb2' + # @@protoc_insertion_point(class_scope:types.FieldTypeGraphic) + )) +_sym_db.RegisterMessage(FieldTypeGraphic) + +Graphic = _reflection.GeneratedProtocolMessageType('Graphic', (_message.Message,), dict( + DESCRIPTOR = _GRAPHIC, + __module__ = 'template_pb2' + # @@protoc_insertion_point(class_scope:types.Graphic) + )) +_sym_db.RegisterMessage(Graphic) + +FillColorValue = _reflection.GeneratedProtocolMessageType('FillColorValue', (_message.Message,), dict( + DESCRIPTOR = _FILLCOLORVALUE, + __module__ = 'template_pb2' + # @@protoc_insertion_point(class_scope:types.FillColorValue) + )) +_sym_db.RegisterMessage(FillColorValue) + # @@protoc_insertion_point(module_scope) diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index d5b411375..4b221cd19 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -4,6 +4,7 @@ from analyzer import AnalyzerEngine, PatternRecognizer, Pattern, \ RecognizerResult, RecognizerRegistry +from analyzer.analyze_pb2 import AnalyzeRequest from analyzer.predefined_recognizers import CreditCardRecognizer, \ UsPhoneRecognizer @@ -139,3 +140,32 @@ def test_remove_analyzer(self): language='en') assert len(res) == 0 + + def test_Apply_with_language_returns_correct_response(self): + analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) + + request = AnalyzeRequest() + request.analyzeTemplate.languageCode = 'en' + new_field = request.analyzeTemplate.fields.add() + new_field.name = 'CREDIT_CARD' + new_field.minScore = '0.5' + request.text = "My credit card number is 4916994465041084" + response = analyze_engine.Apply(request, None) + + assert response.analyzeResults is not None + + + def test_Apply_with_no_language_returns_default(self): + analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) + + request = AnalyzeRequest() + request.analyzeTemplate.languageCode = '' + new_field = request.analyzeTemplate.fields.add() + new_field.name = 'CREDIT_CARD' + new_field.minScore = '0.5' + request.text = "My credit card number is 4916994465041084" + response = analyze_engine.Apply(request, None) + assert response.analyzeResults is not None + + + diff --git a/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go b/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go index 65d686081..c5b2ce7f7 100644 --- a/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go +++ b/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go @@ -74,3 +74,17 @@ func TestAnalyzeWithNoTemplate(t *testing.T) { assert.Error(t, err) } + +func TestLanguageCode(t *testing.T) { + + api := setupMockServices() + + project := "tests" + analyzeAPIRequest := &types.AnalyzeApiRequest{ + Text: "My number is (555) 253-0000 and email johnsnow@foo.com", + AnalyzeTemplateId: "test", + AnalyzeTemplate: &types.AnalyzeTemplate{}, + } + Analyze(context.Background(), api, analyzeAPIRequest, project) + assert.Equal(t, "langtest", analyzeAPIRequest.AnalyzeTemplate.LanguageCode) +} diff --git a/presidio-api/cmd/presidio-api/api/mocks/mocks.go b/presidio-api/cmd/presidio-api/api/mocks/mocks.go index 1b1b9b37d..657b46d3a 100644 --- a/presidio-api/cmd/presidio-api/api/mocks/mocks.go +++ b/presidio-api/cmd/presidio-api/api/mocks/mocks.go @@ -161,7 +161,7 @@ func GetOcrMockResult() *types.OcrResponse { func GetTemplateMock() presidio.TemplatesStore { templateService := &TemplateMockedObject{} templateService.On("GetTemplate", mock.Anything, store.Analyze, mock.Anything). - Return(`{"fields":[{"name":"PHONE_NUMBER"}, {"name":"EMAIL_ADDRESS"}]}`, nil). + Return(`{"fields":[{"name":"PHONE_NUMBER"}, {"name":"EMAIL_ADDRESS"}],"languageCode":"langtest"}`, nil). On("GetTemplate", mock.Anything, store.Anonymize, mock.Anything). Return(`{"fieldTypeTransformations":[{"fields":[],"transformation":{"replaceValue":{"newValue":""}}}]}`, nil). On("GetTemplate", mock.Anything, store.AnonymizeImage, mock.Anything). From 61c65a2693250f18fe7a4c1cce22092dc71fac69 Mon Sep 17 00:00:00 2001 From: Limor Lahiani Date: Mon, 25 Feb 2019 19:17:42 +0200 Subject: [PATCH 06/75] Fix Bug #604 - Refactor test assertions + some pylint fixes (#100) * Fix Bug #604 - Refactor test assertions + some pylint fixes * fix spaces in spacy_recognizer.py * Update test_spacy_recognizer.py Fix PR comment regarding Bug 617 --- .../spacy_recognizer.py | 8 +- .../uk_nhs_recognizer.py | 4 +- presidio-analyzer/tests/__init__.py | 7 + presidio-analyzer/tests/assertions.py | 17 ++ .../tests/test_analyzer_engine.py | 65 ++++--- .../tests/test_credit_card_recognizer.py | 99 +++------- .../tests/test_crypto_recognizer.py | 11 +- .../tests/test_domain_recognizer.py | 20 +- .../tests/test_email_recognizer.py | 24 +-- .../tests/test_entity_recognizer.py | 1 + .../tests/test_iban_recognizer.py | 21 +-- presidio-analyzer/tests/test_ip_recognizer.py | 7 +- presidio-analyzer/tests/test_pattern.py | 18 +- .../tests/test_pattern_recognizer.py | 22 ++- .../tests/test_recognizer_registry.py | 2 +- .../tests/test_spacy_recognizer.py | 175 ++++++++++++++---- .../tests/test_uk_nhs_recognizer.py | 25 ++- .../tests/test_us_bank_recognizer.py | 12 +- .../test_us_driver_license_recognizer.py | 12 +- .../tests/test_us_itin_recognizer.py | 26 +-- .../tests/test_us_passport_recognizer.py | 7 +- .../tests/test_us_phone_recognizer.py | 9 +- .../tests/test_us_ssn_recognizer.py | 29 ++- 23 files changed, 309 insertions(+), 312 deletions(-) create mode 100644 presidio-analyzer/tests/assertions.py diff --git a/presidio-analyzer/analyzer/predefined_recognizers/spacy_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/spacy_recognizer.py index f9bb5fa50..7b18f0dd0 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/spacy_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/spacy_recognizer.py @@ -4,12 +4,6 @@ NER_STRENGTH = 0.85 SUPPORTED_ENTITIES = ["DATE_TIME", "NRP", "LOCATION", "PERSON"] -# Import 're2' regex engine if installed, if not- import 'regex' -try: - import re2 as re -except ImportError: - import regex as re # noqa: F401 - class SpacyRecognizer(LocalRecognizer): @@ -18,7 +12,7 @@ def __init__(self): supported_language='en') def load(self): - # Load spaCy sm model + # Load spaCy English lg model self.logger.info("Loading NLP model...") self.nlp = spacy.load("en_core_web_lg", disable=['parser', 'tagger']) diff --git a/presidio-analyzer/analyzer/predefined_recognizers/uk_nhs_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/uk_nhs_recognizer.py index a2a3106c5..2b3dc287d 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/uk_nhs_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/uk_nhs_recognizer.py @@ -18,8 +18,8 @@ def __init__(self): super().__init__(supported_entity="UK_NHS", patterns=patterns, context=CONTEXT) - def validate_result(self, text, pattern_result): - text = NhsRecognizer.__sanitize_value(text) + def validate_result(self, pattern_text, pattern_result): + text = NhsRecognizer.__sanitize_value(pattern_text) multiplier = 10 total = 0 diff --git a/presidio-analyzer/tests/__init__.py b/presidio-analyzer/tests/__init__.py index e69de29bb..1bf4805a0 100644 --- a/presidio-analyzer/tests/__init__.py +++ b/presidio-analyzer/tests/__init__.py @@ -0,0 +1,7 @@ +import os +import sys + +# bug #602: Fix imports issue in python +sys.path.append(os.path.dirname(os.path.dirname( + os.path.abspath(__file__))) + "/tests") + \ No newline at end of file diff --git a/presidio-analyzer/tests/assertions.py b/presidio-analyzer/tests/assertions.py new file mode 100644 index 000000000..80bd4bdd0 --- /dev/null +++ b/presidio-analyzer/tests/assertions.py @@ -0,0 +1,17 @@ +error = 0.00001 + +def __assert_result_without_score(result, expected_entity_type, expected_start, expected_end): + assert result.entity_type == expected_entity_type + assert result.start == expected_start + assert result.end == expected_end + +def assert_result(result, expected_entity_type, expected_start, expected_end, expected_score): + __assert_result_without_score(result, expected_entity_type, expected_start, expected_end) + assert result.score == expected_score + +def assert_result_within_score_range(result, expected_entity_type, expected_start, expected_end, + expected_score_min, expected_score_max): + __assert_result_without_score(result, expected_entity_type, expected_start, expected_end) + min_score = min(0, expected_score_min - error) + max_score = max(1, expected_score_max + error) + assert result.score >= min_score and result.score <= max_score diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index 4b221cd19..3347aa1a4 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -2,6 +2,7 @@ import pytest +from assertions import assert_result from analyzer import AnalyzerEngine, PatternRecognizer, Pattern, \ RecognizerResult, RecognizerRegistry from analyzer.analyze_pb2 import AnalyzeRequest @@ -17,33 +18,28 @@ def load_recognizers(self, path): self.recognizers.extend([CreditCardRecognizer(), UsPhoneRecognizer()]) - class TestAnalyzerEngine(TestCase): - def test_analyze_with_predefined_recognizers_return_results(self): + def test_analyze_with_single_predefined_recognizers(self): analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090" langauge = "en" - entities = ["CREDIT_CARD", "PHONE_NUMBER"] - results = analyze_engine.analyze(text, entities, langauge) - assert len(results) == 2 - assert results[0].entity_type == "CREDIT_CARD" - assert results[0].score == 1.0 - assert results[0].start == 14 - assert results[0].end == 33 - - assert results[1].entity_type == "PHONE_NUMBER" - assert results[1].score == 0.5 - assert results[1].start == 48 - assert results[1].end == 59 - entities = ["CREDIT_CARD"] results = analyze_engine.analyze(text, entities, langauge) + assert len(results) == 1 - assert results[0].entity_type == "CREDIT_CARD" - assert results[0].score == 1.0 - assert results[0].start == 14 - assert results[0].end == 33 + assert_result(results[0], "CREDIT_CARD", 14, 33, 1.0) + + def test_analyze_with_multiple_predefined_recognizers(self): + analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) + text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090" + langauge = "en" + entities = ["CREDIT_CARD", "PHONE_NUMBER"] + results = analyze_engine.analyze(text, entities, langauge) + + assert len(results) == 2 + assert_result(results[0], "CREDIT_CARD", 14, 33, 1.0) + assert_result(results[1], "PHONE_NUMBER", 48, 59, 0.5) def test_analyze_without_entities(self): with pytest.raises(ValueError): @@ -60,6 +56,7 @@ def test_analyze_with_empty_text(self): text = "" entities = ["CREDIT_CARD", "PHONE_NUMBER"] results = analyze_engine.analyze(text, entities, langauge) + assert len(results) == 0 def test_analyze_with_unsupported_language(self): @@ -93,19 +90,20 @@ def test_add_pattern_recognizer_from_dict(self): text = "rocket is my favorite transportation" entities = ["CREDIT_CARD", "ROCKET"] - res = analyze_engine.analyze(text=text, entities=entities, + results = analyze_engine.analyze(text=text, entities=entities, language='en') - assert len(res) == 0 + assert len(results) == 0 # Add a new recognizer for the word "rocket" (case insensitive) analyze_engine.add_pattern_recognizer(pattern_recognizer.to_dict()) # Check that the entity is recognized: - res = analyze_engine.analyze(text=text, entities=entities, + results = analyze_engine.analyze(text=text, entities=entities, language='en') - assert res[0].start == 0 - assert res[0].end == 7 + + assert len(results) == 1 + assert_result(results[0], "ROCKET", 0, 7, 0.8) def test_remove_analyzer(self): pattern = Pattern("spaceship pattern", r'\W*(spaceship)\W*', 0.8) @@ -118,28 +116,29 @@ def test_remove_analyzer(self): text = "spaceship is my favorite transportation" entities = ["CREDIT_CARD", "SPACESHIP"] - res = analyze_engine.analyze(text=text, entities=entities, + results = analyze_engine.analyze(text=text, entities=entities, language='en') - assert len(res) == 0 + assert len(results) == 0 # Add a new recognizer for the word "rocket" (case insensitive) analyze_engine.add_pattern_recognizer(pattern_recognizer.to_dict()) # Check that the entity is recognized: - res = analyze_engine.analyze(text=text, entities=entities, + results = analyze_engine.analyze(text=text, entities=entities, language='en') - assert res[0].start == 0 - assert res[0].end == 10 + assert len(results) == 1 + assert_result(results[0], "SPACESHIP", 0, 10, 0.8) # Remove recognizer analyze_engine.remove_recognizer("Spaceship recognizer") # Test again to see we didn't get any results - res = analyze_engine.analyze(text=text, entities=entities, + results = analyze_engine.analyze(text=text, entities=entities, language='en') - assert len(res) == 0 + assert len(results) == 0 + def test_Apply_with_language_returns_correct_response(self): analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) @@ -166,6 +165,4 @@ def test_Apply_with_no_language_returns_default(self): request.text = "My credit card number is 4916994465041084" response = analyze_engine.Apply(request, None) assert response.analyzeResults is not None - - - + diff --git a/presidio-analyzer/tests/test_credit_card_recognizer.py b/presidio-analyzer/tests/test_credit_card_recognizer.py index 6e2cb1b26..0c156ac22 100644 --- a/presidio-analyzer/tests/test_credit_card_recognizer.py +++ b/presidio-analyzer/tests/test_credit_card_recognizer.py @@ -2,12 +2,13 @@ # https://www.freeformatter.com/credit-card-number-generator-validator.html from unittest import TestCase +from assertions import assert_result from analyzer.predefined_recognizers import CreditCardRecognizer + entities = ["CREDIT_CARD"] credit_card_recognizer = CreditCardRecognizer() - class TestCreditCardRecognizer(TestCase): def test_valid_credit_cards(self): @@ -19,30 +20,16 @@ def test_valid_credit_cards(self): results = credit_card_recognizer.analyze('{} {} {}'.format(number1, number2, number3), entities) assert len(results) == 3 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 16 - - assert results[1].score == 1.0 - assert results[1].entity_type == entities[0] - assert results[1].start == 17 - assert results[1].end == 36 - - assert results[2].score == 1.0 - assert results[2].entity_type == entities[0] - assert results[2].start == 37 - assert results[2].end == 56 + assert_result(results[0], entities[0], 0, 16, 1.0) + assert_result(results[1], entities[0], 17, 36, 1.0) + assert_result(results[2], entities[0], 37, 56, 1.0) def test_valid_airplus_credit_card(self): number = '122000000000003' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 15 + assert_result(results[0], entities[0], 0, 15, 1.0) def test_valid_airplus_credit_card_with_extact_context(self): number = '122000000000003' @@ -50,120 +37,84 @@ def test_valid_airplus_credit_card_with_extact_context(self): results = credit_card_recognizer.analyze(context + number, entities) assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 16 - assert results[0].end == 31 + assert_result(results[0], entities[0], 16, 31, 1.0) def test_valid_amex_credit_card(self): number = '371449635398431' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 15 + assert_result(results[0], entities[0], 0, 15, 1.0) def test_valid_cartebleue_credit_card(self): number = '5555555555554444' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 16 + assert_result(results[0], entities[0], 0, 16, 1.0) def test_valid_dankort_credit_card(self): number = '5019717010103742' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 16 + assert_result(results[0], entities[0], 0, 16, 1.0) def test_valid_diners_credit_card(self): number = '30569309025904' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 14 + assert_result(results[0], entities[0], 0, 14, 1.0) def test_valid_discover_credit_card(self): number = '6011000400000000' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 16 + assert_result(results[0], entities[0], 0, 16, 1.0) def test_valid_jcb_credit_card(self): number = '3528000700000000' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 16 + assert_result(results[0], entities[0], 0, 16, 1.0) def test_valid_maestro_credit_card(self): number = '6759649826438453' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 16 + assert_result(results[0], entities[0], 0, 16, 1.0) def test_valid_mastercard_credit_card(self): number = '5555555555554444' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 16 + assert_result(results[0], entities[0], 0, 16, 1.0) def test_valid_visa_credit_card(self): number = '4111111111111111' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 16 + assert_result(results[0], entities[0], 0, 16, 1.0) def test_valid_visa_debit_credit_card(self): number = '4111111111111111' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 16 + assert_result(results[0], entities[0], 0, 16, 1.0) def test_valid_visa_electron_credit_card(self): number = '4917300800000000' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 16 + assert_result(results[0], entities[0], 0, 16, 1.0) def test_valid_visa_purchasing_credit_card(self): number = '4484070000000000' @@ -171,26 +122,18 @@ def test_valid_visa_purchasing_credit_card(self): assert len(results) == 1 assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 16 + assert_result(results[0], entities[0], 0, 16, 1.0) def test_invalid_credit_card(self): number = '4012-8888-8888-1882' results = credit_card_recognizer.analyze('my credit card number is ' + number, entities) assert len(results) == 1 - assert results[0].entity_type == entities[0] - assert results[0].start == 25 - assert results[0].end == 44 - assert results[0].score == 0 + assert_result(results[0], entities[0],25, 44, 0) def test_invalid_diners_card(self): number = '36168002586008' results = credit_card_recognizer.analyze('my credit card number is ' + number, entities) assert len(results) == 1 - assert results[0].entity_type == entities[0] - assert results[0].start == 25 - assert results[0].end == 39 - assert results[0].score == 0 + assert_result(results[0], entities[0],25, 39, 0) diff --git a/presidio-analyzer/tests/test_crypto_recognizer.py b/presidio-analyzer/tests/test_crypto_recognizer.py index 994fd365c..1f3de6f8a 100644 --- a/presidio-analyzer/tests/test_crypto_recognizer.py +++ b/presidio-analyzer/tests/test_crypto_recognizer.py @@ -1,5 +1,6 @@ from unittest import TestCase +from assertions import assert_result from analyzer.predefined_recognizers import CryptoRecognizer crypto_recognizer = CryptoRecognizer() @@ -15,10 +16,7 @@ def test_valid_btc(self): results = crypto_recognizer.analyze(wallet, entities) assert len(results) == 1 - assert results[0].score == 1 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 34 + assert_result(results[0], entities[0], 0, 34, 1.0) def test_valid_btc_with_exact_context(self): wallet = '16Yeky6GMjeNkAiNcBY7ZhrLoMSgg1BoyZ' @@ -26,10 +24,7 @@ def test_valid_btc_with_exact_context(self): results = crypto_recognizer.analyze(context + wallet, entities) assert len(results) == 1 - assert results[0].score == 1 - assert results[0].entity_type == entities[0] - assert results[0].start == 22 - assert results[0].end == 56 + assert_result(results[0], entities[0], 22, 56, 1.0) def test_invalid_btc(self): wallet = '16Yeky6GMjeNkAiNcBY7ZhrLoMSgg1BoyZ2' diff --git a/presidio-analyzer/tests/test_domain_recognizer.py b/presidio-analyzer/tests/test_domain_recognizer.py index d671eef82..9e1559418 100644 --- a/presidio-analyzer/tests/test_domain_recognizer.py +++ b/presidio-analyzer/tests/test_domain_recognizer.py @@ -1,5 +1,6 @@ from unittest import TestCase +from assertions import assert_result from analyzer.predefined_recognizers import DomainRecognizer domain_recognizer = DomainRecognizer() @@ -28,25 +29,14 @@ def test_valid_domain(self): results = domain_recognizer.analyze(domain, entities) assert len(results) == 1 - assert results[0].score == 1 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 13 + assert_result(results[0], entities[0], 0, 13, 1.0) - - def test_valid_domains_lemmatized_text(self): + def test_valid_domains_lemma_text(self): domain1 = 'microsoft.com' domain2 = '192.168.0.1' results = domain_recognizer.analyze('my domains: {} {}'.format(domain1, domain2), entities) assert len(results) == 2 - assert results[0].entity_type == entities[0] - assert results[0].start == 12 - assert results[0].end == 25 - assert results[0].score == 1.0 - - assert results[1].entity_type == entities[0] - assert results[1].start == 26 - assert results[1].end == 33 - assert results[1].score == 0 + assert_result(results[0], entities[0], 12, 25, 1.0) + assert_result(results[1], entities[0], 26, 33, 0) diff --git a/presidio-analyzer/tests/test_email_recognizer.py b/presidio-analyzer/tests/test_email_recognizer.py index 827d6566b..424347ac7 100644 --- a/presidio-analyzer/tests/test_email_recognizer.py +++ b/presidio-analyzer/tests/test_email_recognizer.py @@ -1,5 +1,6 @@ from unittest import TestCase +from assertions import assert_result from analyzer.predefined_recognizers import EmailRecognizer email_recognizer = EmailRecognizer() @@ -13,37 +14,24 @@ def test_valid_email_no_context(self): results = email_recognizer.analyze(email, entities) assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 18 + assert_result(results[0], entities[0], 0, 18, 1.0) def test_valid_email_with_context(self): email = 'info@presidio.site' results = email_recognizer.analyze('my email is {}'.format(email), entities) assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 12 - assert results[0].end == 30 + assert_result(results[0], entities[0], 12, 30, 1.0) - def test_multiple_emails_with_lemmatized_context(self): + def test_multiple_emails_with_lemma_context(self): email1 = 'info@presidio.site' email2 = 'anotherinfo@presidio.site' results = email_recognizer.analyze( 'try one of this emails: {} or {}'.format(email1, email2), entities) assert len(results) == 2 - assert results[0].score == 1.0 - assert results[0].entity_type == entities[0] - assert results[0].start == 24 - assert results[0].end == 42 - - assert results[1].score == 1.0 - assert results[1].entity_type == entities[0] - assert results[1].start == 46 - assert results[1].end == 71 + assert_result(results[0], entities[0], 24, 42, 1.0) + assert_result(results[1], entities[0], 46, 71, 1.0) def test_invalid_email(self): email = 'my email is info@presidio.' diff --git a/presidio-analyzer/tests/test_entity_recognizer.py b/presidio-analyzer/tests/test_entity_recognizer.py index 165cc2ce1..5df7d7265 100644 --- a/presidio-analyzer/tests/test_entity_recognizer.py +++ b/presidio-analyzer/tests/test_entity_recognizer.py @@ -10,6 +10,7 @@ class TestEntityRecognizer(TestCase): def test_to_dict_correct_dictionary(self): ent_recognizer = EntityRecognizer(["ENTITY"]) entity_rec_dict = ent_recognizer.to_dict() + assert entity_rec_dict is not None assert entity_rec_dict['supported_entities'] == ['ENTITY'] assert entity_rec_dict['supported_language'] == 'en' diff --git a/presidio-analyzer/tests/test_iban_recognizer.py b/presidio-analyzer/tests/test_iban_recognizer.py index 263760c6c..27242ffd0 100644 --- a/presidio-analyzer/tests/test_iban_recognizer.py +++ b/presidio-analyzer/tests/test_iban_recognizer.py @@ -1,5 +1,6 @@ from unittest import TestCase +from assertions import assert_result from analyzer.predefined_recognizers import IbanRecognizer iban_recognizer = IbanRecognizer() @@ -13,31 +14,19 @@ def test_valid_iban(self): results = iban_recognizer.analyze(number, entities) assert len(results) == 1 - assert results[0].score == 1 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 23 - + assert_result(results[0], entities[0], 0, 23, 1.0) def test_invalid_iban(self): number = 'IL150120690000003111141' results = iban_recognizer.analyze(number, entities) assert len(results) == 1 - assert results[0].score == 0 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 23 - + assert_result(results[0], entities[0], 0, 23, 0) - # Context should not change the result if the checksum fails - def test_invalid_iban_with_exact_context(self): + def test_invalid_iban_with_exact_context_does_not_change_Score(self): number = 'IL150120690000003111141' context = 'my iban number is ' results = iban_recognizer.analyze(context + number, entities) assert len(results) == 1 - assert results[0].score == 0 - assert results[0].entity_type == entities[0] - assert results[0].start == 18 - assert results[0].end == 41 + assert_result(results[0], entities[0], 18, 41, 0) diff --git a/presidio-analyzer/tests/test_ip_recognizer.py b/presidio-analyzer/tests/test_ip_recognizer.py index 489f5229c..1b945c9cc 100644 --- a/presidio-analyzer/tests/test_ip_recognizer.py +++ b/presidio-analyzer/tests/test_ip_recognizer.py @@ -1,5 +1,6 @@ from unittest import TestCase +from assertions import assert_result_within_score_range from analyzer.predefined_recognizers import IpRecognizer ip_recognizer = IpRecognizer() @@ -14,11 +15,7 @@ def test_valid_ipv4(self): results = ip_recognizer.analyze(context + ip, entities) assert len(results) == 1 - assert 0.59 < results[0].score < 0.8 - assert results[0].entity_type == entities[0] - assert results[0].start == 14 - assert results[0].end == 25 - + assert_result_within_score_range(results[0], entities[0], 14, 25, 0.6, 0.81) ''' TODO: enable with task #582 re-support context model in analyzer diff --git a/presidio-analyzer/tests/test_pattern.py b/presidio-analyzer/tests/test_pattern.py index 66d091593..50b0f5255 100644 --- a/presidio-analyzer/tests/test_pattern.py +++ b/presidio-analyzer/tests/test_pattern.py @@ -2,19 +2,23 @@ from analyzer import Pattern +my_pattern = Pattern(name="my pattern", strength=0.9, pattern="[pat]") +my_pattern_dict = {"name": "my pattern", "pattern": "[pat]", "strength": 0.9} class TestPattern(TestCase): def test_to_dict(self): - expected = {"name": "my pattern", "pattern": "[pat]", "strength": 0.9} - pat = Pattern(name="my pattern", strength=0.9, pattern="[pat]") - actual = pat.to_dict() + expected = my_pattern_dict + actual = my_pattern.to_dict() assert expected == actual def test_from_dict(self): - expected = {"name": "my pattern", "pattern": "[pat]", "strength": 0.9} - pat = Pattern.from_dict(expected) - actual = pat.to_dict() + expected = my_pattern + actual = Pattern.from_dict(my_pattern_dict) - assert expected == actual + assert expected.name == actual.name + assert expected.strength == actual.strength + assert expected.pattern == actual.pattern + + # assert expected == actual diff --git a/presidio-analyzer/tests/test_pattern_recognizer.py b/presidio-analyzer/tests/test_pattern_recognizer.py index e9f7de3b1..6bf5e01b4 100644 --- a/presidio-analyzer/tests/test_pattern_recognizer.py +++ b/presidio-analyzer/tests/test_pattern_recognizer.py @@ -4,6 +4,7 @@ # https://www.datatrans.ch/showcase/test-cc-numbers # https://www.freeformatter.com/credit-card-number-generator-validator.html +from assertions import assert_result from analyzer import Pattern from analyzer import PatternRecognizer @@ -28,7 +29,7 @@ def test_no_entity_for_pattern_recognizer(self): MockRecognizer(entity=[], patterns=patterns, black_list=[], name=None, context=None) - def test_black_list_works(self): + def test_black_list_keywords_found(self): test_recognizer = MockRecognizer(patterns=[], entity="ENTITY_1", black_list=["phone", "name"], context=None, name=None) @@ -36,15 +37,17 @@ def test_black_list_works(self): results = test_recognizer.analyze("my phone number is 555-1234, and my name is John", ["ENTITY_1"]) assert len(results) == 2 - assert results[0].entity_type == "ENTITY_1" - assert results[0].score == 1.0 - assert results[0].start == 3 - assert results[0].end == 8 + assert_result(results[0], "ENTITY_1", 3, 8, 1.0) + assert_result(results[1], "ENTITY_1", 36, 40, 1.0) - assert results[1].entity_type == "ENTITY_1" - assert results[1].score == 1.0 - assert results[1].start == 36 - assert results[1].end == 40 + def test_black_list_keywords_not_found(self): + test_recognizer = MockRecognizer(patterns=[], + entity="ENTITY_1", + black_list=["phone", "name"], context=None, name=None) + + results = test_recognizer.analyze("No blacklist words, though includes PII entities: 555-1234, John", ["ENTITY_1"]) + + assert len(results) == 0 def test_from_dict(self): json = {'supported_entity': 'ENTITY_1', @@ -54,6 +57,7 @@ def test_from_dict(self): 'version': "1.0"} new_recognizer = PatternRecognizer.from_dict(json) + ### consider refactoring assertions assert new_recognizer.supported_entities == ['ENTITY_1'] assert new_recognizer.supported_language == 'en' assert new_recognizer.patterns[0].name == 'p1' diff --git a/presidio-analyzer/tests/test_recognizer_registry.py b/presidio-analyzer/tests/test_recognizer_registry.py index 8c2d79f7e..bd5ea6840 100644 --- a/presidio-analyzer/tests/test_recognizer_registry.py +++ b/presidio-analyzer/tests/test_recognizer_registry.py @@ -4,7 +4,7 @@ from analyzer import RecognizerRegistry, PatternRecognizer, EntityRecognizer, Pattern - +### consider refactoring class TestRecognizerRegistry(TestCase): def get_mock_pattern_recognizer(self, lang, entity, name): diff --git a/presidio-analyzer/tests/test_spacy_recognizer.py b/presidio-analyzer/tests/test_spacy_recognizer.py index 4722e50f3..fc771d816 100644 --- a/presidio-analyzer/tests/test_spacy_recognizer.py +++ b/presidio-analyzer/tests/test_spacy_recognizer.py @@ -1,5 +1,6 @@ from unittest import TestCase +from assertions import assert_result, assert_result_within_score_range from analyzer.predefined_recognizers import SpacyRecognizer NER_STRENGTH = 0.85 @@ -9,85 +10,181 @@ class TestSpacyRecognizer(TestCase): - def test_person_first_name(self): - name = 'Dan' - results = spacy_recognizer.analyze(name, entities) +# Test Name Entity + # Bug #617 : Spacy Recognizer doesn't recognize Dan as PERSON even though online spacy demo indicates that it does + # See http://textanalysisonline.com/spacy-named-entity-recognition-ner + # def test_person_first_name(self): + # name = 'Dan' + # results = spacy_recognizer.analyze(name, entities) - assert len(results) == 0 + # assert len(results) == 1 + # assert_result(results[0], entity[0], NER_STRENGTH) def test_person_first_name_with_context(self): name = 'Dan' - context = 'my name is ' - results = spacy_recognizer.analyze(context + name, entities) + context= 'my name is' + results = spacy_recognizer.analyze('{} {}'.format(context, name), entities) assert len(results) == 1 - assert results[0].score >= NER_STRENGTH - assert results[0].entity_type == entities[0] - assert results[0].start == 11 - assert results[0].end == 14 + assert_result_within_score_range(results[0], entities[0], 11, 14, NER_STRENGTH, 1) def test_person_full_name(self): name = 'Dan Tailor' results = spacy_recognizer.analyze(name, entities) assert len(results) == 1 - assert results[0].score >= NER_STRENGTH - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 10 + assert_result(results[0], entities[0], 0, 10, NER_STRENGTH) def test_person_full_name_with_context(self): name = 'John Oliver' - results = spacy_recognizer.analyze(name + " is the funniest comedian", entities) + context = ' is the funniest comedian' + results = spacy_recognizer.analyze('{} {}'.format(name, context), entities) assert len(results) == 1 - assert results[0].score >= NER_STRENGTH - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 11 + assert_result_within_score_range(results[0], entities[0], 0, 11, NER_STRENGTH, 1) def test_person_last_name(self): name = 'Tailor' results = spacy_recognizer.analyze(name, entities) assert len(results) == 0 + + # Bug #617 : Spacy Recognizer doesn't recognize Mr. Tailor as PERSON even though online spacy visualizer indicates that it does + # See http://textanalysisonline.com/spacy-named-entity-recognition-ner + # def test_person_title_with_last_name(self): + # name = 'Mr. Tailor' + # results = spacy_recognizer.analyze(name, entities) + + # assert len(results) == 1 + # assert_result(results[0], entities[0], 0, 9, NER_STRENGTH) + + # Bug #617 : Spacy Recognizer doesn't recognize Mr. Tailor as PERSON even though online spacy visualizer indicates that it does + # See http://textanalysisonline.com/spacy-named-entity-recognition-ner + # def test_person_title_with_last_name_with_context_and_time(self): + # name = 'Mr. Tailor' + # context = 'Good morning' + # results = spacy_recognizer.analyze('{} {}'.format(context, name), entities) + + # assert len(results) == 2 + # assert_result_within_score_range(results[1], entities[1], 5, 12, NER_STRENGTH, 1) + # assert_result_within_score_range(results[0], entities[0], 17, 23, NER_STRENGTH, 1) def test_person_full_middle_name(self): name = 'Richard Milhous Nixon' results = spacy_recognizer.analyze(name, entities) assert len(results) == 1 - assert results[0].score >= NER_STRENGTH - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 21 + assert_result(results[0], entities[0], 0, 21, NER_STRENGTH) - def test_person_full_middle_letter_name(self): + def test_person_full_name_with_middle_letter(self): name = 'Richard M. Nixon' results = spacy_recognizer.analyze(name, entities) assert len(results) == 1 - assert results[0].score >= NER_STRENGTH - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 16 + assert_result(results[0], entities[0], 0, 16, NER_STRENGTH) def test_person_full_name_complex(self): name = 'Richard (Ric) C. Henderson' results = spacy_recognizer.analyze(name, entities) assert len(results) == 1 - assert results[0].score >= NER_STRENGTH - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 26 + assert_result(results[0], entities[0], 0, 26, NER_STRENGTH) + + def test_person_last_name_is_also_a_date_expected_person_only(self): + name = 'Dan May' + results = spacy_recognizer.analyze(name, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 7, NER_STRENGTH, ) + + # Bug #617 : Spacy Recognizer doesn't recognize Dan May as PERSON even though online spacy demo indicates that it does + # See http://textanalysisonline.com/spacy-named-entity-recognition-ner + def test_person_last_name_is_also_a_date_with_context_expected_person_only(self): + name = 'Dan May' + context = "has a bank account" + results = spacy_recognizer.analyze('{} {}'.format(name, context), entities) + + assert len(results) == 1 + assert_result_within_score_range(results[0], entities[0], 0, 7, NER_STRENGTH, 1) + + # Bug #617 : Spacy Recognizer doesn't recognize Mr. May as PERSON even though online spacy demo indicates that it does + # See http://textanalysisonline.com/spacy-named-entity-recognition-ner + # def test_person_title_and_last_name_is_also_a_date_expected_person_only(self): + # name = 'Mr. May' + # results = spacy_recognizer.analyze(name, entities) + + # assert len(results) == 1 + # assert_result(results[0], entities[0], 4, 7, NER_STRENGTH) + + # Bug #617 : Spacy Recognizer doesn't recognize Mr. May as PERSON even though online spacy demo indicates that it does + # See http://textanalysisonline.com/spacy-named-entity-recognition-ner + # def test_person_title_and_last_name_is_also_a_date_with_context_expected_person_only(self): + # name = 'Mr. May' + # context = "They call me" + # results = spacy_recognizer.analyze('{} {}'.format(context, name), entities) + + # assert len(results) == 1 + # assert_result_within_score_range(results[0], entities[0], 17, 20, NER_STRENGTH, 1) + +#Test DATE_TIME Entity + def test_date_time_year(self): + date = '1972' + results = spacy_recognizer.analyze(date, entities) - def test_date_time_simple(self): - name = 'May 1st' - results = spacy_recognizer.analyze(name + " is the workers holiday", ["DATE_TIME"]) + assert len(results) == 1 + assert_result(results[0], entities[1], 0, 4, NER_STRENGTH) + + def test_date_time_year_with_context(self): + date = '1972' + context = 'I bought my car in' + results = spacy_recognizer.analyze('{} {}'.format(context, date), entities) + + assert len(results) == 1 + assert_result_within_score_range(results[0], entities[1], 19, 23, NER_STRENGTH, 1) + + # Bug #617 : Spacy Recognizer doesn't recognize May as DATE_TIME even though online spacy demo indicates that it does + # See http://textanalysisonline.com/spacy-named-entity-recognition-ner + # def test_date_time_month(self): + # date = 'May' + # results = spacy_recognizer.analyze(date, entities) + + # assert len(results) == 1 + # assert_result_within_score_range(results[0], entities[1], 0, 3, NER_STRENGTH, 1) + + def test_date_time_month_with_context(self): + date = 'May' + context = 'I bought my car in' + results = spacy_recognizer.analyze('{} {}'.format(context, date), entities) + + assert len(results) == 1 + assert_result_within_score_range(results[0], entities[1], 19, 22, NER_STRENGTH, 1) + + def test_date_time_day_in_month(self): + date = 'May 1st' + results = spacy_recognizer.analyze(date, entities) + + assert len(results) == 1 + assert_result_within_score_range(results[0], entities[1], 0, 7, NER_STRENGTH, 1) + + def test_date_time_day_in_month_with_context(self): + date = 'May 1st' + context = 'I bought my car on ' + results = spacy_recognizer.analyze('{} {}'.format(context, date), entities) + + assert len(results) == 1 + assert_result_within_score_range(results[0], entities[1], 19, 26, NER_STRENGTH, 1) + + def test_date_time_full_date(self): + date = 'May 1st, 1977' + results = spacy_recognizer.analyze(date, entities) + + assert len(results) == 1 + assert_result_within_score_range(results[0], entities[1], 0, 13, NER_STRENGTH, 1) + + def test_date_time_day_in_month_with_context(self): + date = 'May 1st, 1977' + context = 'I bought my car on' + results = spacy_recognizer.analyze('{} {}'.format(context, date), entities) assert len(results) == 1 - assert results[0].score >= NER_STRENGTH - assert results[0].entity_type == entities[1] - assert results[0].start == 0 - assert results[0].end == 7 + assert_result_within_score_range(results[0], entities[1], 19, 32, NER_STRENGTH, 1) diff --git a/presidio-analyzer/tests/test_uk_nhs_recognizer.py b/presidio-analyzer/tests/test_uk_nhs_recognizer.py index acf50ebfb..bc6a2943d 100644 --- a/presidio-analyzer/tests/test_uk_nhs_recognizer.py +++ b/presidio-analyzer/tests/test_uk_nhs_recognizer.py @@ -1,5 +1,6 @@ from unittest import TestCase +from assertions import assert_result from analyzer.predefined_recognizers import NhsRecognizer nhs_recognizer = NhsRecognizer() @@ -8,37 +9,33 @@ class TestNhsRecognizer(TestCase): - def test_valid_uk_nhs(self): + def test_valid_uk_nhs_with_dashes(self): num = '401-023-2137' results = nhs_recognizer.analyze(num, entities) + assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].start == 0 - assert results[0].end == 12 - assert results[0].entity_type == entities[0] + assert_result(results[0], entities[0], 0, 12, 1.0) + def test_valid_uk_nhs_with_spaces(self): num = '221 395 1837' results = nhs_recognizer.analyze(num, entities) + assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].start == 0 - assert results[0].end == 12 - assert results[0].entity_type == entities[0] + assert_result(results[0], entities[0], 0, 12, 1.0) + def test_valid_uk_nhs_with_no_delimeters(self): num = '0032698674' results = nhs_recognizer.analyze(num, entities) - assert len(results) == 1 - assert results[0].score == 1.0 - assert results[0].start == 0 - assert results[0].end == 10 - assert results[0].entity_type == entities[0] + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 10, 1.0) def test_invalid_uk_nhs(self): num = '401-023-2138' results = nhs_recognizer.analyze(num, entities) assert len(results) == 1 + assert results[0].score == 0 assert results[0].start == 0 assert results[0].end == 12 diff --git a/presidio-analyzer/tests/test_us_bank_recognizer.py b/presidio-analyzer/tests/test_us_bank_recognizer.py index f86098218..0dcd51c7e 100644 --- a/presidio-analyzer/tests/test_us_bank_recognizer.py +++ b/presidio-analyzer/tests/test_us_bank_recognizer.py @@ -1,5 +1,6 @@ from unittest import TestCase +from assertions import assert_result from analyzer.predefined_recognizers import UsBankRecognizer us_bank_recognizer = UsBankRecognizer() @@ -20,15 +21,12 @@ def test_us_bank_account_no_context(self): results = us_bank_recognizer.analyze(num, entities) assert len(results) == 1 - assert 0 < results[0].score < 0.1 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 12 + assert_result(results[0], entities[0], 0, 12, 0.05) # TODO: enable with task #582 re-support context model in analyzer # def test_us_passport_with_exact_context(self): # num = '912803456' - # context = 'my banck account number is ' + # context = 'my bank account number is ' # results = us_bank_recognizer.analyze(context + num, entities) # # assert len(results) == 1 @@ -37,14 +35,14 @@ def test_us_bank_account_no_context(self): # # def test_us_passport_with_exact_context_no_space(self): # num = '912803456' - # context = 'my banck account number is:' + # context = 'my bank account number is:' # results = us_bank_recognizer.analyze(context + num, entities) # # assert len(results) == 1 # assert 0.49 < results[0].score < 0.61 # # - # def test_us_passport_with_lemmatized_context(self): + # def test_us_passport_with_lemma_context(self): # num = '912803456' # context = 'my banking account number is ' # results = us_bank_recognizer.analyze(context + num, entities) diff --git a/presidio-analyzer/tests/test_us_driver_license_recognizer.py b/presidio-analyzer/tests/test_us_driver_license_recognizer.py index 0c1507757..43b922521 100644 --- a/presidio-analyzer/tests/test_us_driver_license_recognizer.py +++ b/presidio-analyzer/tests/test_us_driver_license_recognizer.py @@ -1,5 +1,6 @@ from unittest import TestCase +from assertions import assert_result, assert_result_within_score_range from analyzer.predefined_recognizers import UsLicenseRecognizer us_license_recognizer = UsLicenseRecognizer() @@ -14,15 +15,8 @@ def test_valid_us_driver_license_weak_WA(self): results = us_license_recognizer.analyze('{} {}'.format(num1, num2), entities) assert len(results) == 2 - assert 0.29 < results[0].score < 0.41 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 12 - - assert 0.29 < results[1].score < 0.41 - assert results[1].entity_type == entities[0] - assert results[1].start == 13 - assert results[1].end == 25 + assert_result_within_score_range(results[0], entities[0], 0, 12, 0.3, 0.4) + assert_result_within_score_range(results[1], entities[0], 13, 25, 0.3, 0.4) # TODO: enable with task #582 re-support context model in analyzer # def test_valid_us_driver_license_weak_WA_exact_context(self): diff --git a/presidio-analyzer/tests/test_us_itin_recognizer.py b/presidio-analyzer/tests/test_us_itin_recognizer.py index ce278bc99..144228f0f 100644 --- a/presidio-analyzer/tests/test_us_itin_recognizer.py +++ b/presidio-analyzer/tests/test_us_itin_recognizer.py @@ -1,5 +1,6 @@ from unittest import TestCase +from assertions import assert_result_within_score_range from analyzer.predefined_recognizers import UsItinRecognizer us_itin_recognizer = UsItinRecognizer() @@ -14,35 +15,28 @@ def test_valid_us_itin_very_weak_match(self): results = us_itin_recognizer.analyze('{} {}'.format(num1, num2), entities) assert len(results) == 2 - assert 0 < results[0].score < 0.31 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 10 - assert 0 < results[1].score < 0.31 - assert results[1].entity_type == entities[0] - assert results[1].start == 11 - assert results[1].end == 21 + assert results[0].score != 0 + assert_result_within_score_range(results[0], entities[0], 0, 10, 0, 0.3) + + assert results[1].score != 0 + assert_result_within_score_range(results[1], entities[0], 11, 21, 0, 0.3) + def test_valid_us_itin_weak_match(self): num = '911701234' results = us_itin_recognizer.analyze(num, entities) assert len(results) == 1 - assert 0.29 < results[0].score < 0.41 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 9 + assert_result_within_score_range(results[0], entities[0], 0, 9, 0.3, 0.4) + def test_valid_us_itin_medium_match(self): num = '911-70-1234' results = us_itin_recognizer.analyze(num, entities) assert len(results) == 1 - assert 0.49 < results[0].score < 0.6 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 11 + assert_result_within_score_range(results[0], entities[0], 0, 11, 0.5, 0.6) # TODO: enable with task #582 re-support context model in analyzer # def test_valid_us_itin_very_weak_match_exact_context(self): diff --git a/presidio-analyzer/tests/test_us_passport_recognizer.py b/presidio-analyzer/tests/test_us_passport_recognizer.py index 795efe5ed..92f0483f8 100644 --- a/presidio-analyzer/tests/test_us_passport_recognizer.py +++ b/presidio-analyzer/tests/test_us_passport_recognizer.py @@ -1,5 +1,6 @@ from unittest import TestCase +from assertions import assert_result_within_score_range from analyzer.predefined_recognizers import UsPassportRecognizer us_passport_recognizer = UsPassportRecognizer() @@ -13,10 +14,8 @@ def test_valid_us_passport_no_context(self): results = us_passport_recognizer.analyze(num, entities) assert len(results) == 1 - assert 0 < results[0].score < 0.1 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 9 + assert results[0].score != 0 + assert_result_within_score_range(results[0], entities[0], 0, 9, 0, 0.1) # Task #582 re-support context model in analyzer # def test_valid_us_passport_with_exact_context(self): diff --git a/presidio-analyzer/tests/test_us_phone_recognizer.py b/presidio-analyzer/tests/test_us_phone_recognizer.py index 54d1dc047..a19e2f24f 100644 --- a/presidio-analyzer/tests/test_us_phone_recognizer.py +++ b/presidio-analyzer/tests/test_us_phone_recognizer.py @@ -1,5 +1,6 @@ from unittest import TestCase +from assertions import assert_result_within_score_range from analyzer.predefined_recognizers import UsPhoneRecognizer phone_recognizer = UsPhoneRecognizer() @@ -13,10 +14,8 @@ def test_phone_number_strong_match_no_context(self): results = phone_recognizer.analyze(number, entities) assert len(results) == 1 - assert 0.69 < results[0].score < 1 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 14 + assert results[0].score != 1 + assert_result_within_score_range(results[0], entities[0], 0, 14, 0.7, 1) # TODO: enable with task #582 re-support context model in analyzer # def test_phone_number_strong_match_with_phone_context(self): @@ -94,7 +93,7 @@ def test_phone_number_medium_match_no_context(self): # assert 0.59 < results[0].score < 0.81 # # - # def test_phone_numbers_lemmatized_context_phones(self): + # def test_phone_numbers_lemma_context_phones(self): # number1 = '052 5552606' # number2 = '074-7111234' # results = phone_recognizer.analyze( diff --git a/presidio-analyzer/tests/test_us_ssn_recognizer.py b/presidio-analyzer/tests/test_us_ssn_recognizer.py index d64d54ce0..d3c6fe153 100644 --- a/presidio-analyzer/tests/test_us_ssn_recognizer.py +++ b/presidio-analyzer/tests/test_us_ssn_recognizer.py @@ -1,5 +1,6 @@ from unittest import TestCase +from assertions import assert_result, assert_result_within_score_range from analyzer.predefined_recognizers import UsSsnRecognizer us_ssn_recognizer = UsSsnRecognizer() @@ -14,37 +15,29 @@ def test_valid_us_ssn_very_weak_match(self): results = us_ssn_recognizer.analyze('{} {}'.format(num1, num2), entities) assert len(results) == 2 - assert 0.01 < results[0].score < 0.31 - assert results[0].entity_type == entities[0] - assert results[0].start == 0 - assert results[0].end == 10 - - assert 0.01 < results[1].score < 0.31 - assert results[1].start == 11 - assert results[1].end == 21 - assert results[1].entity_type == entities[0] - + + assert results[0].score != 0 + assert_result_within_score_range(results[0], entities[0], 0, 10, 0, 0.3) + + assert results[0].score != 0 + assert_result_within_score_range(results[1], entities[0], 11, 21, 0, 0.3) def test_valid_us_ssn_weak_match(self): num = '078051120' results = us_ssn_recognizer.analyze(num, entities) assert len(results) == 1 - assert 0.29 < results[0].score < 0.41 - assert results[0].start == 0 - assert results[0].end == 9 - assert results[0].entity_type == entities[0] - + assert results[0].score != 0 + assert_result_within_score_range(results[0], entities[0], 0, 9, 0.3, 0.4) def test_valid_us_ssn_medium_match(self): num = '078-05-1120' results = us_ssn_recognizer.analyze(num, entities) assert len(results) == 1 + assert results[0].score != 0 + assert_result_within_score_range(results[0], entities[0], 0, 11, 0.5, 0.6) assert 0.49 < results[0].score < 0.6 - assert results[0].start == 0 - assert results[0].end == 11 - assert results[0].entity_type == entities[0] # # TODO: enable with task #582 re-support context model in analyzer From 50948022034446aa0a52fa2d7404fe2aa2868772 Mon Sep 17 00:00:00 2001 From: Itye Richter Date: Tue, 5 Mar 2019 14:32:45 +0200 Subject: [PATCH 07/75] fixed bug: changed 'push' to 'pull' in Makefile (#102) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 0bdc4adba..fc1e6662b 100644 --- a/Makefile +++ b/Makefile @@ -64,7 +64,7 @@ docker-push: $(addsuffix -push,$(IMAGES)) docker-push-latest-dev: $(addsuffix -push-latest-dev,$(IMAGES)) %-push-latest-dev: - docker push $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) + docker pull $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/$*:latest-dev docker push $(DOCKER_REGISTRY)/$*:latest-dev From 585e2f45c4172fefec0d9e250bf0a5c8c0b048f9 Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Mon, 25 Mar 2019 14:08:39 +0200 Subject: [PATCH 08/75] Bug666 - Adding pylint to the Analyzer microservice (#105) * Adding pylint for the analyzer microservice --- presidio-analyzer/Dockerfile | 1 + presidio-analyzer/analyzer/__init__.py | 5 ++++- presidio-analyzer/analyzer/__main__.py | 8 ++++--- presidio-analyzer/analyzer/analyzer_engine.py | 21 ++++++++++++------ .../analyzer/entity_recognizer.py | 1 - .../analyzer/local_recognizer.py | 1 + .../analyzer/pattern_recognizer.py | 21 +++++++++--------- .../predefined_recognizers/__init__.py | 1 + .../credit_card_recognizer.py | 17 ++++++++------ .../crypto_recognizer.py | 11 +++++----- .../domain_recognizer.py | 8 ++++--- .../email_recognizer.py | 8 ++++--- .../predefined_recognizers/iban_recognizer.py | 7 +++--- .../predefined_recognizers/ip_recognizer.py | 1 + .../spacy_recognizer.py | 4 ++-- .../us_bank_recognizer.py | 1 + .../us_driver_license_recognizer.py | 1 + .../us_itin_recognizer.py | 1 + .../us_passport_recognizer.py | 1 + .../us_phone_recognizer.py | 1 + .../us_ssn_recognizer.py | 1 + .../recognizer_registry.py | 5 +++-- .../analyzer/remote_recognizer.py | 5 +++-- pylintrc => presidio-analyzer/pylintrc | 6 ++--- presidio-analyzer/requirements-dev.txt | 3 ++- .../tests/test_analyzer_engine.py | 22 +++++++++---------- presidio-analyzer/tests/test_pattern.py | 1 + 27 files changed, 97 insertions(+), 66 deletions(-) rename pylintrc => presidio-analyzer/pylintrc (88%) diff --git a/presidio-analyzer/Dockerfile b/presidio-analyzer/Dockerfile index f62c3caa0..5a71b5865 100644 --- a/presidio-analyzer/Dockerfile +++ b/presidio-analyzer/Dockerfile @@ -7,6 +7,7 @@ WORKDIR /usr/bin/${NAME} ADD ./${NAME} /usr/bin/${NAME} RUN pip install --no-cache-dir -r requirements-dev.txt && \ + pylint analyzer && \ flake8 analyzer --exclude "*pb2*.py" && \ pytest --log-cli-level=0 diff --git a/presidio-analyzer/analyzer/__init__.py b/presidio-analyzer/analyzer/__init__.py index c7cbb4d83..46daf1f7a 100644 --- a/presidio-analyzer/analyzer/__init__.py +++ b/presidio-analyzer/analyzer/__init__.py @@ -1,6 +1,7 @@ import os import sys +# pylint: disable=unused-import,wrong-import-position # bug #602: Fix imports issue in python sys.path.append(os.path.dirname(os.path.dirname( os.path.abspath(__file__))) + "/analyzer") @@ -11,5 +12,7 @@ from analyzer.recognizer_result import RecognizerResult # noqa: F401 from analyzer.pattern_recognizer import PatternRecognizer # noqa: F401 from analyzer.remote_recognizer import RemoteRecognizer # noqa: F401 -from analyzer.recognizer_registry.recognizer_registry import RecognizerRegistry # noqa +from analyzer.recognizer_registry.recognizer_registry import ( # noqa: F401 + RecognizerRegistry +) from analyzer.analyzer_engine import AnalyzerEngine # noqa diff --git a/presidio-analyzer/analyzer/__main__.py b/presidio-analyzer/analyzer/__main__.py index dbcc1f4a8..14f6145e7 100644 --- a/presidio-analyzer/analyzer/__main__.py +++ b/presidio-analyzer/analyzer/__main__.py @@ -1,3 +1,4 @@ +# pylint: disable=wrong-import-position,wrong-import-order import logging import grpc import analyze_pb2 @@ -16,7 +17,7 @@ # bug #602: Fix imports issue in python sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) -from analyzer_engine import AnalyzerEngine # noqa +from analyzer_engine import AnalyzerEngine # noqa WELCOME_MESSAGE = r""" @@ -70,7 +71,7 @@ def serve_command_handler(env_grpc_port=False, grpc_port=3000): grpc_port = int(port) server.add_insecure_port('[::]:' + str(grpc_port)) - logging.info("Starting GRPC listener at port " + str(grpc_port)) + logging.info("Starting GRPC listener at port %d", grpc_port) server.start() try: while True: @@ -91,6 +92,7 @@ def analyze_command_handler(text, fields, env_grpc_port=False, grpc_port=3001): request = analyze_pb2.AnalyzeRequest() request.text = text + # pylint: disable=no-member for field_name in fields: field_type = request.analyzeTemplate.fields.add() field_type.name = field_name @@ -101,7 +103,7 @@ def analyze_command_handler(text, fields, env_grpc_port=False, grpc_port=3001): class CommandsLoader(CLICommandsLoader): def load_command_table(self, args): with CommandGroup(self, '', '__main__#{}') as g: - g.command('serve', 'serve_command_handler', confirmation=False), + g.command('serve', 'serve_command_handler', confirmation=False) g.command('analyze', 'analyze_command_handler', confirmation=False) return super(CommandsLoader, self).load_command_table(args) diff --git a/presidio-analyzer/analyzer/analyzer_engine.py b/presidio-analyzer/analyzer/analyzer_engine.py index 790b4f325..c721d7850 100644 --- a/presidio-analyzer/analyzer/analyzer_engine.py +++ b/presidio-analyzer/analyzer/analyzer_engine.py @@ -48,22 +48,25 @@ def __remove_duplicates(results): return filtered_results + # pylint: disable=unused-argument def Apply(self, request, context): logging.info("Starting Apply") - entities = self.__convert_fields_to_entities( + entities = AnalyzerEngine.__convert_fields_to_entities( request.analyzeTemplate.fields) - language = self.get_language_from_request(request) + language = AnalyzerEngine.get_language_from_request(request) results = self.analyze(request.text, entities, language) # Create Analyze Response Object response = analyze_pb2.AnalyzeResponse() + # pylint: disable=no-member response.analyzeResults.extend( - self.__convert_results_to_proto(results)) - logging.info("Found {} results".format(len(results))) + AnalyzerEngine.__convert_results_to_proto(results)) + logging.info("Found %d results", len(results)) return response - def get_language_from_request(self, request): + @classmethod + def get_language_from_request(cls, request): language = request.analyzeTemplate.languageCode if language is None or language == "": language = DEFAULT_LANGUAGE @@ -109,7 +112,8 @@ def remove_recognizer(self, name): """ self.registry.remove_recognizer(name) - def __convert_fields_to_entities(self, fields): + @staticmethod + def __convert_fields_to_entities(fields): # Convert fields to entities - will be changed once the API # will be changed entities = [] @@ -117,12 +121,15 @@ def __convert_fields_to_entities(self, fields): entities.append(field.name) return entities - def __convert_results_to_proto(self, results): + @staticmethod + def __convert_results_to_proto(results): proto_results = [] for result in results: res = common_pb2.AnalyzeResult() + # pylint: disable=no-member res.field.name = result.entity_type res.score = result.score + # pylint: disable=no-member res.location.start = result.start res.location.end = result.end res.location.length = result.end - result.start diff --git a/presidio-analyzer/analyzer/entity_recognizer.py b/presidio-analyzer/analyzer/entity_recognizer.py index 5fe63569d..7bfe567ce 100644 --- a/presidio-analyzer/analyzer/entity_recognizer.py +++ b/presidio-analyzer/analyzer/entity_recognizer.py @@ -39,7 +39,6 @@ def load(self): Initialize the recognizer assets if needed (e.g. machine learning models) """ - pass @abstractmethod def analyze(self, text, entities): diff --git a/presidio-analyzer/analyzer/local_recognizer.py b/presidio-analyzer/analyzer/local_recognizer.py index 7de4c25d9..0e3225cc0 100644 --- a/presidio-analyzer/analyzer/local_recognizer.py +++ b/presidio-analyzer/analyzer/local_recognizer.py @@ -3,6 +3,7 @@ class LocalRecognizer(EntityRecognizer): + # pylint: disable=abstract-method, unused-argument def __init__(self, supported_entities, supported_language, name=None, version=None, **kwargs): super().__init__(supported_entities=supported_entities, diff --git a/presidio-analyzer/analyzer/pattern_recognizer.py b/presidio-analyzer/analyzer/pattern_recognizer.py index f30d5750a..abcb1daa7 100644 --- a/presidio-analyzer/analyzer/pattern_recognizer.py +++ b/presidio-analyzer/analyzer/pattern_recognizer.py @@ -1,5 +1,4 @@ import datetime -from abc import abstractmethod from analyzer import LocalRecognizer, Pattern, RecognizerResult @@ -53,7 +52,7 @@ def load(self): def analyze(self, text, entities): results = [] - if len(self.patterns) > 0: + if self.patterns: pattern_result = self.__analyze_patterns(text) if pattern_result: @@ -73,7 +72,7 @@ def __black_list_to_regex(black_list): regex = r"(?:^|(?<= ))(" + '|'.join(black_list) + r")(?:(?= )|$)" return Pattern(name="black_list", pattern=regex, strength=1.0) - @abstractmethod + # pylint: disable=unused-argument, no-self-use def validate_result(self, pattern_text, pattern_result): """ Validates the pattern logic, for example by running @@ -105,8 +104,10 @@ def __analyze_patterns(self, text): text, flags=re.IGNORECASE | re.DOTALL | re.MULTILINE) match_time = datetime.datetime.now() - match_start_time - self.logger.debug('--- match_time[{}]: {}.{} seconds'.format( - pattern.name, match_time.seconds, match_time.microseconds)) + self.logger.debug('--- match_time[%s]: %s.%s seconds', + pattern.name, + match_time.seconds, + match_time.microseconds) for match in matches: start, end = match.span() @@ -132,15 +133,15 @@ def to_dict(self): return_dict["black_list"] = self.black_list return_dict["context"] = self.context return_dict["supported_entity"] = return_dict["supported_entities"][0] - del (return_dict["supported_entities"]) + del return_dict["supported_entities"] return return_dict @classmethod - def from_dict(cls, pattern_recognizer_dict): - patterns = pattern_recognizer_dict.get("patterns") + def from_dict(cls, entity_recognizer_dict): + patterns = entity_recognizer_dict.get("patterns") if patterns: patterns_list = [Pattern.from_dict(pat) for pat in patterns] - pattern_recognizer_dict['patterns'] = patterns_list + entity_recognizer_dict['patterns'] = patterns_list - return cls(**pattern_recognizer_dict) + return cls(**entity_recognizer_dict) diff --git a/presidio-analyzer/analyzer/predefined_recognizers/__init__.py b/presidio-analyzer/analyzer/predefined_recognizers/__init__.py index e8c95e69b..4306363e5 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/__init__.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/__init__.py @@ -1,3 +1,4 @@ +# pylint: disable=unused-import from .credit_card_recognizer import CreditCardRecognizer # noqa: F401 from .spacy_recognizer import SpacyRecognizer # noqa: F401 from .crypto_recognizer import CryptoRecognizer # noqa: F401 diff --git a/presidio-analyzer/analyzer/predefined_recognizers/credit_card_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/credit_card_recognizer.py index fe7cdcf4d..6f6271f87 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/credit_card_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/credit_card_recognizer.py @@ -1,6 +1,7 @@ from analyzer import Pattern from analyzer import PatternRecognizer +# pylint: disable=line-too-long REGEX = r'\b((4\d{3})|(5[0-5]\d{2})|(6\d{3})|(1\d{3})|(3\d{3}))[- ]?(\d{3,4})[- ]?(\d{3,4})[- ]?(\d{3,5})\b' # noqa: E501 CONTEXT = [ "credit", @@ -28,9 +29,9 @@ def __init__(self): super().__init__(supported_entity="CREDIT_CARD", patterns=patterns, context=CONTEXT) - def validate_result(self, text, pattern_result): - self.__sanitize_value(text) - res = self.__luhn_checksum() + def validate_result(self, pattern_text, pattern_result): + sanitized_value = CreditCardRecognizer.__sanitize_value(pattern_text) + res = CreditCardRecognizer.__luhn_checksum(sanitized_value) if res == 0: pattern_result.score = 1 else: @@ -38,11 +39,12 @@ def validate_result(self, text, pattern_result): return pattern_result - def __luhn_checksum(self): + @staticmethod + def __luhn_checksum(sanitized_value): def digits_of(n): return [int(d) for d in str(n)] - digits = digits_of(self.sanitized_value) + digits = digits_of(sanitized_value) odd_digits = digits[-1::-2] even_digits = digits[-2::-2] checksum = 0 @@ -51,5 +53,6 @@ def digits_of(n): checksum += sum(digits_of(d * 2)) return checksum % 10 - def __sanitize_value(self, text): - self.sanitized_value = text.replace('-', '').replace(' ', '') + @staticmethod + def __sanitize_value(text): + return text.replace('-', '').replace(' ', '') diff --git a/presidio-analyzer/analyzer/predefined_recognizers/crypto_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/crypto_recognizer.py index c20893ad2..832d6737f 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/crypto_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/crypto_recognizer.py @@ -1,10 +1,9 @@ +from hashlib import sha256 from analyzer import Pattern from analyzer import PatternRecognizer -from hashlib import sha256 -"""Copied from: - http://rosettacode.org/wiki/Bitcoin/address_validation#Python - """ +# Copied from: +# http://rosettacode.org/wiki/Bitcoin/address_validation#Python REGEX = r'\b[13][a-km-zA-HJ-NP-Z0-9]{26,33}\b' CONTEXT = ["wallet", "btc", "bitcoin", "crypto"] @@ -19,9 +18,9 @@ def __init__(self): super().__init__(supported_entity="CRYPTO", patterns=patterns, context=CONTEXT) - def validate_result(self, text, pattern_result): + def validate_result(self, pattern_text, pattern_result): # try: - bcbytes = CryptoRecognizer.__decode_base58(text, 25) + bcbytes = CryptoRecognizer.__decode_base58(pattern_text, 25) if bcbytes[-4:] == sha256(sha256(bcbytes[:-4]).digest()).digest()[:4]: pattern_result.score = 1.0 return pattern_result diff --git a/presidio-analyzer/analyzer/predefined_recognizers/domain_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/domain_recognizer.py index d94e4f9ea..d5826a06c 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/domain_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/domain_recognizer.py @@ -1,7 +1,9 @@ +import tldextract + from analyzer import Pattern from analyzer import PatternRecognizer -import tldextract +# pylint: disable=line-too-long REGEX = r'\b(((([a-zA-Z0-9])|([a-zA-Z0-9][a-zA-Z0-9\-]{0,86}[a-zA-Z0-9]))\.(([a-zA-Z0-9])|([a-zA-Z0-9][a-zA-Z0-9\-]{0,73}[a-zA-Z0-9]))\.(([a-zA-Z0-9]{2,12}\.[a-zA-Z0-9]{2,12})|([a-zA-Z0-9]{2,25})))|((([a-zA-Z0-9])|([a-zA-Z0-9][a-zA-Z0-9\-]{0,162}[a-zA-Z0-9]))\.(([a-zA-Z0-9]{2,12}\.[a-zA-Z0-9]{2,12})|([a-zA-Z0-9]{2,25}))))\b' # noqa: E501' # noqa: E501 CONTEXT = ["domain", "ip"] @@ -16,7 +18,7 @@ def __init__(self): super().__init__(supported_entity="DOMAIN_NAME", patterns=patterns, context=CONTEXT) - def validate_result(self, text, pattern_result): - result = tldextract.extract(text) + def validate_result(self, pattern_text, pattern_result): + result = tldextract.extract(pattern_text) pattern_result.score = 1.0 if result.fqdn != '' else 0 return pattern_result diff --git a/presidio-analyzer/analyzer/predefined_recognizers/email_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/email_recognizer.py index e990aaf3b..5bd4c741b 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/email_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/email_recognizer.py @@ -1,7 +1,9 @@ +import tldextract + from analyzer import Pattern from analyzer import PatternRecognizer -import tldextract +# pylint: disable=line-too-long REGEX = r"\b((([!#$%&'*+\-/=?^_`{|}~\w])|([!#$%&'*+\-/=?^_`{|}~\w][!#$%&'*+\-/=?^_`{|}~\.\w]{0,}[!#$%&'*+\-/=?^_`{|}~\w]))[@]\w+([-.]\w+)*\.\w+([-.]\w+)*)\b" # noqa: E501 CONTEXT = ["email"] @@ -16,8 +18,8 @@ def __init__(self): super().__init__(supported_entity="EMAIL_ADDRESS", patterns=patterns, context=CONTEXT) - def validate_result(self, text, pattern_result): - result = tldextract.extract(text) + def validate_result(self, pattern_text, pattern_result): + result = tldextract.extract(pattern_text) pattern_result.score = 1.0 if result.fqdn != '' else 0 return pattern_result diff --git a/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py index 2847f10ac..af6662ba7 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py @@ -1,6 +1,6 @@ +import string from analyzer import Pattern from analyzer import PatternRecognizer -import string REGEX = u'[a-zA-Z]{2}[0-9]{2}[a-zA-Z0-9]{4}[0-9]{7}([a-zA-Z0-9]?){0,16}' CONTEXT = ["iban"] @@ -20,9 +20,10 @@ def __init__(self): super().__init__(supported_entity="IBAN_CODE", patterns=patterns, context=CONTEXT) - def validate_result(self, text, pattern_result): + def validate_result(self, pattern_text, pattern_result): is_valid_iban = IbanRecognizer.__generate_iban_check_digits( - text) == text[2:4] and IbanRecognizer.__valid_iban(text) + pattern_text) == pattern_text[2:4] and \ + IbanRecognizer.__valid_iban(pattern_text) pattern_result.score = 1.0 if is_valid_iban else 0 return pattern_result diff --git a/presidio-analyzer/analyzer/predefined_recognizers/ip_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/ip_recognizer.py index d8d702ec8..ce37af99f 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/ip_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/ip_recognizer.py @@ -1,6 +1,7 @@ from analyzer import Pattern from analyzer import PatternRecognizer +# pylint: disable=line-too-long,abstract-method IP_V4_REGEX = r'\b(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\b' # noqa: E501 IP_V6_REGEX = r'\s*(?!.*::.*::)(?:(?!:)|:(?=:))(?:[0-9a-f]{0,4}(?:(?<=::)|(? Date: Wed, 27 Mar 2019 15:41:01 +0200 Subject: [PATCH 09/75] Ll bug610 - Fix bug in IBAN recognizers + additional test fixes (#101) * Fix Bug 610: iban recognizer and fix additional tests * Ignoring 0 score patterns, removed predefined 0 score patterns from code. --- .../analyzer/entity_recognizer.py | 2 + .../analyzer/pattern_recognizer.py | 3 +- .../credit_card_recognizer.py | 5 +- .../crypto_recognizer.py | 3 +- .../domain_recognizer.py | 6 +- .../email_recognizer.py | 6 +- .../predefined_recognizers/iban_patterns.py | 239 ++ .../predefined_recognizers/iban_recognizer.py | 52 +- .../us_driver_license_recognizer.py | 2 +- presidio-analyzer/tests/__init__.py | 1 - presidio-analyzer/tests/assertions.py | 18 +- .../tests/test_analyzer_engine.py | 5 +- .../tests/test_credit_card_recognizer.py | 57 +- .../tests/test_crypto_recognizer.py | 5 +- .../tests/test_domain_recognizer.py | 10 +- .../tests/test_email_recognizer.py | 9 +- .../tests/test_iban_recognizer.py | 2129 ++++++++++++++++- .../tests/test_spacy_recognizer.py | 27 +- .../tests/test_uk_nhs_recognizer.py | 8 +- .../tests/test_us_phone_recognizer.py | 3 +- 20 files changed, 2494 insertions(+), 96 deletions(-) create mode 100644 presidio-analyzer/analyzer/predefined_recognizers/iban_patterns.py diff --git a/presidio-analyzer/analyzer/entity_recognizer.py b/presidio-analyzer/analyzer/entity_recognizer.py index 7bfe567ce..3f54d9d37 100644 --- a/presidio-analyzer/analyzer/entity_recognizer.py +++ b/presidio-analyzer/analyzer/entity_recognizer.py @@ -4,6 +4,8 @@ class EntityRecognizer: + MIN_SCORE = 0 + MAX_SCORE = 1.0 def __init__(self, supported_entities, name=None, supported_language="en", version="0.0.1"): diff --git a/presidio-analyzer/analyzer/pattern_recognizer.py b/presidio-analyzer/analyzer/pattern_recognizer.py index abcb1daa7..70914d25e 100644 --- a/presidio-analyzer/analyzer/pattern_recognizer.py +++ b/presidio-analyzer/analyzer/pattern_recognizer.py @@ -1,6 +1,7 @@ import datetime from analyzer import LocalRecognizer, Pattern, RecognizerResult +from analyzer import EntityRecognizer # Import 're2' regex engine if installed, if not- import 'regex' try: @@ -121,7 +122,7 @@ def __analyze_patterns(self, text): pattern.strength) res = self.validate_result(current_match, res) - if res: + if res and res.score != EntityRecognizer.MIN_SCORE: results.append(res) return results diff --git a/presidio-analyzer/analyzer/predefined_recognizers/credit_card_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/credit_card_recognizer.py index 6f6271f87..1b5312e25 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/credit_card_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/credit_card_recognizer.py @@ -1,5 +1,6 @@ from analyzer import Pattern from analyzer import PatternRecognizer +from analyzer.entity_recognizer import EntityRecognizer # pylint: disable=line-too-long REGEX = r'\b((4\d{3})|(5[0-5]\d{2})|(6\d{3})|(1\d{3})|(3\d{3}))[- ]?(\d{3,4})[- ]?(\d{3,4})[- ]?(\d{3,5})\b' # noqa: E501 @@ -33,9 +34,9 @@ def validate_result(self, pattern_text, pattern_result): sanitized_value = CreditCardRecognizer.__sanitize_value(pattern_text) res = CreditCardRecognizer.__luhn_checksum(sanitized_value) if res == 0: - pattern_result.score = 1 + pattern_result.score = EntityRecognizer.MAX_SCORE else: - pattern_result.score = 0 + pattern_result.score = EntityRecognizer.MIN_SCORE return pattern_result diff --git a/presidio-analyzer/analyzer/predefined_recognizers/crypto_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/crypto_recognizer.py index 832d6737f..a6e7b4cf1 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/crypto_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/crypto_recognizer.py @@ -1,6 +1,7 @@ from hashlib import sha256 from analyzer import Pattern from analyzer import PatternRecognizer +from analyzer.entity_recognizer import EntityRecognizer # Copied from: # http://rosettacode.org/wiki/Bitcoin/address_validation#Python @@ -22,7 +23,7 @@ def validate_result(self, pattern_text, pattern_result): # try: bcbytes = CryptoRecognizer.__decode_base58(pattern_text, 25) if bcbytes[-4:] == sha256(sha256(bcbytes[:-4]).digest()).digest()[:4]: - pattern_result.score = 1.0 + pattern_result.score = EntityRecognizer.MAX_SCORE return pattern_result @staticmethod diff --git a/presidio-analyzer/analyzer/predefined_recognizers/domain_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/domain_recognizer.py index d5826a06c..94c553ece 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/domain_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/domain_recognizer.py @@ -2,6 +2,7 @@ from analyzer import Pattern from analyzer import PatternRecognizer +from analyzer.entity_recognizer import EntityRecognizer # pylint: disable=line-too-long REGEX = r'\b(((([a-zA-Z0-9])|([a-zA-Z0-9][a-zA-Z0-9\-]{0,86}[a-zA-Z0-9]))\.(([a-zA-Z0-9])|([a-zA-Z0-9][a-zA-Z0-9\-]{0,73}[a-zA-Z0-9]))\.(([a-zA-Z0-9]{2,12}\.[a-zA-Z0-9]{2,12})|([a-zA-Z0-9]{2,25})))|((([a-zA-Z0-9])|([a-zA-Z0-9][a-zA-Z0-9\-]{0,162}[a-zA-Z0-9]))\.(([a-zA-Z0-9]{2,12}\.[a-zA-Z0-9]{2,12})|([a-zA-Z0-9]{2,25}))))\b' # noqa: E501' # noqa: E501 @@ -20,5 +21,8 @@ def __init__(self): def validate_result(self, pattern_text, pattern_result): result = tldextract.extract(pattern_text) - pattern_result.score = 1.0 if result.fqdn != '' else 0 + if result.fqdn != '': + pattern_result.score = EntityRecognizer.MAX_SCORE + else: + pattern_result.score = EntityRecognizer.MIN_SCORE return pattern_result diff --git a/presidio-analyzer/analyzer/predefined_recognizers/email_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/email_recognizer.py index 5bd4c741b..444dacbf2 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/email_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/email_recognizer.py @@ -2,6 +2,7 @@ from analyzer import Pattern from analyzer import PatternRecognizer +from analyzer.entity_recognizer import EntityRecognizer # pylint: disable=line-too-long REGEX = r"\b((([!#$%&'*+\-/=?^_`{|}~\w])|([!#$%&'*+\-/=?^_`{|}~\w][!#$%&'*+\-/=?^_`{|}~\.\w]{0,}[!#$%&'*+\-/=?^_`{|}~\w]))[@]\w+([-.]\w+)*\.\w+([-.]\w+)*)\b" # noqa: E501 @@ -21,5 +22,8 @@ def __init__(self): def validate_result(self, pattern_text, pattern_result): result = tldextract.extract(pattern_text) - pattern_result.score = 1.0 if result.fqdn != '' else 0 + if result.fqdn != '': + pattern_result.score = EntityRecognizer.MAX_SCORE + else: + pattern_result.score = EntityRecognizer.MIN_SCORE return pattern_result diff --git a/presidio-analyzer/analyzer/predefined_recognizers/iban_patterns.py b/presidio-analyzer/analyzer/predefined_recognizers/iban_patterns.py new file mode 100644 index 000000000..54c1f2bba --- /dev/null +++ b/presidio-analyzer/analyzer/predefined_recognizers/iban_patterns.py @@ -0,0 +1,239 @@ +''' +The IBAN patterns are based on the IBAN specification here: +https://en.wikipedia.org/wiki/International_Bank_Account_Number +In addition, an IBAN example per country can be found here: +git shttps://www.xe.com/ibancalculator/countrylist +An IBAN checker is available here: https://www.iban.com/iban-checker +''' + +# IBAN parts format +CC = u'[A-Z]{2}' # country code +CK = u'[0-9]{2}[ ]?' # checksum +EOS = u'$' # end of string + +A = u'[A-Z][ ]?' +A2 = u'([A-Z][ ]?){2}' +A3 = u'([A-Z][ ]?){3}' +A4 = u'([A-Z][ ]?){4}' + +C = u'[a-zA-Z0-9][ ]?' +C2 = u'([a-zA-Z0-9][ ]?){2}' +C3 = u'([a-zA-Z0-9][ ]?){3}' +C4 = u'([a-zA-Z0-9][ ]?){4}' + +N = u'[0-9][ ]?' +N2 = u'([0-9][ ]?){2}' +N3 = u'([0-9][ ]?){3}' +N4 = u'([0-9][ ]?){4}' + +regex_per_country = { + # Albania (8n, 16c) ALkk bbbs sssx cccc cccc cccc cccc + 'AL': u'^(AL)' + CK + N4 + N4 + C4 + C4 + C4 + C4 + EOS, + + # Andorra (8n, 12c) ADkk bbbb ssss cccc cccc cccc + 'AD': u'^(AD)' + CK + N4 + N4 + C4 + C4 + C4 + EOS, + + # Austria (16n) ATkk bbbb bccc cccc cccc + 'AT': u'^(AT)' + CK + N4 + N4 + N4 + N4 + EOS, + + # Azerbaijan    (4c,20n) AZkk bbbb cccc cccc cccc cccc cccc + 'AZ': u'^(AZ)' + CK + C4 + N4 + N4 + N4 + N4 + N4 + EOS, + + # Bahrain   (4a,14c)    BHkk bbbb cccc cccc cccc cc + 'BH': u'^(BH)' + CK + A4 + C4 + C4 + C4 + C2 + EOS, + + # Belarus (4c, 4n, 16c)   BYkk bbbb aaaa cccc cccc cccc cccc   + 'BY': u'^(BY)' + CK + C4 + N4 + C4 + C4 + C4 + C4 + EOS, + + # Belgium (12n)   BEkk bbbc cccc ccxx  + 'BE': u'^(BE)' + CK + N4 + N4 + N4 + EOS, + + # Bosnia and Herzegovina    (16n)   BAkk bbbs sscc cccc ccxx + 'BA': u'^(BA)' + CK + N4 + N4 + N4 + N4 + EOS, + + # Brazil (23n,1a,1c) BRkk bbbb bbbb ssss sccc cccc ccct n + 'BR': u'^(BR)' + CK + N4 + N4 + N4 + N4 + N4 + N3 + A + C, + + # Bulgaria  (4a,6n,8c)  BGkk bbbb ssss ttcc cccc cc  + 'BG': u'^(BG)' + CK + A4 + N4 + N + N + C2 + C4 + C2 + EOS, + + # Costa Rica (18n) CRkk 0bbb cccc cccc cccc cc (0 = always zero) + 'CR': u'^(CR)' + CK + u'[0]' + N3 + N4 + N4 + N4 + N2 + EOS, + + # Croatia (17n) HRkk bbbb bbbc cccc cccc c + 'HR': u'^(HR)' + CK + N4 + N4 + N4 + N4 + N, + + # Cyprus (8n,16c) CYkk bbbs ssss cccc cccc cccc cccc   + 'CY': u'^(CY)' + CK + N4 + N4 + C4 + C4 + C4 + C4 + EOS, + + # Czech Republic (20n) CZkk bbbb ssss sscc cccc cccc    + 'CZ': u'^(CZ)' + CK + N4 + N4 + N4 + N4 + N4 + EOS, + + # Denmark (14n) DKkk bbbb cccc cccc cc  + 'DK': u'^(DK)' + CK + N4 + N4 + N4 + N2 + EOS, + + # Dominican Republic (4a,20n) DOkk bbbb cccc cccc cccc cccc cccc  + 'DO': u'^(DO)' + CK + A4 + N4 + N4 + N4 + N4 + N4 + EOS, + + # EAt Timor (19n) TLkk bbbc cccc cccc cccc cxx  + 'TL': u'^(TL)' + CK + N4 + N4 + N4 + N4 + N3 + EOS, + + # Estonia (16n) EEkk bbss cccc cccc cccx  + 'EE': u'^(EE)' + CK + N4 + N4 + N4 + N4 + EOS, + + # Faroe Islands (14n) FOkk bbbb cccc cccc cx   + 'FO': u'^(FO)' + CK + N4 + N4 + N4 + N2 + EOS, + + # Finland (14n) FIkk bbbb bbcc cccc cx  + 'FI': u'^(FI)' + CK + N4 + N4 + N4 + N2 + EOS, + + # France (10n,11c,2n) FRkk bbbb bsss sscc cccc cccc cxx   + 'FR': u'^(FR)' + CK + N4 + N4 + N2 + C2 + C4 + C4 + C + N2 + EOS, + + # Georgia (2c,16n)  GEkk bbcc cccc cccc cccc cc  + 'GE': u'^(GE)' + CK + C2 + N2 + N4 + N4 + N4 + N2 + EOS, + + # Germany (18n) DEkk bbbb bbbb cccc cccc cc + 'DE': u'^(DE)' + CK + N4 + N4 + N4 + N4 + N2 + EOS, + + # Gibraltar (4a,15c)  GIkk bbbb cccc cccc cccc ccc   + 'GI': u'^(GI)' + CK + A4 + C4 + C4 + C4 + C3 + EOS, + + # Greece (7n,16c)  GRkk bbbs sssc cccc cccc cccc ccc + 'GR': u'^(GR)' + CK + N4 + N3 + C + C4 + C4 + C4 + C3 + EOS, + + # Greenland (14n) GLkk bbbb cccc cccc cc  + 'GL': u'^(GL)' + CK + N4 + N4 + N4 + N2 + EOS, + + # Guatemala (4c,20c)  GTkk bbbb mmtt cccc cccc cccc cccc + 'GT': u'^(GT)' + CK + C4 + C4 + C4 + C4 + C4 + C4 + EOS, + + # Hungary (24n) HUkk bbbs sssx cccc cccc cccc cccx + 'HU': u'^(HU)' + CK + N4 + N4 + N4 + N4 + N4 + N4 + EOS, + + # Iceland (22n) ISkk bbbb sscc cccc iiii iiii ii + 'IS': u'^(IS)' + CK + N4 + N4 + N4 + N4 + N4 + N2 + EOS, + + # Ireland (4c,14n)  IEkk aaaa bbbb bbcc cccc cc + 'IE': u'^(IE)' + CK + C4 + N4 + N4 + N4 + N2 + EOS, + + # Israel (19n) ILkk bbbn nncc cccc cccc ccc + 'IL': u'^(IL)' + CK + N4 + N4 + N4 + N4 + N3 + EOS, + + # Italy (1a,10n,12c)  ITkk xbbb bbss sssc cccc cccc ccc + 'IT': u'^(IT)' + CK + A + N3 + N4 + N3 + C + C3 + C + C4 + C3 + EOS, + + # Jordan (4a,22n)  JOkk bbbb ssss cccc cccc cccc cccc cc + 'JO': u'^(JO)' + CK + A4 + N4 + N4 + N4 + N4 + N4 + N2 + EOS, + + # Kazakhstan (3n,13c)  KZkk bbbc cccc cccc cccc + 'KZ': u'^(KZ)' + CK + N3 + C + C4 + C4 + C4 + EOS, + + # Kosovo (4n,10n,2n)   XKkk bbbb cccc cccc cccc + 'XK': u'^(XK)' + CK + N4 + N4 + N4 + N4 + EOS, + + # Kuwait (4a,22c)  KWkk bbbb cccc cccc cccc cccc cccc cc  + 'KW': u'^(KW)' + CK + A4 + C4 + C4 + C4 + C4 + C4 + C2 + EOS, + + # Latvia (4a,13c)  LVkk bbbb cccc cccc cccc c   + 'LV': u'^(LV)' + CK + A4 + C4 + C4 + C4 + C, + + # Lebanon (4n,20c)  LBkk bbbb cccc cccc cccc cccc cccc   + 'LB': u'^(LB)' + CK + N4 + C4 + C4 + C4 + C4 + C4 + EOS, + + # LiechteNtein (5n,12c)  LIkk bbbb bccc cccc cccc c  + 'LI': u'^(LI)' + CK + N4 + N + C3 + C4 + C4 + C, + + # Lithuania (16n) LTkk bbbb bccc cccc cccc + 'LT': u'^(LT)' + CK + N4 + N4 + N4 + N4 + EOS, + + # Luxembourg (3n,13c)  LUkk bbbc cccc cccc cccc + 'LU': u'^(LU)' + CK + N3 + C + C4 + C4 + C4 + EOS, + + # Malta (4a,5n,18c)   MTkk bbbb ssss sccc cccc cccc cccc ccc + 'MT': u'^(MT)' + CK + A4 + N4 + N + C3 + C4 + C4 + C4 + C3 + EOS, + + # Mauritania (23n) MRkk bbbb bsss sscc cccc cccc cxx + 'MR': u'^(MR)' + CK + N4 + N4 + N4 + N4 + N4 + N3 + EOS, + + # Mauritius (4a,19n,3a)   MUkk bbbb bbss cccc cccc cccc 000m mm + 'MU': u'^(MU)' + CK + A4 + N4 + N4 + N4 + N4 + N3 + A, + + # Moldova (2c,18c)  MDkk bbcc cccc cccc cccc cccc + 'MD': u'^(MD)' + CK + C4 + C4 + C4 + C4 + C4 + EOS, + + # Monaco (10n,11c,2n)  MCkk bbbb bsss sscc cccc cccc cxx   + 'MC': u'^(MC)' + CK + N4 + N4 + N2 + C2 + C4 + C4 + C + N2 + EOS, + + # Montenegro (18n) MEkk bbbc cccc cccc cccc xx + 'ME': u'^(ME)' + CK + N4 + N4 + N4 + N4 + N2 + EOS, + + # Netherlands (4a,10n)  NLkk bbbb cccc cccc cc + 'NL': u'^(NL)' + CK + A4 + N4 + N4 + N2 + EOS, + + # North Macedonia (3n,10c,2n)   MKkk bbbc cccc cccc cxx + 'MK': u'^(MK)' + CK + N3 + C + C4 + C4 + C + N2 + EOS, + + # Norway (11n) NOkk bbbb cccc ccx + 'NO': u'^(NO)' + CK + N4 + N4 + N3 + EOS, + + # Pakistan  (4c,16n)  PKkk bbbb cccc cccc cccc cccc + 'PK': u'^(PK)' + CK + C4 + N4 + N4 + N4 + N4 + EOS, + + # Palestinian territories (4c,21n)  PSkk bbbb xxxx xxxx xccc cccc cccc c + 'PS': u'^(PS)' + CK + C4 + N4 + N4 + N4 + N4 + N, + + # Poland (24n) PLkk bbbs sssx cccc cccc cccc cccc + 'PL': u'^(PL)' + CK + N4 + N4 + N4 + N4 + N4 + N4 + EOS, + + # Portugal (21n) PTkk bbbb ssss cccc cccc cccx x  + 'PT': u'^(PT)' + CK + N4 + N4 + N4 + N4 + N, + + # Qatar (4a,21c)  QAkk bbbb cccc cccc cccc cccc cccc c + 'QA': u'^(QA)' + CK + A4 + C4 + C4 + C4 + C4 + C, + + # Romania (4a,16c)  ROkk bbbb cccc cccc cccc cccc + 'RO': u'^(RO)' + CK + A4 + C4 + C4 + C4 + C4 + EOS, + + # San Marino (1a,10n,12c)  SMkk xbbb bbss sssc cccc cccc ccc + 'SM': u'^(SM)' + CK + A + N3 + N4 + N3 + C + C4 + C4 + C3 + EOS, + + # Saudi Arabia (2n,18c)  SAkk bbcc cccc cccc cccc cccc + 'SA': u'^(SA)' + CK + N2 + C2 + C4 + C4 + C4 + C4 + EOS, + + # Serbia (18n) RSkk bbbc cccc cccc cccc xx + 'RS': u'^(RS)' + CK + N4 + N4 + N4 + N4 + N2 + EOS, + + # Slovakia (20n) SKkk bbbb ssss sscc cccc cccc + 'SK': u'^(SK)' + CK + N4 + N4 + N4 + N4 + N4 + EOS, + + # Slovenia (15n) SIkk bbss sccc cccc cxx + 'SI': u'^(SI)' + CK + N4 + N4 + N4 + N3 + EOS, + + # Spain (20n) ESkk bbbb ssss xxcc cccc cccc + 'ES': u'^(ES)' + CK + N4 + N4 + N4 + N4 + N4 + EOS, + + # Sweden (20n) SEkk bbbc cccc cccc cccc cccc + 'SE': u'^(SE)' + CK + N4 + N4 + N4 + N4 + N4 + EOS, + + # Switzerland (5n,12c)  CHkk bbbb bccc cccc cccc c + 'CH': u'^(CH)' + CK + N4 + N + C3 + C4 + C4 + C, + + # Tunisia (20n) TNkk bbss sccc cccc cccc cccc + 'TN': u'^(TN)' + CK + N4 + N4 + N4 + N4 + N4 + EOS, + + # Turkey (5n,17c)  TRkk bbbb bxcc cccc cccc cccc cc + 'TR': u'^(TR)' + CK + N4 + N + C3 + C4 + C4 + C4 + C2 + EOS, + + # United Arab Emirates (3n,16n)  AEkk bbbc cccc cccc cccc ccc + 'AE': u'^(AE)' + CK + N4 + N4 + N4 + N4 + N3 + EOS, + + # United Kingdom (4a,14n) GBkk bbbb ssss sscc cccc cc + 'GB': u'^(GB)' + CK + A4 + N4 + N4 + N4 + N2 + EOS, + + # Vatican City (3n,15n)  VAkk bbbc cccc cccc cccc cc + 'VA': u'^(VA)' + CK + N4 + N4 + N4 + N4 + N2 + EOS, + + # Virgin Islands, British (4c,16n)  VGkk bbbb cccc cccc cccc cccc  + 'VG': u'^(VG)' + CK + C4 + N4 + N4 + N4 + N4 + EOS, +} diff --git a/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py index af6662ba7..a9410df49 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py @@ -1,9 +1,19 @@ import string -from analyzer import Pattern -from analyzer import PatternRecognizer -REGEX = u'[a-zA-Z]{2}[0-9]{2}[a-zA-Z0-9]{4}[0-9]{7}([a-zA-Z0-9]?){0,16}' -CONTEXT = ["iban"] +from analyzer.predefined_recognizers.iban_patterns import regex_per_country +from analyzer import Pattern, PatternRecognizer +from analyzer.entity_recognizer import EntityRecognizer + +# Import 're2' regex engine if installed, if not- import 'regex' +try: + import re2 as re +except ImportError: + import regex as re + +IBAN_GENERIC_REGEX = u'^[A-Z]{2}[0-9]{2}[ ]?([a-zA-Z0-9][ ]?){11,28}$' +IBAN_GENERIC_SCORE = 0.5 + +CONTEXT = ["iban", "bank", "transaction"] LETTERS = { ord(d): str(i) for i, d in enumerate(string.digits + string.ascii_uppercase) @@ -16,16 +26,25 @@ class IbanRecognizer(PatternRecognizer): """ def __init__(self): - patterns = [Pattern('Iban (Medium)', REGEX, 0.5)] - super().__init__(supported_entity="IBAN_CODE", patterns=patterns, + patterns = [Pattern('IBAN Generic', + IBAN_GENERIC_REGEX, + IBAN_GENERIC_SCORE)] + super().__init__(supported_entity="IBAN_CODE", + patterns=patterns, context=CONTEXT) def validate_result(self, pattern_text, pattern_result): - is_valid_iban = IbanRecognizer.__generate_iban_check_digits( - pattern_text) == pattern_text[2:4] and \ - IbanRecognizer.__valid_iban(pattern_text) + pattern_text = pattern_text.replace(' ', '') + is_valid_checksum = (IbanRecognizer.__generate_iban_check_digits( + pattern_text) == pattern_text[2:4]) - pattern_result.score = 1.0 if is_valid_iban else 0 + score = EntityRecognizer.MIN_SCORE + if is_valid_checksum: + if IbanRecognizer.__is_valid_format(pattern_text): + score = EntityRecognizer.MAX_SCORE + elif IbanRecognizer.__is_valid_format(pattern_text.upper()): + score = IBAN_GENERIC_SCORE + pattern_result.score = score return pattern_result @staticmethod @@ -34,9 +53,16 @@ def __number_iban(iban): @staticmethod def __generate_iban_check_digits(iban): - number_iban = IbanRecognizer.__number_iban(iban[:2] + '00' + iban[4:]) + transformed_iban = (iban[:2] + '00' + iban[4:]).upper() + number_iban = IbanRecognizer.__number_iban(transformed_iban) return '{:0>2}'.format(98 - (int(number_iban) % 97)) @staticmethod - def __valid_iban(iban): - return int(IbanRecognizer.__number_iban(iban)) % 97 == 1 + def __is_valid_format(iban): + country_code = iban[:2] + if country_code in regex_per_country: + country_regex = regex_per_country[country_code] + return country_regex and re.match(country_regex, iban, + flags=re.DOTALL | re.MULTILINE) + + return False diff --git a/presidio-analyzer/analyzer/predefined_recognizers/us_driver_license_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/us_driver_license_recognizer.py index 5ddbf04f8..d671d81d0 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/us_driver_license_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/us_driver_license_recognizer.py @@ -36,6 +36,6 @@ def __init__(self): Pattern('Driver License - Alphanumeric (weak) ', ALPHANUMERIC_REGEX, 0.3), Pattern('Driver License - Digits (very weak)', - DIGITS_REGEX, 0)] + DIGITS_REGEX, 0.01)] super().__init__(supported_entity="US_DRIVER_LICENSE", patterns=patterns, context=LICENSE_CONTEXT) diff --git a/presidio-analyzer/tests/__init__.py b/presidio-analyzer/tests/__init__.py index 1bf4805a0..572ca2a79 100644 --- a/presidio-analyzer/tests/__init__.py +++ b/presidio-analyzer/tests/__init__.py @@ -4,4 +4,3 @@ # bug #602: Fix imports issue in python sys.path.append(os.path.dirname(os.path.dirname( os.path.abspath(__file__))) + "/tests") - \ No newline at end of file diff --git a/presidio-analyzer/tests/assertions.py b/presidio-analyzer/tests/assertions.py index 80bd4bdd0..697def579 100644 --- a/presidio-analyzer/tests/assertions.py +++ b/presidio-analyzer/tests/assertions.py @@ -1,17 +1,25 @@ error = 0.00001 -def __assert_result_without_score(result, expected_entity_type, expected_start, expected_end): + +def __assert_result_without_score(result, expected_entity_type, + expected_start, expected_end): assert result.entity_type == expected_entity_type assert result.start == expected_start assert result.end == expected_end -def assert_result(result, expected_entity_type, expected_start, expected_end, expected_score): - __assert_result_without_score(result, expected_entity_type, expected_start, expected_end) + +def assert_result(result, expected_entity_type, expected_start, + expected_end, expected_score): + __assert_result_without_score(result, expected_entity_type, + expected_start, expected_end) assert result.score == expected_score -def assert_result_within_score_range(result, expected_entity_type, expected_start, expected_end, + +def assert_result_within_score_range(result, expected_entity_type, + expected_start, expected_end, expected_score_min, expected_score_max): - __assert_result_without_score(result, expected_entity_type, expected_start, expected_end) + __assert_result_without_score(result, expected_entity_type, + expected_start, expected_end) min_score = min(0, expected_score_min - error) max_score = max(1, expected_score_max + error) assert result.score >= min_score and result.score <= max_score diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index c2ffb6b70..1a8e625fc 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -1,4 +1,5 @@ from unittest import TestCase +from analyzer.entity_recognizer import EntityRecognizer import pytest @@ -29,7 +30,7 @@ def test_analyze_with_single_predefined_recognizers(self): results = analyze_engine.analyze(text, entities, langauge) assert len(results) == 1 - assert_result(results[0], "CREDIT_CARD", 14, 33, 1.0) + assert_result(results[0], "CREDIT_CARD", 14, 33, EntityRecognizer.MAX_SCORE) def test_analyze_with_multiple_predefined_recognizers(self): analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) @@ -39,7 +40,7 @@ def test_analyze_with_multiple_predefined_recognizers(self): results = analyze_engine.analyze(text, entities, langauge) assert len(results) == 2 - assert_result(results[0], "CREDIT_CARD", 14, 33, 1.0) + assert_result(results[0], "CREDIT_CARD", 14, 33, EntityRecognizer.MAX_SCORE) assert_result(results[1], "PHONE_NUMBER", 48, 59, 0.5) def test_analyze_without_entities(self): diff --git a/presidio-analyzer/tests/test_credit_card_recognizer.py b/presidio-analyzer/tests/test_credit_card_recognizer.py index 0c156ac22..cbeee5584 100644 --- a/presidio-analyzer/tests/test_credit_card_recognizer.py +++ b/presidio-analyzer/tests/test_credit_card_recognizer.py @@ -4,6 +4,7 @@ from assertions import assert_result from analyzer.predefined_recognizers import CreditCardRecognizer +from analyzer.entity_recognizer import EntityRecognizer entities = ["CREDIT_CARD"] @@ -20,16 +21,16 @@ def test_valid_credit_cards(self): results = credit_card_recognizer.analyze('{} {} {}'.format(number1, number2, number3), entities) assert len(results) == 3 - assert_result(results[0], entities[0], 0, 16, 1.0) - assert_result(results[1], entities[0], 17, 36, 1.0) - assert_result(results[2], entities[0], 37, 56, 1.0) + assert_result(results[0], entities[0], 0, 16, EntityRecognizer.MAX_SCORE) + assert_result(results[1], entities[0], 17, 36, EntityRecognizer.MAX_SCORE) + assert_result(results[2], entities[0], 37, 56, EntityRecognizer.MAX_SCORE) def test_valid_airplus_credit_card(self): number = '122000000000003' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 15, 1.0) + assert_result(results[0], entities[0], 0, 15, EntityRecognizer.MAX_SCORE) def test_valid_airplus_credit_card_with_extact_context(self): number = '122000000000003' @@ -37,84 +38,84 @@ def test_valid_airplus_credit_card_with_extact_context(self): results = credit_card_recognizer.analyze(context + number, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 16, 31, 1.0) + assert_result(results[0], entities[0], 16, 31, EntityRecognizer.MAX_SCORE) def test_valid_amex_credit_card(self): number = '371449635398431' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 15, 1.0) + assert_result(results[0], entities[0], 0, 15, EntityRecognizer.MAX_SCORE) def test_valid_cartebleue_credit_card(self): number = '5555555555554444' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 16, 1.0) + assert_result(results[0], entities[0], 0, 16, EntityRecognizer.MAX_SCORE) def test_valid_dankort_credit_card(self): number = '5019717010103742' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 16, 1.0) + assert_result(results[0], entities[0], 0, 16, EntityRecognizer.MAX_SCORE) def test_valid_diners_credit_card(self): number = '30569309025904' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 14, 1.0) + assert_result(results[0], entities[0], 0, 14, EntityRecognizer.MAX_SCORE) def test_valid_discover_credit_card(self): number = '6011000400000000' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 16, 1.0) + assert_result(results[0], entities[0], 0, 16, EntityRecognizer.MAX_SCORE) def test_valid_jcb_credit_card(self): number = '3528000700000000' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 16, 1.0) + assert_result(results[0], entities[0], 0, 16, EntityRecognizer.MAX_SCORE) def test_valid_maestro_credit_card(self): number = '6759649826438453' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 16, 1.0) + assert_result(results[0], entities[0], 0, 16, EntityRecognizer.MAX_SCORE) def test_valid_mastercard_credit_card(self): number = '5555555555554444' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 16, 1.0) + assert_result(results[0], entities[0], 0, 16, EntityRecognizer.MAX_SCORE) def test_valid_visa_credit_card(self): number = '4111111111111111' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 16, 1.0) + assert_result(results[0], entities[0], 0, 16, EntityRecognizer.MAX_SCORE) def test_valid_visa_debit_credit_card(self): number = '4111111111111111' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 16, 1.0) + assert_result(results[0], entities[0], 0, 16, EntityRecognizer.MAX_SCORE) def test_valid_visa_electron_credit_card(self): number = '4917300800000000' results = credit_card_recognizer.analyze(number, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 16, 1.0) + assert_result(results[0], entities[0], 0, 16, EntityRecognizer.MAX_SCORE) def test_valid_visa_purchasing_credit_card(self): number = '4484070000000000' @@ -122,18 +123,28 @@ def test_valid_visa_purchasing_credit_card(self): assert len(results) == 1 assert results[0].score == 1.0 - assert_result(results[0], entities[0], 0, 16, 1.0) + assert_result(results[0], entities[0], 0, 16, EntityRecognizer.MAX_SCORE) - def test_invalid_credit_card(self): + def test_invalid_credit_card_with_no_context(self): + number = '4012-8888-8888-1882' + results = credit_card_recognizer.analyze(number, entities) + + assert not results + + def test_invalid_credit_card_with_context(self): number = '4012-8888-8888-1882' results = credit_card_recognizer.analyze('my credit card number is ' + number, entities) - assert len(results) == 1 - assert_result(results[0], entities[0],25, 44, 0) + assert not results - def test_invalid_diners_card(self): + def test_invalid_diners_card_with_no_context(self): + number = '36168002586008' + results = credit_card_recognizer.analyze(number, entities) + + assert not results + + def test_invalid_diners_card_with_context(self): number = '36168002586008' results = credit_card_recognizer.analyze('my credit card number is ' + number, entities) - assert len(results) == 1 - assert_result(results[0], entities[0],25, 39, 0) + assert not results diff --git a/presidio-analyzer/tests/test_crypto_recognizer.py b/presidio-analyzer/tests/test_crypto_recognizer.py index 1f3de6f8a..1ba2322fd 100644 --- a/presidio-analyzer/tests/test_crypto_recognizer.py +++ b/presidio-analyzer/tests/test_crypto_recognizer.py @@ -2,6 +2,7 @@ from assertions import assert_result from analyzer.predefined_recognizers import CryptoRecognizer +from analyzer.entity_recognizer import EntityRecognizer crypto_recognizer = CryptoRecognizer() entities = ["CRYPTO"] @@ -16,7 +17,7 @@ def test_valid_btc(self): results = crypto_recognizer.analyze(wallet, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 34, 1.0) + assert_result(results[0], entities[0], 0, 34, EntityRecognizer.MAX_SCORE) def test_valid_btc_with_exact_context(self): wallet = '16Yeky6GMjeNkAiNcBY7ZhrLoMSgg1BoyZ' @@ -24,7 +25,7 @@ def test_valid_btc_with_exact_context(self): results = crypto_recognizer.analyze(context + wallet, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 22, 56, 1.0) + assert_result(results[0], entities[0], 22, 56, EntityRecognizer.MAX_SCORE) def test_invalid_btc(self): wallet = '16Yeky6GMjeNkAiNcBY7ZhrLoMSgg1BoyZ2' diff --git a/presidio-analyzer/tests/test_domain_recognizer.py b/presidio-analyzer/tests/test_domain_recognizer.py index 9e1559418..2dc160d68 100644 --- a/presidio-analyzer/tests/test_domain_recognizer.py +++ b/presidio-analyzer/tests/test_domain_recognizer.py @@ -2,6 +2,7 @@ from assertions import assert_result from analyzer.predefined_recognizers import DomainRecognizer +from analyzer.entity_recognizer import EntityRecognizer domain_recognizer = DomainRecognizer() entities = ["DOMAIN_NAME"] @@ -29,14 +30,13 @@ def test_valid_domain(self): results = domain_recognizer.analyze(domain, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 13, 1.0) + assert_result(results[0], entities[0], 0, 13, EntityRecognizer.MAX_SCORE) def test_valid_domains_lemma_text(self): domain1 = 'microsoft.com' - domain2 = '192.168.0.1' + domain2 = 'google.co.il' results = domain_recognizer.analyze('my domains: {} {}'.format(domain1, domain2), entities) assert len(results) == 2 - assert_result(results[0], entities[0], 12, 25, 1.0) - assert_result(results[1], entities[0], 26, 33, 0) - + assert_result(results[0], entities[0], 12, 25, EntityRecognizer.MAX_SCORE) + assert_result(results[1], entities[0], 26, 38, EntityRecognizer.MAX_SCORE) diff --git a/presidio-analyzer/tests/test_email_recognizer.py b/presidio-analyzer/tests/test_email_recognizer.py index 424347ac7..a2ec7dc79 100644 --- a/presidio-analyzer/tests/test_email_recognizer.py +++ b/presidio-analyzer/tests/test_email_recognizer.py @@ -2,6 +2,7 @@ from assertions import assert_result from analyzer.predefined_recognizers import EmailRecognizer +from analyzer.entity_recognizer import EntityRecognizer email_recognizer = EmailRecognizer() entities = ["EMAIL_ADDRESS"] @@ -14,14 +15,14 @@ def test_valid_email_no_context(self): results = email_recognizer.analyze(email, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 18, 1.0) + assert_result(results[0], entities[0], 0, 18, EntityRecognizer.MAX_SCORE) def test_valid_email_with_context(self): email = 'info@presidio.site' results = email_recognizer.analyze('my email is {}'.format(email), entities) assert len(results) == 1 - assert_result(results[0], entities[0], 12, 30, 1.0) + assert_result(results[0], entities[0], 12, 30, EntityRecognizer.MAX_SCORE) def test_multiple_emails_with_lemma_context(self): email1 = 'info@presidio.site' @@ -30,8 +31,8 @@ def test_multiple_emails_with_lemma_context(self): 'try one of this emails: {} or {}'.format(email1, email2), entities) assert len(results) == 2 - assert_result(results[0], entities[0], 24, 42, 1.0) - assert_result(results[1], entities[0], 46, 71, 1.0) + assert_result(results[0], entities[0], 24, 42, EntityRecognizer.MAX_SCORE) + assert_result(results[1], entities[0], 46, 71, EntityRecognizer.MAX_SCORE) def test_invalid_email(self): email = 'my email is info@presidio.' diff --git a/presidio-analyzer/tests/test_iban_recognizer.py b/presidio-analyzer/tests/test_iban_recognizer.py index 27242ffd0..0f1bae7e7 100644 --- a/presidio-analyzer/tests/test_iban_recognizer.py +++ b/presidio-analyzer/tests/test_iban_recognizer.py @@ -1,32 +1,2133 @@ from unittest import TestCase +import string from assertions import assert_result -from analyzer.predefined_recognizers import IbanRecognizer +from analyzer.predefined_recognizers.iban_recognizer import IbanRecognizer, IBAN_GENERIC_SCORE, LETTERS +from analyzer.entity_recognizer import EntityRecognizer iban_recognizer = IbanRecognizer() entities = ["IBAN_CODE"] +def update_iban_checksum(iban): + ''' + Generates an IBAN, with checksum digits + This is based on: https://www.ibantest.com/en/how-is-the-iban-check-digit-calculated + ''' + iban_no_spaces = iban.replace(' ', '') + iban_digits = (iban_no_spaces[4:] +iban_no_spaces[:2] + '00').upper().translate(LETTERS) + check_digits = '{:0>2}'.format(98 - (int(iban_digits) % 97)) + return iban[:2] + check_digits + iban[4:] + class TestIbanRecognizer(TestCase): +# Test valid and invalid ibans per each country which supports IBAN - without context + #Albania (8n, 16c) ALkk bbbs sssx cccc cccc cccc cccc + def test_AL_iban_valid_no_spaces(self): + iban = 'AL47212110090000000235698741' + results = iban_recognizer.analyze(iban, entities) - def test_valid_iban(self): - number = 'IL150120690000003111111' - results = iban_recognizer.analyze(number, entities) + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 28, EntityRecognizer.MAX_SCORE) + + def test_AL_iban_valid_with_spaces(self): + iban = 'AL47 2121 1009 0000 0002 3569 8741' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 34, EntityRecognizer.MAX_SCORE) + + def test_AL_iban_invalid_format_valid_checksum(self): + iban = 'AL47 212A 1009 0000 0002 3569 8741' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_AL_iban_invalid_length(self): + iban = 'AL47 212A 1009 0000 0002 3569 874' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_AL_iban_invalid_checksum(self): + iban = 'AL47 2121 1009 0000 0002 3569 8740' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + #Andorra (8n, 12c) ADkk bbbs sssx cccc cccc cccc + def test_AD_valid_iban_no_spaces(self): + iban = 'AD1200012030200359100100' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_AD_iban_valid_with_spaces(self): + iban = 'AD12 0001 2030 2003 5910 0100' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 29, EntityRecognizer.MAX_SCORE) + + def test_AD_iban_invalid_format_valid_checksum(self): + iban = 'AD12000A2030200359100100' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_AD_iban_invalid_length(self): + iban = 'AD12000A203020035910010' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_AD_iban_invalid_checksum(self): + iban = 'AD12 0001 2030 2003 5910 0101' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Austria (16n) ATkk bbbb bccc cccc cccc + def test_AT_iban_valid_no_spaces(self): + iban = 'AT611904300234573201' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 20, EntityRecognizer.MAX_SCORE) + + def test_AT_iban_valid_with_spaces(self): + iban = 'AT61 1904 3002 3457 3201' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_AT_iban_invalid_format_valid_checksum(self): + iban = 'AT61 1904 A002 3457 3201' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_AT_iban_invalid_length(self): + iban = 'AT61 1904 3002 3457 320' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_AT_iban_invalid_checksum(self): + iban = 'AT61 1904 3002 3457 3202' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Azerbaijan    (4c,20n) AZkk bbbb cccc cccc cccc cccc cccc + def test_AZ_iban_valid_no_spaces(self): + iban = 'AZ21NABZ00000000137010001944' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 28, EntityRecognizer.MAX_SCORE) + + def test_AZ_iban_valid_with_spaces(self): + iban = 'AZ21 NABZ 0000 0000 1370 1000 1944' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 34, EntityRecognizer.MAX_SCORE) + + def test_AZ_iban_invalid_format_valid_checksum(self): + iban = 'AZ21NABZ000000001370100019' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_AZ_iban_invalid_length(self): + iban = 'AZ21NABZ0000000013701000194' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_AZ_iban_invalid_checksum(self): + iban = 'AZ21NABZ00000000137010001945' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Bahrain   (4a,14c)    BHkk bbbb cccc cccc cccc cc + def test_BH_iban_valid_no_spaces(self): + iban = 'BH67BMAG00001299123456' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 22, EntityRecognizer.MAX_SCORE) + + def testBH_iban_valid__with_spaces(self): + iban = 'BH67 BMAG 0000 1299 1234 56' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 27, EntityRecognizer.MAX_SCORE) + + def test_BH_iban_invalid_format_valid_checksum(self): + iban = 'BH67BMA100001299123456' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_BH_iban_invalid_length(self): + iban = 'BH67BMAG0000129912345' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_BH_iban_invalid_checksum(self): + iban = 'BH67BMAG00001299123457' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Belarus (4c, 4n, 16c)   BYkk bbbb aaaa cccc cccc cccc cccc   + def test_BY_iban_valid_no_spaces(self): + iban = 'BY13NBRB3600900000002Z00AB00' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 28, EntityRecognizer.MAX_SCORE) + + def test_BY_iban_valid_with_spaces(self): + iban = 'BY13 NBRB 3600 9000 0000 2Z00 AB00' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 34, EntityRecognizer.MAX_SCORE) + + def test_BY_iban_invalid_format_valid_checksum(self): + iban = 'BY13NBRBA600900000002Z00AB00' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_BY_iban_invalid_length(self): + iban = 'BY13 NBRB 3600 9000 0000 2Z00 AB0' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_BY_iban_invalid_checksum(self): + iban = 'BY13NBRB3600900000002Z00AB01' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Belgium (12n)   BEkk bbbc cccc ccxx  + def test_BE_iban_valid_no_spaces(self): + iban = 'BE68539007547034' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 16, EntityRecognizer.MAX_SCORE) + + def test_BE_iban_valid_with_spaces(self): + iban = 'BE71 0961 2345 6769' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 19, EntityRecognizer.MAX_SCORE) + + def test_BE_iban_invalid_format_valid_checksum(self): + iban = 'BE71 A961 2345 6769' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_BE_iban_invalid_length(self): + iban = 'BE6853900754703' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_BE_iban_invalid_checksum(self): + iban = 'BE71 0961 2345 6760' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Bosnia and Herzegovina    (16n)   BAkk bbbs sscc cccc ccxx + def test_BA_iban_valid_no_spaces(self): + iban = 'BA391290079401028494' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 20, EntityRecognizer.MAX_SCORE) + + def test_BA_iban_valid_with_spaces(self): + iban = 'BA39 1290 0794 0102 8494' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_BA_iban_invalid_format_valid_checksum(self): + iban = 'BA39 A290 0794 0102 8494' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_BA_iban_invalid_length(self): + iban = 'BA39129007940102849' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_BA_iban_invalid_checksum(self): + iban = 'BA39 1290 0794 0102 8495' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Brazil (23n,1a,1c) BRkk bbbb bbbb ssss sccc cccc ccct n + def test_BR_iban_valid_no_spaces(self): + iban = 'BR9700360305000010009795493P1' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 29, EntityRecognizer.MAX_SCORE) + + def test_BR_iban_valid_with_spaces(self): + iban = 'BR97 0036 0305 0000 1000 9795 493P 1' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 36, EntityRecognizer.MAX_SCORE) + + def test_BR_iban_invalid_format_valid_checksum(self): + iban = 'BR97 0036 A305 0000 1000 9795 493P 1' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_BR_iban_invalid_length(self): + iban = 'BR9700360305000010009795493P' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_BR_iban_invalid_checksum(self): + iban = 'BR97 0036 0305 0000 1000 9795 493P 2' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Bulgaria  (4a,6n,8c)  BGkk bbbb ssss ttcc cccc cc + def test_BG_iban_valid_no_spaces(self): + iban = 'BG80BNBG96611020345678' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 22, EntityRecognizer.MAX_SCORE) + + def test_BG_iban_valid_with_spaces(self): + iban = 'BG80 BNBG 9661 1020 3456 78' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 27, EntityRecognizer.MAX_SCORE) + + def test_BG_iban_invalid_format_valid_checksum(self): + iban = 'BG80 BNBG 9661 A020 3456 78' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_BG_iban_invalid_length(self): + iban = 'BG80BNBG9661102034567' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_BG_iban_invalid_checksum(self): + iban = 'BG80 BNBG 9661 1020 3456 79' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Costa Rica (18n) CRkk 0bbb cccc cccc cccc cc 0 = always zero + def test_CR_iban_valid_no_spaces(self): + iban = 'CR05015202001026284066' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 22, EntityRecognizer.MAX_SCORE) + + def test_CR_iban_valid_with_spaces(self): + iban = 'CR05 0152 0200 1026 2840 66' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 27, EntityRecognizer.MAX_SCORE) + + def test_CR_iban_invalid_format_valid_checksum(self): + iban = 'CR05 0152 0200 1026 2840 6A' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_CR_iban_invalid_length(self): + iban = 'CR05 0152 0200 1026 2840 6' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_CR_iban_invalid_checksum(self): + iban = 'CR05 0152 0200 1026 2840 67' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Croatia (17n) HRkk bbbb bbbc cccc cccc c   + def test_HR_iban_valid_no_spaces(self): + iban = 'HR1210010051863000160' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 21, EntityRecognizer.MAX_SCORE) + + def test_HR_iban_valid_with_spaces(self): + iban = 'HR12 1001 0051 8630 0016 0' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 26, EntityRecognizer.MAX_SCORE) + + def test_HR_iban_invalid_format_valid_checksum(self): + iban = 'HR12 001 0051 8630 0016 A' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_HR_iban_invalid_length(self): + iban = 'HR121001005186300016' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_HR_iban_invalid_Checksum(self): + iban = 'HR12 1001 0051 8630 0016 1' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Cyprus (8n,16c)  CYkk bbbs ssss cccc cccc cccc cccc + def test_CY_iban_valid_no_spaces(self): + iban = 'CY17002001280000001200527600' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 28, EntityRecognizer.MAX_SCORE) + + def test_CY_iban_valid_with_spaces(self): + iban = 'CY17 0020 0128 0000 0012 0052 7600' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 34, EntityRecognizer.MAX_SCORE) + + def test_CY_iban_invalid_format_valid_checksum(self): + iban = 'CY17 0020 A128 0000 0012 0052 7600' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_CY_iban_invalid_length(self): + iban = 'CY17 0020 0128 0000 0012 0052 760' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_CY_iban_invalid_checksum(self): + iban = 'CY17 0020 0128 0000 0012 0052 7601' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Czech Republic (20n) CZkk bbbb ssss sscc cccc cccc + def test_CZ_iban_valid_no_spaces(self): + iban = 'CZ6508000000192000145399' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_CZ_iban_valid_with_spaces(self): + iban = 'CZ65 0800 0000 1920 0014 5399' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 29, EntityRecognizer.MAX_SCORE) + + def test_CZ_iban_invalid_format_valid_checksum(self): + iban = 'CZ65 0800 A000 1920 0014 5399' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_CZ_iban_invalid_length(self): + iban = 'CZ65 0800 0000 1920 0014 539' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_CZ_iban_invalid_checksum(self): + iban = 'CZ65 0800 0000 1920 0014 5390' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Denmark (14n) DKkk bbbb cccc cccc cc + def test_DK_iban_valid_no_spaces(self): + iban = 'DK5000400440116243' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 18, EntityRecognizer.MAX_SCORE) + + def test_DK_iban_valid_with_spaces(self): + iban = 'DK50 0040 0440 1162 43' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 22, EntityRecognizer.MAX_SCORE) + + def test_DK_iban_invalid_format_valid_checksum(self): + iban = 'DK50 0040 A440 1162 43' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_DK_iban_invalid_length(self): + iban = 'DK50 0040 0440 1162 4' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_DK_iban_invalid_checksum(self): + iban = 'DK50 0040 0440 1162 44' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Dominican Republic (4a,20n) DOkk bbbb cccc cccc cccc cccc cccc + def test_DO_iban_valid_no_spaces(self): + iban = 'DO28BAGR00000001212453611324' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 28, EntityRecognizer.MAX_SCORE) + + def test_DO_iban_valid_with_spaces(self): + iban = 'DO28 BAGR 0000 0001 2124 5361 1324' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 34, EntityRecognizer.MAX_SCORE) + + def test_DO_iban_invalid_format_valid_checksum(self): + iban = 'DO28 BAGR A000 0001 2124 5361 1324' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_DO_iban_invalid_length(self): + iban = 'DO28 BAGR 0000 0001 2124 5361 132' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_DO_iban_invalid_checksum(self): + iban = 'DO28 BAGR 0000 0001 2124 5361 1325' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # East Timor (Timor-Leste) (19n) TLkk bbbc cccc cccc cccc cxx + def test_TL_iban_valid_no_spaces(self): + iban = 'TL380080012345678910157' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 23, EntityRecognizer.MAX_SCORE) + + def test_TL_iban_valid_with_spaces(self): + iban = 'TL38 0080 0123 4567 8910 157' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 28, EntityRecognizer.MAX_SCORE) + + def test_TL_iban_invalid_format_valid_checksum(self): + iban = 'TL38 A080 0123 4567 8910 157' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_TL_iban_invalid_checksum(self): + iban = 'TL38 0080 0123 4567 8910 158' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Estonia (16n) EEkk bbss cccc cccc cccx   + def test_EE_iban_valid_no_spaces(self): + iban = 'EE382200221020145685' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 20, EntityRecognizer.MAX_SCORE) + + def test_EE_iban_valid_with_spaces(self): + iban = 'EE38 2200 2210 2014 5685' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_EE_iban_invalid_format_valid_checksum(self): + iban = 'EE38 A200 2210 2014 5685' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_EE_iban_invalid_checksum(self): + iban = 'EE38 2200 2210 2014 5686' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Faroe Islands (14n) FOkk bbbb cccc cccc cx  + def test_FO_iban_valid_no_spaces(self): + iban = 'FO6264600001631634' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 18, EntityRecognizer.MAX_SCORE) + + def test_FO_iban_valid_with_spaces(self): + iban = 'FO62 6460 0001 6316 34' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 22, EntityRecognizer.MAX_SCORE) + + def test_FO_iban_invalid_format_valid_checksum(self): + iban = 'FO62 A460 0001 6316 34' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_FO_iban_invalid_checksum(self): + iban = 'FO62 6460 0001 6316 35' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Finland (14n) FIkk bbbb bbcc cccc cx   + def test_FI_iban_valid_no_spaces(self): + iban = 'FI2112345600000785' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 18, EntityRecognizer.MAX_SCORE) + + def test_FI_iban_valid_with_spaces(self): + iban = 'FI21 1234 5600 0007 85' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 22, EntityRecognizer.MAX_SCORE) + + def test_FI_iban_invalid_format_valid_checksum(self): + iban = 'FI21 A234 5600 0007 85' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_FI_iban_invalid_checksum(self): + iban = 'FI21 1234 5600 0007 86' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # France (10n,11c,2n) FRkk bbbb bsss sscc cccc cccc cxx   + def test_FR_iban_valid_no_spaces(self): + iban = 'FR1420041010050500013M02606' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 27, EntityRecognizer.MAX_SCORE) + + def test_FR_iban_valid_with_spaces(self): + iban = 'FR14 2004 1010 0505 0001 3M02 606' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 33, EntityRecognizer.MAX_SCORE) + + def test_FR_iban_invalid_format_valid_checksum(self): + iban = 'FR14 A004 1010 0505 0001 3M02 606' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_FR_iban_invalid_checksum(self): + iban = 'FR14 2004 1010 0505 0001 3M02 607' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Georgia (2c,16n)  GEkk bbcc cccc cccc cccc cc + def test_GE_iban_valid_no_spaces(self): + iban = 'GE29NB0000000101904917' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 22, EntityRecognizer.MAX_SCORE) + + def test_GE_iban_valid_with_spaces(self): + iban = 'GE29 NB00 0000 0101 9049 17' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 27, EntityRecognizer.MAX_SCORE) + + def test_GE_iban_invalid_format_valid_checksum(self): + iban = 'GE29 NBA0 0000 0101 9049 17' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_GE_iban_invalid_checksum(self): + iban = 'GE29 NB00 0000 0101 9049 18' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Germany (18n) DEkk bbbb bbbb cccc cccc cc + def test_DE_iban_valid_no_spaces(self): + iban = 'DE89370400440532013000' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 22, EntityRecognizer.MAX_SCORE) + + def test_DE_iban_valid_with_spaces(self): + iban = 'DE89 3704 0044 0532 0130 00' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 27, EntityRecognizer.MAX_SCORE) + + def test_DE_iban_invalid_format_valid_checksum(self): + iban = 'DE89 A704 0044 0532 0130 00' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_DE_iban_invalid_checksum(self): + iban = 'DE89 3704 0044 0532 0130 01' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Gibraltar (4a,15c)  GIkk bbbb cccc cccc cccc ccc + def test_GI_iban_valid_no_spaces(self): + iban = 'GI75NWBK000000007099453' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 23, EntityRecognizer.MAX_SCORE) + + def test_GI_iban_valid_with_spaces(self): + iban = 'GI75 NWBK 0000 0000 7099 453' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 28, EntityRecognizer.MAX_SCORE) + + def test_GI_iban_invalid_format_valid_checksum(self): + iban = 'GI75 aWBK 0000 0000 7099 453' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 28, IBAN_GENERIC_SCORE) + + + def test_GI_iban_invalid_checksum(self): + iban = 'GI75 NWBK 0000 0000 7099 454' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Greece (7n,16c)  GRkk bbbs sssc cccc cccc cccc ccc + def test_GR_iban_valid_no_spaces(self): + iban = 'GR1601101250000000012300695' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 27, EntityRecognizer.MAX_SCORE) + + def test_GR_iban_valid_with_spaces(self): + iban = 'GR16 0110 1250 0000 0001 2300 695' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 33, EntityRecognizer.MAX_SCORE) + + def test_GR_iban_invalid_format_valid_checksum(self): + iban = 'GR16 A110 1250 0000 0001 2300 695' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_GR_iban_invalid_checksum(self): + iban = 'GR16 0110 1250 0000 0001 2300 696' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Greenland (14n) GLkk bbbb cccc cccc cc  + def test_GL_iban_valid_no_spaces(self): + iban = 'GL8964710001000206' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 18, EntityRecognizer.MAX_SCORE) + + def test_GL_iban_valid_with_spaces(self): + iban = 'GL89 6471 0001 0002 06' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 22, EntityRecognizer.MAX_SCORE) + + def test_GL_iban_invalid_format_valid_checksum(self): + iban = 'GL89 A471 0001 0002 06' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_GL_iban_invalid_checksum(self): + iban = 'GL89 6471 0001 0002 07' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Guatemala (4c,20c)  GTkk bbbb mmtt cccc cccc cccc cccc + def test_GT_iban_valid_no_spaces(self): + iban = 'GT82TRAJ01020000001210029690' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 28, EntityRecognizer.MAX_SCORE) + + def test_GT_iban_valid_with_spaces(self): + iban = 'GT82 TRAJ 0102 0000 0012 1002 9690' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 34, EntityRecognizer.MAX_SCORE) + + def test_GT_iban_invalid_format_valid_checksum(self): + iban = 'GT82 TRAJ 0102 0000 0012 1002 9690 A' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_GT_iban_invalid_checksum(self): + iban = 'GT82 TRAJ 0102 0000 0012 1002 9691' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Hungary (24n) HUkk bbbs sssx cccc cccc cccc cccx + def test_HU_iban_valid_no_spaces(self): + iban = 'HU42117730161111101800000000' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 28, EntityRecognizer.MAX_SCORE) + + def test_HU_iban_valid_with_spaces(self): + iban = 'HU42 1177 3016 1111 1018 0000 0000' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 34, EntityRecognizer.MAX_SCORE) + + def test_HU_iban_invalid_format_valid_checksum(self): + iban = 'HU42 A177 3016 1111 1018 0000 0000' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_HU_iban_invalid_checksum(self): + iban = 'HU42 1177 3016 1111 1018 0000 0001' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Iceland (22n) ISkk bbbb sscc cccc iiii iiii ii + def test_IS_iban_valid_no_spaces(self): + iban = 'IS140159260076545510730339' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 26, EntityRecognizer.MAX_SCORE) + + def test_IS_iban_valid_with_spaces(self): + iban = 'IS14 0159 2600 7654 5510 7303 39' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 32, EntityRecognizer.MAX_SCORE) + + def test_IS_iban_invalid_format_valid_checksum(self): + iban = 'IS14 A159 2600 7654 5510 7303 39' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_IS_iban_invalid_checksum(self): + iban = 'IS14 0159 2600 7654 5510 7303 30' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Ireland (4c,14n)  IEkk aaaa bbbb bbcc cccc cc + def test_IE_iban_valid_no_spaces(self): + iban = 'IE29AIBK93115212345678' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 22, EntityRecognizer.MAX_SCORE) + + def test_IE_iban_valid_with_spaces(self): + iban = 'IE29 AIBK 9311 5212 3456 78' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 27, EntityRecognizer.MAX_SCORE) + + def test_IE_iban_invalid_format_valid_checksum(self): + iban = 'IE29 AIBK A311 5212 3456 78' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_IE_iban_invalid_checksum(self): + iban = 'IE29 AIBK 9311 5212 3456 79' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Israel (19n)   ILkk bbbn nncc cccc cccc ccc + def test_IL_iban_valid_no_spaces(self): + iban = 'IL620108000000099999999' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 23, EntityRecognizer.MAX_SCORE) + + def test_IL_iban_valid_with_spaces(self): + iban = 'IL62 0108 0000 0009 9999 999' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 28, EntityRecognizer.MAX_SCORE) + + def test_IL_iban_invalid_format_valid_checksum(self): + iban = 'IL62 A108 0000 0009 9999 999' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_IL_iban_valid_checksum(self): + iban = 'IL62 0108 0000 0009 9999 990' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Italy (1a,10n,12c)  ITkk xbbb bbss sssc cccc cccc ccc + def test_IT_iban_valid_no_spaces(self): + iban = 'IT60X0542811101000000123456' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 27, EntityRecognizer.MAX_SCORE) + + def test_IT_iban_valid_with_spaces(self): + iban = 'IT60 X054 2811 1010 0000 0123 456' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 33, EntityRecognizer.MAX_SCORE) + + def test_IT_iban_invalid_format_valid_checksum(self): + iban = 'IT60 XW54 2811 1010 0000 0123 456' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_IT_iban_valid_checksum(self): + iban = 'IT60 X054 2811 1010 0000 0123 457' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Jordan (4a,22n)  JOkk bbbb ssss cccc cccc cccc cccc cc + def test_JO_iban_valid_no_spaces(self): + iban = 'JO94CBJO0010000000000131000302' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 30, EntityRecognizer.MAX_SCORE) + + def test_JO_iban_valid_with_spaces(self): + iban = 'JO94 CBJO 0010 0000 0000 0131 0003 02' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 37, EntityRecognizer.MAX_SCORE) + + def test_JO_iban_invalid_format_valid_checksum(self): + iban = 'JO94 CBJO A010 0000 0000 0131 0003 02' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_JO_iban_valid_checksum(self): + iban = 'JO94 CBJO 0010 0000 0000 0131 0003 03' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Kazakhstan (3n,13c)  KZkk bbbc cccc cccc cccc + def test_KZ_iban_valid_no_spaces(self): + iban = 'KZ86125KZT5004100100' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 20, EntityRecognizer.MAX_SCORE) + + def test_KZ_iban_valid_with_spaces(self): + iban = 'KZ86 125K ZT50 0410 0100' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_KZ_iban_invalid_format_valid_checksum(self): + iban = 'KZ86 A25K ZT50 0410 0100' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_KZ_iban_valid_checksum(self): + iban = 'KZ86 125K ZT50 0410 0101' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Kosovo (4n,10n,2n)   XKkk bbbb cccc cccc cccc + def test_XK_iban_valid_no_spaces(self): + iban = 'XK051212012345678906' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 20, EntityRecognizer.MAX_SCORE) + + def test_XK_iban_valid_with_spaces(self): + iban = 'XK05 1212 0123 4567 8906' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_XK_iban_invalid_format_valid_checksum(self): + iban = 'XK05 A212 0123 4567 8906' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_XK_iban_valid_checksum(self): + iban = 'XK05 1212 0123 4567 8907' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Kuwait (4a,22c)  KWkk bbbb cccc cccc cccc cccc cccc cc + def test_KW_iban_valid_no_spaces(self): + iban = 'KW81CBKU0000000000001234560101' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 30, EntityRecognizer.MAX_SCORE) + + def test_KW_iban_valid_with_spaces(self): + iban = 'KW81 CBKU 0000 0000 0000 1234 5601 01' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 37, EntityRecognizer.MAX_SCORE) + + def test_KW_iban_invalid_format_valid_checksum(self): + iban = 'KW81 aBKU 0000 0000 0000 1234 5601 01' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 37, IBAN_GENERIC_SCORE) + + + def test_KW_iban_valid_checksum(self): + iban = 'KW81 CBKU 0000 0000 0000 1234 5601 02' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Latvia (4a,13c)  LVkk bbbb cccc cccc cccc c + def test_LV_iban_valid_no_spaces(self): + iban = 'LV80BANK0000435195001' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 21, EntityRecognizer.MAX_SCORE) + + def test_LV_iban_valid_with_spaces(self): + iban = 'LV80 BANK 0000 4351 9500 1' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 26, EntityRecognizer.MAX_SCORE) + + def test_LV_iban_invalid_format_valid_checksum(self): + iban = 'LV80 bANK 0000 4351 9500 1' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 26, IBAN_GENERIC_SCORE) + + def test_LV_iban_valid_checksum(self): + iban = 'LV80 BANK 0000 4351 9500 2' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Lebanon (4n,20c)  LBkk bbbb cccc cccc cccc cccc cccc + def test_LB_iban_valid_no_spaces(self): + iban = 'LB62099900000001001901229114' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 28, EntityRecognizer.MAX_SCORE) + + def test_LB_iban_valid_with_spaces(self): + iban = 'LB62 0999 0000 0001 0019 0122 9114' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 34, EntityRecognizer.MAX_SCORE) + + def test_LB_iban_invalid_format_valid_checksum(self): + iban = 'LB62 A999 0000 0001 0019 0122 9114' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_LB_iban_valid_checksum(self): + iban = 'LB62 0999 0000 0001 0019 0122 9115' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Liechtenstein (5n,12c)  LIkk bbbb bccc cccc cccc c + def test_LI_iban_valid_no_spaces(self): + iban = 'LI21088100002324013AA' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 21, EntityRecognizer.MAX_SCORE) + + def test_LI_iban_valid_with_spaces(self): + iban = 'LI21 0881 0000 2324 013A A' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 26, EntityRecognizer.MAX_SCORE) + + def test_LI_iban_invalid_format_valid_checksum(self): + iban = 'LI21 A881 0000 2324 013A A' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_LI_iban_valid_checksum(self): + iban = 'LI21 0881 0000 2324 013A B' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Lithuania (16n) LTkk bbbb bccc cccc cccc + def test_LT_iban_valid_no_spaces(self): + iban = 'LT121000011101001000' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 20, EntityRecognizer.MAX_SCORE) + + def test_LT_iban_valid_with_spaces(self): + iban = 'LT12 1000 0111 0100 1000' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_LT_iban_invalid_format_valid_checksum(self): + iban = 'LT12 A000 0111 0100 1000' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_LT_iban_valid_checksum(self): + iban = 'LT12 1000 0111 0100 1001' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Luxembourg (3n,13c)  LUkk bbbc cccc cccc cccc + def test_LU_iban_valid_no_spaces(self): + iban = 'LU280019400644750000' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 20, EntityRecognizer.MAX_SCORE) + + def test_LU_iban_valid_with_spaces(self): + iban = 'LU28 0019 4006 4475 0000' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_LU_iban_invalid_format_valid_checksum(self): + iban = 'LU28 A019 4006 4475 0000' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_LU_iban_valid_checksum(self): + iban = 'LU28 0019 4006 4475 0001' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Malta (4a,5n,18c)   MTkk bbbb ssss sccc cccc cccc cccc ccc + def test_MT_iban_valid_no_spaces(self): + iban = 'MT84MALT011000012345MTLCAST001S' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 31, EntityRecognizer.MAX_SCORE) + + def test_MT_iban_valid_with_spaces(self): + iban = 'MT84 MALT 0110 0001 2345 MTLC AST0 01S' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 38, EntityRecognizer.MAX_SCORE) + + def test_MT_iban_invalid_format_valid_checksum(self): + iban = 'MT84 MALT A110 0001 2345 MTLC AST0 01S' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_MT_iban_valid_checksum(self): + iban = 'MT84 MALT 0110 0001 2345 MTLC AST0 01T' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Mauritania (23n) MRkk bbbb bsss sscc cccc cccc cxx + def test_MR_iban_valid_no_spaces(self): + iban = 'MR1300020001010000123456753' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 27, EntityRecognizer.MAX_SCORE) + + def test_MR_iban_valid_with_spaces(self): + iban = 'MR13 0002 0001 0100 0012 3456 753' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 33, EntityRecognizer.MAX_SCORE) + + def test_MR_iban_invalid_format_valid_checksum(self): + iban = 'MR13 A002 0001 0100 0012 3456 753' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_MR_iban_valid_checksum(self): + iban = 'MR13 0002 0001 0100 0012 3456 754' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Mauritius (4a,19n,3a)   MUkk bbbb bbss cccc cccc cccc 000m mm + def test_MU_iban_valid_no_spaces(self): + iban = 'MU17BOMM0101101030300200000MUR' + results = iban_recognizer.analyze(iban, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 23, 1.0) + assert_result(results[0], entities[0], 0, 30, EntityRecognizer.MAX_SCORE) - def test_invalid_iban(self): - number = 'IL150120690000003111141' - results = iban_recognizer.analyze(number, entities) + def test_MU_iban_valid_with_spaces(self): + iban = 'MU17 BOMM 0101 1010 3030 0200 000M UR' + results = iban_recognizer.analyze(iban, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 0, 23, 0) + assert_result(results[0], entities[0], 0, 37, EntityRecognizer.MAX_SCORE) + + def test_MU_iban_invalid_format_valid_checksum(self): + iban = 'MU17 BOMM A101 1010 3030 0200 000M UR' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) - def test_invalid_iban_with_exact_context_does_not_change_Score(self): - number = 'IL150120690000003111141' - context = 'my iban number is ' - results = iban_recognizer.analyze(context + number, entities) + assert len(results) == 0 + + def test_MU_iban_valid_checksum(self): + iban = 'MU17 BOMM 0101 1010 3030 0200 000M US' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Moldova (2c,18c)  MDkk bbcc cccc cccc cccc cccc + def test_MD_iban_valid_no_spaces(self): + iban = 'MD24AG000225100013104168' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_MD_iban_valid_with_spaces(self): + iban = 'MD24 AG00 0225 1000 1310 4168' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 29, EntityRecognizer.MAX_SCORE) + + def test_MD_iban_invalid_format_valid_checksum(self): + iban = 'MD24 AG00 0225 1000 1310 4168 9' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_MD_iban_valid_checksum(self): + iban = 'MD24 AG00 0225 1000 1310 4169' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Monaco (10n,11c,2n)  MCkk bbbb bsss sscc cccc cccc cxx + def test_MC_iban_valid_no_spaces(self): + iban = 'MC5811222000010123456789030' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 27, EntityRecognizer.MAX_SCORE) + + def test_MC_iban_valid_with_spaces(self): + iban = 'MC58 1122 2000 0101 2345 6789 030' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 33, EntityRecognizer.MAX_SCORE) + + def test_MC_iban_invalid_format_valid_checksum(self): + iban = 'MC58 A122 2000 0101 2345 6789 030' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_MC_iban_valid_checksum(self): + iban = 'MC58 1122 2000 0101 2345 6789 031' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Montenegro (18n) MEkk bbbc cccc cccc cccc xx + def test_ME_iban_valid_no_spaces(self): + iban = 'ME25505000012345678951' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 22, EntityRecognizer.MAX_SCORE) + + def test_ME_iban_valid_with_spaces(self): + iban = 'ME25 5050 0001 2345 6789 51' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 27, EntityRecognizer.MAX_SCORE) + + def test_ME_iban_invalid_format_valid_checksum(self): + iban = 'ME25 A050 0001 2345 6789 51' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_ME_iban_valid_checksum(self): + iban = 'ME25 5050 0001 2345 6789 52' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Netherlands (4a,10n)  NLkk bbbb cccc cccc cc + def test_NL_iban_valid_no_spaces(self): + iban = 'NL91ABNA0417164300' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 18, EntityRecognizer.MAX_SCORE) + + def test_NL_iban_valid_with_spaces(self): + iban = 'NL91 ABNA 0417 1643 00' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 22, EntityRecognizer.MAX_SCORE) + + def test_NL_iban_invalid_format_valid_checksum(self): + iban = 'NL91 1BNA 0417 1643 00' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_NL_iban_valid_checksum(self): + iban = 'NL91 ABNA 0417 1643 01' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # North Macedonia (3n,10c,2n)   MKkk bbbc cccc cccc cxx + def test_MK_iban_valid_no_spaces(self): + iban = 'MK07250120000058984' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 19, EntityRecognizer.MAX_SCORE) + + def test_MK_iban_valid_with_spaces(self): + iban = 'MK07 2501 2000 0058 984' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 23, EntityRecognizer.MAX_SCORE) + + def test_MK_iban_invalid_format_valid_checksum(self): + iban = 'MK07 A501 2000 0058 984' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_MK_iban_valid_checksum(self): + iban = 'MK07 2501 2000 0058 985' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Norway (11n) NOkk bbbb cccc ccx + def test_NO_iban_valid_no_spaces(self): + iban = 'NO9386011117947' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 15, EntityRecognizer.MAX_SCORE) + + def test_NO_iban_valid_with_spaces(self): + iban = 'NO93 8601 1117 947' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 18, EntityRecognizer.MAX_SCORE) + + def test_NO_iban_invalid_format_valid_checksum(self): + iban = 'NO93 A601 1117 947' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_NO_iban_valid_checksum(self): + iban = 'NO93 8601 1117 948' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Pakistan  (4c,16n)  PKkk bbbb cccc cccc cccc cccc + def test_PK_iban_valid_no_spaces(self): + iban = 'PK36SCBL0000001123456702' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_PK_iban_valid_with_spaces(self): + iban = 'PK36 SCBL 0000 0011 2345 6702' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 29, EntityRecognizer.MAX_SCORE) + + def test_PK_iban_invalid_format_valid_checksum(self): + iban = 'PK36 SCBL A000 0011 2345 6702' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_PK_iban_valid_checksum(self): + iban = 'PK36 SCBL 0000 0011 2345 6703' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Palestinian territories (4c,21n)  PSkk bbbb xxxx xxxx xccc cccc cccc c + def test_PS_iban_valid_no_spaces(self): + iban = 'PS92PALS000000000400123456702' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 29, EntityRecognizer.MAX_SCORE) + + def test_PS_iban_valid_with_spaces(self): + iban = 'PS92 PALS 0000 0000 0400 1234 5670 2' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 36, EntityRecognizer.MAX_SCORE) + + def test_PS_iban_invalid_format_valid_checksum(self): + iban = 'PS92 PALS A000 0000 0400 1234 5670 2' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_PS_iban_valid_checksum(self): + iban = 'PS92 PALS 0000 0000 0400 1234 5670 3' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Poland (24n) PLkk bbbs sssx cccc cccc cccc cccc + def test_PL_iban_valid_no_spaces(self): + iban = 'PL61109010140000071219812874' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 28, EntityRecognizer.MAX_SCORE) + + def test_PL_iban_valid_with_spaces(self): + iban = 'PL61 1090 1014 0000 0712 1981 2874' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 34, EntityRecognizer.MAX_SCORE) + + def test_PL_iban_invalid_format_valid_checksum(self): + iban = 'PL61 A090 1014 0000 0712 1981 2874' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_PL_iban_valid_checksum(self): + iban = 'PL61 1090 1014 0000 0712 1981 2875' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Portugal (21n) PTkk bbbb ssss cccc cccc cccx x + def test_PT_iban_valid_no_spaces(self): + iban = 'PT50000201231234567890154' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 25, EntityRecognizer.MAX_SCORE) + + def test_PT_iban_valid_with_spaces(self): + iban = 'PT50 0002 0123 1234 5678 9015 4' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 31, EntityRecognizer.MAX_SCORE) + + def test_PT_iban_invalid_format_valid_checksum(self): + iban = 'PT50 A002 0123 1234 5678 9015 4' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_PT_iban_valid_checksum(self): + iban = 'PT50 0002 0123 1234 5678 9015 5' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Qatar (4a,21c)  QAkk bbbb cccc cccc cccc cccc cccc c + def test_QA_iban_valid_no_spaces(self): + iban = 'QA58DOHB00001234567890ABCDEFG' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 29, EntityRecognizer.MAX_SCORE) + + def test_QA_iban_valid_with_spaces(self): + iban = 'QA58 DOHB 0000 1234 5678 90AB CDEF G' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 36, EntityRecognizer.MAX_SCORE) + + def test_QA_iban_invalid_format_valid_checksum(self): + iban = 'QA58 0OHB 0000 1234 5678 90AB CDEF G' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_QA_iban_valid_checksum(self): + iban = 'QA58 DOHB 0000 1234 5678 90AB CDEF H' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + #### Reunion + + # Romania (4a,16c)  ROkk bbbb cccc cccc cccc cccc + def test_RO_iban_valid_no_spaces(self): + iban = 'RO49AAAA1B31007593840000' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_RO_iban_valid_with_spaces(self): + iban = 'RO49 AAAA 1B31 0075 9384 0000' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 29, EntityRecognizer.MAX_SCORE) + + def test_RO_iban_invalid_format_valid_checksum(self): + iban = 'RO49 0AAA 1B31 0075 9384 0000' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_RO_iban_valid_checksum(self): + iban = 'RO49 AAAA 1B31 0075 9384 0001' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + ### Saint Barthelemy + ### Saint Lucia + ### Saint Martin + ### Saint Pierrer + + # San Marino (1a,10n,12c)  SMkk xbbb bbss sssc cccc cccc ccc + def test_SM_iban_valid_no_spaces(self): + iban = 'SM86U0322509800000000270100' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 27, EntityRecognizer.MAX_SCORE) + + def test_SM_iban_valid_with_spaces(self): + iban = 'SM86 U032 2509 8000 0000 0270 100' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 33, EntityRecognizer.MAX_SCORE) + + def test_SM_iban_invalid_format_valid_checksum(self): + iban = 'SM86 0032 2509 8000 0000 0270 100' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_SM_iban_valid_checksum(self): + iban = 'SM86 U032 2509 8000 0000 0270 101' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + ### Sao Tome + + # Saudi Arabia (2n,18c)  SAkk bbcc cccc cccc cccc cccc + def test_SA_iban_valid_no_spaces(self): + iban = 'SA0380000000608010167519' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_SA_iban_valid_with_spaces(self): + iban = 'SA03 8000 0000 6080 1016 7519' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 29, EntityRecognizer.MAX_SCORE) + + def test_SA_iban_invalid_format_valid_checksum(self): + iban = 'SA03 A000 0000 6080 1016 7519' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_SA_iban_valid_checksum(self): + iban = 'SA03 8000 0000 6080 1016 7510' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Serbia (18n) RSkk bbbc cccc cccc cccc xx + def test_RS_iban_valid_no_spaces(self): + iban = 'RS35260005601001611379' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 22, EntityRecognizer.MAX_SCORE) + + def test_RS_iban_valid_with_spaces(self): + iban = 'RS35 2600 0560 1001 6113 79' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 27, EntityRecognizer.MAX_SCORE) + + def test_RS_iban_invalid_format_valid_checksum(self): + iban = 'RS35 A600 0560 1001 6113 79' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_RS_iban_valid_checksum(self): + iban = 'RS35 2600 0560 1001 6113 70' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Slovakia (20n) SKkk bbbb ssss sscc cccc cccc + def test_RS_iban_valid_no_spaces(self): + iban = 'SK3112000000198742637541' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_RS_iban_valid_with_spaces(self): + iban = 'SK31 1200 0000 1987 4263 7541' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 29, EntityRecognizer.MAX_SCORE) + + def test_RS_iban_invalid_format_valid_checksum(self): + iban = 'SK31 A200 0000 1987 4263 7541' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_RS_iban_valid_checksum(self): + iban = 'SK31 1200 0000 1987 4263 7542' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Slovenia (15n) SIkk bbss sccc cccc cxx + def test_SI_iban_valid_no_spaces(self): + iban = 'SI56263300012039086' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 19, EntityRecognizer.MAX_SCORE) + + def test_SI_iban_valid_with_spaces(self): + iban = 'SI56 2633 0001 2039 086' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 23, EntityRecognizer.MAX_SCORE) + + def test_SI_iban_invalid_format_valid_checksum(self): + iban = 'SI56 A633 0001 2039 086' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_SI_iban_valid_checksum(self): + iban = 'SI56 2633 0001 2039 087' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Spain (20n) ESkk bbbb ssss xxcc cccc cccc + def test_ES_iban_valid_no_spaces(self): + iban = 'ES9121000418450200051332' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_ES_iban_valid_with_spaces(self): + iban = 'ES91 2100 0418 4502 0005 1332' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 29, EntityRecognizer.MAX_SCORE) + + def test_ES_iban_invalid_format_valid_checksum(self): + iban = 'ES91 A100 0418 4502 0005 1332' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_ES_iban_valid_checksum(self): + iban = 'ES91 2100 0418 4502 0005 1333' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Sweden (20n) SEkk bbbc cccc cccc cccc cccc + def test_SE_iban_valid_no_spaces(self): + iban = 'SE4550000000058398257466' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_SE_iban_valid_with_spaces(self): + iban = 'SE45 5000 0000 0583 9825 7466' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 29, EntityRecognizer.MAX_SCORE) + + def test_SE_iban_invalid_format_valid_checksum(self): + iban = 'SE45 A000 0000 0583 9825 7466' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_SE_iban_valid_checksum(self): + iban = 'SE45 5000 0000 0583 9825 7467' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Switzerland (5n,12c)  CHkk bbbb bccc cccc cccc c + def test_CH_iban_valid_no_spaces(self): + iban = 'CH9300762011623852957' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 21, EntityRecognizer.MAX_SCORE) + + def test_CH_iban_valid_with_spaces(self): + iban = 'CH93 0076 2011 6238 5295 7' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 26, EntityRecognizer.MAX_SCORE) + + def test_CH_iban_invalid_format_valid_checksum(self): + iban = 'CH93 A076 2011 6238 5295 7' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_CH_iban_valid_checksum(self): + iban = 'CH93 0076 2011 6238 5295 8' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Tunisia (20n) TNkk bbss sccc cccc cccc cccc + def test_TN_iban_valid_no_spaces(self): + iban = 'TN5910006035183598478831' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_TN_iban_valid_with_spaces(self): + iban = 'TN59 1000 6035 1835 9847 8831' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 29, EntityRecognizer.MAX_SCORE) + + def test_TN_iban_invalid_format_valid_checksum(self): + iban = 'TN59 A000 6035 1835 9847 8831' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_TN_iban_valid_checksum(self): + iban = 'CH93 0076 2011 6238 5295 9' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Turkey (5n,17c)  TRkk bbbb bxcc cccc cccc cccc cc + def test_TR_iban_valid_no_spaces(self): + iban = 'TR330006100519786457841326' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 26, EntityRecognizer.MAX_SCORE) + + def test_TR_iban_valid_with_spaces(self): + iban = 'TR33 0006 1005 1978 6457 8413 26' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 32, EntityRecognizer.MAX_SCORE) + + def test_TR_iban_invalid_format_valid_checksum(self): + iban = 'TR33 A006 1005 1978 6457 8413 26' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_TR_iban_valid_checksum(self): + iban = 'TR33 0006 1005 1978 6457 8413 27' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # United Arab Emirates (3n,16n)  AEkk bbbc cccc cccc cccc ccc + def test_AE_iban_valid_no_spaces(self): + iban = 'AE070331234567890123456' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 23, EntityRecognizer.MAX_SCORE) + + def test_AE_iban_valid_with_spaces(self): + iban = 'AE07 0331 2345 6789 0123 456' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 28, EntityRecognizer.MAX_SCORE) + + def test_AE_iban_invalid_format_valid_checksum(self): + iban = 'AE07 A331 2345 6789 0123 456' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_AE_iban_valid_checksum(self): + iban = 'AE07 0331 2345 6789 0123 457' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # United Kingdom (4a,14n) GBkk bbbb ssss sscc cccc cc + def test_GB_iban_valid_no_spaces(self): + iban = 'GB29NWBK60161331926819' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 22, EntityRecognizer.MAX_SCORE) + + def test_GB_iban_valid_with_spaces(self): + iban = 'GB29 NWBK 6016 1331 9268 19' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 27, EntityRecognizer.MAX_SCORE) + + def test_GB_iban_invalid_format_valid_checksum(self): + iban = 'GB29 1WBK 6016 1331 9268 19' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_GB_iban_valid_checksum(self): + iban = 'GB29 NWBK 6016 1331 9268 10' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Vatican City (3n,15n)  VAkk bbbc cccc cccc cccc cc + def test_VA_iban_valid_no_spaces(self): + iban = 'VA59001123000012345678' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 22, EntityRecognizer.MAX_SCORE) + + def test_VA_iban_valid_with_spaces(self): + iban = 'VA59 0011 2300 0012 3456 78' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 27, EntityRecognizer.MAX_SCORE) + + def test_VA_iban_invalid_format_valid_checksum(self): + iban = 'VA59 A011 2300 0012 3456 78' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_VA_iban_valid_checksum(self): + iban = 'VA59 0011 2300 0012 3456 79' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + # Virgin Islands, British (4c,16n)  VGkk bbbb cccc cccc cccc cccc + def test_VG_iban_valid_no_spaces(self): + iban = 'VG96VPVG0000012345678901' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 1 + assert_result(results[0], entities[0], 0, 24, EntityRecognizer.MAX_SCORE) + + def test_VG_iban_valid_with_spaces(self): + iban = 'VG96 VPVG 0000 0123 4567 8901' + results = iban_recognizer.analyze(iban, entities) assert len(results) == 1 - assert_result(results[0], entities[0], 18, 41, 0) + assert_result(results[0], entities[0], 0, 29, EntityRecognizer.MAX_SCORE) + + def test_VG_iban_invalid_format_valid_checksum(self): + iban = 'VG96 VPVG A000 0123 4567 8901' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_VG_iban_valid_checksum(self): + iban = 'VG96 VPVG 0000 0123 4567 8902' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + +# Test Invalid IBANs     + def test_iban_invalid_country_code_invalid_checksum(self): + iban = 'AB150120690000003111141' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_iban_invalid_country_code_valid_checksum(self): + iban = 'AB150120690000003111141' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_iban_too_short_valid_checksum(self): + iban = 'IL15 0120 6900 0000' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_iban_too_long_valid_checksum(self): + iban = 'IL15 0120 6900 0000 3111 0120 6900 0000 3111 141' + iban = update_iban_checksum(iban) + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 + + def test_invalid_IL_iban_with_exact_context_does_not_change_score(self): + iban = 'IL150120690000003111141' + context = 'my iban number is ' + results = iban_recognizer.analyze(context + iban, entities) + + assert len(results) == 0 + + def test_AL_iban_invalid_country_code_but_checksum_is_correct(self): + iban = 'AM47212110090000000235698740' + results = iban_recognizer.analyze(iban, entities) + + assert len(results) == 0 diff --git a/presidio-analyzer/tests/test_spacy_recognizer.py b/presidio-analyzer/tests/test_spacy_recognizer.py index fc771d816..932de4cd6 100644 --- a/presidio-analyzer/tests/test_spacy_recognizer.py +++ b/presidio-analyzer/tests/test_spacy_recognizer.py @@ -2,6 +2,7 @@ from assertions import assert_result, assert_result_within_score_range from analyzer.predefined_recognizers import SpacyRecognizer +from analyzer.entity_recognizer import EntityRecognizer NER_STRENGTH = 0.85 spacy_recognizer = SpacyRecognizer() @@ -26,7 +27,7 @@ def test_person_first_name_with_context(self): results = spacy_recognizer.analyze('{} {}'.format(context, name), entities) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[0], 11, 14, NER_STRENGTH, 1) + assert_result_within_score_range(results[0], entities[0], 11, 14, NER_STRENGTH, EntityRecognizer.MAX_SCORE) def test_person_full_name(self): name = 'Dan Tailor' @@ -41,7 +42,7 @@ def test_person_full_name_with_context(self): results = spacy_recognizer.analyze('{} {}'.format(name, context), entities) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[0], 0, 11, NER_STRENGTH, 1) + assert_result_within_score_range(results[0], entities[0], 0, 11, NER_STRENGTH, EntityRecognizer.MAX_SCORE) def test_person_last_name(self): name = 'Tailor' @@ -66,8 +67,8 @@ def test_person_last_name(self): # results = spacy_recognizer.analyze('{} {}'.format(context, name), entities) # assert len(results) == 2 - # assert_result_within_score_range(results[1], entities[1], 5, 12, NER_STRENGTH, 1) - # assert_result_within_score_range(results[0], entities[0], 17, 23, NER_STRENGTH, 1) + # assert_result_within_score_range(results[1], entities[1], 5, 12, NER_STRENGTH, EntityRecognizer.MAX_SCORE) + # assert_result_within_score_range(results[0], entities[0], 17, 23, NER_STRENGTH, EntityRecognizer.MAX_SCORE) def test_person_full_middle_name(self): name = 'Richard Milhous Nixon' @@ -105,7 +106,7 @@ def test_person_last_name_is_also_a_date_with_context_expected_person_only(self) results = spacy_recognizer.analyze('{} {}'.format(name, context), entities) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[0], 0, 7, NER_STRENGTH, 1) + assert_result_within_score_range(results[0], entities[0], 0, 7, NER_STRENGTH, EntityRecognizer.MAX_SCORE) # Bug #617 : Spacy Recognizer doesn't recognize Mr. May as PERSON even though online spacy demo indicates that it does # See http://textanalysisonline.com/spacy-named-entity-recognition-ner @@ -124,7 +125,7 @@ def test_person_last_name_is_also_a_date_with_context_expected_person_only(self) # results = spacy_recognizer.analyze('{} {}'.format(context, name), entities) # assert len(results) == 1 - # assert_result_within_score_range(results[0], entities[0], 17, 20, NER_STRENGTH, 1) + # assert_result_within_score_range(results[0], entities[0], 17, 20, NER_STRENGTH, EntityRecognizer.MAX_SCORE) #Test DATE_TIME Entity def test_date_time_year(self): @@ -140,7 +141,7 @@ def test_date_time_year_with_context(self): results = spacy_recognizer.analyze('{} {}'.format(context, date), entities) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[1], 19, 23, NER_STRENGTH, 1) + assert_result_within_score_range(results[0], entities[1], 19, 23, NER_STRENGTH, EntityRecognizer.MAX_SCORE) # Bug #617 : Spacy Recognizer doesn't recognize May as DATE_TIME even though online spacy demo indicates that it does # See http://textanalysisonline.com/spacy-named-entity-recognition-ner @@ -149,7 +150,7 @@ def test_date_time_year_with_context(self): # results = spacy_recognizer.analyze(date, entities) # assert len(results) == 1 - # assert_result_within_score_range(results[0], entities[1], 0, 3, NER_STRENGTH, 1) + # assert_result_within_score_range(results[0], entities[1], 0, 3, NER_STRENGTH, EntityRecognizer.MAX_SCORE) def test_date_time_month_with_context(self): date = 'May' @@ -157,14 +158,14 @@ def test_date_time_month_with_context(self): results = spacy_recognizer.analyze('{} {}'.format(context, date), entities) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[1], 19, 22, NER_STRENGTH, 1) + assert_result_within_score_range(results[0], entities[1], 19, 22, NER_STRENGTH, EntityRecognizer.MAX_SCORE) def test_date_time_day_in_month(self): date = 'May 1st' results = spacy_recognizer.analyze(date, entities) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[1], 0, 7, NER_STRENGTH, 1) + assert_result_within_score_range(results[0], entities[1], 0, 7, NER_STRENGTH, EntityRecognizer.MAX_SCORE) def test_date_time_day_in_month_with_context(self): date = 'May 1st' @@ -172,14 +173,14 @@ def test_date_time_day_in_month_with_context(self): results = spacy_recognizer.analyze('{} {}'.format(context, date), entities) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[1], 19, 26, NER_STRENGTH, 1) + assert_result_within_score_range(results[0], entities[1], 19, 26, NER_STRENGTH, EntityRecognizer.MAX_SCORE) def test_date_time_full_date(self): date = 'May 1st, 1977' results = spacy_recognizer.analyze(date, entities) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[1], 0, 13, NER_STRENGTH, 1) + assert_result_within_score_range(results[0], entities[1], 0, 13, NER_STRENGTH, EntityRecognizer.MAX_SCORE) def test_date_time_day_in_month_with_context(self): date = 'May 1st, 1977' @@ -187,4 +188,4 @@ def test_date_time_day_in_month_with_context(self): results = spacy_recognizer.analyze('{} {}'.format(context, date), entities) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[1], 19, 32, NER_STRENGTH, 1) + assert_result_within_score_range(results[0], entities[1], 19, 32, NER_STRENGTH, EntityRecognizer.MAX_SCORE) diff --git a/presidio-analyzer/tests/test_uk_nhs_recognizer.py b/presidio-analyzer/tests/test_uk_nhs_recognizer.py index bc6a2943d..fb87d393b 100644 --- a/presidio-analyzer/tests/test_uk_nhs_recognizer.py +++ b/presidio-analyzer/tests/test_uk_nhs_recognizer.py @@ -2,6 +2,7 @@ from assertions import assert_result from analyzer.predefined_recognizers import NhsRecognizer +from analyzer.entity_recognizer import EntityRecognizer nhs_recognizer = NhsRecognizer() entities = ["UK_NHS"] @@ -34,9 +35,4 @@ def test_invalid_uk_nhs(self): num = '401-023-2138' results = nhs_recognizer.analyze(num, entities) - assert len(results) == 1 - - assert results[0].score == 0 - assert results[0].start == 0 - assert results[0].end == 12 - assert results[0].entity_type == entities[0] + assert len(results) == 0 diff --git a/presidio-analyzer/tests/test_us_phone_recognizer.py b/presidio-analyzer/tests/test_us_phone_recognizer.py index a19e2f24f..7fa134b35 100644 --- a/presidio-analyzer/tests/test_us_phone_recognizer.py +++ b/presidio-analyzer/tests/test_us_phone_recognizer.py @@ -2,6 +2,7 @@ from assertions import assert_result_within_score_range from analyzer.predefined_recognizers import UsPhoneRecognizer +from analyzer.entity_recognizer import EntityRecognizer phone_recognizer = UsPhoneRecognizer() entities = ["PHONE_NUMBER"] @@ -15,7 +16,7 @@ def test_phone_number_strong_match_no_context(self): assert len(results) == 1 assert results[0].score != 1 - assert_result_within_score_range(results[0], entities[0], 0, 14, 0.7, 1) + assert_result_within_score_range(results[0], entities[0], 0, 14, 0.7, EntityRecognizer.MAX_SCORE) # TODO: enable with task #582 re-support context model in analyzer # def test_phone_number_strong_match_with_phone_context(self): From 45f7a4edfe6816a3f1490431e9a395c0b205a2f2 Mon Sep 17 00:00:00 2001 From: Itye Richter Date: Thu, 28 Mar 2019 10:46:04 +0200 Subject: [PATCH 10/75] All fields (#107) support for requesting all fields (entities) and language refactor --- Gopkg.lock | 8 +- presidio-analyzer/analyzer/analyzer_engine.py | 12 +- .../recognizer_registry.py | 38 +- presidio-analyzer/analyzer/template_pb2.py | 401 +++++++++--------- .../tests/test_analyzer_engine.py | 39 +- .../tests/test_recognizer_registry.py | 4 +- .../presidio-api/api/analyze/analyze_test.go | 2 +- .../cmd/presidio-api/api/mocks/mocks.go | 2 +- 8 files changed, 264 insertions(+), 242 deletions(-) diff --git a/Gopkg.lock b/Gopkg.lock index 14d77ff02..36aecbf32 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -81,7 +81,7 @@ version = "0.4.0" [[projects]] - digest = "1:ef17fa8a0edc01cb33eefed09d6865064ebdcc74ceef1637693bc466c708deac" + digest = "1:9e8ebf3883dce8687221c4022bdb3b0bfdcde430b911be9cc7e52478a026893c" name = "github.com/Azure/go-autorest" packages = [ "autorest", @@ -106,12 +106,12 @@ version = "v1.3.5" [[projects]] - branch = "master" - digest = "1:09d02863ffab900bdd5433a84148021ffdb309eda6949e111ae9fd3fd31985e3" + branch = "development" + digest = "1:d020c7bac116dd1a01523be75d6402bab0665a7aadb89fb79a11bfbfb0536e56" name = "github.com/Microsoft/presidio-genproto" packages = ["golang"] pruneopts = "UT" - revision = "fb5d00d4a6df7f7e468b2cb1b9375f614d41a7b7" + revision = "bdbe699528941d1a17ad1c9d015ed144c9bff022" [[projects]] digest = "1:a59a467c541a1bf8b06e4fad6113028c959be6573b78ceca9f8020cd0d2127fc" diff --git a/presidio-analyzer/analyzer/analyzer_engine.py b/presidio-analyzer/analyzer/analyzer_engine.py index c721d7850..320d9f633 100644 --- a/presidio-analyzer/analyzer/analyzer_engine.py +++ b/presidio-analyzer/analyzer/analyzer_engine.py @@ -54,7 +54,8 @@ def Apply(self, request, context): entities = AnalyzerEngine.__convert_fields_to_entities( request.analyzeTemplate.fields) language = AnalyzerEngine.get_language_from_request(request) - results = self.analyze(request.text, entities, language) + results = self.analyze(request.text, entities, language, + request.analyzeTemplate.allFields) # Create Analyze Response Object response = analyze_pb2.AnalyzeResponse() @@ -67,22 +68,25 @@ def Apply(self, request, context): @classmethod def get_language_from_request(cls, request): - language = request.analyzeTemplate.languageCode + language = request.analyzeTemplate.language if language is None or language == "": language = DEFAULT_LANGUAGE return language - def analyze(self, text, entities, language): + def analyze(self, text, entities, language, all_fields): """ analyzes the requested text, searching for the given entities in the given language :param text: the text to analyze :param entities: the text to search :param language: the language of the text + :param all_fields: a Flag to return all fields + of the requested language :return: an array of the found entities in the text """ recognizers = self.registry.get_recognizers(language=language, - entities=entities) + entities=entities, + all_fields=all_fields) results = [] for recognizer in recognizers: diff --git a/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py b/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py index 9db71180d..2cb996c12 100644 --- a/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py +++ b/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py @@ -64,38 +64,38 @@ def remove_recognizer(self, name): if not found: raise ValueError("Requested recognizer was not found") - def get_recognizers(self, entities=None, language=None): + def get_recognizers(self, language, entities=None, all_fields=False): """ Returns a list of the recognizer, which supports the specified name and - language. if no language and entities are given, all the available - recognizers will be returned + language. :param entities: the requested entities :param language: the requested language + :param all_fields: a flag to return all fields of a requested language. :return: A list of the recognizers which supports the supplied entities and language """ - if language is None and entities is None: - return self.recognizers - if language is None: raise ValueError("No language provided") - if entities is None: + if entities is None and all_fields is False: raise ValueError("No entities provided") to_return = [] - for entity in entities: - subset = [rec for rec in self.recognizers if - entity in rec.supported_entities - and language == rec.supported_language] - - if not subset: - logging.warning( - "Entity " + entity + - " doesn't have the corresponding recognizer in language :" - + language) - else: - to_return.extend(subset) + if all_fields: + to_return = [rec for rec in self.recognizers if + language == rec.supported_language] + else: + for entity in entities: + subset = [rec for rec in self.recognizers if + entity in rec.supported_entities + and language == rec.supported_language] + + if not subset: + logging.warning("Entity %s doesn't have the corresponding" + " recognizer in language : %s", + entity, language) + else: + to_return.extend(subset) if not to_return: raise ValueError( diff --git a/presidio-analyzer/analyzer/template_pb2.py b/presidio-analyzer/analyzer/template_pb2.py index 4f9309f7c..2bf704b0b 100644 --- a/presidio-analyzer/analyzer/template_pb2.py +++ b/presidio-analyzer/analyzer/template_pb2.py @@ -7,7 +7,6 @@ from google.protobuf import message as _message from google.protobuf import reflection as _reflection from google.protobuf import symbol_database as _symbol_database -from google.protobuf import descriptor_pb2 # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() @@ -20,7 +19,8 @@ name='template.proto', package='types', syntax='proto3', - serialized_pb=_b('\n\x0etemplate.proto\x12\x05types\x1a\x0c\x63ommon.proto\"\x89\x01\n\x0f\x41nalyzeTemplate\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x12\n\ncreateTime\x18\x03 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x04 \x01(\t\x12\x14\n\x0clanguageCode\x18\x05 \x01(\t\"\xca\x01\n\x11\x41nonymizeTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12@\n\x18\x66ieldTypeTransformations\x18\x04 \x03(\x0b\x32\x1e.types.FieldTypeTransformation\x12\x34\n\x15\x64\x65\x66\x61ultTransformation\x18\x05 \x01(\x0b\x32\x15.types.Transformation\"g\n\x12JsonSchemaTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12\x12\n\njsonSchema\x18\x04 \x01(\t\"k\n\x17\x46ieldTypeTransformation\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12-\n\x0etransformation\x18\x02 \x01(\x0b\x32\x15.types.Transformation\"\xd1\x01\n\x0eTransformation\x12)\n\x0creplaceValue\x18\x02 \x01(\x0b\x32\x13.types.ReplaceValue\x12\'\n\x0bredactValue\x18\x03 \x01(\x0b\x32\x12.types.RedactValue\x12#\n\thashValue\x18\x04 \x01(\x0b\x32\x10.types.HashValue\x12#\n\tmaskValue\x18\x05 \x01(\x0b\x32\x10.types.MaskValue\x12!\n\x08\x66PEValue\x18\x06 \x01(\x0b\x32\x0f.types.FPEValue\" \n\x0cReplaceValue\x12\x10\n\x08newValue\x18\x01 \x01(\t\"\r\n\x0bRedactValue\"\x0b\n\tHashValue\"K\n\tMaskValue\x12\x18\n\x10maskingCharacter\x18\x01 \x01(\t\x12\x13\n\x0b\x63harsToMask\x18\x02 \x01(\x05\x12\x0f\n\x07\x66romEnd\x18\x03 \x01(\x08\"7\n\x08\x46PEValue\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05tweak\x18\x02 \x01(\t\x12\x0f\n\x07\x64\x65\x63rypt\x18\x03 \x01(\x08\"E\n\x08\x44\x42\x43onfig\x12\x18\n\x10\x63onnectionString\x18\x01 \x01(\t\x12\x11\n\ttableName\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\"\x8f\x01\n\x08\x44\x61tasink\x12!\n\x08\x64\x62\x43onfig\x18\x01 \x01(\x0b\x32\x0f.types.DBConfig\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\"}\n\x10\x44\x61tasinkTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12(\n\x0f\x61nalyzeDatasink\x18\x02 \x03(\x0b\x32\x0f.types.Datasink\x12*\n\x11\x61nonymizeDatasink\x18\x03 \x03(\x0b\x32\x0f.types.Datasink\"S\n\x11\x42lobStorageConfig\x12\x13\n\x0b\x61\x63\x63ountName\x18\x01 \x01(\t\x12\x12\n\naccountKey\x18\x02 \x01(\t\x12\x15\n\rcontainerName\x18\x03 \x01(\t\"e\n\x08S3Config\x12\x10\n\x08\x61\x63\x63\x65ssId\x18\x01 \x01(\t\x12\x11\n\taccessKey\x18\x02 \x01(\t\x12\x0e\n\x06region\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\x12\x10\n\x08\x65ndpoint\x18\x05 \x01(\t\"Z\n\x13GoogleStorageConfig\x12\x0c\n\x04json\x18\x01 \x01(\t\x12\x11\n\tprojectId\x18\x02 \x01(\t\x12\x0e\n\x06scopes\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\"\xa5\x01\n\x12\x43loudStorageConfig\x12\x33\n\x11\x62lobStorageConfig\x18\x01 \x01(\x0b\x32\x18.types.BlobStorageConfig\x12!\n\x08s3Config\x18\x02 \x01(\x0b\x32\x0f.types.S3Config\x12\x37\n\x13GoogleStorageConfig\x18\x03 \x01(\x0b\x32\x1a.types.GoogleStorageConfig\"r\n\x0cStreamConfig\x12\'\n\x0bkafkaConfig\x18\x01 \x01(\x0b\x32\x12.types.KafkaConfig\x12!\n\x08\x65hConfig\x18\x02 \x01(\x0b\x32\x0f.types.EHConfig\x12\x16\n\x0epartitionCount\x18\x03 \x01(\x05\"Y\n\x0bKafkaConfig\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x14\n\x0csaslUsername\x18\x03 \x01(\t\x12\x14\n\x0csaslPassword\x18\x04 \x01(\t\"\xcb\x01\n\x08\x45HConfig\x12\x13\n\x0b\x65hNamespace\x18\x01 \x01(\t\x12\x0e\n\x06\x65hName\x18\x02 \x01(\t\x12\x1a\n\x12\x65hConnectionString\x18\x03 \x01(\t\x12\x11\n\tehKeyName\x18\x04 \x01(\t\x12\x12\n\nehKeyValue\x18\x05 \x01(\t\x12\x1f\n\x17storageAccountNameValue\x18\x06 \x01(\t\x12\x1e\n\x16storageAccountKeyValue\x18\x07 \x01(\t\x12\x16\n\x0e\x63ontainerValue\x18\x08 \x01(\t\"\xb2\x01\n\x0eStreamTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\"Z\n\x0cScanTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\"\xc8\x01\n\x16ScannerCronJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x1f\n\x07trigger\x18\x03 \x01(\x0b\x32\x0e.types.Trigger\x12\x16\n\x0escanTemplateId\x18\x04 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x05 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x06 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x07 \x01(\t\"\xa6\x01\n\x12StreamsJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x19\n\x11streamsTemplateId\x18\x03 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\",\n\x07Trigger\x12!\n\x08schedule\x18\x01 \x01(\x0b\x32\x0f.types.Schedule\"$\n\x08Schedule\x12\x18\n\x10recurrencePeriod\x18\x01 \x01(\t\"\x8b\x01\n\x16\x41nonymizeImageTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12\x32\n\x11\x66ieldTypeGraphics\x18\x04 \x03(\x0b\x32\x17.types.FieldTypeGraphic\"V\n\x10\x46ieldTypeGraphic\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12\x1f\n\x07graphic\x18\x02 \x01(\x0b\x32\x0e.types.Graphic\"8\n\x07Graphic\x12-\n\x0e\x66illColorValue\x18\x01 \x01(\x0b\x32\x15.types.FillColorValue\":\n\x0e\x46illColorValue\x12\x0b\n\x03red\x18\x01 \x01(\x01\x12\r\n\x05green\x18\x02 \x01(\x01\x12\x0c\n\x04\x62lue\x18\x03 \x01(\x01\x62\x06proto3') + serialized_options=None, + serialized_pb=_b('\n\x0etemplate.proto\x12\x05types\x1a\x0c\x63ommon.proto\"\x98\x01\n\x0f\x41nalyzeTemplate\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12\x11\n\tallFields\x18\x02 \x01(\x08\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\x12\x12\n\ncreateTime\x18\x04 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x05 \x01(\t\x12\x10\n\x08language\x18\x06 \x01(\t\"\xca\x01\n\x11\x41nonymizeTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12@\n\x18\x66ieldTypeTransformations\x18\x04 \x03(\x0b\x32\x1e.types.FieldTypeTransformation\x12\x34\n\x15\x64\x65\x66\x61ultTransformation\x18\x05 \x01(\x0b\x32\x15.types.Transformation\"g\n\x12JsonSchemaTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12\x12\n\njsonSchema\x18\x04 \x01(\t\"k\n\x17\x46ieldTypeTransformation\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12-\n\x0etransformation\x18\x02 \x01(\x0b\x32\x15.types.Transformation\"\xd1\x01\n\x0eTransformation\x12)\n\x0creplaceValue\x18\x02 \x01(\x0b\x32\x13.types.ReplaceValue\x12\'\n\x0bredactValue\x18\x03 \x01(\x0b\x32\x12.types.RedactValue\x12#\n\thashValue\x18\x04 \x01(\x0b\x32\x10.types.HashValue\x12#\n\tmaskValue\x18\x05 \x01(\x0b\x32\x10.types.MaskValue\x12!\n\x08\x66PEValue\x18\x06 \x01(\x0b\x32\x0f.types.FPEValue\" \n\x0cReplaceValue\x12\x10\n\x08newValue\x18\x01 \x01(\t\"\r\n\x0bRedactValue\"\x0b\n\tHashValue\"K\n\tMaskValue\x12\x18\n\x10maskingCharacter\x18\x01 \x01(\t\x12\x13\n\x0b\x63harsToMask\x18\x02 \x01(\x05\x12\x0f\n\x07\x66romEnd\x18\x03 \x01(\x08\"7\n\x08\x46PEValue\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05tweak\x18\x02 \x01(\t\x12\x0f\n\x07\x64\x65\x63rypt\x18\x03 \x01(\x08\"E\n\x08\x44\x42\x43onfig\x12\x18\n\x10\x63onnectionString\x18\x01 \x01(\t\x12\x11\n\ttableName\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\"\x8f\x01\n\x08\x44\x61tasink\x12!\n\x08\x64\x62\x43onfig\x18\x01 \x01(\x0b\x32\x0f.types.DBConfig\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\"}\n\x10\x44\x61tasinkTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12(\n\x0f\x61nalyzeDatasink\x18\x02 \x03(\x0b\x32\x0f.types.Datasink\x12*\n\x11\x61nonymizeDatasink\x18\x03 \x03(\x0b\x32\x0f.types.Datasink\"S\n\x11\x42lobStorageConfig\x12\x13\n\x0b\x61\x63\x63ountName\x18\x01 \x01(\t\x12\x12\n\naccountKey\x18\x02 \x01(\t\x12\x15\n\rcontainerName\x18\x03 \x01(\t\"e\n\x08S3Config\x12\x10\n\x08\x61\x63\x63\x65ssId\x18\x01 \x01(\t\x12\x11\n\taccessKey\x18\x02 \x01(\t\x12\x0e\n\x06region\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\x12\x10\n\x08\x65ndpoint\x18\x05 \x01(\t\"Z\n\x13GoogleStorageConfig\x12\x0c\n\x04json\x18\x01 \x01(\t\x12\x11\n\tprojectId\x18\x02 \x01(\t\x12\x0e\n\x06scopes\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\"\xa5\x01\n\x12\x43loudStorageConfig\x12\x33\n\x11\x62lobStorageConfig\x18\x01 \x01(\x0b\x32\x18.types.BlobStorageConfig\x12!\n\x08s3Config\x18\x02 \x01(\x0b\x32\x0f.types.S3Config\x12\x37\n\x13GoogleStorageConfig\x18\x03 \x01(\x0b\x32\x1a.types.GoogleStorageConfig\"r\n\x0cStreamConfig\x12\'\n\x0bkafkaConfig\x18\x01 \x01(\x0b\x32\x12.types.KafkaConfig\x12!\n\x08\x65hConfig\x18\x02 \x01(\x0b\x32\x0f.types.EHConfig\x12\x16\n\x0epartitionCount\x18\x03 \x01(\x05\"Y\n\x0bKafkaConfig\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x14\n\x0csaslUsername\x18\x03 \x01(\t\x12\x14\n\x0csaslPassword\x18\x04 \x01(\t\"\xcb\x01\n\x08\x45HConfig\x12\x13\n\x0b\x65hNamespace\x18\x01 \x01(\t\x12\x0e\n\x06\x65hName\x18\x02 \x01(\t\x12\x1a\n\x12\x65hConnectionString\x18\x03 \x01(\t\x12\x11\n\tehKeyName\x18\x04 \x01(\t\x12\x12\n\nehKeyValue\x18\x05 \x01(\t\x12\x1f\n\x17storageAccountNameValue\x18\x06 \x01(\t\x12\x1e\n\x16storageAccountKeyValue\x18\x07 \x01(\t\x12\x16\n\x0e\x63ontainerValue\x18\x08 \x01(\t\"\xb2\x01\n\x0eStreamTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\"Z\n\x0cScanTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\"\xc8\x01\n\x16ScannerCronJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x1f\n\x07trigger\x18\x03 \x01(\x0b\x32\x0e.types.Trigger\x12\x16\n\x0escanTemplateId\x18\x04 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x05 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x06 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x07 \x01(\t\"\xa6\x01\n\x12StreamsJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x19\n\x11streamsTemplateId\x18\x03 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\",\n\x07Trigger\x12!\n\x08schedule\x18\x01 \x01(\x0b\x32\x0f.types.Schedule\"$\n\x08Schedule\x12\x18\n\x10recurrencePeriod\x18\x01 \x01(\t\"\x8b\x01\n\x16\x41nonymizeImageTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12\x32\n\x11\x66ieldTypeGraphics\x18\x04 \x03(\x0b\x32\x17.types.FieldTypeGraphic\"V\n\x10\x46ieldTypeGraphic\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12\x1f\n\x07graphic\x18\x02 \x01(\x0b\x32\x0e.types.Graphic\"8\n\x07Graphic\x12-\n\x0e\x66illColorValue\x18\x01 \x01(\x0b\x32\x15.types.FillColorValue\":\n\x0e\x46illColorValue\x12\x0b\n\x03red\x18\x01 \x01(\x01\x12\r\n\x05green\x18\x02 \x01(\x01\x12\x0c\n\x04\x62lue\x18\x03 \x01(\x01\x62\x06proto3') , dependencies=[common__pb2.DESCRIPTOR,]) @@ -40,49 +40,56 @@ has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( - name='description', full_name='types.AnalyzeTemplate.description', index=1, - number=2, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=_b("").decode('utf-8'), + name='allFields', full_name='types.AnalyzeTemplate.allFields', index=1, + number=2, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( - name='createTime', full_name='types.AnalyzeTemplate.createTime', index=2, + name='description', full_name='types.AnalyzeTemplate.description', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( - name='modifiedTime', full_name='types.AnalyzeTemplate.modifiedTime', index=3, + name='createTime', full_name='types.AnalyzeTemplate.createTime', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( - name='languageCode', full_name='types.AnalyzeTemplate.languageCode', index=4, + name='modifiedTime', full_name='types.AnalyzeTemplate.modifiedTime', index=4, number=5, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='language', full_name='types.AnalyzeTemplate.language', index=5, + number=6, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], serialized_start=40, - serialized_end=177, + serialized_end=192, ) @@ -99,49 +106,49 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='createTime', full_name='types.AnonymizeTemplate.createTime', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='modifiedTime', full_name='types.AnonymizeTemplate.modifiedTime', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='fieldTypeTransformations', full_name='types.AnonymizeTemplate.fieldTypeTransformations', index=3, number=4, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='defaultTransformation', full_name='types.AnonymizeTemplate.defaultTransformation', index=4, number=5, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=180, - serialized_end=382, + serialized_start=195, + serialized_end=397, ) @@ -158,42 +165,42 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='createTime', full_name='types.JsonSchemaTemplate.createTime', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='modifiedTime', full_name='types.JsonSchemaTemplate.modifiedTime', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='jsonSchema', full_name='types.JsonSchemaTemplate.jsonSchema', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=384, - serialized_end=487, + serialized_start=399, + serialized_end=502, ) @@ -210,28 +217,28 @@ has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='transformation', full_name='types.FieldTypeTransformation.transformation', index=1, number=2, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=489, - serialized_end=596, + serialized_start=504, + serialized_end=611, ) @@ -248,49 +255,49 @@ has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='redactValue', full_name='types.Transformation.redactValue', index=1, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='hashValue', full_name='types.Transformation.hashValue', index=2, number=4, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='maskValue', full_name='types.Transformation.maskValue', index=3, number=5, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='fPEValue', full_name='types.Transformation.fPEValue', index=4, number=6, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=599, - serialized_end=808, + serialized_start=614, + serialized_end=823, ) @@ -307,21 +314,21 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=810, - serialized_end=842, + serialized_start=825, + serialized_end=857, ) @@ -338,14 +345,14 @@ nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=844, - serialized_end=857, + serialized_start=859, + serialized_end=872, ) @@ -362,14 +369,14 @@ nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=859, - serialized_end=870, + serialized_start=874, + serialized_end=885, ) @@ -386,35 +393,35 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='charsToMask', full_name='types.MaskValue.charsToMask', index=1, number=2, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='fromEnd', full_name='types.MaskValue.fromEnd', index=2, number=3, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=872, - serialized_end=947, + serialized_start=887, + serialized_end=962, ) @@ -431,35 +438,35 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='tweak', full_name='types.FPEValue.tweak', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='decrypt', full_name='types.FPEValue.decrypt', index=2, number=3, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=949, - serialized_end=1004, + serialized_start=964, + serialized_end=1019, ) @@ -476,35 +483,35 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='tableName', full_name='types.DBConfig.tableName', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='type', full_name='types.DBConfig.type', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=1006, - serialized_end=1075, + serialized_start=1021, + serialized_end=1090, ) @@ -521,35 +528,35 @@ has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='cloudStorageConfig', full_name='types.Datasink.cloudStorageConfig', index=1, number=2, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='streamConfig', full_name='types.Datasink.streamConfig', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=1078, - serialized_end=1221, + serialized_start=1093, + serialized_end=1236, ) @@ -566,35 +573,35 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='analyzeDatasink', full_name='types.DatasinkTemplate.analyzeDatasink', index=1, number=2, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='anonymizeDatasink', full_name='types.DatasinkTemplate.anonymizeDatasink', index=2, number=3, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=1223, - serialized_end=1348, + serialized_start=1238, + serialized_end=1363, ) @@ -611,35 +618,35 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='accountKey', full_name='types.BlobStorageConfig.accountKey', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='containerName', full_name='types.BlobStorageConfig.containerName', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=1350, - serialized_end=1433, + serialized_start=1365, + serialized_end=1448, ) @@ -656,49 +663,49 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='accessKey', full_name='types.S3Config.accessKey', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='region', full_name='types.S3Config.region', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='bucketName', full_name='types.S3Config.bucketName', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='endpoint', full_name='types.S3Config.endpoint', index=4, number=5, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=1435, - serialized_end=1536, + serialized_start=1450, + serialized_end=1551, ) @@ -715,42 +722,42 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='projectId', full_name='types.GoogleStorageConfig.projectId', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='scopes', full_name='types.GoogleStorageConfig.scopes', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='bucketName', full_name='types.GoogleStorageConfig.bucketName', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=1538, - serialized_end=1628, + serialized_start=1553, + serialized_end=1643, ) @@ -767,35 +774,35 @@ has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='s3Config', full_name='types.CloudStorageConfig.s3Config', index=1, number=2, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='GoogleStorageConfig', full_name='types.CloudStorageConfig.GoogleStorageConfig', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=1631, - serialized_end=1796, + serialized_start=1646, + serialized_end=1811, ) @@ -812,35 +819,35 @@ has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='ehConfig', full_name='types.StreamConfig.ehConfig', index=1, number=2, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='partitionCount', full_name='types.StreamConfig.partitionCount', index=2, number=3, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=1798, - serialized_end=1912, + serialized_start=1813, + serialized_end=1927, ) @@ -857,42 +864,42 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='topic', full_name='types.KafkaConfig.topic', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='saslUsername', full_name='types.KafkaConfig.saslUsername', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='saslPassword', full_name='types.KafkaConfig.saslPassword', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=1914, - serialized_end=2003, + serialized_start=1929, + serialized_end=2018, ) @@ -909,70 +916,70 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='ehName', full_name='types.EHConfig.ehName', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='ehConnectionString', full_name='types.EHConfig.ehConnectionString', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='ehKeyName', full_name='types.EHConfig.ehKeyName', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='ehKeyValue', full_name='types.EHConfig.ehKeyValue', index=4, number=5, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='storageAccountNameValue', full_name='types.EHConfig.storageAccountNameValue', index=5, number=6, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='storageAccountKeyValue', full_name='types.EHConfig.storageAccountKeyValue', index=6, number=7, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='containerValue', full_name='types.EHConfig.containerValue', index=7, number=8, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=2006, - serialized_end=2209, + serialized_start=2021, + serialized_end=2224, ) @@ -989,56 +996,56 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='description', full_name='types.StreamTemplate.description', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='streamConfig', full_name='types.StreamTemplate.streamConfig', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='analyzeTemplateId', full_name='types.StreamTemplate.analyzeTemplateId', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='anonymizeTemplateId', full_name='types.StreamTemplate.anonymizeTemplateId', index=4, number=5, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='datasinkTemplateId', full_name='types.StreamTemplate.datasinkTemplateId', index=5, number=6, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=2212, - serialized_end=2390, + serialized_start=2227, + serialized_end=2405, ) @@ -1055,28 +1062,28 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='cloudStorageConfig', full_name='types.ScanTemplate.cloudStorageConfig', index=1, number=2, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=2392, - serialized_end=2482, + serialized_start=2407, + serialized_end=2497, ) @@ -1093,63 +1100,63 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='description', full_name='types.ScannerCronJobTemplate.description', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='trigger', full_name='types.ScannerCronJobTemplate.trigger', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='scanTemplateId', full_name='types.ScannerCronJobTemplate.scanTemplateId', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='analyzeTemplateId', full_name='types.ScannerCronJobTemplate.analyzeTemplateId', index=4, number=5, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='anonymizeTemplateId', full_name='types.ScannerCronJobTemplate.anonymizeTemplateId', index=5, number=6, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='datasinkTemplateId', full_name='types.ScannerCronJobTemplate.datasinkTemplateId', index=6, number=7, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=2485, - serialized_end=2685, + serialized_start=2500, + serialized_end=2700, ) @@ -1166,56 +1173,56 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='description', full_name='types.StreamsJobTemplate.description', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='streamsTemplateId', full_name='types.StreamsJobTemplate.streamsTemplateId', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='analyzeTemplateId', full_name='types.StreamsJobTemplate.analyzeTemplateId', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='anonymizeTemplateId', full_name='types.StreamsJobTemplate.anonymizeTemplateId', index=4, number=5, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='datasinkTemplateId', full_name='types.StreamsJobTemplate.datasinkTemplateId', index=5, number=6, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=2688, - serialized_end=2854, + serialized_start=2703, + serialized_end=2869, ) @@ -1232,21 +1239,21 @@ has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=2856, - serialized_end=2900, + serialized_start=2871, + serialized_end=2915, ) @@ -1263,21 +1270,21 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=2902, - serialized_end=2938, + serialized_start=2917, + serialized_end=2953, ) @@ -1294,42 +1301,42 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='createTime', full_name='types.AnonymizeImageTemplate.createTime', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='modifiedTime', full_name='types.AnonymizeImageTemplate.modifiedTime', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='fieldTypeGraphics', full_name='types.AnonymizeImageTemplate.fieldTypeGraphics', index=3, number=4, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=2941, - serialized_end=3080, + serialized_start=2956, + serialized_end=3095, ) @@ -1346,28 +1353,28 @@ has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='graphic', full_name='types.FieldTypeGraphic.graphic', index=1, number=2, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=3082, - serialized_end=3168, + serialized_start=3097, + serialized_end=3183, ) @@ -1384,21 +1391,21 @@ has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=3170, - serialized_end=3226, + serialized_start=3185, + serialized_end=3241, ) @@ -1415,35 +1422,35 @@ has_default_value=False, default_value=float(0), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='green', full_name='types.FillColorValue.green', index=1, number=2, type=1, cpp_type=5, label=1, has_default_value=False, default_value=float(0), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='blue', full_name='types.FillColorValue.blue', index=2, number=3, type=1, cpp_type=5, label=1, has_default_value=False, default_value=float(0), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - options=None, file=DESCRIPTOR), + serialized_options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - options=None, + serialized_options=None, is_extendable=False, syntax='proto3', extension_ranges=[], oneofs=[ ], - serialized_start=3228, - serialized_end=3286, + serialized_start=3243, + serialized_end=3301, ) _ANALYZETEMPLATE.fields_by_name['fields'].message_type = common__pb2._FIELDTYPES diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index 1a8e625fc..abfe24351 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -8,7 +8,7 @@ RecognizerResult, RecognizerRegistry from analyzer.analyze_pb2 import AnalyzeRequest from analyzer.predefined_recognizers import CreditCardRecognizer, \ - UsPhoneRecognizer + UsPhoneRecognizer, DomainRecognizer class MockRecognizerRegistry(RecognizerRegistry): @@ -17,7 +17,8 @@ def load_recognizers(self, path): # Task #598: Support loading of the pre-defined recognizers # from the given path. self.recognizers.extend([CreditCardRecognizer(), - UsPhoneRecognizer()]) + UsPhoneRecognizer(), + DomainRecognizer()]) class TestAnalyzerEngine(TestCase): @@ -27,7 +28,7 @@ def test_analyze_with_single_predefined_recognizers(self): text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090" langauge = "en" entities = ["CREDIT_CARD"] - results = analyze_engine.analyze(text, entities, langauge) + results = analyze_engine.analyze(text, entities, langauge, all_fields=False) assert len(results) == 1 assert_result(results[0], "CREDIT_CARD", 14, 33, EntityRecognizer.MAX_SCORE) @@ -37,7 +38,7 @@ def test_analyze_with_multiple_predefined_recognizers(self): text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090" langauge = "en" entities = ["CREDIT_CARD", "PHONE_NUMBER"] - results = analyze_engine.analyze(text, entities, langauge) + results = analyze_engine.analyze(text, entities, langauge, all_fields=False) assert len(results) == 2 assert_result(results[0], "CREDIT_CARD", 14, 33, EntityRecognizer.MAX_SCORE) @@ -50,14 +51,14 @@ def test_analyze_without_entities(self): text = " Credit card: 4095-2609-9393-4932, my name is John Oliver, DateTime: September 18 " \ "Domain: microsoft.com" entities = [] - analyze_engine.analyze(text, entities, langauge) + analyze_engine.analyze(text, entities, langauge, all_fields=False) def test_analyze_with_empty_text(self): analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) langauge = "en" text = "" entities = ["CREDIT_CARD", "PHONE_NUMBER"] - results = analyze_engine.analyze(text, entities, langauge) + results = analyze_engine.analyze(text, entities, langauge, all_fields=False) assert len(results) == 0 @@ -67,7 +68,7 @@ def test_analyze_with_unsupported_language(self): analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) text = "" entities = ["CREDIT_CARD", "PHONE_NUMBER"] - analyze_engine.analyze(text, entities, "de") + analyze_engine.analyze(text, entities, "de", all_fields=False) def test_remove_duplicates(self): # test same result with different score will return only the highest @@ -93,7 +94,7 @@ def test_add_pattern_recognizer_from_dict(self): entities = ["CREDIT_CARD", "ROCKET"] results = analyze_engine.analyze(text=text, entities=entities, - language='en') + language='en', all_fields=False) assert len(results) == 0 @@ -102,7 +103,7 @@ def test_add_pattern_recognizer_from_dict(self): # Check that the entity is recognized: results = analyze_engine.analyze(text=text, entities=entities, - language='en') + language='en', all_fields=False) assert len(results) == 1 assert_result(results[0], "ROCKET", 0, 7, 0.8) @@ -119,7 +120,7 @@ def test_remove_analyzer(self): entities = ["CREDIT_CARD", "SPACESHIP"] results = analyze_engine.analyze(text=text, entities=entities, - language='en') + language='en', all_fields=False) assert len(results) == 0 @@ -128,7 +129,7 @@ def test_remove_analyzer(self): # Check that the entity is recognized: results = analyze_engine.analyze(text=text, entities=entities, - language='en') + language='en', all_fields=False) assert len(results) == 1 assert_result(results[0], "SPACESHIP", 0, 10, 0.8) @@ -137,7 +138,7 @@ def test_remove_analyzer(self): # Test again to see we didn't get any results results = analyze_engine.analyze(text=text, entities=entities, - language='en') + language='en', all_fields=False) assert len(results) == 0 @@ -145,7 +146,7 @@ def test_Apply_with_language_returns_correct_response(self): analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) request = AnalyzeRequest() - request.analyzeTemplate.languageCode = 'en' + request.analyzeTemplate.language = 'en' new_field = request.analyzeTemplate.fields.add() new_field.name = 'CREDIT_CARD' new_field.minScore = '0.5' @@ -158,10 +159,20 @@ def test_Apply_with_no_language_returns_default(self): analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) request = AnalyzeRequest() - request.analyzeTemplate.languageCode = '' + request.analyzeTemplate.language = '' new_field = request.analyzeTemplate.fields.add() new_field.name = 'CREDIT_CARD' new_field.minScore = '0.5' request.text = "My credit card number is 4916994465041084" response = analyze_engine.Apply(request, None) assert response.analyzeResults is not None + + def test_when_allFields_is_true_return_all_fields(self): + analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) + request = AnalyzeRequest() + request.analyzeTemplate.allFields = True + request.text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090 " \ + "Domain: microsoft.com" + response = analyze_engine.Apply(request, None) + assert response.analyzeResults is not None + assert len(response.analyzeResults) is 3 diff --git a/presidio-analyzer/tests/test_recognizer_registry.py b/presidio-analyzer/tests/test_recognizer_registry.py index bd5ea6840..bfc1e8577 100644 --- a/presidio-analyzer/tests/test_recognizer_registry.py +++ b/presidio-analyzer/tests/test_recognizer_registry.py @@ -27,8 +27,8 @@ def get_mock_recognizer_registry(self): def test_get_recognizers_all(self): registry = self.get_mock_recognizer_registry() - recognizers = registry.get_recognizers() - assert len(recognizers) == 5 + recognizers = registry.get_recognizers(language='de',all_fields=True) + assert len(recognizers) == 2 def test_get_recognizers_one_language_one_entity(self): registry = self.get_mock_recognizer_registry() diff --git a/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go b/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go index c5b2ce7f7..059e127fb 100644 --- a/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go +++ b/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go @@ -86,5 +86,5 @@ func TestLanguageCode(t *testing.T) { AnalyzeTemplate: &types.AnalyzeTemplate{}, } Analyze(context.Background(), api, analyzeAPIRequest, project) - assert.Equal(t, "langtest", analyzeAPIRequest.AnalyzeTemplate.LanguageCode) + assert.Equal(t, "langtest", analyzeAPIRequest.AnalyzeTemplate.Language) } diff --git a/presidio-api/cmd/presidio-api/api/mocks/mocks.go b/presidio-api/cmd/presidio-api/api/mocks/mocks.go index 657b46d3a..0aaf8e1f3 100644 --- a/presidio-api/cmd/presidio-api/api/mocks/mocks.go +++ b/presidio-api/cmd/presidio-api/api/mocks/mocks.go @@ -161,7 +161,7 @@ func GetOcrMockResult() *types.OcrResponse { func GetTemplateMock() presidio.TemplatesStore { templateService := &TemplateMockedObject{} templateService.On("GetTemplate", mock.Anything, store.Analyze, mock.Anything). - Return(`{"fields":[{"name":"PHONE_NUMBER"}, {"name":"EMAIL_ADDRESS"}],"languageCode":"langtest"}`, nil). + Return(`{"fields":[{"name":"PHONE_NUMBER"}, {"name":"EMAIL_ADDRESS"}],"language":"langtest"}`, nil). On("GetTemplate", mock.Anything, store.Anonymize, mock.Anything). Return(`{"fieldTypeTransformations":[{"fields":[],"transformation":{"replaceValue":{"newValue":""}}}]}`, nil). On("GetTemplate", mock.Anything, store.AnonymizeImage, mock.Anything). From 8e077d5486f2d893e535149965ecdd647f144038 Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Wed, 3 Apr 2019 14:38:11 +0300 Subject: [PATCH 11/75] New functionality: Support custom pattern recognizers (#104) Adding a new service for persisting recognizers Adding API support for adding, removing and listing recognizers Analyzer service now calls the recognizers store to get new recognizers to be used during analysis --- Gopkg.lock | 4 +- Makefile | 11 +- charts/presidio/templates/_helpers.tpl | 7 + .../templates/analyzer-deployment.yaml | 2 + charts/presidio/templates/api-deployment.yaml | 2 + .../recognizers-store-deployment.yaml | 47 ++ .../templates/recognizers-store-service.yaml | 18 + charts/presidio/values.yaml | 14 + docs/assets/Persistent_recognizers.xml | 1 + docs/assets/persidio-design-architecture.jpg | Bin 0 -> 102807 bytes docs/assets/persidio-design.xml | 2 +- docs/install.md | 5 +- pkg/platform/platform.go | 94 ++-- pkg/presidio/presidio.go | 8 + pkg/presidio/services/services.go | 124 ++++- pkg/rpc/client.go | 10 + presidio-analyzer/analyzer/analyzer_engine.py | 18 +- .../analyzer/anonymize_image_pb2.py | 245 +++++++++ .../analyzer/anonymize_image_pb2_grpc.py | 46 ++ .../analyzer/anonymize_json_pb2.py | 179 ++++++ .../analyzer/anonymize_json_pb2_grpc.py | 3 + presidio-analyzer/analyzer/anonymize_pb2.py | 220 ++++++++ .../analyzer/anonymize_pb2_grpc.py | 46 ++ presidio-analyzer/analyzer/datasink_pb2.py | 262 +++++++++ .../analyzer/datasink_pb2_grpc.py | 81 +++ .../analyzer/entity_recognizer.py | 2 + presidio-analyzer/analyzer/ocr_pb2.py | 136 +++++ presidio-analyzer/analyzer/ocr_pb2_grpc.py | 46 ++ presidio-analyzer/analyzer/pattern.py | 14 +- .../analyzer/pattern_recognizer.py | 6 +- .../analyzer/recognizer_registry/__init__.py | 4 + .../recognizer_registry.py | 152 +++-- .../recognizers_store_api.py | 80 +++ .../analyzer/recognizers_store_pb2.py | 520 ++++++++++++++++++ .../analyzer/recognizers_store_pb2_grpc.py | 131 +++++ presidio-analyzer/analyzer/scan_pb2.py | 96 ++++ presidio-analyzer/analyzer/scan_pb2_grpc.py | 3 + presidio-analyzer/analyzer/scheduler_pb2.py | 327 +++++++++++ .../analyzer/scheduler_pb2_grpc.py | 63 +++ presidio-analyzer/analyzer/stream_pb2.py | 96 ++++ presidio-analyzer/analyzer/stream_pb2_grpc.py | 3 + .../tests/test_analyzer_engine.py | 142 +++-- presidio-analyzer/tests/test_pattern.py | 11 +- .../tests/test_pattern_recognizer.py | 18 +- .../tests/test_recognizer_registry.py | 237 ++++++-- presidio-api/cmd/presidio-api/api/api.go | 1 + .../cmd/presidio-api/api/mocks/mocks.go | 158 ++++++ .../api/recognizers/recognizers.go | 76 +++ .../api/recognizers/recognizers_test.go | 113 ++++ .../presidio-api/api/templates/templates.go | 2 +- presidio-api/cmd/presidio-api/main.go | 20 + presidio-api/cmd/presidio-api/methods.go | 68 +++ presidio-recognizers-store/Dockerfile | 22 + .../cmd/presidio-recognizers-store/main.go | 372 +++++++++++++ .../recognizers_store_test.go | 269 +++++++++ tests/functional_recognizers_test.go | 148 +++++ .../analyze-custom-recognizer-request.json | 4 + .../analyze-custom-recognizer-template.json | 19 + .../new-custom-pattern-recognizer.json | 18 + .../update-custom-pattern-recognizer.json | 18 + 60 files changed, 4594 insertions(+), 250 deletions(-) create mode 100644 charts/presidio/templates/recognizers-store-deployment.yaml create mode 100644 charts/presidio/templates/recognizers-store-service.yaml create mode 100644 docs/assets/Persistent_recognizers.xml create mode 100644 docs/assets/persidio-design-architecture.jpg create mode 100644 presidio-analyzer/analyzer/anonymize_image_pb2.py create mode 100644 presidio-analyzer/analyzer/anonymize_image_pb2_grpc.py create mode 100644 presidio-analyzer/analyzer/anonymize_json_pb2.py create mode 100644 presidio-analyzer/analyzer/anonymize_json_pb2_grpc.py create mode 100644 presidio-analyzer/analyzer/anonymize_pb2.py create mode 100644 presidio-analyzer/analyzer/anonymize_pb2_grpc.py create mode 100644 presidio-analyzer/analyzer/datasink_pb2.py create mode 100644 presidio-analyzer/analyzer/datasink_pb2_grpc.py create mode 100644 presidio-analyzer/analyzer/ocr_pb2.py create mode 100644 presidio-analyzer/analyzer/ocr_pb2_grpc.py create mode 100644 presidio-analyzer/analyzer/recognizer_registry/recognizers_store_api.py create mode 100644 presidio-analyzer/analyzer/recognizers_store_pb2.py create mode 100644 presidio-analyzer/analyzer/recognizers_store_pb2_grpc.py create mode 100644 presidio-analyzer/analyzer/scan_pb2.py create mode 100644 presidio-analyzer/analyzer/scan_pb2_grpc.py create mode 100644 presidio-analyzer/analyzer/scheduler_pb2.py create mode 100644 presidio-analyzer/analyzer/scheduler_pb2_grpc.py create mode 100644 presidio-analyzer/analyzer/stream_pb2.py create mode 100644 presidio-analyzer/analyzer/stream_pb2_grpc.py create mode 100644 presidio-api/cmd/presidio-api/api/recognizers/recognizers.go create mode 100644 presidio-api/cmd/presidio-api/api/recognizers/recognizers_test.go create mode 100644 presidio-recognizers-store/Dockerfile create mode 100644 presidio-recognizers-store/cmd/presidio-recognizers-store/main.go create mode 100644 presidio-recognizers-store/cmd/presidio-recognizers-store/recognizers_store_test.go create mode 100644 tests/functional_recognizers_test.go create mode 100644 tests/testdata/analyze-custom-recognizer-request.json create mode 100644 tests/testdata/analyze-custom-recognizer-template.json create mode 100644 tests/testdata/new-custom-pattern-recognizer.json create mode 100644 tests/testdata/update-custom-pattern-recognizer.json diff --git a/Gopkg.lock b/Gopkg.lock index 36aecbf32..d53958a77 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -107,11 +107,11 @@ [[projects]] branch = "development" - digest = "1:d020c7bac116dd1a01523be75d6402bab0665a7aadb89fb79a11bfbfb0536e56" + digest = "1:8d270b7938356d0f7262169aaff9a272a1b747ede5bcd5040bb5cc9c012e57e9" name = "github.com/Microsoft/presidio-genproto" packages = ["golang"] pruneopts = "UT" - revision = "bdbe699528941d1a17ad1c9d015ed144c9bff022" + revision = "992d0127ad644ffcde2e6e1185f772a07ea14273" [[projects]] digest = "1:a59a467c541a1bf8b06e4fad6113028c959be6573b78ceca9f8020cd0d2127fc" diff --git a/Makefile b/Makefile index fc1e6662b..ce4ab3fe0 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,8 @@ DOCKER_REGISTRY ?= presidio.azurecr.io DOCKER_BUILD_FLAGS := LDFLAGS := -BINS = presidio-anonymizer presidio-ocr presidio-anonymizer-image presidio-api presidio-scheduler presidio-datasink presidio-collector -IMAGES = presidio-anonymizer presidio-ocr presidio-anonymizer-image presidio-api presidio-scheduler presidio-datasink presidio-collector presidio-analyzer +BINS = presidio-anonymizer presidio-ocr presidio-anonymizer-image presidio-api presidio-scheduler presidio-datasink presidio-collector presidio-recognizers-store +IMAGES = presidio-anonymizer presidio-ocr presidio-anonymizer-image presidio-api presidio-scheduler presidio-datasink presidio-collector presidio-analyzer presidio-recognizers-store GOLANG_DEPS = presidio-golang-deps PYTHON_DEPS = presidio-python-deps GOLANG_BASE = presidio-golang-base @@ -117,18 +117,20 @@ test-functional: docker-build -docker rm test-presidio-anonymizer -f -docker rm test-presidio-anonymizer-image -f -docker rm test-presidio-ocr -f + -docker rm test-presidio-recognizers-store -f -docker network create testnetwork docker run --rm --name test-azure-emulator --network testnetwork -e executable=blob -d -t -p 10000:10000 -p 10001:10001 -v ${HOME}/emulator:/opt/azurite/folder arafato/azurite docker run --rm --name test-kafka -d -p 2181:2181 -p 9092:9092 --env ADVERTISED_HOST=127.0.0.1 --env ADVERTISED_PORT=9092 spotify/kafka docker run --rm --name test-redis --network testnetwork -d -p 6379:6379 redis docker run --rm --name test-s3-emulator --network testnetwork -d -p 9090:9090 -p 9191:9191 -t adobe/s3mock - docker run --rm --name test-presidio-analyzer --network testnetwork -d -p 3000:3000 -e GRPC_PORT=3000 $(DOCKER_REGISTRY)/presidio-analyzer:$(PRESIDIO_LABEL) + docker run --rm --name test-presidio-analyzer --network testnetwork -d -p 3000:3000 -e GRPC_PORT=3000 -e RECOGNIZERS_STORE_SVC_ADDRESS=test-presidio-recognizers-store:3004 $(DOCKER_REGISTRY)/presidio-analyzer:$(PRESIDIO_LABEL) docker run --rm --name test-presidio-anonymizer --network testnetwork -d -p 3001:3001 -e GRPC_PORT=3001 $(DOCKER_REGISTRY)/presidio-anonymizer:$(PRESIDIO_LABEL) docker run --rm --name test-presidio-anonymizer-image --network testnetwork -d -p 3002:3002 -e GRPC_PORT=3002 $(DOCKER_REGISTRY)/presidio-anonymizer-image:$(PRESIDIO_LABEL) docker run --rm --name test-presidio-ocr --network testnetwork -d -p 3003:3003 -e GRPC_PORT=3003 $(DOCKER_REGISTRY)/presidio-ocr:$(PRESIDIO_LABEL) + docker run --rm --name test-presidio-recognizers-store --network testnetwork -d -p 3004:3004 -e GRPC_PORT=3004 -e REDIS_URL=test-redis:6379 $(DOCKER_REGISTRY)/presidio-recognizers-store:$(PRESIDIO_LABEL) sleep 30 - docker run --rm --name test-presidio-api --network testnetwork -d -p 8080:8080 -e WEB_PORT=8080 -e ANALYZER_SVC_ADDRESS=test-presidio-analyzer:3000 -e ANONYMIZER_SVC_ADDRESS=test-presidio-anonymizer:3001 -e ANONYMIZER_IMAGE_SVC_ADDRESS=test-presidio-anonymizer-image:3002 -e OCR_SVC_ADDRESS=test-presidio-ocr:3003 $(DOCKER_REGISTRY)/presidio-api:$(PRESIDIO_LABEL) + docker run --rm --name test-presidio-api --network testnetwork -d -p 8080:8080 -e WEB_PORT=8080 -e ANALYZER_SVC_ADDRESS=test-presidio-analyzer:3000 -e ANONYMIZER_SVC_ADDRESS=test-presidio-anonymizer:3001 -e ANONYMIZER_IMAGE_SVC_ADDRESS=test-presidio-anonymizer-image:3002 -e OCR_SVC_ADDRESS=test-presidio-ocr:3003 -e RECOGNIZERS_STORE_SVC_ADDRESS=test-presidio-recognizers-store:3004 $(DOCKER_REGISTRY)/presidio-api:$(PRESIDIO_LABEL) go test --tags functional ./tests -count=1 docker rm test-presidio-api -f docker rm test-presidio-analyzer -f @@ -139,6 +141,7 @@ test-functional: docker-build docker rm test-kafka -f docker rm test-redis -f docker rm test-s3-emulator -f + docker rm test-presidio-recognizers-store -f docker network rm testnetwork diff --git a/charts/presidio/templates/_helpers.tpl b/charts/presidio/templates/_helpers.tpl index 717bf5175..5d4f360ba 100644 --- a/charts/presidio/templates/_helpers.tpl +++ b/charts/presidio/templates/_helpers.tpl @@ -33,11 +33,18 @@ We truncate at 63 chars because some Kubernetes name fields are limited to this {{- define "presidio.scheduler.fullname" -}} {{ include "presidio.fullname" . | printf "%s-scheduler" }} {{- end -}} +{{- define "presidio.recognizersstore.fullname" -}} +{{ include "presidio.fullname" . | printf "%s-recognizersstore" }} +{{- end -}} {{- define "presidio.analyzer.address" -}} {{template "presidio.analyzer.fullname" .}}:{{.Values.analyzer.service.externalPort}} {{- end -}} +{{- define "presidio.recognizersstore.address" -}} +{{template "presidio.recognizersstore.fullname" .}}:{{.Values.recognizersstore.service.externalPort}} +{{- end -}} + {{- define "presidio.anonymizer.address" -}} {{template "presidio.anonymizer.fullname" .}}:{{.Values.anonymizer.service.externalPort}} {{- end -}} diff --git a/charts/presidio/templates/analyzer-deployment.yaml b/charts/presidio/templates/analyzer-deployment.yaml index 96b2586ef..5448f8d59 100644 --- a/charts/presidio/templates/analyzer-deployment.yaml +++ b/charts/presidio/templates/analyzer-deployment.yaml @@ -36,6 +36,8 @@ spec: valueFrom: fieldRef: fieldPath: metadata.namespace + - name: RECOGNIZERS_STORE_SVC_ADDRESS + value: {{ template "presidio.recognizersstore.address" . }} - name: GRPC_PORT value: {{ .Values.analyzer.service.internalPort | quote }} {{ if .Values.privateRegistry }}imagePullSecrets: diff --git a/charts/presidio/templates/api-deployment.yaml b/charts/presidio/templates/api-deployment.yaml index 75f7dd0d5..2f7fd9230 100644 --- a/charts/presidio/templates/api-deployment.yaml +++ b/charts/presidio/templates/api-deployment.yaml @@ -56,6 +56,8 @@ spec: value: {{ template "presidio.anonymizer.address" . }} - name: ANONYMIZER_IMAGE_SVC_ADDRESS value: {{ template "presidio.anonymizerimage.address" . }} + - name: RECOGNIZERS_STORE_SVC_ADDRESS + value: {{ template "presidio.recognizersstore.address" . }} - name: OCR_SVC_ADDRESS value: {{ template "presidio.ocr.address" . }} - name: SCHEDULER_SVC_ADDRESS diff --git a/charts/presidio/templates/recognizers-store-deployment.yaml b/charts/presidio/templates/recognizers-store-deployment.yaml new file mode 100644 index 000000000..c7b6b9b3b --- /dev/null +++ b/charts/presidio/templates/recognizers-store-deployment.yaml @@ -0,0 +1,47 @@ +{{ $fullname := include "presidio.recognizersstore.fullname" . }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ $fullname }} + labels: + app: {{ $fullname }} + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + # Currently the store uses a mutex to synchronize writes to the storage, + # hence it relies on the fact that there is a single replica. + # Increasing the replicas count is allowed however mutual exclusion + # between the different replicas is not guaranteed... + replicas: 1 + selector: + matchLabels: + app: {{ $fullname }} + template: + metadata: + labels: + app: {{ $fullname }} + spec: + containers: + - name: {{ .Chart.Name }} + image: "{{ .Values.registry }}/{{ .Values.recognizersstore.name }}:{{ default .Chart.AppVersion .Values.tag }}" + imagePullPolicy: {{ default "IfNotPresent" .Values.recognizersstore.imagePullPolicy }} + ports: + - containerPort: {{ .Values.recognizersstore.service.internalPort }} + env: + - name: PRESIDIO_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: REDIS_URL + value: {{ .Values.redis_internal.url }} + - name: REDIS_PASSWORD + value: {{ .Values.redis_internal.password | default "" }} + - name: REDIS_DB + value: {{ .Values.redis_internal.db | default "0" | quote}} + - name: REDIS_SSL + value: {{ .Values.redis_internal.ssl | default "false" | quote}} + - name: GRPC_PORT + value: {{ .Values.recognizersstore.service.internalPort | quote }} + {{ if .Values.privateRegistry }}imagePullSecrets: + - name: {{.Values.privateRegistry}}{{ end }} \ No newline at end of file diff --git a/charts/presidio/templates/recognizers-store-service.yaml b/charts/presidio/templates/recognizers-store-service.yaml new file mode 100644 index 000000000..8d69c390b --- /dev/null +++ b/charts/presidio/templates/recognizers-store-service.yaml @@ -0,0 +1,18 @@ +{{ $fullname := include "presidio.recognizersstore.fullname" . }} +apiVersion: v1 +kind: Service +metadata: + name: {{ $fullname }} + labels: + chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +spec: + type: {{ .Values.recognizersstore.service.type }} + ports: + - port: {{ .Values.recognizersstore.service.externalPort }} + targetPort: {{ .Values.recognizersstore.service.internalPort }} + protocol: TCP + name: {{ .Values.recognizersstore.service.name }} + selector: + app: {{ $fullname }} \ No newline at end of file diff --git a/charts/presidio/values.yaml b/charts/presidio/values.yaml index 073ab42f5..ebb8bc3df 100644 --- a/charts/presidio/values.yaml +++ b/charts/presidio/values.yaml @@ -10,6 +10,11 @@ redis: #db: 0 #ssl: false +# Update url with the internal redis url, phase 2 +redis_internal: + nameOverride: persistent-redis + url: redis-master.presidio-system.svc.cluster.local:6379 + api: name: presidio-api imagePullPolicy: Always @@ -67,6 +72,15 @@ scheduler: type: ClusterIP externalPort: 3001 internalPort: 3001 + +recognizersstore: + name: presidio-recognizers-store + imagePullPolicy: Always + service: + type: ClusterIP + externalPort: 3004 + internalPort: 3004 + collector: name: presidio-collector diff --git a/docs/assets/Persistent_recognizers.xml b/docs/assets/Persistent_recognizers.xml new file mode 100644 index 000000000..b94dbe909 --- /dev/null +++ b/docs/assets/Persistent_recognizers.xml @@ -0,0 +1 @@ +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 \ No newline at end of file diff --git a/docs/assets/persidio-design-architecture.jpg b/docs/assets/persidio-design-architecture.jpg new file mode 100644 index 0000000000000000000000000000000000000000..83b80f71219f816b2ea456e93643eaa7675d249c GIT binary patch literal 102807 zcmd?R2Ut_vwl5yVUbZ3xkRn^EG})AZfMBBt7?2VIp(#}Y0Zk~!ADK8{oX2 zwyrkd&>;Ze5bFgvU;^#{*baYh-{09-?Z~ks-`j~}$BrI5dE(TmlP6A|Jay*WnNz2K zI(_owS@yF(ojZS?{rssj7dS4Q=U~z2zyHah@0@H$POuoypFVk-b@qR;9ef0^pI~b{ zn#*=b2ymGF5F7iUgGzwF_n{tUJM?|Pf3)LAkFokWeCQ!QNA3uHK=*go; z53vId9cDXn^w@Fs6BjOX$Z_%r-7=430J*r8ji3*Gu(9pJ^4E;biYUdmb)G!`qHx#F z`}dcxFjXf-Ops}!;waA-Vv2X{V+u4)e*-C{XMFnH+P1{%<|3>A@9ob+V0|7w#Oj9i z)i3O<(}&m&A31XD`!K)%;?QCC%W@YM*=`v<5yGT>I>I6UuyyEpfw01o@zH}`z!^5y z6|u9i18xBJ^M3u#@PE8r^2=zYU+x(A8Dvml>`6M;Ao(bx;0_qHT%&KZ$x{>saTQSD zCurG}ii2BjM-#4Zt}$9Rj?a4>8czOPlVN}jY$|znV##30zBNa$e4JTv1ox|`jYOZc zpW+m>kLmL*igTVzo!1jTk0@Q#%RLPFFR&SC$Q%H)tnCi~_elqU5BHvWfLDX8-U|qo zx<6O6*aL2p}n?Dd$np(rS>vPJ6Uok(S4Wwhj}>(0qB*Pd@x^i zX(LgK7d~D3VLGw*kKq4CHc-Ccy&~ql4@r4x?FjYi0#$h5S+Ts}8kd}R4h#ChwG+i$ zApYEBWt&p&bjZHv;Nk&bKmDlb|8WWX-~2<4*;LyikMpz&!P4oYWXlf!m0lU#PDE7& zQ&+D?d|_%USZZjzc%z8~}2G_w}Cflh*gCI_Fd~ z9@#zfEHGluN9=bX%89yW;7=5oG&CX|8UXJ~0nqhC$_bspGSIC!rEq56{2b!k6+7wDjr&j^quG5` zx)wUnSlIZB?328N0HpR5gk+z9L$j_z%hY6gu<`(Kk}EL~l+LKsz!-gP{ag=+!vczX z`0rO%XISv@^ceAWz=ed3lr%Lpr9PfhwNrh00B}w@^xDkvty0jJ@f&_59Z~S-9$#|1 zsa~XYUzmu8j@uZRq1*$-t!X^DzOmoj_|qy?>}2OKZ&sNel3XenvTw80E8nuL;Cb4; zSTH4%Wg9>k42S&fY>dTZlXii7!@<6*BSr-QL{+Qf(LO!0oNG;?1C!~qV=w2&tdo)t z0FnV&tJt7q<=q=Bxp@xx>s|f_pt`)cK_ye=Y?$ezohQXJYh=8XRUSsIjNi3K?I?L# z_loj8LMA$4cB1Tb;wD6U(EZ=9fHeGHnP;e7pZ07rK2*9 z?(PxBz!Fwrq8R5J8*@G#r*?K6r$6*ZS|Gfym;|;)Ik?ZwsI~G_F3^;vjEs49B3@}P zo|={7v5bT$di4;s$NQGUqP<|~F6G@AZm-@Gd_}($s487!PCz9WK0aKsQa=9)A=mvl z3Dz3Le5iSMa?aDp_+vlK(*dEKvm5@AHK{*g$Lkzx3GFcX(^|mh7ilU^S@}5*Uu#o^ z->#aGHq!b5cO8eOV>mosPHNjeX~%_Zn9q4mQr_-{joZ&fs|bfCGdHF323xPwW9%hf zPG|M^aKcb{o>iV^OEsGv)YLSWzvK3`cAq*e1q^p`-k3&$rmlIiNs*m*m>wuu>!aIt z44Prj?bBeeAqBr8HPscH&9vlQxdmn0>5(?OhvI1B8++Me_bh|S@bB{bi?rw!t&DU*Z}bUL%Wyx~+rzlO z{NfKnucA3|U+@6Xp_eCl08sVV3_1W9{p+9oOSb>cW38vn8>p%nRV$atIL)kVm>C|H zC)`E^-tCG9D*24B8BTGfL#hNT&A|IrJEK2)@MyR^=(wlXl`y%r;QJSo=oXydVQFj+Q!_lSctyU>#;2S8cIIS1|&PywLN!-B2={eE$ zTdt|5;fa^E_*MspzTD8RC@a#3LgTY`ZTAxS{5f2%dc%C>Pv41KO9TmXr7JD$Nlc|_ z8sOfUeaar@ZOEkVrkr~Tw^8uR(;D*v3AA056!w`e7x7UO*bhqxTjhJrn#QyCvW&B! z_Q@Tqh5K@iUZw6S)7C#{K?X}5Fts@RxaIO>M*Jl6EBV}ygHCQ{ka6)%S26Rny2tzJjn$S>`gDD3E-ec%I| z8=ocL{aH#*nxzEzO;7CnwxNtQhUBDPqEYw1?KOQf9az__(G~8kmIF4U;v6&$)*!-4 z{sVcgnKE^YNmai{AItD6vXVf2HM5a@y)fEYO#uh0%P6$xBRu8HwWVzEypnRLfcsWb zGS?O1jJ83wG+f3f55{>tMvsNC%!&%pFbl}8Cra+uaxQLz$rR1Lhz>q6l{akX);(fx zM1TjL2njjT^3B62P}&PR+XwCb@&od?apuZc3=X9;r0)}nj6I=UpKhWQe=nozIuXT1&HHV8guOiXkU8ZEw9{&?0m!{)i zYzZ>k(|Kd(Q{Rnv<9po`oQ^N^qX1lNtTU!7z)gT_fJsaX9~fz=ELnA!8F=SJ7P&@H;M=cx7LR z?6EF8c$u|ou~{^%)_^I8HEOQ6&$^04x!YLMai=Poqq}%3mwhRk;BkEoQHzI~^N1Ha zkBcZ3-EU&5L-(r8S+@T1my9W$Bsf7Wpl%TpnCP((n>WL<#Y@P_!L}TAhDG9XfY8A3 z0iaVgmb9N`dPri)wl$t+onOKn_GJdv&v%)X?idrZHEkD^-0tWoMj4LkE?Uc0E^U=S zED*79KNldB4wd!ev zcYM*oDb_jEyWN6{_X}FUm^P7C3b3x#D)sW{QV?+KnZ~6v)e942N{?Hrrs(R>S^#xsea}3*mRerZ)8gCjvFF4Iu`ykD?k z{jJbwhAAk*pjCyUU@Lb$dIh+D#;Nty;1y>Ad6W_B(qpZTC|J~-Xf?f~);;+x7g>_; zU(N8r*51Bu?d5TT+LKk>y`tI|#7%WF+woLVS=w&M?C6rD!4qJ0X7fFl<1cl#93j_H zjD01?Py#|9)FZrG-dmpSWjrW%D(3TQ+b)IW;^yyvQTJP&I^vF0wOQTmuvz8W-8oX~ zu-2_b0=@5aFsl}!bfW#=$eXnlc=xn@?2-;Q$YazdPx?lk{kq#=7%Oc&lySkS13GKV zSy*N9r0i?Qod=fw12HD1_vpWqA}@KSh_{<;d5jyFZ%ed2i-fyvt`#1A^%JfNzZe$$ z&?`ng7oXv!-tc8|{{AN^E*0DR#8|yexR@IT#5)Z!z+rH&avo9a_1zzLm@+9+KpZ;S zAtyKbbdzF4Fa-NL<5KQk!hGl5ksiMlI$^=|&I=jKk5YLEZc8r1M`btQOpsZUq(9vaFJg^r;a8BCxo5nxPreGQ4B&w%o>EvS7+Pnbj+{9z`0N7N!Eq=C7~Tn_2`^Q9;m^62&{h5z~M}V7~4uX z1*4Tc>bFw5@b>WKQb%OEr|s)`1>>S=m=vV``VN1f=$bOcfSG?3lqWv1!8pZuVk9Rp&r?)hkP zkVCS&n%;_M_*?Mjl}st0+Y%7V9Hx#N23tF1Wm$b*C6UZ-ytVNTHNSuz^}1%o4vKaTxkpd2!KZS&n|%yLB!p4zd&o^nMd3o&pp_oLZ-tO2etJ?d(jtc_|Lx`k}6<(8#fK zCIXy#lxkujMe@&D2x_LaK?9QA^9p;yDv|DF3j(87f8c`=FYP)2m-gZD-F)+G`y@Ew9Ezl!&?C3L} zC=*@hmut#gV9)1~883<2G+m7KmQn_m-D(a4G&*;=?NK>iD_c4IaH*ENm-6)mu=KS6 z<>OtJR*mpt^vOKOIjp2_wAkHMV(99WaMFYdkWWY$9Cv-F3zS>E%p}C{;~jeoA$iNa zQ78eo!1@)J7uVu>wJ8yS3Mn-dF$c(ri1AlcBJ#>5T)|+zuX=XE0U*NWk$Y!-r}rC1 z5$H_H$)57QMhu#ObPr>2HyS)G*y94lOdkL)?+d6@eQ{#<`}}^UBX0Z53pD<6t*fl7 z;WJ!`KCBX3P?Df+nTC!%u5NF^b!BVaGZNyTG$DliVd;Dsu(%|>}hr*2^N)0 z3Dp*Mxt3YezI;cNrbTsX$y9GwtA!?Kh{4(*Nz=sUFP^>1(D2Z(;Pc5w|rhQJ29UYLetH<=$@n09Oqay z2UIpqgGC)*OsNJ1IrkzfVgifD$GiuH5;5;w_fz}VEHw)wK<`bBZXxUPA+ae!)^c_6e)5>)vyw^2Se;(^fYd;}PD7Z)ThB?7dkCL@_x5r%JtSksjJX*6it> z5{A>%d$z#sG4T-g$GpFNPKk%QT(ZpB)_QJd4tl0@P^lbZo*6eVr{oXz^hIPZK@F82`qqhi0guW!xM>kSxD#V2`KWk4 zp-=+c%RjmJlRkBcFRP=?2I0N?GTNG;G(EjuW2s}ItD}&2*~0myWan{hvUX@$K~1kd zuQ1lWANR_o_W+Q!+C{Un8G5ZAhkly@K_?7a7#Z{o>S4jzdcj|M^5dQ?rt9esAX_MRQWy6fIXsdHd568i%lT5O(yGK-)&v4F^h_r z3o8VdL}7=>BW2c;O-#1xLOf%bR>h@%N3>c}uhiNyJ1aY)kKAD@EM!xiOf=QXjfoXp zZ9h^9TMQ-THH_GFh6kF+{f+|BOLLa5cqo40no#b2=WIrr%13 zy`M4TUSJB8^iAdkwh~LEp{G<_vL>=~10+Y?^5Hl&j6kcZBp&JvY65>toqPt2b{Xe3 z2^~$DP+znqf!?56O*KqYqKV~xxyiz|d}=D};$|u#B)F8a4sk~ma89uA>GiLX5?4TH z6Ee_v5+{{D9E;xbi1{?Hh?%buwiK)HD&XJ1$(zwbNXQH$C`#o^4In`+^DY18)%2

dlnnWZ^$;d9)7p8_4EE4LnBd5cHgItR11E0OrCIG=mp8;!gxR5*6Wq!zrF0g zQ0h!$Jgi!k()*d(#+<{9G(7n->6HF1KlX)|Lm0PXYcfeVB|;PGR*q_Kbo1I0V%$QZ z65%gb3ymrKA<+#zwUNjT6{Cxz_aPvHsdU<*@v_wGxti9`Q}lu^6Dm1xjrfsKw$^m)M-Y;iRwRaLuZD zry+j#)u!i|DFpyHK7uvE>Sf4Xmb_gygtM_%1}*MZ*G@>mD4f#;^_YeRMc2B* z*ORhm647egHb%b(85n+(jfc9`PH8K*{7EO;@T@QqT3>n=%Xc&(l!4{^+GN5pEhr?MtgqA*>V&c67&QZxA_~V6 zfVbYvILYQ;t!MxXpi`sE+uqbp&d1#D{rw$w86__J8v)^pDI<=1=36s+vbUXNzKA*J ztdPnCtaB(@-tU5$ukX$rt}`*>2Qj?l^zR-3PH4A->nx?OqL}iMO3K18LeCvk(UWt* z1BG{=sJbfM5db2F3k6C>jav(3AYCxCdI~4zZnSjL?4n=r3Qehc;`VyMJW@zTbEWv? zXVHwKjAKroHcP2)Srt}fUKf)SSsNd>@PdkAh9JfQDwZcR*RQ}nQpMMjgUi5-DzbX_ zd(>DJHCd3K3f(%lI91QGk$;t{Z-Ju;CnF$z>9qxyn(I8Xv4v#CsG(4~8Di%)Y_#rVw;s$K{iNpHCd#RMI&(vdBiW*%p4h`dWUk)s{!1FbP($$L zVt^X*%SF&wgW&NmpPAufw_|v!$D`3M1vB=e#h{3ERGu&-TYIh^Lmap*R#$SB>GrDQ z>HLkeUf^avARUVh_w)jt&9c*15GbDWFYd1`6`RD5-x2z$BJNeDt${}PohN6&v0Xj! zVZD4F(KbD+p;{MiPbq0mm?4+(#hNq4d*?_0(;kEGooo2gCcv{T*E;fH7sOD$=4Q5T zW+vC#(V>J5m2UKAQs$XHLbHkKr1+n>hItZYNQDaAMM{Gr^unnNXpxs~ZlTTjV`EUa z&@ROG%OU+^AI$y%S=1UelRRt+&mtahsW}B0B5%YF{Aub{R(7V7H45*DRL!>ZIxU(# zYHHryBZ-Yh`N+ysr%DnFx9>NE*GROh>}%w==_@(`j%)q;cX!JEl}jf6XP4|9Ks;dt z|M*e0py8Vq_w-ynd^lG}!pLLGKw3mw239N|KRdeklp_Y~V5XvyQpv3E7umQfQRphB zd$xDs`ByQ8s~o_oB2Q>8UNVOUeu4mA|3#mV>!N+Eg^;~qb7+H*x)Bcjc_NnwV&mbO znNi{t*Jne1Q9yYQh1s5>LU=a&ISUN=UpH~Y3%6Pb1C_^+NO}Ykylb?&7W-8C&DKx4 zhK2$uv{uzGGExFYL!buRA@!ff(2sBor?7&%BkeyRB7CqFu=A^Rz`p7GjZrfEJZTShT3Pb0J z)sDS2m&sAB`jZxSWK41ro)=`KXWD$e%T!2-gd%a(ou#N7kA|k3z)!Mkk@uLQb@C@< zi+rZs83V^T3+wdtWfae=yjj%Yc<3sQ?aQ~fBKao~r*rKchdlUVMA6&v1E#&a0mK}+ z7Y+1Ntx7}2(Z%>=v&7<972(|EB(mPTc3ly?*$f$+YFiz)Np%53q6Er@LB3ct zUG|sy>H519>A^fs_6gPPhC+QV45Jp5uuk@Zb#g=br0n@vgnOcJQM z;ovQg0Eo1Iny1$jPzZ5A0=BLaVMa|>Nf@U(d|#Ot-Nv-1>C(!lT z{FSr^%yc+8TeXgWnO+cwM-x#x8!OX|OvDxsqDY%Vv^{?{UG*dgbvU+|DKN-lk0^mk+F832EjB%Yv)X;1Wbl>^r(t4#WfYr{spMO zint6l)V)x9pLP zP4i*ge=nQf(*n~a%gz%U%h{h~zgDOC+}%EzP@U68L<))5=RVtPa2ZVGV?b=>131Pc zDt6~~c<{f;H~);kf&g|S207btywmihAijuwbU-ltT^GxmVOMI$yZb#1x--=J9Z_on)wT5fxR@yIUGs zR(%ry`};4^Y{rayfo*KIiUHeMxRI$X0;Fil&`aC|-5D`OyN~VGsR;H}Bp}|My`f7v ztgioQ5~=d2bI{9)5>^yW>)q5-GL#4;=THV6v)PGy>dq zI{7SKI_26B^=qHXUPJBW2qk+-Ob7|>q+A!7$BILIue#-H+Ii)d-LW(3dr%G1cu{B| zubbMVa|a`3mxT|q-5(*s2_WOE^|_rlD4NU^CCS;N_XR_8M|D6(Mt8-$+B)o7hqvB-0w^*%QCjNYJXiw+|G zhCB}EsJTe1UKquJgkJiX2@iz+a#8(AHRr%?j{JbJPyVh<2fFMHB+{dccIpXK!{6Pd``{1w~*AV$k( zj#(0aud*fJah=Hhe3C1~l2=lQZlo$CxeDHo$#X<}D#qzpGcQ;Po^i*|CWJ5&u__fM zI7G!)Z4+GGzKIW6t@5v{56kCkZ;7(DIsiO=Hon~PhgknH64u9mEdA(5E{U;LIKIad z$?MZ%8gUpCvb|%TSzS|K+4Mv<({pzJ4fE>5DW^1VYgA5Tgr#`{;`CVV9MJe~vW&5I zv(4z3S#9ao*8cA*6U`b20C*BLc}oGnmj3Hs2k>{J*z&xJdP&7ovl|VLqSY_*l9N^` zkBc8UdQ4LzwOnH+P1PQpRDI0&wWo}rT;6FXh$ypBUYs2kTWX(gWV}kZm zPsDj_<<4HqNvzv2)XO~pv@P;Q7!2;I7x^Cm8d+F2jpYwvG*7x~xr$qaf4jhEwUe;a zJ2S453Ha4@|G?w*i!%@CULD~Ffa}eeAdSI>m*2fHP4akSB8Ju_o$Oug zv$G3Ey`avu2Y^#^*aeGR38T;wFe^*=?55PsKi$1!8o*M2x_M7R%DAY2e7kH6t))fT zEtRlEe-&gvzdnAm(&Wy)KJwUo=Bw)qB}Q_qW$5G>J{i4Y1NX5BsJap}$9XloHBMo0 z&crwT1Avui_{VFM z*5Z04hkcZB?CV)_Z5I-CfS z+EyDnrUGGAEe#fr)yXB?28;a)==}BHnT2DQV&UA5U8+wf8I&pg^_%TNro?P9PEG6TUIhP{=VrCyVH3)G$%v+OtFvKm%MzoKQ;AzphLHD8ohBW$Z&!>LnY(O91+}} zo;`K4?nk)px4nSs)rNb^1pAMhZi~EDS1WGM5PiYK7PCU;&mWZX3R;_Skthq}O>8Zp zAK19e;Px_R&G>rEx9j&Ou~ET(Ob9=;QF6{QcfCR2lXG^!z3iMtiD_ZizH0E0u{t|b zxELyqoK2g)@dHd*2kmqEQiLjGL#cmL^gH752MG4loW})|?OC=c#NdnXpq9Upt#WZJ zbohgk5#enyFub>5M~3U2$x{%mOG`jhnt!t@Vqp}m=*ri)L2V#=p|DU~o~tNKc`1xI zXwDf_Uwr-#(9kiV1YW1HILY8Xm5D%FXjImy>FnDcIE2i~m8epuiu=`WWGOYg{TrP5 zpL|zX&U(&O=V$jm0w(_vmGhV+fdNt8Rs|M+kva2A9aL1a0EIM&bhUGH?ThSjfY_{R z8osH2i$CnW)G;7js#=~hB?X_dLg&_gGw%*}h2dLOrQ*h*lB@Ig@_0N{=_XOR|D=b* z+&AC5*!6_%>7q-_+J78 z|3?^NRw1&|@R+YPNS(`sm>~N!CleP;OOXmTgy%YdK$Q^ix@p&6BR6By^9i)f2h*IBYD8Fs^_4NI}tC{*YJGWa_9dBhO zazED~Fxsm$yW@qb@x4^uv2g=}HpObpbf(bHtGk|O%<}iHPSsPjfh{>N?oPbKq1SSe z1r|;1-suWn;5gC^n~=on0GI3SalC48ii%cE-?tiTVs07o*+qo*b%K;M4QDk~Ze9f| zp#gxC(6cwVp+Wg)7PfGc5Zxb-{`J-d?4!ZztEGVtj94h{ZdTR0wIbZwd;4R?KIHwM zf!x2W7TzztN^P&vCgeL7zg+I{M(2qb#O4V*%Oa6@2kYJ41HkQzzX_h|rw`=2H5sBV zM8>1`xu9u4_fg|6=}Xk-^sc{~6TmoM zB?}M9cQ~~=XO&0u7)1#Q`7*9Elh-NvaJdL=N*-BAtTuF+J)62Co%r6eEK)$l@fIl9doltJfhxv!}qudgFg^GKol1*+nUTBbNy`glidr)Foy*|R8 z1D`aW?qw=&)`+qNU5Jm{%`ui-tv;1GC*gLDarCG)LRz1Z2ZdT6)ief^a}*xF42REm zcqXDq9wFLFxZe&J8*~K9I0ZeN8@3_D6yz?3Y#3^^ed0I(P(mQTVgt@j%VtI5_7c%M zJU`N`pwO1hFKo&lSK%~VUM=_oE&1*w^y5JKw)r%v=L4zW)ekh9wfAXJIk!Ak6~FE- zHT&=f+71W!FLtIkxt^_=oTUt;z1gy4MBs}?WM4w}lxkcerc>|OFt+cHeo^S_txM9% z+%7#!vm^vcQ$Fn9a(ljLG?4Zxt~hj&-U5SvXr1$`X&$GeNp`bcWL1nrSFDoio-DN> z7i(&%ab0l*cKa_o#Z>a|6bAr5;7RNX0)CjG|$YCgRre@_j1!#0wQ;A7)f9435T1D=_C;A_Pm+AbemcqX>vAj(;x(AAYv$CH+oXn-rH!=oCXhw4R?$ zg$%s|hUi=%I93-&!tW;SxAKk#*$bES!Ah?vG?Um-jch1znMm_Go4&8BOtAm;7v2PH}A_& zNY{MUu5v-WZ{uf+%bj~Fjty5!2Fa)Hd3`_(#p+Gt8CINcRt#nW)&25iJyT3BoXU15 zj+vV2wqw~F5{ls3DGor28S|KbWFKx)LYRbm5jl0F?G{+rsI_supq>!XWQtVxW^J|8 zx-6AOMKsttH;_Jffo#+k-|eQTz{PSfDaYQrDQ!-xiTag#66^|i2@_iI8xHO*iuIQS zykyU{_u2e*s{OR^)y`QCmBOyp=54JPaD0R#e^JMB`rU|lvXTVU(Z>nh;o4VuUtRwW zPSw$~+7)H#o<0J6x<;LMbnHD%zy8Lw*}bn$WX`VuotQm|OLTu#<&JmpD*nP{@}wgP z7TcgWn-w_%8cu#gif>|VRj4P8Lh%qFZx}5-Jw-e5L`S~C9uG{@0rfsCLZbi?5 zJd7NnfK17q`nMzETw@&5{2?9KUmD+2t!k)7k)EzXxj{GWG0_F&%5{2_Cd!J@59FpA z={o&oWdi@E^~*FtqfJ`vI59F^V%8g`ckyyA!O?!meAv}rG#R7WW0Z&#PUK@Z$}2Sa z(6#oL_3fzX-{2m~EfSdh)76a~8**2sV&emnJD>TMI_!Bg&8lsCO-;!EO~G(Dl@&wA51HB|lCeikCZ61HrsNpUX1RZn!ids@m*2rrJjDLNo^(FvtO!hJ? zAuJ#^5z)*m_0pf~nh0v$-;(AXW#>E4DM`J~O!pwWXO8(f<(KH(it5&_FD^qQQ^;BTmRIBoQ|^VGR9ZSj8BeVN=_O z)6P7ZN?g`WBULb3I*+FA8sqeSuxEOY008)Q#uH+p3lZu|89s^aWX@73p2b%+gJ>w7 z)2RZ=R~srGjb|wJ1gTN|Bi?`x7`j-uKI(@bQsm@f=id zsmk?+;)bs$wBwf)fR5@bm+QUs4Hh=5)gPwJJw?PaA+Dv}ViESe3I3idEEvAOwGe2x zpfp9P5L3U(bRi8Fo+UFSC+dC)7;f>+AW&M>F6B~cM4Pof(6sN@@jtF#SC7#>Q;^*< z?mB|9>6DZpIR!{N&}u0V)t=ODcVK9^)sqB?rQL^!=?HwlIT$)}AUmVjjFzCR#5ooL zw56$eG8fJask~jTvNp1K-YaDhL>>_25`59#b7fF~x=byg60j@k%QRHP@5?dE=I6>cy z8RRgpnkIK4(4V5M-o^FROQmROxw;}ul_h&0Iipjo$30uA7DlJBupZ$sioLDmmD0t* zTb_)2B;)uu4QEtjUS#(+7`L);8C26xXB9WDx_eIe=jBOU)^RkXwV$I#7pn_M6=+g zVacS|`ir5{i!OPyW22dHqWsAg>~23@c&?MKt06CrSguMfQ3 z%^DEXFI=sM2f@}_#sj)}xQA{9cLy}$i#c;RYRl~Np99`6ZB4gl zX%mJ5yu}5vz1$5(y^@(2Raj<361}dTfbm&b**oEMCP2!2!>^zaq~55f>X>aS?v zJ6+l4l}(dCwG^07mO~!4`r^hZOoVnu_LzZ}ra?&JLdjRp1q_o&@igCq+>5}rAZp0= zKDrkTrCkfV8k6Ro*JtTF67fPF*z(ZCuirgzwv&&%)Tc*`o<(<0I%dTBBc$8Z>!*ZV zd!yAbM(l}oygA8M;qV1=y7*OXiWk>LFT&@_m3iQt`8&)qlFr%%rBj`Fb-@*A6dJTN1+`K{XRVs+LukYoxvE*N)_Y&VzOU94szZQLnix+HCEh35jH zgfKq`ystWaY2M6Gp6;6z9xnCI)Q%MxMeN$mlyn{yTqgW}>Yq&dp^)t0c&cN4TFFno zgYN0}Q@%hoF(nw*EvdhundpZoF=&|rlgIy-l2%Fpt7Mhe5I-363qbYCKb9f_ru%Ri znHd2jJUZTn5Uk<|?(MM|b@0zg^`YjKq@rZn;MU1%cc!J>xh1Dd9X$6UZnw78Ph!ap zY%m5?1xm*Y)isoWG2G3Lky+W1mR`i@7k*TDR?c%w-E|UF11cmW#i~5uh%aHkhO?gE z@_}$QtE^AFLZf%nDI+?a36_rZUI-<@?~dBdh7a6jp|7+-K0QVpiqF1#5euuS8T@_^ z#a@eADehWZ@4r{57{{kU{@D>2a`Ru@iIYD{0c$7!Uo$2rfd7=LW&4k_hNz$IB+|0o zEs(j0FDX9E+_&2{S_X9yv00Yl%E%RbN+P1YaNFcjLp)0w%BrpY5#UTeP66BLCkc^5 zLFw-eZx^n!@8nLYX~A!gtMhTq88$;D=|O>Nv(WD|K1>{4Rfn;TzesB`+~&pJp$+ zY*rqM=nRVh2~-!><^(hwIF)$LU8=^(s(sf1Nd!%7M{L~jpCYXnEuyqNv)yP(2|iJ^ zoRap&4cWL0cSANn^xDXPz`c2Gkbkj)&hQ@!v+ZtJ32S} zk2!zE>mc9OVdFAyNC;lJ@v3q|uF=&F#nau}S&9=E=IhFy#+7<09Opk@1wP}ss2z?L z%i!slcV#_Z$#7of%$G|^o^lCbz#;G%(G-2heFE~SMApQsl9Ki&p)J`^vag4oFQ(bf zYbAr1&J+k+nHkiaJ`G|$iK80t^m)rFv7oFr`YuZt6cQK^P17EzgALjNqofJm*f#JP zZZMP>Y~KVAOzr^$I9b=~djJ40^&2rxgBN57!-n^zQ}^E2BRtncO4KVq?!Slgkc;~V zgE3W=e4a*vV)@>j*3#0O6wQtV4-TC}@rMa}FMHdY#1UtD)v}oquXA&Qqf=nG_zTz4 z>@zVleVyscCaDZIghE?j#Z7G&_w!xcXR=EW%t#0ItMA4r=RAAPrSkYCxtL>S>1)sjGCK)A5|;BlVW4HlI=DPPUdkp>K12WyOb)vhKRedbSBBH#&{B_dz{OD0y5mt#R z?CcP=q~wWC^5nVz@h#9j#c8QiDHvxCe}GF%eV?C(dpWrYcNy#PS1}*e(&{mx1A#!J zJGH8m{Oekat)3k+3bYwbYGs>?qxX|Y+Zneviff$-+ZN0-7>z3#vyLzOcIPa3N+pqA znh@Br)Ct;34y-@(DOVc2bnQ}AKF?k=T@Y%QUc#_|Pv$+$gMvE3BgnT`6JwkUAOa1A zF@bR|Ip2OeLM$F{Y5@tVH_7&NUjL9~qvc1+4h#flS+{)aNP4YwC4tm4KcI|KS2nqk zS$5@NFEsvEF35M@H))h>$GAtvE8ekJm4v#IM1WR-`Q^0u1Ny@I`*360bLGr=F4m1> zwZs3fr%Y-F1byyKSvW`Fwj_si2j+?+kN7Btkx3Az8FuT#}Mh&g?gxQ!J@A~AObf>#D{_rOQy@1pAAI#1d+v1b4 zCN=kop@o+30uqr@e3C2WiVHpS_I@Nk?fw{dd{3=_XCQsmTQVf=gDJ9DnQqy{312VP zg>?yKU?Y6wg|h3*a_Qr`xe?%)1Hd+_f(zzru%G(beM4vR4;V)u3$=7*2VO_O?uOTE%+j+olX+wh-XrLSa`LzOnh)^7`~$On9nZ5 z^U!d6j%|@2e#TyMJ!HwHoz$xiSu4Q$Ykq%zsf?=Ejt;K8Sf2DKiYr-lFN0xgc@bK~ z+RqI6pT-s%I@EBn()ua`s+xlu;Zi24gRP>$;!sI(wJvNa zNXgacLZ@Dl#|XD|`THve0Ib{3f2?MI6B;S*Z^Fy)uYAYD7+5v$#{c@KdcdF5^6&q4 z=!=EXw>L42b@`@l?J2cuZ;Afv;B{9M9Bz&*#9w`NgC9%EC^oUS)RmgPXd=ufS#0qj zbPm;;cy&iAR*C9g?3ccgrot2}h_@Dwt>Gt7+M6J^jtW@cL!=O|SQZ-lP`C`|mP@AT z&}N^H$`{)eMcN**T=aRG#=y23P}9gte9Xznv2NPqwPPmgD@|t}IP8RT^qho9nn2C@YlXP~Bimh)JaWf7E?< zTvKV@ua0GA>J)5+q7!;RM1E z2uLW7g)S(NfB^y`y^mB;aqe+m6*oQPz4gLf9>3XA}L|jOB6^Onlb*7~}bf<8Tk0 zh-u>}h2W~M8}MY|c0W}k`{FMPahgF^eH;

xa)4cR&zK52anyicQc2JeY&=MZnY8EXq8->frveg?*P52#e zOy6DkvPydR{$Ht4pRLco|2`YPvEgZsR`vPt(+atmzUdZdnJW)u0@dHAXQ>ukjdwxF zIbCmhN3VJ=;x;H zOz&pCKt|bIIOqgZ&ktSUnu2tn-ld83MRr%yZ)KrxewSqY16bBSlVJY;lryn)-_N~% zT@#kK#lkSUfdimtUMc?-?QWtXyk3|tk$*B6e+*yrDkhB+>^;ew9B-JvE-U4bT|r;*mCri zG}6Lh)e0!0ly?RKo6b((55;19hL3Ju8^w5P|!(?q=h zKGWGiJ&4&^dqI1a@vLb!2vZq5SrrV_pwZ}PhD+~Jmm92WB`k$zTq1Td$4+UifnRO| z&Bw6WrWqrXh{)ekp_Z+$;uUqa*R?!tfrZW2I)Vxlm)gdHg=;(GKJwy!)cj?`YsOjh zZ^JrT`qPHJpcv!co)Z*L?bP?V%cEt>P)2sOXF=iqZ5aqYsh4R2yv+*J-`0j38>8(# z@o}{=f~XwZdHT?d0d>MNvbZQGhgvH%RlZg`_ezc*(q!Pk8DxA#pz&N502s5|SOzJ7 zc6UInSJ`{ZQmS%sw0Ei(5ewX6k~^Ios4gh?&@X1uVP`v0wKcRNO=OMW^h{TPYYAZY zEOD5zpdhQg-qKUkBYBX{I@H|(WlNe5Q6(BS`avP1;To)^Xcd#h~Ir;PqJo^ ziz9VZa=3Y)Vug&*i6BI}-LD)72{TeO(6)4%BkmLS4~3QIb{MjgqPQ z7u>U4f?$aYj;qaWK%6Zfk<)t}zfr3d6#1opF~PRcY!luhIeJTc#DN z+^NHNR(4C&0i0pfk1NEF0 zQ88#F-)*mvnGSCe^Q=%_^nE6d+Dt-d(F3k9<%L-g2qmipqx9KUh&Z`?$rw0 zJ@EavW1ypzsO13h!xg_?E~+koCvR?HY2P>JNVKo95Ar%;s1p~&=O5Q9zcV9eJMN7w zoIZEWBw@-OX}D3zQ&=rRw`O$m(3vM1K{Oo?c02ch5 zo{1K(C(5)u$bi>dU4QM%h;Q8~#ub*371|y1*XLC(={*w0l-PZKiH;2NW>RZB>U@n6C z_naQf>wcIc$H-7yu1@$i`-!2e4{9i_iF}0O-CH*1v)1gHs=nGVbGhH2EB*~!vE!qI znCsAsndUY--#N(zBKlSam%9W7^FX2bZo@Y<2{9|Li){jrvQwofH{Dxgs|McwaQCmH z1&afzZvEXVFw=G!BhPmVXmwimP*lTi4~sW9qq82o)|G@h+|rlde-tNhinOooNTmjh zxM|wE4BWy#KM&Q0A2mzYv9!uJtPa{nDrq80VF6OgVUSWv4BNIZ_y}^A+yBi`Y zUC&^a!71|-2p0XPw?{ZIPv>BEb&Fif*=8G}aY5CAI!2){Olmwt0aFX z^Gq>bg&Q~K9%1D*(p&Ow|3{nd^>>pF(_%0)pESt7OGMroPIZ_zc6663#ZDe-SHPeEX{U%Mc)vQc zx`V_c$h6eHuS9N`UQauVeRy44-p8lbQBgmO;GpwQFz!M^Jll0^&|a z!TP%3P#x7EiHSK{S!J3#JkOp6I0Xl#>P^iP%I|{?S9VRhU-{nQ#CN9}W86G=v3HBC zMnidEg(4Wp$9S*t)GPwPmJm)z9d%U1kbP!1wwA$Yb*nAgm# z!;>S-7Ns3=s8XB5X5_m+)bkvp6>H_JCB97Ff4^D6=n^!4hGL0{ljVDJ#W*bQz4b)24> z>pm4n7s${DoVkd1_6LWrzO11XF-lq?9)%d)85@u+ra!S#2vd9EN#lBpkl^NcpcG@`J z_M~~x>LBjI>CEtjuR}IOaSw=C+e?yavz+$tjYhiTL`+eoXCXS>6Ts?hi#rxD*+(I9 zpYTjgjLA9+7{z-9F_7Wr`!*!L zRIjKYZwS8_3yMEAW;O9Ok$Ky?hUx;$mG;zg`i$yC;W&6X{y0ZMZ1 zh6kAU+I^8SvL9`&g=6{bsHV1!FREK2sI){}9u=Fekw~6E8>>*f?#@Gl$yG)fI7gsL z!x3QodJv_62ho-!ezRXeG~f8wgQy;Q=CFc>Cu_6F!j#C?{bL+lJU zHza}zEYP{FMa&78v`PC@%%r@xhiz76_{u|5!5ALV{Z zZLW*QAnT00K=%B9vfI(6u$0ty0WiX3R$l&fU_vc@yk57U9~c==fY-d~Ak60bdd=t9 ze~{hype5>oH`{YUJI()*rraW_xHmwuKSZ^oxz5GVHp>Ubz*&z?M7j-UdmES)9?`j>^vu(UHfr~b{ z1t;F+@>MjVIm-J@#@`RA{Cu&aR~cI47v4OEg0(A9ri2hIb$~S0CQW0fZ`ygQqa#-f zKFTZ-+r`|IZccC4#Fx|**=97)7PKh3W`=u_tJL2gq??(K))q8Gan2R(C?C*vYzfF= zIlAUlIOkBTd=;AR(k)Jy{~QI-MG+$7hJGj8pMY_t%PY1atFF0c2-I` zS~4~UvCU7J{wHOzbuq)V7QR6d9$FdG&5lqh4n3Os%wLe&RCBq%7*%d^ag23zjL!D& z?CktWL@I5b0Xbb@Le~Zg04fkLp|)r3zCcahGbmP&EP9SzM7t>uHz-C7Qz(uJiNd8h zF#Y8$h8o}wxiU`(@tVIbX8QK8@Vexi}JE=3wWBW?pfgI-Wk!6nc~qWL)H+&7;J zRvV#%`L8GEmac;SUk0Ar3V!!?!+#g{S_l68gS_m2S~TPD&AL-f^A3i}6lv%*wK63< zLrrc)VKVJwp#IPGeA}gWGDYZ*ubrr`m{$w;jT5yzn)$bU$fKfyious_UG5G=qFKbt zo}OlmULHO-xOov*m`DZ{xcj&ee&1OY)06xMcQg1GC(hHjeWMn@zDn*Q42_Jx4!v(* zjU`Pj-$us;`gye;vvL%y-WIR`SG~#jX znVGZ^N%t1`$3>Jb!IL@e<>Y(6QLp8OTfRj3@pdq&=I6_E*|sWevX@-I8$B+_V?4X6 zwCLuhNeiA(SaO|F<&yQCzI{eURC90@tqQ9>%b0IQjV)3_Rq{K&1&c<;@0=$Oi$5xU zuz`SQg#T20qgx>v@za;1^>kGVmSj2{6a;9>^??zkk4dG-gxQbe>Vh#nYxUz}+{OWn za)(jJ8RsHcfloU6Y}RCVr5~OoJwThE>kd$=kwLl~8?qa{`=C9=_n@MpQlNE70_78Q z&1%-l;_8bUMEDI_qF){rpRPeRaKki`4WfxPO+x5&e-Ow@Cs zg~vyD1=M{lu&;b-!bfSdpoYOb0Av!v)LP1zz!GxDbJFCyi@rXg&bl(#ehd_HcLZOo zS3Ct(QHl7u_u{NP-Q*!8J8NbTpxbY??vHS`X=!9(S1Ox?yv7V-=u$6Ho5&oCE>>O` zSUUE)|J%Qmn>Mux9k?z#4Sw`4+3|3%h@*Yx!olm5O7_@iHg zxONmzAYjz z$;ZqdjGJ#k6D!=+Z~i!abOa}Ril*75*-USOxh6UePx26D*hgbqUITMUribGRNoV?t z3?dz!(>+4XPL*$K2}~(Z%(Av~c&=yMntBim=Gqu^r|mM&tx~N6(+Vb* zk+XVk9~7Q%?Y6p~R$4TTUJ%Igy#Riw!8nIjNanmS|Ln_fHen08;uNII^H^Ea74nb` z;lYb5efgYW&=_EInD4~khce0y)&<9LNogMqhIEeWJpJf+O1_6bh+e_DjVf~XMME>f zwgAOn)-rX|U^8-~*_1q!O#1-AiWBQBSinK}o9!AHtgK5y{^z3B<1oU} z84-H_w#vQ7kFsMsYOb*B*+B6Jnq%&@Z7#j(?oO6!5T3(eXB~v`Jdm#fqqPW^925B@ z1Wh*UeUw9$#BOJ1lj1$h{_pLpxssp6qw$TiOQA-|Riv?jyxu%3DskD#cHl z`sU2zzT6|l5e|N6RZ_AA*2GO(VL}>axh}&AoflwOpt`!3*EZg_+IFR>W@wEa8?y=u zw*K@F&JmmbnJUknf3O@vw81KnciLs6JB=W$?|-`Du5Caw{)l=(L2~NxgFUt!@(3Y% zNn+$F)cKH7-x^(se&O8Zn~D*?wXc*R9GWz&PpE>hZu>MQ)J&A_Cryo=3Vp5=<=1SD zoAj9Ns?vgXNoVC2FR5h5*bQ47c+|AQy_UT7A`0r8O^b!k3sZk)eMael9-m1y^&#UNg(;SnS(m^X>yv+JRX? zju8&FBX%;kv1z`k!L9A);Sv|MtJP$xS9>uEZ3A(~7V>T|+&{CE1x9_ln3)}d+nKGw zlU;KIi^Ul=cDru8uqZy!<<9tcgAu$~IjktufDK*m@ZG1@W8Q9h=ax@pG?2|OM)no# zb0>AXFU~C^x}?g7zA6;&ctIFJ_fS+=QnTF^+{qjcCy|b}YA8%LZh2O0Qx%?1Pbjh) zV90}MuXVmlk&%&aiMEB8@BFN=_5OM0?Xbp~gTWbiH3KSeEj8~NUlLK#dP#tJax6R4 zb{xXF>V{OP8@8uQ?6miPWNrq1DefR1%LImrRaSFTpMSY;?R1-eYk~S>7xsit&W|3R^g&ejL_jvk zIHBhCm)0C~vJmezXo9Fw`buDTYM9w;dp0Zt!yl`8_MSEW*zR{n6r9(j6(wIA-G_t* zr_kDSP0#i;KmP)6V^y%}F0D=ue>*u0qhhSuSF-DH++^!@hq;Q!HDkSe#rXb>O@m&O zD7evyRrv7qdu`8ZUU1M#aIe<48@00AtCo^amMDK+Cpg4!k9-moj#YrInIB_HMk);D z;LLP2$4K>QH2-8z7>*iZ@%}h#)C1xnuqMnp7Z|%7^=5b;^b0aM`#Q$0cQyzmkjeE_ z66;wARNaOtA391eiWS=6j(&^xKU2=@(?5Ats@t+acoVIVWY9kr$;qlOvDNHaRmNB~ zyVS`c%{@ev5UsyitVQNx5RL@>*YA4gJF!O!*C>Izgcbox5+L)K zME{Z<62`_`DuD9UHrz_H!qM->6-hU0-$Zf0+dwX#KsZ>@ULSxPp7_jWTaAmI@nKDV z6C_89#7+0y0)@J~E?7JTb-Dg#%n6=4{x-Zod{H5BF<*3o`@!ff)%y3xlfP=({ZA{! zKmWUp`IF%}&Jzsgweb(w6&gw!oU%-Yg=GeuWk)Ag3|obvUOW}eW5P&@Yp;Sq(JVc@ z4m%uJ%O$_Zt#5zgj_hD2jb{Q61AJ;Fhu5_~rQn{Zv)^gpi+fy)%Wc-Rzxl)+Y2wr- z_HdU|LDP_R?H@i7RsL^XtYP56&BiwlzH^lqb}D2Als!^CUBF|ODl$?gR4J50qn6>Z zRM$c&8SCSX+&SrDCRF?hCld{8s$hejm`kw}DkgKY1t+q_2? zLOybb;;WFzH{fK-$e1;TGCpOfV|zhJ>tUT36_6-0;|oGTj9WhCz<`wQNk9b(OeglA zn=sb9**o_vbqHa1dR6rXn5Cfy*%w=%9)^#)%r8IV8F; z+3DMTT)T36x!4tBO!MuaK9QA`XU|MG`(XB6a57!dS(!k-TLbaR=YAc}8|50poV|&6 zconfQTm6p&t$in>OPmp;=TiaD{1uZCwhfOCu7V-9L%wcInpWyc@cc1?e z(p3f2g!?0e>?f7Ef2E(raYomB*0pn~eY}?M8yFtilARUGITd!K6>};Rw6UX-q$$-L zDr%r{VI>Rt;Bu#hj(?k$as6#n?~>xK8aLhX68E@y==f~dN;r%IAqybB*xS~&#h7~X zQ=dgN3cju34{ysRatL$0GhuWP$SS?PpYZXaZn z*fvZTp-=+=G%)MI`#*pc|Epixf9Yq9u`}2WZS#8;bdaZpZ;nxj*V zE4(Nuf_PknNDhv#jbBic-zg`5%;kyA<}i81@J>U0nZXFUCh_Vvb8lHXYgN{16SO!Smi?>&hZD%4>FEVws!iiBR_RTh5cqry8WD z(h6~U&yuR{0!41dn5wjN+2FNjKPoGH1V~dY*>K-dcRTqSVcvK=xEX9qdzmwB+%V;1$v`>OwARah@>@{$K^ zlKUFm-eDI#-FLL+7tn1M-X5A6#lU12GWmI}Xv7ML7jUj|2xez9*?CO*fxNHuEZSZ)@}SVi_CfWtm*CcDfNUi@C5e~g-pjM;|f2x zUC0(XcMfn0uo-&HECCyfE`}#ej3$r*fYS!R5Yv?LmY6?WXAXj=DI$#k%$u z)ZXG72>7C-*t@L6JMi|p_EnHTKbVY+lNDfQG%>x9b?vXvC*`tV8yx|yK_ahfY-e)S z`dc0B+})i9A3P2}BPwjY+Sd{}5~0`G+71ABGm%D!`GJ2!@Q{;FHmZ2#xMcGzQ}DQ4 z9Fli|g-x8*Z-*!AkNoQaGm8c?vMbVE>e)PM$R9L;_{IzoG9`~d%qFo49!n*d{)zX0JXBk+ zoV88~@~_~NL!{?#2Yw>W8MpnZH!mFzf1HfyN(_y?)*X;nQ0|_NKHZ!!9GzHwc; zMqLkgsbaH(Ym>|(oF9>T3ZA-D`e6lbcY1&Js0AL)h6&q~Bh|KAZr<1UniGWEbD}I% z&vg}fxUmN9F4TT?ChYTD)5rF-XunNW%Re}qjkx7rz>_GuK!R@otab$QFkv@-RsA;0 zrf$``{%I)J$XhS#N%&BN6Mcp1`twdxk(PCu#?ztJBLigj0K7tr@0hD`PT-QRd2wK3 z^C$$3M9V%TArwk?b+;V~^K}W1hG!Ltlp>r5E4tjg?IPIh6@^;X?ME)GB8CpWcFTxw z&nOXsB}*$VX9rNfzbyZ_oRFUusAHWYdKt*Ho<23GcF5*r5P5rHcSLuibTTK9ns~R5 zGg=Y>hg(VIIfq(1;!sb*xElp{$1UDkXx4Q1a$K4Ajf*(SEY;PIB6S1ZBALUh?(Jwp zbZ^4ImkKtt@$v+0iCMeIYn4C<-e1*n(TkT)=9L3>QaSv?b_(Cnypzq9cV?%4^C@=O z{98;9iXQ*Ys#klut4rLDeO)D_lNf^MRP)u*8yZIGRqVW0jETP2T4(qSpC_^0c_GfA zZR~Qjn&3stW^NEQ!gd7o&y5Jp#$-DSNR$a-u?n;}RP{pbop<><*0{$#?sgHC4k;bo zEyzuSScOP2xyz;rMz^WB(l$nPABlkKR*^ir1<}l@oe)L;9CCEx1+tpkm8Lp{O5@?s zUttbQptaXzrVx+ELnArk6R)p*aR59Qp0+NbSV*zpIpK1F`VIO?v|YazD?LA+Nx~KwHfxi)fvZ zTM(cxCR7z|N6($)`8-WX;u};!D=~2^*6jGp4C?Vg%8ySJ-4qolAZ`MJG^NQtyLsZh z&dfmP+I!*&uH;>p02H_8BqV~{nw90^Us)udzWCoWNt|1;YJS~4)!tuo9t(7wW?V3n zTQ9#7MVvlVK8W5YF>gBj9I4!sd0RR@f?x-?qVNk)Sc*CHOH57M(T`F3TybtgZ+8C} zs4!y2L{D}M&Aj-;s_J`vZ!Foq2I~`N$>no$kLHq?*gH|Jf=i8VE*QS9YE#)tKps@29UKs5 zRRzRLvCEUH0)y?iDcN%8ML%bVzq zHd=Slf6sUJcfaf31$>b8&!|)O3BwR=mqPHfAVdi~Lr#mfg?w2k=vA%gXsOlpbqRHD zGPjhJ%5$okXZpy)BXH~c*q=U)oelp$pzYb!e@0;{Tl>Ppp1g&Fmq4ccudj`ijIYZL z>1-YuT^XsLxPx{^<9k~W;-QAjn65!$;>zLDa7(KFEoxpKMWip60xrJBcux!@)6~ct zs!Cxw>1ixhijBNRtfuxqZD=7aS-aLt^|lA)9U1137Bt(4Dt=mKjNLIA?a00Q=<%BO z(}`mi2Yu{3J<=Q)==yE5{#pj7Y+g1f0Mp>ifsZGZPXgTel^|)>{VYL8gJlr=8dy<~ z{P0M@x<3DfX#6{mImL*9^fz9HVRYW&`>F!>3yG=A8VrouSz=98#5}~BHzAw`#*8Y% z;p3O|o|yab9pY_Bsjo|I>Bj9U9ReA{c^OEkX&PzZf^qLG2Pk;)Y-795yo2sKZ8or^Qsp(;=7SD=;1Rt$9YND?0kK;t1(#SD@F zC6MDajO5N75>q_Yd}@>u>TJ|oDKglZcU(sJctW)V8y$J{9F>`RVDN@|HiT-JjjhiI zffH7itO|?cbGIenYSREeX&H1qplI<@r&TeD+<50!$T0@Gg%9GTP}7^JZ1y%=RRCat zCD9~Ry6rH*RfG^XbK7NP0U%8^1Ad7f!|A@g_LPfmOgI*5|4G#kza(W#(w>fA^F z+;cb6u0T}_dgIZ5iiJ(|Yvf{TEdbTyi=1MmqXSbhMmI!W8L@#A5zFjjhPJLPKRW#;1!X;uVm-rg#yMlfb z#`=;C{AEyX?5gY#b@k%MIkV30lhCi|M}R@!!)m5|V(Z4P#%^5?Q24;cpcU=NDR}WE zOKtegu>!>EWhJ{`HY^m>X#BFF2kkV%OCn$tjc+QZc^#ftWH2Ss<$USMbX-|@WCuG& zD=)f0a;9;hbu+=G_nNK}Eikie8^ynE;9bhE(_%fEM-Q+sdERmF%*W!N2VNT=_*K5^ z*AM=SKR(lWx@PU*aPcSgr$vS>jP}ZW6Cpdr1mGr?qtk}ArZ?_Nhkk? z0zR_deCy`A!!{grfLU+8wLW$5$pL1aXV?E1-4Y+l7|lX1wD&F=?_&hi?5u3dwkMM{ zKh^?~y@QvQa89LG-MsfCH^uq6QEx~3ZwbrKGA_|0IeB5QSAM5T+&^oZb?rirKMh^- zWHcwX%SSK5hARuVAN<}41H_e;KOA~IFxA|U( z_2f0v=9j&bBQyil*P?6sTCEnI0zgJ!O_dbhvCTF8LN;zmRs`Qxm{@(;n)e}xDnX)U z5@u+o-MEEW;$;8GI3+?LF3UCV8ZksTkmz|#+v;5`RmwA}MVpN(74gdkb5L{;7F6yV z6#p_?#9>g#>=_%!&zkPau;j4c&fg2arHl&u` zC33ea3;vlbNXPOEGX#(%T2wL)jxi4J4WFrfpFb1W**AsPU49|vGem9no7s#|06aHL$J2?Gwu`6*+f&QO!d4sG{(|l4l>815`l|?m3|yH8;>W z!3tKu+G7tWI=~U~W}1i$rPJ011&b;q%R8S9cV7JU`EJT3R`&)@d@htfstDu-?$K4? z#qxF1L2O=eCI*z~18VlP_#eZz;QA}Iji8EfrL^q~=c5y^K~h%n3W$jTfJG}u(7Zrd zzM{L2XP_;tO?5xU_Wdf0^u=s@zeB1UdO`>_mRAssCyFD`Rv}U~p%BX?XNh zW#G-@9wX zKn2^Pe1$@d`PzGj@~vx!)F;HI*0oTp&?_&cB^D1;1$l~xSBrB_l676*f z8j$_E-s$Ih@^EUPsiJU1qD(w8V`zVL)=FqO~ZP+COHxC_8u+lKyz5m`3TJ#z?n_p-T$ZMF^{_bHi~j#Ox(CIz^$t@Jt6{N!m(DSz!Aaq>uSd8K*9$-h^ zs#te{KO2*+(^gr3flu4gI&Br3|Jn`TDVjQj016}02ez$RmV2jN@Q~mCzWq#HQ8*zh z%AES$S1sBH{qNx-zR)ajkus>2uCTEPHJD!}XJA_%6Qj48*zSkp!>e)#V&5`whiJFh zq<7SCK8rq}PZ%ReJU87Leur!%-?QI{R&yPjaN=CSybXDybnEw3uD?oj{kCTPaZA~> zZuB{|XEG#xOBYoV%%=Dk6c%Rw2r4=Ek2Nm+89z4daFC0=TCi)n%ybLB$Gcr`x`^K1 z%gT%e?DegHLdV3?v>y{2Iisf+KdZf187#awy7Q8|1-jtQ%+Ag*t8%T_P>iwj!_;(g zsp)%W-bUeP>4r-Rb(~d;;FYDY8fa!fY3Z6AJKH6^aBd!d?@Ue1JN4dCwX3h*?yS*r z)mFweT%^gE9TJC-=@$3K>qYQQ)^Eu5Q^FyIn1YI7@76dA`Aa#JUpB1bil|#Z;SlpX z;y>t*?On2}ficpZ#5-V&Z=OP2+DB9Nrz6#{pQ_~4ov75^SOnEfRX4n#c5a z*imTH08ye6dJ2S4C<%pHxZ3#pVeogdVV?y>y!O4heM@XPcW#eKP*Bm;W=Q}rFZ{xf z5^tm1!DfTn|H>y65A8EoE91^Fs^0urF4^~SLrys(ZoVS^Pj5;FeLLLkYggSL9{8lP zY-pw#?GS(&fJF@L{*QK3+@)B=G~fu@$^({*2BXYZCtQV{?RP`mx;=%BSRtwGYbNSk;# z#?4&QDj&gz&XR$Lg40EOtk!asnu6H8IG>Gg5tbi!#>L+D`gn^_zUtY`BC6ThIh=IZ zDKBq!$+@$n1TGh3Ew}~dkPTrj9a^!wf{gT+ zMB`n#rkNEmg)tR&jk|CW4srvRrMr765yH?e-|(c2iC~$>-7RMuO;LF^(7+^N_E~uF zq{wr5d<2f5g`!egvvY3H=q-?Ql#=XG8jjW|>WwgtuEcy2V?b2!z>o`+(^&|qD?2jr z)%~1nHyGrX0YiaEfd>yoaK<6Jfknq9<8Mvu@;=!ptb6?fM0)bG3~y5DzFL&8yiv>b zo&}qV>%Aqt%5n1m4FjmYIb(Tj5baoHu22jQ2sd8<4#z71at#BwEdpS&&C>5Ts$?{?Hn zoz$j3R#1jVtJ;Yd&=c_pryT+FwRT5jC{DS}2_Y}@ypt}t*y^1&`nsaOD9kh`jAXEr zX%I>xqOsgkS~Lvf2baproPKoh*>sukeBtrDY@au|^4ekHrwK6jb(tT!TGY&nvx|u` zhS)nJSAT%LgRwagdR2VN9?hr!NHyqlWdy=p*-Q{dYyb=79S)jQ>ylgeU|wjkm6eqg zZL{fze?|#l;26${Q}M`~+LK?cm)k~AmZLqbpROXB<3gbZ;hqf6%tr)cM;z2cRpibz zw0Av!t@qT*{L?W9q+;gTTZFsCBO#CuOq7-s*(6+iIhYzcLd^hebcY^#D$SmiE43YV|F!GGO6%6CDXt*2fsF^;z7S0XclK<6YL zY8EavdUwBJ{0N9OqT2(b&z75@-6)GJk6{TiJQ^_av+`snWXfhGo5V(BGTwMgk|^o& zC`LQFmVV=N$WTrkEY&STj8qM+;XL&$_wvH1=pg!HV_piJ91w`8>pHt~Vsgmt-yMXt zH&+24@fshJ^AS})v|L>c{aCctmOlDBu-is5w^|S1oU{|jVomC$cdFEC*vakktTaUj z_Yy;dMkKTqT0=9>j4x3Vr&Wc?*VEheCk2c?X^N&VYnXsUdky1tV6tqYc9W1DB3c!k zajDx(?4QgkEVeOU%l?{h@N>i7hW(DgD2YiWx}~4nm-sv+o1V(j=K|Q~y!u=8f;T0% zll{=3U9S~F91b$*p-ZOLc0t|RYZA4pKN}M4?f*&#W2f5^e8*Rw-!ZH}@tliKWgnhT zLE#(hys=4Z@;4aCF!^vt$%~4({_Fc%`~^eh_#N>LaCv2+1R<}itZ!HP^)*Atf%>(b z1f=ZyFT*ob@D>2>-4i|4RIX0Y4WBlzBGQ>>Nks02UpDOH89owfH}`BcK4EPC^WRZY z-1Ilx(C=ytV3@gKgG_amZWoO8;CZf)c;!i8nf`%HZA<-?>S$n0a{=Lo{M=uBLzoW1 z8*b%IcJpSvr%enU)zyPHZndNZ)SmQ=W^-%Z=gK0dk)o4U@8RwRT&O8NQ6WnUZ{zkZc!2P!6i`gEYjQdoJE<_`*G|h>M2nt{lJu7KER0Alrjf%$ zG!Joi>)YM>zGglz2FmKmYmp5)t~DPkWU`wBG%DSU$F<95J|0q?L_H@+i*d*8BT( z8(&k(LjIfuEpXfRmo%vB@8anKxTf>drdKq+4FowIvp>9&E0&ixeUUo!udwB|W)=wV z`F3tMFb|4T^Xr%*WgZDEcZk_*KPB*CbI)BRgTh)oVqXz1kic3oC_*1gcX9H4T*A%s zq<8bl;tM&c<@~I*_Bus$n`f0c-0_pnfYoHqirt^8YF^K1S5BgNSdYhRwm-bUPNJ3P z@S+hLz3U0&BD;Z7TtUng3(pK8|H$(0j9ql;f@$WVh~l#yQeMie-OS6Udo{})6qC6g zUB1Bevj8rOc-qz2cYqMM=J8z8XMxpBEin=Awxl zTW*wXWKybhb#`0`b+T_wMO*dT`&aHyZ?{VHyB3K*|L3q*uIRspy~}_5SHQ*}2EDY{ zeu;J&zRoeZZ!+J1HyJyjovy5;XL2HZ3O@jKM%pg}rjLfGne#1&NnHJ%XcyI6VfPsU zV+}msl?Nw?k#aEx)E26HR_Rbzw4&!0Q{)dF?aur!eDP7tdKClN_j~5n--dWWs`tpi zu&exTuVT)Fl3KLcq!41bO+%3@Xg{j2QVk^(5H+C7g*J_XxGm*Lb-|g=iCXs$TSARek=sW&c8^CaJX&_cZf*Mqh9Qwv*di! z755n*qMz{lutXF>%6l0FEkYM80; zD_UGyf#Rd^f?YR`KvG=<9W^fn*JX;}o_)Z>ugxRp9`2ZAaX08*?uT;KczO3r(SpUI z7++>l=J&Gp8Ek|7+|>xD=ho05vC+gsr7o_ZOX{?M3II@hG;pw08mXKi*QTQ{krZC{S5z{Ij)60`Rj&hqv=XYf*qLI z^kQ!3vD)Z}0fq&%&H)}%$h;AybLg{1W`n5nY`tW!n@j{T#-<|P$;evJ%pxs>wz~$o zylc*&80fu4`v9%_bPF4h+ulrG@^YE2QOGbb2}~^c;j{BpVSmi3^06QO9s1;wv||G^ z4*%Wb?xzNKbvLJ-=xZ|uYCF(g9y9rHY`u}+hvdGhaQvK_XLEHUbwpwq+U9mTe8Ep> z0r2(?3>6D}BfPhAa$~+`mK=TWqKn3q;qcsMJ{V0d=1vboH5s$I^l$02SlO zgv4i6D(df5qHdQ93K4fi_+TzyU5bwIUUO=vnuJ8DF5KUc_w9zwQ=SXkyL7)dY`@v| za=x#&Tr)%&?HJr#o~2x4UqKoTGSg%gO!OCm!^x9;RHgpPN;(g7R|3WgB>~SuhxU1k znNxbYE70#(V+?cTioC}wq3!#d&m4*Lg82P(8D=3*{boEU)#S*S;mT_6AyInU3QE&eqK-T~nLYeLsk1-uf%L{bo$|hdB9zfZZHsrh%hHa#+L5yi zw9!SM86UF3ti<1?<{7Qfjsdz)xKwp~v)cgmGlSkj)w;?|!_$wJ^kG}voRGJh-(e>- zlWV5!hJxJDivk(@@sa#dDaLO`zPi0CqJ3s7r=!|b_eo73-_9YW53hZu?!u31ywtvl z37_k4;Eobje-n4#bnrMwENP3LT`JySCU$!4}MVb$=`=V?=KO`A($P~Uf%+?4%7=nm~bZLzYU-VYFny-?u} zbkbezbP)>`R;$WL=&}J*y|;@EV>S+A%^ctOG^rOpnn9P}Wocf+p0@n*T9c*zW$e_r z+vhN~SOBy$Pu8S2sc7gx4m=Hgu($_YzU+_ZQ;o|MkC0sqrlXO9*3e)oyznLowUC8b z7g%kD$pgR(kH-VKMkrcOTzuM(PlEMMq-z;C2;V0iODp!TaiLAE$+^l$;5_p%>-oSL zv)+lW$Ey8@68!cpclFHvpeZi{c*p2Xx_1?xvNT6i0_BM{-C9AoLVy}TKhn2+a$Ct^C_DHTzNQrqUC}s3Gx7owMR(R?=l>2kf0JWZ1YYs@y^fL2 zb)O-ZaJ*Se%x((FC#fPBdB$8Pv6+avXMUB%a%V(_V2vu$?pq) zlY(mR(z_}MDi;;85}V%{;}7;cj(jE4rY__ql5AQ=kg=4m^P~59yh~<4z+ElbEJ3@bB2l-#r zy?0oX+1u~S6m{%_8j-S7MQJA40r`#t;G=bUTj9~N*udD?o`v(|lo@6Y%BA){M_ z1hXlk(b?5bjoL)4ODS}mWsL3XR`|KxLF=f8 zWbgc}Q(&35K@X-@f?Vizw54B(hoEImZ%icgY%gQ_kEM;^2HNBbD?N{sN3Qx2(k%)I zy!Xvl?Xp+!j5=x=V|yK3>{GXZa=iV>*=zgvM6+60$&=n!n?#4~zF>;ki2H4&(j(bj zl!OR-+~`at+{^fSCN-_<-BR3B35*5W&1zJ^UJj-3`a}_yzZ~be?UCU?4$4lP#PRKM zyyG$sfmt(v&VA&4=C8FX%W5K)sySndU`F3+gw#es^s>j}@s-57HHcdsl@*|kZa4H_ z%8D6#&;9-C!|+>09B0CwO6^P7DzG^x_ zR8nbMhNMZ{|2@&1>#X`?LnfP4#XOyRBsM!sS4Sr5wbK>@Osj;!uD_{WIA|JPl3z0s zs>ZX}W#X+ybryV@09LEKdn?#v))nN~(~PRa)n?YI&V5fxaoF7Lue=P|+75igXyCuu z2wu9ocUFIQ(Jvj`PNP#5Rj8UxPjoK_7ZAD^m9mGF+Xg%a&@Q}Gej4DsLccU};DBma zRLi7O#TniTy{Mq95BWlTPb=TfyHj~#(_K3RvIlUIut8^UDm$!b2fZk9+lul!yLWuP zZ7-3Nc}z>!soAQ? z`jJ9L>ue8YB9P3&T2s4ZgR>@v)Gn-e$J383HnXqA$3kF~*x#!q=32#13z!B1U$i&fGUi)WZYJN>J+(j1neg++mtr+AeHsiC9580%BN&nMy+o+5B+4xMaobj}w0GYKOLcP`+ zAHCCATmLTx6a9de`(hujz1wHjNL{`EtEgONip-<5N#__Z%bO*4>tFVeIx+)-uowlB zPpD-+62Lz%y&2)G)rv{I;HSysiZ36eGy;aL5R)5G@w=LK&TdEGzpQHYvgwagye2a$ z`sD=yl{eP`*kno?_H5~tXjRDL>SbN7QPGDtP_KG(@fml7i`r4;gz1sFR+USN6=lux zOX%Uaj@fV*A^l#K7%R`Wr`&C-$&>VE>+LzB%5mDXy{5`15Wan?x?Q6(e(Fkj#_0+Z z#wY8ne9F@z?|A3UWTq^muT}F0tF1iM+_rRRJ`-2G^*TB?URKY&NNjmec2N=(RNCvdFA?myqP{=ZElZq{WO)iB;5wBFiT-%#fonWX>FHd${=C-KyR=ly8=GfQkuYY!AsAY^#-0j8{7e_4MD%iQA3Q)FEGhQH>1Dy06| ztJ^V^GoIsldpkDkh?WUHA0##O%~D|Kr}EhBJy5TPq)uxd9E~sS;D`;P2H$QckaUkm zomN@$xXY5ka|wECq97K9Xp8-B$&28Cfk-279CBa(Bl(h*VI0 zQ%R}6GWW2!WLu)y)MKc9Ta&A6c2;BP^I82LkyqtZ5bE8>x*Vp+CVz(?SO6cFlmj~K zbJEV%qijmo((zKVdEs`gAHtu~F{{h_kk6{GQy+ml@R zpizCSIj3Y$DNQDR3}g2Kj)_xLaLQ`Z>wLYLDqz07t5RneO)1q@OX-rj^UqcJCK%7b zv>6HO!rcBFc8;k1^D(;XFjU9ml!;1aUb)$W8Kp3t;Bi=3_2i@9yh(Et=6?a-?0ydwMwmwhn*YTmcGy`6%*6yGLji^v*d+_@>*dqQ8oJT6hST=VM2a)rpT0L3&mRXzMn%%gXP zykWGAo~nI`uWT$Ify5XHaNLxSrbZeD#*MQOhg|ABi*~aT^;QFOdNEJR+~_!@JK0b- zH2=|LwAr9mKBJE`1lD1U4>#P;v*UCyS&~~!;FX^_8$i0LsN~6L8aL1nQaXz=m7dUf zFjN9tmj=t!X{|Pb{i-l1XHT4^@|Xn1`a3Kb$g5d6mDtE;(oBQQm39IS1D zc_O&epXY>OuW6ECE_Z^ow9&Mc#8Qcji~_g>d5yE^eLzEw8CUfT;*-@Sq~I!Zyy zbiU@or}Yobgm}9S<_yV}SQ&biI7RIpprS*N;jP>?@Jh*eSnzk(S~nw{gG^v=Hh0G5 zL4i*k)|{uK+Bdeivtxp*x87NJBVjg~N@9 z1=9^n44x}81gzS|4jx}PC~|y=71-djvAx@YAz8V(#PYbek+rq* z*6t`GSuFEV?Q*1FMzKb)*$B$SY(}M!{PsYj27~8x;=6L)$iiAOxp4E;jb&B_+Zan3 zkekd2!8*lr0g-|T!B~B(GaBOkWc(J^c0V^vPA+q&EnP42QVrI35k|HzlS@<`1B$Sa z-B73rV_z_)A8PKqNSPRGi$_bE@rb?;&}gmtI>9LJsGeSVRYdh1d)fy&-v08W zZ8A&$!zRCX`_M|j|LWX8e6Huz=E;kEt0I_YcBhq^?rlID?@riKDj!`SMHNfM)_3C} z5q(lESEez9F#~BJ`1Q8le`vQWAa?g?q7;&1=fMpKtQb{8E$D)W1gcUCGFfhNE@dqh za}|NTIJysJBj~nrY%Z$jr2BVGBeG7F89urLSBt8SJJw8J5{ul{gi7hS1G96j zd9n66*&1~YM@KH%zU*Sbta-4hQlrLap?v$g?ibfLx#ag@3}#{ZOloQctXFRwBzrW; z$%}9c(orma*4&HYG@g=aT36q}06h#H`mRkQ^2BGMdsE>|LXRAA;nhaGcG)yDs#$|R z8xu0nRAG~0caH;cm`f82u9Rx2ltbXxqen0I@GX}0>K2SD=6xPAp0Py}3nb%R>543l zk%Zkq9}>k^Pu(1^z%w56dOX5tco~-=$g2ohsbDTPm-V_D86EK{X3!?`Qor9aMmCYjY~AEW`UHp0xYDOUqv|Xz6!^Ct%EOk(uqErRskH zZfhMs0hV{CRVlKM63LM2s*6QCTl_cQ;P|?FDMK$^rgH_u&T|h7?=f27rl260TgS9b zbVxa+)EF^RT(vxdfAtgZc7z0crUuH+;BP<8JnEOqI~m|}jPlI(5LTuTG2hni)<>US zqaf>W4Q_7BzV3bc{mJ7bb5ondd^3hD(__!O)$u!Nz-xx>A>5c5&rxvAV&v*`KK6q_v8kBg8LG#W;(>HI)XW84bW{#CXM4ee~yv^Hjr0 zK`R-iRbDmyF3jIzFxaJTeZ5M7I9a5oHqiKBD%tN5=3>7Rj`b5z!&I$X1&s_qt_j3s zoehNbsK~mVzLUniqFU+GXnDI^pE9nyXRH=rluo(fGY-kdnXr~6K1F@JN70oRgwSr| zv!hQwXqaqYsmuhg2%?9^c0A)b(;p{nMws2*Ls?lkE`FzbvB9pIx#fM8l$<0J1(ir_ z=+`)xA%-*=JuneM+rbPe$G4GfVsq_^I%s;hd9adfx-6y7rd|AdTtQ;P3AUSD8za7p zWs_?UU4OXP_;Ipm4I3qT{YL!@uHob>yBQHzLDh1kBE_!C#uZ&>PzUl+-4B_bne44L zO13=1LBM#rpM{8@g}548wTaY3&jtQurm+dpK5T2m(D*7p{j<=OxxrnHhs~x*zjTl~ z?z^m%z8f?bf3PsGBV)>59HD{n;4o?(I4t$8i(UUB-f22*R{5}Yz|(Y#A~UuiU@czZ zit1$FWO{U+KcshHabIkDuIqP@yqZTEjm&n^HdpyhhMd!z2}Q|>1aH?dXgfnhx64Uq zTi(!17&vl#WsQc}!o-tGRIouvgj?gROnSWtEiq^#@5Jtg3h4F)jUv0mQXEl!BTqSV&wkA%b7_am(71V$k zU?ZBF5!@;cLMC95om_^zAkWBZ-=}l$QYW7jZSCU)l~lv&JudacKWn#C%ABbW#xN;$ z+Mk60Iiz9hQW)s_a?~>rq}%P6Auf8k@;J6u#LH}KM#jsHa*o-U6?E}n#%^!=mTx-q z_-dxM^L}4Udl*H}zqRb0f`(i7WEN#lO)cwo`lF6Tz6GKmUM|Hopu#w&R*7+MR#|j(+lV5q&#VwqLjkDv``m$m(KZ*3i_%JV?--eM}N8} zZ}gVDn|J(J@9p4wF>hreM7=_$gGb`}=Bqa-VtE$ZQjD+DBZblDpLhQCZbsA#-%#=H zNrRN8IB`3a)JxaCe@ol6N#X(7T1xwNJA?4LdS3thSo$#E(b{+>+R@8icm2DuA!X|T zxu7b1;n-N^?sn+|Ltw0nkMXrkj(An!)o<#N+4udKR1IfG{ff%NvexD~<%&a9rUtM! zMhO;l`nFp{`wN5RtM~T$ID3-?ekykdUI~Xvn4cc~KzXvZ206E--aVpE#en_j_uu{; zsj3^Ud-?rQnP02ruECnpK2TEIb#)Y$2GYHQW4H6S1Q`;`+v7GXNf2+xPu0IxkB)0q zER1@>`L_40ZIkUC=jxnJ5?4?4*&b=OX?vZl4L$leo-iAbN{*<;CXwnRc#`L%2G{93 zDQn91KXk61tDNCFM?nmrR<@jhvE}vl;j$9HL~2j|$1JBImNBMe(#!UmNBEV;ABZf! z2e}z<1|uSvzt*r+K(rs+o#-w@Mpu-a;zmtM+{?z7rUO(37hd*m1WEMHzpCJCWybWv zqVn6+QVF~tSHl?v$%hpO!$5T#Q7XECzN;&-WL)e{7?beA zIOL${Ra9IvUPu-j?0QPyAVJdCe8nFuk4)>pf;DhF32wYWv`p@Tfo96dcD$u+Be>JL z0o-AsHUtbwJgy^PL>_3vM56F7C@RS;qN+2d2HE|8h>?17-d=n$=81|_K}F{^D3$rl$5#OPP;Gq!gV@P*^%lvh3W&4dBjp|~tEx;dcAaqjgyOhlBN=7qu-HJ%^N80*Xrm-agp=UrzX z$h7!K8HN4U9_63s=708B{2^v?Zpu#@ zAC@-HG$LVRN=omB>8d**!z(IYy5p)7hwT~;VHg?Y0>Vx5Z~b@!B&WLKHdx0%E$(K+ zpTELUh}U9hl7n|m(;RrJ+Hrn<{E{kq{4_NI>c9_Qi}v3SHhj;L?03-;$mN|LP)79{ zsrZQ~48WWeU&rwSYuPbG?3X?_qwcV>rR3aM)%LwE+Lo{9Pei`h?*!w9EIj}z13FQj5v7y=TnI&;ztB4VeJfNw<~EN~2raFx69>;}1O5emLO zzTcN$kPHo%(KE}EnQ?LohL_;{D5~?)e!NYW2ZK701*>Gg=+V8W6^1kBt_x;^1sjn7 zU;U6{@Qgz(AKl)ly1=ZO0gYM~1Uz-r5bbtRk?AE|LJek#vMhg5ZcuNN?CHfY5P2nVuzAz9N&mV_bE4u8iM zy*nONT`}*wSMCjx1;-#nmmX8S3M$J~FQbjL48#M#D|F0%+9+r$mh@|MyDKGl+sCI7 zx-w_OEEYOb`qMBs7vvu2l-0IRHA_ot8E~Z)w1ejx7P$@t8-bA}ojXwm*UZh+rQOvz zKix(tRJt*uGhUazH&mM4b0Zw;-=txg2DR30+9l0<6?&?!MS_-W+v*uv^1Vm|YIB6O z$Zz|VAjPKDX%C5I)#i$0%bo)3ILy4+l0{Q>!KwnYts&rGN8M`zC+UFbrLk85LOXFfYSuQ#yZ~d7c(t zs)8J37{{b(`;q89eak^(u}{mYOw~+^DFe!9a8(}oF%1)LjNjvdp;Y^ zfIbwZVt0+Mno33)ee4@}rXOUnxhpOafZzY20LjK?lCcyRoB#yl?jLwQX%zghxYA`_ znvu`Cx3T%71usZ0ZW&cehR)IH3}B`}8H-MGymalZ^0o0;-a_#CUwC&r4J7+>bN8VZ zypdG?aaQt0FeR?s`HFvQEz39p)CSmA{qp}G!|Gqe?EjI@Wf7Vm5&iutBC=iJq6CN( zyu+sHxo@q{z=}H95u-d?s_kTqfvIc4>njLK=U8d&o3`;!RLke=$>I3@`>rD~7^%lI zXCsGDbh_VJKTQ6MrO*ONpL_NEF}4R*^)yXOyFsgVJX<*bL91ScecMcV`H}hA>}qQj zZbp+$2vmhI$m}jU{G1&u&es&is34bWJn8LkC-YN$bdnMyGh`x$SIF>}P5+2uPV0}d zWOP5`dN_J<^t1vpTy?@art2LEVT-ajGPQQ{4VpXu@k6irqqzP#j~ZagbY??@W|8FTwR#TCc=L z&uo+m@c=vw%;Hl@m3fuNKK)Tv4`SqW_H`q%nGErZUq*!g$>$q(XHlq%38CAjxQP|y z^`~G26Z2k0(+zoC#!aUG+!T6Hi&;pqPxr?SWJRUmV!u&n(3@yI^`UzrKtAPi3s!ye zb|>0MEd~zVU_Q*8`YiNW$gWY%;@M;D*`tBIfvx=>{J=h-bf2!~u<1!>bVN`!YhME; zX#kNGta!Rtf}@bVGN1PFCC2-_c}oBFUL3>TFl`pgK(VaZ4RX`;#~G(-=Hh+MTms&2 zW@QM1xQw@!9T?;8$y@&U_LB%cP0vLw@A+&}urss2AyLO+BVcNQBD=EI2pQ{Mvo=B! zq<&X)R11w_j~v+I>`U%8&=_xT7a=)OX)A zO+Q4w*l0Xe>BzXKs44>1L*p2Qj}oaB{qRAQA%}MGukCt0)lpNKu${b)Skm-j8RY{|sNSsO&5<)D9;3q&O{VJgAB!vfzENVn z&N9a;Dk_d&y&Z$`L2)e>A~UCt<1omr=rbLRG@E3ehE0p4KqfnGM3XAsk%bF0eDK;a zSkm^lan?z4x6!LksN``3&#uof$LWdT;fF!8_xhOP9Wy#0EkP~v_~UPsYTvcyS6kv5 z_;P8h*2lR=MqT@pq-zFj9S?XiRa}rkrn=^+kpg7Io{xdcG;)%N-cqcHuu;gbbDm6S z8O2Ud2%^~}-M9xMO04C4oJ4G5I9}E_Am7HsuX*WdVX5X$^-l+>CoeVb_1)rfR-tfarW9=^Gy0VOsGi%gf>#ARcQsz(Bzh5w6EF6nQ%5F3KT<_X` zG{XhryBhZ)m7w>_YfX)+Ek2Gxi-nR&@R4`Bp^HIQh52LI{)qw|eYrWtLb3B_p}CEv znOx*P3SX4EB|Up-^@l3o24U}K%cnXG3<{}FxHPW`rwk;rzgK!^D%sS~R4u0$|6AFe zuO8D=U+Lif(?IjOwhm$S0KF%pn17kd_Tz|0s9c|r17>=U9%HA%jU?i^Eqp>hcx}0x z8x+9775;~WG7PYb3VXlE%v(7*Hu0r~jpx-LWtexmO;-?syAqb@0rAzJ7 zTzef@gxF1|I=}e#t_FBEv)lc}`!3Al>QeXyvsu*6EE_Fxu;PbU& zGSB)oVZy|hxeT`FH|njI{rjz)R<9B(M%X#jT-N4b7TOwkREPX>+BWR_U$$!&KSG$F z?`#*7(MeB9C+b$MoF!NYj0ly`_#xW>?kI4t4k07hxkr?6Mw#=)7a#0UFTRO*(2ROE zQuJq&H6_8gL z(f{ym|5Q)(kItSYxMpk?(Frw<=^w2=*g~l)yLrm?pwl1j(z5k=T65&d3}K6Nzzq*8 zQg{}i6J4}WFnNps^A0UQT?ucMOSWm}AT?r48q!RQP%mJo9~V*JwVZW@)RyH=Mh2@< z=CxtIT%~15jdQ8t;Sp}3ED>7S?tJvTC44BylY}xqn(l=N0!h^Cq(=_)CZSzE3k62c ztX*xY*XIJ7tG;IvadDUeD8%??ETc2nT%ykqu_G#p)(7v`)rE(NFd2tCCH))x%Fz=Q z2aj*0cqG=i%!#|X9qTpU@Ab%B>F$_zbUUx@N`E?*wairm`@hvZR+4*e*4;o{1~!-E zUe-p++Qd>oB6A4nRkG7^nA{L%Pw33culo1j&3{=O4%yzV-TkZlvk(bs?ju~mfA=fF zk;=VGtN&pAX(R<<2XC2|%Oqz8vz$y?Xtirv83XkR#>k)tgGFH`oC%hWB#rc0=teDk zOr`pqDAL^CR@(f`djtARJ=uoz<#UMe_2E#z)}S6o)m%-(W-=Z~m@xXCcnu z3GaYe4~nn^f3A3g26p@hJzu9h+@e!%l%7_XNJK+%a?b};BvLuc64_(#ZBH@Q49Gjd zwu+2cmKzlW!Yf$3_j%X=(~Wt3sy4fA%Yck(P$>FxGAw);RX6+kkjn+G?N#T1seC_l zCB(yX#=R1d6M9a1VYkr(^`M5RKC`s9Rc=~XZ+`OMKAz`DH+{3xTo~ekvpHJ}@`mXY zILE`Q7}efA_H<#kP(WDvlqyiYG4K5-7#&nZPIPwY#2uZcFN5sNa#ZY0dR(WbQEhcQ zJD>j0ne8zIiWakLb9OWV9`&97c3r>A52=IRysP7835P4MJ^t~TO^>f!IE~%T&4<%; zkS_Gt*g86^&eRlb>ThAeh+~4rijPH9X=(fW)OXMUP3A<_y6QYf@x51tIffj9a9Ln7 zzy*#4X(1bvwPGy_$t}Q)Us@pxfEMuLsntd2KU!BUpRdsBAYpbp?*Sb5V!o;|SrGDXn^0%8=JeWCJj+=8TIY!Pn znaeTN0%K4C1G&DFXRIfSF2&-E zscKWF!z1qqja~}*o;gr&ST(E$q71otJNny?&d9er5`Yy3vMXzI+R-yy9b`|9ZP|t# z=K)vjf1A zt1Z?tw+&}nmJ*uTDUCp=yzffB5lp5Lob$44WguUc;^_bNgY{3_W|iL^?R?TzmG49# zO)OHMGcLaAP$y)RY5r6c_p~JQkO7Qh-_$FYgwOYKm$d^+o5~_z9<2Lq_Cv$tx9_JO z6hssiIZ7Td)-;|2*5{(1>~d)$G(*t`%<}wjnG~Qp%sMt_X4JqkvQ0AY)y@GE@hn-D z--bw+?QUXe0W&Y$ET+vC-Wt!57$Xi@TX<9M>L1=!lkY@APc+`Zzb!NE)5y~EENz{! zX+UsfA}|L=gwd~+{j8BDRlXv0&^toOP@NXkuc+Q*J6tJ;%gQM*Kua1a#CU%ymug1U z8m7Xzf1mhwC#f@+(++#5U+C6w_9Bg(H06(BGHg$&WjyUgKmj8mqTA1+zgzR_VP(EK zRk;>ii_ZFmxo!A}pVpu(b=&C=>%GekjM&<7x~)Be4NK##_Ls^7c)5t)XY*Zb*NePQw3pgzHPI~+ANN9P3pSPPOEk+qczPBiRyK5^LDIT>L;SHau0%~yKmso@nhdxWn!XLs>}ZTu&PM!w;%LsUc7caYO_KIc{r@U=?O!s){(;w4 zbig$O>y19j(!Iw{{c&Ul>ScA#Ra1^!_$Yo-jBo4gD2sEQCKzOAMPo(h~j#s1*rQx+C`{zD(C<01 z^)Q%LX1s;nAb)b%FM>H;7$j*^&b&|@0bj#>itm^br01NDN@wp$%XT7=yRcjVaWIkI z3Bvx=x*j9FGzcpprG`L^SBaPUm86*CwlEoL@IAUiT&HNda=E=7-*u1rJtX-hqR(6! zY?%9qJ|cq$(`dj91~CUjQL?~b+9QE3DAt;#| zsr>Q}PsqQng<&Mueu%0NC^49X&g(&vIygdaF(bd#=W%k3dG2)36i$w4V6Cr;XE4() zj&#*AfSP!u(5cej>Y*EDe7EvjZXPGUYJ;wS&wLdCi&7@qFOAs`zxb;CrI7lAIM2;e z61k@h+WPscwmM*=)o^fAq`wsz-Q66OVE9%0OKJ93JNU2fOc5>8D`(SFPRc>$iVz}w z%@bdA7q$U|}$&99i}V zprs84_-px2u`&HZr~ZYFTG0UNo@D#g5s=utFj$LVyH5r4;IlAs62$hzc3!% zQ$aQiT=fIOdu3Y&)qgN&io*TrrOP5A>M&t#C=xoNg>cQdSwITSxTK+FHigki!T_;{ za2l;B{Cw2R>A%40GgnVQu=h4>i-Q)5>%6e}SiUyl;`9_odWF$6B0@xrgsT~RG`tKv z5$*eiUBj1)7m699v;90%O-GqG__DSsAm7KvEYCurP+N^h9fawtN%#CGR#95sMYVCJ zRu!F7E9L9m0)@rsfE$worM5bMs-Jl|psies(0bjgx6HGOQj;$7Z0WRPrDWBH@q4YS z=12AwE4;b@!}bXe)qn4J-y{^*HaMrIiCKiV0AH%a6#q8(XYfkG!ZKwA37wnpUsb~O zh%$;AEN^k(8nXeOKeUYp`T;)C)Q|}2*#%~2by}jJW3KK|%Hx8&F==986!S(=Vwnja zUkd|(c-nw#ULH7mv-VT2-l|(NapCm~-zwK$|GWI z8!Y3*TbR`m)-5i%i8>$A{IkiBw{TglKipo+G#Rg#AsSdbf;w2|IpZm;CFZT#{*9Q|*R;wW#(qm=BdV}B(ZOcUNkgZ2%`ty8RAXsuPwLl!sF*X7>(X<8d}bO)&9cd`D=o< z>#$HdQd3%X|Eu;F8?LW*@c-$~Jd}zu%~g+6-O8OC3cSK{7JsT1u!0bi9#yTwc75jd zvg%D8Viw-fQBl`wUC8(q-ng*Vm)PTpBfCe$S;wKAS6O4xsL=rb+a<&h8uJ zpe26b@9K>27p*?uDl(ely<^fjdOa2Ck+|hg)5-Hdar)esVfkJ?Jg@!nYafGqonw{T zCE;41g~FR}QEzWtG1z#o6ugEDzj|*(CgihF^wXKsmP%Llp^(#=tDl7=CYQl~9lkcd z#ht3xlqBBm1bFp51<&6P)29TRl5m&ZYQ6a&(bEk!$OW%kxC8wkQR`!?Li8E*RQa)cMB}`GBD7)0?zI?+cYWDEkYuGQ3kAVzp{Tv3&u7v z>8MolC~~`F{KJcu)_Y0jIPl)I|Hof}+0_5aW)k+F;zzl8tl!;$K%TDXpNw3$Hy&{f z;sIlN7#z8^Nh)*9U;CKFqNSy|dD?Yrh;eBFg+K#v00A4(+@|-X^<`ZWh^RRYs;>@4xQ6mMKOe-T&ZeXmyK&3>uO{KLD5F`M^+`?neif|_&Xp5$uoiDb*Y4dW}fY&;^ffI0fBJ% z^|G#a7+wy&b-h-MQZGSg-PWMV3ur4@U^SzQfrGGq7e%i}>_QSPH&{uI^hNSs8V*(q zKQ|ch$cI77DC?JtIb!3=U)SOEm*7^z>+LHrwCLcu;%fBvxQz3Cy`E1AlTxAH-?V=J z4tcF3(>Fueu2QP~+*FVYf8mR6{)Ewg1VFxY`F}(_+kNd({SET}{(Sr^r@+5mB5p8M z;Lc~O789=b%ROhk?}SURgB+1Sw2k13GNac8t+DqY5~SEHPhVy+#w#;Z@eL8GQCTSr znVY>f3z3MOvRUqf!KaO$c(Bhl^qaGTTkM_%JNdw0AeAjPOaKI-fiTP^mQKnECkpCA zW(NW%&!0zv%D2w#1#al@zNgOB)sGgpGn9Q*@mkCuY~Tcd#fS>+AbJ1mm_9 zt~`0jHB3Ird}v-!K#Gn^iK2Fo3%iVe+zab%LoKgtdU#_Z9;6aFtr~0=ujr3+qN``N zB=>bU*5keH#SNl|rpLUv^Z7ps=_&&k=R+py+3A^Z17~q$M}m8OM{L#xP_172GLHM( z)~}v%_+3R`hVKe$_bhLXiQW+zY>MO1Xghw1F7*?Z*@-)nL-VOeldHyw+yN9!Bh4OR z`pXVXc*Rt@S(ZLIubrMD+R(;cDBpGM?UTG8|?{wEUR9r-*{^;xd@U(%rM)&-5xgA=Bn&2B z7SU&xRZ+Zkh}iZAE0uMQ<7jN+oiZ_-(pRAH`}YkeJ!+c$+EtM1uIinsCH9<{`GYHW zO)GV|knz5@rjH*UQyTmAoSgFp7F5O?-uzo7Odp+2>6o(d zv`)`YVFuf5j5B*WV?MkG)Pgp-qQ4U6SGE?I_mB*+LYCJlb)S17rVzIE=oEAnvmK;h z-3PA+op_D!eBqUTM+p1Nf4gGe`UbyFi4p7%R_#T-UbptwR!TIc5~5bn9k=oUwCxad~C3?pL^M9 zkjXFcKz3v;wG?F>zw>XG^qWCOH4J5K3x``CD~Hv47EzX<4!JB8qo})yQl!MWj*B@TM{H2yua9!CoK_a6|L;Azql3D6QDf1>+|fM#ezVbiP5l31PjuF zKggLXWq#;eK`c!h+aRz=r#Sh2SR{+89qiM{kW}yRtR+U@A*c~Is}@uN=62d^q>SLq z^|ChJjN80H=T3>urYmzzn%!1MebDXepJESbDZ_2RQF3xni>C2@vEZO~=^aY(+UgqM zyv2w$4~^*;t-Niukc^cCb?o_*GH3nET(Uc|ypZTi-Sl_nZzIm?h+gsj)1U`i>$Ku6 zts`uaqmr2n6y=UM%ntEPhN(Ri*FRfgvs$I*vRU>D8XM#G00vP07jiC>jsCPWz)V&9 z_NC#;P4T0gS%>Z8;jUGC;}XO_tNg%Y?d~(SV4ZVMR@%=k56_gMYT3|*nA=}E5Fc7J|ZGUX~nYxQwzYO0pid6|Md z#pVk!TJS!BcRW=!Rk)H)X%lMtjy z-5C5qxU0&DLsJTSOQ70dHvOoa!aeEpPp|u)-b^xENfYe(aL*zJ5YMCyb!cC*7ZN!Vd<+n=0n`_= z78K6A`E=Hqb(u>=iY()Nd|_9!e|h7_yE_?1aG9q`mo)WK^5xu3&i%oZY4Vur-MO1UbS-5^47dpM~0;1Am*}Uxr?Mu%bzJ zKl?!WT<#SdN1&LJc0`FNOL%FVC*zSQ%=$E?@r#8$I^dF_tNDuwrsEsi9ss^{G#z|< zsv3o;zg^2H#Qr#BtmF&N1G&^YKEH_-HxVsOMi+A*jInhF)ofaI z{S4R9qhmMRLn5*QGsb!l#`6c9v&k!deG1S=uQFC3^bdO)%C2nl3+8Uta9e%Vm_kxP zj%*?Q-ds|QH+X4AM;K0UorYc?zVqL8k^kx+ENU405=77y=ktt!kAgB_o`8rZkL*TQ zW&+ZqUQh7D|NNpKrnl2gA*8&YJ@zDw>=t~N?W;EY5ZW6UDAb`*;hwsv+2&`VCUVkd zc0s~(@0zHj>*`8XpyUBA)@vr&!Xl<@ED7t9Bh{*aGUJbQhZC`xLkfsrG=I&SBYQ+V z9bsQKxph6+I}`%PiPY+V;+!Uoqg0;7&3YLhX&3QNOsPt0fG7e-Ls&WcS?DO)T+zou zcRJ3URQ6}tnlV7EdWQFpO%}phv+^(<7;j`AQis^M;y#L7He~b7ePpEsL1Y`y` zznxw2dd?Yw!LS1fq=xk8#XeC6#(wmoN0D}G)@Krs9%B>UYOZ&Hbjv@Zc792>{1e#c zU;ig=Hd?U_FFSrcpNo0}N8jF(dofm*XwcsdxXl$XmM+lECT*RM*%iLy{k7q0gY=8z z(8V48?uOs|?+pGbD#LokUuSByOC+M|$<}>eeAh(kk6E!NV&dH%0tDbg%+fQUo@bk2D0_EIb(81M)Y zcc~3@PNCNq|&;vWXZn zep~;ccJ)3%D41ZgIdHh%YuH&e)bCcr`zEGLTUol`eS9er)X`eQg5n;-DRC{FBJI&OJFhtjM{_8V` zzrK=o33pvEJPM)h71}9yCHc6XPWM1-d4fJJZ#yN{$+vgQQU)O)WDn8WiH2`+XrJKT z&@QPv>eYLktQLKl>KS%cV_qV`)c!B>XQ8^4K(N~tH=j|XRkk>7+{WQ4W4%99XruZ|Iqb;zwC2QVhdpO^f#6>ss~M;zU0H( zC)z#2Wiso>wGaTheo@yvcPd12aYF#lu(rNkHF?Qj?@kXonM@nd%yM@zsN#ryE_A%;%bq@fw{OR#M33|M265v;x4-e6S zvG9dFGv#;Xh|0m)WMrBA6JL+P-kTmN!Isgv2OFx24UsXqH;W&v)ahcXwu^r0nUBsH zD@j4j?*0Zp&}-QEd~l5McKp}s0ouC8xN$r`wU4pAK`5P}ezK!!{So%22p)Q;mXB#L zibMTs=X7aw2Zk#uLpds);>=2%U2tlZ9us#SdA`lEe87KapC9JLi05V@ubdjrUTwjg zkDG>#tOeY96SKQ;IV+%u!PU}+4H)!L0VdJ6}iZ-z& z^gZo;;nB9Hq^B}C=POT9#1Z=k+L5wkx}TM?351PlBRkreG>wQQ20hty@$}ewn!AbA zfFAlH_KB?sFVl2`p zpm}XqPkP`w-;ps>Z7QR-RFd&i0q)cbrJaIgpO&T=*A9fE7~2C*{G~jZW!r>5=Dw&^ z>1)llC`n5`sI~3!TU(3Q8R4QG5EOwynT!wSdI?IWa_h6U11$U%)E#bm+zTwJo-|Hx zZnZx}IFrDws=Sz1ozh9S@PYWkrGfR>(xj9#{g(T*x?f4}E%YHo@)Ki-oDE`$#a z7-klpiGYnIH*J`jYyit~AjS7k!hvk>-g7I<+bv;8i!&5b=!H~nBauq*AzvOK=r|j* z!~#&7(zJ*29}<-N$FX(38_H(4x;}{k7)UYst^%&fmRx^MqqIf25fEso-^Apcet6`= z|L{@!pa1c&J>cq=KY`C<`Jp}wbuV9ISkx4LxYL!gNzV`XIFtBwzysjFN&4I^vJKc` zUr>H$E{poXpsTa^je9NMVQ;c)o}#}BP$KQ(Cr4D^@RC?m@xo7xySfa={7lgY$)>XY zrjTXCxC9W$1nJW9%8ia&X~22L?e0`YyG#TOpoVE7zVJbE-A(i1wnaLif&0u#tK_R* zzxme?*7ZUV4iXyyzX!|`&#-YF_<+jbh!+9zvFRo z$%zsS4p{YLZ=yETwQ0kqMn^Z%_(jkno`q)UvhH9E7)zA(W9FL0>GTv9 zAQ1%dwXP$>`^Je3IaYNr)QgWx!$E=RD1!@Hh4ohlm#0iZCBwDtC48Zuaz7Xv9HH{m z|H{v% zUESSP__yiM2v>?30tM|_m{`)YKLP&an4b~|IZ#x+pOkcf37AoFcJ~F0$E3i_ol@${oW&+WZ_&w8RMRE-@W8G>9 zfeGPnx}OacraAgrm^Y~*>{eg5O8fmW7W0ePae8t+^n}Zm#Y)$!H@X5_Gb}6HO`8gjF#ED+!&(CgE~q*dhqT9k z`eI5@A+dCB9Pi%Nnp%zUc)#}7`2y5L|GC4;S3PN9yZC@sU9G{jc*7^!>b0J@L=R7G z-5|M?nJ<-_Fa5=V+K#!~W^?j4-Ksp4c}JH)-niK?8W7YMA9 zHG*M)A&0eYbXU#F(p}G2v>B(HdA6uad4^m@*I@ur?7Vk7?Afxa|qb@iKNZ%I&=+a_AhFbLWpCha}aHg@| zDgMSgB!1kftuRzu-t&8-&kK`twH3X;Z_<3+t1V{~Xo8<93dhItlk(Jmi&@RFs*d{oY%2b3|8)3&M&fur~Ej;EUk&bgeJ{*P@d3( z5hU9frR;~DuB+k8Yd==4%%>qwkkHz<$uG_DQMqZBn?CWC_{;oY`rFp0&$sKWjhJAS zch5azskJo=>%J!zdIi-6B?v6Zitb=|OH1Fv)Sc*(Lcbrgeqig?g+G32s^XN$y4j4X zg!auH4lDp^v7qt$$G2jkXAS?;bSV zlX_^@25t|78^gQQH;q9>U5(>~H;?h8tUl@~Wlzv4>}En+e9Dy_z8x0@PRy;iP=2K* z-$$#;DvFV^vzW<&cQx#b@Yp@6T*1_>j((Q3MhOq`r*`~a{)hb82fi*{Dx_6a_jD$G zdvB$Yh>&=LW&QD{+hULj-+3WTk=f~2c!;ZFmL@Lug81p-l+F4f?yt8!v`Bj=qU>#4 z(_r@YxJx$>CRL%GQ?5G{)*4GKEZM&;>BkMX^Fmq|JPY3$S;(h6zpo3T#z{hfBr{^J z3dMah`=@^yA?L}VV|(U--0DzgTew%4Rz`0R`MKb;jM3_TNf@!hjeT07eY&QIhy<^# zxnysQ&>K8}!oa1r)|?vYtLxFhwcNUd=L%6_o$73vHn4mnZbE;95kDq77EPgpR`x)p56jql68oJl*7CvOM{*5rXYHVgf~uc*Y`QRn z3Wf3QC8KRpvnjC6JmDQ1S_9eRGB&h$eVWihc`%w&%;GxrQq z>Y=_ig+y0vhPiJLtydP)Z9TI!sd0}TlwLt-v(aGesd2Ez)sZoW6nY{{sl5qiWapEP z&J}Z{{eFpExB4a^&L$J{?ib<~D27M;8B5DR+7-h?~_ax=yWKhUh_|=LZ;9 zUvx3dFPadR@n`LQ;`RMog4UarsI){%9*4q_21-=YDjMgRaP(ShmH{P)63Otyde31np2(a8F)GK5Q`2t~y9@5glV_7ZF6k@esa^`7tWgVbX z64%%#bm%I@O@aD~_(X}mWQ7UyM!x6Clyclm>U3CBl!;KiA+)XW!9>4XU$uO&7cIE+ z@*E!vM$uLKb&iFVN%>|~Kq_kP6qDy}TvEcnL~BovCyHX{3`Qg3Y}LLl>H&PLKmJ^emM+e*&SApyohr^L5CWilL``|Bm#ZR^0@cbu7dZ#*9H z^mZlOe9h~NPK~HLO8aOXV65w}n*GzTEU|+~WwiIg@CW21kP+`VZ@3BjzlcdQn?nZ5 zJa@?tz73$p$#VvV~XNQDglSDe~1VV(a;2%_9TdgzT9$#*TX2TgAG1gfn$zGtoe554X*S{(7r`0nbN?|=UfNq^b(iJ22;kstf*&}nck$}H1urj z&c}zCrm}vn-uW(9fBLB7o0HaCnJr3;0*o%1nI;*PC%QzE-FwQGers3GkrMY4X~FuV z5oM^vY39kRpSn~Ge{asQlItL=q`_5!&e7%74EcuIE{%>Wm3!K&mhy&g_K&wjH_lxZ zaBl#^wWKv|VILu!iht=WxA3y^y$@ENTOIN;nm&KGYp!pT8Xe->w&dnfuE6bMp^(BE z+NI*y&<5RiVcnoa9Y5{ueE%2tv`8f(H#qo=)kbQ_ibQ|5p(B&8ucCuCq1e= zkyqY)k4QWKFg(*f_a2KyZyNtL)9`!3y~1aUkSlSA8Z4A=BetUMzxw~3}6Xw zpX63?+C>HjV8M!&|ArjpW}S9@k^_|7n}4QX`}X~6GQz}>MUG9p?;lV4C$2&rMl=GQ z4P>3D4ZhHwsb9oy!S~de@?}<2>^5E%T>I^+sjpYBv3XC9w_^*Kvuxc;H9~2GR>+aP z@u>oWwvavN!TbwPbLmG>R{ReUgaWBptkkbGf#cA;))fA{&v5~Pl~EpwtSF~@MdI+t z&5>S>3tu~Q;HEFdke8;?MP!FBI|L6u%=EKvSoD5PWg>>fBM0Pi!Vxo(Pcx5_1SFd_ zd2cRa_=LySEMjejs^a8#136*mW9I&PWoiw`ZW-upejOKe)PDcqSI9qiIN}g&-@T>6 zSN1z|31%zPJoGda@pVH&p{Gv-LxLHMqQ^oK{;mmamj&id3eE%<+|~vESllYGOQErN z;fk(zo@c(SI||N0oe>KMf_oIhjIDLD=|gN{SHRoZ^Ew#oxHFwbMnz?ur}AJ6J^Rqm zCB3rAeJ!o@Qje=SA_#7nXAlSaH_Ov+x$@t(zm^=It5UYqFy(_zmFp;)7^p-N06ax-FPrcUH2lZE{F!&8 zyQEx|(tBxqrS#?8m_>~Q`6Fnlczc!wnqN8b)ctZgQGTbooS-w&Dqz|ORk6IJ78bdM zl!zZ6);HvQL=fsoAL2_235)ye@8lhb@06-(rS~Zjqvu)bYf#*9a}GpN6wz~7SciXl z_AELkWp5%HbK}GtYWT`MCh?`EEK3PxS9^zOST>adLHp6?+iR=-Ya}cw~HNU`--`3MfG*VEI0j^7*e*?PDx?eNA%n?js=0M ze<{uLY_?qG+DLd`A4-}8Su)-4j|1QZdKzYqp1OT_Spf*-62_JuR|&Na*7wuD+joEM z2Ya|o0*Ra!0Si^E4i9@1j!DPX>lG=+I9NyM4qd=VKI0B?)swowjb&4n(9YdivkSHJ+_|WJX z!MRKAo63>ht7=ED3=je;ul%q2lbBdHB-6CCCwDhz)%Rig+q;>moBgv>5zy~t z$r0LUVHo9&n>oW1@2W_d-4+;1HiJM)!KaZtf1(pkVSMAKnreRWYZk=@+E3f1iGn(- zGWND-Gx#%4&$_ME=!Gd0b{ouvY9Yeh05?p)T4{gGEe5R9*0j<=$J*+|;$Z6Kco=#g zbI{r)z@_hex6!zh|5&ki2CvG>ofUSXuS@!4XS1%mB;!-|YfaW@V2}rynJv;F?h|b~ z$P|^94Cspj;lXtdzeutU;@^A%xkkvZ#R9ewuI2r+h3F`4>)h^WjP=LV!ob_qh>~Zr zJ6Uz+>}@XuzNMWysVaK&et{)AwIClZn6Aj3JVs@f@>GJ>t(Ic~9T4Nhpu*yGwL#B) zO!^?@)J0487F`EaIiVvrN#S1kE0{_>xBAhIX_({UjhN*bu(N!e3RJ$+fAdnf!#N{& z`@xDCmRSQ7vx`#ft2q{dj2zcY0eB5DgbmP=i zpFv;vJb=53Eu(t<;<1z`{TodqGg*6&K~ZyAJBLN#Db~9)$PR;`ypdhuZH~f*ld;`} zNbwrwo1Wtfy(lg8E?}<6d56?%Z&|ggMftFuZPZJv+lXM~F2pMf+wqk8YU>)wS1)>r zOIDj`gM~21aI134GKw8@__kwPBQ?R?hjg)!lJsVs#!e+20RpTB}oP&k9u6tmV zJvt2JS&EHgf@{{ND4u(|Y({?Ttn?K~eL|_K#q^0>_XF_vP`yZlc)-hsFMhV^{^`*L z-a99#z9{ImoL0LQhU`KNWx2Te91!x+d7QXRUuf~o=;dF&=lsWa?0@;{_s9n67q~O| zOh`@ra(T(ujPb*b48GbdY7jt-8xeK>8gIV&7zhmhdZ3L}` z2Sa@-cw2BA+4{oZ@kmolfNF_w72s4EhaGoui_{68ZP}Uoi8Mam|%b*M;y>Y6;P}*u;2R091x0rRksu=*%tuqdt|^&Siq+l<1F0 z+i=|;4H7j#BIY(5hOTJ0KjCK)$O8q|c8r$mef*V(%?2FH#!>X(l+>>CpJI-x%gf!y zzFq-fRxp5Q)ph<^(>Y`sYc!+@3~$3)xy!v@YrRu~9ZI=#Co3xID6jgGy;`ux0y6FW z_PfZYf!rVVtGKJg8G)uS52@EbF11K>&EeWubF{8#%ER{XO!s#;zJ*Uq{-1%V_XRah zt-Q~PBd$c~U-eF#hG|TVnDd8dN?nFa#ho6>+@(1(@_-?a=#JrL9?iQE@P=+3-PF1o z7T}nfC9I&QOa@YOYg+_D`U=9R3OBenW=5G(m@X}7t?ZmAAu6YCt7%1-H7BnqD$32* z%-?yrVOI?=7ReADx*cn!y|34vp$jU9_QIBoZ4r4Cj?6kfX_O_7c3S0tuIo zZ2YHy>8u^%u>HEBZNziIa?5qYW=-8oAe?O=8#G|R%Otz42!qM_-8pt{FB90l6FP6c z2T0~Jbw!hX+b5r^FSX7X%!>XX?AaTkQ}U-9qbS+}X0=BPN^o{-)Q?Xp8^)O-_5Ntj zPR&R8MDo&01_NZn*%u`j8ZYm~_D9Ej_C7Bvig1}r;iu`2Y=uWF79=b#X<5D=KbW@f ziKhNG`tkT?5CUyos!4ZVV9y7CKu<_k&07tjrg_`tvaAIXy$r_1W!NjTc)>Y*oZgdvy{As5`MzYFocxZ- zclP&w>?6rK7tl1NKu;^>_VN{rl7)(Ni$Mc62&vJE2U=)qM2A@vc;s1=rP%a~- z=v%pas>4}B>cufdbVf!nxv-~2qfA)5=$^!Z<=Nl8a_%;I2wd^9t zRQEC`h^U8x+LckTT?uVN%3gD&CZK=q(?4zDv8|d|bm|mThmmozG{SwAIoool%g{hw zd~~XF-O)j-b+CGlN<~{qrK#1IHtF>LRLgZ`+Ve|;&~z2bsK)Ksj!mQJCZtt+vgpO2 z?=W0uSNMIbsW$#BxBy+XH4MQaZx*(4nkAWWYyDbUH-$B1Rv3?9a1n_x%li?Jl$pHu zez)v_wu(>dN2>jfuw$^`NJ~H~2QFDAjIhw2_b82VOUK?Hw}5nnA;sb6TT6d7^y}pw z=|e$W1|(_iy&fyjMYE|tOF%Dl=Jo2`BGfII3bSX!@fen^fvOL<#)hTX_liG*f9^s} zO2-l_>{($91u}i%)b8So+^i5Pvq6(wxVGs4KtHKzuh6a_z2OV=l4#|-mdV@FlwdL- zbB5es-~&Y~s?Ls-v|YT(s>8i_fuBm6EO5+fCO0!Cg%PGtj_pfP4Qi2)<$Mh6wObud znBdz#65B{Wa&dCb%O|dYxG2J?>1OiLBF{nr`!pQP^-i`Ypw9OW(Z})ka15Ag8#1Ci zqww%uMYMLX5T71BA@*5%eLfFC`i zIn>iUVfJ#`WS`$pC7bf`W*taDb+637Orkc$RGPdT&{HW-28CvgQiEk4b2-DvG z(@W^T_{VpkcM0#jrt+C#Ed9@sE@r@TJ$W&CaC{rvalL-I7XI+xQ%$^C8u4wU*P2co ztDwZU0laRTbM96~k?B`eyjYe)`HF1h>DuOND%r>GdK~xW`Huv6LOg747#=_Be4aMw zK7T0e>i*}`VcAX30V2RyPdmSLc*du|4Xp1LZDUvCb@C8$DOEg2Npff7*a^xec1A3v9;%R{)y(B(+?3}X+3<7r3 z=`deOe|S3@^g@I&5O0~7qL%nH4S~z>m3@N*QufS-(GmK1iIt?dbU2oSKeBV}J7NU?Pr&RpTVK^3HwH+pqvbM8I zg4>K5WM^x%Q{Fff)yQgXhye(QN$Wx5*=d!DZoAiQ+tRCFIbcW@hBzWI3apEnDu`Zn znm+C`af2j9r$b6F=?T?G)`LY0*hNIoJr7auUeoFArSdOg#62|T{~M5d^q-%l@%n$< zf&94U*QpK4@t00kyvvLQ? z_d4>R&dP<^N7O{~Zj%5t3`%<-^KbwRsrmh6{)~9p&;Q1cYgJ{ z#V5u^P+^Yc%!z5;`Di*l@6^Rq)52JSGZ1HmuCl8}qQE=C3uaX-k zn_un!V1U~9JaQNrJA)I`v{J>piS0P^-$?=Z?Tb~%tw&c>LQLAN4SkC})gtoqf(K5! zF2@}BOpcCfZp)4P3~;Mp6FOqRM8G@LHoYmydtHN}TJtox%6Zzitc*bxTI>}6$Ex)D zL#&@;Y&eRq`lKur?z{P{&3>1UtOf@g=s&uj8_3sBF85X%rMx`(BdBWo>S9(s3T2F%XjIBrdEYmTRf`Qj_ z;|l;D7HT%&ShgK=Z0{%Cq2q#n56N)usPn$meO*Qes|`}d%{=@^UvCB?JW^R&(Lkw; z;+%f7yCt|5tUK!!iM7yy?X2KCD+fboD|1*BP&i@%;eIbwgZnN=OWWX|bqszSJ3ER* z+k{sa>6`_myM>B|C&r@by1wp2g`B_%dgMHd4mx_MqSY6YmWlZ19GTth|K!+Zc3V+i zL!Vn|MxRZ7&Ad;{qvV-YL;W76kYs~A1BB<0`{3--Kzy%~njP6=xIeCUZmKWBl^Put zSnokoqSQSd6o&NLx_1+1VM-4e$@&9P4*nyhNV5q*>bmnBPWj{lO!wU$>tjnOdFShb z-%7$1o2d`Xh_uf{*uJ1>#i;ou`s!uLH~+=3B_<{j7u&KZWi0vlvzk-YfH~IvfR-kc}pbs-wM}vkMQS6tIl#^sMb7RrvMTVHbW}uAlELqHPERY7M)cgKfMh8 z%?EiI<)Y(qst;tF*oK%<&)qi~Z#R%HQrh$)9+TO8UHo1wLvP?k-LXw059qBKh{S0~ zb@TY6AuI||_hO%&PEX0s=xhZ135B?xk*d?NSUoO?zG6i7(hUex4-3Jf-YT)oEL z^XPjA?!(gRgDnGB{p!0c`JK(7Hc$<#FJf8#GoYd5?KH=<*VR97mX9xWoPG3$G`T`Z zJy;wO5r&TuE~Etpc{>K@aMH?Wxdsf5;KA;q>j54Iiz|>uQk4w#GEFN@ee0TW09U1K z_Gv4HM(l2_SJNd*)QC$w%ajv6tE_!N^~?OOUjg(y?2{uXo&!s* z{v0{8^IP=%-yibh!hOoL=>xYaLt2!EwCc;dZ?!zq(zAS^OpaoCbWew(m8>IoPa>OE zb->|;zf0S-A>CXQ=(g9@0G+vDW@fD)2eEYFv01vAcmEf$J^rI$bwKFeU*%EKZMj#wPxL!%Ti8rk4#!AElh1ZuNQ_8x85pZbLbVqn^?Cy6j?TSZo8~!2@3*D)dM6844@V&Dc!E1+o#K$#&>T)G^Qt zz&Y~e`;Y0)au5W%eMz7KLr7FOv45OKrnYr8=csoXlQY6iE{A>6O3S}<3w3oUxEi~% zrS$ZJR&sg`?D%)0)^t_6;=2(!N$wvP)*npj+D8 z6do?H)C|QRG_jZNmtzUt{T`k3uJoMJeEnxxT>dXb_1gE)`w~fsbV5;kn$itg1%EC! z$k`wU5vDw>e`fWvBFRosv&M(yy8A@Q2Z68t5tcOCWu|QVPuu`lGN^#aYQM|J5h#{j zsm(bRtt(Ko*WJ=-&OsYQ@E0-FryqES`)f%h@=bK?vL$VaOfcPK}m-D(!qj62bis9Tx zOj}c1W+-G1#lsbFEkBeg#UB{H>j}|;>Nq7tKJTj+*RtwvXezR-f!Cmt`*d4_8 zOZ@Z_7Ym5I!q6%l5(yLhzV@TS4=+4yNGw8GOYv&;ZI{=xzAV6vyCwW4F?^MH7_q8j zeed?oQqqsENa-MTcZE)81H~{WUPQRo?O0&=-y07GaK}%wb`?`Lec$iP&K98t{XGw< zYo4r7G`#V!gM25@nx)!-FAMW%@K;FuH{xAk$vJ<_OBrh}9+t11(lv^3fY!AsHpwlD z?2Dps_Vc{)9!Kv4IfmWHaSzb@OXzwrroI{i5VTwAr?KM4SYHxgdhzwl-b zxbBwO2XI)d99`wXBBjFdd6|!3Z)FeP0RyFQ%W2a*0OvxVyAjLkx3_}1s(?k_jLEsd zHn8`&9daOQEYN!Db9y+ehaP;ZemT|zGLx@gQhp5Ob7V2npWnYO@#yh}#q38i{LL4! zoQ}_)8LWjbVm9?93FpdpJ7cMZG-f(!3Ev%xON%2_5VUmWa`9Z{@O*nk%@zUfT!QXF zXJFn$zPeTU%X52@%k=aKhEGv#X+-RHjSZd60;-&lo`4rsTX4a*T+Uv!ZDF$&Gf=m+quV!!+Gs} zzJ9l_iOoDp6LrG;1joZ0v!*pw{;-GP+S~-g#A+>7jI3iujY}^I*`BZHz>W;Ob1qP@ z{X4B^2wwS``fH6ynoFJA5U|h;S@$ra{6NJF*;PWOzfC=3*(=2&Dt#m~U|TQyV%7Z~ z%|k;B;u2~&I0JGBOMG^zlql&CG#Q&7N{NKjmfEXOlVtx!lma!gqHaM5A8tAtUgT?b zD3IPCJ^+ujP%J?KWDmfdgnxcUkMlO#fy0=C#Q~Uqp6rN`acZSE*MYB6Vxg6-U&A<@uMygu+69~Y zu55ZD1lxOCp@r-`MgX}AO&g9@2#{06DVi09PEpj)l`u39etqmr@(o~jgl6DPq_tkXibq_W{SezR`u|{ zi1l%b8Nn&(1vjG$t^u-|yXZZ9!>X}NmtvGh?tuDYM4BBj(>hDu#HVF)pr=KVEghHB zI;#D*{^Eb!wg3LBz#OPN<#EO;tKF)8xjASG(2LJ1B{*a2}o=spEAH+KG6g!HQ+r6#wH1v8A^fLYw!+- zMs|B?GI>6+Sk1ynqMe{_^m_irpZ_?%Z?OXZ4%ja9aocVC0uWgmfLxJh0wce4@9B*q zU_F(YxP3a)UfO;b(jr%VCstvh;K4e2SSy_0^}bBV5>38Oug|L1%HRO!I{FUpWbS-m)daYTtkvcN18NVHZ(sYlh}c3;*nX)ScIIC_iy$gd9yTw zA_4dwE;Yh9jLAKcWrMA8bURKlKWioHl19psnN(ED8}Na;cSF+Oi!IL=Bo2h`t*i1} z0)6Bt>CsyDb9Pk>6-2n#jUL{g?tBsdfqTlAmH~*nF(aW~^n!I!r^WM27rh?2{?cbF z&^&U}MzyhwX5o|QYG!4-b*Trs-=xkk^2tUX6EIPj$$_{qQIw+l-xanMzTR%LW`w7z zay^#zx_1M7)VHo-W#L4DJD1;WIh~y*O3)o*j+=8#vTtKu`q(F17on>9@_nB$TL;wPR|8+qc0bz))NE2Bo*34}Ai&?Y35o!KcZKpGfy;Gdii%Z7b z8$VtwrlzC%9n^I7@s2GdX?@=`;rbkHC6ZR|FDT>6!~1Fiv?4SLOFE}65(p>`f+(Bv zniJYQK5x(;K8yrl|G1CzffiKSC$HJ+zFo(bn18-~`&stcd-hZ|He%rAvAz2s)7hBO zfzVn976;4D0-Pr)PpWhcA1+rQ6CO4TEEI#d5qA8;6NU9I45gwN3_-eYdF|)kidVL) zUUTD?<7V+wypW#Ge9+MyjLU!^{i_W~_bANykmgj&J8YM<$PBTlWjiVD8UMMp=R^bU z`W>6!p<#KmyW+g8jAP4gF7sEW=JDb!GG!g^88x+39=#knG^=6`3*>OETTg{RV3|Ha zI~>UFQlsQHbwRCgu_r%xVJsLGW;%(=ZS*<9ZUJi};jhqkRmg}gmgNOnh$5XAm({VE zvfTxDNk@JW%MQ7xFIVh_YSR)EE57xPt_Y&fCB+lK{><{ua=nFP+O4z;8A^Z%44w6p zyBujQN}&{gWMyW6o|kH`AOz_@Q?`3_{b|wAR!uH)wgU^_*p_p2eq^~m%z~?DJS(eZ z49#R6xt*4R~+9U3zzAG(7FI+ufNh* z*ZvV@0K=I(9K`GF^oF3`fWxa;6JOojiLm9l^jK`Yx}@F&YH3b1T7yJ}UC6D-(&=zJ zDY!>WR_sPX4bLe(%&3euBAAzTV&;da{OOUfBRND4T}m)?R7Kq^i z6vQcd3UhJ*sc)Cc`gN%2aSx((OC@h7^M;-&g-j?QOX~#ZY_2U1;9zSyz7ajDHX`>2 z-(hSvZm`^kle%UUPC!*qRwcyt7Vvws{ClN|dIrQQ*i2683ISo>ixgHlt&X=Nj zC>x*ZaU5uB7F_M(#GuZ2yAPZ^RB=pJxMENSwVyY7aSQ5pUlmUBH=e5KaZ(LhKMIB6 z`wXX*!!Cxjy%B(Ee-I^+6)HMqLsPr1e*KYlSa(F!Ls$A{B~#(-m^}@_jy;?*kkRmG zLSob}VYz*+uP5+jDE0em1$e#TbbFoa+#AR-?`KVg9Aey3b^LE1D&Bf`yIR+6y4D}r zLsjw6GuDW<2|$mkM~)qoRgf}KsbVY=c^|IP|KHn9`_V<0QOXuEg49rA$iL}}K7L{t z4VEVW^8$C{%a9IiQTXNNNhR@@Uo};N*GkA@!)N}w?jnLSjp7Ic=qMht$>!YIokXN= zGf(IEeL>iWXju^=iT?Og^+6Qxrrilo+U73hd+SWg$EQ{Dj%F;~s|xYCsXkuPU;ANdgT{6r|i;hHT;e^B&W@ZBt zK4(t<-HE??@;`kr)Mtn?WH~RRn#&LC@#1u&iWy9*&(AON)$;(`-P7zdi{IPj+Dr@j zi}PQJjsi=DEIsXuv{Fh!OPd=rNCwdDYut*tQ`kH2R}+OYm03J|+q1*%mI}uKRMJYb z0XvkeuWrJPt}TBNJLcvltWq5F?HNAX)5d+hC@mMMSUE1$GDZfu2l2a+Xor@asj}5v+7%4-PP&56sE4U~{ z%iDlom|Mf7yWo&)gTf!!(^5xFD)+XZjI^VAu|22@uaKG)RN=f&h#B>|k&(_E;R(y4 zUZ=pVG`@4Li{bIGdE-_^m!ojt!6_sEX&}@%4(bkg;2V3*utx@%ciG~=rf)X2>+&ID z-|ap@Klts-^Zs7;oper}5O?cimOX;4{zdHFzI1~Zs&~fwuU=`^Jrxu=um$z2ceKCs zxS8L2yNK!IQ16TQ)JKo7zqjZ3zdK*w$qV{ENr~1*ewoT-v3s7&XP)xLnb&oeBNKu3 znj!OWd5j?TmAwNW$<(meA0)5Rm>v`Nj0DPdp_2E#Z+{V^lGaR}s9|)$;Ya{SargkL zrW|9EZUJfGf;6Y7@dT4YW%OrdksilFR&_hz$>-!f43I{->-}Ra16N!6dVFjZ;M`oe z=$^GR{fn5!&C7rH?>`p_-gu{paxFa-Z-uC>k}`hRfOOZ&KH@{Sj_(>(a+ygq=v=_U zJKgb;)=%#=U-_u&eDU$SO)x8OEP&_G26eYM-|0W|3!dKJIKE6_Bc(rDMO4bXlYj+e zU3eaRweG>|fDFR*8u#vEL0I8UL%kE&UyA8LE)YGln1?T&NT~`rU4a-d=mfPia_h^JOi&0HqM105=`-31JK5+6t@# zB!`V6dS%lrF0HJ^ziWEAojd=zSLcWn$vr3vYF>;nwUM&P5;%Z&N<6iCQ>B-pW>6jn zJ`9?yVaB{}vQiqER(2ZT7C1GYJpO`V{#iRhIQ#vbP1G}_)wi>lzbLy0JMb~>-dBVX zI_SFIe%DKn>2(3qaOy$?5*ZC?E}cMz(#y4wi=z+k?RN8XW3@zGyyWK?Oeyx=LDP~j zFModv+4ANkQGW@^kd>s4+og_EG+TynIgwt&`TlWrb^1zCS?8-3oRndBbbu@u;uyz? z1kow;HFtNm2fkbXJkWDVk})BJPq}y>^71@(L4(0|y*RJ`5J6r&WB{)?cL)+&aXO`Y!&}8Avdv;}NMAWj+5~hKu7r>7nPW#C*LsD3e<}!L5yCF*! za9N>|Uz+}>KHa6|0@{mC~(K#D2A3@zk4p!*Z8hupW)${w-)om_0hpeypVqH+I?T8CM!Lh z24xHG&$#+7U`_@1yx27Q(FYlr)G*-I=>tW~q*h0VaArBP08H~Zbyi-2KK&~>VBE^f zS;;~p#BNeQUNk#Gwngrb@XgKx*_KUOZx+*CFfPRv-qwGj7CO81L_Vn^)n=XnTu&H} zNM|GtT$%k;=|N@yAeQMn&wZNMGdEMxpG@L2JI!V_jS!{2(MG{5H=TD~`Y2P9TP7#j zdot1xlp6Fb+c4YZasJNtt-WE&e}yM$3I(9F#i>CJoYt%x+aw0pOvF3F(15+12x^*a zaga#F*~s^smm?ishWB7N18+%}7=?@~1AYXX}Rz ziKYOnzOTjBbBJf`9E(*@88)7bsgoxn9V@G{_o}RsxRwK3oq<}RlsHfv_J;=5kk4gq z^|-t56Asj#oJDyTaDz%u_TQFi(1a9cy13cpALYR8!@2fX*rh*%Tj>@B$gcWIym)f8 zA!va-r7-zumXsaToSY*@_JB2J&)$F(ZZ zlW_^cG*<52R}Ivdun)Kc!&dE?*(sB2L7~5ku$zOEQL*h)3m*4()=%4AdYGko%S#;I zk7YNZXH_1tZoHljMhSw?44^7{*!hIyd-FtO>k2G0m^^i?@7iBi==Vtc=X+aGs|i^N z;!S&Kpyn@P)^WyL_3iaZW7qyt1{G5?Tqk;b7}^!Mk!Jn$o@n)vx71Ivo3Btv5F3MA zE;0t^6}40^>G)d@6xF%pDiD=EUb*xTEGw z(#`M}UZ>cZKQshx_!(H=>to38lbSij+x#^-sZp_{){TAFaRCq6#G#W@YG|kIaWuQ1r#%70w-to+Wk#cBq49ZmD5h&)~QN7E=?CXRl z3tgGFi{_cJhoXjU>|5{k#?5x;9p3^i*3E>RDg$45cBKuck}CA79%Lfsi;GH<4YkuY zk!;$O$>#d1SScYr0l0U8vxZg9pB1`RDpFaF7R1QDvpj_KzcPawWK zmT$qDH}o1hxmSyn@?%EMNcuS>*q8M1w8K&Pm+%meZIZO%VdU(Nv}(HuJz;*6XK>yC z+N&kd-bbIeE42EVzj7$3LdZt4o~=NgCcGF5Nj#8288%Yq?2G-Niz^&{Y-RZ$L+=B| zTp0u78@SUNr9pAVjBVA%2AATHVXzSnVR8EMUm6VGo};YUcyK(e+GSOJ*TV=8&+8p3 zjWskmab|d%Ya%*MHv|*-^^&2vB4eX7LTiV&E;@ zNJ{ZXkQP9L@u2!9Ehy*j*s4aCNLZFO=uXqi@7L?K$V>K=R?CKo11Scgt4x$Ykd&h& zbIs}6Uw!!rz5lc{I-zvm;wJrTZta_A|2uVw=s()1$&0%H2V4VRThqSP0Qi@n1Kb2X z*`pdLUa;hi*2ake_Bkd_-_*40o@Lqu?Xh+KM)-%|&wP2s=f`4`LaNn?yRwIok=(v> zR?u@hW-u7lXuVTj-pH3RxqNXd0Lk3^ndHx`<3VbMuHI+?Yy@VO=r{*&AS25)0N-DB zf4Rd`mdlF1rS+$Es=(l3+UWTC{U3KeD|HFGHTdW@=7e)_m1Ci}24Hk-&7;>jd+Oefn3u!pLs z6CpVt7IFa+!u2m=B7ZkLS31g=${enpb6@jFA3(?T(4Ozdojn(vSG*dSNcVT^^#%u{ z4gKg8^%`Z72r-P#%o>)U&{uTD^3qO~POo_9+;t{ITN^C=?1=1pG4P;}8VxnduK&DW zXcV^v3hIhJDv^%43KFNmcZL=y49MKU7!jL!1sU-o=z^n6J1&nvVX+3Boo!6Kl`RrR zjw##X+v@#a=h0t;YI!6j@gs*~!`4lYx9vEnT%*DoIK(uT=5m^8TE1+>(l{K7-8#YG zW%!1{qs#5a$RT|bBZnFkdo55aBc{>Lu5x+yI!o5f@lcB5LehNC#m$VD!5`nu

    ~ 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 literal 0 HcmV?d00001 diff --git a/docs/assets/persidio-design.xml b/docs/assets/persidio-design.xml index 97f07643c..7851c2493 100644 --- a/docs/assets/persidio-design.xml +++ b/docs/assets/persidio-design.xml @@ -1 +1 @@ -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 \ No newline at end of file +7Vxbk5s2FP41nmkfNgOI62PWm03SJo2zTtu0LxnZ1tokGDkg767z6ysuAnTBxizYuK1nsjEHIcQ537nq4BEYr59eR3Czeo8XKBgZ2uJpBG5GhqHbwKX/JZRdRnE8LSMsI3+RDyoJU/8Hyols2NZfoJgbSDAOiL/hiXMchmhOOBqMIvzID7vHAX/XDVwiiTCdw0Cm/ukvyCqjupZW0t8gf7lid9a1/MwMzr8tI7wN8/uNDHCffrLTa8jmysfHK7jAjxUSeDUC4whjkn1bP41RkPCWsS277rbmbLHuCIWkyQVefsUDDLaILTldGNkxZqBw8TLhKT2aBzCO/fkIXK/IOqAEnX5FTz75TL9r+fe/ku8vrOQoJNHuMxuWHJTnFjBeoUV+7t4PgjEOcJTeESws5C5MSo9JhL+hyhnXmAHbpmfkB82fJMbbaJ6vvMAOjJaIDbMyGlpwEMjZ8xrhNaLrpAMeS8Ezua8qMme0CAWQ+A88cGCOv2UxXXGHCfbpig2N6YqhOdk1OyYRm58je6D8sqoohZlcD3ATOY7GT5RxQZqIfqk8d0lKkaJGjeN2gBqGDU3GBkOULiHqeKBwOGuEGmvwoLFclxO1qwmibooZ2/V48Nn9Ycb5r2HGGBRmDOaCc1HrwPA6Ao1j9QUaQ8LMm0+fJpRyh75vUUzioyEkCV1LPwlMcEjySISKDlw/oIj4NCR4GfjLkBJnmBC8PoDCWrBUkQEGBQybx4VttoRFEbwx/2M5fcECSLB4Gy4jFMdZSEhFHAQokrERBDSApAfXjyufoOkGpur7SGNYHiQiFGAOgTmVKp13j5gT0KCnvVJl7NIEvms53ytS1w2F2F2tXsIcT/cw0JMYOPlwI/ErfvTXAQzRiThiAvOFxfHEshU8MS2ZJzrogCmWxJSXk7eUMEXRg0+RIrInDfMLN9E9pLxjGajzASCzD4cAJfr8NrwDQNY2at6m+SGOyAovcQiDVyX1umSgps4oeF9u7Esp8pOF8Q9xiltKufWTVae3+IoI2eVSgFuCKalc2TuMN3VOIvnc3irEmBxzzmQ8Tp1JrTQPGv3n5hNAAIHgm5ta80PzdGfMdTkFHYAxcizBmwGFNilNkdmBOrHYsWqLKER39JnPZ4/0o5moG6JBP6FJ0s1LwJVlnxRXCh9HLeVu7Z8XWeaxbDwrsti8w0KWrrsmxxLXcJpBq4jc97iZOzQnMFwGqLwhcBxBCEWOWbmjrRACC+DZ/WBAuRBCgq4ToMXPdCiG7FCmBEdJyTeRnA3XCWKzv5Ryk/yZzmEYJinD2XSgAFV7HTAbZhBd6IAlW5IbSCClzHzKp6jgcziLN8UDXxY/deOUDLUlhko8O1TcqOeCqio+kPrDFdAsgfFXIkObRq0OH7RKaWGHlSm5BjEAH+BYusBK17ObOYFO4gtDjrmm8xVabIPz2lZwLCPPGl8A2bYOoexZW2BvVRCtWqRcPNUa6bA2XIDJb9LZTssiqSlUWx1x56Y7C2V1sOEibJ3kW9m9YsFSbKVYTYsnp0GDC3g06BpoCQfX4K2M5wiL6RAOcs03CdhmMEbUnIwT68yC5OwgQnCtMDRNa+gw3mSdGff+U2LaO0lxTENUH0syy65C8F3UzO2arE/7iS5au6L/fvv53P6f5oB8BGQ2LS/8y3JAW5UDJog+YxRiX1aGZ8ux3LAyvA74edIMz5bDuqMdcj0Xqj7U/j/De77DtDtIyHuMk5h6DjhOcoQNSd1sGzY7tjiT2LzSYaOSHCgNSe6K8HhgDSUm4IM0V4y+mkrdEjpKXK83ZXfl4G5IQjcUyu46g5K6K4TmulRQarz1rImdRGZvm8+u0ZPc20q9KmHmIwciYV1n29JMLrrTUsK6IGFLdAwdSlgu1HYi4UP9qI0kbAxdwg5oK2GhG93urxvdlROVX7czmkwikpQ2aL43j5CiZhqv4Cb5Ot8FWUJzMEmZZRnNu1lBKF73+LAldBY0OtAzfEQ3HIs/mfNj5rRa6jBkKOiaVS/2pqmK20GqUrwWUrRny2pTekdmNBu5Q0UMxEAwEE0yhTYPr22HviW0iRbvIPWgR3LBmNOjX/CsVodighJd2aDIp3dMdCklTcrj67QOyFJ8VYYfwBkKJjj2iY+5E2xL4p0wYO0vFmkjobhnUZw4oM8xPeOHy3foPpG+vhd/R1QZpPhFk1W3t9Zet4t3cy7rjS5jUKpPDbCg+6LKNnei2gEr0qHyd5j1HgebU7yeY6tgM6wtRl0T0marNWw0V5ipP9h4HeZPFwKbgVkbQ+PjRKvt+zvi+6MS/rpDze3vX/8m780fX7+sP0aetyFj4+OVAkqD6KjhdQkotrf666dRM0rOaD+M70bn29jaL87L6LlRP8NFtJAXVYQzQnJ/X/nbddZ8OyyAXlbTufoZutij2lPE6svJVh3qXusxEC9rObzK2W1jM0fYv5Qm6tvJ9vwW/vkAM6z6T3eAERp67P5+oeHzHwH8jr4Yr9cT/8ry9TfjT7/1F5VJZlch9z0uUOxzPulrVGpOyWHZHZrjZUh5EcUpu3B0ai94gMt7RD5ML6hesBymDcmopbPdPFXnvtmNyny3Zs+YHpUXJQfsGqWha1MzP0r6J7Kbwi6y1bpPSCidGf3VQNRslCPiCTUDfkxQusyYNduKhrTvTaln2V3L8cT3S9wXihZcoDC8GqgHw/PUv20QHFMMkLYtHR2r5x4QnW0PSxOFXbRfH9+/xb+aKnnhvrWxy7C3+2LkMDwEq2wedhHgnLD0hFdoWv/4ma7bBo9KcTE1qKQwgbvKsE0yID5ixfn+YN3CxPFMW0qlyFbQUEXoYfmDktnw8lc7wat/AA==7L1Xk+PI0QX6axRx74MU8OYR3hKOIAxfFLAEQHhP/PqL6p6ZHSdpdrWjbxRXs9HdZLFQqMo8mXkyq8D9C8o1uzRGfXHp0qz+CwKl+19Q/i8IApMQdP4BLa/3FgTGyfeWx1imH3r91nAtj+xD44cLH0uZZtMXHeeuq+ey/7Ix6do2S+Yv2qJx7LYvu+Vd/eVd++iRfdNwTaL621a/TOfivZXCod/a5ax8FB/vDH9ccRN97PyhYSqitNs+a0KFv6Dc2HXz+6tm57IaSO+jXMRbdZ8v2FH9vbFHmu5nDrH/+j6Y+Hsu+bSEMWvnP3lo4n3sNaqXDwL7sNj59VGCj7Fb+h+cwoeprtk4Z/v39BvFH4f9TYQn+LKuyebxdfb7cNVHoX+AHfxRX9tnOvyoqeIz/SHYh8boA24en4b+TTbniw/i+R2iQv+1pE6E9OBlEo3nSOxWlHN27aMEtG2nfZ1txdycd+Xh8+WPipT4BzL9VnYfhIX+qKzgnyUr7DuyIuoZrB/+QmTEsADzYfOunf86vTkP5uxwimX/7cPz1QP85aM5Oj+8ZuNaJqdP+TDkOUMw6nufj439H7/NxyHi8d8f45xb/1vbV3A51Tl/CYlpHrtnxnV1N54tbddm4IZlXX/VNJ2gKtvH2YD/9s7tToDxfz0t5/vQ604E5fWbByvKNM3as+007TbNgCqhD0v74MHfRvnzEfrRuolvEArT30HoTzNm/J8C9EsdAqm8ObB3FXyubBRFaTrPv9W/NWZTmZbdF3CFtCXOxjab394wYwKUlMzLmH0Gl/e7fYOiz6f1q8Po34wVPxoT/op9x9Fh8HdgRP4sGCHfwMgy+W/DwlY2dfSuhN8sDD5XykZ1+WhBxDhFlY0/UXYE8oXszoD6rQVi+Leig3+aCX7LPJg2ql+nbD7ZzDeS/A1n8L+Or3+WrJHfK2sY+Rv+hbS/Q15g5Ds4JX6WrMn/Gpx+ktWvA1TqO0Dt2ldT/npQJX+vuH89qNL/NVDFsF8Oqh+D32fSMznnV0Mp/Xsl/euhFIa/EfSvClMC/fVg+i1v+tylKg2o6fxioIV/t+B/QdR+L+35Fao9X7FT9DuSIsnvSepjMfLPF9X/Wbnn98Ls66wI/YjUz8s/38uKUBT+WcL7OfUfrhs/cwv/q//8MvWf382cyL/R0Gf/4K8QjH3HUVLI37DPr0G/BfTPi1f/PfkTAf0N+VxM2Jch6K8I+a13gFH6y4u+RwbQnybc7+RXlvLLhf8/klph1Beq+JdkAMa/1MN3MP7zuAH9jaCz9JFdP7ztxrnoHl0b1cJvrV+5j8/Enu3lHHz2OgRd/oYAb3XKeXx9+gy8+eLDlAGbfr+5urNFLMFS3m5RZfP8+qDPaJk74NY+zUzv3jzgt470rVCLoqL4HUCA95/1gyCOg36P7/uACyCrf4qKMaujuVw/7/R9LX641OrKt7LzJzSRfyOof4wm/MsBp24Zk+zDGL8h498ddo7GRzZ/M+wb4D6t9d8op36bvv6qfhZDqR9wrN9Lq/7prtuH+zmAO7ePOvvthihJfpVOIPS3LoT4Ds9D0C/vF9WnaNpozlhgvdNPUeS36fF17sb3tO7kQ1ED3PT777OFB7+uSXTmDOOv5vg/YfKP530Y/S0y/qN538fc5TN1fNhLncr2+UkfbTz13+Op/61yh5H/c8H/QBb5WchL6miayuQP5Y2fTqm8ef4fsM1/GbE+kxr+HaF9bPs3A9tfUQj/Sm9//VofPxrNSPRL/X/NWX92+Po27X0LX9D/A5Lev54/xv/7SwYzAvlfMPsnav22anadxyxqfrlQhf1exf+CoerbbeFfP1T9+3L/BULVtzWOnxaqsB8NVfj/QtVPClXfFl1+1UwLwf5ocPp5G1gfrfXzkJAUWbrUv2ACQ/1eif96G1fot4WBL87SXbNkzObpW/x+3KF51eUp+fFfSz1+V5Eef2qIkufjTXHmMp+jZL/P1/2RAu5XLoYmv5H+9/bCKPxnCf/bZP4L4atd/A8FP80ZEHCfjeU5GaCAtybrt/dsXu7Zx+cG4O/hvI7irLa6qZzL7osPgFDLJKr1rzo0ZZq+lSc/dmA+DPnpg38Bgg8bKnqWA9nCP/VM47f6ptC/fWeH/mO4+iKuUT9L5d8WDJwsLYG2ueh0dN/oO5r694c33rT5pTQ/V/6Hou43Ov70bAWQdRpNxaeicvl2GOBbd/ihnS+bx7nEuozBQqckOucmvs3x728z/rs1dumSzH+b1sdPVCOMfbWHReLfUeL3uAmG/Cwd/tHaw3Ryj/mf8ryv9wk/KexTSR/U9z+v94cfP/ywLwBA/9nOwFe7nH9B0BwH/32vkk+8/fvduvyXZPNjjHlnXv9swB8t+f9nWCny8dGOj1Tya9bzo5T066OX9H+WkqLfOzTwn8Ur+Q1efzYq/9vABhNfBSsc+2Ngg6GvBoK/Po3ys+H2A2ehfjLc/gS0/WN/+md7xx8F7Md041cBLPkVzrA/CtivB/qPA/ZPKNB8gcSvYfrH0fflLj/+T1AO7vOvjxX8n2EX+qWwi1L/4CHY34td7KsjwJ+ecf5PQffbatP/oPsnQxf7taBLf4W4rx/B+2HofvWQxae607+A7oml6PVZtx50mP7xhD89N/gVsfmH8/q6P0LB//QC8utTjl9dcL54n/Ofa3nfFip/t+V9bWzf5xzf4S9plFF58l0zTKgszv+Fsf3JpvTxRPe/pty/1gY59nV+9/WW6w+b0lf53dfV2p8cBD4a8v+g+ONQpH8tJKJfUuCv96x+eO8LRr8a5z97zPBjre5/SPxxJP5aZYg/C4k48n+MROR/SPy9SER+LSR+9YDFHy2+4uhXSIT/w0j80593+089TER/lSF89wE4FP0OBBDiZz0A97HI9Cc/ACfsb4fd6jfYviPrHz0F95Xq/n/7qBlNfo0O4tvtdJhC/rGD+PPB8QOFvO79rAH36UvxvtkX/Ueb33E3z13znc3vueu/t/P6OSQ+2HezP8CXAf4t2ib0b303zY8x+/s01H8v22mO2uRrpJwRAxFIlKXO9vPKtMx+e6TmI7D+U+rGv6rSfu+w0PfOlRLoz9L2D9S+fh1tNy+g5zT+pOq/I7+usmEI/eW0/QP1ll9I29MXZv1L6xqGfzVd4z9Q0Ph1dN2NUVJn/zWmDdO/nLp/oGrwUfIfDy19cyzq46mlFJxpR5n3t4jYnwQL4UqPNZ0N0qRHx5z/jOutEG4PhmF18Pa6cYxy/hVch7VN0CIFjujLjhsjdyhFxNfdZtm7RJf3K6vGvtjePbUOfQdPkrq2zv41XqiOIN4yY5xaE3awg7FtFpGY50OGGBHa5JttPh/Eg+kU8cbIXWjZdrqxhM0QCos+DbnaMs4h17syOViM5hWD51DQ4nTQmqNVUV7F1HxbiQNj35hH53DSQxAZRRPsomNZm+MKQYn+nM9eju/f4zobyZo2Xf74C8IWDY34h7/0aI71syTKMT6goZq9BL1wet6/7EGXYgbvX5s4kupJbrQIT5FLPHWitXunGsgXtZr2OZTb3O3J2xY0hHL5JOIsVIp10AtQHdi5/rQ7nF/gYngMvSrTrShqOj6Q9q3RhMCDUzZx62j0I/PKPwzT4FYfnFAT7SrBWa/HOXTJbUXZDznaakK/d3yedfuV32OZuGDJet6uje42ui2UneRkeV68kilC5ilpB2xA0W1y6+81vAfNyS3EdiSQ9WjM/gQgGxrkaxE3gR3XShyhDsjFqs5sRISJOENnSnPW9GyrdWXfKt2rJ7rBEHgCHWBf7xd3maE82zYBl8424564+ZKOSxgRZBCh1v1e7EkA04FMxue/4BxL1M/lnY6AnSnUq3Zj4e6GiPl+2zEKcaBSWTruRU5J4lW/RKZzaZQiuNkQ57Ue2R4RJ7i/s9HD64sOTBbMOA4jpA+15FZ4WHAN+oscmNHbB8LpFdi8xft42M5XF0sLO5ovyN4lUWtqT92PTxTPrsl99J4v62kelH+HoKg1Vpkz0KxJN57xZaY7uiRNL+ll53mjbvcuII1QZ+4rukqB9hjmjpfB00RijoIl5v6LFOCWoqxiswA+ivOXePoUNrhSZUIOFwsigAj0dqEZ+zWIXrPPRjHTKtO4dzLPK/64DmMfnJ2iK143NxzWXrahVRvpZq1Pnuss1fNOQ75dr+Zc17cl1sLxhupBTCYvvh068rnRhfFa+0yKKaVHaTsArkMXQl9LfYySc+wJU2UjX51ZFLpqG17UkPfRo04nmqxfaRRj8O05Y1MGAV9oa5VTLZAkEUtIeLdzZpNfzwoWna/wY2a95xyiHKbt8ZNcPYsX98S3H69lTe1mFe0dyKCm84fCHhvVJROsF97VqbPbyzIuNRF6mYfGI01kTR9UWZAhCmocmTrMLwffoHJ9kRPNW3l53GnUQFM6xC3DRuF8PMddk96gsqDAXnrjGinBKwAbccyc1imWtuBFqRTcBn9tsWy4KskA9YxHZGGso8C5x8ii69dk11i6GCnJH665NAIA8eEhb/ulvIEYgLre9ooKrkwWA14YSukuJ0pa+6WfH0oU0pFcO+7X3EQsEa5K/ILcuz7e677CQjsSKfs6W35tZn4jO0+HidvTpailiIw3Rl3SYF9YD/ZwtK/GOQ9q/Erzi0DMrhZVV3LUnte7q/LR9qhbzvDc0bbSZD1NFCdMWvGIfNgfT2/0DlRUgrwnaB/4oaJA1+wQIPowRnN4zmfTfrMpo3nmV9qus2LxD/iYnr0YhwBqYCFefC9E8y48IICXhL9Q3FSk/gDf4IgPNQRJ6icRcssL9mmbiJ9a6Twmr7wkGsoaYIBRNNnyJWenE+44N1wFApW1S/DQCZjLr1fYbQNiXDI2gCcSB0bTUfeZ1NzSorwEy7xU8El8DtMsufKi/zxDHSt4hEtVBrGWT+0cUM9VrlLGUpmVmbwVpwPp0j3u8svrcZNEGKryIGxEXYkRxy9KDnFvrSc3R8vy5+1YwuEGqsSt5TDr63MzmOxnRx/tPV6wGbO6FzoqbWkbinS25QenMBeFMRPGtxnp/lASJrKZBGPuFNvYTIGwPcMiiOe5N7QnDGFvJotcCzrO1IIntDpGr4hxUhGWfmzbeTtWUOywYEqBU86rwVvBfjgceKuAZkUBb9+aP/RSfuvFCB96cZ/1Yj/0YrgPvcAt0mYg0Zig11SkbzAfnVSHfcw1tK5JTOjxdvcXfuvFKxJxkxLaNUMVKCOVhSESio7HSUvAqeUKKEQsAddUOT8QS7+ZZnnhfKg5NZrmg27cn5dTaO6caLsrn1FlNjlkipU6xPTrQq3661lR+KCv1+wQN6wnG3s2hYSaFGTP1jHa1uHMrsQMTQZhZJgXC8n+hQ/5ivIrmlj0UnnY6656t2ntE8mkuRCJtV6anRYjoAmbT6tmNYnFn6dXOGMiwZKAECKi9brMhGjfb3jOds3dfGtlM9xCvYOirYRAKZR9BafPwK99g/eHD0ICEY5Uq45BtSX81NO1OPU4faSoe5MlCHjX82epQEhmogRKVv1K7Tydy6hJxmcrzE4JmZ8xljUNL0AdYi/aeKi0R/jU5HFZxxZHkmPunDoEnspPQNyr3ydMXai9coExw9rZrDZLQMC+2rd3Moib8hQIkFOKkniB5QE8QwfCX6TuUWi4UUsLHRQTU8VNPLj983Bq33qs0iu93V9Uq+x4Qd94L7mRxhS2/V2t4mPKa7Zqzjuhb0QGXWkKeFkDOOqAiX16cPDm9BOcaXqmVl4izaO30+V5UAvBmnltKpgwdasr7mDKNPWgYL5OWLHAHWbkBuUBvex8UHGrrw4myKqFxgKwgvz8xVbWlgIqslXpeJlfyk7lMiAdO76ETcpkKu1b4abRvvnmrPox38TKjF/DcfcOSUm0COruEqVmWnDT8RR3OiZ0V6M5ren5qvR1vzn+6t/CvCPoOF6oxrnoHJJZqBtX7Bvgnu7uvggcUtHDbFmHfAayPVTHdRuAmTy1zt0t4ogQG1s3h1hfZyQVHbBSQexaH/e1rJyVCbqGBIDDrEWJZDdUJ89Njs7QYEUuAnWVF/U+PYuOBZXj6Of99RnnJM0m9Q1NkPTAHYuSscp8Ec7BrDSDGaufkMfxzCrM19FAKw7gCg216dtuaOPImq1Hu6rcsa6BqUM3Amgskg31Not40lvXmnfUW5Jc/WmzoVNPlDc4zGHBZM4qBlEm9TiwVo4/n7vRGlrb0okmn3iyqUbMIne1xj0EbHuy2wQZkngprgq4xXPXbwMVQfczDvbzYvRrJvuNd14dIq64svgiqLQqU3GSMpLcyohG58fTtHsJmOPrUKVGJ5TK4W64uWGOCJjKKOyupwxNAfMW1+r0Sfr9XbfvzyykUnHdW7OFXs7oy7OcJf688bfNOfmuv64LG0mvOnHT0tHvvR6dsKdH+SEWkUFD4R0LlmqDkWUfp1t976eHTj73HrKGsC5ypA4eMzdjtlcQrWgs11tHXFeeA/REzR1VN9Bprl/kDGnALqRY0FCbymNkHhkIv6LP4jnAwUT7UzJ45j5cVLG7LJ7J64m9ZremsDTPPZskRCiKkyRBc5RLPQNdTNtHekGLsrdMFT2yutUkfX33S0ZX7P0b4RYRSr1wzvV8k/qKU9bFNbwTiNTv5VXSzAioI4SbIvPyDsmQBu1nUcZ5X5KLZ3WNdvHVYVCdolDGK/VrhfdWle7DIkb6/cCC7ElAg4heCuyks6RQpSBtQGMZslaaUPFzCuK9y7npEkeliCKbXbbrpEsWF9Oa6VlZh4IJ4wyDNLMEzGibHYV0/nEM+hSpvhfmPvZivhPmBOnDWBrBfOrvHnBFsQgPJtHR17um8IOxRw/59pbr+ooYPlKN8R7s+fLMPiImD5kgZOTz9blQOc6OeVVrijfwhbBUmTfljeDDqxS3BkI2+GpoyTIwKWcyJXPe/PxhFQ78KJzAgB/hoYCfxxm4wc+VeZw//+m+K14Z7JDCk3h+fr0ckrYnikrnLenHIN2yk8sr2a/QgnJqvC0g1dSpRCKYJgb5L4DQvaKVnnHx8snAJ0rOFAjSt+0SdXcNyPZ2xROFVlw4EOGUy2kw9js8wwSGJDZb+Nxgn23lY5wpENJ4qmbX4a2789p91RE6iUqmbRn0w1UrZseadIYPJuQEFaItF7ZPwp/zUBTWzlaPTEgsKu2osF9VsD+Kia08PYZsc45EYR875I6GLWRD3gLW5bmuGAiyPLNBkthhwHR0bCy8e6zjOUrnqbYyZrkUhkDLDCEDN3kjcDitlHc3zrrSY11PE37N1inmeumLDads7kHsiu8FZzh6c/bG1NV5ATeLUJruYDQD1lt2qU/WAoZI2hpfcis+8nW4LAGtDbFu5Mh5zXjD78W8jiB7xA1teI/wOs5kJA37vYfMIMQ3gDi15Qi9Zch00MLj3HbXVE3NlAZ5/fnx9Kx4IENDylQklvuU7a5mQjfTsdFl1LAlNunmtdggsl5GbMqnHLG6Qb7fyUeitvtbvDXg1IWS2EHdKXStXX9Rq2RvkHWkMR81EEEgWuKTrQmLUpu1J8sKJ/ExoZfYswZEkbKHNkPoqQbZeLBPkM7SyVMjjwFpKm2NOTqeGvb2OOmEzCEWv1GxngIyRNxxPdgDw9k9WnFeVm8uAhZNwkqCAodgT25cUCNZmIgasbBjoChWL2aFumR/0mUY7UKe3fCoCMhHnfiQnVWdHii3wvGjSJyXVL5R+3ZUZlipahc97/72KNtbggrXgZ/zOTX0wCdU1OnDquP5W6QidYTPE4rEhlubdhglS3kvrIiqjuV+C1RV9m8ei/NiLOppwcAMTulOgy8xQ669VydFoVm3Zz8cIpM1UQ4jqtCKvJDTI2TzM6iVmK8lwgo2sI4hRfYouaZoivZpDRw+cqy5SzEMIVqYXxIXz789W4TDL3DTUWSgnEYgKxDFKkM87012LZNbUG/j5JldRF1mhnguXR7ayAEqJCjI5o0M6WpkyqOJ0tu15a34tdfWZOPZVW1CHMj4RTlnkK9lVXsZeRdYQ2Ot6PPZ5ZQHmQ+4b9t7SYyhLDThhutMzRui5T5fllxEsKaJSWay6aXpr1e5QJiicRLUGRwolZMBcLhVXZfQ7uQb5EeIJ8Ov5yyPNT1XT0J5xIExRpjuOqlKzrMcuvlc3dOBObOJy/XeEI9lDU5Sk1ePCHd4YWgZXHhi+wkN1nGvEsolEuO9btaZhPs1J2QevDXicm+ZTAAlsUWgG1SOSCs/04Sk0kCC6hsL93qZKeJW5eiFN/UhJg2Jmo9rauG6+uzU0+0b5vAAZYf71cYyNKOhM7YDYo9hjs1e5UdRPinYulqxMt8Hm+wSnjWO1IO59rCY29YcdlhCt3iAIb+8ZaFfpktiRSVtpi190n0LphaEqJMeYi+IibQ3vxArjg0zOpJ7I8erY4rsk8RpsL7VBPAx+kRb5x+OJ1ILeebYqsqLEPqN5U/AbTkerV+Ngjdi/0bJJH3lmCfXX0dscedwfLmsgtxd7V7Ih57zGBhJax2djoOy72e9HsKy2cd0dJECsHzq5PBHoBSriQv7kyKlw0gVXAi0W+NeBjkBBBf4tpsLbo2o+bVXBhE/Uu5J9q69r7EnDF2/kM+a5o8UFC+MPbi0dazdIYcCExbkQJqrwxEGrybPfFuJYC9jrfVCGy4ykhhSkEmb03N+9pXzWDAjvsYE7SW37Lbwt0V00+fQL1OhUDmg4nhgqZkdkOmQIWGJ1I9MN44QWcoi0p1rTY6Ge29E0UuCNnRPpgK3K+dL8LFUcXZzZ7IF/HwdqokOb1UrtiqZnu42s4gAhMqHGkJIw3lzs2jqLL14HIdvLb2p3qylykr2AhaP+LGQOrtkrPZCMRMUyMCVLPxaYAnNUhq9VjRvIA/ingmywaKi6qBFr06p6zoesYPeNXLPM9p2iye1Gmg3PdoeMTQnup3pONzBjFRBIa3Z8Zl4ShznvEyzb5hVpSy9m+kmvLuPtRsw24FewP964QYHvlSFxT15kLi2iNti3SGQib08Ytaa7LQbS8tQkJrYEOZf9DYIHJfM2uaqDLdkkYz3mBS+5BuLljyzC2df378Rkv3yr+WLxiRT6TdPr7z1LmfbFMr0sOmPy+P6Htdu3ZOJbz3IRQ3V1iAtkpLTOdehWh8zKLczIMuqw5O7zuUYxLh4zMwLNCap7rOdxabjCiOp3jzz0Zrvb2fln1UHmC+iuVu3CUwhSzuLnp4UgV+2TGiNIPaZ1a0LtoCgKTCbJdvE0F4KT3wuaYpV2C5ujCqlHTuzg/nT2dkfHlNmwtyQGSte6LiYuw15TPzVZfrNxBnyyZWqJ+gLp7G351CyJusJcvLIXhrOECE/2GbPkJCe5SmlF8KFLZnra8kvmFVhSWnbfAIens/Td+rVxgi+kIxSCgzzZJ/sh5md7MsWtPdpqYwNPnub+Y3lGM4W3qfNqMydsT+s7OzAcp8uBp85YDUiYz8+Xmyfl0q2wH0c1mHs7uPFzKdh7ZPxf7j4h+f0UJyFuf1597/ndT81NJEFLU4tHqgNA9/p8fROKzKIMPQaIugMihT5u6mcbRQFqEtP00cOnsEQE9SqaoyFZp61XFAfasFORjazj/u78EGmh1IXciQQEKHYp8sljwQ4fPApunkyf2mZ4Rlz6COXjvbAaWqYGZV97Wxf8Quar0FNgwm4oc25s7tfmC4hHsB1NdBDeBJBEXW5XTrJ9bElJ7d8vHZNYnUdttwENZD3OTzT5z4YFAfNwkvJVpjOg3RBM5gYI+kmPHvnonBePEcMqDGxs6Ih5hrDBAzigXDOuX7akJBAp3ckzD5UirP/jbOtFTBT6uBxID73yTxJlhfMAsFhWWDSm8K8cG4jUJfdrSVve5JyS7a4v7BVh1LaIljTFJGUsuQdch3qKbMm5wYXm++08CJcQdZgWdaBcGAnikZPpVP4RL0vCDoow0+jTmt4xZSU3Gpfqgu5yrrD64aXiD0WJ8vVMwdu7g3b12p7Q5BL6WT3SOJhUz9MfI1LLJOBvryHtlG3EgPUtkBEVXzx5ouAnhuVDKmtuWyp4PIWq4dU5/uRoT0hDZobFOykOG87GPxxuWeczO+UxFauMdEFpyob8NTWuq70JTYtJGAeywx8LI4sL5U/GWPdRbeLyIBif3F7hJwXCQTvGKY6oMqjm/RCleyKnzwMP+r+dVdkjl71rROrNkFn0qena5RVwOWr5tG+nIp+eGngYW97gwdsDhmvziw2iEZTmVedtOuNTF8OdUsv+2P3Tmaayd4OWWlQJhucmbHeYnbU5HMwXN/2Nh26fcYt0akbSdMv2T+zJ8q3gJCgHtEKFwR/wAPnfEii3qMSg/dsOgP7qt67lnIveLprnprCUU+ceKGFuTnZUBRiG3D2WmxIy2uwojPv3NJxAA8UitYr0XwZi426lbeanC5D75r14CP3O2d/KOJGdGmhCv1MYe9C6wO23Iph2xw64MVlek23Zd25yK92YIQcEfSPZ2ZcEkXa9etgBhSiFkpmNRZc75VwJWKTMEvOqYZwncz5waviBoRYd1rwvA5IJGTLqxP3B+8dYzvqWDYg8K5ZR6HmNjnsxP2y1aopQsK7UVc7RjyCzKqCRhLAMYBri80gA4y8+7wdLXFmS2DTSnilHk4neEYn6vVWccP1NsG+6rxvu8aR5jnsRMd+M0QMvLpKI8QcMtwjoSGo0I/nVX4X8rr0UjfeemMRoDpr7DYggRGv3ha3rum2fnq1gDF5b3kDGUSApD0JIoxiN3E0N6su5JH3aSiaD06va7C7VGlELozokwqVBd2kF3Y1VtnN5DFHQUoz483cjha1vC5C1PgH+MYasQJLrc8ABK9Xq9id6kwQsEhpHKx/Y1rVGzLPn43xSJhIbqeHxlAMsldf52vwPUdss5RNND40+aTNPCsRQHppd3fwy3yyDBy69izZIrl/JsLzxZstD7axi3WBY0NxuTTRHjvOLGhywNq9R+QMvin06QfVPc/XVnSdwnwFV8jTBXNHwDLZCm+zqNqKOZLBqruHgkEYk0RGId34K1Z23o161VimkdB+IyY1h2j5qjV72ITXwgam7yAFlNGTKYOvr0FEbPnk9g88ciXQJgx1Lnuz7hxmdXtXLg/iSwufuTZBNEOaPrRJZzVT9kqNPpb2CiYz8K/d3NqN57GUndvodkBFme/zI3H0ZtcqOrsUJyO2SheHOpODbaUbTnnU8YBp97s7nMQJ78kA717m6R2xtN3tQ3iVakHg6jNIZgjoK8n14nY4mmjziP2Ai0QViMuSvSbH6m8upHWxZYKwJheYQTgXl9ElkMWHuzvSSFyWka9fkFm6reqhDYXGeCcfTNEEfZFtA2h3kM+U8OaQ8HfBWI9dH+iXKXpthhilXYQ2EAi50JCWLnLaionbPA605/2hDWlCJeNLksOCqz5yUNjin2kD5bQNwW49DAdpGe3VvCZ0/UI1M1Fgf8mnxzXyXaodg+DhSE+gpVuFZQLuC8uoEjsyGWAkv/UD64U9q4GKYofv0zZmbzWMlqa4RviLTU1DIRPMteYnEY4t/lxpvdecR6iPcZ6ZBtwTF/cupykM01HMDxHi47e7KR1EBSeO2xG9WZow+ejkFFZUATi8uDxD0m7NznTJ4WjBCsgoMYu5s87JYtGAuzv+CuoNqE+gz4u2Ow+DX30cPhLlGagRW8CzJvOn7dKBG4VPlY5HL8h1TRVe0xrnCNaC3aMHZcf+OKsZcSDrIfD+AbbXZu2oDguVOGSDr841WnR+koq5gUaaCwyQ06QnizkGnTZtCAlA+hdHQeA/4qybo81E0fC1hnyYMdBhLkv75HkagD2k/DBUzrRKw3Fvy8znqjxpeIH9uaOXFWds3ge7ShitVRDREcAyAg2n/ahgrxQFnf515MdiuMAo60xMtYWaU96XTqBzdyof/gOirhCWEPB5KQmOJji7wANEXWx/toA5ObS3u5Y0ViuZZl3KVxl0leJcC6e3vfJsO6RZBa5y30oQah3giq9VP1xUbOQKIpo2jXi4ax9klI6O5OHY6yFG2fNO00/0YEQkKqynpD6w7I5sawJXIXTOeFPj8o3kDYHtY+mtzf12mCKxe76beHnz1JymB7JHri9B11Eli/ASj4hdasLX27l+GeuujDtl1vXMYkDyxRcoJpRuthc9hJjlCnYHaeoR9EsG0Z0QmVsSd8SwQfXqkORM3EExk67GBkp5e+l5NqDHkktlUFVoUI1zde9+eZK5Pw8lLZpZZQRYgulFed3MiwRmLxrwUoMkO+THTPS5o0jKUD9qYCEwNKqIz47M9poFDKcrVCemOk1fMtglSJ/r0UAUdb9W9oU3M1BdODkxJTOIMtu9OqKUxW2DSNG9uJzOj2snHpayxSXoCkmmOCFcH87TXh6jEvUFjDrtr3Q66dm86s0AhVGhaBDiiH3keXVoCrOhQ7XpaDIh8oQErtEN1DbjKFwk9Q7LkIkCf9Oo5wzpe4sOGuQ2t3wIW5k2lFpnlesFS0selHp6IuqbxG7kiKcNSMXTrZSsYbquZiJtc2QYVarqrADtAC/aTO6umpljTzyF58XrUGvvLUt8YwiqDi25N/ZSm4qSk9LRmimkoLnl2E3hntKefCVvs9z6foDE7B2N4a1ZJZi7LgT/PLntG5fKbwUV3UXdXWhuxhylEa2UkCWde2DDoBP4XCv2Th9G3ZGmyVzxkB3uFzij0e3m64h092iUsMWXUkg0DWPGBYe5+xMJCTaC7asVVcflnTX50AwYKTRiLjhD0qmFiui9zOsoczmZayY/7j6sJlWY0VJTijg41HTXLCHNAZs7QSKS4ezCW+tcx8HqwZAwmZotGE3iHdTaAD3zmsi/8CVsm80enZCHsadTsGZwbPZRcActBEojuSphZKHVy9G91wxkwAPYfubmQiaGCfZZsxHcsveBSgdRMbWyp5QPzL+v9ivIh+j3gJJHQTyb0bD3GavVBCnBFvjkMr8KKo1pZRG6M6YtVv0caOiBqAJahfDpw3JtHMspLuc2rGAnJl7udikT1slssF1yrIRHAe1clDAba55tObM8cf64R5ExHAdH+7AJnGa0hLB6pt6z6+NhnHTVGULNLE1SiW90SwALADX4Ni9K/O2oUkthiENhty70JsjpjnGyQ3K4jxygaEimlJOhzgaQ/QHTQeDq9nBNRSTozGkul8SH+viNPRAkiWfXarrDmjLIHXolGhFf1cXeiDtK9OVrGp06gF7oADPEPgu3y9KrWQGcXy8+bqqPJR2pPNMyRzIhk46Cmoe0Im8Zh5RtfGcIXbRPmuMceM1cjCcthc/mSU0LQRZIVK78la72pHdV9AoyZOL0Hjctc7P3VJnN08x57qt5oj57DVmlOAeKTs64YmzEN9bU6+biPOapZ/2KVMDZjKtqVK0AuAbunkSOLhSVO2qbKhoKPh1zXN5029zJZxGuxjJD2BPUr9BQCmfESZQDVtwZp93Yg+uWscvgIvMqmgTinga+Z72UCb3PwUlPWwDqduj38iSmbfaixYhW14ZVXqcHkRufk+l7/VDYZCw3XQP8cy1t1MZP+gloDPvYoabt+FGLDqsW8dexY8vatUFoE2E4Cuky4jKouxAvWdZ4BRE2yngg2YVR6Y7gGnYX2iht7jj2Vv2ku05ynxaHoigW0znEcN6K0QsaDyYlG50pST5XPR0eFM1YpVkKllx306464np1up3BxdNV3o6+NGsUNrajJDb/ZIDeTk+vKuYJAvGlWZIOz3hQ6afqBHQFqTHwsEaNZwHYSyUJgkxXdD/jlENcJ2RaO4HEcuRMkt/NbCG7IW+N7nEVS+D6ppQJVZvAnMqMFIrlshxK9DyOwLEWlvBYzCNAcFy2QLVL4XpSqo3eiJi/2KeHwbPlTr8wj2VU7UCH1mP3k5l4MqaWis0Ypwn9m1Wn31VZ+tWrXfgCoM6MLErC6a4gz/xhQrSA8SrDawzHhOVFcB6yIhqsySiE0p6LYFzmjikhp273jblThl8cS9VhtKqkqWSAraG6OggiiE/hRysK1cv4OiNAsXkCsxlQxzAFx7DnfMHh9Ns5t455sAwLhAUWIZzTUk452KfMHhwjMgKjnR1txj4bH2fj26Xnut86npfaLMOdHU/ZnI3K2fh26X/oHsyZq72dsv8V51e97Q1mdNB+iGmrV8noTGZwGngUxjzkfMEoi9/2A31nlnqplE6O5HmCfoiB64gj7oNRtke7kief3JCjoy0XIk0Q+1lbeHBcWkTIOoLOwAfUApwHZ2J75nXEzWYEzC7AARjlprQXLSNNckHnIzFFiGFNicmO++3kyBgrV1Y/F6MhXk96dLCYFSPG8grXIWTODBb1bMCYIi2iY1sK42eAEXS2LpH7qBglfshPIbxV7AMz+YPP7+gMZkJwMdVfNyHkejaT6TcnZXLFObVLin9KpsmUugYFOOpn6JculV7gzIhXMFRRqu8FL0GKghrP1WYjVh74bZBw5SuKBUn24J7hSlqXV9Kq0ehpmSTv1o1aSORMtto7muLwhVHUbbHKfjZW1Y8erpawlyrdoKUmVhW/EdRjP1VHDtiillKS7/Qa+69T1Zy8kjhOv4e9EXv2iKGveZJHEL4xZpgWTE270pmLNh35kNMav2CvKLM6UDgjj54fV0bnt7J94zIgUyu8JAh6QKcf7ekggF5EkjZrF8XTxOLAdSpVr80et4xwsKoY5VdpMXimJlI0DaKCRk4qfd1blYI4TOs4/ZLKPdGsb5cCAJBPjcere1vIiU6qWg5O7uYtTjD5Bh+WGSsM7oXXO+s9b8NkZGO0FSCjMFsuRTN0AfKG0KwZiGV8Gs0dOxwuNpIs37TT8W0OVjpHFF2xsYWhVHahlevAkZIdaG/NG8DAWfuyeBPJ2pTJz12ugagMYVOrXhOhOs2o1M1jOtErV6DGeJgy60WUXHhorq27AI4VHVUHTlmOYE0rKJfYgDfmI3zyk+PogznfTt1IScI5682UVPKcuNvMomTB1dvmSJUbcj6CwhWbY15HzTfAubBBR/YrIMzehXUbOjcEyGGc6EHImsplY2Zsp/VfBL+72fcM1+QsVCwvar1XUmn6dpBPgZWzzq1edFJzxnuFDhgtIEasAki2P6RI1NLUVtSQcnyoz/MbdXmaQOn03QKCessHIxklFpuKwdI8vUyzBYDhclhHuxwN3kSRumuHsU4HBmo8kP/CT8uN+0f69nAIe8xH7L4tkU5hUIJTuON10diUaHulXyiQH0tZPy0OiRNiQ1jlK4sIVB/oTDuy9jKBRE+Q2ZEMFCLNx+ueCTSvtf0soLSDSBKHjU2cdVwEM1wym0YOEm2w3BtVO8cjsYJ0TV7HTLcJOLdjSA04XHoRcn0FrIPOAg+hM98x3SfuD7Dvevc5ZnrwAJh45str34BvST89XtfX2mQhTkOiEWXGBkqeHtDar9vSQniyBCI4ZTgqC+Qbd4J7ozpX9iAciLcJJUBbXUaPtCWhckichRb8p2tcJzlOLdO9I6CE/cwkRCeD5WbQ0xBUM0GCTYend8xD/vRC8pXaMYpJ4lUG6jpT5ZDnVQozg9U7DT6ae3BmfyiiupXWwCTdbqhP6ubbmxRYSkA8ETzBUjQyvCNBpVke96af+pK4W2PaRfKJVpINDUV6eFfC1Ml2HvjmUsDkdoQickomWy9kWUPJQZEmNr8QyqF5Or1kFNlnIz5JlFwmp/5oi5OiY7rg7l2eTb/JM8uqVH3XuFJRpBt5Dx53CzWORNW3M4lkXreVB1u5Y9+vzcJpvbM3cHpmaxWEUZnlg3LfFCDU2Z0EBglSsNXoQRKR3RX38DaNu843g+ygKia7OYqdtwffEJsF4YRD6pEVT4o5x3EfX9UMJy403vdcRaxdD8AVI/o033oieeUW3W0UmafrisBkQkHkLK/5Iaavj0GA1X2XEHcXcwqdSdBrZlwRMgHFbJKjJwxC/a4xjTZj+PAyruiH9HCxGezhFNyrrZYrflN5cZ+OM7xCCwgQj+s08jQhn9lCo7iRPol0XmGL1Bioscbl7j0XiLnk9EFldMsL7aRa9wy5tnduQpuVXuvQcIf7ENxIY5lYOn+mc5gB4osYQwDzEWQRA9ioxm9mVRhqKtZ6pyNnqEEL6G0rEbssDIxEFoY5iH7HrwMDfCEIJ/UVrffjNRkDKHd1iBrul+0iwQw1oMXQohGOzORCNnTk9ELE++j8SNNn1cSgsO0YN3evqs7PVpL2AqJzG8eX+HOF+3NHo4IS6QGcmePkHcMpF+w5yE99qdHqPvt9DDwalIouxRBR3SkJJFqnH0ES+XFtQrLjCUEu3xMDljIULC4L8vCQw2i9cSGWx0jezKEx/MSpMLHtGQPrd68YQe2/eSqqxc1GqKDxqhg4BF1odbvdqQF/iYOpsW6AyeUhsI+We/RsjUGJ9OYBsMVY3Tw68DPPtKw7bWioJRvIhuqmfWpES56MIGFDuqsZ0sFkhaFJXETjfWibizHliTn5DSx61cXU7uDZu6xwGUMBpclK5rE0y3QQaqSXi0unVJcEiSnAfca4oVfgdWM1N3FVhDIeOKdVIxzq5CBVLnDaBkv7FTkJwWlYKOcF+kW7XO/UqGgLl0pmcg/vAi7eF3W8gJLC1V9IjqCHlWq3nRBooGwaOroGy4cjZ0tG0Yj745iW+AUqdFraqFM/8NNC7fSjuXOjFW2r9Vi9NE2zM8XueOiuntx05LT2hvjJHstI1lYgmGSXW98E+VyhAYXFuH/FVh1ZhVxNrpBpMDbL+imqvp7znTXYcAT1m/hRkZcCp1PJjXk4DoYZ9eO4LeaIsx/K1kmTNyyCB5/8EaO9EaTdz23whmtPUc3L9qVAaLCTF8NEgpou8PKAfrIX8JDDvbsYDo9BJCTrURaolufLF8qT6FRce3yd9gxpR4g3rY9EpSPMosqRJ1q/dk9WLZ9+HREyD0MSS9GTEvxcGMMZs8cz8Hv8CyTemZoW2MWM0VeUgB33W5Si1Z747y5knke6Selkl0uuJl6sS7gXd3eW25TQVbyuLqSSaPlaLMbwCu6ki1uzNk/yaMNYKJImyU/KFt5X3+B1xtOvNbu0VzxdL0vMCdOrKG5JjNxaWQQu4D6sZNNAd+UqtVwDd889DuiBRsW6ZoqLybbcmnG2h8ehVUUgzAVr7m92Y/CBKJDV0IqHZSExqgaJEPqiS1BDOFfvTFPM/eBKhS+l4sCJHyG8vJ43hzxtbgFFcitqBUzeyxvNsQqBmQKdJE220awvXA8LxODEEUN+4oQThtjiVdyA3NR5SN2H3VhSptKwVoEKxSbOJPiLZU6DOFKYvKjrWLcAxpF7C+1F39RXUJBLN0tknLiws8O0aIKSUFWtsM1eoza0lKpl3qdNu7eOKtuM5AtwonhZ8NPsnaLdn4W+hSb/eCm9pAPXcmerXUCa6rmn9dRcTs4Rr8aAHBl/ObTOgrOJppZ+w6O3aD/O3fu+qSih4GpGQuSBDqDMCXEc50UqflVn4qCUieI8VXeTm6QAiKRD+36vjN7D0ntAXIDvZQVfxtzKadgm9+6nbYOqnau3q2lX4CWxtuD/8iSazf1MriUUn1CQeansc72PIyfhhWcifGDRr+5gDhuW5Wtg2bKDZadIQjOE0hG1FHXnWPNOJTfPxEitjwWWfkRpvaIk3Uw6VseMFd3RZTSTDeHMBJcylsoj/GXtB6DG0T03QRFHeiZN3VfmkyNLUzUTFtXqZoj5tt6TQHNnW/N6kTfbQCVELFQ20js58Uuf34rXGiKPUQYcnAdTmukMcz6EYdBUYWsQl8RWdOuWyg6U9h5CnTGj9NNKFDllywAhLVVwcgF+4YVTl3gesrz0qtDCy1Y4G14ZIUlkmM39iCGk7GyJzLmrbV2aW9pf0gwhyzUiezugmPkadq5YHIvOKs/Oh3vmOFJCQtE10lOIaY88QZw073M/ZRupQ1UzXrcsUzTrgbdvO92z93SpxF3vjTmcvl9mRoGJw9eZAAAHMIaqniU8j4IT4+KKkATXxQ83KE+RpGFSki13s6Z74ey4hcgeV+fOOg0pAQpk14tG17251scZPuZFhAN5ul4TqAAZwgy2v+aJ2m56hffLgW5+6qLSEAQEdxfJJ6D1udeyT1QXhhv8lw9niESMHtxIPgSv6NuWHtrRay43Y+DYBO2e0Kvia8eOC5O3dGBnbI6/ncJJcq3oZ38nITOirHxmkbByHwMxkZdj8LEp8bWbk2CohYsIJsqNdOjqLX26kT2cy0LaPtyQ3esmk+oQUAbIB7tv/QOwNbrk4bGu0j4zQa36roMtrMvkyaRC8GfK4G412wusJxZqR07EOhgKRLY51UWQDHagnkONhaUuvAI08tPIzqtiRbDqQqA7Qhtl5RaUVbxoCEqL/K2oXldIONDeMKSwdiVvDDCrkwde1XB1l8vpPn0zAGfEWVPGxDP97htDQoMLeiDhDRrRAW4n+lESV+jkaAhtTfrJWMvkkZD3BUe2ZpZ6r9leHRB5OpEkt7krLl+uMN/EXZojib1iMrLG8mDwc8qnJ6HiGwDpK3l0Feo9LmSapRHpaFKmDf6dojmFrMr4tpj0Ns5C2d7ww6pdjhYnHvhjacmmaeHeNllWQjXs4LxvKK70oayuFmg1JS3oLUDuJHs1D5MvXs0LsIjYuDxf99zJpFW7aMsx3qEbpFpEsfUhap0Q3NAt7ay+lalHUbmBEdC1BnZGuPZ1IYuH/wQclrukA3bK6IEaUX3dFPCtV+wANQM8882dpU7n3ma+C/cJKM4SInyQK+Wx/XC/38fCuNF2nax2fFI953Gs5EV5VMd4ctQekl8jU7gL+lC3TkUxu/VccXRvMRIP/W6yDSWFrule7wkPu0fyCl72dGceuS6kCoG2aM+6l21HbnADV7E7CNcpU+61S6/XdClLUNxBql0/+sNxcNa37hjfhvINZcGXPYkv8uVmPHUBgTvLTX/mmojNZJAbV7gry1WBbjfqScw0xHVpi+95MtPkgW/tdguWBJsvYvlQSpxW8mv+OtLaz9e5GKLUkTB9m4PVUIHqlBKm7Rtx9c1GlmQox9xuexVzJ0fBmWe5cIPh2oxe842DMxvXuy0u5+I18YjdBxIHh/TzruQWlZ9GkSHPNTsal35FyDLCGy/fW2JBnieIwb4PTXhypPsGFSb6637G0YIx1OUozMSMlOaG04IxZU9qgTNF7vIiSyzk1cD4lS3xg77jfYkg1uuFXLAUuAPusDAbi7DOEUdUIOrbOG790VoQmS4ob2eDb6/rbl+yAJ6HtXROKlt7d4TSZuKhAvsf3yofBlUhSQejbzsdcprfxxbz9P4yus4lX7e37IowAeT4UA6ufZFIUhNIkRj22V8+nHaao2B5bUHRK0Hu6jA56/YrXHB9vcRtcnmiSCvd8ZA0KvDgQuWOu+zFgutQGB2vCVKs+xLQl8sGqlCrhXT423dFWG/7yWLmcLG3tSCZvD1jJTiDkWhXHG02LYvvcBK9LqCwAM07Dh80vAMXzxZza+HHoECH9JTIg4zj5EiF7LK1U13zMIod2Mk0NlhWZPK5sCrYx3s78WVM2wk/mjUvqnETFD8FfGChtCjB/YvvgagpyLCGUbdtgcdpvc4pxO7Q66n2S9nihJd4DWU65C6SY+uerCqMqYcsCQGG5vmyU6JCMwKWWlbDkwqVjjAywOUMl9x7KqVhzsWVIXCuTpSHSG3fTppnKl8z8Uh7ODkZ7oZSN0nHcJVgV6Z4UhfLYoAmT4d1gff2pNAt9Sw6dllf2h0P4EzsCtUCks9gsU9q3aJIoqjk20t60laNURxlBjegyymqE3D0OvNSU/AJg7xm7R2tdTq/RtgKIlFHGppYPEaHOnnKQZ6RfBwJYrn05xTXpGtwC6bX6MzeOY2SDUUS9OytoK2bfulad+G173lNCm1Dx7xuxPzab3N7u2mzfCG9vjIcpFoh4xQZb3g7EELUHfpSZIMYqsycv51RGSrrEqsPD79Q/JV6NFnARtLpge7NDbLlYksqXvtwyIpcxlm5gwL4mVDFcBLUpB/U4CkPxgzcA06DnQo0sPcIz2QWDE+2uwdd9BAmtHXIk5IHBEwcF9EGB+cOr7amAtfbWG6WeD/wnWUgVmz0NBYQDOHN+s1IWsNhxnxYbutDGbYnvjvxZIUIPY1y3RsA2PXeIrzM1TeNKXuu8JmWts9wMyLLGdRJ4Lrbnkjbe+NQfMepif0YpLIczZMmFEbaXz0voxEJW40a7nH5hnEXWAtFxRaU6+FSYrN04pYASpzAoJpMrRDE4Mpb8Y3ghP+Pu/dYklxbtgO/5s2hxTCgVUCrwAwyoLX+emJHnvu6jUZra6ORtMtXg6wqZGZgK3dfy9UuXLUAgWY1hX8E62+lkFHeBp09xE8LNVCdJ7wTSfRNd0wgDA+9Y7xS7pwWjmU/YO0irUZu3e6XJX7l8hrEaYMQqmsHR0Bf7MyekjzNdrysHIU72MLIr5J04MUfMqJKlqbGvTxizuCFWFl5rAaMNFsyzsARKr+sv2iKtKqBaezZhvj0/uwCbL3asuNREqedB2LuOa9eObWwL6G7dlZ51ft9FC/lxfjlsoLAh8ur6/qoqpnBGQtajutV9WxqbDfHqNGzH2imF4Nl8cFHcYq3WeNqTn7CieLeVWK0svz6SlyjmfexteVSJ8E6x+h7KPdfhTyPUkDPsFMapslXF6JOSNXX9JVtbzVBIbtXMl+xID4fpQVRHDiTnIZ/uV/UD3gol8qPVSW42e0E2eFbJLKv136nuRLSM/IYx2b5hZ3Jv+AOwyM5CDbZ7awXcx8hGZZkc81yL2yBa/rTWrULUWIVB/ReoCV9pC9ZPWysRwDUYi6CdqV6esFo2b3UYsNGHSuAVg8JbNMq8p9mCeh+ft6Bm7cXXcxMyhy6yvIvjliaR2X9n47X/R+JCf67zYexUJNSDp3MwjswRgniyrpxX7x1NC+B/8rdV7bUGER7vzL2Mr5ayqcM/wIdJ07pZS9MA4lURmsHEX5NcQbWdFR+2XYSuiM0yyRtF9KZ8lC856GJ/o4XqI+Z/q/Zs/9/75CtIX39G4/vf/4d/9Xm8693/G9615eZaSMdTbpgJrKk+obqc1A3AY/RLn24AN9+EWKoON5x4hJJtqM4UfR4UeC/6KNWWRXGsm+ZAWpYAjUzgLOEfVVh1kUVP5ddJYOfKJFfO7ECPanb3OIHTqCgSvb+Ni/Lr2CQM99G48s/g+mg2gZbjlfA6BxR+0QegozfNtJfAXdQJkexpMHJL/gdlflwIg8fqkgQCn2Y/jwe9Wt7Id+4cEvMEbw4QSg6SwJ42R9V/uY/b3tKThD6R1f1CLh/6pZoHXdEDIQ3M/X1mo6S8AhmiPEf1ngYO6gSZxoWoI3zdNPcQKYAeE6M1y/TTOYO2oyhNefl48HbgqhQcQfFPYQ08lFPJnyX3fhoJLR6vf2HXtN9opfQEbyGU8k8pG7j4nt2SzIVb9Fi2C2hPtTea9NLPjn7+f79q/rYyI207vYQmy/jy07EOV9Bh0lydUfy9bJLvaPCFuAYZCGBn/3HEjD0qM9GOgHi6z0kNY9X7UG1E0ceufKSXzxbmimLccGS4V65+0dZVgxnHvTlKiDEt6yacHq+TIu+834B1iHsGJYIiilY+ytocx549tXIQHbtohIRXu3g+pjd5xyNvsXj0H2OnzVuMG/NK4ix7li/LvNkfocjNiG09vHi16ArsUb5bTEcQhQ3ZYokV/N7N86Xl9rW6zu/pMe092lKCejp5+p8flEtET3r9f7thcmqBjnh7ofMK6m0x/DlO3yTyzmvkymquJ8ur8LVw8qvPBzeely0ESLGww91+ctavPdRHVl431hcsOvGzXFhohTRHC/+4+XbPJHqHtZpxhld9zyaRyKxq4eWNvueCvczGex6seP/UEo7obA3F9al52AlwwctRGWOr73eSv3Gxo/cN0f36/qDutXnJeSSag3OsjW9XbloHe73F3uz0SPs8ge/0p63Fz/+MsC/Nv68oc8gWVGtdXTK+ClIO4F6hCf+ysylE2GxG+GjAYRccLI+Q3/pbYV8R1Qe9spqKwJtzq8D+DAfyZ/dt2IxjJj3J4XqvxwAekFgCpd0bBBdiDbTz4K+nJcMOIMNKlh3ot/NVZQsSsbpmEH6Z8df0bP1FEy+XSDiGerDUM0HaBPHUKVFJLqj5farj/1/gThBWy87DUEotKVw+CsN87E71yPWS6J7Qv6TgBsyKsXAkRYqdtq0oDS7ZZAIBEYK0/M3n1+Gbjm/mvBTjSlebEpnTN/rr/1gLgsbvwFKI91+MQ9XwTiDpRlU++jEmr1HIu9pJlVsn8CuIQMugGQpgXogzuojpPIVXKRpPBtQZw1SnmnUt/6X+TJcMosW0BC59AVXrDBdHTVBb+qI3BcepC0kPL8ErxS8DzzfPu5rB7XLVyvu2WhwInHXBt94/SFwVJCFecke7yyQaJtAU1xVM+Gj3l2dfGrm7b1etxx16B3JyaQIV6je9FRaePxZiyjT/lnKbE4AVzDmlx6pWWjoZbef4hR9p5dCupTUaY0jeY/ireckvyaN/1I3lBZQNH7o4LEqvGi7a8P0K8hN4p1F4a6vEDniUdbxrCX05GCV9a3T3xHpQIz4NcY2+E+bO/yXFUMQYXned2Aad3DTkHdSPsfIKk6kIJxpqPgqcMsNfNXmKXSYFbeosJaXd0Fvezifiqb/ejiYX7wSRziFaJ6Bpd01cfeXHrHPBIbt17Nmnr1aSxB8zdyjgL4DbStRGr//1HocfXz2flTAc0iw8CZBFesWNPNHLRnEKBdsDOaJ2B5TprPaTSzJ2LUR7Lfox4GMG/uZMpy5P6UAdy94x/KOVgazobqG5gu5g+VQOIuye/8TgF9Q3c0wo//Xkc7nb4dBnwEHTpJxBqMAQutsCgtXvN2iXeinKVYC9mzNSAJW0O0wXCcjxALNZISCOxoNuZ1HDjOOOzOCzLi2OOoLm0P4kTCP/FfzmFEPbazxJeQxjJIyWl2PEg/pyW7gRcXWgmj0EM0x1e/gQWmN8f5njGJVyEO2v8mbRY5kor/Uj+2CDAUOn5FzBhZy31jUE/n5MkOYjtI9JLVdRC7Qd8uFOxm3rT6Hj/ZbL/sQ5+o9S57NI0jBjTa8HR+c3kAg4NeBpsUc0NflVsCTwhmvIrHPFUmFXkYIsOI09EiK6J4nzXI7jwgV/8w6iTMzikxS5uKazSK92BKxnk+znzzn9uC10DayIyEVs1fhtIjBOieVPNt+PNekw7ai1xDg9fvYaEm9hbm7vwq6j7cOXO0NsY0T7IThAjuuCnV4YzwbFYdvh8G30Xu/QdiMftHYyrVunIAsJcnVsubWW8v869cGdFNtK20kbxi9jfpMAZ/sC02s2UC7ic/xPOnwvUOrgPKKOmD3YItr3Nhse0kO/wUhVTt/SHDlMSOkWvTdxuKjr+dYx+VIB7e1L4i5742KDehEwv4taiLO9wWqgezkGsfY2wwbp6NirchWoBHbucxjexRAZkV1jNmR4ehsNataZvz1S2jJCY1FixZsbwZcxiGAFy7xaMBfue0C9kJkCUffwgNp76L/uf/KZ8l5q9QRJVlRFaERkClWeoVxc0nvaoTe+aD3JjNfwA0AVmHGE2oY0mvBpP77Ldl0HN/K2uwFLda2IU8jMmoUynwRemMrffOYlNYRMpVLclzrnkBV8Zwh/DH8ofTGxLeugZpNLP+KtJi53vBOPvdm2dOWOn4Xbujs1BC6GC6YYj+1+5KtyxuFt3HIRjfD4eSuNzK1SasWWpZ6ND432bBJj+5two0oYpkQjeV6osUbNDtkFJl3s96Pf62UbhhpxzZzVBdv6Q7BGhrVfkonNequAQe6pshX1tOTwX1vH/MA5A1NhyeRI/3kndKjTmAFefQqdpxCCt7fPMKmXH3kJtay7XXPtA5F7n34obXw1YELXf5S0oN7f9R2u3WJts+Rrasek7U11ASizIiChJVRtjQRtQV0Sa8VQhfilHyOEssjcdRc7hUUP/idlCgx1aH/njBUTorO3rR6sErBXrk8WxJJ3x67mMwfKvRJKJzpQdzmDFU3tEuBSbGFPQ/yl804LPTMkQyI3TT0dsr78ccVQNoTYl5InuboBXsFymtmcs8g0yhR9jIRpVgYlpoQzYvsa1UBlltB27h9zG0gD/I3lJ6je2jzut4PADLUsT6kMFSwKzi4RFiweuNO7EMV5qUiG8gHhsm9L2aTgn1442ThqMePPlGeLMmk7uzvQLfbcpxQkCYS1du3qAdjL0A7KN7/uAYVmVKhC/BqwN+K8wQ5yzUCvs59LobnYfj9DkGIgItThPTnAkaDmVlVbb3TYhH0de1p1UqP7XqMD1VIJ/0ecxM4wWNtOb/ld2RTrIkgToOqUOLa3kFsH4fQj/lRUExxNgqNES/xQIwuG8sRAaDuge4IjmOhS7gu9cH6bfxSBWWs390uf42PDYJHNhg4oj0R1jITVC8yGW9wa2ouwgRRy0KR3PeXwzBBSJjbalAUgbID+3JNIKhffLeBKi0JnvUciHvPkaa+0wkd6h3x7OxBkR0v33PT9oW6vOhJWRnzGfhoRgcYgkbDRDRnf25kIdaSQIHm/a0kxT2P9NIrF8ggYlryP0O66644FiQaWTZcKvrLvdTTmqxZOi7xL+NOZc/a6mo/AJD7yqomqOH9tnqIH/qN4CAMI0WUw26lSKb63ZVbTe/RAPgAZZ3PYhGggcxHb5O+YFHS2YWFLJGEZNxUJKhiwFIWT4EnHt0/ILWpd/N9/5h4O84VQUobeW8+FYtv19EfW8glUy7fbElQsTEhTVi4o4ejso+L+XazDx+rtWhcxYbt/X1ZjZLE/ccKhqP1YHlfmpQk5ItAf+XH+/Mw1ItXrOp8dMyBlSKOPeiEhJQQRIW3EvZQP2eouCe6nP9jeQ6Fid4zeiyXArhNCHHngTUOBTbp/HTU8bQ9OG56ldxrD/LvAHexUKiqSmb+1ZCNuFTX8te6/Wu+5+INWIjGLNT6QSfu43u2vFYQQSRmfYLSq2qgmSDtdaHzVZtirKwSGpCTa7vko/zzb5vjE0+992S7Uq90mjvRH4QVBw3wAJO8isFLoWf01Ta6UXAd/y5gowrdnA9Sh3rZ2/6Gl859AIf0svhPKJVOjXhtULJzf0EgNbnocWoN61CMkRC0sRAMo0vciKa+vMm+BCbgzrisw9BxyhwntQPLxRJ633Jyh/uac+sxE8JU24+UnhfPVKc6RGYpvCJsVF4f0CFujp8/Ckvsv/bWTI1PtLYP5cH7siWU0dht4npLJDWhdhIDoakVDOT75xkruzVEZWjdz89xR9I+72XsxUuYYaMESNguxq15iB5d9G9KrbAjeN4WdQ7eWo8WSxLYi23rmYHOtXgQPmp/VgAZWr0LrbcWyn+KXSvTx/RWGMR+1Vk286753O7cOxpbINSuXpFRiFHVH3f7QSVVtr5OEBSHbJUm1fuEPiyYstGsRyaPCSXR3ArZsonPrRVgxRqu75d9rcBlYyR4nLrspzxI9Z0g6zMeuh2TfzUf3LpGhXqW8L7yq9IcMDD+DV+jhdnNTVjbi2DGu0NdHbFLrhDcK8BemdrPd62N1bUBCWfLkJO9B43svjGjDbtT+bS8c58msD2B12hpQFa6oDjY5jmYEAEAWoHY1KTvoPBi2DGQ+C7oGO2/HzJ2ZkEuvhhitUcIqauBkgxt/bTor7cwCKsSFCnLXsI9tDTwweUBAvoljfP9LCSbvrj2805DAjt3geYjBdHD4eWDvH92g88sLJoIRAFkWtBc0O3vVxEc61qx8l1MMER556zF99KHVOoROl4El2qDwB2kAW+d7YokpSblHL/RAp38vVx1yfjEIc1DpTw+tJ9gKbPGDJHkpHXfvBUjyNPe3GKdGqwq+3aFKSSGyiGyvlpOELcXw6dx0fTQkjyXHu/iIdtvB/ihAVreu+mmN6mUIzc20Z9CZXoTSYDd+Kc2DZzbQWXli/BR7JdUiP+SBI41wbL4BNYCerdN+qhmaNkp7ri1jBt0BZaxin7wolzcYNMf3Ssc1c0WVIHi6Fho2UWSfZvfjzi6UGujegSImVA0LlmQlVraG5zXuKD4v4SZPn2U366wb4u+gybp9Siwn81LM4Pxa02r0jY4q9ELo3U8Ghj91Kby6HEkV9Bnvoa87g7ghSC7FJFuF6WLCSW2KMPoBAQMrdkBWYPWIIcnXTmZg42GlzIYMqM6XUQIoGG5xoHUOnyubH2snZnduJjAMnLSx+n80nbEPxAevV26M1Qvfoxe3iiwP+1wOsMxXhd+6gWPpgTB0cA6endo2ozDiIvVN0B3ukEeTuzRG4QgQBKIjP5qadHM98kAxkwgTkUnq/b4063W+O5RCZ39l15K+0EYZHTqAVvFEFshOUjDmy36F3oksgkFJQ6lEL7BvgnS2rehv9/CFv0itWR7jPbYsUM0vR2K7yZs2FiArIRrTqmKsdN0jsdwJtsomlPAWUwz3vOlJUXuS3+jHGRbMZuGI4P9WsRBuFdPgSZP6FW7On23Eoxxi03cSX6BD4FEgHVvTiEoddiULQQeA260di5Ddf9tzLLHCfRwHrtXKjoiRaOiHijLDpkiJk3L5LqzvDPNKSqqyrKZaHqGUiNaD0e0OBf0qNg3YQpUKjEWgotTqhTD++sXYUtW3sxJuhPfoRY8OjzkgFEosMNd54eHrbwMukkLgmzPfaOeNbwTdyogyIamCGepdmlgv5Y7p1GO769XziKlzu1SZGyHWZq5ZIsjDhbpckkFmSfCKlEH2qG4b2lu8hcCy2bQfCS3cK7cqeYXgDEzrh65n4UtYWyowTwqjP3OL0w4RbHD5GQ+jKN4+c6b44WfW2sPrV7iMcuuP4NfA/I/pWRUqM3NhDIl1hhhCAsNUhryPdRQBIsbkgGy3OrS8HfPR+CSwogbI+By876nLOobY3ldCxlFwQd0PUh+KtDVpswcVwQbFJUAj1Q/zmbuzC+yorTdv/ZtPCEDFQkMZ58PoQ+0jGspvitdlrUPn5LGINeGQd4fzmaQZ63GJeOuZpBFO60WBi/3q/uY0/rrEyTw4JoQOJxW8yvNsKMSfLJvUbWCfJtWyeXgIrlVZ7lJEXRTfFiqfzZdnNrN9CkjxNzYh45JE7eoUwty1hVE2QxdiP1n9acPTFyeWWs2ewrgrJ72rPVCvrWMqkzJdwcrW70fg3P77AASMcdpzmX1nIzhwvqiv/DPrl0t47xNuO9RbW7rnI5EQWWnBIJfy9iZhGWY49udaB44jKmBMQDUMbiBqObv+nDI48zhN10Vpq9srHg0rvcgNc1vMOhBkhWDXBJP1jXbfsgHkRjf+g/b7vuO2bEAJWupJ19D4UkFyOW6JmSj3ZnpNrRRL6f1zkg48mqW8JPEiu1run++BYsyvjAS4MXwIr5jsunRiBFevyBjkZP3XXOdOz5QFSPa5Hph/aBHM2aAsEMKfPFwnvifXzPZd/zjD0eVl5yYkmPxpS1b1X/VaPM0rWeOZmSu7foMbg0S9mHe7fxkgmezwR20TKocq3yNHZfSdAg1zyJvvc/w4ieZ5n7qdPbttUPijJemgpN2gEg7DyLo7CK8RmHmTdV7i85BbYLs0KPIpAbaoD5UPlqHhLNcNRU99zWQjdR3NKGROsG9pUU9V2iH0SNmKflXfA4cB5BJerffAhXkgV94QOnnIj0SpqgRzzKHrAWYoaAxs6LSUC14lWLv4XYYsJYy07HcYn7Deh7Ztj9IJG1S+FGR70fJWuvRVYSKd06EvCHE00d3XuRXqY0ilz60TWMAkpBztGUQCVLhkYJ4K5jl910gehk92lg/fN8FkHwH7FVAc9/8fErZd1KGQa/h59ksnbchvzLABFc+VAb+E4nI/iWuZ6VUbW/srWpAzQqCjM+hl0HNEjz3isNwnTcRXo1VEblv/LMiU7WoFf2PL8W5CRf4W02IVtw28PBcPkh2ashxmLoUTy2gq8OP1gWybfB8OH3i2yYiAa+NEd6AU5gJEU06lqpzyRhvnn1eduTcElAfyEiS9ClJcasEdOhl0LRCcBxIudJevCxtx/ALgiH9s/X74mANxL+t8VHuYkNK6bs1QlPp/L4aydr8VUs2+C7290ZN8Nh3wcGUrTB8zCwsscCofJ9GQAotErZa4BQOWYZa74IzvZs9OJzDn2S1pTlKeNCTFbE1YM2bnzHYh5By1Ls8tqS0fSW4IeAKCLPA/W6mcfScFMYgcwMxRsRQYhgzkPYfP53jyQvVYFZ9ej4ryPKvSIfDN4ix231BWa+/VgoNdLB1hK+tGUJlTeLGIqigXC7MTUFzLzC0KXuwH7i8Aw399wFAKQAeJ4igDWavTSX4ZNo3Qsqt7EBbvui79ixzQMgEFMMydNyh8f37RR78Yow+IJkUka/b8E1MDCXcB+r7a3LL83ArTYv4K1msZ2XXsugsZ+/qIUiA6NidZP0YNmcYA8/Zt5bAawbus0dAezabVfpRpZnv4iT/GyF4NwlJeYl5WXwkn2y+rHApRKRhY2A59bfNMAh6E3sFv/DNn2PuwAwCyMXxH//kwhRHUGrKHd7y7LmsGmek2LT2kUk+nIayRXE7lWSVyafUJwQxX/0NAn+AXWFJL3k6cBTRvLjY97CKuaOE71Q3aVc3BK6Fey4Iy9olMrrmwlqSstuAktD/9sVybziOTsUqmn6a/T6Pf6Dztg8AnFW02hQyjrmZSwPXW8PdYxrj9rLqFj1FZ8pbK6a1TiliUCvp3jQGIubqa9WFi2kpYGGAE+uVwx2NfZLSgHZj89CmxDXeg0G91ZBikLF/ICjk0hAmaN1TvI+kaScywwMcM+s4lWahU0R/Okh/ba2dSyeViLcRxCNFEogZ0o47uZtB4RAqvHCs+aVVT+VaIuOnFIVzYu/ou4rLW2HQeH4IGa5/9BU4XUSowkvWR3q5+9gk+bC070MJ9l/osCgWQvyk34UkK3H6nBgaBHkZ9KN9GKX2a/NkmuQ7RRRrAb3n5xgedYMeIWirk29a7bsDx+VEb1otbywTX18TrswM9K8SdN9M5qvWuH5Co5xIoIQfsg9svr5r74Sdc8/Lw0kx1aDs3kES4q0ciy2xmWiSmxLgU4cbcksO6OulgMgc+8df2u+7SOIvyTrQs8vd6tq9vd0tosBr+RP08QvhyWY89ta9aNjfnGctau60ZbDrD2Gi/DujbWgt3i5Bw0ymP3DMm0do/dWfQpCeuSUUojIFwy78gljmvX88tBja8KF8KQ2aa1fy+0gzYt4YWuuBrcGG7+ZUciZhnyHcRzH5XYnwfNyteZowDzwXf36q4Y1pkoR6foicBvyGrNQRP89nWbb2EBe//9Ak317E6vYE9pG/t6TpRH5Mv5Y9yidp6re0oUvOTga+P7bBgB9I5EW727ZurQoZOZ/FY1dKlEWE1GI2yYFMvp3fC5sxzbjblPApZt02Hd3tkFV3Ordkr+jbLPkkRebya6ZcnjkfhGQf+oTprUNw1bHm0x+5uVO5uAqTWvWognz+5U+DVQY84u/xIzs8GKMnA2H/6lHqAJ2cRPXsMNQDJBdyEkLjQY0d8aCuG9OeHdEU56ow6N6lRL1G9fXMdqF8EsncoIemTUKKtKdjoAy7My6sh7HyfI/qZ8nyawEZ7zs+Zt3bWqtmT/ZM6UzQnC8Sq8d218Lil1JTfzJvX39Aa24sVVsJQAnfIqreIK6rpShp9/k7usd5iqI0no6vByDyNmkIKXp8dBd7DyexN2UtjxEgW4FwDTr06aJG16s5AeHS2K4mhYL2WjlWuPRhlMau77/8TyjsG6vp432FKB0tEl0P4POStqLJEAiNBap92Q/O3zvkASjyh0beSciHmIFGi0WfktsfR1eobYRfL6sJPrvPJHS06sBSENiAZ+hSuAJAPjhIj3ioX/2AGT9epmPnZzN/mGHfWjEakTm+OPzGBxu+VYvY4KxIo7n0LH4VPwRxjYgvcObu8dUX2nsuYkRJYUEfaf1w2jMPKyB+F2iBkFUNIMs9bCKIl/cllmvZdOtErFj1DEcHf6AoPVUgwG/4+0iM3AeKSTyuo6KPiLxYLUS88OQDjCKLIueeoAAM4SYaO3Nvs+AQzdSEFpiLwXsKoAS9wUl6qYIt3ahZ9dSHNlJpJwG4Myusb9Yi/XWaRdeDwFnY0T+QMYsdRD9EXC9bdYsTW9Ng2d8G7JE+x6b1YbB5QUWDVMw3hdZ/RX/FogJ5DfA7fFiG9SW9heKx+d2eyUgzTpNhfjIZnME657dgH9p+jea3dWejBV0yhNGI58gFvd2mCJiU4qMe8u3tyy9dkAc1+cyc2a9ur94lPrTP74KJT+29HCS3lWg+7nyHSkVHMunEivsBau8rFTVuitJIwJPibDrlQid+hSneEoIqqiCSOTVvflZs/H2gNaI+EyDK5U/ONggwWXR+ZVJHsps7vhqW3NCrAX7SXSjZODn6q/6KUxcnsyA/5Kr5MZ5cOOC5r7RHFvPbK8Kch/SXkUpB2Ju+kzINbvw6MQrQNwa4xdEhfmhfD+ECokhDTo5s4e4AoJRd3ivXwFJGGZC4w4FYkJef0dQCftwEboPkuwkSRFoRiabrrb41+15Tfz2KYCvOFIMP0kkUGTMfgUYougdK/ENU3emo0khtKqUV7Ge51m8bbgOIQq1hBOIaehYvD2I7a0jPU9bm+0KV4E/0iTyOIkGYlQkSBLAXmAn4/gOOk9hEJj8KND58H3YjSS3JUhXhAcqHVuMFhF3Mv1GZTwgC9TvsrRRQI4MvSrLA/pqldFCO8+Ex9SB+2qpvGNuEGr5Ps4wf1ioJEwZh0DFYmv4uSdTRg69unIdBeXZMOGmUdA65q+3kzMI1IjX5M/FQm1RRpraWRfJfksdViSgOj+qdGzV+HWXz1LPw0ZkvfnatblbadRlMsrJZgqkVRA1FmZjmmuFmsmjeSiCNAQjlxFz5K5kGTjMbzvFf+diDJSdFhEYGUTsvgsLS5KuFhXUnZfUdUPkg1jp+fNm/4kBiNSCQsbFphq8eVzTnSCc+/FaxYHCkxCo/DF69ZsWFHgULRsqlK7uh2cf9sndg73fokAdwZwmPXkzHRUcaK29l9At1koyUoqtuVzQRaaqkKaVB2Ibvb2pU0VYUTdgjAgSxk3PEJm5Zz3jJQJxK/8Zog60m/aYUkA8e5PtFfTh0GkBejpUy8HMcgJtLaE0bQIqwS5XVOmqYnb8JhzdKQe7RNM8MuBiTyQ67evQXYECB0gCAcR6k0ZCsO+ExJeb0h8fycTRuhSqE8+ptFIt/VeyeCsz91ldmg6EkqbyBJ1VXwSLfxkSIhWMIhMhPQ2v0IeBxQpdNEYh7WUhgfqDQ3OQofuFOvKv1+DWln28jWfpkEtit2Dmr1+cdvW6XLOEAfrHLnLBB8lh8laWw8yXG3cv/JN+WCF4MORn1QMiZRcpeCYRfhOMZPfbA+16nG25fwRljRKgKp2d54ePU1AafLBC1KpXlnvbnGYo/b+XLvlLHkbBkgP2JaRAqEaz2Eevvw5uRIoSzpBVEaXx/G94rnV8nNMYAFtzGvzDwbPrkP+18QCyNFB0/qHhi5YUhfhNx+A4zMkKixmMEhydwgrWDrQbpBX99iIzvP3n72itzj0aUX8CPYhPi1jqtKqk70CNjJswlVWQDz7B8vIgGNkks9/Dnb+HbGHzVg1dN78ND/fFscq+BZx2aGBBtkRfrG3qPYvynL9uDjXmw5rBVRl1owhH7lZeZnDrI499qXxJ5NZ0Cb2AvuITVj/G2Wj/Ol8YHkZ7KGshLslOj8OAs5Xw481XqfLM1Hwlu/Da5g+acVNRBnD7knPlcFhxPz/vNlH3ufQdWXWQluzJoMErQvOczauE8POxfB1cOqH2DsAm3CbnU4kEQwHibgRb7QELtfTN/pJO0gcj1euyX9OwnAYXGKixbgxI/ShZKQ6MZfeJRyuSC5xNP/OXtuXB7pXTyl2b7DFUi2KqqOd7r53PVFXfuIuV3Fe4InRpamI2JsPejOeeE0IkJ8x4u8OlamWdgIRBUo6gfspN7Y1a70V3PxAFwhsk9Nu3XogM0vJbgQG2ovsENm8eu7EV64geqluZtBXek8jbxYjkY0HQ3lpywgblzVEHW625e0QNfa8j4WtmXY/wy09YBrWEzjnBfeIH2TFtQxA0FfBQUw77lHNyaawcXvJCGW3zQlJI1W3KVmwuO18dAWMrcUfIUEiEaEmMf2/jR3y+ME/IefzRO1swrCGIAvoeaEafiO0mj76/4vGm1MpFJUXkLu/fXezG/TDZs2oDR+B1f7XcrNUz7jTW9ZOOA0m6cZyv4ThyVvr7/XS62ugONY34xiEkli2E6nksj14J2rmGFm73cDyB2b+1l/JJS9RtgQvQvwiPM+fAF9VqG8n4xovh+ltZ9kESlgFXEYdDHaL1EEAO/K+f1tefpmI9du2jcR2TVzxOQZ5yuU+y/bJYPraIsy3B6pdXPbUjUULqX0iNjOD1JrZIT2zxeIlgFw3w2FBZwmtNvDyWm5nYJEJASLJxlXyBcQW9KKSbKM9mSm96o+Qtd7yRw0bpri1vW98sK70wqiW4WEXIhzZs2LW/JJaAGbgo11R4A+gKFqI4nUvuQGP+d6G+sqyFejMtxPI2RWnceZBor6udbbjtAo11mHSCd3IQWm+1HIi0R6vnwE2S3BEXFUWI6coefqB5yvQS7eqRRCtnWZGtqgvEBD/8V8FR6hzYH2z1ShhEFo/KoX51bMsNgEmCnc/7ZTDcTPoEAcpr35KIMaXnZE7tFUSUuQ8wJoBqL99/KaBLIuf5tWzG3+4u3SnGJ3/A/Kmj3B15k3vq2A4TX1v1BPYckHZlHx3zKyBYaa7YtK/q2JjGkZDY5JzSCtYbvjHzoCvL7j3H0LLruW4ESdLz3zxqzC0/92p/t06PHK0h2QG+Uo7hEyyjxeSeNG0Ler9f1knEI6hqceaPOSdKmq16g6Yi6uMTybCIhpCCe7RIgb9z2qeKrjwvXp+uQmzUGoAV87VHtKUAiDv3O0eHjucd1gA5nKFixRpZZNTUZ9lvU9a/IVIgC9Q6AAwEc0QAe413CGP0iDm55g134T3H5d6iw+F/4jv9q8/nXO/6rzef3jv8N75p0oKB5gqXzG9YUYU9e+r0BU04VdfD6fpeP3L6+8fOl4lnlEU3WsLiFJazaY1Wr/j7fUA4gjxlckhTZConnf7IcBAyLxzDSeunBsWwQRAsjffsAVkt9PX8Cqkq8ZyYvmQdfH4sAvrIM//o3ecaD9XxWGqz/i/mt3b/Hs+cvxgP9aJ/vfJ8xP4cEjJl/fvTf5Nm/4ZD+nr2q3zaDW9rBMEvw9d/q2b/hkMCzf4vN+/949r/gIzwWuLb8r9vqyq7/ejU8SAhWC5jEhwMzuXPTz/GfuNyv11Ss+TEAiFWZgg8j/+7DcSbhwxfRlnT/uiGlPrvMQdM2oYBXcaQN5TQqDSztN2J1UL0v7SB37qFWyEYuCVZwDa66frx38U8DiBYt76CHAKpH81slQ6OG7K9RghzeH8x/AZ1gdp8KdOukQ4BiOnz6iCeqgJbAZxoLvWqBjXUocLdkpu1ZXHvi3tLN0VRiY9E0ZIF14S4Jf1blKLr+rDpVhYNfO8nJGRdgM7wW9WQYeUHihi4k5yR3DitfQCc7LhbOS5K+X0p6GeD8CEFThgwMiiB3H99NBpsrQEduZjFnAtk0Cdg4ngFpY1f9SeYcnWr06B3ZsqsLih9CAw7fsHHIuWhjpk4wOJ3Ogn1AbxFhoMPxIYy7g0lrlZ4ucFvt/6w5L4y/jRXKlbcUNOs/m3ZR/vlJVS3QHhLdjcOz519Jc6LCRZENRkw7DjVqBhuaoREkqM/3B/GCeA1rdetUv4BUQZTJWQn3gQXr0ct6O8jh7ceccx0rTqz+tD77R+I/6VHKqfAJ03mLOiSh3k+Jl9pcXBzTbiu8kelDUgsIHCjql4Uzf2LMaQgmD+FnY0uMgA6wrazHtJTfAP/Jn0NiIMS6HTOsln4sj8rNi5qVRYW4n8DKDKN+KQoWB7RMmS+eMu9CWvqDNM5FH3el/B18DmBebMj084tTxjzFWxAcgCvFLptkyVwcEVdl7gD1QFSGtkSwmtrwY45oUzrT1L0qPomgjQW+SKfmEVFZVjCXXYDrhQyTZLNA1XZoW3tlIsEdJFxyA59bUWGdwoWeHl5vgw6nXwCauVuMBU3ZWWZharw8cBAMB9VOV+sZKHIiJugf0qEi5NlMLlPgyH9dFn2Uk11Kyraklzt5RwdcJ38X+R1J4lrAK8b0HvxgnpfSRMA/WLekcSy/RowPk6Z2inbi2f8AB/QNXHPMjQQHJNaND/TCgsC7nFz4ljbIDHUwsojxMxfD8L4wuBvSksgEbTTspyxQk9ljNEPo5xDN3rvlTXml7znGWZB3IszHSwHaFQsTRR8KlS58+O4veO5R41EXBciVZIgH/T27zr0VYXPh31pjMLOmLV33/VRWgiu9AH5iB4/FAiftldNbh/Ek+96U5WeaFB+EkHU84v3xuyN34fam5Xp4vTRu/RYcQteRKasnpPr+RrDvn5i0S8gcH4lmnhPHvJW1VdMM2jHuN1qraOJiA/vUHiPeDwB5MI3ucXBggasgjBMne052niObGeLrd85n4a80l/ld7JFDjqZueeb3KCSBHziGQTjq041/BZTRaX1CJWIUMpxpigPqXAYxGIP79TyejfI5jyRlMtYQqcoeIChyd2CtDDTRz7R5vbhmNc2fv6PWT+CHj1wz+R0DfZloYXDVwp1G+73Q/dsIxwPkQvxcADljgV9v1/xYy+k5jRk34msZ4nFH/5qr32upidLya/+onCltuO2pA9kXk9++KwFVP4N/thKNqVBGH7IZ5djQ3c2zsu/aXErgIdMZDflTTEgaILfZJq+hWidwN7Kg3/j1HHzD6csCNFV8g9BN5GCEWV/Upi0jcP5ocK79SP6wUOCMZo9Kc9/Wby0XhReH8x28q67CEIHxEibipixZPbV8PfoKNxXceKzR/3OTGYb9/MEgzE6/2nGLnD0kabEdiL8uXlv4OweaqXYzFjK/bFl1myfkBM3tGf17kS/HrtwyYYEXAefSvEb/08PKtEKfmOeWmaPEHOkJim/Xam1+2jxGGTzP+pn+zFkJVeIvPfewqHdsE6aNZG3kBB+iTt+U6SLrnTbuBAyk0f/WqtjcXxrTnVRSeaWtFuef42cio1QJD0Ty8Bm0X2XI5ELKamwc4P5tOwSv0Kk5gFE+6g+VvCfjPHtfdrlipooCZc/X8ShFDUuHec6SsUcXTgQzMtOZ4yuXqalp2P3vm330oBEQ96T9fEj6QqCP/DwS5Dcxk1K3Fw6OdRsrNb/95r31cL6Ld7HSclzHCzBQWfa3ZzSRS/Xn1+9/t2U9Kf0TeLLnt7ClzGMdVkjNLBDLijjW2GC0iqVnov38u3bM336XQraQUR6GkAbEtf/TYXXG2Tj8xzobN+iNoIjNPa50MZ++QAKrtbABxJt4DstXXbQX4vjdNIL8k/2v2tq/PG/N9jXoP3roSFx8PwLN/PxoaQGKjiMkaq+AFTzg1/QDRTcF6dALP9dKGWiuZi8e2TsCkDL59RTPfy3Mj7PwOVzFDvCLWZKcgKmrdboCm57c7HjLeWuO3E9q89Vv1nFnvoJ0UoWK8yJEWudjbaRV39do1keHhQB8G4Qdc1kCncPUG0Sp7UCK+ZupMbj9HB2FVLR6JiwwePIkbL/ePb5iRctccjXFlj6rKL38pifM3PqF5E9Xfig3bLfcxuOjmBRnrh3tSe9jxsq8XbELxGHJOykRu/RYcJ6fwYLamChhkscEY1o3Z/GcVkxrr6wYCllFxyInZ5zpVY+ZvDKMlihQp3wF7tUFGQS8xCS4QOGgtJdDXAPDyrZQjhzzkR5J8Tvuc4PUM3PAiQIKisKUlt8SFTPMNe4DGJds9Z3k5Xfto1oUEhN+giL9KmZPhroSltwQHDZwcA3N9Qlf7Nw1/jUh2nvL6hLZjk8bkjFb+3f2K+LdyQ0PDOX+V62FHFbBkr7dyJvyObP3bbelOiDggEuIkQjdiffeivTo7nwACUnM8Eg1wd8MGUeWUL0nDeelqi4Jbf+M8beGp/aBzuKMbg1BdTx1QeulTUEUfUPWeUy6IaJBZ7hFiKjnFoVFOV8I5IKYCwZpH48XwCbShAn/bvhVflragRU5NYu0rggCn6PfhQAmuBurlGHvYc42a7ZsrxGzrkDQeyE7wjckZjuOSPX30wZNA9jRhk1POHZZOqYvPd+FMsNRVd2daqI0rJ+TTmeE+/ybKyp9ZDF7Lgw/xAf+/aoEi43iTBVEMhRm5tNCJokWgS+q9ZK30227RPEgzI3idl0LGFy5ZD2eDjW8xQyeve+HfsDa8O4eBfsfoLlZrqVyVMAPeuCZMdpi4FI17x0inxMd33l873CvGYb0aUgERykslMLCvhZk9RPTzcWsV0gSMo039wrGt6w6yDApkcN7JTHUaHr4gTCsmZioQ7OltLwVvEC6FLhlIkJWk6oohLhAtGX+lB9RDwn6d4lTJ4HTcTp3SIYAsjcQVkfvT6+B89bA4hdq2+X6yyjvUZ/SfBAsIIFmIWKhKKnlaHpV+1ZEegJuCWgPsAw6SpF+MPic6sWQn8lAIoSe2PzFHZxNJ4mYe2+DNmp+WuUyjVN3QhwFBc+/7ATUjjUHXcmCkjfz77ZZWDL3SnIYx0Om3713/aZMLhLAvPWgaR5F90C+cKAwqwnxixfsuZJukroB6ysksUjicIBAkZ6VTflV724W8pQ0IqnniDlF1x9gkEIPhsqc2HmXDcizHgsP5AiigAnhZcsBM/lB5qIF018ewQWepb7cdHvsSE3AszAKcMt5rVrE4MQQ3Mtt9J+pdzBNZ0Alb9sehYKDhIGICdKdhLcWarf810CQQ+Ezk/YE/7xLl4TTFB3t46VvUlENv2LpgqRzitT9IBCu+Iuu9L7SwOT/6nQPVgTbJwndd/ueqYRaiHTQlv6eiRHAnIYu6t5oBxQpOIw2fXir+ps0hummaYCuq39uId/mUQUBA/Y+Fzqf6WzQJZz8eImiaaneA8NbCPd39Sjixhod/R7ani5wB5FMA1YDrbbJ4LqZa/li9qw4FN2wF4LZMT7sx+7BcqZ7ppm/jMFJKaLiLue/Ck0mX0e1/WqN0kZr5VF57UXnpzqBxZY20nYwFp1NiO/bHXLSe14k7Hdphtg2Pr9rG5mTS0ijau2h93KtMG9eAKW178qegc2FIDsNt6X6AtAGq9/jAaTh/fq7FXdBoFkdCP+Zt21nZIJQS3BPZh7SmeDGY5VntQ+I+fIwZRmChb5YR6yZZkM6L9roQSciMoQDigrktlmJOxzh06tRbUIFa759eF9/AVVQYLH2RS77tjpKH/1Gbd4C4wjcufhYfqb0az6qjrZlSaZmF1NOyMuPk5Ix6qDZ7Vnpd/wMUq+k33VG6DP5i9WaEG06aBxyZwdWJDYexL2b+yyBtaMWJ+zt0VauLLMLTeA0MPPiFNcFF0BmteAgVQEC9MUcDf7gyV+Nn0g0fNiaez92+bGhD/UJ8sJZ5ZnLpyx0PJN8GKuBrmZo1FpObSefEPeyrDc4jWGCwqB3J5LSXlQknKl/Po+ZBKZr/YanPS7PBw/8xAGNUQgVIvpABG5tG/wk/8qT28besE79qK/0g1rKo9KkZ/v8Mw/BNT4A0cyaB4Z8+UK/5E2IE2PWVyScBB1d2wXnIs4R4Zts1e/IK1u+qeiGiMYHUkuAvKz5Hp6gQE0INyeqDRVkH5nhDCT9jJB3/PrwANKA/Hh6SAZSor3s4QFTosHazWdNNb9p6tws9qXPoURR0y7vvz6kuqXVVFqBmopfFwu7RBwrj0Tlr3kMfH0eBIxm7gdBLwjBGgsYNDAkKseJ2ec/grocWDmrCvvG9kdJ0PiBvI5A2t1rl5v+XHwEHmuxOR2aLlrQLZvxNuWmu0cK5RR2NdFJpOqqMMOsMWR0LyqO+D+G8BcgBZAvxibnfD2oxDlAa6VJ8LV7GWgjQvK+wtBHcp8PF7l226WLWl7JRlZBixkf95sWu2W1+ybbS0T+WiDOD7vK5iWT8vVOHmKNN6KeoAEZskkXI8EV4IijNYijT9jNB4mDwYPNAZ3dTSvBEOJ//Ks8m0xaGNh47SAIUI0aqEL1V5+615eR9D+ARBv1SCuX8HfNJEPtNfZIE/O7n8sYz7mlEM0NYbdoUQp3veoGcV+ga8IDuPoWunsXp4J7dcG2uY9/Tg/sWv65gmOS3hX/5XllMz4F7WujFCQx6EshdI+B8LUvtWZyZTYzdyPkz38W7/0cwKSfymjV4GlfS+3af260Et8FNxRANHcVJoHWO68/P89FBfCldYrPy4/FJb1OIbUhFEdenSc+LUnp5uPyrc0PSP3oRoI8aDwMzETm2bbznU7TFY8IGnE5jt1G3q73ofBH0rCeecu/1qb/avuiurUVr9m6JHb2t7x710/3auIhhnPeBxihzRrzPgPlXT/waS95uPglhDSHR211wVXYYJ80uq7IWGmb7+QzFTjTnn40knovpx5mNPT9aqFrteWbBnbwMTPqkA+sF4KKwp9rC2hbdkN0LkL81qsVcj2717PWNqzQK0X5gioEUBOaj5UGdRXW55rNNTkoQiTgZPzs0aK1Bl1feI3vW46epO+56EWxaK+4EX3PaFeJ1f5G+A2cd5OqlZ+okTMCnFjxis3SPxR2JxuwExdIpgrT7vsKsprA3dLd3w9NBTJ4Y14G9LQwvS/6jbXje3WRuu/TqU4fDWuDglcBRgyeDvTA2Fu0XbMKC8d9OUlpG8K9IKbLysKLuGnGrtABA7PXGMwo745kXno7UPtgL2f3GNdOm6r0InsIh4BzAGhiCYWLvu16COuzi/607z4oVNRgTCYAZ0m5ejyT7CZZGDrRP51MqNTuMaYaagQ3ch8QrgsD1KFmw4v9Xnx/KeH7q7jZ8Xf+SVFGFvfSq4KmweY16QflGqg7eim2tSYjDJhPZOflZB5MnL2Z7WFlbTexdHpmISx3mtSFmPlH5cKWcKMpDyPpy8kHYgcvS6vySxGu4R10+3JJZBw6sVYkZxL7WkIA2KisRkqzIUVvymtpKM9na8h9VSAgKqtXYof7F8m6SNLqBd8+gHT5CwUdclcu2uz7wr7Rj0zSV2nw8tcVH31+V+OfWtPv9nj//M48LJ7utPLxi6ZGKEiwPCZUhwiqCk8yCpLHvPVU91CT3tIpt4D+zga8VCj185olK6iU9z1y0qvECSvYOsQv7LxiqOkoR+wfsqx9P2vbL1M7cXq/W0CUvovJWRTy86198qB9LJqa5PlYZ/ZX8CMNJAmKO/BIFwx6slHfX8VNfeMcwYP43pLWH1mNHO0Lg5wkouE6dLa/yNYDgYiHfTIJHXhD7i3IAzHuogxFGHF1zIwS5XMl1iTxGUjrQD4yKg1UuJCnqNpB8jqzMeWmzFgo5uGI/LEDH5xMzItvUXmbsUC1Y38+ufBL6F9K/KJdQ2l5+nmgRjvRgWqZ4c+p3YOoA2kAhWyaxc9m0Z2zA8+c4I8/d4J6IrbONx1Qmm/5tJ9zQ0vCMKTSTkdVHmb6ZQv7Ei6GHk6ivZ++R9s//7CiVmxLyrsJXEJMQPXe5ltxAbAoeh3Xb12d0HW0PdN7aJiN+8dJ5oMSXjiIYcBmZKj1O679OKcZZVj96VerJDjyM7IxgEcWEQW49BedGe2fiiDoYPd2ZgWkWGjD2CUc0OUx/PXOOiNho+gv9SBw0NTRoY5nDJ0Ej1JYOvNKeilg8EY9E4XgkXaahgGgVyJ4pWhKNxU0ePEqz6OQYDoN2a/0khkCUbFtIEx7Es/xLSqJr1LFTyf9t/beq8t1JD0Q/DV6nDnw5hEg4QlLOOJlD7z3Hr9+EMxb1VXdrZZG0uy09ixv5k0CCAQCEZ938dyhhcrao5ljoIix1RKJSXNObvSzG2Qq5CZwGE+h3zUL8bplspuqUHrvQXHaQkR3ItuBW1ZUobCE2U9E2YJlPAlGNy7ICq2ZlMjdKt7USm7RstxaJOhMatblmsPKnIw8zTr84Ki+xem0G8J9GmCSdxWLFDItJAkDQdzKpuaqsh+9m/ePXLrFSAuGEAItD+hs8LEwv1tWTNBbPnyiFd8EQvsKrQ+wHuYLvcIOcsSKZMoCv2ydp7K77lytlOdgZgWe9kcazH8/hak3KqlWa+5bv67PRgFRb4W2ZyncIjNg56HaSDku8DcfD6eor14MSdHG11tkAUv7Y87Ykdpe+6wyk9dj3hJ30AC8Vg5i5Pgx49KRNhfRKACM8FEqh+yG1UnInXtiqglDZZ3Xzrd343pf1CVCHCixvDsRJrrRj0s9zJBdrYxmuNf1BnwCUc82s4gtu1wCHoQ9dT+eTO6zlEOINR18Y2z8E3+SwpheY22f7g3sP76sJ+phKHWEG05G9fbokJvvE+LHFdsxk5VZLg5NfC/FIUQiQQAGBvKBgp1j9luLWvFA1KxcDSYtuNrHXgTqYc0htn2NPl9abXjJe1gexrtRIOgDOtBxGsQO31qrCkwTOqgy6akjb5HcQem/AmOzdIH0Fb+1enZ2zweo24E3x6m2W4ZoDLDwckeNzU2LIhSCj3VSTxkIVHxfJP+G3kCsXc7LANxUBTm/ByBPwJtgRKMDg4H9iNvFwD4Yh5qKYBuYKN578Xx0q+XzYjoJ4dg9P0fNYAJznlH6lXa1nMCbxHfxposxZNM54MSIa+Qcg7GHTVIhkWgFBIvKjItEiBTN32XcBXsMqorxhRPlxk1zveSUoHGr+dPIzuW1KErfBIWeRz23G7HX7PHx1lNbLYSkCzCQc9HG7CitU01+UyX7SplTtkOn4WuaQ6KesBgXmMr8YSI0PemsWytrrS0TDmA0RRlxb5BXrBvOyX3zvGARtSejtOJG3kBGrU+fOvxNdQgij3FvWnuPIv+phFDRi1VsHDbGWMyHEw8cJddKzoGcovItoneXNmsNucrhTCXEmURUXX4cPOzUheYg+J3MZg8UUEx10g/bCsabavVyqiluJhf3NXv8ICzegWKiQ9NRMNacG6KkT1V0xhsCzCvZQUQvN7RcZ/xMC2Bnq69qNdVd5xYFEbGW2Fq7k64a1U7hbKfyhTcAwhkdelraL/lWWudNcJ5jm59tCvb2QzfyyDB5IszDnapUXGl0xmNRJIvKfW+lBU/asB7AOsHBa7xYoPZVO74MlrD5r8X8Ws8BzgjgX8C3DkfLDgk7MYpuqQNPDhkeZS+3n9F8aaNKz6CgIJuTMfrSjW+aCiC8uIM3j0KRi3TwPD3vRCeOywG+mQFnFGFeQCo71mPTabMhHDLPXZPSpnoOio2B0ZTVrG8uKOoUNfLDjtZRn5OzL5hVCG+5Og6WTgbFgtJaS7jzVvL114RAiZM/Wmx91OHy3RcRMNRl01SMgIDeNKzYKfTwW47ksLUiRWTca5sVPceQKGYvGNp5EvaRfHglc/y5lZJ575hVfFH13qnHrnd23ChgseMTRhjFKIP7/RZ+WvtU8ZGdkz3m1hPrk0AOoJNL+Uo5MNIpdifmdLS02L3Uzoj5rvkq4NEH+4FigCnfdClM7B9lwp+aXRKdjB38b6ZSNLe+QO6OeVjUcFmHPX369VVIC8FN3z104nT3b6pc2SBzmlAL2cHxN9Vj8bvIeeg0NSR85ViBUsuENFVubMm0pGAb1K1Etc/gLjd9cN4eB1L3eQGlZ4bfW0gttGjNJHquwhImb/HWBbzzob+TW2OZ2mY6n3dz5P7Bi9FSOb9HHogXnqU9KsXriQjrgqwUTOXTU7fLXSlv3Cg/8jgDR6fxyafsLCWZt2IeYm8+h5A4sRA6SPCf4tIUTuFdZ+xnTIjyLLDMRxKKGIyYvnBiFGInejIim6P9lCdBSqq8nwD1NvB6vUS4SaZQE2c6/vAjpakkpcEtGpeEPrgJuMp6e3ZenogENuqkxblrTq6FD0YY+d17RZan3mt6PUBAH+981oejYv0qIEveUNDzIZnRpANa+YBIKsDf5/b5ONoo5ZNn5CDIpZX26LWG7PX1VBjGjzKtWpAR1y4xNTGRri+ErlLasveB2z83R4tK5bxaP5h6HuwUBjyAPHTLCuM7bFO++k2NZ9d1O4PT9t6PufTzT0Dk6yEZ8dvVU6d6DQoh5zc2Faq2UiAXjXVwCKZQUdPtGiO6GBdR6APjDTH5Evndk+frvgBKeqAcy8gx/vWE4bRUVljmtf5YRXF7V+v7sTnFrWkc/byIosD8VqMIpOSx/s1n3dQKnhU0yK6aQ/YLKs4yieCbO2Dcg5X2YUVmn7eiCHj1hwhspfJjLKGcdvBL7GeXL4typxjEjGQdvDlHHj7ND/r82AeT0ZsVLKpR2xnAqqQ33OI925ZNOT2xPjphExhCbM+VvZprfFgS2ldFivnMZGiB1G0L6UupsT1IBZQjEiCKZ2OsPZTGLbplw7cm9PvzwfrY66dOM2t9BXH+AaJvmPmrBBXEaA+sJU2vZD1TjKLndLwUfV/rkU2e5Zl/aMxcbnB1KMVc6O3pMs90AVQ7WjGNbLQuQDIhm65DB+vZ3fy70sN22tvYvKJTgbEZrZGbd8ZV8Q0LoXUjGhJ+mMV+XQbIIsBugeX4/MjoFpf7GJu2NSZ46+su7LnalmMVbrrANEAPp2sWxVwJsDKZRrUgZEwJR6+yNZHs9dwCQkOI02d8hOmE7MP8MEuuqQV5R0z58xhL/tK9p8IW/mc5rXIYIFmBlxh1Ydc7sjLh++ZJaUMRWfPmOG8e6tRv2kTuPDH1c2syaENULqnr/qq8gtCuD62pyhJDy+JTzwl1cyPduGljEZIPYFpnt5AKSsmdXTZe9h24yGNUsFa2L8dI55Z+pSl/fQ/JWzWzpbIgV0ifIFYoMiBj3F7W0NQIfDle5viDHZZ7ySmv4Uy7NYyd600XI60s1OEAP0Swig23e0gLAeUdiT1sf3a3jN8Epa6InzNZ3Ejphb0SX8j8xily2s63sJ24dpTIDZA7chPfk0aTLlTa+g1FvLgDgexynZlYvYB7gqMdUI/KO6794znTLfgbw9ycQ46Ll91ZvXDSh4FFDjMR7X4zXkwVIUqeVJis0uhKG2hdPd8MbVaR0ti7btFauVhGlTFaifhb46xoOt18hBbzsTx/qIMoikg7IPcjh8mwFLm8xomn9MLBEn+Rx4ZuBPy6pK/WZyDEswUsn4Y1/0fbTABFzWqKn1r0ohuYMFi8K2HfjbzhZX/8JKqSKI6+O4gGO6n5WpIGoFQItOs/xOBcddoZ/Fty+qErdnyST92q8dTOY75arK3QgeTMjNm6QRqSDk/Hh4boLThJ6+gnRDqp1spM4ZHLjEZzA8eL2xnRS0jHaUDxxLw4LkzA1qj8Cykh6pbOwDvEdDrIDsJR4TXZ59IgHUvsUxNg9yLcSve1Dn6UvHKpUJWCUQrlVSCIPzQwcsAEsi+bqRQmX70nXrnedYD7oNArL/rlOo2E8vqMkgf7AwWkMt7NJHHmHtjqxz0eUWTgzemkUVNdXZvJuI/cZ5MmdBVasGWHamQQyFE5tkza7puNJ3LQlEnbs45IU/8pgb0O0AVP84/+mPumqh7qEk7oPAAqEGURGrSvLR6RNykmN3yF0WQEk9qHMnuvho1nCFbObobjDvrdKVAWOicEhBQYxvjq46W6EPZv9r0ehQZjhoqbPVRsYbqh1lfei8r3BEkj3NuD+PqsENel63qzDuIFW5mwLwP6+kZvbhFElrTxRjjYnw5DhnVqf3cCENKglsDr8BQdc6tkhS+Lm1hetGJ8HWbY5bIpzAk98FxquTbKrDag4UeffGZQu0OhgTtTDzYN2G7CPL4hMMwyqkZb57ynIAgF4uE9MES1Rp3eIhi+hYWm/LzcdBPiACGIdPyqBIFgcu0Ie4sxLKyy5EO3vWUOD8nz5iGn8JKT8AXKOo/016V1jEq5DHj5iYmGs+OCnqjRyR7a+AKQvP2gkzWEWCWMwM45auuFCeY4jQwaOp4bs9723XH16yJLWw3lwc6m26OellsNVaxYlHI/QRPcn7zMsJBZHu0a14kOesAGxrDxazTe+ovMYPimIp5ZeywwL56zBDbKqjrE0OMSsma647zr49jxzU5YbEjs1zLpvO+VsaaQlXw91QZYg1i1H4X1c6EsYVQ9PmY7sNICFIw013kM4rS4qKGqt7z+jnvE5+p2VBcqRQ5Jcs5IVaFnY2TtiGuQ/i2tsSh4khuHbTmkVoh6C6xQKAlsSuIrLxuoLx9OMbonZmu3bnz6cCuIb9JIq4de30hZPJ3ImNQVmJxf67CFxa3jzl0dVk6guFypsAPDD0MwtWoW1LOb4Hq9OqZ1DO+JHtKlGun+SmhWS17K6SHe7vl0k6tKZTZBVXARo800Oah1Lzde0lJdSmwR4jZaBIxtIHHxTZlNjYcyyVAXtXTh/qS+NUeqdPaQTz8PrPLJl4f/xHlseobUfsRK+H5rD4Ke9A2SclSWKtoZ07jC6wWxktdy6qwqj+trJYWdEINknR6C/sr6c9jDKSKdgPPcRCq/eGhjm5G9RhnRJaSAWK/qHTfshvfjM6s+zRcG99QGjgvQRqZbO7EUxronnI5S9QQW0MQu41vucxcCyxMB9EgnBY33OHRFkx/GWtlDXu5cX02BQDYaOqcC2HSVlvP5IwUKMaPzyFeUqZBEdML18fiYOWcAjUhjl3Oh1wWr3reAnSIBnmeDDckYIabfOshL5n7czSU3EPV59zlK9eOAWvMCZOX0wiqMIuebFVpOhQ/8DCDAhBjE+SVB7xp5JZI3SlJ0MA/Pkof5SBRfcBmK2+dBsj9uqJu9k9vne02694iuZHWmhLzOipJye5fv9hO5XFxtbhyYIFaokRZxyTfuFpr69nwlE27Zh+MTY6PBRmM7sp8gZbv8MHeXknEUVIo/9CyMHmuHekQ0TG5IrylCDA+0Nz/t05V54g27aUymgJWQ2IfWo/P1OC2rmU+PUwrRveJbXB07Zwi/YescoB6esH+qTk1RDtw2kSux0oXqHTsvMK+fIA6cAg6vBhLmr2vNKLFCbnHAce93HTBift96vLAIAVl9ANclKcViH2HkR2SgzJFiG3szfqibeYtN+0bGMxaOVcsB7BJ6H92yMBI+U/Ub2UdiLuU/ygAZDgjKtzGYC+sjGwgCja/ECj+o98lvYowMzOLTWJRZt36XbhGTlfbmqsuSVrE5gcRenl812U+sCzcuajOYB4q/nrcIkWQRAiumDSIW2YJb+068HulEuF7cPhYDf+vKsl8LclDffP45frCJNcsMcLgsawKK4bAsTM7CstbhxrEfEnpiWKUT+ytxgGughej8G3kfWIzCst4kDL2rzZ2CD1MEjDDS8wQxzL5OQ3rvQBt/JL7lkkf3o7mwHv2UxKMidh9xHtW+UotNv0/qFb2Jb/lsdlYjHJegBxITqCvW4S3HUWU5PL8RojcZRlTJv3xg2ATd2b1RFJd5kynfqEnfd3XpyZ+wRX3SU7PWNnvPY6s4ubc+3reChAB8AJlggumtpay27XmL0IDloxFNnZHirSNCcnHQFV2M0I9HCcwtw0+7Lc2p45o/T2HEifoTw7d60Hpr5+X243mmJuFb33ToRjeaxiRC1+f5dwq8kPiB3ZoheD25wz5fRfHYNHXayTe3jrwsTX17K9nRCmciSme/dMehTO0zIVO0zW9ycSWGrL6t1AGyI8E34zON8CHVym0wHOByGUC8vfT9Ri3Ajhn+vCubaGs7K5hcheZU70f2VPemBgr9u/H4tlpipy7nEQ2QCnkQ1YUOIk/77xalPjb5sZNI1G0bErTUHDQeFhbSeIVsm8q9S4WouE0gUsZ1gw0wYnxdP9RbtC4fjDJCDazKKvpcEtLiJBg1/FGFoPaxxac64Ixnr5Y2DQn3jV9dyARN3Q2DX7IPNo8wbtAmlJWKr2f6khL3gs5PTB4+kP1eZU8dJmwL6jLTukvdA4hsubiaVoIbEq+k7LHWJLCwypdieBK3bw4q0MAINApCfIso2dt+XZnWuKO71RGyjuSynnPuP2jDuYFomZeROnHKjWjXfasT1XJMzu+5ks2QR+cxikDSGbqh123KsLLpdyMoXmRL8ZcD7Zswrj6kDuuyzeCfcKcOJXcDah27nRAUbDoxhRvaJdttdrcyi0PItx7pVqb5avCC7xz7raqheoDIiWsQGdiHjg5gGLVwTM+xEbk8kBqsI3+9orSMUdNXC3/Bd6MoKa00UPryZLzQfOO11OwnuSUtGlALZp+OCLmoIpZnadqAODaFt6ZcVlfKxqz6LfD4rN9W177jX5A4NWETPSk0onjz1rMigctxeB0vVW2BFBSXS1h1kBc/MF8xWvKe3aNGJOvpWfgecBGoM6cJOBwD0xcx6rG77uS8thYQOb2w6EvPm+Hksvz1Vjp3c9uCUcUotaYgR13fwCyu/0xruULIi71VgnvR37hXdBDQXATTnKB3Ou0C+eyIJjFEYHf+xrNwKcm9QnlXnU9DBDj8jf6F2/C98EzLy1E2aq4Kt8t7qo8GWa5RNg4XmpETIftZh86CkigkIy9g4zoeRDM2z4/HFzPPOgo7G8c22lFyAY4jfdXp9zHLJXhftqsX7yq+sjgfNXDnb7faazeXZHvS+xx++Jh5CjO+Rq0MYFWgxrPazG7g3TSNbmxAq77T4CFRhCEpNx7bNA7uT2PCR/q6NbFPS8HA/KdwtY+K8zs9DJJ3bzmwS1P5pqqv3gEent+8/QKM2tE1JjjwStl0N9KrirZeaMAmuThQ6N08zgggXKt1FphsMmRcA0Awe35ALWjqULqLHQ/twvSNIlIW1pHkVBwvTXqSrlNmjg0TiyxFeGmdvz5sCL9mV4CxfZVZvX3XauwqE4TfCu7Nj6eSVLTsk9OKJ7pMQoApOyK4Waf2lvLXNt5e5w7TWSNOUXAmXewe2q11RAAOyAaDhZ5E6O0DeRGOQYAhFddAa4HudSJhAdyj13rOMi7Zslt+39IsGef1E0reQ2ndCETH3AIcdPOWI8O9cPKueflqfgDDIB9W6nMJi4UocARQ1BIZXcUYgYdzYECcig0781JU7rYUZSQm1wQo/+7EX/L7GcekOTgBHV0Vh3VQjmmkOndEoRkqyCm+pcibNTQa+WFzla09WnS36luLcYiy61vzOxFRtKfQ9KV1HCM1bgbriI50fOTU8q1RSPYtBUDu8iZ5ENBaTD0sFliKFxcQXEYZi8d09MTBEFHv1uKPAeU+JROG3v54jz5NFA5D8dvoKeVZrBYqJNH6emJXEGHMThARYZFTNgwFh9avUpsUV+emFFZeNmSXU0KcUyJulQrByUy5FR4I2kbUwKbx3QANrXSgaWnN2EWSuIMJxQOYa5FiWkfgzclkvr1p6Xy9/cfX/ju90861j1zaE0+c+ypNepG3qJRt15XIxUoPQVyVDzuZmHXbz1ZO6/Evvyo7wAQi6jr3QN7d8OIt/3jc0tByYP4NpsGPU3z5ZO9V9XkZT2uYpjFy6XRLbbsexlJxe6GAWLOdAIvUZIwTGXiLKvQtt323HAc9/MTcJiaSVCviv3ITxOkFMZz4iy6Ma4PeemjDUKT8I+TcUBXoWC0G8PsDb9jb8EYPJBVsBzI6gcUjmuFyOAZ8jw/YtRQdnhDrugXj+CA9fBvbb6gU6yCjp4mUTxC/NmYztlqunE9l949bOTUeMAZHPAwLY7DMUor+tseVW0WvQLzF2OlsFFjiFF9Oy2+U59qPo3Ns/JT0kbJGoNtXe9OWuannvgscTTZPR25dz/vue7QNSiZaX7/QXEw6lBmJCCGPAKWcV6FZ2pKbp9hTE5Jzc3AsL9jLsjEasKJwTqBJjn5lotU78HZ1BLbaQ/KGtLxlXWdzsY2siymDVjdovMW2NauyIZoKcE01DP0raf/kOvVZd4C9mq7v5o7FE20RyFR8zvpIiNnjHJQX2PmT1GeGeHqABEHv1fajfSTa+VO8mI08Bfij51cXjCzC8GxIpEJb71GdTCDDcPaPS0nsfg0haUMzCDZUuhgHSgTStl0CUSois4gskA812aLQDfYif2TmzUDPTxS9SK0ugRTj+I7PPjkwsfQbgE5I1WXyMteA8ugrhALxGZ59/k1CdC5ni3lEgV/hRU+JE4ZlfeVzkx3tkbL6EW+3Vj80hg99NzqHjBOi/P5z0EoqY1Kh2SaU8NbVF27jnJ/s5p9ZIVDDISOgnCAsar2JLMKAGSHV5B83UHsmRA+XW1kCVoDPNmrhDLnG7fXUZvu5nkly4clJD4jaY2MLUU2Fgloiw6W6WwIVekxAw4VccEXytcNIL3ozhaXZYNFE1Vv6Ij3vrZuwILu+bZs1LeylhXwir+KIY1ZZOZGGM+uw1DpglzuMxdHE3dGf42itYSQER3vCIzcgt6wvlLJP4U02oa9ttIZIXCcceu5nkhoaiTXrjboiWeC6VWtsT3tQhXi+BoXFvBE3cljQMEA78MI5g4ZWlv9dCL/ERoWzXNFR6BLD4egroHV7Mhn757wQ2QMQ+40Q6GE429KmiKF482HiM33sNhOGed3F6uGuq77o9bbsO++0D+X94IsYBLnkaU8jdgHl1EU/PwcAYdYlvIJ8z8s+oig3UlCYpThSF4NMlvu8ZnmjZLmXbQU2ukkJ9JJIySdbTQAdbVigG4bh0w6P5yO9X0N+N6WgIWtw2J5L1IyFN3AGc0fYELfOZ1OUAXKVTWjRWBon0mlUGgTR0hmur4nmq+IiQeXhmE4bKpY5EMvb6IBksxCSA0nvAs5CySY7Im0dpCZeQmWTdi4Vi+tKL9yGfK3L0JOieEDMWqJXqYOs+2gIlKnEVqXcRVwTjgkYQHjz5j9mMApEhL0l0R4Tm9m9YfZemTMie62yYnWScAQQIWsiwiworUhXzSOybWzOpHp+abfeX4oHjcLHo7eJ3paj7w5k+hY/M7ZvFEKnAebQd1SadjBIvXXohtCuWb2lvXCf4MEZqoymq29EM4mc45SUAwYgYrZovsiJ8rDxwSkyXGhOAUGj7RV9jleyutzQjgpg3hdEbyi1vTEIcZ2G5uey6ekkEOO5Qvl+vbWqsCufxmHyFHomR0NHkW3hgIDq3wlVUA9YTdd0Vj6qp4QNK+tDJkaj7ebe4O2t36v4p8jmrJpPs3PumXu2+CVxx8bt01zKQCF0HSUeCW+sQnc81JGXgBT8dKZakDIU6W7w3N39JveiiEGs9sm5J06aqolzT29acMoC9RT5CihrEjrCJI7Ezf7dxGwAMUY/mVS8plgkoaL5lN9wr5HQ2g0gI9/za4Ve7Tp4oEUE9tv72aKXNWKioepjF6qk95OxH1+lfqzGQaYw8Aepx2VHbjiFetWTeSSwjGQ89cBWFEKQfcF4qcwgPC+wCyDYCYNld2Tm9vrwaY/Zb3AUE124YabbkTeEPbafOlD8MV/i/cIr960D8HWGogrdniZbCsEVew1NJ1lGfdOMIX14EeS0qkEUk1JGse1gFOy36jpvZ99t5FEiGK0FMBB+uy5goauG+gMzrar1mtSbzvZqCJsIfVDqq1I2qslIm88N+8gQBkRqyuYTDGSgWz3rcNyL3tnrlmGjC1uvSFLQPODhGcT0xcrCI3qBrKRv5PFSYMZErLc4RxBWI0XREtJjDPCE5N8rw7BU0TkSfD4WT5PXhYz3YDaL37aWBPo9Z7C9PspaBU6iN4lUUB4OjhOkTb9KhOuo4cGZ8kunNFEyMeN5qoWrvNCPeBwI3hXweau5yhzf7Uc734qyacoIwsIM5NGWoRJ891EEWtTBKgREeVjgPsa5gTYy6RSJYZScfX3MrId9EH3y+ZXLW44HEGyExO0Sq5av8oEhm2v/ZMYiUDGPosBxhjsBAkSxIFoMCK3XSU0vnzhbEb75eBXdCjXUqy/WfI3HNtwz/cltgdsgSrq8wBa2U0W8E7nnxe+jiVrxXjifQPc2hlHQHlDP1U7xNCUGlnNvn9JvZJe2LsQcw6u/U0/mtHoXSZ9KcDC1mfvhrQoH+oBqR+wJRWoyTO4/JstSJFPJY7hcyqMzicm9vkUWRGYXaJeoQFilZRJqR+Hv7xprM3xrZUt4ZIpy88SeFnpsIIzv09MJTrsACOoiiAzLSkxkckYqjL16Wv3sgX1q+QSNUWXFRHZPrPjzTVEw0BeoAQYq+d0qD4Dd5hkS/Cux5UoNJpBsoylP6aAo3yR0u/vxABGFCSISTZBnZH+kdzO+pJ9NyLYh7m8ufNOoG3VIwh3p8SDkbveeNwWKg1vTBwYbUDcEVFkAN/gAU1Lxm8csfHdyOXKZT61Rs4R4+g7ym5K1NbU6NNzNYCmRDH4wHyI655uX9TQfjPkqAvaDRcatmG6vkTiLU5NGHFpp5Aa6+XFe53mCec6QgU8TtxGN8XitcnxeeocBbZrdY5EyYy3/KeGSwesGP4raSDpgWr6pQ4CK/oC9FcnI5M0QOkkC9YRy/6VLIMqp9buSjrhHv/MNnvi+Q4hp/C0pUbxq2njek5miDSrIA4BK5dUvVYZ+Q9oiNHjpPXb39gRRfNxsPZnw2ck3Y7oP16Zz6kZO/M9OXVFlFmFdNwCP8xE5yMfdvvbCq/XgC+y2C8aKA4MgEDpEuNcsMEogqSI0DvLqDJRMkGb3MvKoAvEnLvF4HCAG2n5kL1nrUYlyCeF9Ap73vJtTMceBPnSPhhv6xuFhgdeArkyQrfQv392Y+c/SRwj1rR8yjUOVPxjpES/TyKvaGpKqUcyKzCffms/+vGyg/gSQwV/N62MvSbUYzNhAEmC4MynYEXyLR/QNzH0839D/PGf94o7GobHUjrMT4b5FOLRAGWnv62D/QzGaVw7Lgw6CSkAFjfudIteGY5/Dv5ntqvqtX8O2ccfutZA8wZ6qIC/r+lp2VnKb2txkcmGfbYEGyDTFUEuhIPp7eboh1Nx99oqCrL2ze3vGA5zzR9pfMyl4MlEAtc8+efbr+1vJobp0+7cqDCdZK41zQ4EHrK4d3xNqxZ/ml3rR5l5sLrEpQ73TlXbVSDjBtlJ9w9wl65YIyOaS3s89fNaBJZNfT9+TQBkseTpgbVrvQgb7pjnfyg2ots4J34MM88ZnhQtEYZyl0umqUMPhxJt+GYK3VM8ZQG5hCHON1NVDPl4eusApEnofIYfE2c0wSFC+xSLF75yzUQXkMcgranStwOTpV40FVuG1RlxTRXx9C5c1IHJnlu57wCHxVAhV3jSQOeW423bPj/WuNFSqkdfnc4nYrry+/ilMZ8iYAOsm+aa55VnlhNUumO/RO0BGXwfsrBlEP60TGYPrI+zNUrjR6/nR+GZrwfrzM+iqBq5avLCzB9QxmK8b+VGVEEjqN/uPtHUh81PZRPnCiFLYxpfXiRagoHusPb6GgdWXUbA9cIxqV9a8P8+39EMbTiwKb0V4obMQAlwuj9WPua3f4H6tPFN2e3/pWmPey6sw9VIo1BgOPiJMK7GuA6TPoA4Iq5g3dxzRAE2IzA6/5V3c453BSA59MWsbHFDUJXdf36xZDW9UZEWRoUmf40t4ldgM20Os3E3m8ogeoBCPt2UoTRmFwwuzI75/j8xnmEZWvT2nwRv2yy10nY4F4hi06Kdmj4tUkMs496hMr+kl4zjOnzFk5PzueUVxBklgzEeOv8xbMC0m7LNY1C1p3K2FG9tHiFOwdCvxlZwv84Hht1hAdbgqfGUZbS8BdeLMdZtGrJRavFBqQsxQ+GY8t1QKYv6B2MUTokn8xF1xHwQJNPShq5cjKyXL303Gef7sX+NKjoHN4ivnA+sEWcRsYTkCwG1Aba9X+tzTm4ZeSHgCOZume+2Bra/8llVQxAXvqJKjGjVnB+GNPmD0VYf7OFvF83hRkP58cle9PkG7N9xLj0uPrjiqKLAXLTXuSe3d2Nnj+qebx2ruv09lTZvzaFC3agO1iI0On8aPtX1IwJH0ZykBg4GUfdvyZvUaQ0cQxQNkeLNqK2ceg52ZVFC/V9SinrSq5Or/9eJg/6hQ3f/90mp//xwzciXD/JMUHPy7RQgtMH8My0nfYYICS/8c5/6JKkj+d6sq+c82pD+f+8/DCTfVX1kLkGk3KG2JjNrvyrCy5eD3VTnP839Bn9+fW+K89QloCKe0W8AZ5BbUq2BRsav6f1pzoulheSDm/4B/2m3ptKTHT7vvKZT7F/TRHkLat+ky3UIe9OsqDbLSwS3nz/H/gAnkf/46tZfJUvycpsifU0Va5sXy53Ph/HOc/945UH1+HgkE+eORNs1vI/h+R6Ay+QcvgSO/3iJs1vSn3c+JeTmbXyfmIhzA17IN8/svG85DGoOBZeWR3p2zvy48k3AJ/wVlfg6B0eBWlMCw6G8Rl3tx33UgW/m9vGDyzfsv84293x/cza8cnjVdno0DwYpvdoKV4olN8hlPr/pzapWEqHUwmjX+AL9hg5++GyRxQwyFHyBF2D0Hea2BOnbZQES6/uW3pPE//lAs/32S4/BHHfAHVYqllL1KsBf4vFrgKdXntNrhYdcy+B39Tr51Lvj0x5ZPupAfY71uCftJaoZNGn/3IT8/jy8MNvnDY1RWypkHZ3JPzgQweDBMCMD7lmMBgP/pw5ofltnN77z1DJCQv6df7V83/Of/7AH4XxDMn0NgT9W/3xD683PKFbTv30RDf048ugMDf5/Div1q8tD/qklv7X/ThAeiDMtQA4TGPyeVQgRzuD/up7Nlb+F/uZKJ5ffKDWq34CXcLTs72X8uTrUXTSW+MLfkdd+3WgXizb/eh7cqc2v3v7wga2PUC13+8MogUMB40KT5l1O8Ue1bihhlQXR0/vtpiazEMXSRx4P4bTbAZDEKybJJOv/lzPQ2Utr/SC+QC8fqcUDEv/UiKI1QwZkjMk/tIiRlZWP5r4YCHFWMJKxFNEj/9pXD+WDf3celOpjbW4XsgffW+u2yyB9VpCg4GOFmCcUr/W0ynowQ//W76L5pZOE0czxM/GXS7iUXgJhZ9E+WBDFZ1J9n6j5jM8GfpxjoRX+YdecDBRv2+/FDrp5BoFIIoDrsJTFPzhc62/jV2sVin2LO++XcT0+j0M9pZXsCIAAzOc5OShK/huAXRwJOuqtmJPSvB0z7D1Ci2a91HakfoPxjd99F+dt7guzXMLH1KxOwS/Nr+b4py//2x3S2lQkJGLF73RiPney8SlZ/XVSulZBOK25j4Gsm+mtdMMgQy5AMMygiWW53ug79/Bq2NKrfaoCfYgFGFq9OiUfXR5dAplt2Xdv4JD/jp0w8dCym8df8XYlhHAGCEdrsvz5lFhf4dzcNm7hGQjM2LkGpbUP7ev+7w//v/zF+KHbAgDqlO/c3FPum1jyTA7qO5EBwkcy/08m/8SFusDFA9dynd0O6HgNSBdB9Z54dsF9o2To4zONI8eOe5qdTD66OGjlfmdgJwMlLeLv/wtXj6sdECcQfGom44msYm+8zuGbxwnCw117BfjV0JTsspw7cKEflcvTKklL5CI7FqI1s91uxnXnpZQ9HivClE3xnOeEXy28CYE/JmjHyhLbgTFjDSsvwlUUdORjlzbMMFVyA5Zx5XqBGKHD/3N9Plfmv/IAlumWImv21Rn99/ZYkVZ4BLpqQAULl8b//iCeYURqgrB7cLxckYJXx+w3ZBgaX248D5Wxfn0QOkGZWxkzceqF8vQZwe1e43refR8q7/hjg38U69q9LLx2+PISdWmBJSdNhaKLwO25KWedwjfsVHIWDKzjvKhNQB9xtqJ43dWP0BTd7H4kAQcNfj/C/D2Of09AfQZqH/byCW2LUdx1GaEUUrLGowTkjbi54p2i6O0wBzn/S+23+QsX/Kz7ij0QEtjMAYv2TMVWJNXv+xpznTwvucX9nWMu1OUtyNJ591yOXq651zebH5E7uVGH+iHuuNDk+z0t1r98mrI3s4/N5/o5597rbFPPl7dVJy58P8xr3doJyDJXgtBYFXPj/wEE7zC5uvhHikTTe3LfhHPWMsH/SZU6wLnBHgiLp7flRDa/VKXse4SQFthq0mAVxssVURtxw/QanRjvyfMJDtXGksvh+ly1Ywrjx7KfxiH9WCnfPI8HPqSnJq/FfPNopq/fa9P/3mmgfS6ynyLRr0SwI6EDL+6Wn6doQSs/1OWsjF8aBY9MoICld1D/OQP+cRLyFtV9vD67/miGza77p+pHpsuZk4imD7+3ybLBn9wgh3c/1WB7KC39Vo+k1st3QgdGl9awGxntlNb9jj9UaKUzXzj8faBdWjXnz3GUDFyDMJtCTQPKR5UPxtYs6B/fxxsSY7RUnxV6FZkpc9/bgKVK4QXdq0lo+9dWxN7Imcl8vkkycdTZ7v68g1GYx8CObBvL6fQ0J2/hmqfQiyf+2inKHvjKouuyhLwYQ2SzqzjTyPkbnPfBFijP/5wN5WS5FxetAibzmeLgD0TBclDRIDCsOXzfKuGqb4LrHgp1//Gp9pofLjLHTpylqfLfwfa8NC1sViEdjjyimlwpXCEd0BZ0UUF9s2/yxhCBcXYWH2RYjbIQQuVooN4y4AwRlKYtWfsiMf8tqsuOj6CjGzlIg/eGgmkThx0pMqktxOGZ0TMaAtb++0WsPHdK+HZImejhybdL2fX4JYbBN4gfwi5JhIWd0CtO59clnc/c/z/rKDiHXeFHDTpC9CiDYjU/LZIcz7G9ODK/nWTJCA2wLSn8F4Sc000VhnvgUOSYEdfSjp5WL5+/Hd+HyCygHsFUtHG3znKm0ci4gQJ5vMNL9beWkDJkSuqnwbvDc8eVTm3jfnJcxSQFn32cR4lFqhA/jKsDtGGR79K/97b1LYenQ4RwIEz1If45OLV4e9mzVv3p4Y3qyxXoP/klb7i1PWshUl1i1+DJ/iOH6fpU6dAVO0ICdwFjZEqjMiLplzmT/XYT9gX6oi3zGBWtiag8w+m5F3h0l9J86sj+Qw3FjrElm8zCbW54QzGe0/Lawz+/CRhO1lEZNK/Pz5RJ77FDySH90JeXgdBZ54xFu3rPsCk3zCi71OCmhAyI3LZz8K1CLPBnCoYx59nkjs+zJCRsktksoAZ4rwMtwNlooK6RO3sjPNUmXYE+vfK/XSrGTJfsBmnJQyb+8Xn4+Dmq37GcQBlEkn41iaivyQ4YMxP8dE+//gmRiyhinHowFmY9bJgveds1MNLsQ0E1ueIXTBtazXPpNw+tENOSU+PAL9YhgabBVfo5cMBhdVs4GbryXHE5ikcXbacoxQ/3TAYQypTo9II3hsjqge0IyCyXGA95H5R0httUuFjiLu2c0vkI8TswCvpW0dI6UZfLqBIVYuKKuSF+zXTRDGkNpE3Ejb5HI7YdwIgnQ2HM9h8ljI4p5RJbQulUg0VN+8NWcbKkLQaKW5A61vTwzToFOGSm2kg+DL+Y9FrVM+ggrvNYCGPlPdMuXUnDKcpMxxO0kh3+qfz0Pn5e/geKDo3CCncjFhGT/HQ2IbwOlUE+qzRpWdG6MUj9T40tHubpxJSj4f4cTzhGoyRC6uDtKP7FNz8hfgpNZhqWZ9ZekwAzZj0Cgby0xTCz4BbbhvhHAczT9Jd58U7PuaVo9SBSkpwI512vBVumBPU2ruSGx09s+t4DeijXvOs85eXjky8O9qRRj2WVZDEdK1O0kdbqNmanXKG+X4x4DSfSzhtMMq3AwWwsu98lu1WhhZIaiG6QkdRBE4A7vDrIHU+tw0/Dv9eV7O6CObE7xaNzFEh5iNvP2uXblHCkX+IiSkR8EvHWDkrTSrpN1jxYDY/Ks+jHOCLCZkMaiDUegCGhc3g84fURO2j6KFROjmHWyOtt7eVyqEedSsw97+8Z4hFozsw8UkAalXtmXv9t2rSCet9n9Yq76QAehNLKqpReLkQePdHYlGESLr7D7WaYDi/nXG+lKE0TDL0pYebBQr30Ayiexj4l+l/08mlfktAJFvJFFSGG3P31ayw9BfNST7N1TNbpycwZEr1qQFuRskgwwCP47UA8Vg6I+mpuHN77XDtSalnD7gdxsWVw9WrzTq09ydyjG6npHofoWRaVPXZrmGYN82sZt1dMTWCJ0/LlnbWJuQyX7jn9/5PCnmWYTkwIPouyuQzg4dJejw17k2/N3V0KVFzlnZxgkgedMz1D5eL50PRquscyH7VZnh8LXTmAuC2QeqZxky8jiVvtmM5hagTUhAeQIYy7fu3xLUtI1PGKM/twacL0ifcmgChcIQaMQj1cw0zRrUmt49pG6jG4/s9BALxnGZZprZTLGFttLrSWZkR+2Pb/xl1WcDLFE0aDwTeI1qgrCmcn+6u1Kj8aw7IsD8RDBXQqJKBreC5enO3tzEeYP/+aCcIc0yBBUb76w3sRj7ED4q62B/3s4daLHG2fShN5FFQ7sNynPyVPpKiW9GNWb+Kh6cI+9gV+WLD2IOgQebTQcqU70OOSTvxKzxonvHgm8TFMolfduND7OUTRseF4ZkJxTwwFw3c7dBYJzvJzTNId8lC0qh3JUlQyh0nPh2pM2hwrHhvhr8PobqVaHqGWq78+sECbgheYRkMxD1G6Uepi31ZcTRCFGep9F2XaZ+sN642ruO8akeSeHusCQWHa5jSsSuWhW4HxMwX8Tufz8TGfkia/D3wz45nKIo18tD0AQG213W6W61B6B5BALMSTwJbfluy1FbjgHH3ZDxY+4V1Tr6VxkHjQX3iVz+kt5WWkEbP4MF5w3ToltfUjQsUFxsRnvZ0Ocivtxa8VxBfWdUr6dlMgHwppj1epb7iaI1vOZdnygXnaQu8xL83i1O9yqDL8ACdG2yuN+UAdl9Ee+YlJJx2ZZ1t4pDsapK8kMV+HMJ/0pIkppvZ1C+Yo3t+jq1MrbGQWHC57u7hmIGoeE9UnqpVEgq+Tz+9npJ8pCDyIq3eyDh1IdrTSZYWC10n1/Y2L1wYBSwycPC3PLcp4otPcoz9J+9xNbD/VXmZkbPeQD2yn1QwlASiRbGebbcBuJUnyxWlW+OGYlOlk9zHKl3D+z9OKCTX3c2h80SE49SGdeSWF1eFKV9S0XlOogCU7btd7wvEmQE5x8JVmNNKz6Ce0C14v93mlXuhYft5HjxwGCPg/nrXxKqz+794m2ae12r8GtvM/wgAgNHpV1hvn5fD4dd55xPjpkefSZwjYNe87rxGIlTfWMAArStOiU6RahGxCly/AciLqkTOZV3Fq+qT6YPWeZ3fmLNc5kHsyhvVlWsDhWciSbu0yeOz8+f0oVa+YSl//uU3kwJoj2ZwxCYavrer5dO/uQloMiOuRqdLXw/8ljzjNEd4Co8PXNodqph40BTnmAMyBrg0tZmRIzoC5pPIJkpdYqKT4ENSm7QXSO84K7nkeN0yt13WZTxhNt/u6FHarQN8cf0om8MxZFjMp6qwOgl45uMq7V7LCnxDd/q2OIsU/mD8PluMQEuZRH0d1EQsXjxglizZWlKwbaZp88cZ0jAWxF8b0iINIQs9eOSlaVUO1bS2SYS/Zv0JiKYQIMUqNSjzKMEFv0j265qYe7eFTjJAjjl/DpmeArCgxB/AbCB1lF78mX8apXLordvYrKoR8t0VD5T5y/b20ULW4aQ098zWmM8rmyFN6WWEKwqRBQ3jad/SEGFKjg2CUGRsYBDIYI8gvZW64F0TOIcVKrYFrbZQyxy9Pu4fiwQ03BQ11z5/XdkYQZK/4DtuB9HGok3OqfuoZVshohCa0KyM0asfFFjGkwV6aA09mLkXEmGBv5gzvDS5TyanzwxUqh4Il8+d24N/spEdOiQuLJ1Xnssd77/LY8sIXunfka4nlnWBBS+Pv0fsfOmPvRasPLfLvmz4A8fiX0kZXwfAT8n0bG0diSsXyT4ztDJIGlctZ9KLuMiCVq1+jr6IGiVu72+koSQkH99BZu0H76tw9m9SHrH8P+pAUVPheVYugnBolhgLGEeLTnqQlmbPL+DXMTAEb8BkbqBsZ7ycftBsZ/14VKU0zTB3SC6H6MggcIeQG+4F3iWBA09Bfr7Y3b0pODJY6z5BunzUOt2UOytYfKPxjJ+N2au3OssQPRk2F6fxGd/OWd2H/8F4hqfarJ6Ep3u5Bc2YcK7S0hjVwjW4AuGNSlJJIK4gdGJlVpxJB/V8tjsJDC6UlzdJLeVXTo715wSGH5PNgaiF/PFN/mVJMOZEH3dITWRdmvN0OqUv0c3SmI+fuXezwhyaAEiX2x9TcJJvRvWeRDOkZOW7gukyyJYf4I4K46jI+RiQ/al4/nDUR+hbqpkag1UPMNkFeS4n2slje4L66gwNoH3kK1MSjGSXWB7NLNaOphuU5fXZEH3sC94yy2gTn7VSmnft4/+y7JtEwuzzcen+Tn1fpeYdmCWXNBJjZYN4tbVXw32doZwI4dsrqRAwRMv9qPv5wCgaf259XgWt5/LrFrh7d31IUbvMpxbg5PTPFDkr8FgyXU2Yt4k2f+VqR2R7VbXdbRps4zWRnV9QF+n3lasEAEhHUMi9aEAJ4pjs5pKgbiB0T3KZRPjYfy8Yxgoy59S6JlZboDX+G3TkxGsy+FMiT2po3eG5ueG1B73dYxn/BjmJfvL8MQwG7zCI7wUnl3eJXt9Q4qQNu1m6Bu2fPJPd3WHaOV//f+liJz0BaMPUdD+MIVuu0sLFaANqMYQ1RHZ81xfPLWDUP9ccPQ54Yh9QYu4Qau6gauf9eFgheM06DH745s9f/ZEBcYQv8mxoX4mwgXGEb+NsTl95P/9TEu2L8d45J2CTNN/X4fxU04z2V8z1KxtPeDnvD9Neu75V1eoDUC3cfpUS7+fQD9T/zX0ee3lmXT3EJLP307RpMwpTLQ2bxMfZ3+4QoRU2mUgbu7+y2/nf128Pn2TOLf28Jp+duhfU/zZfPb+NIkT9+/XqeflqLP+y5suL+c/d9c8rlfpzj9B+2QX5N6jyNP/1GHGPrTEIzwH4LQHwAEh/4WPn47N6VNuJTbH/v6+zDz6wlGX94v/DuEYn8FoCj05x5+XvzXTX8BvL/ph4TRP/VzD/DPHf1MzN909IXg39/6PwHU+H9PoEb+S4G6Wm/+8atFOMX/B8Ac+v/B/D8C5vfihucfmg2gwfyvjxeH4T/zjV8D/tfGRZHQf649ghF/hZY/Q/6vRVLyP4ik/wqC/APcTcK5AMGYP4jzG/LBf0Y+/G9ROcPBv7+Lyt/P/zZK/ZuYApP/VJjyez+/QwZE/MdwBUX/Cobhvx7Nf5gn3IdT3y9/bD6FQ6H2SQpa/C8=7LxXl6NI1jb6a2atcy7mXXhziRVGwgkjuPkWViC8B/36Qyizqqsqs6d7prt6Zr7zqkpKCIIg2PbZOzb8DeXq7TSEXX5pk7T6GwIl299Q/m8IQtPI8Qsa9rcGGCPIt5b7UCTvbb80XItn+t4IvbfORZKO33Wc2raaiu77xrhtmjSevmsLh6Fdv++WtdX3V+3Ce/qh4RqH1cdWr0im/L0VhqBfDkhpcc/fL03h7wfq8Evn94YxD5N2/aYJFf6GckPbTm9b9calFSDeF7oIQv74e2GeBTGI6QZ91g6y//1tMPGfOeXrLQxpM/25Q79zdwmr+Z1e7/c67V8IeNx2BzbjcDhGYte8mNJrF8agbT1k5mjLp7o69uBjM2ub6V0IDiKh7Pv46TCl2w8M+Y27gb+S+JDNtK3TadiP895HId8n+i6Wfyexdy6t3zCZeG/Lv+Ev/oXx4btg3b+O/Qvxjo13+v0TtET/bFr+NNph5I/Ew38v8fCfRDzsE+IR1QQIAn9HQ6KfgcK9BO3v40vSmKPDQZTtl4PH1h385eZxOiY4jF8GO+YGxns7+oE9B52n71kwTkNbplxbtcPR0rRNCi5dVNUPTePBxKK5Hw34L3t2ezCU//tLDz5jdXuwNqteJiUvkiRtjrahnZskBZSC/p3aRH+iTRTyUSDQn6VM9H+LMiHUj7T7xBJhCP5XWqIvvvfP1qZ2SI+D13RYijj9NZ16a+z+9ct8GSIa/vgYx9y6/1X335bZf7u+k9AH9qTJAeTed9thytt724SV8EvrD9T7ho8HcYb9BrgB/Q8E0V9afNDzf2CS+tJgpENxzD8d3odIwjF/jQcGeaTTtL+zI5ynFrDw6zTO7Yvbb0ITDhMDoCowRlU4jkVs50Xz5ZBYVJ/L198QlHh9vrIa3PK/wOgDUofDPf1H/dDPBWJIq3Aqlu8v+hlz30812uKYzldBQr+w7YsgEfgPAGFs5yFO30/7QUa+zuMPWDr4g6UzdP6jq1iLugrftPkXHYOP22LDqrg3gHEHnYEc/Dylo39UOuij0lHYR51DyJ/mJz7Cf8aQ/waim19M8JvxfDf6Hyj7iwrC/1Rw8DntwyH+Yv+wP8aKf8wL+HuxxT8y4jP0+/W0P58R6J9q/bZiun2z/Wb2EPxbwwh/bxPfDyZfzNi7nzta3q0X9E8ZxB+sHPiI4icCAPa/6QdBHAdBf8Qa/grb/6CZgxHyfwgK+uWDfS8+v8/m/dFh32z8TzOlFPXzZfA7EYS+F0H8R//7T/jVf1ky/yz/+8byf9QP/ysd9UG1cP+mQweEZvx1ASfRH/w4jhH/UHQ/nIDR6A9C+TaHP9fb4/813h7HqB+QEYJ+cDLwl6j2Wy8D4+hv2icrjaewuVfpt1CM/B/8u0sin4D6T93aD9cLq4M0TTilLFDu8afANuIDI69TO4BU7gt8hDVADm+/RwsPfq5x2DTp8EsA+pOxSFF/kzI+aPTe8jVX/Gr6A+KD/0P5of8Hgn7dM3wSrUGfiBL+0/AK+YF/fDiFgB1FU35lYBON3WeB9/9vGAV/EfTf4hTys/KqMPWBUx/d/C+4793NfpY6+xd8I/57nSPxOYm/oRr+aTb6re0Poru/oxD9g+n8O4H9a5CO+N4twvAPtvVnYzj6fzHcz8NwXxYC/0swHEr+J2I45GNu+oXhoP8HZKb/fny1//e/A9FhOPIJoqM+Q3TI//WIDvmYiLtOQxrW/8Zs0k8GBt8UAXyaY0L/SQhHk38hgvsCA/7/gOD+EKNg+KsW/gavvqj+n8+r31FZ8LMw3Nfil990jr9iPf8yDId/wHBfGfdPozjkexH4cT6/guJ+3WP/6db2Y7nEf2oiBIOhD4z5Mv3voqJPhAOGf4Dhf55KfcwkXeMDts7VK8Pw7/JYf8DGYf+QC8Q/9EXwJxaO+gTI/Dxv9DEfpFLXz7gQD+k0fjzwUfS/FIfsVXEwaPht5kRvnDxHXxvCuLy/+KvP0zFK+p0p/Rmr8j+UZaH0Z4z5zIp+qcn80xnz5WI/O6D8lxbe/1EU+m+MEt958ZtR4q8llv6gK8TxH3P50A9W9J92X79yoQ/xIw7/Q7f64YQfVhl+TsD5ZUn9f3Mi/1Zp/zUQ/nOTIn9cRlES//kyinzMp6vUcVuQ0ka/6trGKQUurPumhgk0fVPTxGbFln6pyYc/AxxVGKWV0Y7FVLTfHQDurYgPIfuhQ10kyUtDvnRg3of8euA33Ox7sdw5zYC0wD+zzA1DfmT+Fz/5Lc5BPrpT9B+I4R/DOR+z8VaaFJ8Cmu8jcC484OkHUQjH7u2ZiRejvyf0t3LxXs7xgf1fH2n4rvgN+hKAfwJZ39v5or4fFKiKCNBhjMNjbuJrjv/ndUP/xxjaZI6n/xmX+0/kMIqhP2or9glkwj6pvkXpn8Xj31G6/Gm0/plf+NX4AflQrviL//mm1Mf/wW29u6ovjutv31e3Ho4jw8G/f69LIX4vgPq3phww7Pso6lDuH8Kj35tuwOgfRkJ/LLP9yctG6Mec/F8isn+x7P27JAUlPmAK+l+TlA8jYX+1pHxM8/+HSsqvW7xvDOUHO/nTbdvvhsu/ks/5i2wb9WOk9q8uiGPkD7aN+PGpl58tsZ89WfafKbG/UpuL/3G5/G1XivxbxQ3+kBj4FzP3Hx5IwNHfl/z408Ttv6dEkSTR/8G/SQrD31Pus8cTYPizLP3Pe6L2Y1qYacJqf37zGNoH0v6nZOTRf1w3BsP/g9G/fCj8+0jmS7HBt5HqJ2uO2E8j/ceMxJ/xCOH7GvP/PkL43/4I4cdnyD97hJD+xFz8tEcI0Y/Jlf9o0/uDo6I+eQb/Lza3HzMXzCGte138dxhc6h8b3B+f+fps0fOvNLHYx6j7P1deiR/lFf1SJPFb8kr/rLVJ7GMsqnPW3/4bV++x36hQgn+gPkb924UX+S8S3h9EFyM/W1f/XHj/wbM4f4x+H2u6vrW1cv32LMx/vOD+Stz4q4L7aanJXyq4HwO0j1L7vsL2ZRHkwzLLl1WQBMBZlHnbRcRHd/8bAqZFP44fjGHMaxko1p1hGZM59o6/zDG+iKycwLCmI7KmK7JxcLLiTM6xQtqxQdnj4Vz6u/aQkUsZ9GaJc+AbVvh+c4MkroguvwVIHjZ8p8zl+jeEfdoQ+AWE+fCfYsXXlRxH3MpA3KhCKuTsXIyNso+zBa7y8Her7ji7VMC3vzVK4lbwfutrMWlCsY/1siZsntQMmzQ+vcjbf45hBZmp7pzHXFj5znCCKfCCKR2HNoYJGUZgmPvloMH3H9b0WWY1X3RrGY4R3nqc6x87/ud/1gD8nk7m22583JL+2kJo/63JPWmvv4mGvjVwzYaBv3w3Y+9dOP2HLq21fugitsdflqE6CI3fGtVcAjRcuePqbNFa+C9HMql4HTlE7XJnTkfPxk7Wt4ND6UVDgU8MfVCfLWYrR7zx/X5E62Eu9frLDbI2Rp3R6Ztbdg7JMziaNH9pEo3HuqSIUeREQ9+/NsvkQ+pDF+E44gs1ALEYlWTZJB1/aRmuRkrffPk8HGaI1eOAiL+MclKr0wPOHInhtSchqzMbKz9M5bhRnJFPcx518m8f2RwfOx9HH3IZjDVyRFPesWd9OSyJ2yNSVRzMcLFO+Tn9QgyeOcU/3ot+M40sHEZBhIlfiHaw/BQdg+Ytz5LTsUF9T6mjxWaC70l8TLX8huqODwUL9nWfUx58EFwoBFgd9ikzvHA7Nbbx3tvF4hvF7MfNuX5Lo9Bbs7rwQAgAJfvRSUnifQq3fEtAoztrRkK/X2BY34QSzd752lNvQvntcC+mfDwnyN6nic3Caz5T9c6+dWd+z8d0lpkJCRixW93ot5VsvIdyeT+oPmdC3q24jg9fwBLtc54wyJCKkAwzKCJZYXWaBvXfpy33F3w++vn5dPBV9MqU4Jo2ep7IdMmez6XnSb/3i8RD+3zo3+n3TAxjCxCM0Mbb2S+yOMdBNaRoE8+e0IxFSFBqWdC2XD+d/n//x3iz2AFjHgxchQ8W+7DWInMHdh25MzJjyuYng/zGhzjExtCPE3nvkHQ9BqYKqPvK8I10bGvZ3DkMt6X4dpCZd8rO1VHjLj5MbAfi5CWi3b7kinu2faIG0puNRFzp3PXV6xpCNXlh2Nlzq2LvHV3ZDouhAScqUTFtrTql1L0H+1JUR7arvs4860ULR+rpZSfExnLCl5YfBsAekjljlAGtQUtYwmrNiA+L2u5glofPMi7gAKzcGf55Orb046aO7f3C/JkfwKIDQ5TsO49+PC4cnlZksqNXeDhdltn++UvwgKI0UFk9OG4uSACX8eMO2QoGh2vfge5sW+7EHSjNqPaZtLSn4nzuwOlN7nqvcbhUdG99gL+Yta0BcRAk7V4+hB1qUECSpl1XReFr3pQ6j+EctzPYCzv35Fwf2Ql1wNnGxfOGpo9e4mavPREgaPh+idvrYiw/dO0WpPewHWdwSozeXIc51RIKeCxp8J2RFhfcUzQcA6ZA5/30uJtfrPif8ZHeEJHACMIxWYFnzIvMmq14aA7/1kPgjm2GtVxbsGRHE9lr2Qv3i2s9R9M3hV3YL7C4xa1QmIJ4vxeXtbyasNaznO/zXzXv4LtNMS/f/thpxfeZc7/WA3THUBlOS+mEn/4v2Km70cXNK0JwSeWNbR2OUcucVj+dxgRrArcnKJJeeP9ieLVO2WMPg9gIYdF8PEmDLaUK4obzcmiiGK0Iz8PdYxFIdbrdmmzCEsaNx1sa97g/U7i7bwm+D1VBPqvbWUQbdfbOi/7XddF8SyqHyLRLycwJaEOL46aH4bkglH7Xx6yO3NcrIlgjh+R0unxLgZYfJLyGtfe7B8ffKWQ2FQAadWS6rDmYeMrgaz3xFcY3XAjpt7seK13xxM+P3vQqxa7owGjScrwExnVmtVvDbrPVU5iu7d/vaE/s0d8rflUM/ARhNoHuBHLvWTGUzqukC3AbL0yM2V6+U+wz10xZaK4ePESq0OlOSVqTXz4b9lDWRGnLSVaIvcxG7ysHoTo7Qi9ENA3k/JWHhG3MwJ+3Eil+4aLSoOcMejztrs07+zhX0p2hF28YfW/7o680it/vKNP0VC94GaiRV22c2xEVI0RJhcSw6ohlpfaztpxcd5uw/dtNyx84l+ljp01T1Ij841LXuWJh6zFvx/YWxfT0wFXCkdyTTp7Qm1TXd24KDxAqXuButKUI6yFEeUyUG0bCdgSfrDpphU9m4lW5JCveS45qrCxFzymyUVWiiv1DSh5P1RGY3jEZA9Z+PNGrNx3SXgOSJro5SmnS9tE+hXClXmAf+IuCYSGnd3LTOeJJvjrGH0d9ZrtQqLyoYgfInk/D0U9Mi2SFM+xDQ3fm94I5VeXRprbPIPRDM51UhseHyDEhqKG5llafonhcvgmnd6HsDnPFwtEyjtmFVvfJCkHlIEa6XzgnZ8iQ0NUDbzrP7c83apGOk+9FTFL50dWfTnEvVyefcdWDd2yQrdGv/W29p8rSoSM4ECZ5kM73Tik9PYyvLz9cvDI9xWI9TuRpyz3wpIUMZYE9ppsiblI4X8+FDj0DJ6j82zG2Yp2ozIiaacyU2zUP2w31qSfJxzlrYpcWaPTRizwGSujvBrJ9yBGEPtZks+LM6sATJ5OPpi+M5V+MjQZqKoySVkf+7BJr7FBKT/u6mgpwOkqiwYWLxxdNrmleLqSeICd0QNxNCyd/ELXIUyAcyhi+vVcKy+7CaYGkegpl4HNP8NTtlRYqKqmTh/ILVdIkGO8V1/k5U+xgKbcATQWoEM9eq/DcRq2WzQdhEEXKXqmmNiNvZshAbl818fgJkoEpYpziGAsyuQOTBVe7ZAaanQjoMDeiKmgd61kufaXheSAqckhu8Bn1iGCqsFnheyHojCYrRgM3rtMdTmKJxethuGPG5bsdCGWKy8BBGiNkZUC3hGzmaowH4g1VVoRYZjuf4Cxu+Kg/h3icmDl8BGnpGKnT4JUJCrHwg3pG+pytkhnSGEqbiBt5k0wub4YTSUDEftfvMLktRD72yBRaRwgkeeqbvpqDLTfh6SCm7HalPfGZoEK7guRLIYbBS/O46VIkbYTlXm0BjfwPOuVlKQR1OswY4jayI/KXH+ngn2/LMYrYn/YhhDspIdnf0YF4dVDzy07VWcVKzqFRF3+obvJWzG78OKn4f0ODswWXpAtd3O3lF35j+eg2BTszdVM16mdZhRmy7QGgry0pTCz4fOVfCuA5mn6WDr+pWQeZZg+STjKvQs7zPGGzzGG8aVWHJDZ63d4tELdi1bW83wWl4+4T5x5WirHsosi7LSXKepAb3cbM1KvUqysIXEcS7ajhNMOqAsyWJ1fwsyM0mhiFoegKKUj9egzpdtcGsjtTa3DTuB38FVs7oLZsTPGoX6UC7mI289axdJU7UkzwFiW92J3w2g0K0kqbRtE9WgqMwbNKrh8RkDMhjUnrtkA9oXFxXGC/IUpSt1GsmhjFzIPV2N7ZE1KN2KeS5ezFwUDAoVUjy6HANKjlzJ5vq22XKuJ5i91O5qx3dBDKPXux9Hwy7gGXjq4MH85enGHXn4YNi8XzFWkKsz1mMKnhw4NP5dwGV0A4bqCvRTv25jNy6hNFXJHplMJuu99o7b6dJK4cFO8gVe8q1R4Q7cWCtODOJkkHx8A5ox4qBXm5VYcPr25e3VFzWsC1D7nZNLl6NHm7V+7k6lCM1bSOSrU1isp+WZjmHj+PESq3vuzeiSVC5za2rE2Mdahmr/mv3B32q2E0MTnwIMpuGkSAQ3faGuxMXr3b6sqoeibHbA+DJPCcgQ9V37vJT64SKsvkbPexNyj8XAnMZQHmkYtBsYwsrjULBRBIy7EqJACOMMbiuioHkpKfHRdjtH9EwOWMtAWDqkJwCiqV4M7BSNOsSc3h3kaXqXfbkYU6esowIdNcK1MwNl/Ol1JWGIWz7fGKn618Z4gpijpVrBKvulw6IATts7UfetSHRZtviIec3CmXibwSvXDi3dEb8/DO3Q4vCDdIhXTB4yrm1pXg+iYDuS0N/LZw6kTcFWfShF6lCxzYV1IZE15tHmr6ZC7eIEYPTuDWCj5biswRZXh4VBENe6qRPAHx7+fELHGivwI8otAUSt1bN+q5vZcMGx5nRj2uUsJBc/wZm2cAsj13QdMckitqVAmV6FEwxIUec9cetDFUBTbEz53XHko1O0SpUG27Z/lp2EGWDKmOH6J0o9TDvKV8OkEUYqTnT+qyKtQ3/MYv95tjDJq3C6gLEomgigNXZXLSrMDxzdPtStwV3h/2yJPO220x4MPLIY7+rEUgglhvu8ssl4XGBbJDTESXwE+lLq51IQnd3t1gN1RvkXCOSj0d88yDxtx7KoJ+Vs9WGoGcPyME+6FTUl1uMrQtUJwvxpWviF11fbdUHfd0uabUzU4KxIewapu18sDdBFF7N6buOdTLNnJVRHnsn/UK1xdGnABCtK1iOy7UQBntK8+YVNO+mqa5dfKNccqHbIbzab8POi8hamFdnVx9wZsDujqlenX6kyMEvLt6BnKJQ8Lyk3KqVMgqxPtx7dSPstCDiIdutgGnPrZaHswwsGr5OL8ysXJjzIOYuwifxpoVPOlUH7PcC/vaDmzZla9gZqz0UAxsp9A3NXCAdXgY5tVwK5lSb9Jjvoj5NqrRzuphdleL1R/lsxAsF+6I/qBOdspO3u8POXxsnvzI2loIiksnn5y6qb2OP0yQE+ziQ7YquZv1HVpPQiu1a6M90zn33UqJuU0CNsW5qn5htXtz3dE6Ld3m3LkPz+84iNDgXp1HWBx3nnfcccTFaFOU/sbktmnY471MLFbWLp4RQEGa5o06HBC6ugGuisIClm9M5pwfUb554Zj1zjKr80s2zmQ4ZtOuLHuyBFZ2ZFt4mqKw+zdxlx+seZeF+9c1FY4xBTCqQajs4/nkr66d+aTloIgOuRr9mMQ/uC94huR2EBWepwtI41GcjQFPuYGWEeRCUlahpAyES5qIIFmh1WqKd0FJKm4Q7f044a7nUf1wTl23WtR+R6tPD6zQA70K4ibvyDVjUcR4WNdLB+ylo5uMa1Ur7Knx4d/KGGLsnflmuoKQmBaYVN4cRuKCx5UTxJqryM8YRJttwuO6QALZiuKDI8eQLGbPDZXMF+JiH1EiwzyV2yEaQ94NwEFqVOpRhhFik+7rlpt6uItHJU42wHrjA5/gMwoSQeICqlNYVW/Js3EuZyGK3fURFV3bW5JxEf34fj2iUTQ/bAw9iKWgMar/zFJ4mWIZwYb8hIq26aycFFDUcl6bxMDIOIDBFJ85WFN4RofdYhFjp+aTaS1Po4tdkXY35wY71BBwl/nunIFOmUz/EH3RKkZuu0SnI/y7zOEjmY2QhGb1cfTosf5M9GkwPswTTmdnRsGZoK8UH3e6syTfHz0n5jOFgiuKBagUZLNcJ8FyLHpKPOWxb2ustzdxmThsoltnfHbxuDIsfvT8St7X3Blz3WqtO5tX13ybkCfOhN6zMn7vgf+nkb43lqQvrmR/zRD5xFJ31uXUVUGkArVL9Ly1IFArVns+Jwmhorf0ADdoO/z2znjhFN03bD/NqZCfLhRD8xgkhQHGEtJW77t2MmNTvB0yNwBhxA9hpA5hPFjeL4cw/q4DD001zRuwE0TzlhQ8hKgXwFrwKgvs/fJt9vbQbZkXYFkQLOXQaXO7lOwm2xp3ETlGNr5mc1eBNVYAPRmmvU2Scz97O/avfwFUa1NNQWe6WU/JM/Op0F4S0rhrZA3UBYOalETSk+TDyHBRKykUr4+J6ywkd1rS7J2kdVUd+vSAQ54mn2NLAL/4FF/GVJM3ZELXtIfmSV2fV4a8yCXfu0MQi8dX4HhINqiTzJ7Z8gLC9fB2YBGfdIw7beG6QrIkht16IHePzfCNTOLom7LxhxDdHqibGsmlBGE+eJU5m+JtfCkOcZ/ckwprPryEl8qgGCfVT2STLkZVdtNzv11mhMMruHWcyTYwZ30+1F3fj//rKiu0Qk78FY930j/XNy+37JNZCkEmVVgzSssjzwA+Wxngjh3ycSjHceXlXPu3aT8ReGr75wrX7q3/lJq6u3pbmbvBuejHavOkFN9kBUBLUUadNY8XZRSPQGp1LnatKzpalfdMUfvLzIEvf09zFkBAWMewaE4IsDIl0HeaigH8gOg2he5D5aFiPCJYr8sW0LqsSFewVqgDAcxo9qxShswettG7YgO/gLDXrR2Th7lunF5fhiFA3oYLtvB5Ed3uXNTPa/AAtl07DOqS8bzAu7XbR7P4e7+FxGy0BWN8b5xecoUuKwtLD2CbUYwhHltjjXG8i9YhQ+12yJB/yNDlEK7TIVyPQ7h+14FcPBm7Qfdg9YMuQUnHzysugvEPT6B+rIMlP3mXCvmz3haFfSya/9MKXLoGFLgULqtbK6Se7qAGgdEO7CeA9Rn2DHav62GqwAKHbbHma9H4dLNET7LsCAmgBBH3wGTZ4EQXwZVVDnvfBEes5nsWHsdVBdawKzw/rJ/opNowNjpsYU/GNFnkxJR3CWJEaJUcUy/vxJ1pZdFhpNY3TDNZWcJkCJlFS016rClnkUsgjxYWodmDwTPo1uD0rdEH40G5D6bim4fYM6bD3FuLO90FkZFVwcxbljU5Lhfk8M85tlueF0RVOpAVrds8UPO8phHv6c0dmmHddBKlCO9RX0l34ZxbHe9dtlubYBrvXesoPFWjVKshniCXaGxFY3MPNpA7tejAl9t1YI7uOqM+lEkg+QwVYnXrBKi6mdm5NFucn+G8v/edItGNKKpnvCdNp1aFmwsnbGxX4eCF+pW/a7rGLSClXonmI8aPYB/n0DkzZXl7SuFaEeeg5bO03a78FknEBYuBG2/CwETXmTLjjARgYCEThMwS0ryxN4puYqcLKni71cBGNQOBLM9af6WDfY3cZ/FwY8PyEAeoBXQxHiB7ABNRik6Uai0AkVVneVsfZ7ca6RpDYIAjYdg7d7M9T1CWrquAg2yUFsR2NifD7IcEeQtRIwjyLb7B9E0io+NzA+HaGVR2gJQChbqPTZu5QBMxz2taRiae6KkoLPsiJSSxV7vItDaNUgQ3aeK0VAPbIeIIdwEb3t0uB7jEADOO/BDpfDV2che7XW/dRbrp4euA8DK6Dd5FPSjQuhiq39J8TnY2iRpjc/B+KFE8vcbB4Ja7UepPygsgKGy0ReI0NK2TlWc8iTlC7zhJLsll43mtarb2Rmr+mQkWdDnd1Hs/tbwEXugoZii4xczbSQFuKMrIVwPIB4CGIoiMbleqiMn+YkAgFTydm5lmzL0/rP42aflEK0xtB2SWPfjntR86kPMOr3hVOzis7qamPlbSThuPPO6zUED+L1uvV32qKmeOVH9w0PMtIuOdb/qWLFc61/alS08RJXcobYJ4pjsLvqcmHnbEAFgJU0UtXa1JFNrH2u9Un3XhvUpGmqz2JIww2CknbEzBq5JFU31Yjxk6nYjZJ1yAukevmmTscPEs/pxYt5x8lMPUA/CTi2vw4hZ75n2fl8SsF9HcAA0qOrvL7HOl2niEz7l7tarU2Q3tUhG+m7poNNBEWne3R3pLERk9Qjyln3YLX6Fi2cmR5o2seAY0qqEJ7eOGZqJwNgAfH3dHCHDLsf1c21pC8AByGFEEolexMAU3TE43p/eWBkv7qxz3UMe4IPo9A8BQR8h8Pl/jTWXpfKBO3gFxTwMQIP5ACut2KRzgA1DbXfcw54p41uCZoeT2ckhJY+6gTOhEIS3JNcN2zXTEEOFHgV+QoO2ireoemG+GRwh5nQyv0lOvlqzSYqIDstRKISKDwyhzcttm1oVdHO0ew5TdKvxK87NATLYaPq7koJbXwFb4cL1XDae59mAaSbwcKooTOi27RNZv99Id3CcqyresI2gP2KE8R5f0KUD0Uxv0vpxeYbJJaXWZXWmzSvPZe8LPsezE1/oZQGsnNwpyUQ+EOwTkJeYvFDfmidfDDhzyvoogcVUSPjfvsEebRFSqhXUf3eISqyirgQEGUWeLXUoPI9xytr8IBCqpl9v9TMBcdr3CdnMjhjllb/BI4kBpWiqYSNUuDMqNsdRNBI/EJz9J4ysveuUdRCsuYVMPjViKUj0GPGcK95CHQp7kiXTyw4C0yRa12WW/OycRhh7Zza/FsxwhlpcXHGI7jSvVz4blRxAAWFxPFbgxP/XqWq4ak/5s76O++Qs2ZRb7QoeFeVr7PJlM6c7JzEVm9JjxTOYU3OWYCU0mxpiAYmuTyRG2Y1gEcV3bQTtCE7Z6NMglp6NUyXlCrSL0imgViM/u63pcjhVk08+ZQuDk42ywK5h3iwO7MmiWZbD7an7vJf/SixHee3Hf9GLfezHcey9wiaTuSTQi6CURaQfmQ1AwdJ8qaFniiDhHa+DN/NqJVyTkRtk3K4bKUeZU5JpIyGc8ihsCTgxbQCFivnH1I+N7Yu5WXS8unAfVB0eTrD9rwSvqsKdY3Wzp8CqTziFjJFc+dr7OR/S9lw8K78/LNX2KK9aRtTnpQkyNMrKlyxCuS0+DohY07oWBYXYWkrwL7/MPynvQxHwu5Lu5bIrrjEsXn3Sa85FI7U6T1WAENGITgPHqicXLwyocPpFgybe40dgvEyGagYNnbFsH+tsSRYobqPukaCMmUApl99thM/BrV+Pd0wMugfAHqlGG22ON+bGjK3HscPqZoLYjnSBgXUHiHUT9PhPGULycr9TG05mE6iQoWoTZMSYzsLCka+4NtYgtb6L+od79UpWGeRkaHImfU2tVPrBUHojysuq99PhCbQ8bKDMMkrNKPd8I2FO6JiBvUV0cBAF0SlASz7HsBk/QE+Evp/aeq7hWnWb6lo/MI6qj3u7Kp1V5xn057YkT7FQjb3hOO7wbO6Q2+k0XKI/oOWYV+wBxBfoCMuhCU8DKasBQ35jIo3sLrw87wem6q6vFJVRdej1Mngs1EKzq1/oBE/rZaHOQmYBp6k7BfBWzYo5bzMD18h3azaxXcKN7PJlb+php7Ea/4qlX6nFNABRZH8lwmXZ5ozJQBHTe8NmvEyZVaM/wV5X29Jex6oZsFR96tPfPwH2e5FgNoTY4UUqq3pwznuBWy/j2otWHNpX747xsjuUtnuNnLUFH0UzV1uXMIamB2tGDfQlcaW/2TuCQgj71hrXI8iaZ/eN5XXugJqXa2ptBPEPExJbVIpYdBJEWuFNBbBsP99S0mOQRuvrE423hJT6ZNdVKU52hE9QboY1A7cMNO4+eRMuAimHwsu5aRhlJs3HloDGSPHHLoCTsoe+E9WQWmsG0xYvJ57NMH5h3Rm9q/gSmUFPqrmn7JgqNybg3i8I9l+WmnyGHABwLJU1xJhGPO+Na8ZbixPHVG1cTOvhEub3FPA2YzFhZI4q4GnrWyPCy3LRGU5uGjlXpkCeTqsU0tBdj2HyAtkeziZE+jub8Chb3tHI7Oz0VQsHhB7tp1rollbzaPc72EVtcWHwWjrBfoqI4YU5SIyEqnT1L3exOQB33p3Kqz4T8sDgH11fMEgFSGYTNduW+zmHe4JozfYB+bzubQZn6VCIuW6M30G4NnjRJaexNK++s1oF3vWWZ2fC0V7GdFNY56M7hIfb0IN3FPNRoyA+w2/xYYWTehtGpgm68n8ly6yCj96s8Q6rbfeImzHRzohG1+eq0xHXhOQBPlMxSzho6TtVOTpAK9OIUCSpqUlmETAMD4Ve0zMsevo20N8a9q2/9RRHby+zq/Dk2l9Spc0N17aPphAh5foAkaAqzU8dAF930kE5Qw/QVqaLPtGrU03l5s0tam2/dC3CLCKVcOAusUSaebBVVfvUDAjl1W3E9qXoI2OHDdZ66WYukSI12kyjhvHeS8vJxDTdxbzGoSlAo5eVqX+CtUU5BP4vhOXhit7QkoF5ELzl2wFlSeCSvsqFIgoyFJhS8A+uzbcaNlygsRBRZzaJZxvPJ4CJa1V0jbVEwYZxhkHo6ATVaJ0smrV/3QV891Wdu7ksv5hM3J5zex1IJ5mt/+wk/KBbhwSRa+hqoMt9rW3iXnFes68mif09Uxr2zx+YRfYRM5jM3n5GObVDjFKXPaVEqitfwmTAUidelleD96ylqNISs8UVT47lnEk5nCua4+PFlZQ58ZU5gwFe4y+B7Pxw3+F6Z+/H9q/su+ENj+wQexeP49fI8qVssK3TWkF4Ewi0zvuzxdoVmlFOiFawMLmcqPhFMHYH4F4hQ8KDljrHxomTgQ0qOEAg6r+slbAMV0Na54rFMyzZ8E+GEy2gw9pt4+jEMndh05jONLZuHh3G6QJyGgzXbGV7bgFeD5YzQcVgwTcOg72ctmBmpp8N9MD4nKBBt2LB5AP6Mh0K/stZqYHxiVmhLgb3HA/YGMTbl0mXIJuNIFPawp9TSsIGsyMthXcplwYCT5ZkVOoktBlTnjA25G0RnPEPpLFEXRi/mXBNoiSEkYCYdAoeTh/xmxln7dF+WQ4X3yTjIXM1dvuKUyd2JTfbc2+GOXsZeG9sqy+F6Fgrd7rW6xzrDLM6jAeqX7Lip8Dkzome29Jf5Rqt9dNYy5DhncPAgn5YBRI+4BuqwgIc/40xK0rDXucgEXHwNgFNTDNArQqZvDTxMTXtNlERPaBDXH4fH8sEDGmqnVEEiqUvY9qrHdD0+V7oIa7bAxrN+zVeIrOYBG7MxQ4y2l4KAvMdKs738rQYnNhRHFmqPvm1s551aTuYKGc8k4sMaIghEjT2y0WHx1KTNgbL8UbyP6CVyjR6RT+ldnSD0YIOk3dkShLN0XKrks0fqh7pEHB2NNevcQSkLhxj8SkXnBIAhIsDPt+2mWZtLy9ZudPosYOEoLOQrCWuOdpRTA5nriBKysKWhKFbN+gO1ye6AyzDa+jy74mF+I+9V7EFm+mjPN9nJLS8MxWlOJIfa1udD9x+K0oZl4K33onFiVLj2/JRNiXa+eYSCWp3/aHneCRWkCvFpRJFIsyvd9MN4LoLcCKnHcw6cm6JInuOyOC9G4jnJGZjBqbNV43PEkEvnVnGeq4ZTdv1TZNI6zGBEERqRFzJ6gEx+ArkSfZ9DLGdvxrNPkC2MrwmaoF1SAYOPPJfMphiGEA3MK4iL6zllg3D4Ba5birzJhxJIMkSxch9NW51ei9i5VeswunobUpeJIcq5zXwTeYIMCQqieS1F2goZs3Ckzs3S8Ea0b5Uxmnh6VWofBzTeKetw8pWkqLuWtTejr40FLcs2o1xIv8Nd0wQFMfiSUPsrfmYqXhMNu9wNKQ9hVRXjVGeTS91dr1KOMHltxajVW1AixaCC8LEoy+ybreRAXoi4EryXkzRU9PQoCfke3bQhxM62lSjkNEm+nU2PIOmZI5q4XIOauM/L7QA12eMe4hYv9A2DCyW2gaUAy76eUC4+Me7uGEcQ7lWckLrwWotz0DCpAFJis0DXqBSSRnaECfFDBQGqp83cvusJYj+KwfUd5S7GNYnq92ti4GelbJXD7Gt6fwdph+BqYima0tDh2wGwxzDLZK/SPS9KCjauRiRPQW+Sbcyz2jNxYa55Goyz1k/TLyAn6mHIK5zU94pkjo2woPWkoQ+4b8DUjBBV3EHsBdGRxvFy8cGxfkqHUqdl+OM5huYB4lT4vFYEsDHnkQZLVRxPJAZSZtiiSLPge7XhjcBsWS59vmo5r0WeQ0kkfeWYkuuuAzbbkz/sNisjga0GufQ8ZzwGRlIb60xHt6LrpnPV+0W9DclgI6A4tKIODP+8yfmi48JWUuTpqSUyLtxUp7YvvQRWNmBg2xwbXBpRsmsn9yL+TLiS7GxzWyJX6NtuJsuK5p8JSF5o2+3SVJEaQBYFJixIt9P0eFpC71bkEW/LIeymrLFcaM1GBhJDcjJuMnoC6w1SFgl6yFeYoO5Sw64z78yinZR9N4+5TGUAiuM3Q0nNG5n0KeIXSHVPz9rTR+YiD8/WtSIHzQ5qUXTjW+PbB1KBm4XzTvBzfkSpY09kA/D50j9G2ncejdgoZHKY29QgbsBV3hUfQmrOnepZVabTzuM47DT0qriTmsgL2QlYNODPmTyzc8qqO/papgex/J2F9xk+oWlCo9cHzWvInQhSQdJYVFQsNO+UMbFtyyU20LtCgiylTTsvqUVD2/HedIimWqFzhONwCzOnB+TTqhkdgeeJ46xd17uaWRTKOLcTXfuBfV/aHjMtaAf21/VX+OadHn4exHcSV2dxnY23JfjdJSa1Tg+9MdQUFL5PJoR5l3Nzu1k2mTb1Ve6deD5pbz7J3yWHRQue2YSjr+c5xMncvWux09hJl7vVPT/cJZDSdfQlul/P98v9+ubXnLZkIqcDsaimmCqkhqf4MM6Vr1TPCaTbwYMWZeUf2HUqhluEi8+J2UFjnJw9tjXYZFhgJDnXZTYYU/B6WXD5aAHyRVR7bVeByaXTxqKHJUXg3ZQItRbELjXaZcZm4DQFZjUkk+ibS+6K5Zwk2APbxJVRTknLTmyv/3R09i+PKTF+pkmMEc10lE/titxH/moz3arjDFlyheIK55lTWafsC1ZnXUGK7+mu4gzh872pdwwJndMsoc65cGEL5rrP2QUzHlhcmCYfg0qWLHmDXk2E4DPJyIXAMCV4BOptZgf6MgX1bVoKeOC6ZF8zd1iO4UzhbdqMAh5re7+zowPLfT0ZHLPA3YiMef9ysnmcejIF7suwFmO2X05mvg5rHoj//eTfPae7bM2M8+ddP8iqbqxpIr01ODWDWjQR2E6XpzdaloCHoRcfQSeQpMj+9v7MNku9nmzsaPqZgde/iDFqPCqMhSaeNcBT5WIDVjLSib0Hb8QHkR5KXciBQICHYkubi+8xMPjgKLq6En9pmL6MOPSenZ7NE6epfmIUdt/Y7sHPaLbcKhpMwPZNzp7s7cK0MXEHpquG7kJJ3PKwzczCiq/3NT6w5X3f1BN7PsOGHaMa8jaHMim3XqM4aBJ2OV1gOrslM5rCxBCeHKHsrIvMudEUMiDHxE6yiuhLBBOgCFQUjjlXpQkJMXRYR0LvfDk/+jucaSzkq5aGxwH57JIpSZYX9BzBYUlgEkdmdpxbCdRmN2POmo6k7ILNgx1bzlBCGwSr6yKSUIa0QbZFlRKrc/btYvKt6l8EULm4GIbxRDiwEkWjB9MpfKTebgh6UpqXhK1a87J+kjOj2RUbsuVlg5cVLxBzyA+Ue04tuA5qtquUxkGQS2GlQXjiYf381PElKrBUAvxy7+pKOQUGoG2OiIq48/pOQOVKxX1iqjZbyLi0RsrzVGXbM0U74tSr9i1nR9l6rWDwz0uQchK/USf2YWsjnXOKvAJLbSzLQl8i3UBuzH2egI3FkXlX+AMxVm3oXETmVWDl3H3ODQWCtzRd6VH53o7nXDmZD350MfxZdXsgSxy9nNdWfDQxOpEePV7D9AFMvqI/m9160Hc3ubnYa23wCet9yisTi/WiVj/065k0q5VMdotykst239wDmaaSu0FGciviFU716NxgZlhn062/vtY2Lbopo4ZolZWk6V3yjuiJ8gxAJKhD1NwGzh/gwCnr47BzqVjjXZMGdROQ+8alzL2V9pIluvCsRk680MJUH2go9DFQQ8OokXaa994Ij7hzTYYeFH2Jxh6rnoRFWtVIa0WOl76z9ar3kCDgzPckbkgXBirTZQK7F/rcY7OT9+tq0TdenMd9dOZl40LvsQEl5Ihbdy9T7RLLp+187fUbhSi5nBq1AVfbQ7gSkU7oBWc9en8Z9enOK+IKiFi16q289kgopPPeitudd59DM5yxtEfgTTWeuZKZZL8RwWWtFF2EhDelfmwYcb+lxuNWnwRQBnBtsAlEgKEbTOuzIY5oCSxaCXvi4nSMp3SsXJ0H11+dEfYU623ZNQpV12JHOvLqPmTgxZZrIeKQPgiFmqB8L5qW96cml7k7tYPTabMAVWltNjfwvlx2cdeosXW78ZKrAZTJfcUN5C0EIK0kCD+M7NhS7fRxIZ9Zl/iifufOVQVWlx4qkQkDWlK+PKPraceu2iLZqTRkKAhpJryemsGg5v0ihLX3xAHjwEs8rtXhgODlauSb9TgCBCyUawvrXkjr8ZLMv4Fnll0SJmLnsNAYikHm4p35CrzPi63nog6HuyodsJlnTwSgXtIGFn6ZDpSBQ9eOJRsk845AeLq4k+HCJnYxLnCkyTaXxOp9w5kZjZ+wGnSIlMKOTB92UNmybGlE28r1/XaF3LOgbwi4TfaBN2n4WPMplMBdt3cZgzAmDrX85PBXrGhdh9orLFVJaHOIUckgWrqq9ebX/jU3gepbSA6l9KhL4F0xiIjNX83+Ew/tE2gT+iqT3OlsPfWH88ZcHviXBj5ibYKo+yS5q+OZVXXJLVT6OTdXMJme3zd9bVaexxJ2akLnCeVFtk332DrXm/qg00veg9dG2DjU6hxsym1/0KOKekwNArs/gBPekTe83fXDOmJJs5lPYS+UnMCV8hZPEOBXnJ1z52mposkj5h3OY0UgLnO6j5bROTaktpGhA7cm5ZhGWBebOZ9AFO9v9kAjUVGE3vmCTCdnUZ5qn6uMe+DBBI3RnWxqALtv2UQJL4OEvxHGuG/nnt510W1SRCvM3DcBQciZhtRklpJGjO36/kQ73usbnyYUMrrEGSzYyh3UxZt8mdRQRpsQbFd9/yQNrbnq15iudlTVYxn25my8X0PPpprhdrtbpxJwyXlgqYB7wjwoxIaMGhjJa7ybsWPlo6fCyOK7pIlYp4LRQheXEN/ZRNdkMsZsYyoJf2jwcqHPnWrd/fMQZamuwR1xsQMpSWCYDiO+DxEPdwL99CQecGzZLdHphQ6T91ZKYFkRgMGLisMlbcZkjZcMDmcsh7QCM5iAtQ4Ui964wPIWkG9APQItL+pm3TV+8XD4GcvlTQnZHJ5UiT90l77ZoV8qdDS4t+ysKsI+LlGGYA1YPbpTZuQNk5IST2R5CrwHyuzSSX0+ngZ64pAVvlrXcD7z4ymfamiguZsGYprkQDHP/kzrJoTcQPgXhbebd4/SdgpXHUX9ffF5P2Wgpz7PTcnz9OtVEZTn+/IRVqk47q6pXi5yScMz7E0tPS84Y/IeWFXCaPUBES0BNOOm4rQX5uyVoqDDvg78kPcXGGWtkXmsvmoVwdwKdGaPxd27Q9QVwmICPk4lQWmCtQk8kKiL6U0GUCeLdjfbOA2PhUzSNuEfKXQ9RZnqj6+18nR9nibl9VKdFZT8bhYwxddH118UbOByIhxXlbjbS3dLqTM6kE/LXJ5imJYBTZfokxGRMDfKk3LH0gBZlxh++NAx41WJihfI62+mhyVOk3lNP4ZiW76peOG4SkbTPdkh1104n1E5DfECD4ntVPv76xWeEtZeGXtMjesRxYDgi89RTCjsdMs7CNELUGfI0tT91s0pRLdCqK9x1BL9ClWLRZITEYBkJv0Yaijhzbnj2Rs9FFwigaxCjaqcfXaDS0lm3tQXtKinD+2Gxdg5L66rfjmB2YsaPFcgyPb5IRU97pnHhX9+VkBDYGhQEI8dmHWfBAynH+iZGKsk2SWwSpCUy7OGKCq4PswLr6cgu3BgYkpiEHkyO2VAKYNbe5GiO3E+jB/XjDx8SmeboB9IPEYxYXtwlnTSEBaoJ2DUoX+F1Z7Keq9WDSRGhbxGiGfkIeXVoinMhJ6KSYejDpGHSOAqXUNNPQzC5aQEsATpKLA3tXLMkA4atFchu3ay3m8kWpOrMytfL1hS8CDV0xFhV8dmLYU8rUEKnqzFyejH66LHp3UKNe2RKGdWgDYgL+pEbraS6kNHlEJ5cVvU2DrDEF8IQTlDc+YO3alJxJOV0OGSyqSg2sXQjv6W0K50JZ1JajzvhkRsgEbwWi8nmLvOBF8e2PaFpTInp8JAPNszzU2YJdeikRDS6czdsb4/E/hUyeZGP7WqJXWdueI+2wcXOKXR1fHOyClwaZQwxV3OTzQNY9oFh7mgRHyCDWHzaoSP5+UNNXnQBBApNGDgce+wVXIFOXcSf0aZy4FcU+keeLASP/yUPtWFiIOipkA1hCQDaO4QEpH0JxteG+s69EYHhoTJRG/AaCfeQo0VwDO3Dr0LX8CmXm/hIfIwVlo5q9+eq/nMuSct3OT6ZCuElvpGJ4VBp2pIj99gs8z0mYw1HayzpgO4ZOe9yutFWVeLjpLfkX/32K6vUtU3h5KFt2jSw37rUlatCPIEG+DIZdpzKoloeRbaw6fNRlX2NHRHFAF9+PBhwzJ1GIoxKqbGf8BWROz2eili1kpNsFzyXAiXAty5yH46VDzbcHpxyPk9CEOtfz452oN1YDTD2YeVI/SebA/3o7h9HC5UT5M4OfH12RDADYAcfJPlBf4qVWooDLEozGl9d4Ss9jmMpk/2wcABiIakcjFqyqQB2j9h+nazz2Z/TUTk1urjVMyxB3XRCz0QJImn18cYwKrcSy16JWoRX5TZXIkAJbpiHwerukE72sMMsU2Cc5k7Jc2B8evEu6N4WNyScpkUGZIK6emZU1OfPEgn5ZCiiQKGOIvmAXOsJ14xF62kT35Zl9Q4E2SOhMXCX+nHFne2gl5BhEwc1sNRUzt9C5XZLEmtclv0Q+rTvU8fsvVE0dEaFowN+doYu7M+W/dp7FjvQcqgNuOqaI9GAFgDtw8gR+eywj0rk8prCj4Mc1Q4Z1PfyDL3F22eIKwE+SvUP/kTYsXyE5btCaftyIWrhjGL20XiFTS+iVty81xjl0c0mG4HPAXPuwVN323FAUybdKfFkFaWmpX3w4JItcdJdFDdZTYeivWsAvy5FCZq4gf8BDCGvW9Q3bT8oIZPoxLx/blh89I2N98kfH8QknnAJZB3IXZJUnkZEVZKuyPphVHoluBqdhOaMKkDHHtlP+m2PdmlwaEoikV0BjGcu2D0jEa9Tklaq59OHvcoLR4kzVi5nnOWXDbdfLTE9Wq1G4OLh6l0nl2hVyisrc+CWL0DAbobPe6PiCcIxDtNp9PT1e5U8jU7AV1BaAwsrFbh6Q2spZIEQSYLuh1+yiKuIzIurUBiGXIEyW9qNpNtnzVae7+KBTB9Y8L4iklg1kMPZYrl0gyKz1kUgrIWlnBZzCWAc5zXm2IWwvWAVCu9EhF/MQ8Lg6dzQO+YyzKK+kT7xmW3A5m4EqYUsslohwr9wazTP5VZ+k/PduEzEHVmYFESTjYZKbO7DtECxisMrzIc4xcXwbpLsqixOiMTcgPejmYzASb7nLIGKxNQmpc/50eL0YqcJCcNLA1VjydB3KKD+OGCQtU87IcHyFdXYFYNahkm5xj2mC8oTneOubXgORoWEAvchHBMSz7oYB40u3OMyAiMCt6vw5hH4/1ofJ163Per43GqyTLc0RG84EdmZPACLHDqX3QN5ojVXlX2/4nze7zWBlP61rz7tMV9SOhEpnBycymMuUvZjFEGv25P9A1Zngu5sDIky2L03QcuA47Yd0Ze781CHnhyRZ4tbdgQqQPfz5rCneOSPEQW8HaSBdiASoCz2xHYHnEd4ZiMgJk5KICRHbm5qCmpkzM6PWNdhBhWPzHpM3AOjIyx0sPopnzQxOsBj54sZkSINu/+0vvMEcGirgkQU6iGdGSe/Oj/4+49diTXgizBr6k9tVg6tXBq6dxR06m1+vrm9cxXaAwGg0GjuvG6chGZyYhwXmVm55i6TYgRdL5vsVvWLzkppYb/eDVTYgZ3c0WErmAkBJtQo3PwH3Zkcon+KSmDrZ6haRn+n2SazCgnrECqn/7Whky8QM6IX72o6qv8cXjxYhy2eKF0B7FzQG8DwlXsKBameck2n500tSvtlXj21VyUTtOjNhJ5yFYfoRkOay9ZOTbzO676rgRx6aopo9XZAW0tsSu4R1Dl+WwdOWGb8hXT4qT3JLierWalncRx+o/Zm7FmRPT3XqRFDOHHy/hk1aulXfHhot1AllLW4hp2xbkJ2uow5D1y8/56c8e3/2EZwNQqPw3DEcDpsn8UBNgXgaSN1kXxLDVZ8HsK1e7dmfQv/mYUIS4ccdO5V0tkaBbGFY08UNo5e4WCWEwd2LeWSSPR7b9fBQeAbFQOr6O+ktI3qagFyNwtepx4FQd8m0Yiv3D/40SM33jToudzfFSAURg9m6E5uoH1htC8m4htbvQuwm6bTfQ0Lw71UXyHjX3tO44dbO5hKJNcaGcHkFJygt3bi18HM8bSNn8hGYsyuHUoVGCVIWzpFSfl60eMvm/jXp7TK9XAx3gbEuPHlFT5aKHuJw/Siu56AFmWM5jTDtwlFsCNxQw/+OS+x3AtjmdvxDRl7d0zRIV8Bu52qyCacP0LjtSFLhUzcFwxBeYP1OoBzIVNb+R0AGD2Ncbt6ELnIftlxyUhqQqbz7l+PNKv8cHgWVGOq1L+kU0/7v0rrdX3cZMNz0j54NYXnbas/sdDB4QWACNGBiA7mDIk7mnqqFpIvv/657mD0hoDbDodmWChfnwwllBis6gETM1/f7N8A4dBu8273+4O7+JYOdVb35cbAz4eKLjwR3KTscx+xSHMvd6J+5sincHABSez96WpTEb0ozxuFODHYj4um03ihNAR5vfKYwJ9T3Su3nmvLYDo8RIzk6FMZMXsnDlPc2o/rjxK24gostjcJfnAxvCLTVdDLwDRBtP1qNa+y9QMsz297pXuU5C3o4sdSC7V+OK9A9RB56GP0HlgG26DBxMcuH60Jq8RXNslPHx5HzvQDv3ReMPYqouJ2B2JxpSR6Cj5aEDzdI6th/B0CwWQZTjLGxToEcH+oI7D3IQNcRYhh2j/ltA760noO6X2RvNB4+rOIiWZabgR6Au1NrmIvMlw83R6mcJ6JUgQdGj8e52Kxv+QV2YlKCYKjgS266HKH45TKMwId/8R+HgFBdXMVMVtL+6hQbrD1D7QLbAOMTTlkGgQPMUyNNb9O0XFVZrPblzGLxGZczbE0nNaSeajy2LpO4TxJvt14jqtgsnj/gjIszL5rpHfFkpvijSw9UIom+boTMspcsxnfBEp6Zs++0ebrBjfi4a7kbQaQVfkplkr71Nlv7IsemQUlpGJ6neqvI+HRL4ub+dAKHcex73bWHW0zw7OHrZWQxiVmwFw9y0hQj0/TgKBBBRs10dAIvJIdm//UFln9XRygOqEHNY4sX+Fb4jFAHPCIu3MCA/EBD0yEkfJcUKj8XFka2IfRnC4EuS9rN5IpFdh0sNBkUW27whMphRErtJe3EJ2/WMEmHfgEsLpYnb1fqWok+sOQqbAmU2y9IJBaDB0ht7nL+6jzTv6lx5u1gsr7Yq9+npzcE/hhHO5H/MKbcBAlM4yczQhPWyhk934vQh0UWOb2Omoviff02826KUV9E3ldM/x/aKYUY44fcQuaLfTe/vR3SmaQo/Ut4WhiyZbPzkAvog+hTAXQyYxgUA17hl1pSuZ0L6HN/KYGrSCfqFETNteMBKbGGYj7wh3phfQhcCctA7anve16BNwdw2I8jm1QxPhFzWh1dSjMY6s5EZ2dGyPfMwF6FpmWVN3CXBs27rnnnU9BPlO0n5IDG5nByL3zPBsTjSuKIGeQM4cK50YTrkg5iA1761F62gNxgRoNCgTXOpFxO0gp5BgPnoESaXS6T7kwBG89P1DDBhKl7HkW5G3j9x6788bsZUz6RlTpwepXWNCP750bDz9aga+/66RFZNd9Y+MJrus4xCk0crhRdSEX8JkqIwbYtL35pmyZ8uRaTEoFX8aANv03S3iG394pmlGtK6ipqQjB/o2rGdH1LR58SI2ZaeSIwNM1hiaJlU8R1PfafpSpMYSdLDg15qhRqD2Lq/cly4D12QtcViW529gasTLxcVnVbcUSSiAfeako3egdROlMHBFgHIOKKddJWzqwSB1wbPqAYungzyA4BEslPXDt6ZqTkTNsrqxmWik0SficSHalFkDLgUn2EiWoKed6o+T4Gmw2TR0Dx1WTHfBfF+ySkTlvWzJBTx0atYpyzhxy0addNlF7GzGx26Wu59lWf5Q7IGDIuXBpjOr9h4SpGciIXlfA2OSa97YhcVaoyGFJXjgYPsb2flCSR3I0F8WwwQZqlzNGjE685mB/yYpa1KrcDoT3YSDk3Ba0SBJ+mqNWauUj0Fc/GnjffjBjxjtz4B2N8fkT85IUd1lBWLId9iDi2EiRQ0XaHkAPxkNFDlEg6bbHAaRkPSO81Ax/UDSKF+kM2Ef8X05c6SfIc4w/wEqA2FUdYE0aHudvqSYAX3dMbJOU5qIcUPxQcHPnxWz5sfw+9wFiHeuZBWmGQl6xSmIuHtxhtZnGvxRIes6011Gp6f0ZVviYlzC1dzT3rwlpetk311IIdHvtZkv3a/YBy4e3d415N1/Er5Ku7R4INsn2gOde7/8t9MyW+/g2a5tCcsvV1V5aYJ4vSQAFRBNO9l1UCQ7Ys928NCcSUhPNCq07avSDKZn95y1fDz5mHUMzFy4F8FhdToXCjxZT71wmyaSoEqY8p9AcAlq+qz1H6QpFEHoUJ9LrlmQ8cN/tKvxbPKRuQ04yc245zHp/Ho0y8gEZvB0mnb5QTMB79wmsMGpLXy4heWfY4htfs1OiKesU+aWVmeKuULDag08FIewkuBvLLc7xBY/6UU5c9uDYxy73sfa3odyhRW5DatIJqkL2ydMCwZwCdX1DluME/cfU677159h0643UN8+J7kKZBRvG/6IvV31Z1O9j4/BlZc8im+gWiKmPnmkq5sza5dOezBHsusTcuecdquDCecLTW3jgcc/az+vw5+4qSCi4LdfIiJNdAjl9gfHcU6gkqt+iIP8TWW7UdxD6tIKnEj6Y0VRrY8+lkUhoQHdC9rnYG5td0xX+NEj28Br57773bBq8E9i70mgNowuesi1iOILCpiXwjR7NM+siFe+gXChSV/D/botWJKc0LQkG8ufJfkYHyibUVNWTpYxIir1fAMj1THhGbqMs3ZHSbpb3libvMw4QrfZSA+ENVJczBmqiPHLPG8AjeOoMIATR2zSrh1ro2HJr6EYKYOqbTclXN+eaai6q6X6o8AZfagQAvaRD9J/MPH1Xn/OaxWR5jgHCs6HKdWwp7WYPp+wqz+9TmipJb9NL5NsKBt9hHpsxjfIakFg5SMHgPSrgMwF+MIru/3ixYfhxKtGKz/f4Xy6ckIUyU++jjOGkJJ9pBLr7papdV42almOkN89JkcrpF6r8xlcobq3NyM3QwCPr/vOCBFF9/idQa/+LlLEzoqxCDKmEwdUMZL9yHNZNUu8/0W6V79xqdTdo86YHt0vvWb+lXyuhwAABTB/lHeechwKMsaFHSEJdkhKN/w+S5J90i/Zs565RJV94iYi+Wxb2PsyZQRwkDmaSrejsbf3Yz7WTYBDaXGcFKoAQ1hB+GtdqMN71/i43egRZC4qTmFIsJFAgq6fXOH3TIO++cmD/+Ofex8wenJj6eb9aux7eupnv9M8fWKZFB0a6Kq51raSyuDMN5AzpsB/WThpoVbjGpwkZMSUWawM8qndciIWUrunAFvSQPXsFENNXEAwQerE+614WePG1vRMC+nHz4Gc/rAY1IAAN0AxWWMfgNtTBPrLwXNbZ2NuAF919AYhLG3xJVImuIcyuEfLjDzjC5UykAuxT7oMkX1BDTEkgQhUM7XY5/vmrxCNgyy2irraEazWCPREaP1buxVlVhcNQVlV/JzqbY18JtqfpgxWHdIDfUCYBwc6ymd3N+1Rn4ERghxxxpAw4aHfY6eLaKihN/LxoBmd4H6hyy/hQA9GQ2hzeT+I9ZuWKRltOHJ0qzj63XENYMmzhSTZw91xSXNgrkuGrEBSa8ckZE+kSefWjMseQMV14Eg75D3UqF9qZJZnMWmrYq5OQUTRrEzW38TbDPqYV/7be/htti5LCwsH9LG45cuysb8gy04ouhU+7/0IO33Lu6uGakuJG+qFSEQyjnEbXHV1F0ARia41V1TYubirmrrdcwR5kGIS1TF+UPM5ggd6ZIM59hJVVrUb6iHdqiAywvaXRlZlAK4zEVgtm7BnjUpUj1vnkClAaCaom+CV6yKGepR7nwcuPKbAOUsI8E3ulM+MUxRFc6V7tNWmu5U8UM8u753U5LK+5wejjpB0za/K3dBSOQYFxazed4XZ9RIkmcbTYDpK/LiG60QpB7t3eoWXtUSvsnjzmUygPToyrnaciAd3cJ24E+8suRy1Lr072fb9AucOUp/ve7xtG2cCM8K4/iN5KIMD/+RFXm7OURow3HlhBCvbxUwu/S6fwF1Jqiv08KiGWGmIHbIeP4t0pckbP/rDC7cUWzXhW8pfnJYLp7jurA2Kfa2mOLNF7H2s4a4rYOvkL0xbHuEERieJElRg7nBc1TpIcfjwLBfuMFxdUac4WDi38PdwJN+1uhYOscZQZOEP3URyYVLFIxQ50uz53bn0FSPbDB+cFPXEhjTPIQZxH5rwpfgd6NQnfV/RY0erl65sd2WkRix3Hk7z+pI31AbnsjQUVZ6ayNXBuMN88ZuO8PGLIOZ1IRr267rK3iZmYTE22MKM8kTrzfMx3r0JkdmGclY+Bda+n5aWh/A67V/7gbKtHyGUuhKlAuR//nk+dKpG0gFGf5EOKSuiucf896jNrq0V+/FjV4QBjhz3kUJnrFJR7EIxFj5j/h9/s53WONyuI6xGOSzcN0yub+v6bPh715I+1RoU6cUI/5B6DQoXanc+JT/hXZvC6GRPkWo/t5DWtAN4oXYTGfBfrwjzF08WcptN/KMHZNJrEjl8jJFg1SxtdD2Dn3AaXxpwLEDricM3DZ9AxTPV2pv4PcnQLTYieZNJkt4Zn2tHv7QtB6PYjT1I44AlWSKbjVFAHO+X8aUvx3P8aMbQFN3j5SADeGCj1DjFAy3wgdXkJVjFKO/Y4HnZnTWDmBO6GmXcvj1O+KnfUYZNngI59+6Dqj4JVUoiH2JoUWwnJcj0i8cy0+w4UqayGUYm+LvCX/YPlVIxW3MlCOTVCdIUK/0v0zxXuPaVzLSPk4vuHijliW8MVwhmf1UNpZnmC+zko7A0+OwfCN1TTTUw236pER7CuTBUiglWPoeFMW3fJkUSVS15l9jQZotRLGWEHtjLJW5TkHqd+5nBB4ROOnkfoe2bLpwY24ElGkhdFapytqkHp9zkY8nnmSA2bXyGuKdDh5swvccPe2dVStJlkX/nP4f22wi+rhnx13kWLcn3HZ1wbz3h9vFYe89TV0kj/bHWbaTeIf1ZMk73T7AI8XC/tyqfhI/yWotfjspUm1qilD6uUZxDlV0eMrH4aKCo8yBLqo605tS/SVbkNq9yBBzgD6FK4DRsySBsQZXHywjdG87CkwpVEHuEVzIPp4YZonCIS35Be5t8IHlIwMStCRZInLv91lwq/N0nUrcl542fzAtihO6dJTyCIZzR/oSk1+3XXEybt5fydDT4aSeL+UHoZZbaUf81RD17hJPY1lNf35GtgldPW4+5mZHtMeokUN39SGR91NkUN7BKapWT+P3OxgMTKj0bHd/PaUTEdr2FR1zyMFaD1Y8gW7zs3C4ldNsgHKA7n5DCwJtM7RD0wuWf841g+cJVCxBoVlP4R7D+rBQyytugs4f4aaEGqvOEdyKJvumOCYThoXeMV8md08Kx7AesXaTfkVu3+2WJpVxdgzhtEEJ17eAI6Iud2VOSp9mOl5WjcAdbGPlVkQ68+ENGfJOlqXEvj5gzeCFWVh2rASPNlowzcITKL+tPNEVa1cA09mxDfHp/dgG2Xm3V8SiJ084DMfecV6+cWtiX0F07q7zq/T6Kl/Ji/GpZQeDD5dV1fVTVzOCMBS3H9fr2bGpsN8eo0bMfaKYXg2XxwUdxCs2scTUnP+FEcdo3MVpZfpUS17zN+9jaaqmTYJ1jVBuq/Vchz6MU0DPslIZpUupC1Amp+ppK2fZWExSyexVTigXx+SgtiOLAmeQ0/MstUT/goVyqPtY3wc1uJ8gO3yKRfb32O82VkJ6Rxzg2yy/sTP4J7jA8koNgk93OejH3EZJhSTbXLPfCFrimP61VuxAlfuOA3gu0oo/0JauHjfUIgFrMRdCuVE8vGK26l1ps2KhjBdDqIYFt7y/5t1kCup8fLXDz9qKLmUmZQ1dZ/sURS/OorP/T8br/IzHBf9t8GAs1KeXQySy8A2OUIK6qG/fFW0fzEvhS7krZUmMQ7S1l7GWU75RPGf4FOk6c0stemAYSqYx+H0RYmuIMrOmo/LLtJHRHaJZJ2i6kM+WheM9DE/0dL1AfM/1fs2f//94hW0P6+heP73/9Hf/d5vPPO/43vatkZtpIR5MumImsqL6h+hzUTcBjtEsfLgA3nQnFDhWHFicukWQ7ihNFjxcF/os+vr/WF2NZTWaAGpZAzQzgLGH//WLWRRU/l91XBj9RIb92YgV6Ure5xQ+cQEGV7F02L8v/wiBnvo3Gl38G00G1DbYcr4DROaL2iTwEGb9tpL8C7qBMjmJJg5NfsBZV+XAiDx/6kiAU+jD9eTzq1/ZCyrhwK8wRvDhBKDpLAnjZH1Wu8R/NnpIThP7RVT0C7m/dEq3jjoiB8Gamvl7TUREewQwx/sMaD2MHVeJMwwK0cZ5umhvIFADPifH6ZZrJ3EGbMbTmvHw8eFsQFSruoLiHkEY+6smE76obH42Efl+a/9Bruk/0CjqC13AqmYfUbVyUZ7ckU6GJFsNuCfWh9v49veSTs5/v37+qj43cSOtuD7EpGV92Is4pBR0mydUdydfLrvSOCluAY5CFBH72H0vA0KM+G+kEiK/3kNQ8XrUH1U4ceeTKS37xbGmmLMYFS4Z75e4fyrJiOPOgL1cBIb5lfQun58u06DvaC7AOYcewRFBMwdpfQZvzwLOvRgayvy8qEeHVDq6P2X3O0ehbPA7d5/hZ4wbz1ryCGOuO9esyT2Y5HLEJobWPF78GXYk1yprFcAhR3JQpklzN7904X15qW69yfkmPae/TlBLQ08/V+SzRdyJ61kv77YXJqgY54e6HzL9SZY/hy3f4JpdzXidTVHE/Xf4NVw+rSnk4vPW4aCNEjIcf6nLJWrz3UR1Z0G4sLth14+a4MFGKaI4X//HybZ5IdQ/rNOOMrnsezSOR2N+Hljb7ngr3MxnserHj/6uUdkJhby6sS8/BSoYPWojKHF97vVX6jY0fuW+O7tf1B3W/n5eQS6o1OMvW9PbXRetwv0tMY6NH2OUPfqU9by9+XDLAvzb+vKHPIFlRrXV0yvgpSDuBeoQnLmXm0omw2I3w0QBCLjhZn6G/9LZCviMqD3tltRWBNufXAXyYj+TPrqZYDCPm/Umh+i8HgF4QmMIlHRtEF6LN9LOgL+clA85ggwrWneh3cxUli5JxOmaQ/tnxV/RsPQWTmgtEPEN9GKr5AG3iGPq+IxLd0Wr71cf+TyBOeK+XnYYgFNpSOFxKw3zszvWI9ZLonpD/JOCGjK9i4EgLFTttWlCa3TJIBAIjhem5zOeXoVvOryb8VGOKF5vKGVNt/bUfzGVh4zdAaaTbL+bhKhhnsN4G1T46sWbvkch7mkkV2yewa8iACyBZKqAeiPP7EVL5Ci7SNJ4NqLMGqc406lu/ZEqGS2bRAhoil0pwhzjT1VET9KaOyH3hQe+FhOeX4FWC94Hn28f990Ht8tWKezYanEjctcE3Xn8IHBVkYV6xh5YFEm0TaIqraiZ81Lurk0/NaN7rdctRh96RnEyKcIXqTU+VhceftYiy99+lzOYEcAVjfumRmoWGXnX7KU5ROb0U0qWk7t04kvco3npO8mt68yV1Q2kBReOHDh6rwou2uzZMv4LcJN5ZFO4qhcgRj6qO53dCTw72tco6/R2RDsSIX2Nsg/+0ucOXrBiCCMvzvgN7cwc3DXkn5XOMrOJECsKZhoqv/jpE8982T6HD/HKLCr/z6i7obQ/nU3nrvx4OZol/xRFOIZpnYGl3Tdz9pUfsM4Fh+/WsmWev1hIEpZl7FNB3oG0lSuN/by6Oo4/P3o8KeA4JFt4kqGLdgmb+qBWDGNWCjcE8EdtjynT2fRNLMnZtBPst+nEg48Z+pgxn7k8lwN0L3rG8o5XBbKiuoflC7mA5FM6i6rS/AfgF1d0MM/p/jnQ+lx0GfQYcOEnGGYwCCK2zKSz85e0W7UI/TbEKsGdrRhKwgm6H4ToZIRZoJiMU3NG8kdt55DDjuDMjyIxri6O+sDmEHwnzyH+ax4x6aGONLyGPYZSU0ep6lHhIT3YDLyq2FkSjh2iOqX4HD0prjPffMYrfQh6yXSNvFjmSiS6pH9sFGQocPiPnDCzkvrGoJ/LzZYYwHaV7SL53EblA3y0X7mTctvocPtqyXvYhztV7ljybR5CCG214Oz44vYFAwK8DTYs5oK/LrYAnhTNeRWKfK5IKvYwQYMVp6JEU0T1PmuV2HhG+/DPrJM7MKDJJmYtrNov0YkvEej7NfvKc24PX4r2RHQmpmL0Kp0UM1jmp5Nn247kmHbYV/RsBXr+PjVaUJszdXSroPt46cLU3xDZOsBOGC+y4KtThjfFsVBxqDoNvo6dpIGxGv2hs5Vo3TkCWkuS+s+bWW8v8068N6KbaVtpI3jB6G/WZAj7ZF5pYs4F2E5/jedLhe4d+A8or6oDdgy2ucWOz7SU5/BeEfNv5Q8JgM4X0HZXbWHz09RzruBrp4Lb2BTH3vVGxAZ1I2L/Ft4jzfYG+QXZyjWPsbYaN01Hxu8hWoBHbucpjexRAZsX3GLMjw9HZala1yvjrl9CSE28WLVqwvRlwGYcAXrjEowF/5bYL2AuRJRx9Cw+kvYv+5/6rniXnrUpHlGRFVYRGQKZY5RXGzSW9+yb0zge9N5n5Am4AsAoznlDDkF4LJvVlWbHpOGrK2uwFLda2IU8jMr4plCkRemO/+uYxKa0jZCpX5LjWPYGq4jlD+GP4Q0nDRE1/g5pNLC9FWsxcb9CSz71Z9rSljt+FGzo7NYQuhgum2E/tvmTroqHwNg7Z6GY4nNz1RqY2adVCy1KPxucmGzbp0b1NuBFFLBOisVpPtNBAs0NGkXk36/3410rphpF2bDNHdfGW7hCsodH3T+mkRt014EDXFPnKenoyuPL2MQ9A3tB0eBI50k/eKT3qBFaQR69ixymk4P3NI2zK1UduYi3bXvfs3aHIvQ8/tBa+Ohj4oH42O7j3R223W5e89zmyddVjsraGmkCUGVGQsCrKliaitoCu6PWL0IU4JZ+jwvJIHN8u9wqKH/xOKpSY6tDXJgyVk6Kzt3c9WJVgr1yeLYmkb49dTOYPFfokFM70IG5zhqob2qXApNjCngf5y2YcFnrmSAbEbhp6O+X9+OMKIO0JMS8kT3P0gr0C5d9mcs8g0yhR9ioRpVgYlpoQzYvsa1UBlltB27h9zG0gD3IZSs/RPd7zut4PADLUsT6kMFSwKzi4RFiweuNO7EMV5qUiG8gHhsm9L2aTgn1442ThqMePPlGeLMmk7uxaoNttNU4oSBOJ6q0s6sHYC9AOivc/rkFFplToArwacPnlPEHO8jcBX+c+F8PzMCzLIQgRAqji9OcCRoOZWdX3eqfFIujr2tOqlR7b9RgfqpBOWhtzEzjB4/dyllUJrlJqIoh7Q99Q4treQWwfh9CP+VFQTHE2Co0RL/FAjC4bqxEBoO6B7giOY6FLuC71wfptLKmCMtZyt6tf42OD4JENBo5oT4TfmQmqF5mMN7g1NRdhgqhloUiu/OUwTBAS5rYaFEWg7MC+XBMI6hflNlCVJcGzngNx7znS1Hc6oUO9I56dPSiy4+V7btq+UJcXPSkrYz4DH83oAEN40zARzdkfN7IQv8E1gfOuKUlxzyO99MoFMoiYlvzPkO66K44FiUaWDZeK/nIv9bQma5aOK7xk3KnqWVtd7QcAcqWsvgU1vDWrh/ih3wgOwjBSRDnsVopkqrWu2mp6jwbAByjrfBaLAA1kPnqb9AWLks4uLGSFJCTjpiJBFQOWsngKPPHo/gGpTb2b7/vHxNtx/hKktJH35lOxqLmO/thCLply+WYrgoqNCWnCwh09HJV9XMy3m334WP2OxlVs2N7fl9WoSNx/rGA4Wg+W96VJSUK+CPRXfmifh6FevGJ9z0fHHFgl4tiDTkhICUFUeKtgD/Vzhop7osv5PyzPoTDRe0aP5VIAtwkh7jywxqHAJp2fjjqetgfHTa+Ke+1BXg5wFwuFqqpk5l8N2YjL91r+tG4vTW0uNMBC3sxCrR904j6+Z8vrFyKIxKxPUHr1HWgmSHtd6HzVphgr+woNyMm1XfJR/nnZ5vjEU9qebFfqVU5zJ/qDsOKgAR5gklcxeCn0jL7aRjcKruO1Aja+oZvzQepQL3vbNXjp3AdwSC+L/4RS5dSI1wYVO/cXBFKTix6n1rAOxRgJQRsLwTC6xI1oquRN9iUwAXfGVR2GjlPlOPk+sFysIO2Wkzvc15xbj5kQptp+pPS8eOZ7qkNkVsIrwkbl9QEd4ub4+aOwxP5rb83U+ES/96E6eF+2hCoau01cb4mkJtROYiA0tYKBfP88Y2W3hqgMrfv5Oe5I2ue9jL14CTNslAAJ28W4NQ/Ro4teo9QvdgTP26LOwVvr0WJJAnuxbT0z0LkWD8JH7c8KIEOrd6H11kL5T7G/q/QxvV8MYkt1ls28az63O/fOmy0QalevyCjE6Nsfd/tBJVW2SicIikO2KpPqfUIfFkzZaNYjk8eEkmhuhWzVxOfWCrBiDVdZsq8VuGyMBI9Tl/1UB6lqCbI+46HbMfmn+eDWNSrUs4RXyq/v2/ndq6PB12hhdnMT1vYimPHuUFdH7IorBPcKsFem9vNdv8fvtQEJZ6uQk70Hjey+MaMNu1P5tGi5TxPYnsBrtDQgK11QHGzzHEyIAAD9gtjUpO+g8GLYMZD4LugY7WsPGTuzIBdfDLHaI4TU34GSjPf6adFfb2EQViUoUpa9hHtoaeCDywMEtCSNU3sWkk1fXPvR0pDAzl2g+UhB9HB4+SDvn93gMwuLJgJRAJkW3i7o9verCI71d7HyXUwwRHXnrMX30odU6hE6XgSXvgeBO0gD3jrbFUlKTao51tACnfy9WnXJ+MQhzUOVPD60n2Aps8YMkeSkdd+8FSPI097cYp0a7Fv17QpTSAxVQ2SV75wgbi+GT+Oi6aEleS49tOIh25oD/NAALe/ddNObVMmRG5voT6EyvYkkwG78rU0D53ZQWfkifBT7JRXivySBY02wLD6BtYC0tkkf1QwtO8Ud9zvjBl2BZexLP3hRLm6w6Y/uFY7vzRZUgeLoWLyziyT7Nr8fcXSh1kb1CBAzoWhcsiC/amVvcF7jguL/Emb69FF+u8JqFn0HTdLrUWA/m5dmBuPX7/c3bYPzO3phtI5HA6Of2lQePY7kCvrM15DX3QG8EGSXItLtonQxocQWZRidgIChNTsga9Aa5PCkv07mYKPhpQyGzKhOFxECaFj+5kBqHT5/bX2snZnduJjAMnLSx+ksaTviHwiP3i7dGaoXP0YvbxTYn3Y4neEYrws/9YJHU4LgaGAdvTs0bcZhxMXqG6A73SAPJ/boDUIQIAlERn+1tGjm+2QAY+bv6qpOVu3xp1utUetRCZ39l15J+0EYZHTqAfuNIfaL5CANb7boX+iRyCYUlDhUQqiBfROktW9Df7+FLfpFasn2GO2xY4do0hyK7yZs2FiArIRrTqkvY6fpHI/hTLZRNKeAs5hmvOdLS4pcSZdRDrKtmO2NI4P9WsRBuFdPgSZP6FX7e/ruVzDGLTZxJ/kFPgTyd19ncwpBpcOmbCHwGHCjtXMZqvuaMcseJ9DDeexepeiIFI2KeqAsO2SKmDQtk+vOomVvp/hS3yybiaZnKDWi9XBEi3NBjy+rEaZApRJjIbg4pUoxaKVfhC359WZO0p34Dt/Bo8NDDhiFAjvcdX542MrLoJu0IMj23DfqWcM7cacCgmxoinCWalcG9mu5cxrVqJVeNYuUOrdLkbEdZr3NJVsccbBIl0u+kHkirBJ1oB2Kq0lzk78QWDaD5iO5hXPlznd+ARgz4+qR+1nYEsaGGsyjwthyfmHCKYodJifzYRzFy3c0jhd+bq09tHqJxyy7/gx+Dcj/lJJRoTY3E8qUWGOEISw0SGnI9/CNIljckAyQ5VaXhj/3fAQuKYy4MQIuN+97yqK+MVbXtZBRFHxA14PkpwLd95SZ44pgg6IS4JHqx9nMnflFfqn37l/7Np6QgYoEhrPPh9AHWsW1FN9fXZbfHz4ljUGuDYO8P5zNIM9ajUvGXc0gi3b6XRi82q/uY07rr0+QwINrQuBwWs1SmmFHJfhk36LvCvJtWiWXg4vkVp3lJkXQTfFhqf7ZdHFqN9OnihBzYx86Jk3cok4tyFlXEGUzdCH2n9WfPjBxeWb9ttlTAGf1tOd3L+Rby6jKlJQ7WNmv9hic22cHkIg5TnMuq+dkDBfWF/2Ff/b31TKOZsJ9j77nts7pSBRUdkog+LWMnUlYhjlq7kTzwGFMDYwBoI7BDcR3LteHQx5nDmv0tzB9ZWPFo3G9B6m9/QaDHiT5ZZBL4sm6ZtsP+SASo6z/YNt93zE7FqBkrfSkNBSeVIBcrmtCNu87M92GNurltLSMhCOvZgk/SazYvqb751uwKKOEkQAvhhdRjsmmRyNGeP2CjEVO3nfNde74QFWMaJPrhfWDHs2YAcIOKfDFw3nif37NZLX4xx+Ob15xYkqORUlbtqr/qtHmaVrPHM3I/L3rM7g1SNiHebfzkwmezYYBQ0uVY5WvseNSmg6h5lnkrfcZXvwk09xPnc5qXjskzni9VXDSDhBp50EEnV2E1yjMvKl6mugc1CbIDj2KTGqgDepD1aN1SDjLVVPRc/8NspH6jibepE5wmrSo5wrtMHrELCX/is+B4wAySe/2W6CCPPALDyj9XKRHwhQ14lnmkLUAMxQ0ZlZUGaoFr1LsPdwOA9ZSZjqWW8wyrOeRbfuDRNImhR8VqT1K1lqP7kuoeOdEiAYhnj668yK/qvcoculD294MQBJyjrYMIkEqPFIQbwWzrN0FolfRo431w/ddAMl3wF4FNPfNz6eSfSdlGPQafp7NytEM+ZUBJrjyoTLwn0hE9pK4npVS33tjb98G1KwgyPgcehnULMFzrzgM13kT4dXYNyL3jX9WZPou6pf+60txbsIF/lYTohW3DTw8lw+SnRpyHKYuxVML6Orw8+4C2TZ4Ppw+8W0TkYDXxghvwCnMhMhbOpZv55Ix3jz7vOzIuSWgPpCRJOlTkeL2FdChl0HTCsFxIOVKe/Gy3juGXxAM6Z+t3xcHayBes8ZHuYsNKaVaa4Sm0vn9dyRr81ct2eC72N8bNcFj3wUHU7XC8DGzsMIC4+v7NAJSaJGwfQdO4ZBV+O5dcKZ3sweHc/gjWW1ljhIe9OSX2Bqw5s3PGOxDSDnqXR1bUtm+EtwQcAWEWeCWm2kcPSeFMcjcQIwRMZQYxgyk/eunczx5oRrMqk/PZwVZ/hXpcPgGMXa7Lyjr9ddKoYEOto7w32uGUFmTuLEIKiiXC3NT0NwLDG3KHuwHLu9AQ187ACgFwOMEEbTB7N9TBT6Z9o2Qcr928F5KVKs9yxwQMgHFsAwdd2h8/34RXKR6xugDkkkRKd2Gb2JiqOA+ULXS5Jbn4VaZFvGnZLGelf2dRWc1e1cPQQJEx+4k68ewOcMYeM6+tQReM3CfPQLas9ms0o8qzXwXJ/nfCMG7SUjKK8zL4iP5ZPNlhUshIg0bA8upazbDIOhN7F/4hW/+HHMHZhBALo7/+JsLUxxB9Vbu8JZnz2XVOCPFprWPTPLhNJQtitupJPuafEp9QhDz1TUQ+APsCkt6ydOBo4jmxcW+h1XMHSXUUt2kXd0QuBbuuSCsapfI6JoLa0nKbgNKQr/si+XecBydilU0/TT7fR7/QOdtHwA4+9JqU8g45mYuDVxvDXePaYzby6pb9BSdKW+t2Lt1KhGDWkn3pjEQMVdfv124mJYCFgY4sV453NHYJ6kMaDc2D20q/M17MKi3GlIMMvYPBIVcGsIErXuK95He7xOZ4QGOmXWcKrPQKaI/HaS/ttbOpZNKxNsI4pEiCcQMaced3M2gcAgVXjjW/NKqp2qtkPFTicI5sXdUruKiKQwazw8hw/WPvgKniwh98Yr1kV7uPjZJPiytfCjB/gsdFsVCiJ+0XEjyK06fE0ODIK+CfrQPo3r/2jyZJqmliGItoPf8HMOjbtAjBG11UqbffXfguJro7V3LG8vEV2nCXzMD/asE3TeT+arfXD+hUU4kUMIP2Qc2X+XaO2Hn3PPycFJMNSi7d5CE0JRjsSU2E01yUwJ86nBDbskBfb0UEJlj//CXttSKJC5J1oGeXe5W1+7t7W4RBV6rn6CPJYQnm/HYW/eiYX9znrWoudOWwa4/hIny74y2obXQXIKGmUx/4Jg3j9D6qz+FID1zKyhEZQqGXfgFsYy2fzy0GNrwoXwpDZprf2XtSDNi3hj63QNbgw3l5nzlTMI+Q7iPYvK7EuH5uPvtvYV54Ln481MNGvaWJNTzQ+Q0YA2yUkf8PJ9l2e+HuPj9hyb59iJWtyewj1ze0lsn8mP6texRPklTa9KGLjk7Gfj+2AYDfiCRF+1u27q1KmTkfBaPXalQFhFSi9kkBzL5dtYWNmOacbcp4VPMum06utshq+50bsVeUdks+SRF5vJrplydOR+EZB/6hOmtQ3DV8dunP3Jzp3JxFSa16tEX8vmXPw1WFfCIv8eP7PBgjJ4MhL3Uo9QBOjmJ6tlhqAdILuQkhMaDGjviQV039n525K041xeD7l1K1GtUX89sF8onkcwNemjaJKRIezoGyrA748J6GCvP96h+Viy/FpCh3fEx697WWjV7smdKZ8LbKZFYPba7Fha/kpr6k3n7+gNac2Op75UAlFATUfUGcd13ipJ2n2vRPc5TFKXxdJQegMjb9EZIcGv4Xew9nMTelLU8RoBsBcI16NCnixpdr+YEhOvNdjUpFLTXyrHCpQ+jNHZ9/+V/QmHfWE0f7ytE6WiR6HoAn5e0FU2GQGgsUO3LfnD+3iEPQJE/NKIlIR9iBhotFn1Kbn8cXaG2EX69rCb47D6T0NGqA0tBYAOeoUvhCgD54CA94qF+9QNm/HiZjp2fzfxhhn1rxWhE5vji8BsfbPj2XcQGZ0UazaVn8b/xQxDXiCiBM3ePr754a3MRI0oKC/pI64fTnnn4BeJ3gRYI2bcBZLmHTQTx8r7C8nc23ToRK1Y9w9HBHyhKT18Q4Df8fSRG7gPFJB7XUdFHRF6sFiJeePIBRpFFkXNPUACGcBONnbm3WXCIZmpCC8zF4D0FUILe4CS9VMGWbtT89tSHNlJp/90GbH6xvlmL9NdpFl0PAmdhR/9Axix2EP0Qcb1q1S1O7Pcblv1twB7pc2xaHwabF1Q0SMV8U2j9V/RXLCqQ1wC/w4dlWCXpLRSPzVp7JiPNOE2G+clkcAbrnGXBPrT9Gs2ydWejBV0yhNGI58gFvd2mCJiU4qMe8u3tyy9dkAc1+cyc2a9u/2oVPrTP74KJT+29HCS3VWg+7nyHSkVHMunEivsBau+/KmrcFPUmAU+Ks+mUC534FaZ4SwiqqIJI5tS8+Vmx8feB1oj6TIAolz852yDAZNH5X5M6kt3c8dWw5IZeDfCT7kLJxsnRpforTl2czIL8kPvOj/HkwgHPfaU9spjfXhHmPKS/ilQKwjT6Tqo0uPHrxChA3xjgFkeH+KF9PYQLiCINOTmyhbsDgFJ1ea9cA0sZVUDiDgdiQV5+RlML+HETuA2S7yZIEGlFJJouTdXe9r2m/noUwVacKQYfpJMoMmY+Ao1QdA+U+If4dqejSiO1qdS7YD/LtZZtuA0gCrWGEYhr6Fm8PIjtrCE9T1mb7wtVgj/RJ/I4igRhViZIEMBeYCbg+w84TmITmfwo0PhQPuxGklqSpb6EBygf+h0vIOxiXkZVPiEI1O+wt1JAjQy+KMkC+2uW0kE5zofH1IP4aatqMLYJNXyfZhU/rFUSJgzCoGOw3rpWkaijB6VunIdBeXZMOGmUdA65q+3kzMI1IjX5M/FQm3yjTG0ti+RLksdViSgOj+qdGzV+HWXz1LPw0ZkvfnatblbadRlM8muzBFMriBqKMjHNNcPNZNFoSiCNAQjlxFz1K5kGTjMbzvFf+diDJSdFhEYGUTsvgsLK5L8LC+tOyuo7oPJB/O748WX/igOJ1YBAxsb2Nnz1uKI5Rzrx4beKBYMjJX7zw+DVa1Zc6FGwYKRcurIbmn3ckr0De79DhzyAO0t49GI6LjrSWHkroyXUSTJSia66XdFEpKmSptQbwjZ816hRRVtRNGGPCBDETs4Rm7hlPeMlA3EqvYzRBltNWqMUkA8e5PtFfTh0GkBejpUy8HMcgJtLaE0bQIqwS5XVOmqYncuEwxulIPdommcGXIzJZIf9ffQXYECB0gCAcR6k0ZCsO+ExJeb0h8fycTRuhSqE8+ptFIt/VeyeCsz91n/NBkNJUtGAJ1VXwSLfxkSIhWMIhMhPQ2v0IeBxQpdNEYh7WUhgfqDQ3OQofuFOvKv1WJrSz7eRLH0yCexW7JzV6/OOXrdLVnAAv9hlTtggeSy+ylLY+RLj7uV/krIlghdDTkY9EHJmkbJXAeEX4XhGjz3wyut0w60UnDFGhG/h9CwvfJya2uCTBaL2TWW5p/15huKPppTsK3UcCUsG2J+YBqESwWofsS4f3owUIZwlrSBKo1Y2vFc5v05ojAEsuI2XMPBs+uTfdj4glkaKjh98eWLlhSHWiDjUwoyMkKjxGMHhCZxg7WCrQXrBnz5ERvk3b//9ytyjEeUX8KPYhLi1TqtK6g70yJgJc0UV2cAzLB8vooFNEss9/LksfBuDr3rwvpN2eKg/nk3uNfCsQxMDoi3yYpWh9yjGv33ZHmzMgzWHrSrqQhOO2FJeZnLqII/X1L4i8u90CryBveAKVj+GZrV+nC+NDyI9X2sgL8lOjcKDs5Tz4cxXqVNjaz4S3FgzuYPmnFTUQZw+5Jz5XBYcT89bY6o+98qBVRdZya4MGowKNO/5jO9wHh72r4MrB9S+QdiE24RcavEgCGC8zUCLfSCh9r6ZP9JJ2kDkej32K3r2k4BCYxWWrUGJHyULpaHRjD7xKGVywfOJJ/7k7blwe6V08ifN9hmqRLDfb83xXj+fq664cxcpv6twR+h8o4XZmAh7P5pzTgidmDDv4QKfrpV5BhYCQTWK+iE7uTdmtRvd9UwcAGeY3GPTfi06QMNrCQ7Uhuob3LB57MpepCd+oO/SaFZwRypvEy+WgwFNd2PJCRuYO0cVZL3u5hU98LWGjNLKSo7xq+y9DmgNm3GE+8ILtGfagiJuKOCjoBhWk3Nwa64dXPBCGm7xQVNKftuSq9xccLw+BsJS5o6Sp5AI0ZAY+9jGj/5+YZyQ9/ijcbJmXkEQA/A91Iw4Fd9JGtVK8XnTamUik6LyFnZa6b2YXyYbNm3AaPyO7/t3KzVM+401vWTjgNJunGcrKCeOSl/l/yMXW92BxjFLDGJSyWKYjufSyLWgnWtY4WYv9wOInfZ+Gb+kVP0GmBD9E+ER5nwoQb2WoWgvRhS1Z2ndB0l8FbCKOAz6GK2XCGLg99d5lfY8HfOxvy8a9xFZ9fME5Bmn6xT7L5vlQ6uoqiqcXun35zYkaijdK+mRMZyepFbJiW0eLxGsgmE+GwoLOM3pt4cSU3O7BAhICRbOsi8QrqA3pRIT5ZlsxU0aav5C1zsJXLTu2uKWVZasoGVSRXSziJALad60aXlLLgE1cFOoqfYA0BcoRHU8kdqHxPhaomtYV0O8GFfjeBojte48yDRW1E9ZbTtAo11mHSCd3IQWm+1HIq0Q6vnwE2S3BMWXo8R05A4/UT3kegn295FGKWRbk62pCcYHPPwn4Kn0Dm0OtnukDCMKxtejfnVuyQyDSYCdzvlnM91M+AQCyGnek4sypOVlT+wWRV9xGWJOANVYvK8po0kg5/pn24q53V+8VYlLrMF/VdDuD7zIaPq2A4TX1v1BPYckHZlHx3yqyBYaa7YtKypbkxhSMpucExrBWsN3Rj50Bfn9xzh6Fl33rUAJOt77Z43Zhad+7c/26dHjX0h2QG+Uo7hEy6jweSeNG0K01+t6yTgEdQ3OaKhzkrTpqhdoOqIuLrE8m0gIKYhnuwTIG7d9qij1ceH6dB1ys8YAtICvPao9BUjEod85Onw897gO0OEMBSvWyDKrpibDlkVd/4pMhShQ7wA4EMARDeAx3iWM0S/i4BYN7MJ/isu/ocLiv/Ad/93m8887/rvN5/eO/w3vmnSgoHmCpfMbfivCnrz0ewOmnCrq4FWWy0duX2X8fPnyrPKIJmtY3MISVu2xqlWXzzeUA8hjBlckRbZC4vmfLAcBw+IxjLReeXAsGwTRwkjfPoDVUl/Pn4D6Jt4zk5fMg6+PRQBfWYZ//Uue8WA9n5UG6/9ifmv373j2/MV4oB/t853yGfNzSMCY+edH/yXP/oVD+vPs9f1tM7ilHQyzAl//Vc/+hUMCz/4Vm/f/8ey/4CM8Fri2/NJtdWXXf70aHiQEqwVM4sOBmdy56ef4Ny736zUVv/0YAMRvlYIPI//ch+NMwocvoi3p/rkhpT67zEHTNqGAV3GkDeU0vm+wtGXE6qB6X9pB7txDrZCNXBKs4Bpcdf147+KfBhAtWt5BDwFUj2ZNJUOjhuzSqEAO7w/mv4BOMLvPF3TrpEOAYjp8+ognqoCWwGcaC71qgY11KHC3ZPbes7j2xL2lm6P5io1F05AF1oW7JPxZlaPo+vPbqSoc/NpJTs64AJvhtagnw8gLEjd0ITknuXNYKQGd7LhYOC9JKktKehng/AhBU4UMDIogdx/fTQabv4CO3Mz/KO+9lpy3knTRp5nLVsCbS4IkvCM8cTMB773H0x8slNROGrd3d0yf2BW/SkUQBIGVuTK/9JM+Esgi80DHvRmQNnaU33BM0KFEt9YUPkZxQMFl0ADm65YXsk9yH0sDDLjTnLAv6C3CdrTXXwbjamL8XES7BdxW669r/mb7m7BsPr8/Ihq330U+KGf/RpLsypcR3fTdRfOMl00/tVBkgRHdCDyZGgFBY9SHWOl6v+MO6C1jtfrZpQwYVRClvz7h6wuzn0suq3UneKcTvMxjm3Fidob5oh+J37tHzIfUIXRT4VSIR+1biOfymB4vpl5meCGjy0hNIcBQ1J2FM34DzKwIJvHgi7A5RkAbIOvTZmrKqYD/5Mch0RFcWfcxVvK3lUcl+kGN4iRBr3vDCgwjZRQFcx2aR0yGR4yS8lO7kdo+qf0q5jfjvwDmxbpY3TOc0sYhWFx3A7ZSYD3DOBzTzX8VsdVBLdgqXZ0jWEkt+Db6tM7vUWQdxTv0oeUJfJFm+UY4cZrBs6wsXE6kF4bLB1Rte8ZnLXTEPd3wFZ7A55YWWCO+PFv1DkWjveEOQDNnjT1BU/YnMzElnm84CIaDaqejtjUU2REd9A9pUA6yDSYRKMDymfVEL+Fk5Ly4TNFhDfbWANfJzyC/LQytD/CKMa0NX5jnIVY+8A+WNalt092I8bKkqZWizWB0vsABfQLXHHMi7gZxZeUAuTAh8CqEB75EFTJCDYxMXHA9i6bZGQxmQ354MkQrGbuFBaoza4DGCH0x0Wgr9VsXZvocA/wJ8k7YcXuIQLpiXiiqXSrRqQOf7QGPLapd4iIFuZIMcaG/i+ovRWQXC77XGoOZOarpsm2HvGAt/gHw07Ozn5hrRq2423PX72Tb6oJwPSb1dj3os13b++s0W2LB9UkLZfd4yK85S18IXfq6IO2Q5DgL8VTubVJPHrN9eZq5OI5RxLmWohhasdd9t5+0CtIF0KneerztAPJgKtV+we4HjILQdpxsX4J5sWyscY+bz0f2pzSXuQd7JJApS0sSOy0K8eCErevYrdyt4C6g9PfP1xN9RiS9kaZeQJwLIAajve6ex6OWX/xIUjrz6XxJXF0ERc4GrJWGhuoeVY/Hq5p1/fZ3lOoO/PC+pYc3G6jTQLOdJaXW0BvKRLeK5vUbyIW4XQAJ8wEfr+dkm/Ph4sb41eNz7uFBQ9/N1c85lzl+uts/intEa1a9q2Dvc+FNd9GlyuvmL1KiAeUJ6GVs+gnWNWd1raxS6lMOPGQqIyM/ggmJXOTU6/DRFfMAZiOz6okfF+NrZpunoKmiAkI3vokRenlQizz1wPkjw4l8G/ndRAEejS+RZimfey0n8c11u+IqRVNgCMvYIeO/hjicbSl/XPIK10Vcu7TRXyaZYdjtDwZhdvpR94tvrh5Jc3VH/HTxWrybD2RdakbMY+5sWWkZB2QHze0ZNTvIh2kUVh4+gRcBf0VJif7Zw8rUbBvq+xLrPc9s0Q6Kb+dirm5pHqAMnsTtSH/HOIcK7k7P3T6UEhiEbiBx7ZvulygjhdItZD6jyhqAgtTae63SxbrTmM6w4PMjquUg+W63ivQj0dsQ3sZH0H6VIcMDyYu+MoH7t24QvECHagNKeSu/VKgM2r63jmC90pFKU/S5P7ZLKMpY1I1jHPYtOr048ER6NL7ehcWU1NCtTqY8LzmoucQ5yLcPSZ0I9No/1w5yqoCJqNP2OvNzajM1Kk6lLC2crNyZzrQQlMEEFFQc/9CMJhK+/N79/ldDUMPc2YEne1TYJWIu7TBDUvwBsSz/9dQWGC0C/nrQdrzHjjnLPRSyhrR809jIJY711w6rI/4MvF+1s3aC3ggiV539TKfj7rAk0FrT04XeOp7AwlGm9YGYTjP0IP9k/am2dg7bnuN1dtuv6pn8KzivDc3cfrQoBUXHPuLXh/tkbeDXdFxR1Vl+U1MnkXMBSK5qTa+9t7kgZTKzRdt5TMxts7wTuAhM4Bf78EIIHl0qoxno9PB89qeQ1Hr/undtMjvV3K9MxvI7lUr4m4PIz35pG35W19kf1d58QgC+deyKWU8CHb3I7ji+bkCKucKUGFx/t4ZCClrawydQeMLALnfvHkf8+NOYv0rqmTtPUWwFhR4wfWkn8r1bwmVyw0b9Wt54z4XpnshbvdNrHz+Ft1E8J+iFhUqYI0ZuPwE/XzcLamP8kAkvFYzJzRgHY1QwtTE/OY+NCzrgXkL80u3iUpNHjNE8BeqUD9c6GjeGgJeYBAMUNkp+mMTRMU/BYPP+xXz5a6c4zet7gtQzvcOJFHLTVOene4nSEX5V1gUYp3h2zPDhNPUlWkQSY++Nwt8VsztDHeGTXBAc1nAwhub4eo/n2FTOMSCyssRljizbt/bI4Fk6Z3wX8a7kgruaeP5WayF4hTtFiuXbQzLGxrqsBl+6BOy+QqInPGt424rIX7I76UBCEtNdu5p4nwwZ+B+2UAYZf/NFmRPy+u2DrISH+oLO3IguFUE1b+qA5kMeXN/PvKd5qXSNQ91Gs1IPkfbF99J8PBDIAjEXDJK/9psFRKQJHb4n/Iq3lDZhUYj0NCoLgsBH/x4IoIPZWLkA25flbDz1+tnKxKiKEKRMZEM4Gs8s2+ZLzroboGnAszdg3Wa3VeC3IaPHMxVH2C+Ks5F0lIbVfVDpmLCuv19pofZPzBhTzfHw7q08Cvbz9INYYjnSY0fmW0M6iaauw0nllNTDaVhEeiHMhXqtquwyuHgIajBskndyMTza2Ze+wFqnNJeA/TfQ3CyRI8FP4Qs9vJneXwLgUtXPFSIvjg7OJDhXuJU1jf9WJIKjFObxXmocEzI7oW4lXNyKJAnpmvJ6uL0iSCbSDaJvvu2c6Eo02hyX7eaYC6WuWiJaWNI3S1oUmDLhI7NOFRRCHCDaMn7zL6d6BH0PcWp4wB27eXqkByB7BWGlr3xbGfBbBXMZVNfT8ZNR3qIOJTsgWEACyUIEbJpT01a1kpwVRLQD2xKYPUAzqChFOm7nvCQ7gJxYADuCbYnFmazOXFSSCF7K0sm97ERFItA4dYbEllLweGcnoEYgm+hMppSw6D/TZmFeXwveZEwbGe65d+0iDhbiwu/PhabfKLq6woEDgVkMiJM+YNviVZ1UNVidIf6JhOYLGFCk/YmH5ChXK/beFN8jkW1yCUWXX6CQPBuG8oRY39bTJfeyT22QI4gCSwjP6xdQk19kTGvw+NO1cYFnqc0X1egbUmbx2PNd/GM+ZtlncKJzz+nU2u/QmpisMqCSt663VMRBwoDPuNFKwksN1UtyNxB8ofAe82uIf5XcIuEoQntje6gLnxbdXSydknRCkarjuuwRZOhMrzMNVP5dp7s9OUA+nm2yJdsjHv0g/EZ/VGUkegBzKjotW63uUCR9YbTuwEvRnqTWDSdNA3Rd/DqFfBl7CQQMnuc+0clIx53K4+TXDkVZjtQWKN6UPbPZpogTq1Q02+Q1muAGIpkKrAZaLIP2asZX/U5H+xN4nOW1rDua2vf5NVqwnNEay/qdMTiIqZ+e+fhTockkcy/VmVyJtT8XNpWUtr9/ix1obH4hDRN7oqMOvdt6hczoHCceu4dmcHXlvFd5IRNy8mhUKm30nI4ZfusHQGm1Uhgj0LkQZETeMhUZAG2wlG0XIPXOx89U3AmBRqkjnOu5DSMmQ4Sa3HPQE4+OWSvoiyQuHWCYT5elLEAw26Zzj1XDqPH7QWst6EREerBLUa5QVzNxej282yUqDyj7GU8HXuc7oAoKLOY2TQTHkHr+q56o8f6A+3CtMf1+nFhs56SXTHmJw1iKDyYfkIcThDmjlW61GqPYrvjuRnZOK2VMqCN5x2p1iNZNNPBeewNWJNAuxL3q68iDtaMm02uN3hCPODZSmX3J4MnTnZsnnAWZ1ayJFCkI0Kej3zmdLWTyeyBR77LWrPPSy5cOvUwfN0nNWRhfyRB7pq2Tl8WqobPuaaWcUMv+DolzmuYTcKMXojDo3YlEtO2n4UtXv99LTQLVNWfebvTTdeHuPbyAxEjZAuEcsAVOeemcMMmEwaoDu5uHtldn+kIt+VbI/EU+Z088MMYHIJpRtsEtHw7bTknl4UQftwUJh25Dl0b6shBz8/FF+JSKb+f1uyroivD7C1LzwHiZk9XbQYEa6y2mX2oSyD7SvRHs9N1HlODxfQNIA/Lj6S7sSJ6248sOGEIZls93XBWjQlP78nk+1NHjKWpYhfXuQ6p+5JKKClBTcXexMHLE/CQ+J/40j4GP74WA0dj6IugBIVj1AQoN3BKV4MTovL+sNG1YPkriU8HWS0jQ+IY8NpdfrWMVqnafHATuS67aTZpOa9Atm7EX8aSbaxcKEWzJnBnyxVFgml5iSG8dVOC/fyyEnwApgHwBNpj740Il5gZaKw2sI59TR2s+krQFhl4797o496qXlT+o6REuZOHWmPa1sihdP596XQRj8sm7BeJ4WVfxOMV8Mp/hZVjjFaeGqEt6z7AJEPdwccSUK8RUB+x8u6GJwZ3xAjK7GWaCIbh/+608mwxrGOh4eSMIUI3qSmzxU5+6locWtjdAorWyp8WD/RkzyVBriV27ibnnc2n9PtYUIlsebKU1SuGWXZwg7gtkjbcBV99EN0q6i7hdps86cfDvbgOqJd/D3QZeKd7Z+y0u2jelHbnn3TAAfSnY5lIQjpxRcywUejW+ToS8/WfB2o4uTDqRgBYVHrUlX8/t90QLTklfXQq25irBJJB6+/Hj5zkoFz7kRnTewqVxSbsRSbnzuP4tjcM7ykn+fAe5Io8XSP2qWohcaNxz9VB4P+vGMRtZFW3Crbhp21YDUSz7S+HXTsNaRhHu1qa/tX2RrPITzPE8hUb8s7xr0w7nrOMehr/sL1BCy6dP2hiUd93g05gSb3JyCKk2m1rK9FVgnbHT6DwjfSEvjpmMlGsOa/SVSUqZdtWLaSjLZM/61LlCAz14qRmpS7qn7YGKwtu1BaTtc0HUl484tV2K5Lw3j2utDVikZ4pyWIl1ocrTLy0N6io+32PU53CjCI6Aw/67+pNca3R54CW+Lgm6k45toQf1RFvR8ulzRJuCK1YFeS+A33WqFO+tRo4IcGIFMzbyv5qwK1kBShwgmcqLmuzhxiWBW7m1KpeZCvbgidkxkNPsoBy0gtW9MltI2bbRUEaXhDVAwSsLI9qbdlVXW2u0nuMC8/p12kl+6bw1JYbjE3sHcdKMUaAdBp5eZjAtPxuSeah1R62dMe3NpVwbeSiig2whHALOASCJeRRO27ppIayND/pbK62bSqjG6IwLeEk8WjzmjSqcGDpUv41ASNRqM7rkycSrf31BuM5zUZMaNTtwWk7JKDa7K25WXEm+EcoI3JrbhVtV2DiHbSceHXX6D9H4zGEPA8vHN5J80DcmiBVmuayyuhmedLTHHiw0Mt94mP5jynk1YflD4vl89hI2xHAfH7lIDpE9OsVt1ungycAzAzkN9zBw5JAAsFGctYh+ehS9iI+pomznWULWowABUUE6QsNbMyRufF4uJ3z5AqPLmShoE5p8kkfHYdeFvvYkfeTaW8gs7pLnZ9H/iDX1rDfl9ju/YW63hvkdPGiqh9wQSwJCMgm3KPAwpiChT2pbsjYpbD8qZaXQD2/AU4FSt9csnEGlvGOTg1qEplfAn43LYPMRQFVDmVx7Gcty9p3rdhrq4aW26wdspWzSXx8KuX1r38StL40mhUnSl7GRsY4vgyRBbgUe6ZRB96fftkd6UlmQILgbnEtYO/1TJnvjwCAz9Gm49MzlJ7J1QSDisj6ZkHbtLrEn5IIYZ5p7HIxYKqb7ofg9ws/Av2OQ1oF8BZTvKG8id04y3PCxx330GmJtopjLRnxvK/DBCcQ4OR8qqeMnEO3Yj0/Oywg1o7gMbSpKTqLvBTXqgXalj+7dTu0WRB1IDQhkXU9vnUU35go8c6zT3+4EaUcM9V01QGgqwm5cfEPzbNdF/Er7ReLF6mGw6+RNmuoNnLHujk0bt39YlIpnTQqrDlxCjEu19uJ8ghRgUfTYjntdTc8y5TVWW6gbtfO2ScaNYh84iGHAuq9JpRKUTpDQjNjNznDXKrGmcN1Z78L9E+FYOHcmlemNW0QQtLvaKzMDo5itvcAiTNDl0bt7Z+0+u1B0Rl0IHDR1NKntuoeGh3vey81xJu0IWPBaORIpa5NGFHkuMK848JWczp+UW+HpI9+3lIfpyHtm/ENgCETClo7QjYHbe4UTQ0ei0lsmvTZoptJmr6cIGGJMOYd8XB+jE/5Mg0y47AMCxmPgtfVMyBcmu6QKpXUuFCUNRLQHsu64YYQlCguY9UKk1Z+Hg3ho+gkZgTGRArkZuUkt5BrO82VFgosJ9TKfU1B+Rj1L0hbf31TX4HTS9sE29jDJOpJBcqkakISOIE5pUVNZWs/OybpnJlww0oAhhECLHTpqfMg/98iKETLF3SMa3iQQ2pNorYe1IJvpBbaRPZKEj8ix89q6CrNp9tkIWQZWlmNpb6DB+ndjkLiDlKiV6pjaeX5XCkC9BVpfBXdBZqDOA6UWMpxjLz0ejGFXyg+SovU7WmQAT/tzSpmBWuVtUh6j22HuHLVQD6JWNqJn+D7hwp7UJ1FLgI3wQSj69OLVkcvsa2HKEUNFjVUP0732epdXBULsKDGbLQ8T7eBFhRakyKaUet1fdL0Yn0CUo0kNYk1Ph4B7bkucryuS2yRkEGKMO1vrK/vCXyQ3JOdQWYdzMftPLOuFuhhK7cGKk2G1Plvk0vsE/3X4ZkhFaRLzXeXNOd+5kCcIoMBAPZC/vR/bZUUtuM+rRqb4o+qfzXPLfWU3pgBbb6fPLat1Nzb7+ambtQRBX3ABDadB7vBltSrANaGBLpOuMrAG+d4p7dfE2DSZIW3BL6uemZzjCfp24PV+KM2aIuoDeHjfe4VNdYMiFIIPVVyNKUhUNE+SNSETwNr5OHWgTRVQ87sD8QSiCXo42DC4sR+4nffM82FTY+6v/SOMto4/nu1ieCyfjFwwtK/vXj0w7nEcYXKjXTUj8Dr2HLxuIwxZtTcIYkQVcgz+0MEfUiKRcAECi0r1k0SIBM3MImr9LQJdxdjcDjP9krlufAjQsFbsoafHLM+S1NV+rmVh9970yK23aDe1xFJyLm59DNRcNBEzCMtYkXepZFdKU8K06Njfrjkk7Ajj4QBXmdePhKrFrXFZZY2xptwOnKbog99qRI403T7ed50XzKPWqBdGVIsrqKj16EOD71IHP3QfziVrr7vIfjohlPRs5OsbGyIsYoORBYGScyEnX0xQ8YLo7alOak0uYjBRMXHEIVUVXxsPWmWm3xBsxtOnAwYoptjJl2k43aQarRgr6j2RsyNPLttzs7ujGG/TdOgP1dsJUNKjSjpldQ5mpXQnQtkJDMcevuMM1NniKWpFteexhn5ILAW2VM6oKXq5UTjTKmzu9kBwhruWFJYsXkbrtHL2a2iyo0nAbD90JfcUE0fisztjmfALjU54xPNkXjrmWhjwqPbLDrwTb3iJZgP0vmoGWWcIi7095udy9HBKgPgCvrY4WrRI0PJheKEOPN5FeBDdzHqF06kOCj2BhoJMRkaorOl3mQoQvLiN189cEvOkd10ta3k7iooevpTBW8+DLIcUZqiGulUnndtF9n2OUpNoGWg2Bu6mKCdtdUBTp7AWn1a4DNoUH13+WLjgwtWRP7ciaBaUVGr8Pi4jX5NHBIrt7Nlgy7MK5nsuIlCo86oqGAEBu6lfsIPrYFMMxaAxQol/OOc6SVqGIWHEnDC0sSTsIVkvx1P0vYySaWsfCy9T1dYq+6a1VlRLgNjRASMPSS/86/lmdly6RPKQ7S26j8tOrA4C2YFNLmQLZcNIK1ktn9Hh3GAXqe0B85yPnMODB+aBYkApX3IpiK0fY8Ib603g7ZTpvbtSKZwajyM3+7MbVH8auzV+u0XOhZl4j/cMnSjZvEsqlxaonCaUXLRx3KQ6LDLzjIWOj4oEcoblKDWPSF1m+hqPcwLGoK4Fqn57Z77kg226b1C6z3IoPT3YrYGUXA2XVKCnMihg8oK3DtCdT82ML4tlbOrxeF2nI9c/PB8M5e11yBNxg6OwBimXXwi3zMhCwVQ2vjSr2KTi2hvFVxwmEOjUv9mYHoUgskbEQsyl5xASJ2ZCAwX+Y1R8uIMzq5T5DjFRHDmWekhMEb0e0SdODFxkh68Hz2RoN2axn5AK68XAvPXdTiuQ9yhS6Ad/tOzuhVJdClKNGzQucJ1/CXCFcbf0OF0e8S3UTvJjU+1MDZ4PbmA3Vw4NV7loej5BQh9rf5enrWDdwiFzVlPQ6yl8wlEDsvIJkZSPm8f6/drqIGSjq2cgyaURtlBeAua8IxW6/mNMKwakR5VDjHVEJIuM0GVCG9bWv7fvpdHCQjrOxvPHjgWTwkAEkIUurDCYQZOw5W9mPLMs6+Eflms+p8LLvj6RLbugR6ajJXYp9xIhZtduyhV1oUAtGmPjEEyhvKpZFUa0Ec6j0BfGa2L0BPKeyXOHL4CR7kv7PLwf3vmC4aSQFlhk1W5feH41y8V8rnZ+WRp7N808zz1+61EESvIY79KzTmL4rxLqRUfJIEuG8qOIQ/jSDtj7yQhbvyCTxxphCKL6fQhGqfw4Syi76b0C+5nyZVDOGIGckbSFV3vPgtfni76+1v5I6dXwZ0WvrBTsqrjTndycLMOi7I5Yni23cg+Cb46FOetzeBoC2pV5gnmPUVd9oV1n0hMSfX2SEmhHxEEUy0RYs0u1k7fziq914HXHk/Ew+adPM2PcQJx9guybx3QbQTkxWD1jCKMcL0eCUfSUDKekbUs1MPGrOLIvjX3mi11tSvrM9PpyHq9kBlI7XDCVrNXWR1IuHc9dA/RsL/1dakEzbk30OcNDgrEJrZBLd0ZlfqeF0Joe9jHbT3y3zD1kEGBaYDG8viK6RsU2RB/LGGK88TQHdh11zbAS/zjANUD3h/PJ86nkYGn86OWMkBHF7Z3CVES8VVMDBA3Bj9/hGSQjsvXT81O864oTN+Qjfp9DwZ6a+5KY3PvOh1H0PSRK8ByhDuy4e1rEbFe/KLXPQ2NabdtkoVa5yyYy+4Up38uSQWuidEhN8xZJ9gOr2tW6LAoMLfJvNcXUpY00/ZKNeUA+gWudWQPKLwRncpho3jYQIo9QzliYrhhC7T13C015i9nHpvJJ59KAHC55gVyhUIf0YZWNvq4Q+LTd1PZ6Kyi24i3J/ZG0SxDZp0nnAy3N1G6DOIS/8PV7c5EGAsY7ErnY9movjF/7hSbx3yOenVDquK3kZWQycYoc18Pk1gNX9wK5GHJDLuF70GjcBlJTmVDI8hsAZKdjT8Ti+u8XeLUB6VG6+7l9XXu8gL/eT/XRZzh/Wq3RcQe961hoP0ai2S7Fiyk8RImjApNlEp5JDS2L630Ci5GEJHLPC1pLJ/NQRIyWQvayOEuaTlYPoflsKI4f6cDzPNL0yPWV/agbklicw8hSWm5jsTeLQ03XHH6ewm316QjxaoDKp2HV+7E2YyBR04pixwY96RomdAZvC9hzQreXra8Xh2UcRuE9QdTfSNVT48QHrUKgTfsRBsei0XbvXcjpR65Y0UG+NKPCEyuL2HI21lwDyPkxpMsKqUjSv2wP6kOTs+PG1g6ItBO1ER+5S84TGk41HM1Oq4cylwxjj+Lx53y/gxiMRmVlpICoC52BZ4jopBdt5E0F52gdc420DLGNtY9dRLiM7nPpvTCWMyFXpPwh5ZKcI4jX1zCywwSyzetHyj9saY6sdJqVj3ug0SvLe8UyDoQkfwfBhb2eAqiMdVKBn95PbPGiDg8p0nenZFSpsSrP9fNwnpnHxHXgSDRniTZViyCRo7QtkbQck4lGslelUd3SlkgS7yWAWQfojCfZV3tOXV2WT2UORnTqgRQI0xD1G3mNBsQk+fjiryAcdX9UukBkLmpYeIpgxeSkOG6j96RAkWvtAAhS4Bhjy6+baFzQmYy57LkKY7qCfzooX4NkRY0b74WFOULCAHdWz8vfBXq3ybJcqoOQYSPltrlH5Tt7cw0hsqB1E3nD3rjrIqxRm9lyAKRBDYFXwcHbn7UUJbbIL2F50pJ+B8yw02ES+M11IHKpZuogMmqPBl9t9B690u4SDcKZmr+qwHcTZNHFgUGaUhXa2Me1BH7AEU/3iSGKMWj0GsLwBRbq4is7ycpFPkIQyXCbBD73eTcD7M56PzPSnPXtaopvPCCPS4ccnCzGgQzaOg/0HdLaB6mYe7z4RkT9tqKcHqnBTp/qIANOXn+2k9EHWMkNwM85qMuJcZ9hHB5oYLtOxLjrPXH1DpEljYqyYLLp+qzG+TJDJSPihcyL0Rj3RjfVDWQSB6vCNaKFnrCOPZhIHnRTk8kUhi8p4n4qlwHuxWMSwKCsskV0LSogY6Lbt3t+bSu61AmD9bElz6PGem4RqRJZiudLqYE3iFG6gVu+J8oQetnhQ7oBLy3YgqHq2M+eH2cH1RXlwutm1CHeu2oGZaYSZBcE+wgVBXrVetoMuAppd2uNWcLjTN8twybVnNca4IVCSeBT4uWsqKGueNr54ByYpV628eHBDcebpJ6UT626NmX+skN9VBbgcpaXfg3yy8ad2ioobV9y3oXE9A+27/2xUVK/mpwY16rF/hh7b450n8zlQHdnTDNqLEuHi7ib69F1pkjlp/bL/B0+1Ikme6XqxNqNG6pNiDVEnFoNgbMNFC6a1Keu8EAkH9RJzW2wvai750iZTC7y7aaekb7Z/PReOIuNr4Da9kgKTFN9EvSorZCQoaJQ0vaQRCVezYgRy/OhMYo4LPJCchvB+/EyPjlNTruj34IxJG3/7TqxUNz70MJWPZUHEdEEJIcYt+xsJ2h78/mdFI9mc/39Uvv320drkW6s2JAexrXgdJgoB/CAxlYRXbjPmQksizlwRTrOabzDoTMcvSBSiw5yM/u8LQUCWWnoGHPg05Wat8fuCTCIHxqL3FCmRGLeDpbn8/vJ3jqwiFRmPmZ6mbHSvAB2gvh4lvYWJGIEn9x9kOfU+TqrQ64g6/O65iBUzx1qPicQK4cblEEY2ndVaDHmHogzgAQToucnWYDMCpFjwR0EIdwfT9cQ+2mPJY9zHtR7m3rB+jqB9unszDrMJW7NAV3I8kgIcZkkKXlvbbZZL+R0cKW+9sAIMVyFNIhDmriTq4rpelLKXdjnzcb6SoNBYxuyHaBku/g+rksK+p5TCf7U0iB8Li3qEmE/OgG9JAjRP9Hu821ejsgSJuwkEZkAVUJiX1oLD/l5GEY9He5bynnnjC64OrR2H9xp628gPVxu+5atkqBv8LGRXIiFzhV331juIf8kceAUCHjVEDfdoTW9wHKxwYHGvZ61x4jJvOx4buZ8svwCrUtSksE8g9ALSV+aQsnSt3r4Upfy5uvGRIYj4vZFzQDvEloXXlgYCV6Jcmf2kZhDec/CR/odgrJ18Kfc+Io6gkCDHBvBF3W/2SWMkf4xezQWpsZl3yVr+EgLa3WUeU7K6DOCwl6WXVTRi40T109q1R9PFJdfF4SI0xCBpY8FMhaZ/L10LX8+k5Fw3Kh5zjpuatK8nTOyU3c9/xQ9mdiYxAcIuMxLDJrhMAxMTty8VMH6Zr4k9MKwUiM2ObZBaKCB6OzOvPeNh8Qw7sj1naNOrYT3YwicMMLrADnMnkZDWmdDK7vHnuGQe/tjuTAu/RL4vSQ2D7Gf5bZQs0WbByWHJnG3z2YmJcRxAXoiEYE6fBVcOI4qiv51Z4heYhhRBO/0gGMTXM7q9Dw/P5eY8vSK9DxHE17sARvUNzlUY2lScxoayc7c5WleBhIC9gOoBOM+7lKIStMcF4QGKh8NaeoIJXcZEPId+W3eRgj9fBbA3dL/nLcmGbWf0/fFDThRfSP4Mg8ad2ndzHq+juRDeMZdDl1rel1/iMDxWNZMQBQS37HLMgSPJ7bY9zYU91VVxo0038vAisLYNZeRHS5wyqN0+qvt2BeJdcRkgjbZJS7OWBcV00hsgB0Jth5eSYj3iVqsvW6DkEsP8u2F+y9qBn7M4OdZmVhdmknCxDL4jNW2py9lqytg0Ju1yzblHNlVMQ2oj5TIkyhPtOdZ2jMblPpa5NeKQ16zLIhTk0+vsjA3k7ocME0idg4VoPw6gkwZx/FXoIjxZflSJm+cHrjLENWxMi3pY45J4y3AqO4NCgQ1zzU6lB5/uNZiqGMfv+/81ZmM0cRZMVgWPTA8Qr9Ym5AWKjpfiSzEzgkd34jcPYD95KKj9g9scco80ZpDXTcQWmJ+1o0A1yReCulzqUjgYRVPSXeF97baKEcDJ9DAcdEFUVLTks9UrZ3BWasQWQZyXo4p8560bl9MNE/zQB045YS045jKSDXvR8ZumZROkEtnEYpAwhE4gduuUr8wyT0IiuWZgv81gHYXjCtPocXadNXZF9wqffG+GLWKnJbzcyYZH7kTWAXTrla7PGabEC870ik/H7nGc7a1LVNRAmUHmRNnzz9gD9pbsMOo+f3o3kxIzk+kAnRkTzlMigj9eEruzfim5wWlFjpKn66I56qny3PFfOMLadFAWjy2cQ+Rk8ojcRLGFcCxMbgs5aI8EyZilLvB46syjbYxo185cayDOnxRaEixn8vOCrl3hsPLcCpKA1BQVMxB2UJu9MQ8SW/Ia3X3ChGMl2vgm/8OQZ85lcPhCLi+iEGLnGUjp6UxAOR0g7wrXHeC49Pwlsvo3D7r6g8KRikVBdnKYgK3uPazrMUCITJzmQQX0U3czVsIWC7c5zNCZjJuHPlqiTrWeeB3vvNZ3gn5lgNxU+xvTfg4fGf/wk1gzuyjYcUwHVRHgZvZHKu9RuZzEPXdgSbkQMhu0qAjpwQKSckT+Lj2J1EP9evrsvnEMrbETPq+DlYYn0DjCLc5be6TWIDnZdpqds/8xuJsWMOtt15mr1WfguUK5tH/6LHPwU34EjYi4FWOGo5y/bQ96yRJeO0GtOxaFe5jievjYmWxVX3D3aGP+ECflyX2bSgYuP+kd+Wh/GQmu06yzoUD2yQRL6kqdzaI8PwW7edg1ArPIcZBVMqi24FeFLRxAx3+kLMNBe6l43QfwtVKY4DLJkWGxQcCs2N71IDGFqXbyHbRNkhMFBHSoAoFu3yzwqjFyTKmn6F+RDxDEW5SZfKXCWB5cjgY2xaR0RqzUiJHGiH8MnAvfTwWpKSm34yWXN55xARYsj2E62VsLpS/NNEqHxtMpzU/hv4Rt5Gzq5fVEQI+IGsM5joSodcv5IY4BgGFlJ89rfqa2/KEAfYevVRTmr7jNb3w+5qk8TAt30Bwn1LjhCA75gJw0KVb9hR3g9E9p/m2/MAOgzxYqo45yGcixxEgUQtkcCR9ABHO/gHyVCzYnua8dNY5L0I+Pkcg+Tc7usXvdxjien9z6OAoOKyBdkwD1ToDCk1QTo7RhSIv1VCr5JfJFKZyad5Zy7sXYx+m593zO+ZRtKPQRFbb90OonRTWEA1p2dCuxMuiEKwLBUDObJIsSGjNxw7mcyzB8xMAl0HEoiEZXL7XedS9rPi9R9/f4hEE7vY0B48mcvtBsevgSsWRLwbKxeEiv7DTD7HHRhAhYZBj2vf5G63kQh0lR3uPCSzJFmQVY0wcY8yvpQLB8UQ5Je5z6kpUwKdxD0BDSw1YWmo9tKHAb2BBcR9+N0g+LgOI5qQi21yydDpN73n7f0czaR1rz4QtdvmpK5O441mDSphmWYiML7UA5FV5sJ3yabv+jHJa9n/7tbMDTCC8pr2fiNn2Mmt4+/NCQ/OOeReb+j9B8fmbmovisSKeVDBNY+TcaobStB2MJfwqo0BYMy0H89SoDyPpu7PCdc17vUeOgyv85NzGHyQuF8STsw/I0/MjOPZmjRuWGr3s0PpBkeIPyLm4ytewivdh8wuvmKm7gwuKCtYdGWzfYBFVd944BmKPT9gxJA0eEeO8gHG0ky6+Ds2dKsXYyOCqPOURxK+D2fS1Ekv7W1rd8zJO9SeMwSELw9zgz5OQoL/NuHLKUPb5C8aORy3BwlvyxKS4szyXbhjsfWXHuAulJQSXlZtLtkx1NXWtb6vi57DFxnHde+7R2kspb9xxoSkfNSjVYx5Cnj5K2XKuGuqcfQ6+o0Yke0/+Psuwm6ZD2GN5bh/Akhy88oOWpu9uygB8tbvg9klxYV17dbCVrPIxhRbHr93ZslSjtCCa8nFV0XXtRto/tU5d2u5gVtN5D3fMX2iDQB/JextfAfl0+BvKcuz4Ker7BHiygwJBV266wdpj9fhpXsyErgTi0ZPc+gODPFgmIBKuqbawikdQYTh5+ynFVrcEkLCiKQTrCp0PPcUDtG0VAEqFZBqSOfKlRovn2t6axa/4MB/Q6xuGMqlWBUAxtmd7zOsNFpY2AesEVFXE8mfxKZc+A8jnX8HRZXcRon3aa8QiEiwHJz3GdhAU1ZlNdbo3e8Joe7ReVn1f6x50DzqH9AOivO6701IiYkKuWh8oZo2zy53aPr7ppT/TnKP6XURAO0GYV7sPMnM9pgdUnX0dX+keAbo774UhYAnEbMMGTpFzWOWXOlmv5YjjE48PukeUDhsaiKpLFPQS6U/FWWMo1yIC6k/khEuSreyHINPrh5vrFeY/qHKhL9J1Te0Dc6LjWdanormtMJBv6JZvYp8URoyF/khbLDF22Hnv+myr/GZrr2EwliDk/L054OHdIxfW5wrRo/A6HVF5HYw+5JcRh17bESe6SmL1cm1dnsxxzahUpqNdqERcT4WCfFqJa3MYUN9DG4jC2b2KloZ3E8IrsEF6Gw5vS3SB4XB4A7R2i0d9+x4nIrqAY+8MgQ6G0zWp8wiKVg8mvuPXalKun5aNL5/OsmizVq3ztrF285TMJ5tHIMklSzoasXIoo0769d0BCzMO4eakOc3bgKLvgYKCNMGRKu9FstimJc1qKc3cdM2xwYkLYJeEUjZaSgzkaM0A2zAIXlawv57J9RiiWReciiz+brkOUT0MvIZT+L0HNXHZfBZF6aBW+QPNKkPjRDIOUo0gajLB1TnSbJmfJOg8HNFJTUXiG+Ty1hoQ2QyEZADpnSBYKFhkSySNjVSEzJUWaWVCPjuOIOMW5Kltih4UxQJh1hCdQu1k1YW9L40FtkjFxuMqt4/AAcJ+Lv3z8QeOCDFT4K0hth6b20+unNoDslUKw5cHCYdgI6R1SHxySs2TRXWJdB3qIy5ft+zWulNyoYH7uvQ60uu8d+2OjHfzM329sxBaFSiHrqWSpIVB6a1N14R6TsqF9oJthHu7L1OaLu+MZhI5hjEuegxwxGTQbJ4RxW7hvZ2nOFcfHIKGqxx+dzlenHffDBJQ3idEryi1mhiEOHZNs1NRd3Ts89FUomy3XFZV0BYvff+wFHrEe02HoWXgQIBq94JKqAu8pksySV/FlYKaEbU+5cPBcjK3d7fG6xT8m6dTWk7Hp7WvlXs1+Cm89/W9jVMhAoPQsaVoINyhDJxhVwZWACj4ZY8VJ6Qo0l7suTnbJe55HoMY9Zu9Xzj5UT74++WOM04ZoJ8iWwJjTUAHmMSRqN7uIWY9yDH6qaRiVckgCQXNxuzie5WElrYHFfmuV0n0YlX+E81DMG/vZ0Qvo0dETVX7xpVx58VDN8iFti/6TiYwiAcp+2mFTjAGWtmRWcgxD0F/ab4lSQQnepwuK4+ee51gCiCYhMEwGzK9t2r3aPexXezIxxp38Uy7ISaEPdefPlDsPp389cDL++4DcAdDUYlujg9TcP4ZuTVNx2lK3WXGkNbLBDkuih9GpJBSTNPrOXN3XWet9B4jjxL+YMxAgbDreQIPXdlXX/jRKGqnCt3HXuWasIjAA62+Smml6pS02Ey39hR5gExN8fMCN9LTjZa2OO6GZipfGDY8seUMBQnNfBaeQE5fJM0souXIQnp6Fs05po/EcsE5gjBqIQzngB4isE9I1lweD4bKW1uAj+fsquIyk9HmT5/8t9GSwL5/60ynDaJagoPoJSIllIX9/QBl03KBvFuqf74/oqxRKi98MP11KLkjyeiX33cEb3P4uMxcaYqu8wcrW/OirosQwoIU1NEWgeTfcxSBFbUzEgFRLuY7z2GqoZWMW0l4PKSMkb+ftIM9kH3y/bWWtxh2AGy42GljoxLP4okhq2P9VMYiUD4NPPd+684IBBDFgGwxAFrPgxpljzgaHr70eBleBjXUKTLzkYd97a+V/mYW914hSjhd3+LWQ0HcA7nWxevCkVrwjjtewPbW+4FTn1D3ruz89REesJi525jcmV3qMhNTBC/eRr0eh9E5SPKS/P1RfTIvuExhX+tRdY9cLk8+j0fmPUfDkISPlEVwMRd7+yFG57ybLPCPjaMdogRplcaHUFoKN28aqxN8WWVzsKeSdOnEjuY6rCf0+9uTEU5aHwB1HmSGpQXGP7KHkOtb+TK6yQVzatkYjVBpwXhmi43oe5co6KgMeoCBTn6XyQN4t34FBCvHllgq/giKbVTpJewU5X0IzWp/IkBE/gEZiR9QZ2R9BbMeZOFnCNnaR92lhS8ZdW0dknAGetgJsd3c1yWBIv+y9IHDBvQNAV0WwAc8sFMS/q5j5u5JLnsmsokxqAYXjfdN3iVZa10pff2+FCzFk/7PzoeI1r7rsl6f5+Mj5z7zxUL9MkxXeSCO/FCFAYcWGrmYbnoe53EcYJ1TpGeT2Kl5fdjlRYyOU2sxYE0zW8RTn0jNflq4pPCyws+80uMWuJYv6eCjvNdjpiToqbjqXCsIoJ9Q5smaALKcGq8t6PD97Da2xmPPswk+ie6WErlc0frrWswErVFO7AFXSnI3lyl6p7SFqC9rHXZd7QWy+N6T8XoEr1a8FNP1cqlbu6rF2Ptu1BmWnzyoqhrs42xAdvJ5nV+5wdm48Amm7YJ7xYFDEIAOHu5UA9wlQKoIjYO6Oh0lY6Te3JTcS5//yUvcnzvIgbaeqSyqHSpQDsGZB9B5r+t0Knq/wTU0l4Zr+trD/QwvPl1+QLXSv93TmNnv3IUIdfcPGYe+zJ4P4RnN48Aq6hKQip5PksjGd89nb5pX0H8CYHC5lr/WHJez/hhqSAAKdyI5K4QveERfzNxF08X9r2PSzvde2zSWWFF6IO+7CYfqSwPt3gH2v2pGI2ew2GsgqQR00LieKXQsOPLe+F3Zrih3/xqmiVpmq7j4BWaqgrqs8/bsLOQ6NtnnkXHbZHE02ExjBDUUCrK/55cTQPV1zU6SkKWzN3dLWbDnvIH2llTwX4/Qh5pXF7+6xbw7OZSnZv3WheEgK6m2Ly5wgde1ZTtCKdnjc0sv+rPlq0OsUl9tdKmeFRKMsCWVd5q7YFyIgKxPwXxtwavyDZG8I30vAn1g8csGtGncE+mtS+bcnRtQdZlitgMV5rXHcCfIwjgKqdUUroKDkf14RQCeUjkmwLm5zk0VUpVPcZdddIYTJHC/XAbxk5NiECfdzSL5e82ZsAR4DHLzCl1KsHjaWWG+kbuNHlVUHp1347IaZO5MwvUZ8JJ4SYQiriqonLKddb3WxzBLFRUqRP5+Tx7bJPmOT2Hag4wIQDfB+3zWLC3toNy4jzm4O6joa4GfNYXol3Egg39+ua2ecyeUX1+VrdcG0J+dwKUqEKrFcyt9Qu0D8zQ928sCAkX9n+4rrG3w+OlsIt08IuWWfus63gASdIvU5+0YWDwRBeOBI1Q909r8vkzhRzYcWBhchvBMpwEEtFwWKd/PutzJ/WpxJMxq3nKt/lzklR7VnEvUEPQewo0LsSw9pE2gDwgjfS7tOKA+GhOpFdztXZzdTGEkg+6dtfY2aOqSOfJdNavitYIsKNLXyWuQObnAJtjqI+k6ZSr28Aka8bhritKUntssN9m8+efM/MejFhV3y2jwhN18ga7DNkAegxr+9OxxkBJyHvZ1Vx+37gR934+fe0jJyexYSbJ7gXt8nhkufy5gmo/YdzaoC2lcZ3PXbh+gt4Qla4Ev5HR+nhh+wQKqxRXuxjLqVgDp9P4s6zhghdDguVQRfIrCl+K5UCnI+QewiyX4D/GTd/X+Ioivok9NOW1RKhj2OmWYpu92O1cyDAyLL+0vrBFkHjG5YXNgbwNpe8rJa0suGXoiwQFwNk136hNb5OzCKijigGdUyEEJ66OF8FrrMfqsgm2YjPy1yxSkvV7vs1pe4DwT7oTnqYVnFJYUmEVLDVtcudfu7HDt205DOXX3tzIf6+3SoG/VCnoR6y0+Dl9j/ZJAI2mvQgAOAyG9z2U/pTwENsfzO6jwZpRGTN0HdqRCTv25oxb1ohUpU/7Xm4P9Z43q/vdbq/3xscfwLh6Pf5GGg3/YhNAA6/dg3sJ9m6DB0r/GsX+hDpL/f+sq+a92S3977P+eT95jdWMtIKYdv7AEMmxuyjCiYePXu2KWZf+Gvu5/F+K87AloTcY52cERBIF/DvXBmLTzz6H3Oy//VHzkN+tHdIuejY0cf7rPQ9//hj6bnUu6JpnHC+RBv16FQn4uc/y8/BNKQj8HtiKe85+DIAUOHMqTIsvnvz0WTD+vsz9fGdg9P98HUPz+TOr6t6+//0agIv5Pbhf/9cJrUC/Jz3k/B6b5qH89MOVBD/4smiC7/s+AZSmioJaDMKn1brqMwK693g+7ee6a64QavMEEUZWN3dLGz67uxvtSaHr//NU1HnWRgc/OXX8dDaY+icAjp8WeXLfN3F/5+O0o9NuR6+98nvtrMR6/hmLb7Jfrv3lMkl+iDkT/phVk0SMQTEL0XUfUdnHy73H4790YRHXy7/8OQzCGQwT42D+R4CT1C/63JCfB3fwdyQn89yT/7dg/nuT0/5zkv6fD70j1X3LFH5M8Ler6rxiEuH5Y9h9B5+aYhvrf/50kSJKi/8lUhiHo78lMEPDvyUz8AZmJfxKZCej/CTLfRCZojKKgfzaRMfi/sZPJPyAx+c8iMfFfkxiI4P4/XJKoa9uLvkH42+nQP2apSOT3+4H8/WLRv18sCvpnLRb6/8R++MeKvR8O+w/p/CcY+wUnSYzASIoiUQQFrab+iurELwiKkgTx22/sDzTfLyQFXboYwbHrlnH0jxThH57yj2cQ/H9tN/0Xy0xdCwAT9AVYIOrCLOTfgkgU/QUmKAqFf/1NEr9bZQz7BYEpFKIpnIZxjEZ+v8oY9ctfLgF+/7NWGfuvV/m6TNFPYANueTEnZh9E4J1tDMDWyefm+sYX/Ee78e+2FXv/XMeneeyq5I/e+b/YG/h/SjTkFwyh/3pz/K1ARH6BSJK+WOS3378Xjyh9kYQicIQiERyH4T9QLf/BKf94zPjf2Bv/qsKz7rKu7Yr4V8l5nwSSZvpumrMxucTln8Ap//ey8j/gh/8ZRTH0FxzDcBKDCJqmSZL+J1GU/INtF2eJ+evLbpxzsG5B/f7LUeY25wDlbnL+1UZM9mL2wOFL7f+8+v56UhxM+f0JcFqZzPNhFif4hmCZu+vQX75I7gBx7/N+bg3cz/8BgLueqVvGKPlPzvvh3t+TakzqYC7Wv/3WP1r6Xz+qdwUYDvXblidg5BLFf/kh/xYCoZekptC//CB/B/3nYMyS+ddr/h15/3yT/+cUp/4pFIf/mt439f+G4tP1UPNjHLvtOhDVwTQVkZUX7W9vsUVd/6sxxz+FNxAa+ltuwGjoF/R/jxv+pQT6f6ye/yOF/tei/s+Sftu2X5rjktwRkPW/BCACdN3CLe3/6L3r4vd7wTb9aUL/MVrgvzCM0L+TCThG/U7v/xE8o/5ZegBH/pUY4b+j2f9M7gL6Zet/1ep1URdBWwVT0vx6ZOv/dOHxObmn9y193QWg9RkwmUC6FCiQYINzGZM/TXM3Xlf/U1h34V/xAAskFg4j/0RuoP/OoYCjxG9m81+xA479nh1+O/Y/YIfr5dh1819LkQtP58plN4Iz/j8= \ No newline at end of file diff --git a/docs/install.md b/docs/install.md index fd9aa5631..fca689785 100644 --- a/docs/install.md +++ b/docs/install.md @@ -15,10 +15,13 @@ $ make DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_LABEL=${PRESIDIO_LABEL} docke # Run the containers $ docker network create mynetwork +$ docker run --rm --name redis --network mynetwork -d -p 6379:6379 redis $ docker run --rm --name presidio-analyzer --network mynetwork -d -p 3000:3000 -e GRPC_PORT=3000 ${DOCKER_REGISTRY}/presidio-analyzer:${PRESIDIO_LABEL} $ docker run --rm --name presidio-anonymizer --network mynetwork -d -p 3001:3001 -e GRPC_PORT=3001 ${DOCKER_REGISTRY}/presidio-anonymizer:${PRESIDIO_LABEL} +$ docker run --rm --name presidio-recognizers-store --network mynetwork -d -p 3004:3004 -e GRPC_PORT=3004 -e REDIS_URL=redis:6379 ${DOCKER_REGISTRY}/presidio-recognizers-store:${PRESIDIO_LABEL} + $ sleep 30 # Wait for the analyzer model to load -$ docker run --rm --name presidio-api --network mynetwork -d -p 8080:8080 -e WEB_PORT=8080 -e ANALYZER_SVC_ADDRESS=presidio-analyzer:3000 -e ANONYMIZER_SVC_ADDRESS=presidio-anonymizer:3001 ${DOCKER_REGISTRY}/presidio-api:${PRESIDIO_LABEL} +$ docker run --rm --name presidio-api --network mynetwork -d -p 8080:8080 -e WEB_PORT=8080 -e ANALYZER_SVC_ADDRESS=presidio-analyzer:3000 -e ANONYMIZER_SVC_ADDRESS=presidio-anonymizer:3001 -e RECOGNIZERS_STORE_SVC_ADDRESS=presidio-recognizers-store:3004 ${DOCKER_REGISTRY}/presidio-api:${PRESIDIO_LABEL} ``` --- diff --git a/pkg/platform/platform.go b/pkg/platform/platform.go index 2357e563e..6d4cef47f 100644 --- a/pkg/platform/platform.go +++ b/pkg/platform/platform.go @@ -47,27 +47,29 @@ func ConvertPullPolicyStringToType(pullPolicy string) apiv1.PullPolicy { //Settings from all services type Settings struct { - WebPort int - GrpcPort int - DatasinkGrpcPort int - Namespace string - AnalyzerSvcAddress string - AnonymizerSvcAddress string - AnonymizerImageSvcAddress string - OcrSvcAddress string - SchedulerSvcAddress string - RedisURL string - RedisPassword string - RedisDB int - RedisSSL bool - DatasinkImage string - CollectorImage string - DatasinkImagePullPolicy string - CollectorImagePullPolicy string - ScannerRequest string - StreamRequest string - QueueURL string - LogLevel string + WebPort int + GrpcPort int + DatasinkGrpcPort int + Namespace string + AnalyzerSvcAddress string + AnonymizerSvcAddress string + AnonymizerImageSvcAddress string + OcrSvcAddress string + SchedulerSvcAddress string + RecognizersStoreSvcAddress string + RedisURL string + RedisPassword string + RedisDB int + RedisSSL bool + DatasinkImage string + CollectorImage string + DatasinkImagePullPolicy string + CollectorImagePullPolicy string + ScannerRequest string + StreamRequest string + QueueURL string + LogLevel string + RecognizersStoreGrpcPort int } //WebPort for http server @@ -79,6 +81,9 @@ const GrpcPort = "grpc_port" //DatasinkGrpcPort for data sink GRPC server const DatasinkGrpcPort = "datasink_grpc_port" +//RecognizersStoreGrpcPort for data sink GRPC server +const RecognizersStoreGrpcPort = "recognizers_store_grpc_port" + //PresidioNamespace for k8s deployment const PresidioNamespace = "presidio_namespace" @@ -97,6 +102,9 @@ const OcrSvcAddress = "ocr_svc_address" //SchedulerSvcAddress scheduler service address const SchedulerSvcAddress = "scheduler_svc_address" +//RecognizersStoreSvcAddress recognizers store service address +const RecognizersStoreSvcAddress = "recognizers_store_svc_address" + //RedisURL redis address const RedisURL = "redis_url" @@ -139,27 +147,29 @@ func GetSettings() *Settings { viper.AutomaticEnv() settings := Settings{ - WebPort: viper.GetInt(strings.ToUpper(WebPort)), - GrpcPort: viper.GetInt(strings.ToUpper(GrpcPort)), - DatasinkGrpcPort: viper.GetInt(strings.ToUpper(DatasinkGrpcPort)), - Namespace: getTrimmedEnv(PresidioNamespace), - AnalyzerSvcAddress: getTrimmedEnv(AnalyzerSvcAddress), - AnonymizerSvcAddress: getTrimmedEnv(AnonymizerSvcAddress), - AnonymizerImageSvcAddress: getTrimmedEnv(AnonymizerImageSvcAddress), - OcrSvcAddress: getTrimmedEnv(OcrSvcAddress), - SchedulerSvcAddress: getTrimmedEnv(SchedulerSvcAddress), - RedisURL: getTrimmedEnv(RedisURL), - RedisDB: viper.GetInt(strings.ToUpper(RedisDb)), - RedisSSL: viper.GetBool(strings.ToUpper(RedisSSL)), - RedisPassword: getTrimmedEnv(RedisPassword), - DatasinkImage: getTrimmedEnv(DatasinkImageName), - CollectorImage: getTrimmedEnv(CollectorImageName), - DatasinkImagePullPolicy: getTrimmedEnv(DatasinkImagePullPolicy), - CollectorImagePullPolicy: getTrimmedEnv(CollectorImagePullPolicy), - ScannerRequest: getTrimmedEnv(ScannerRequest), - StreamRequest: getTrimmedEnv(StreamRequest), - QueueURL: getTrimmedEnv(QueueURL), - LogLevel: getTrimmedEnv(LogLevel), + WebPort: viper.GetInt(strings.ToUpper(WebPort)), + GrpcPort: viper.GetInt(strings.ToUpper(GrpcPort)), + DatasinkGrpcPort: viper.GetInt(strings.ToUpper(DatasinkGrpcPort)), + RecognizersStoreGrpcPort: viper.GetInt(strings.ToUpper(RecognizersStoreGrpcPort)), + Namespace: getTrimmedEnv(PresidioNamespace), + AnalyzerSvcAddress: getTrimmedEnv(AnalyzerSvcAddress), + AnonymizerSvcAddress: getTrimmedEnv(AnonymizerSvcAddress), + AnonymizerImageSvcAddress: getTrimmedEnv(AnonymizerImageSvcAddress), + OcrSvcAddress: getTrimmedEnv(OcrSvcAddress), + SchedulerSvcAddress: getTrimmedEnv(SchedulerSvcAddress), + RecognizersStoreSvcAddress: getTrimmedEnv(RecognizersStoreSvcAddress), + RedisURL: getTrimmedEnv(RedisURL), + RedisDB: viper.GetInt(strings.ToUpper(RedisDb)), + RedisSSL: viper.GetBool(strings.ToUpper(RedisSSL)), + RedisPassword: getTrimmedEnv(RedisPassword), + DatasinkImage: getTrimmedEnv(DatasinkImageName), + CollectorImage: getTrimmedEnv(CollectorImageName), + DatasinkImagePullPolicy: getTrimmedEnv(DatasinkImagePullPolicy), + CollectorImagePullPolicy: getTrimmedEnv(CollectorImagePullPolicy), + ScannerRequest: getTrimmedEnv(ScannerRequest), + StreamRequest: getTrimmedEnv(StreamRequest), + QueueURL: getTrimmedEnv(QueueURL), + LogLevel: getTrimmedEnv(LogLevel), } return &settings diff --git a/pkg/presidio/presidio.go b/pkg/presidio/presidio.go index c0e864229..5c26d8f60 100644 --- a/pkg/presidio/presidio.go +++ b/pkg/presidio/presidio.go @@ -17,6 +17,7 @@ type ServicesAPI interface { SetupOCRService() SetupSchedulerService() SetupDatasinkService() + SetupRecognizerStoreService() SetupCache() cache.Cache AnalyzeItem(ctx context.Context, text string, template *types.AnalyzeTemplate) ([]*types.AnalyzeResult, error) AnonymizeItem(ctx context.Context, analyzeResults []*types.AnalyzeResult, text string, @@ -31,6 +32,13 @@ type ServicesAPI interface { ApplyScan(ctx context.Context, scanJobRequest *types.ScannerCronJobRequest) (*types.ScannerCronJobResponse, error) InitDatasink(ctx context.Context, datasinkTemplate *types.DatasinkTemplate) (*types.DatasinkResponse, error) CloseDatasink(ctx context.Context, datasinkTemplate *types.CompletionMessage) (*types.DatasinkResponse, error) + + InsertRecognizer(ctx context.Context, rec *types.PatternRecognizer) (*types.RecognizersStoreResponse, error) + UpdateRecognizer(ctx context.Context, rec *types.PatternRecognizer) (*types.RecognizersStoreResponse, error) + DeleteRecognizer(ctx context.Context, name string) (*types.RecognizersStoreResponse, error) + GetRecognizer(ctx context.Context, name string) (*types.RecognizersGetResponse, error) + GetAllRecognizers(ctx context.Context) (*types.RecognizersGetResponse, error) + GetRecognizersHash(ctx context.Context) (*types.RecognizerHashResponse, error) } //TemplatesStore interface for template actions diff --git a/pkg/presidio/services/services.go b/pkg/presidio/services/services.go index 3df5eb9c1..e09407c41 100644 --- a/pkg/presidio/services/services.go +++ b/pkg/presidio/services/services.go @@ -17,13 +17,14 @@ import ( //Services exposes GRPC services type Services struct { - AnalyzerService types.AnalyzeServiceClient - AnonymizeService types.AnonymizeServiceClient - AnonymizeImageService types.AnonymizeImageServiceClient - OcrService types.OcrServiceClient - DatasinkService types.DatasinkServiceClient - SchedulerService types.SchedulerServiceClient - Settings *platform.Settings + AnalyzerService types.AnalyzeServiceClient + AnonymizeService types.AnonymizeServiceClient + AnonymizeImageService types.AnonymizeImageServiceClient + OcrService types.OcrServiceClient + DatasinkService types.DatasinkServiceClient + SchedulerService types.SchedulerServiceClient + RecognizersStoreService types.RecognizersStoreServiceClient + Settings *platform.Settings } //New services with settings @@ -123,6 +124,21 @@ func (services *Services) SetupDatasinkService() { services.DatasinkService = datasinkService } +//SetupRecognizerStoreService GRPC connection +func (services *Services) SetupRecognizerStoreService() { + if services.Settings.RecognizersStoreSvcAddress == "" { + log.Warn("recognizers store service address is empty") + return + } + + recognizerStoreService, err := rpc.SetupRecognizerStoreService(services.Settings.RecognizersStoreSvcAddress) + if err != nil { + log.Fatal("Connection to recognizers store service failed %q", err) + } + + services.RecognizersStoreService = recognizerStoreService +} + //SetupCache Redis cache func (services *Services) SetupCache() cache.Cache { if services.Settings.RedisURL == "" { @@ -138,6 +154,100 @@ func (services *Services) SetupCache() cache.Cache { return cache } +// InsertRecognizer use the recognizers store service to insert a new recognizer +func (services *Services) InsertRecognizer( + ctx context.Context, rec *types.PatternRecognizer) ( + *types.RecognizersStoreResponse, error) { + request := &types.RecognizerInsertOrUpdateRequest{ + Value: rec, + } + + results, err := services.RecognizersStoreService.ApplyInsert(ctx, request) + if err != nil { + return nil, err + } + + return results, nil +} + +// UpdateRecognizer use the recognizers store service to update a recognizer +func (services *Services) UpdateRecognizer( + ctx context.Context, rec *types.PatternRecognizer) ( + *types.RecognizersStoreResponse, error) { + request := &types.RecognizerInsertOrUpdateRequest{ + Value: rec, + } + + results, err := services.RecognizersStoreService.ApplyUpdate(ctx, request) + if err != nil { + return nil, err + } + + return results, nil +} + +// DeleteRecognizer use the recognizers store service to delete a recognizer +func (services *Services) DeleteRecognizer( + ctx context.Context, name string) ( + *types.RecognizersStoreResponse, error) { + request := &types.RecognizerDeleteRequest{ + Name: name, + } + + results, err := services.RecognizersStoreService.ApplyDelete(ctx, request) + if err != nil { + return nil, err + } + + return results, nil +} + +// GetRecognizer use the recognizers store service to get a recognizer +func (services *Services) GetRecognizer( + ctx context.Context, name string) ( + *types.RecognizersGetResponse, error) { + request := &types.RecognizerGetRequest{ + Name: name, + } + + results, err := services.RecognizersStoreService.ApplyGet(ctx, request) + if err != nil { + return nil, err + } + + return results, nil +} + +// GetAllRecognizers use the recognizers store service to get all recognizers +func (services *Services) GetAllRecognizers( + ctx context.Context) ( + *types.RecognizersGetResponse, error) { + request := &types.RecognizersGetAllRequest{} + + results, err := services.RecognizersStoreService.ApplyGetAll(ctx, request) + if err != nil { + return nil, err + } + + return results, nil +} + +// GetRecognizersHash use the recognizers store service to get the current +// hash value representing the currently stored custom recognizers +func (services *Services) GetRecognizersHash( + ctx context.Context) ( + *types.RecognizerHashResponse, error) { + request := &types.RecognizerGetHashRequest{} + + results, err := services.RecognizersStoreService.ApplyGetHash(ctx, + request) + if err != nil { + return nil, err + } + + return results, nil +} + //AnalyzeItem - search for PII func (services *Services) AnalyzeItem(ctx context.Context, text string, template *types.AnalyzeTemplate) ([]*types.AnalyzeResult, error) { analyzeRequest := &types.AnalyzeRequest{ diff --git a/pkg/rpc/client.go b/pkg/rpc/client.go index 4833c0590..dc673af23 100644 --- a/pkg/rpc/client.go +++ b/pkg/rpc/client.go @@ -93,6 +93,16 @@ func SetupDatasinkService(address string) (types.DatasinkServiceClient, error) { return client, nil } +//SetupRecognizerStoreService connect to recognizers store service with GRPC +func SetupRecognizerStoreService(address string) (types.RecognizersStoreServiceClient, error) { + conn, err := connect(address) + if err != nil { + return nil, err + } + client := types.NewRecognizersStoreServiceClient(conn) + return client, nil +} + //SetupSchedulerService connect to scheduler service with GRPC func SetupSchedulerService(address string) (types.SchedulerServiceClient, error) { conn, err := connect(address) diff --git a/presidio-analyzer/analyzer/analyzer_engine.py b/presidio-analyzer/analyzer/analyzer_engine.py index 320d9f633..1276e240d 100644 --- a/presidio-analyzer/analyzer/analyzer_engine.py +++ b/presidio-analyzer/analyzer/analyzer_engine.py @@ -5,6 +5,7 @@ import analyze_pb2_grpc import common_pb2 + from analyzer import RecognizerRegistry # noqa: F401 loglevel = os.environ.get("LOG_LEVEL", "INFO") @@ -19,7 +20,7 @@ class AnalyzerEngine(analyze_pb2_grpc.AnalyzeServiceServicer): def __init__(self, registry=RecognizerRegistry()): # load all recognizers self.registry = registry - registry.load_recognizers("predefined-recognizers") + registry.load_predefined_recognizers() @staticmethod def __remove_duplicates(results): @@ -101,21 +102,6 @@ def analyze(self, text, entities, language, all_fields): return AnalyzerEngine.__remove_duplicates(results) - def add_pattern_recognizer(self, pattern_recognizer_dict): - """ - Adds a new recognizer - :param pattern_recognizer_dict: a dictionary representation - of a pattern recognizer - """ - self.registry.add_pattern_recognizer_from_dict(pattern_recognizer_dict) - - def remove_recognizer(self, name): - """ - Removes an existing recognizer, throws an exception if not found - :param name: name of recognizer to be removed - """ - self.registry.remove_recognizer(name) - @staticmethod def __convert_fields_to_entities(fields): # Convert fields to entities - will be changed once the API diff --git a/presidio-analyzer/analyzer/anonymize_image_pb2.py b/presidio-analyzer/analyzer/anonymize_image_pb2.py new file mode 100644 index 000000000..5d704c643 --- /dev/null +++ b/presidio-analyzer/analyzer/anonymize_image_pb2.py @@ -0,0 +1,245 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: anonymize-image.proto + +import sys +_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +from google.protobuf import descriptor_pb2 +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +import common_pb2 as common__pb2 +import template_pb2 as template__pb2 + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='anonymize-image.proto', + package='types', + syntax='proto3', + serialized_pb=_b('\n\x15\x61nonymize-image.proto\x12\x05types\x1a\x0c\x63ommon.proto\x1a\x0etemplate.proto\"\x99\x02\n\x18\x41nonymizeImageApiRequest\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\x12\x11\n\timageType\x18\x02 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x03 \x01(\t\x12 \n\x18\x61nonymizeImageTemplateId\x18\x04 \x01(\t\x12/\n\x0f\x61nalyzeTemplate\x18\x05 \x01(\x0b\x32\x16.types.AnalyzeTemplate\x12=\n\x16\x61nonymizeImageTemplate\x18\x06 \x01(\x0b\x32\x1d.types.AnonymizeImageTemplate\x12/\n\rdetectionType\x18\x07 \x01(\x0e\x32\x18.types.DetectionTypeEnum\"\xc4\x01\n\x15\x41nonymizeImageRequest\x12\x1b\n\x05image\x18\x01 \x01(\x0b\x32\x0c.types.Image\x12/\n\x08template\x18\x02 \x01(\x0b\x32\x1d.types.AnonymizeImageTemplate\x12/\n\rdetectionType\x18\x03 \x01(\x0e\x32\x18.types.DetectionTypeEnum\x12,\n\x0e\x61nalyzeResults\x18\x04 \x03(\x0b\x32\x14.types.AnalyzeResult\"5\n\x16\x41nonymizeImageResponse\x12\x1b\n\x05image\x18\x01 \x01(\x0b\x32\x0c.types.Image2_\n\x15\x41nonymizeImageService\x12\x46\n\x05\x41pply\x12\x1c.types.AnonymizeImageRequest\x1a\x1d.types.AnonymizeImageResponse\"\x00\x62\x06proto3') + , + dependencies=[common__pb2.DESCRIPTOR,template__pb2.DESCRIPTOR,]) + + + + +_ANONYMIZEIMAGEAPIREQUEST = _descriptor.Descriptor( + name='AnonymizeImageApiRequest', + full_name='types.AnonymizeImageApiRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='data', full_name='types.AnonymizeImageApiRequest.data', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='imageType', full_name='types.AnonymizeImageApiRequest.imageType', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='analyzeTemplateId', full_name='types.AnonymizeImageApiRequest.analyzeTemplateId', index=2, + number=3, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='anonymizeImageTemplateId', full_name='types.AnonymizeImageApiRequest.anonymizeImageTemplateId', index=3, + number=4, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='analyzeTemplate', full_name='types.AnonymizeImageApiRequest.analyzeTemplate', index=4, + number=5, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='anonymizeImageTemplate', full_name='types.AnonymizeImageApiRequest.anonymizeImageTemplate', index=5, + number=6, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='detectionType', full_name='types.AnonymizeImageApiRequest.detectionType', index=6, + number=7, type=14, cpp_type=8, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=63, + serialized_end=344, +) + + +_ANONYMIZEIMAGEREQUEST = _descriptor.Descriptor( + name='AnonymizeImageRequest', + full_name='types.AnonymizeImageRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='image', full_name='types.AnonymizeImageRequest.image', index=0, + number=1, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='template', full_name='types.AnonymizeImageRequest.template', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='detectionType', full_name='types.AnonymizeImageRequest.detectionType', index=2, + number=3, type=14, cpp_type=8, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='analyzeResults', full_name='types.AnonymizeImageRequest.analyzeResults', index=3, + number=4, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=347, + serialized_end=543, +) + + +_ANONYMIZEIMAGERESPONSE = _descriptor.Descriptor( + name='AnonymizeImageResponse', + full_name='types.AnonymizeImageResponse', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='image', full_name='types.AnonymizeImageResponse.image', index=0, + number=1, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=545, + serialized_end=598, +) + +_ANONYMIZEIMAGEAPIREQUEST.fields_by_name['analyzeTemplate'].message_type = template__pb2._ANALYZETEMPLATE +_ANONYMIZEIMAGEAPIREQUEST.fields_by_name['anonymizeImageTemplate'].message_type = template__pb2._ANONYMIZEIMAGETEMPLATE +_ANONYMIZEIMAGEAPIREQUEST.fields_by_name['detectionType'].enum_type = common__pb2._DETECTIONTYPEENUM +_ANONYMIZEIMAGEREQUEST.fields_by_name['image'].message_type = common__pb2._IMAGE +_ANONYMIZEIMAGEREQUEST.fields_by_name['template'].message_type = template__pb2._ANONYMIZEIMAGETEMPLATE +_ANONYMIZEIMAGEREQUEST.fields_by_name['detectionType'].enum_type = common__pb2._DETECTIONTYPEENUM +_ANONYMIZEIMAGEREQUEST.fields_by_name['analyzeResults'].message_type = common__pb2._ANALYZERESULT +_ANONYMIZEIMAGERESPONSE.fields_by_name['image'].message_type = common__pb2._IMAGE +DESCRIPTOR.message_types_by_name['AnonymizeImageApiRequest'] = _ANONYMIZEIMAGEAPIREQUEST +DESCRIPTOR.message_types_by_name['AnonymizeImageRequest'] = _ANONYMIZEIMAGEREQUEST +DESCRIPTOR.message_types_by_name['AnonymizeImageResponse'] = _ANONYMIZEIMAGERESPONSE +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +AnonymizeImageApiRequest = _reflection.GeneratedProtocolMessageType('AnonymizeImageApiRequest', (_message.Message,), dict( + DESCRIPTOR = _ANONYMIZEIMAGEAPIREQUEST, + __module__ = 'anonymize_image_pb2' + # @@protoc_insertion_point(class_scope:types.AnonymizeImageApiRequest) + )) +_sym_db.RegisterMessage(AnonymizeImageApiRequest) + +AnonymizeImageRequest = _reflection.GeneratedProtocolMessageType('AnonymizeImageRequest', (_message.Message,), dict( + DESCRIPTOR = _ANONYMIZEIMAGEREQUEST, + __module__ = 'anonymize_image_pb2' + # @@protoc_insertion_point(class_scope:types.AnonymizeImageRequest) + )) +_sym_db.RegisterMessage(AnonymizeImageRequest) + +AnonymizeImageResponse = _reflection.GeneratedProtocolMessageType('AnonymizeImageResponse', (_message.Message,), dict( + DESCRIPTOR = _ANONYMIZEIMAGERESPONSE, + __module__ = 'anonymize_image_pb2' + # @@protoc_insertion_point(class_scope:types.AnonymizeImageResponse) + )) +_sym_db.RegisterMessage(AnonymizeImageResponse) + + + +_ANONYMIZEIMAGESERVICE = _descriptor.ServiceDescriptor( + name='AnonymizeImageService', + full_name='types.AnonymizeImageService', + file=DESCRIPTOR, + index=0, + options=None, + serialized_start=600, + serialized_end=695, + methods=[ + _descriptor.MethodDescriptor( + name='Apply', + full_name='types.AnonymizeImageService.Apply', + index=0, + containing_service=None, + input_type=_ANONYMIZEIMAGEREQUEST, + output_type=_ANONYMIZEIMAGERESPONSE, + options=None, + ), +]) +_sym_db.RegisterServiceDescriptor(_ANONYMIZEIMAGESERVICE) + +DESCRIPTOR.services_by_name['AnonymizeImageService'] = _ANONYMIZEIMAGESERVICE + +# @@protoc_insertion_point(module_scope) diff --git a/presidio-analyzer/analyzer/anonymize_image_pb2_grpc.py b/presidio-analyzer/analyzer/anonymize_image_pb2_grpc.py new file mode 100644 index 000000000..a36e81654 --- /dev/null +++ b/presidio-analyzer/analyzer/anonymize_image_pb2_grpc.py @@ -0,0 +1,46 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +import grpc + +import anonymize_image_pb2 as anonymize__image__pb2 + + +class AnonymizeImageServiceStub(object): + """The Anonymize Service is a service that anonymizes a given the text using predefined analyzers fields and anonymize configurations. + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Apply = channel.unary_unary( + '/types.AnonymizeImageService/Apply', + request_serializer=anonymize__image__pb2.AnonymizeImageRequest.SerializeToString, + response_deserializer=anonymize__image__pb2.AnonymizeImageResponse.FromString, + ) + + +class AnonymizeImageServiceServicer(object): + """The Anonymize Service is a service that anonymizes a given the text using predefined analyzers fields and anonymize configurations. + """ + + def Apply(self, request, context): + """Apply method will execute on the given request and return the anonymize response with the sensitive text anonymized + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_AnonymizeImageServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Apply': grpc.unary_unary_rpc_method_handler( + servicer.Apply, + request_deserializer=anonymize__image__pb2.AnonymizeImageRequest.FromString, + response_serializer=anonymize__image__pb2.AnonymizeImageResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'types.AnonymizeImageService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) diff --git a/presidio-analyzer/analyzer/anonymize_json_pb2.py b/presidio-analyzer/analyzer/anonymize_json_pb2.py new file mode 100644 index 000000000..fb441f354 --- /dev/null +++ b/presidio-analyzer/analyzer/anonymize_json_pb2.py @@ -0,0 +1,179 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: anonymize-json.proto + +import sys +_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +from google.protobuf import descriptor_pb2 +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +import template_pb2 as template__pb2 + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='anonymize-json.proto', + package='types', + syntax='proto3', + serialized_pb=_b('\n\x14\x61nonymize-json.proto\x12\x05types\x1a\x0etemplate.proto\"\x92\x02\n\x17\x41nonymizeJsonApiRequest\x12\x0c\n\x04json\x18\x01 \x01(\t\x12\x14\n\x0cjsonSchemaId\x18\x02 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x03 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x04 \x01(\t\x12\x35\n\x12jsonSchemaTemplate\x18\x05 \x01(\x0b\x32\x19.types.JsonSchemaTemplate\x12/\n\x0f\x61nalyzeTemplate\x18\x06 \x01(\x0b\x32\x16.types.AnalyzeTemplate\x12\x33\n\x11\x61nonymizeTemplate\x18\x07 \x01(\x0b\x32\x18.types.AnonymizeTemplate\"\xb9\x01\n\x14\x41nonymizeJsonRequest\x12\x0c\n\x04json\x18\x01 \x01(\t\x12-\n\njsonSchema\x18\x02 \x01(\x0b\x32\x19.types.JsonSchemaTemplate\x12/\n\x0f\x61nalyzeTemplate\x18\x03 \x01(\x0b\x32\x16.types.AnalyzeTemplate\x12\x33\n\x11\x61nonymizeTemplate\x18\x04 \x01(\x0b\x32\x18.types.AnonymizeTemplateb\x06proto3') + , + dependencies=[template__pb2.DESCRIPTOR,]) + + + + +_ANONYMIZEJSONAPIREQUEST = _descriptor.Descriptor( + name='AnonymizeJsonApiRequest', + full_name='types.AnonymizeJsonApiRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='json', full_name='types.AnonymizeJsonApiRequest.json', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='jsonSchemaId', full_name='types.AnonymizeJsonApiRequest.jsonSchemaId', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='analyzeTemplateId', full_name='types.AnonymizeJsonApiRequest.analyzeTemplateId', index=2, + number=3, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='anonymizeTemplateId', full_name='types.AnonymizeJsonApiRequest.anonymizeTemplateId', index=3, + number=4, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='jsonSchemaTemplate', full_name='types.AnonymizeJsonApiRequest.jsonSchemaTemplate', index=4, + number=5, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='analyzeTemplate', full_name='types.AnonymizeJsonApiRequest.analyzeTemplate', index=5, + number=6, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='anonymizeTemplate', full_name='types.AnonymizeJsonApiRequest.anonymizeTemplate', index=6, + number=7, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=48, + serialized_end=322, +) + + +_ANONYMIZEJSONREQUEST = _descriptor.Descriptor( + name='AnonymizeJsonRequest', + full_name='types.AnonymizeJsonRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='json', full_name='types.AnonymizeJsonRequest.json', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='jsonSchema', full_name='types.AnonymizeJsonRequest.jsonSchema', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='analyzeTemplate', full_name='types.AnonymizeJsonRequest.analyzeTemplate', index=2, + number=3, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='anonymizeTemplate', full_name='types.AnonymizeJsonRequest.anonymizeTemplate', index=3, + number=4, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=325, + serialized_end=510, +) + +_ANONYMIZEJSONAPIREQUEST.fields_by_name['jsonSchemaTemplate'].message_type = template__pb2._JSONSCHEMATEMPLATE +_ANONYMIZEJSONAPIREQUEST.fields_by_name['analyzeTemplate'].message_type = template__pb2._ANALYZETEMPLATE +_ANONYMIZEJSONAPIREQUEST.fields_by_name['anonymizeTemplate'].message_type = template__pb2._ANONYMIZETEMPLATE +_ANONYMIZEJSONREQUEST.fields_by_name['jsonSchema'].message_type = template__pb2._JSONSCHEMATEMPLATE +_ANONYMIZEJSONREQUEST.fields_by_name['analyzeTemplate'].message_type = template__pb2._ANALYZETEMPLATE +_ANONYMIZEJSONREQUEST.fields_by_name['anonymizeTemplate'].message_type = template__pb2._ANONYMIZETEMPLATE +DESCRIPTOR.message_types_by_name['AnonymizeJsonApiRequest'] = _ANONYMIZEJSONAPIREQUEST +DESCRIPTOR.message_types_by_name['AnonymizeJsonRequest'] = _ANONYMIZEJSONREQUEST +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +AnonymizeJsonApiRequest = _reflection.GeneratedProtocolMessageType('AnonymizeJsonApiRequest', (_message.Message,), dict( + DESCRIPTOR = _ANONYMIZEJSONAPIREQUEST, + __module__ = 'anonymize_json_pb2' + # @@protoc_insertion_point(class_scope:types.AnonymizeJsonApiRequest) + )) +_sym_db.RegisterMessage(AnonymizeJsonApiRequest) + +AnonymizeJsonRequest = _reflection.GeneratedProtocolMessageType('AnonymizeJsonRequest', (_message.Message,), dict( + DESCRIPTOR = _ANONYMIZEJSONREQUEST, + __module__ = 'anonymize_json_pb2' + # @@protoc_insertion_point(class_scope:types.AnonymizeJsonRequest) + )) +_sym_db.RegisterMessage(AnonymizeJsonRequest) + + +# @@protoc_insertion_point(module_scope) diff --git a/presidio-analyzer/analyzer/anonymize_json_pb2_grpc.py b/presidio-analyzer/analyzer/anonymize_json_pb2_grpc.py new file mode 100644 index 000000000..a89435267 --- /dev/null +++ b/presidio-analyzer/analyzer/anonymize_json_pb2_grpc.py @@ -0,0 +1,3 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +import grpc + diff --git a/presidio-analyzer/analyzer/anonymize_pb2.py b/presidio-analyzer/analyzer/anonymize_pb2.py new file mode 100644 index 000000000..fedb90059 --- /dev/null +++ b/presidio-analyzer/analyzer/anonymize_pb2.py @@ -0,0 +1,220 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: anonymize.proto + +import sys +_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +from google.protobuf import descriptor_pb2 +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +import common_pb2 as common__pb2 +import template_pb2 as template__pb2 + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='anonymize.proto', + package='types', + syntax='proto3', + serialized_pb=_b('\n\x0f\x61nonymize.proto\x12\x05types\x1a\x0c\x63ommon.proto\x1a\x0etemplate.proto\"\xc1\x01\n\x13\x41nonymizeApiRequest\x12\x0c\n\x04text\x18\x01 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x02 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x03 \x01(\t\x12/\n\x0f\x61nalyzeTemplate\x18\x04 \x01(\x0b\x32\x16.types.AnalyzeTemplate\x12\x33\n\x11\x61nonymizeTemplate\x18\x05 \x01(\x0b\x32\x18.types.AnonymizeTemplate\"z\n\x10\x41nonymizeRequest\x12\x0c\n\x04text\x18\x01 \x01(\t\x12*\n\x08template\x18\x02 \x01(\x0b\x32\x18.types.AnonymizeTemplate\x12,\n\x0e\x61nalyzeResults\x18\x03 \x03(\x0b\x32\x14.types.AnalyzeResult\"!\n\x11\x41nonymizeResponse\x12\x0c\n\x04text\x18\x01 \x01(\t2P\n\x10\x41nonymizeService\x12<\n\x05\x41pply\x12\x17.types.AnonymizeRequest\x1a\x18.types.AnonymizeResponse\"\x00\x62\x06proto3') + , + dependencies=[common__pb2.DESCRIPTOR,template__pb2.DESCRIPTOR,]) + + + + +_ANONYMIZEAPIREQUEST = _descriptor.Descriptor( + name='AnonymizeApiRequest', + full_name='types.AnonymizeApiRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='text', full_name='types.AnonymizeApiRequest.text', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='analyzeTemplateId', full_name='types.AnonymizeApiRequest.analyzeTemplateId', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='anonymizeTemplateId', full_name='types.AnonymizeApiRequest.anonymizeTemplateId', index=2, + number=3, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='analyzeTemplate', full_name='types.AnonymizeApiRequest.analyzeTemplate', index=3, + number=4, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='anonymizeTemplate', full_name='types.AnonymizeApiRequest.anonymizeTemplate', index=4, + number=5, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=57, + serialized_end=250, +) + + +_ANONYMIZEREQUEST = _descriptor.Descriptor( + name='AnonymizeRequest', + full_name='types.AnonymizeRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='text', full_name='types.AnonymizeRequest.text', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='template', full_name='types.AnonymizeRequest.template', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='analyzeResults', full_name='types.AnonymizeRequest.analyzeResults', index=2, + number=3, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=252, + serialized_end=374, +) + + +_ANONYMIZERESPONSE = _descriptor.Descriptor( + name='AnonymizeResponse', + full_name='types.AnonymizeResponse', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='text', full_name='types.AnonymizeResponse.text', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=376, + serialized_end=409, +) + +_ANONYMIZEAPIREQUEST.fields_by_name['analyzeTemplate'].message_type = template__pb2._ANALYZETEMPLATE +_ANONYMIZEAPIREQUEST.fields_by_name['anonymizeTemplate'].message_type = template__pb2._ANONYMIZETEMPLATE +_ANONYMIZEREQUEST.fields_by_name['template'].message_type = template__pb2._ANONYMIZETEMPLATE +_ANONYMIZEREQUEST.fields_by_name['analyzeResults'].message_type = common__pb2._ANALYZERESULT +DESCRIPTOR.message_types_by_name['AnonymizeApiRequest'] = _ANONYMIZEAPIREQUEST +DESCRIPTOR.message_types_by_name['AnonymizeRequest'] = _ANONYMIZEREQUEST +DESCRIPTOR.message_types_by_name['AnonymizeResponse'] = _ANONYMIZERESPONSE +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +AnonymizeApiRequest = _reflection.GeneratedProtocolMessageType('AnonymizeApiRequest', (_message.Message,), dict( + DESCRIPTOR = _ANONYMIZEAPIREQUEST, + __module__ = 'anonymize_pb2' + # @@protoc_insertion_point(class_scope:types.AnonymizeApiRequest) + )) +_sym_db.RegisterMessage(AnonymizeApiRequest) + +AnonymizeRequest = _reflection.GeneratedProtocolMessageType('AnonymizeRequest', (_message.Message,), dict( + DESCRIPTOR = _ANONYMIZEREQUEST, + __module__ = 'anonymize_pb2' + # @@protoc_insertion_point(class_scope:types.AnonymizeRequest) + )) +_sym_db.RegisterMessage(AnonymizeRequest) + +AnonymizeResponse = _reflection.GeneratedProtocolMessageType('AnonymizeResponse', (_message.Message,), dict( + DESCRIPTOR = _ANONYMIZERESPONSE, + __module__ = 'anonymize_pb2' + # @@protoc_insertion_point(class_scope:types.AnonymizeResponse) + )) +_sym_db.RegisterMessage(AnonymizeResponse) + + + +_ANONYMIZESERVICE = _descriptor.ServiceDescriptor( + name='AnonymizeService', + full_name='types.AnonymizeService', + file=DESCRIPTOR, + index=0, + options=None, + serialized_start=411, + serialized_end=491, + methods=[ + _descriptor.MethodDescriptor( + name='Apply', + full_name='types.AnonymizeService.Apply', + index=0, + containing_service=None, + input_type=_ANONYMIZEREQUEST, + output_type=_ANONYMIZERESPONSE, + options=None, + ), +]) +_sym_db.RegisterServiceDescriptor(_ANONYMIZESERVICE) + +DESCRIPTOR.services_by_name['AnonymizeService'] = _ANONYMIZESERVICE + +# @@protoc_insertion_point(module_scope) diff --git a/presidio-analyzer/analyzer/anonymize_pb2_grpc.py b/presidio-analyzer/analyzer/anonymize_pb2_grpc.py new file mode 100644 index 000000000..741bf8e03 --- /dev/null +++ b/presidio-analyzer/analyzer/anonymize_pb2_grpc.py @@ -0,0 +1,46 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +import grpc + +import anonymize_pb2 as anonymize__pb2 + + +class AnonymizeServiceStub(object): + """The Anonymize Service is a service that anonymizes a given the text using predefined analyzers fields and anonymize configurations. + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Apply = channel.unary_unary( + '/types.AnonymizeService/Apply', + request_serializer=anonymize__pb2.AnonymizeRequest.SerializeToString, + response_deserializer=anonymize__pb2.AnonymizeResponse.FromString, + ) + + +class AnonymizeServiceServicer(object): + """The Anonymize Service is a service that anonymizes a given the text using predefined analyzers fields and anonymize configurations. + """ + + def Apply(self, request, context): + """Apply method will execute on the given request and return the anonymize response with the sensitive text anonymized + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_AnonymizeServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Apply': grpc.unary_unary_rpc_method_handler( + servicer.Apply, + request_deserializer=anonymize__pb2.AnonymizeRequest.FromString, + response_serializer=anonymize__pb2.AnonymizeResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'types.AnonymizeService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) diff --git a/presidio-analyzer/analyzer/datasink_pb2.py b/presidio-analyzer/analyzer/datasink_pb2.py new file mode 100644 index 000000000..5c19bdacf --- /dev/null +++ b/presidio-analyzer/analyzer/datasink_pb2.py @@ -0,0 +1,262 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: datasink.proto + +import sys +_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf.internal import enum_type_wrapper +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +from google.protobuf import descriptor_pb2 +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +import common_pb2 as common__pb2 +import template_pb2 as template__pb2 +import anonymize_pb2 as anonymize__pb2 + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='datasink.proto', + package='types', + syntax='proto3', + serialized_pb=_b('\n\x0e\x64\x61tasink.proto\x12\x05types\x1a\x0c\x63ommon.proto\x1a\x0etemplate.proto\x1a\x0f\x61nonymize.proto\"\x80\x01\n\x0f\x44\x61tasinkRequest\x12,\n\x0e\x61nalyzeResults\x18\x01 \x03(\x0b\x32\x14.types.AnalyzeResult\x12\x31\n\x0f\x61nonymizeResult\x18\x02 \x01(\x0b\x32\x18.types.AnonymizeResponse\x12\x0c\n\x04path\x18\x03 \x01(\t\"\x12\n\x10\x44\x61tasinkResponse\"\x13\n\x11\x43ompletionMessage*\x93\x01\n\x11\x44\x61tasinkTypesEnum\x12\t\n\x05mysql\x10\x00\x12\t\n\x05mssql\x10\x01\x12\x0c\n\x08postgres\x10\x02\x12\x0b\n\x07sqlite3\x10\x03\x12\n\n\x06oracle\x10\x04\x12\t\n\x05kafka\x10\x05\x12\x0c\n\x08\x65venthub\x10\x06\x12\x06\n\x02s3\x10\x07\x12\r\n\tazureblob\x10\x08\x12\x11\n\rgooglestorage\x10\t2\xcc\x01\n\x0f\x44\x61tasinkService\x12:\n\x05\x41pply\x12\x16.types.DatasinkRequest\x1a\x17.types.DatasinkResponse\"\x00\x12:\n\x04Init\x12\x17.types.DatasinkTemplate\x1a\x17.types.DatasinkResponse\"\x00\x12\x41\n\nCompletion\x12\x18.types.CompletionMessage\x1a\x17.types.DatasinkResponse\"\x00\x62\x06proto3') + , + dependencies=[common__pb2.DESCRIPTOR,template__pb2.DESCRIPTOR,anonymize__pb2.DESCRIPTOR,]) + +_DATASINKTYPESENUM = _descriptor.EnumDescriptor( + name='DatasinkTypesEnum', + full_name='types.DatasinkTypesEnum', + filename=None, + file=DESCRIPTOR, + values=[ + _descriptor.EnumValueDescriptor( + name='mysql', index=0, number=0, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='mssql', index=1, number=1, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='postgres', index=2, number=2, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='sqlite3', index=3, number=3, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='oracle', index=4, number=4, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='kafka', index=5, number=5, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='eventhub', index=6, number=6, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='s3', index=7, number=7, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='azureblob', index=8, number=8, + options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='googlestorage', index=9, number=9, + options=None, + type=None), + ], + containing_type=None, + options=None, + serialized_start=245, + serialized_end=392, +) +_sym_db.RegisterEnumDescriptor(_DATASINKTYPESENUM) + +DatasinkTypesEnum = enum_type_wrapper.EnumTypeWrapper(_DATASINKTYPESENUM) +mysql = 0 +mssql = 1 +postgres = 2 +sqlite3 = 3 +oracle = 4 +kafka = 5 +eventhub = 6 +s3 = 7 +azureblob = 8 +googlestorage = 9 + + + +_DATASINKREQUEST = _descriptor.Descriptor( + name='DatasinkRequest', + full_name='types.DatasinkRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='analyzeResults', full_name='types.DatasinkRequest.analyzeResults', index=0, + number=1, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='anonymizeResult', full_name='types.DatasinkRequest.anonymizeResult', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='path', full_name='types.DatasinkRequest.path', index=2, + number=3, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=73, + serialized_end=201, +) + + +_DATASINKRESPONSE = _descriptor.Descriptor( + name='DatasinkResponse', + full_name='types.DatasinkResponse', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=203, + serialized_end=221, +) + + +_COMPLETIONMESSAGE = _descriptor.Descriptor( + name='CompletionMessage', + full_name='types.CompletionMessage', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=223, + serialized_end=242, +) + +_DATASINKREQUEST.fields_by_name['analyzeResults'].message_type = common__pb2._ANALYZERESULT +_DATASINKREQUEST.fields_by_name['anonymizeResult'].message_type = anonymize__pb2._ANONYMIZERESPONSE +DESCRIPTOR.message_types_by_name['DatasinkRequest'] = _DATASINKREQUEST +DESCRIPTOR.message_types_by_name['DatasinkResponse'] = _DATASINKRESPONSE +DESCRIPTOR.message_types_by_name['CompletionMessage'] = _COMPLETIONMESSAGE +DESCRIPTOR.enum_types_by_name['DatasinkTypesEnum'] = _DATASINKTYPESENUM +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +DatasinkRequest = _reflection.GeneratedProtocolMessageType('DatasinkRequest', (_message.Message,), dict( + DESCRIPTOR = _DATASINKREQUEST, + __module__ = 'datasink_pb2' + # @@protoc_insertion_point(class_scope:types.DatasinkRequest) + )) +_sym_db.RegisterMessage(DatasinkRequest) + +DatasinkResponse = _reflection.GeneratedProtocolMessageType('DatasinkResponse', (_message.Message,), dict( + DESCRIPTOR = _DATASINKRESPONSE, + __module__ = 'datasink_pb2' + # @@protoc_insertion_point(class_scope:types.DatasinkResponse) + )) +_sym_db.RegisterMessage(DatasinkResponse) + +CompletionMessage = _reflection.GeneratedProtocolMessageType('CompletionMessage', (_message.Message,), dict( + DESCRIPTOR = _COMPLETIONMESSAGE, + __module__ = 'datasink_pb2' + # @@protoc_insertion_point(class_scope:types.CompletionMessage) + )) +_sym_db.RegisterMessage(CompletionMessage) + + + +_DATASINKSERVICE = _descriptor.ServiceDescriptor( + name='DatasinkService', + full_name='types.DatasinkService', + file=DESCRIPTOR, + index=0, + options=None, + serialized_start=395, + serialized_end=599, + methods=[ + _descriptor.MethodDescriptor( + name='Apply', + full_name='types.DatasinkService.Apply', + index=0, + containing_service=None, + input_type=_DATASINKREQUEST, + output_type=_DATASINKRESPONSE, + options=None, + ), + _descriptor.MethodDescriptor( + name='Init', + full_name='types.DatasinkService.Init', + index=1, + containing_service=None, + input_type=template__pb2._DATASINKTEMPLATE, + output_type=_DATASINKRESPONSE, + options=None, + ), + _descriptor.MethodDescriptor( + name='Completion', + full_name='types.DatasinkService.Completion', + index=2, + containing_service=None, + input_type=_COMPLETIONMESSAGE, + output_type=_DATASINKRESPONSE, + options=None, + ), +]) +_sym_db.RegisterServiceDescriptor(_DATASINKSERVICE) + +DESCRIPTOR.services_by_name['DatasinkService'] = _DATASINKSERVICE + +# @@protoc_insertion_point(module_scope) diff --git a/presidio-analyzer/analyzer/datasink_pb2_grpc.py b/presidio-analyzer/analyzer/datasink_pb2_grpc.py new file mode 100644 index 000000000..540ed76c0 --- /dev/null +++ b/presidio-analyzer/analyzer/datasink_pb2_grpc.py @@ -0,0 +1,81 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +import grpc + +import datasink_pb2 as datasink__pb2 +import template_pb2 as template__pb2 + + +class DatasinkServiceStub(object): + """The data sink service represents the service for writing the results of the analyzing and anonymizng service. + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Apply = channel.unary_unary( + '/types.DatasinkService/Apply', + request_serializer=datasink__pb2.DatasinkRequest.SerializeToString, + response_deserializer=datasink__pb2.DatasinkResponse.FromString, + ) + self.Init = channel.unary_unary( + '/types.DatasinkService/Init', + request_serializer=template__pb2.DatasinkTemplate.SerializeToString, + response_deserializer=datasink__pb2.DatasinkResponse.FromString, + ) + self.Completion = channel.unary_unary( + '/types.DatasinkService/Completion', + request_serializer=datasink__pb2.CompletionMessage.SerializeToString, + response_deserializer=datasink__pb2.DatasinkResponse.FromString, + ) + + +class DatasinkServiceServicer(object): + """The data sink service represents the service for writing the results of the analyzing and anonymizng service. + """ + + def Apply(self, request, context): + """Apply method will execute on the given request and return whether the result where written successfully to the destination + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def Init(self, request, context): + """Init the data sink service with the provided data sink template + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def Completion(self, request, context): + """Completion method for indicating that the scanning job is done + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_DatasinkServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Apply': grpc.unary_unary_rpc_method_handler( + servicer.Apply, + request_deserializer=datasink__pb2.DatasinkRequest.FromString, + response_serializer=datasink__pb2.DatasinkResponse.SerializeToString, + ), + 'Init': grpc.unary_unary_rpc_method_handler( + servicer.Init, + request_deserializer=template__pb2.DatasinkTemplate.FromString, + response_serializer=datasink__pb2.DatasinkResponse.SerializeToString, + ), + 'Completion': grpc.unary_unary_rpc_method_handler( + servicer.Completion, + request_deserializer=datasink__pb2.CompletionMessage.FromString, + response_serializer=datasink__pb2.DatasinkResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'types.DatasinkService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) diff --git a/presidio-analyzer/analyzer/entity_recognizer.py b/presidio-analyzer/analyzer/entity_recognizer.py index 3f54d9d37..76f1ca07d 100644 --- a/presidio-analyzer/analyzer/entity_recognizer.py +++ b/presidio-analyzer/analyzer/entity_recognizer.py @@ -34,6 +34,8 @@ def __init__(self, supported_entities, name=None, supported_language="en", self.logger = logging.getLogger(__name__) self.logger.setLevel(loglevel) self.load() + logging.info("Loaded recognizer: %s", self.name) + self.is_loaded = True @abstractmethod def load(self): diff --git a/presidio-analyzer/analyzer/ocr_pb2.py b/presidio-analyzer/analyzer/ocr_pb2.py new file mode 100644 index 000000000..c5ffdd6a8 --- /dev/null +++ b/presidio-analyzer/analyzer/ocr_pb2.py @@ -0,0 +1,136 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: ocr.proto + +import sys +_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +from google.protobuf import descriptor_pb2 +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +import common_pb2 as common__pb2 + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='ocr.proto', + package='types', + syntax='proto3', + serialized_pb=_b('\n\tocr.proto\x12\x05types\x1a\x0c\x63ommon.proto\")\n\nOcrRequest\x12\x1b\n\x05image\x18\x01 \x01(\x0b\x32\x0c.types.Image\"*\n\x0bOcrResponse\x12\x1b\n\x05image\x18\x01 \x01(\x0b\x32\x0c.types.Image2>\n\nOcrService\x12\x30\n\x05\x41pply\x12\x11.types.OcrRequest\x1a\x12.types.OcrResponse\"\x00\x62\x06proto3') + , + dependencies=[common__pb2.DESCRIPTOR,]) + + + + +_OCRREQUEST = _descriptor.Descriptor( + name='OcrRequest', + full_name='types.OcrRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='image', full_name='types.OcrRequest.image', index=0, + number=1, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=34, + serialized_end=75, +) + + +_OCRRESPONSE = _descriptor.Descriptor( + name='OcrResponse', + full_name='types.OcrResponse', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='image', full_name='types.OcrResponse.image', index=0, + number=1, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=77, + serialized_end=119, +) + +_OCRREQUEST.fields_by_name['image'].message_type = common__pb2._IMAGE +_OCRRESPONSE.fields_by_name['image'].message_type = common__pb2._IMAGE +DESCRIPTOR.message_types_by_name['OcrRequest'] = _OCRREQUEST +DESCRIPTOR.message_types_by_name['OcrResponse'] = _OCRRESPONSE +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +OcrRequest = _reflection.GeneratedProtocolMessageType('OcrRequest', (_message.Message,), dict( + DESCRIPTOR = _OCRREQUEST, + __module__ = 'ocr_pb2' + # @@protoc_insertion_point(class_scope:types.OcrRequest) + )) +_sym_db.RegisterMessage(OcrRequest) + +OcrResponse = _reflection.GeneratedProtocolMessageType('OcrResponse', (_message.Message,), dict( + DESCRIPTOR = _OCRRESPONSE, + __module__ = 'ocr_pb2' + # @@protoc_insertion_point(class_scope:types.OcrResponse) + )) +_sym_db.RegisterMessage(OcrResponse) + + + +_OCRSERVICE = _descriptor.ServiceDescriptor( + name='OcrService', + full_name='types.OcrService', + file=DESCRIPTOR, + index=0, + options=None, + serialized_start=121, + serialized_end=183, + methods=[ + _descriptor.MethodDescriptor( + name='Apply', + full_name='types.OcrService.Apply', + index=0, + containing_service=None, + input_type=_OCRREQUEST, + output_type=_OCRRESPONSE, + options=None, + ), +]) +_sym_db.RegisterServiceDescriptor(_OCRSERVICE) + +DESCRIPTOR.services_by_name['OcrService'] = _OCRSERVICE + +# @@protoc_insertion_point(module_scope) diff --git a/presidio-analyzer/analyzer/ocr_pb2_grpc.py b/presidio-analyzer/analyzer/ocr_pb2_grpc.py new file mode 100644 index 000000000..91069b515 --- /dev/null +++ b/presidio-analyzer/analyzer/ocr_pb2_grpc.py @@ -0,0 +1,46 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +import grpc + +import ocr_pb2 as ocr__pb2 + + +class OcrServiceStub(object): + """The Ocr Service is a service performing OCR on images + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Apply = channel.unary_unary( + '/types.OcrService/Apply', + request_serializer=ocr__pb2.OcrRequest.SerializeToString, + response_deserializer=ocr__pb2.OcrResponse.FromString, + ) + + +class OcrServiceServicer(object): + """The Ocr Service is a service performing OCR on images + """ + + def Apply(self, request, context): + """Apply method will execute on the given request and return the anonymize response with the sensitive text anonymized + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_OcrServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Apply': grpc.unary_unary_rpc_method_handler( + servicer.Apply, + request_deserializer=ocr__pb2.OcrRequest.FromString, + response_serializer=ocr__pb2.OcrResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'types.OcrService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) diff --git a/presidio-analyzer/analyzer/pattern.py b/presidio-analyzer/analyzer/pattern.py index c14f6a482..7a7fd52b5 100644 --- a/presidio-analyzer/analyzer/pattern.py +++ b/presidio-analyzer/analyzer/pattern.py @@ -1,15 +1,15 @@ class Pattern: - def __init__(self, name, pattern, strength): + def __init__(self, name, regex, score): """ A class that represents a regex pattern. :param name: the name of the pattern - :param pattern: the regex pattern to detect - :param strength: the pattern's strength (values varies 0-1) + :param regex: the regex pattern to detect + :param score: the pattern's strength (values varies 0-1) """ self.name = name - self.pattern = pattern - self.strength = strength + self.regex = regex + self.score = score def to_dict(self): """ @@ -18,8 +18,8 @@ def to_dict(self): """ return_dict = {"name": self.name, - "strength": self.strength, - "pattern": self.pattern + "score": self.score, + "regex": self.regex } return return_dict diff --git a/presidio-analyzer/analyzer/pattern_recognizer.py b/presidio-analyzer/analyzer/pattern_recognizer.py index 70914d25e..d8b218887 100644 --- a/presidio-analyzer/analyzer/pattern_recognizer.py +++ b/presidio-analyzer/analyzer/pattern_recognizer.py @@ -71,7 +71,7 @@ def __black_list_to_regex(black_list): :return:the regex of the words for detection """ regex = r"(?:^|(?<= ))(" + '|'.join(black_list) + r")(?:(?= )|$)" - return Pattern(name="black_list", pattern=regex, strength=1.0) + return Pattern(name="black_list", regex=regex, score=1.0) # pylint: disable=unused-argument, no-self-use def validate_result(self, pattern_text, pattern_result): @@ -101,7 +101,7 @@ def __analyze_patterns(self, text): for pattern in self.patterns: match_start_time = datetime.datetime.now() matches = re.finditer( - pattern.pattern, + pattern.regex, text, flags=re.IGNORECASE | re.DOTALL | re.MULTILINE) match_time = datetime.datetime.now() - match_start_time @@ -119,7 +119,7 @@ def __analyze_patterns(self, text): continue res = RecognizerResult(self.supported_entities[0], start, end, - pattern.strength) + pattern.score) res = self.validate_result(current_match, res) if res and res.score != EntityRecognizer.MIN_SCORE: diff --git a/presidio-analyzer/analyzer/recognizer_registry/__init__.py b/presidio-analyzer/analyzer/recognizer_registry/__init__.py index e69de29bb..5b699f9d2 100644 --- a/presidio-analyzer/analyzer/recognizer_registry/__init__.py +++ b/presidio-analyzer/analyzer/recognizer_registry/__init__.py @@ -0,0 +1,4 @@ +# pylint: disable=unused-import +from analyzer.recognizer_registry.recognizers_store_api import ( # noqa: F401 + RecognizerStoreApi +) diff --git a/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py b/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py index 2cb996c12..94b7b3077 100644 --- a/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py +++ b/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py @@ -1,11 +1,13 @@ +import time import logging -from analyzer import PatternRecognizer +from analyzer.recognizer_registry import RecognizerStoreApi from analyzer.predefined_recognizers import CreditCardRecognizer, \ SpacyRecognizer, CryptoRecognizer, DomainRecognizer, \ EmailRecognizer, IbanRecognizer, IpRecognizer, NhsRecognizer, \ UsBankRecognizer, UsLicenseRecognizer, \ - UsItinRecognizer, UsPassportRecognizer, UsPhoneRecognizer, UsSsnRecognizer + UsItinRecognizer, UsPassportRecognizer, UsPhoneRecognizer, \ + UsSsnRecognizer class RecognizerRegistry: @@ -13,56 +15,48 @@ class RecognizerRegistry: Detects, registers and holds all recognizers to be used by the analyzer """ - def __init__(self, recognizers=None): - if recognizers is None: - recognizers = [] - self.recognizers = recognizers - - # pylint: disable=unused-argument - def load_recognizers(self, path): + def __init__(self, recognizer_store_api=RecognizerStoreApi(), + recognizers=None): + """ + :param recognizer_store_api: An instance of a class that has custom + recognizers management functionallity (insert, update, get, + delete). The default store if nothing is else is provided is + a store that uses a persistent storage + :param recognizers: An optional list of recognizers that will be + available in addition to the predefined recognizers and the + custom recognizers + """ + if recognizers: + self.recognizers = recognizers + else: + self.recognizers = [] + + # loaded_hash is the hash value of the recognizers in the recognizer + # store. It is used to avoid fetching recognizers when there hasn't + # been a change to the recognizers state. + self.loaded_hash = None + # the loaded_timestamp is used for debugging purposes, so it will be + # easy to understand when did the last fetching occured + self.loaded_timestamp = None + self.loaded_custom_recognizers = [] + self.store_api = recognizer_store_api + + def load_predefined_recognizers(self): # TODO: Change the code to dynamic loading - # Task #598: Support loading of the pre-defined recognizers # from the given path. - self.recognizers.extend([CreditCardRecognizer(), - SpacyRecognizer(), - CryptoRecognizer(), DomainRecognizer(), - EmailRecognizer(), IbanRecognizer(), - IpRecognizer(), NhsRecognizer(), - UsBankRecognizer(), UsLicenseRecognizer(), - UsItinRecognizer(), UsPassportRecognizer(), - UsPhoneRecognizer(), UsSsnRecognizer()]) - - def add_pattern_recognizer_from_dict(self, recognizer_dict): - """ - Creates a pattern recognizer from a dictionary - and adds it to the recognizers list - :param recognizer_dict: A pattern recognizer serialized - into a dictionary - """ - - pattern_recognizer = PatternRecognizer.from_dict(recognizer_dict) - - for rec in self.recognizers: - if rec.name == pattern_recognizer.name: - raise ValueError( - "Recognizer of name {} is already defined".format( - rec.name)) - - self.recognizers.append(pattern_recognizer) - - def remove_recognizer(self, name): - """ - Removes a recognizer by the given name, from the recognizers list. - :param name: The recognizer name - """ - found = False - for index, rec in enumerate(self.recognizers): - if rec.name == name: - found = True - self.recognizers.pop(index) - - if not found: - raise ValueError("Requested recognizer was not found") + # Currently this is not integrated into the init method to speed up + # loading time if these are not actually needed (SpaCy for example) is + # time consuming to load + self.recognizers.extend([ + CreditCardRecognizer(), + SpacyRecognizer(), + CryptoRecognizer(), DomainRecognizer(), + EmailRecognizer(), IbanRecognizer(), + IpRecognizer(), NhsRecognizer(), + UsBankRecognizer(), UsLicenseRecognizer(), + UsItinRecognizer(), UsPassportRecognizer(), + UsPhoneRecognizer(), UsSsnRecognizer()]) def get_recognizers(self, language, entities=None, all_fields=False): """ @@ -80,13 +74,20 @@ def get_recognizers(self, language, entities=None, all_fields=False): if entities is None and all_fields is False: raise ValueError("No entities provided") + all_possible_recognizers = self.recognizers.copy() + custom_recognizers = self.get_custom_recognizers() + all_possible_recognizers.extend(custom_recognizers) + logging.info("Found %d (total) custom recognizers", + len(custom_recognizers)) + + # filter out unwanted recognizers to_return = [] if all_fields: - to_return = [rec for rec in self.recognizers if + to_return = [rec for rec in all_possible_recognizers if language == rec.supported_language] else: for entity in entities: - subset = [rec for rec in self.recognizers if + subset = [rec for rec in all_possible_recognizers if entity in rec.supported_entities and language == rec.supported_language] @@ -97,8 +98,57 @@ def get_recognizers(self, language, entities=None, all_fields=False): else: to_return.extend(subset) + logging.info( + "Returning a total of %d recognizers (predefined + custom)", + len(to_return)) + if not to_return: raise ValueError( "No matching recognizers were found to serve the request.") return to_return + + def get_custom_recognizers(self): + """ + Returns a list of custom recognizers retrieved from the store object + """ + + if self.loaded_hash is not None: + logging.info( + "Analyzer loaded custom recognizers on: %s [hash %s]", + time.strftime( + '%Y-%m-%d %H:%M:%S', + time.localtime(int(self.loaded_timestamp))), + self.loaded_hash) + else: + logging.info("Analyzer loaded custom recognizers on: Never") + + latest_hash = self.store_api.get_latest_hash() + # is update time is not set, no custom recognizers in storage, skip + if latest_hash != "": + logging.info( + "Persistent storage has hash: %s", + latest_hash) + # check if anything updated since last time + if self.loaded_hash is None or \ + latest_hash != self.loaded_hash: + self.loaded_timestamp = int(time.time()) + self.loaded_hash = latest_hash + + self.loaded_custom_recognizers = [] + # read all values + logging.info( + "Requesting custom recognizers from the storage...") + + raw_recognizers = self.store_api.get_all_recognizers() + if raw_recognizers is None or not raw_recognizers: + logging.info( + "No custom recognizers found") + return [] + + logging.info( + "Found %d recognizers in the storage", + len(raw_recognizers)) + self.loaded_custom_recognizers = raw_recognizers + + return self.loaded_custom_recognizers diff --git a/presidio-analyzer/analyzer/recognizer_registry/recognizers_store_api.py b/presidio-analyzer/analyzer/recognizer_registry/recognizers_store_api.py new file mode 100644 index 000000000..ab9030f86 --- /dev/null +++ b/presidio-analyzer/analyzer/recognizer_registry/recognizers_store_api.py @@ -0,0 +1,80 @@ +import logging +import os + +import grpc +import recognizers_store_pb2 +import recognizers_store_pb2_grpc + +from analyzer import PatternRecognizer +from analyzer import Pattern + + +class RecognizerStoreApi: + """ The RecognizerStoreApi is the object that talks to the remote + recognizers store service and get the recognizers / hash + """ + + def __init__(self): + try: + recognizers_store_svc_url = \ + os.environ["RECOGNIZERS_STORE_SVC_ADDRESS"] + except KeyError: + recognizers_store_svc_url = "localhost:3004" + + channel = grpc.insecure_channel(recognizers_store_svc_url) + self.rs_stub = recognizers_store_pb2_grpc.RecognizersStoreServiceStub( + channel) + + def get_latest_hash(self): + """ + Returns the hash of all the stored custom recognizers. Returns empty + string in case of an error (e.g. the store is completly empty) + """ + hash_request = \ + recognizers_store_pb2.RecognizerGetHashRequest() + + last_hash = "" + try: + # todo: task 812: Change to pub sub pattern + last_hash = self.rs_stub.ApplyGetHash( + hash_request).recognizersHash + except grpc.RpcError: + logging.info("Failed to get recognizers hash") + return "" + + logging.info("Latest hash found in store is: %d", last_hash) + return last_hash + + def get_all_recognizers(self): + """ + Returns a list of CustomRecognizer which were created from the + recognizers stored in the underlying store + """ + req = recognizers_store_pb2.RecognizersGetAllRequest() + raw_recognizers = [] + + try: + raw_recognizers = self.rs_stub.ApplyGetAll(req).recognizers + + except grpc.RpcError: + logging.info("Failed getting recognizers from the remote store. \ + Returning an empty list") + return raw_recognizers + + custom_recognizers = [] + for new_recognizer in raw_recognizers: + patterns = [] + for pat in new_recognizer.patterns: + patterns.extend( + [Pattern(pat.name, pat.regex, pat.score)]) + new_custom_recognizer = PatternRecognizer( + name=new_recognizer.name, + supported_entity=new_recognizer.entity, + supported_language=new_recognizer.language, + black_list=new_recognizer.blacklist, + context=new_recognizer.contextPhrases, + patterns=patterns) + custom_recognizers.append( + new_custom_recognizer) + + return custom_recognizers diff --git a/presidio-analyzer/analyzer/recognizers_store_pb2.py b/presidio-analyzer/analyzer/recognizers_store_pb2.py new file mode 100644 index 000000000..5ec66b295 --- /dev/null +++ b/presidio-analyzer/analyzer/recognizers_store_pb2.py @@ -0,0 +1,520 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: recognizers_store.proto + +import sys +_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +from google.protobuf import descriptor_pb2 +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='recognizers_store.proto', + package='types', + syntax='proto3', + serialized_pb=_b('\n\x17recognizers_store.proto\x12\x05types\"5\n\x07Pattern\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05regex\x18\x02 \x01(\t\x12\r\n\x05score\x18\x03 \x01(\x02\"\x90\x01\n\x11PatternRecognizer\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06\x65ntity\x18\x02 \x01(\t\x12\x10\n\x08language\x18\x03 \x01(\t\x12 \n\x08patterns\x18\x04 \x03(\x0b\x32\x0e.types.Pattern\x12\x11\n\tblacklist\x18\x05 \x03(\t\x12\x16\n\x0e\x63ontextPhrases\x18\x06 \x03(\t\"J\n\x1fRecognizerInsertOrUpdateRequest\x12\'\n\x05value\x18\x01 \x01(\x0b\x32\x18.types.PatternRecognizer\"\'\n\x17RecognizerDeleteRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\"$\n\x14RecognizerGetRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x1a\n\x18RecognizersGetAllRequest\"\x1a\n\x18RecognizerGetHashRequest\"\x1a\n\x18RecognizersStoreResponse\"G\n\x16RecognizersGetResponse\x12-\n\x0brecognizers\x18\x01 \x03(\x0b\x32\x18.types.PatternRecognizer\"1\n\x16RecognizerHashResponse\x12\x17\n\x0frecognizersHash\x18\x01 \x01(\t2\x8c\x04\n\x17RecognizersStoreService\x12X\n\x0b\x41pplyInsert\x12&.types.RecognizerInsertOrUpdateRequest\x1a\x1f.types.RecognizersStoreResponse\"\x00\x12X\n\x0b\x41pplyUpdate\x12&.types.RecognizerInsertOrUpdateRequest\x1a\x1f.types.RecognizersStoreResponse\"\x00\x12P\n\x0b\x41pplyDelete\x12\x1e.types.RecognizerDeleteRequest\x1a\x1f.types.RecognizersStoreResponse\"\x00\x12H\n\x08\x41pplyGet\x12\x1b.types.RecognizerGetRequest\x1a\x1d.types.RecognizersGetResponse\"\x00\x12O\n\x0b\x41pplyGetAll\x12\x1f.types.RecognizersGetAllRequest\x1a\x1d.types.RecognizersGetResponse\"\x00\x12P\n\x0c\x41pplyGetHash\x12\x1f.types.RecognizerGetHashRequest\x1a\x1d.types.RecognizerHashResponse\"\x00\x62\x06proto3') +) + + + + +_PATTERN = _descriptor.Descriptor( + name='Pattern', + full_name='types.Pattern', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='name', full_name='types.Pattern.name', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='regex', full_name='types.Pattern.regex', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='score', full_name='types.Pattern.score', index=2, + number=3, type=2, cpp_type=6, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=34, + serialized_end=87, +) + + +_PATTERNRECOGNIZER = _descriptor.Descriptor( + name='PatternRecognizer', + full_name='types.PatternRecognizer', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='name', full_name='types.PatternRecognizer.name', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='entity', full_name='types.PatternRecognizer.entity', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='language', full_name='types.PatternRecognizer.language', index=2, + number=3, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='patterns', full_name='types.PatternRecognizer.patterns', index=3, + number=4, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='blacklist', full_name='types.PatternRecognizer.blacklist', index=4, + number=5, type=9, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='contextPhrases', full_name='types.PatternRecognizer.contextPhrases', index=5, + number=6, type=9, cpp_type=9, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=90, + serialized_end=234, +) + + +_RECOGNIZERINSERTORUPDATEREQUEST = _descriptor.Descriptor( + name='RecognizerInsertOrUpdateRequest', + full_name='types.RecognizerInsertOrUpdateRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='value', full_name='types.RecognizerInsertOrUpdateRequest.value', index=0, + number=1, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=236, + serialized_end=310, +) + + +_RECOGNIZERDELETEREQUEST = _descriptor.Descriptor( + name='RecognizerDeleteRequest', + full_name='types.RecognizerDeleteRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='name', full_name='types.RecognizerDeleteRequest.name', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=312, + serialized_end=351, +) + + +_RECOGNIZERGETREQUEST = _descriptor.Descriptor( + name='RecognizerGetRequest', + full_name='types.RecognizerGetRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='name', full_name='types.RecognizerGetRequest.name', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=353, + serialized_end=389, +) + + +_RECOGNIZERSGETALLREQUEST = _descriptor.Descriptor( + name='RecognizersGetAllRequest', + full_name='types.RecognizersGetAllRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=391, + serialized_end=417, +) + + +_RECOGNIZERGETHASHREQUEST = _descriptor.Descriptor( + name='RecognizerGetHashRequest', + full_name='types.RecognizerGetHashRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=419, + serialized_end=445, +) + + +_RECOGNIZERSSTORERESPONSE = _descriptor.Descriptor( + name='RecognizersStoreResponse', + full_name='types.RecognizersStoreResponse', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=447, + serialized_end=473, +) + + +_RECOGNIZERSGETRESPONSE = _descriptor.Descriptor( + name='RecognizersGetResponse', + full_name='types.RecognizersGetResponse', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='recognizers', full_name='types.RecognizersGetResponse.recognizers', index=0, + number=1, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=475, + serialized_end=546, +) + + +_RECOGNIZERHASHRESPONSE = _descriptor.Descriptor( + name='RecognizerHashResponse', + full_name='types.RecognizerHashResponse', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='recognizersHash', full_name='types.RecognizerHashResponse.recognizersHash', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=548, + serialized_end=597, +) + +_PATTERNRECOGNIZER.fields_by_name['patterns'].message_type = _PATTERN +_RECOGNIZERINSERTORUPDATEREQUEST.fields_by_name['value'].message_type = _PATTERNRECOGNIZER +_RECOGNIZERSGETRESPONSE.fields_by_name['recognizers'].message_type = _PATTERNRECOGNIZER +DESCRIPTOR.message_types_by_name['Pattern'] = _PATTERN +DESCRIPTOR.message_types_by_name['PatternRecognizer'] = _PATTERNRECOGNIZER +DESCRIPTOR.message_types_by_name['RecognizerInsertOrUpdateRequest'] = _RECOGNIZERINSERTORUPDATEREQUEST +DESCRIPTOR.message_types_by_name['RecognizerDeleteRequest'] = _RECOGNIZERDELETEREQUEST +DESCRIPTOR.message_types_by_name['RecognizerGetRequest'] = _RECOGNIZERGETREQUEST +DESCRIPTOR.message_types_by_name['RecognizersGetAllRequest'] = _RECOGNIZERSGETALLREQUEST +DESCRIPTOR.message_types_by_name['RecognizerGetHashRequest'] = _RECOGNIZERGETHASHREQUEST +DESCRIPTOR.message_types_by_name['RecognizersStoreResponse'] = _RECOGNIZERSSTORERESPONSE +DESCRIPTOR.message_types_by_name['RecognizersGetResponse'] = _RECOGNIZERSGETRESPONSE +DESCRIPTOR.message_types_by_name['RecognizerHashResponse'] = _RECOGNIZERHASHRESPONSE +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +Pattern = _reflection.GeneratedProtocolMessageType('Pattern', (_message.Message,), dict( + DESCRIPTOR = _PATTERN, + __module__ = 'recognizers_store_pb2' + # @@protoc_insertion_point(class_scope:types.Pattern) + )) +_sym_db.RegisterMessage(Pattern) + +PatternRecognizer = _reflection.GeneratedProtocolMessageType('PatternRecognizer', (_message.Message,), dict( + DESCRIPTOR = _PATTERNRECOGNIZER, + __module__ = 'recognizers_store_pb2' + # @@protoc_insertion_point(class_scope:types.PatternRecognizer) + )) +_sym_db.RegisterMessage(PatternRecognizer) + +RecognizerInsertOrUpdateRequest = _reflection.GeneratedProtocolMessageType('RecognizerInsertOrUpdateRequest', (_message.Message,), dict( + DESCRIPTOR = _RECOGNIZERINSERTORUPDATEREQUEST, + __module__ = 'recognizers_store_pb2' + # @@protoc_insertion_point(class_scope:types.RecognizerInsertOrUpdateRequest) + )) +_sym_db.RegisterMessage(RecognizerInsertOrUpdateRequest) + +RecognizerDeleteRequest = _reflection.GeneratedProtocolMessageType('RecognizerDeleteRequest', (_message.Message,), dict( + DESCRIPTOR = _RECOGNIZERDELETEREQUEST, + __module__ = 'recognizers_store_pb2' + # @@protoc_insertion_point(class_scope:types.RecognizerDeleteRequest) + )) +_sym_db.RegisterMessage(RecognizerDeleteRequest) + +RecognizerGetRequest = _reflection.GeneratedProtocolMessageType('RecognizerGetRequest', (_message.Message,), dict( + DESCRIPTOR = _RECOGNIZERGETREQUEST, + __module__ = 'recognizers_store_pb2' + # @@protoc_insertion_point(class_scope:types.RecognizerGetRequest) + )) +_sym_db.RegisterMessage(RecognizerGetRequest) + +RecognizersGetAllRequest = _reflection.GeneratedProtocolMessageType('RecognizersGetAllRequest', (_message.Message,), dict( + DESCRIPTOR = _RECOGNIZERSGETALLREQUEST, + __module__ = 'recognizers_store_pb2' + # @@protoc_insertion_point(class_scope:types.RecognizersGetAllRequest) + )) +_sym_db.RegisterMessage(RecognizersGetAllRequest) + +RecognizerGetHashRequest = _reflection.GeneratedProtocolMessageType('RecognizerGetHashRequest', (_message.Message,), dict( + DESCRIPTOR = _RECOGNIZERGETHASHREQUEST, + __module__ = 'recognizers_store_pb2' + # @@protoc_insertion_point(class_scope:types.RecognizerGetHashRequest) + )) +_sym_db.RegisterMessage(RecognizerGetHashRequest) + +RecognizersStoreResponse = _reflection.GeneratedProtocolMessageType('RecognizersStoreResponse', (_message.Message,), dict( + DESCRIPTOR = _RECOGNIZERSSTORERESPONSE, + __module__ = 'recognizers_store_pb2' + # @@protoc_insertion_point(class_scope:types.RecognizersStoreResponse) + )) +_sym_db.RegisterMessage(RecognizersStoreResponse) + +RecognizersGetResponse = _reflection.GeneratedProtocolMessageType('RecognizersGetResponse', (_message.Message,), dict( + DESCRIPTOR = _RECOGNIZERSGETRESPONSE, + __module__ = 'recognizers_store_pb2' + # @@protoc_insertion_point(class_scope:types.RecognizersGetResponse) + )) +_sym_db.RegisterMessage(RecognizersGetResponse) + +RecognizerHashResponse = _reflection.GeneratedProtocolMessageType('RecognizerHashResponse', (_message.Message,), dict( + DESCRIPTOR = _RECOGNIZERHASHRESPONSE, + __module__ = 'recognizers_store_pb2' + # @@protoc_insertion_point(class_scope:types.RecognizerHashResponse) + )) +_sym_db.RegisterMessage(RecognizerHashResponse) + + + +_RECOGNIZERSSTORESERVICE = _descriptor.ServiceDescriptor( + name='RecognizersStoreService', + full_name='types.RecognizersStoreService', + file=DESCRIPTOR, + index=0, + options=None, + serialized_start=600, + serialized_end=1124, + methods=[ + _descriptor.MethodDescriptor( + name='ApplyInsert', + full_name='types.RecognizersStoreService.ApplyInsert', + index=0, + containing_service=None, + input_type=_RECOGNIZERINSERTORUPDATEREQUEST, + output_type=_RECOGNIZERSSTORERESPONSE, + options=None, + ), + _descriptor.MethodDescriptor( + name='ApplyUpdate', + full_name='types.RecognizersStoreService.ApplyUpdate', + index=1, + containing_service=None, + input_type=_RECOGNIZERINSERTORUPDATEREQUEST, + output_type=_RECOGNIZERSSTORERESPONSE, + options=None, + ), + _descriptor.MethodDescriptor( + name='ApplyDelete', + full_name='types.RecognizersStoreService.ApplyDelete', + index=2, + containing_service=None, + input_type=_RECOGNIZERDELETEREQUEST, + output_type=_RECOGNIZERSSTORERESPONSE, + options=None, + ), + _descriptor.MethodDescriptor( + name='ApplyGet', + full_name='types.RecognizersStoreService.ApplyGet', + index=3, + containing_service=None, + input_type=_RECOGNIZERGETREQUEST, + output_type=_RECOGNIZERSGETRESPONSE, + options=None, + ), + _descriptor.MethodDescriptor( + name='ApplyGetAll', + full_name='types.RecognizersStoreService.ApplyGetAll', + index=4, + containing_service=None, + input_type=_RECOGNIZERSGETALLREQUEST, + output_type=_RECOGNIZERSGETRESPONSE, + options=None, + ), + _descriptor.MethodDescriptor( + name='ApplyGetHash', + full_name='types.RecognizersStoreService.ApplyGetHash', + index=5, + containing_service=None, + input_type=_RECOGNIZERGETHASHREQUEST, + output_type=_RECOGNIZERHASHRESPONSE, + options=None, + ), +]) +_sym_db.RegisterServiceDescriptor(_RECOGNIZERSSTORESERVICE) + +DESCRIPTOR.services_by_name['RecognizersStoreService'] = _RECOGNIZERSSTORESERVICE + +# @@protoc_insertion_point(module_scope) diff --git a/presidio-analyzer/analyzer/recognizers_store_pb2_grpc.py b/presidio-analyzer/analyzer/recognizers_store_pb2_grpc.py new file mode 100644 index 000000000..1da00832a --- /dev/null +++ b/presidio-analyzer/analyzer/recognizers_store_pb2_grpc.py @@ -0,0 +1,131 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +import grpc + +import recognizers_store_pb2 as recognizers__store__pb2 + + +class RecognizersStoreServiceStub(object): + """The Recognizers Store Service is a service that handles pattern recognizers in a persistent storage + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.ApplyInsert = channel.unary_unary( + '/types.RecognizersStoreService/ApplyInsert', + request_serializer=recognizers__store__pb2.RecognizerInsertOrUpdateRequest.SerializeToString, + response_deserializer=recognizers__store__pb2.RecognizersStoreResponse.FromString, + ) + self.ApplyUpdate = channel.unary_unary( + '/types.RecognizersStoreService/ApplyUpdate', + request_serializer=recognizers__store__pb2.RecognizerInsertOrUpdateRequest.SerializeToString, + response_deserializer=recognizers__store__pb2.RecognizersStoreResponse.FromString, + ) + self.ApplyDelete = channel.unary_unary( + '/types.RecognizersStoreService/ApplyDelete', + request_serializer=recognizers__store__pb2.RecognizerDeleteRequest.SerializeToString, + response_deserializer=recognizers__store__pb2.RecognizersStoreResponse.FromString, + ) + self.ApplyGet = channel.unary_unary( + '/types.RecognizersStoreService/ApplyGet', + request_serializer=recognizers__store__pb2.RecognizerGetRequest.SerializeToString, + response_deserializer=recognizers__store__pb2.RecognizersGetResponse.FromString, + ) + self.ApplyGetAll = channel.unary_unary( + '/types.RecognizersStoreService/ApplyGetAll', + request_serializer=recognizers__store__pb2.RecognizersGetAllRequest.SerializeToString, + response_deserializer=recognizers__store__pb2.RecognizersGetResponse.FromString, + ) + self.ApplyGetHash = channel.unary_unary( + '/types.RecognizersStoreService/ApplyGetHash', + request_serializer=recognizers__store__pb2.RecognizerGetHashRequest.SerializeToString, + response_deserializer=recognizers__store__pb2.RecognizerHashResponse.FromString, + ) + + +class RecognizersStoreServiceServicer(object): + """The Recognizers Store Service is a service that handles pattern recognizers in a persistent storage + """ + + def ApplyInsert(self, request, context): + # missing associated documentation comment in .proto file + pass + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def ApplyUpdate(self, request, context): + # missing associated documentation comment in .proto file + pass + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def ApplyDelete(self, request, context): + # missing associated documentation comment in .proto file + pass + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def ApplyGet(self, request, context): + # missing associated documentation comment in .proto file + pass + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def ApplyGetAll(self, request, context): + # missing associated documentation comment in .proto file + pass + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def ApplyGetHash(self, request, context): + # missing associated documentation comment in .proto file + pass + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_RecognizersStoreServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'ApplyInsert': grpc.unary_unary_rpc_method_handler( + servicer.ApplyInsert, + request_deserializer=recognizers__store__pb2.RecognizerInsertOrUpdateRequest.FromString, + response_serializer=recognizers__store__pb2.RecognizersStoreResponse.SerializeToString, + ), + 'ApplyUpdate': grpc.unary_unary_rpc_method_handler( + servicer.ApplyUpdate, + request_deserializer=recognizers__store__pb2.RecognizerInsertOrUpdateRequest.FromString, + response_serializer=recognizers__store__pb2.RecognizersStoreResponse.SerializeToString, + ), + 'ApplyDelete': grpc.unary_unary_rpc_method_handler( + servicer.ApplyDelete, + request_deserializer=recognizers__store__pb2.RecognizerDeleteRequest.FromString, + response_serializer=recognizers__store__pb2.RecognizersStoreResponse.SerializeToString, + ), + 'ApplyGet': grpc.unary_unary_rpc_method_handler( + servicer.ApplyGet, + request_deserializer=recognizers__store__pb2.RecognizerGetRequest.FromString, + response_serializer=recognizers__store__pb2.RecognizersGetResponse.SerializeToString, + ), + 'ApplyGetAll': grpc.unary_unary_rpc_method_handler( + servicer.ApplyGetAll, + request_deserializer=recognizers__store__pb2.RecognizersGetAllRequest.FromString, + response_serializer=recognizers__store__pb2.RecognizersGetResponse.SerializeToString, + ), + 'ApplyGetHash': grpc.unary_unary_rpc_method_handler( + servicer.ApplyGetHash, + request_deserializer=recognizers__store__pb2.RecognizerGetHashRequest.FromString, + response_serializer=recognizers__store__pb2.RecognizerHashResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'types.RecognizersStoreService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) diff --git a/presidio-analyzer/analyzer/scan_pb2.py b/presidio-analyzer/analyzer/scan_pb2.py new file mode 100644 index 000000000..ab2521d61 --- /dev/null +++ b/presidio-analyzer/analyzer/scan_pb2.py @@ -0,0 +1,96 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: scan.proto + +import sys +_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +from google.protobuf import descriptor_pb2 +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +import template_pb2 as template__pb2 + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='scan.proto', + package='types', + syntax='proto3', + serialized_pb=_b('\n\nscan.proto\x12\x05types\x1a\x0etemplate.proto\"\xd1\x01\n\x0bScanRequest\x12)\n\x0cscanTemplate\x18\x01 \x01(\x0b\x32\x13.types.ScanTemplate\x12/\n\x0f\x61nalyzeTemplate\x18\x02 \x01(\x0b\x32\x16.types.AnalyzeTemplate\x12\x33\n\x11\x61nonymizeTemplate\x18\x03 \x01(\x0b\x32\x18.types.AnonymizeTemplate\x12\x31\n\x10\x64\x61tasinkTemplate\x18\x04 \x01(\x0b\x32\x17.types.DatasinkTemplateb\x06proto3') + , + dependencies=[template__pb2.DESCRIPTOR,]) + + + + +_SCANREQUEST = _descriptor.Descriptor( + name='ScanRequest', + full_name='types.ScanRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='scanTemplate', full_name='types.ScanRequest.scanTemplate', index=0, + number=1, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='analyzeTemplate', full_name='types.ScanRequest.analyzeTemplate', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='anonymizeTemplate', full_name='types.ScanRequest.anonymizeTemplate', index=2, + number=3, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='datasinkTemplate', full_name='types.ScanRequest.datasinkTemplate', index=3, + number=4, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=38, + serialized_end=247, +) + +_SCANREQUEST.fields_by_name['scanTemplate'].message_type = template__pb2._SCANTEMPLATE +_SCANREQUEST.fields_by_name['analyzeTemplate'].message_type = template__pb2._ANALYZETEMPLATE +_SCANREQUEST.fields_by_name['anonymizeTemplate'].message_type = template__pb2._ANONYMIZETEMPLATE +_SCANREQUEST.fields_by_name['datasinkTemplate'].message_type = template__pb2._DATASINKTEMPLATE +DESCRIPTOR.message_types_by_name['ScanRequest'] = _SCANREQUEST +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +ScanRequest = _reflection.GeneratedProtocolMessageType('ScanRequest', (_message.Message,), dict( + DESCRIPTOR = _SCANREQUEST, + __module__ = 'scan_pb2' + # @@protoc_insertion_point(class_scope:types.ScanRequest) + )) +_sym_db.RegisterMessage(ScanRequest) + + +# @@protoc_insertion_point(module_scope) diff --git a/presidio-analyzer/analyzer/scan_pb2_grpc.py b/presidio-analyzer/analyzer/scan_pb2_grpc.py new file mode 100644 index 000000000..a89435267 --- /dev/null +++ b/presidio-analyzer/analyzer/scan_pb2_grpc.py @@ -0,0 +1,3 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +import grpc + diff --git a/presidio-analyzer/analyzer/scheduler_pb2.py b/presidio-analyzer/analyzer/scheduler_pb2.py new file mode 100644 index 000000000..283fb0cbf --- /dev/null +++ b/presidio-analyzer/analyzer/scheduler_pb2.py @@ -0,0 +1,327 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: scheduler.proto + +import sys +_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +from google.protobuf import descriptor_pb2 +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +import stream_pb2 as stream__pb2 +import scan_pb2 as scan__pb2 +import template_pb2 as template__pb2 + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='scheduler.proto', + package='types', + syntax='proto3', + serialized_pb=_b('\n\x0fscheduler.proto\x12\x05types\x1a\x0cstream.proto\x1a\nscan.proto\x1a\x0etemplate.proto\"y\n\x18ScannerCronJobApiRequest\x12 \n\x18ScannerCronJobTemplateId\x18\x01 \x01(\t\x12;\n\x15scannerCronJobRequest\x18\x02 \x01(\x0b\x32\x1c.types.ScannerCronJobRequest\"o\n\x15ScannerCronJobRequest\x12\x0c\n\x04Name\x18\x01 \x01(\t\x12\x1f\n\x07trigger\x18\x02 \x01(\x0b\x32\x0e.types.Trigger\x12\'\n\x0bscanRequest\x18\x03 \x01(\x0b\x32\x12.types.ScanRequest\"\x18\n\x16ScannerCronJobResponse\"i\n\x14StreamsJobApiRequest\x12\x1c\n\x14StreamsJobTemplateId\x18\x01 \x01(\t\x12\x33\n\x11streamsJobRequest\x18\x02 \x01(\x0b\x32\x18.types.StreamsJobRequest\"O\n\x11StreamsJobRequest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12,\n\x0estreamsRequest\x18\x02 \x01(\x0b\x32\x14.types.StreamRequest\"\x14\n\x12StreamsJobResponse2\xa4\x01\n\x10SchedulerService\x12\x44\n\x0b\x41pplyStream\x12\x18.types.StreamsJobRequest\x1a\x19.types.StreamsJobResponse\"\x00\x12J\n\tApplyScan\x12\x1c.types.ScannerCronJobRequest\x1a\x1d.types.ScannerCronJobResponse\"\x00\x62\x06proto3') + , + dependencies=[stream__pb2.DESCRIPTOR,scan__pb2.DESCRIPTOR,template__pb2.DESCRIPTOR,]) + + + + +_SCANNERCRONJOBAPIREQUEST = _descriptor.Descriptor( + name='ScannerCronJobApiRequest', + full_name='types.ScannerCronJobApiRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='ScannerCronJobTemplateId', full_name='types.ScannerCronJobApiRequest.ScannerCronJobTemplateId', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='scannerCronJobRequest', full_name='types.ScannerCronJobApiRequest.scannerCronJobRequest', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=68, + serialized_end=189, +) + + +_SCANNERCRONJOBREQUEST = _descriptor.Descriptor( + name='ScannerCronJobRequest', + full_name='types.ScannerCronJobRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='Name', full_name='types.ScannerCronJobRequest.Name', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='trigger', full_name='types.ScannerCronJobRequest.trigger', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='scanRequest', full_name='types.ScannerCronJobRequest.scanRequest', index=2, + number=3, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=191, + serialized_end=302, +) + + +_SCANNERCRONJOBRESPONSE = _descriptor.Descriptor( + name='ScannerCronJobResponse', + full_name='types.ScannerCronJobResponse', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=304, + serialized_end=328, +) + + +_STREAMSJOBAPIREQUEST = _descriptor.Descriptor( + name='StreamsJobApiRequest', + full_name='types.StreamsJobApiRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='StreamsJobTemplateId', full_name='types.StreamsJobApiRequest.StreamsJobTemplateId', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='streamsJobRequest', full_name='types.StreamsJobApiRequest.streamsJobRequest', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=330, + serialized_end=435, +) + + +_STREAMSJOBREQUEST = _descriptor.Descriptor( + name='StreamsJobRequest', + full_name='types.StreamsJobRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='name', full_name='types.StreamsJobRequest.name', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='streamsRequest', full_name='types.StreamsJobRequest.streamsRequest', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=437, + serialized_end=516, +) + + +_STREAMSJOBRESPONSE = _descriptor.Descriptor( + name='StreamsJobResponse', + full_name='types.StreamsJobResponse', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=518, + serialized_end=538, +) + +_SCANNERCRONJOBAPIREQUEST.fields_by_name['scannerCronJobRequest'].message_type = _SCANNERCRONJOBREQUEST +_SCANNERCRONJOBREQUEST.fields_by_name['trigger'].message_type = template__pb2._TRIGGER +_SCANNERCRONJOBREQUEST.fields_by_name['scanRequest'].message_type = scan__pb2._SCANREQUEST +_STREAMSJOBAPIREQUEST.fields_by_name['streamsJobRequest'].message_type = _STREAMSJOBREQUEST +_STREAMSJOBREQUEST.fields_by_name['streamsRequest'].message_type = stream__pb2._STREAMREQUEST +DESCRIPTOR.message_types_by_name['ScannerCronJobApiRequest'] = _SCANNERCRONJOBAPIREQUEST +DESCRIPTOR.message_types_by_name['ScannerCronJobRequest'] = _SCANNERCRONJOBREQUEST +DESCRIPTOR.message_types_by_name['ScannerCronJobResponse'] = _SCANNERCRONJOBRESPONSE +DESCRIPTOR.message_types_by_name['StreamsJobApiRequest'] = _STREAMSJOBAPIREQUEST +DESCRIPTOR.message_types_by_name['StreamsJobRequest'] = _STREAMSJOBREQUEST +DESCRIPTOR.message_types_by_name['StreamsJobResponse'] = _STREAMSJOBRESPONSE +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +ScannerCronJobApiRequest = _reflection.GeneratedProtocolMessageType('ScannerCronJobApiRequest', (_message.Message,), dict( + DESCRIPTOR = _SCANNERCRONJOBAPIREQUEST, + __module__ = 'scheduler_pb2' + # @@protoc_insertion_point(class_scope:types.ScannerCronJobApiRequest) + )) +_sym_db.RegisterMessage(ScannerCronJobApiRequest) + +ScannerCronJobRequest = _reflection.GeneratedProtocolMessageType('ScannerCronJobRequest', (_message.Message,), dict( + DESCRIPTOR = _SCANNERCRONJOBREQUEST, + __module__ = 'scheduler_pb2' + # @@protoc_insertion_point(class_scope:types.ScannerCronJobRequest) + )) +_sym_db.RegisterMessage(ScannerCronJobRequest) + +ScannerCronJobResponse = _reflection.GeneratedProtocolMessageType('ScannerCronJobResponse', (_message.Message,), dict( + DESCRIPTOR = _SCANNERCRONJOBRESPONSE, + __module__ = 'scheduler_pb2' + # @@protoc_insertion_point(class_scope:types.ScannerCronJobResponse) + )) +_sym_db.RegisterMessage(ScannerCronJobResponse) + +StreamsJobApiRequest = _reflection.GeneratedProtocolMessageType('StreamsJobApiRequest', (_message.Message,), dict( + DESCRIPTOR = _STREAMSJOBAPIREQUEST, + __module__ = 'scheduler_pb2' + # @@protoc_insertion_point(class_scope:types.StreamsJobApiRequest) + )) +_sym_db.RegisterMessage(StreamsJobApiRequest) + +StreamsJobRequest = _reflection.GeneratedProtocolMessageType('StreamsJobRequest', (_message.Message,), dict( + DESCRIPTOR = _STREAMSJOBREQUEST, + __module__ = 'scheduler_pb2' + # @@protoc_insertion_point(class_scope:types.StreamsJobRequest) + )) +_sym_db.RegisterMessage(StreamsJobRequest) + +StreamsJobResponse = _reflection.GeneratedProtocolMessageType('StreamsJobResponse', (_message.Message,), dict( + DESCRIPTOR = _STREAMSJOBRESPONSE, + __module__ = 'scheduler_pb2' + # @@protoc_insertion_point(class_scope:types.StreamsJobResponse) + )) +_sym_db.RegisterMessage(StreamsJobResponse) + + + +_SCHEDULERSERVICE = _descriptor.ServiceDescriptor( + name='SchedulerService', + full_name='types.SchedulerService', + file=DESCRIPTOR, + index=0, + options=None, + serialized_start=541, + serialized_end=705, + methods=[ + _descriptor.MethodDescriptor( + name='ApplyStream', + full_name='types.SchedulerService.ApplyStream', + index=0, + containing_service=None, + input_type=_STREAMSJOBREQUEST, + output_type=_STREAMSJOBRESPONSE, + options=None, + ), + _descriptor.MethodDescriptor( + name='ApplyScan', + full_name='types.SchedulerService.ApplyScan', + index=1, + containing_service=None, + input_type=_SCANNERCRONJOBREQUEST, + output_type=_SCANNERCRONJOBRESPONSE, + options=None, + ), +]) +_sym_db.RegisterServiceDescriptor(_SCHEDULERSERVICE) + +DESCRIPTOR.services_by_name['SchedulerService'] = _SCHEDULERSERVICE + +# @@protoc_insertion_point(module_scope) diff --git a/presidio-analyzer/analyzer/scheduler_pb2_grpc.py b/presidio-analyzer/analyzer/scheduler_pb2_grpc.py new file mode 100644 index 000000000..7bb2ab0db --- /dev/null +++ b/presidio-analyzer/analyzer/scheduler_pb2_grpc.py @@ -0,0 +1,63 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +import grpc + +import scheduler_pb2 as scheduler__pb2 + + +class SchedulerServiceStub(object): + """The CronJob Service is a service that triggers a new cronjob for scanning a given storage periodcally + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.ApplyStream = channel.unary_unary( + '/types.SchedulerService/ApplyStream', + request_serializer=scheduler__pb2.StreamsJobRequest.SerializeToString, + response_deserializer=scheduler__pb2.StreamsJobResponse.FromString, + ) + self.ApplyScan = channel.unary_unary( + '/types.SchedulerService/ApplyScan', + request_serializer=scheduler__pb2.ScannerCronJobRequest.SerializeToString, + response_deserializer=scheduler__pb2.ScannerCronJobResponse.FromString, + ) + + +class SchedulerServiceServicer(object): + """The CronJob Service is a service that triggers a new cronjob for scanning a given storage periodcally + """ + + def ApplyStream(self, request, context): + """ApplyStream method will trigger a new scanning cron job and will return if it was triggered successfully + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def ApplyScan(self, request, context): + """Apply method will trigger a new scanning cron job and will return if it was triggered successfully + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_SchedulerServiceServicer_to_server(servicer, server): + rpc_method_handlers = { + 'ApplyStream': grpc.unary_unary_rpc_method_handler( + servicer.ApplyStream, + request_deserializer=scheduler__pb2.StreamsJobRequest.FromString, + response_serializer=scheduler__pb2.StreamsJobResponse.SerializeToString, + ), + 'ApplyScan': grpc.unary_unary_rpc_method_handler( + servicer.ApplyScan, + request_deserializer=scheduler__pb2.ScannerCronJobRequest.FromString, + response_serializer=scheduler__pb2.ScannerCronJobResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'types.SchedulerService', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) diff --git a/presidio-analyzer/analyzer/stream_pb2.py b/presidio-analyzer/analyzer/stream_pb2.py new file mode 100644 index 000000000..c35e3b84c --- /dev/null +++ b/presidio-analyzer/analyzer/stream_pb2.py @@ -0,0 +1,96 @@ +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: stream.proto + +import sys +_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +from google.protobuf import descriptor_pb2 +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + +import template_pb2 as template__pb2 + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='stream.proto', + package='types', + syntax='proto3', + serialized_pb=_b('\n\x0cstream.proto\x12\x05types\x1a\x0etemplate.proto\"\xd3\x01\n\rStreamRequest\x12)\n\x0cstreamConfig\x18\x01 \x01(\x0b\x32\x13.types.StreamConfig\x12/\n\x0f\x61nalyzeTemplate\x18\x02 \x01(\x0b\x32\x16.types.AnalyzeTemplate\x12\x33\n\x11\x61nonymizeTemplate\x18\x03 \x01(\x0b\x32\x18.types.AnonymizeTemplate\x12\x31\n\x10\x64\x61tasinkTemplate\x18\x04 \x01(\x0b\x32\x17.types.DatasinkTemplateb\x06proto3') + , + dependencies=[template__pb2.DESCRIPTOR,]) + + + + +_STREAMREQUEST = _descriptor.Descriptor( + name='StreamRequest', + full_name='types.StreamRequest', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='streamConfig', full_name='types.StreamRequest.streamConfig', index=0, + number=1, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='analyzeTemplate', full_name='types.StreamRequest.analyzeTemplate', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='anonymizeTemplate', full_name='types.StreamRequest.anonymizeTemplate', index=2, + number=3, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='datasinkTemplate', full_name='types.StreamRequest.datasinkTemplate', index=3, + number=4, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=40, + serialized_end=251, +) + +_STREAMREQUEST.fields_by_name['streamConfig'].message_type = template__pb2._STREAMCONFIG +_STREAMREQUEST.fields_by_name['analyzeTemplate'].message_type = template__pb2._ANALYZETEMPLATE +_STREAMREQUEST.fields_by_name['anonymizeTemplate'].message_type = template__pb2._ANONYMIZETEMPLATE +_STREAMREQUEST.fields_by_name['datasinkTemplate'].message_type = template__pb2._DATASINKTEMPLATE +DESCRIPTOR.message_types_by_name['StreamRequest'] = _STREAMREQUEST +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +StreamRequest = _reflection.GeneratedProtocolMessageType('StreamRequest', (_message.Message,), dict( + DESCRIPTOR = _STREAMREQUEST, + __module__ = 'stream_pb2' + # @@protoc_insertion_point(class_scope:types.StreamRequest) + )) +_sym_db.RegisterMessage(StreamRequest) + + +# @@protoc_insertion_point(module_scope) diff --git a/presidio-analyzer/analyzer/stream_pb2_grpc.py b/presidio-analyzer/analyzer/stream_pb2_grpc.py new file mode 100644 index 000000000..a89435267 --- /dev/null +++ b/presidio-analyzer/analyzer/stream_pb2_grpc.py @@ -0,0 +1,3 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +import grpc + diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index abfe24351..9371d0c13 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -1,17 +1,72 @@ from unittest import TestCase from analyzer.entity_recognizer import EntityRecognizer +import json +import hashlib +import time import pytest from assertions import assert_result +from analyzer.analyze_pb2 import AnalyzeRequest + from analyzer import AnalyzerEngine, PatternRecognizer, Pattern, \ RecognizerResult, RecognizerRegistry -from analyzer.analyze_pb2 import AnalyzeRequest from analyzer.predefined_recognizers import CreditCardRecognizer, \ UsPhoneRecognizer, DomainRecognizer +from analyzer.recognizer_registry.recognizers_store_api \ + import RecognizerStoreApi # noqa: F401 + + +class RecognizerStoreApiMock(RecognizerStoreApi): + """ + A mock that acts as a recognizers store, allows to add and get recognizers + """ + + def __init__(self): + self.latest_hash = "" + self.recognizers = [] + + def get_latest_hash(self): + return self.latest_hash + + def get_all_recognizers(self): + return self.recognizers + + def add_custom_pattern_recognizer(self, new_recognizer, skip_hash_update=False): + patterns = [] + for pat in new_recognizer.patterns: + patterns.extend([Pattern(pat.name, pat.regex, pat.score)]) + new_custom_recognizer = PatternRecognizer(name=new_recognizer.name, supported_entity=new_recognizer.supported_entities[0], + supported_language=new_recognizer.supported_language, + black_list=new_recognizer.black_list, + context=new_recognizer.context, + patterns=patterns) + self.recognizers.append(new_custom_recognizer) + + if skip_hash_update: + return + + m = hashlib.md5() + for recognizer in self.recognizers: + m.update(recognizer.name.encode('utf-8')) + self.latest_hash = m.digest() + + def remove_recognizer(self, name): + for i in self.recognizers: + if i.name == name: + self.recognizers.remove(i) + + m = hashlib.md5() + for recognizer in self.recognizers: + m.update(recognizer.name.encode('utf-8')) + self.latest_hash = m.digest() class MockRecognizerRegistry(RecognizerRegistry): + """ + A mock that acts as a recognizers registry + """ + def load_recognizers(self, path): # TODO: Change the code to dynamic loading - # Task #598: Support loading of the pre-defined recognizers @@ -23,73 +78,79 @@ def load_recognizers(self, path): class TestAnalyzerEngine(TestCase): - def test_analyze_with_single_predefined_recognizers(self): - analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) + def __init__(self, *args, **kwargs): + super(TestAnalyzerEngine, self).__init__(*args, **kwargs) + self.loaded_registry = MockRecognizerRegistry(RecognizerStoreApiMock()) + self.loaded_analyzer_engine = AnalyzerEngine(self.loaded_registry) + + def test_analyze_with_predefined_recognizers_return_results(self): text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090" - langauge = "en" + language = "en" entities = ["CREDIT_CARD"] - results = analyze_engine.analyze(text, entities, langauge, all_fields=False) + results = self.loaded_analyzer_engine.analyze( + text, entities, language, all_fields=False) assert len(results) == 1 - assert_result(results[0], "CREDIT_CARD", 14, 33, EntityRecognizer.MAX_SCORE) + assert_result(results[0], "CREDIT_CARD", 14, + 33, EntityRecognizer.MAX_SCORE) def test_analyze_with_multiple_predefined_recognizers(self): - analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090" - langauge = "en" + language = "en" entities = ["CREDIT_CARD", "PHONE_NUMBER"] - results = analyze_engine.analyze(text, entities, langauge, all_fields=False) + results = self.loaded_analyzer_engine.analyze( + text, entities, language, all_fields=False) assert len(results) == 2 - assert_result(results[0], "CREDIT_CARD", 14, 33, EntityRecognizer.MAX_SCORE) + assert_result(results[0], "CREDIT_CARD", 14, + 33, EntityRecognizer.MAX_SCORE) assert_result(results[1], "PHONE_NUMBER", 48, 59, 0.5) def test_analyze_without_entities(self): with pytest.raises(ValueError): - langauge = "en" - analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) - text = " Credit card: 4095-2609-9393-4932, my name is John Oliver, DateTime: September 18 " \ - "Domain: microsoft.com" + language = "en" + text = " Credit card: 4095-2609-9393-4932, my name is John Oliver, DateTime: September 18 Domain: microsoft.com" entities = [] - analyze_engine.analyze(text, entities, langauge, all_fields=False) + self.loaded_analyzer_engine.analyze( + text, entities, language, all_fields=False) def test_analyze_with_empty_text(self): - analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) - langauge = "en" + language = "en" text = "" entities = ["CREDIT_CARD", "PHONE_NUMBER"] - results = analyze_engine.analyze(text, entities, langauge, all_fields=False) + results = self.loaded_analyzer_engine.analyze( + text, entities, language, all_fields=False) assert len(results) == 0 def test_analyze_with_unsupported_language(self): with pytest.raises(ValueError): - langauge = "de" - analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) + language = "de" text = "" entities = ["CREDIT_CARD", "PHONE_NUMBER"] - analyze_engine.analyze(text, entities, "de", all_fields=False) + self.loaded_analyzer_engine.analyze( + text, entities, language, all_fields=False) def test_remove_duplicates(self): # test same result with different score will return only the highest arr = [RecognizerResult(start=0, end=5, score=0.1, entity_type="x"), RecognizerResult(start=0, end=5, score=0.5, entity_type="x")] results = AnalyzerEngine._AnalyzerEngine__remove_duplicates(arr) - assert len(results) == 1 assert results[0].score == 0.5 - # TODO: add more cases with bug: # bug# 597: Analyzer remove duplicates doesn't handle all cases of one result as a substring of the other - def test_add_pattern_recognizer_from_dict(self): + def test_added_pattern_recognizer_works(self): pattern = Pattern("rocket pattern", r'\W*(rocket)\W*', 0.8) pattern_recognizer = PatternRecognizer("ROCKET", name="Rocket recognizer", patterns=[pattern]) # Make sure the analyzer doesn't get this entity - analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) + recognizers_store_api_mock = RecognizerStoreApiMock() + analyze_engine = AnalyzerEngine( + MockRecognizerRegistry(recognizers_store_api_mock)) text = "rocket is my favorite transportation" entities = ["CREDIT_CARD", "ROCKET"] @@ -99,7 +160,8 @@ def test_add_pattern_recognizer_from_dict(self): assert len(results) == 0 # Add a new recognizer for the word "rocket" (case insensitive) - analyze_engine.add_pattern_recognizer(pattern_recognizer.to_dict()) + recognizers_store_api_mock.add_custom_pattern_recognizer( + pattern_recognizer) # Check that the entity is recognized: results = analyze_engine.analyze(text=text, entities=entities, @@ -108,14 +170,16 @@ def test_add_pattern_recognizer_from_dict(self): assert len(results) == 1 assert_result(results[0], "ROCKET", 0, 7, 0.8) - def test_remove_analyzer(self): + def test_removed_pattern_recognizer_doesnt_work(self): pattern = Pattern("spaceship pattern", r'\W*(spaceship)\W*', 0.8) pattern_recognizer = PatternRecognizer("SPACESHIP", name="Spaceship recognizer", patterns=[pattern]) # Make sure the analyzer doesn't get this entity - analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) + recognizers_store_api_mock = RecognizerStoreApiMock() + analyze_engine = AnalyzerEngine(MockRecognizerRegistry( + recognizers_store_api_mock)) text = "spaceship is my favorite transportation" entities = ["CREDIT_CARD", "SPACESHIP"] @@ -125,8 +189,8 @@ def test_remove_analyzer(self): assert len(results) == 0 # Add a new recognizer for the word "rocket" (case insensitive) - analyze_engine.add_pattern_recognizer(pattern_recognizer.to_dict()) - + recognizers_store_api_mock.add_custom_pattern_recognizer( + pattern_recognizer) # Check that the entity is recognized: results = analyze_engine.analyze(text=text, entities=entities, language='en', all_fields=False) @@ -134,37 +198,33 @@ def test_remove_analyzer(self): assert_result(results[0], "SPACESHIP", 0, 10, 0.8) # Remove recognizer - analyze_engine.remove_recognizer("Spaceship recognizer") - + recognizers_store_api_mock.remove_recognizer( + "Spaceship recognizer") # Test again to see we didn't get any results results = analyze_engine.analyze(text=text, entities=entities, language='en', all_fields=False) assert len(results) == 0 - def test_Apply_with_language_returns_correct_response(self): - analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) - + def test_apply_with_language_returns_correct_response(self): request = AnalyzeRequest() request.analyzeTemplate.language = 'en' new_field = request.analyzeTemplate.fields.add() new_field.name = 'CREDIT_CARD' new_field.minScore = '0.5' request.text = "My credit card number is 4916994465041084" - response = analyze_engine.Apply(request, None) + response = self.loaded_analyzer_engine.Apply(request, None) assert response.analyzeResults is not None - def test_Apply_with_no_language_returns_default(self): - analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) - + def test_apply_with_no_language_returns_default(self): request = AnalyzeRequest() request.analyzeTemplate.language = '' new_field = request.analyzeTemplate.fields.add() new_field.name = 'CREDIT_CARD' new_field.minScore = '0.5' request.text = "My credit card number is 4916994465041084" - response = analyze_engine.Apply(request, None) + response = self.loaded_analyzer_engine.Apply(request, None) assert response.analyzeResults is not None def test_when_allFields_is_true_return_all_fields(self): @@ -172,7 +232,7 @@ def test_when_allFields_is_true_return_all_fields(self): request = AnalyzeRequest() request.analyzeTemplate.allFields = True request.text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090 " \ - "Domain: microsoft.com" + "Domain: microsoft.com" response = analyze_engine.Apply(request, None) assert response.analyzeResults is not None assert len(response.analyzeResults) is 3 diff --git a/presidio-analyzer/tests/test_pattern.py b/presidio-analyzer/tests/test_pattern.py index 20264dd59..ca38a28a3 100644 --- a/presidio-analyzer/tests/test_pattern.py +++ b/presidio-analyzer/tests/test_pattern.py @@ -1,9 +1,9 @@ from unittest import TestCase from analyzer import Pattern +my_pattern = Pattern(name="my pattern", score=0.9, regex="[re]") +my_pattern_dict = {"name": "my pattern", "regex": "[re]", "score": 0.9} -my_pattern = Pattern(name="my pattern", strength=0.9, pattern="[pat]") -my_pattern_dict = {"name": "my pattern", "pattern": "[pat]", "strength": 0.9} class TestPattern(TestCase): @@ -18,8 +18,5 @@ def test_from_dict(self): actual = Pattern.from_dict(my_pattern_dict) assert expected.name == actual.name - assert expected.strength == actual.strength - assert expected.pattern == actual.pattern - - # assert expected == actual - + assert expected.score == actual.score + assert expected.regex == actual.regex diff --git a/presidio-analyzer/tests/test_pattern_recognizer.py b/presidio-analyzer/tests/test_pattern_recognizer.py index 6bf5e01b4..4bf051c2b 100644 --- a/presidio-analyzer/tests/test_pattern_recognizer.py +++ b/presidio-analyzer/tests/test_pattern_recognizer.py @@ -52,7 +52,7 @@ def test_black_list_keywords_not_found(self): def test_from_dict(self): json = {'supported_entity': 'ENTITY_1', 'supported_language': 'en', - 'patterns': [{'name': 'p1', 'strength': 0.5, 'pattern': '([0-9]{1,9})'}], + 'patterns': [{'name': 'p1', 'score': 0.5, 'regex': '([0-9]{1,9})'}], 'context': ['w1', 'w2', 'w3'], 'version': "1.0"} @@ -61,14 +61,14 @@ def test_from_dict(self): assert new_recognizer.supported_entities == ['ENTITY_1'] assert new_recognizer.supported_language == 'en' assert new_recognizer.patterns[0].name == 'p1' - assert new_recognizer.patterns[0].strength == 0.5 - assert new_recognizer.patterns[0].pattern == '([0-9]{1,9})' + assert new_recognizer.patterns[0].score == 0.5 + assert new_recognizer.patterns[0].regex == '([0-9]{1,9})' assert new_recognizer.context == ['w1', 'w2', 'w3'] assert new_recognizer.version == "1.0" def test_from_dict_returns_instance(self): - pattern1_dict = {'name': 'p1', 'strength': 0.5, 'pattern': '([0-9]{1,9})'} - pattern2_dict = {'name': 'p2', 'strength': 0.8, 'pattern': '([0-9]{1,9})'} + pattern1_dict = {'name': 'p1', 'score': 0.5, 'regex': '([0-9]{1,9})'} + pattern2_dict = {'name': 'p2', 'score': 0.8, 'regex': '([0-9]{1,9})'} ent_rec_dict = {"supported_entity": "A", "supported_language": "he", @@ -81,9 +81,9 @@ def test_from_dict_returns_instance(self): assert pattern_recognizer.version == "0.0.1" assert pattern_recognizer.patterns[0].name == "p1" - assert pattern_recognizer.patterns[0].strength == 0.5 - assert pattern_recognizer.patterns[0].pattern == '([0-9]{1,9})' + assert pattern_recognizer.patterns[0].score == 0.5 + assert pattern_recognizer.patterns[0].regex == '([0-9]{1,9})' assert pattern_recognizer.patterns[1].name == "p2" - assert pattern_recognizer.patterns[1].strength == 0.8 - assert pattern_recognizer.patterns[1].pattern == '([0-9]{1,9})' + assert pattern_recognizer.patterns[1].score == 0.8 + assert pattern_recognizer.patterns[1].regex == '([0-9]{1,9})' diff --git a/presidio-analyzer/tests/test_recognizer_registry.py b/presidio-analyzer/tests/test_recognizer_registry.py index bfc1e8577..58329ece4 100644 --- a/presidio-analyzer/tests/test_recognizer_registry.py +++ b/presidio-analyzer/tests/test_recognizer_registry.py @@ -1,38 +1,111 @@ from unittest import TestCase +import json +import hashlib import pytest +import logging +from analyzer import RecognizerRegistry, PatternRecognizer, \ + EntityRecognizer, Pattern +from analyzer.recognizer_registry.recognizers_store_api \ + import RecognizerStoreApi # noqa: F401 +import time + + +class RecognizerStoreApiMock(RecognizerStoreApi): + """ + A mock that acts as a recognizers store, allows to add and get recognizers + """ + + def __init__(self): + self.latest_hash = "" + self.recognizers = [] + self.times_accessed_storage = 0 + + def get_latest_hash(self): + return self.latest_hash + + def get_all_recognizers(self): + self.times_accessed_storage = self.times_accessed_storage + 1 + return self.recognizers + + def add_custom_pattern_recognizer(self, new_recognizer, + skip_hash_update=False): + patterns = [] + for pat in new_recognizer.patterns: + patterns.extend([Pattern(pat.name, pat.regex, pat.score)]) + new_custom_recognizer = PatternRecognizer(name=new_recognizer.name, supported_entity=new_recognizer.supported_entities[0], + supported_language=new_recognizer.supported_language, + black_list=new_recognizer.black_list, + context=new_recognizer.context, + patterns=patterns) + self.recognizers.append(new_custom_recognizer) + + if skip_hash_update: + return + + m = hashlib.md5() + for recognizer in self.recognizers: + m.update(recognizer.name.encode('utf-8')) + self.latest_hash = m.digest() + + def remove_recognizer(self, name): + logging.info("removing recognizer " + name) + for i in self.recognizers: + if i.name == name: + self.recognizers.remove(i) + m = hashlib.md5() + for recognizer in self.recognizers: + m.update(recognizer.name.encode('utf-8')) + self.latest_hash = m.digest() -from analyzer import RecognizerRegistry, PatternRecognizer, EntityRecognizer, Pattern -### consider refactoring class TestRecognizerRegistry(TestCase): + def test_dummy(self): + assert 1 == 1 def get_mock_pattern_recognizer(self, lang, entity, name): return PatternRecognizer(supported_entity=entity, supported_language=lang, name=name, - patterns=[Pattern("pat", pattern="REGEX", strength=1.0)]) + patterns=[Pattern("pat", regex="REGEX", + score=1.0)]) - def get_mock_custom_recognizer(self, lang, entities,name): - return EntityRecognizer(supported_entities=entities, name=name,supported_language=lang) + def get_mock_custom_recognizer(self, lang, entities, name): + return EntityRecognizer(supported_entities=entities, name=name, + supported_language=lang) def get_mock_recognizer_registry(self): - pattern_recognizer1 = self.get_mock_pattern_recognizer("en", "PERSON", "1") - pattern_recognizer2 = self.get_mock_pattern_recognizer("de", "PERSON", "2") - pattern_recognizer3 = self.get_mock_pattern_recognizer("de", "ADDRESS", "3") - pattern_recognizer4 = self.get_mock_pattern_recognizer("he", "ADDRESS", "4") - pattern_recognizer5 = self.get_mock_custom_recognizer("he", ["PERSON", "ADDRESS"], "5") - return RecognizerRegistry([pattern_recognizer1, pattern_recognizer2, + pattern_recognizer1 = self.get_mock_pattern_recognizer( + "en", "PERSON", "1") + pattern_recognizer2 = self.get_mock_pattern_recognizer( + "de", "PERSON", "2") + pattern_recognizer3 = self.get_mock_pattern_recognizer( + "de", "ADDRESS", "3") + pattern_recognizer4 = self.get_mock_pattern_recognizer( + "he", "ADDRESS", "4") + pattern_recognizer5 = self.get_mock_custom_recognizer( + "he", ["PERSON", "ADDRESS"], "5") + recognizers_store_api_mock = RecognizerStoreApiMock() + return RecognizerRegistry(recognizers_store_api_mock, + [pattern_recognizer1, pattern_recognizer2, pattern_recognizer3, pattern_recognizer4, pattern_recognizer5]) def test_get_recognizers_all(self): registry = self.get_mock_recognizer_registry() - recognizers = registry.get_recognizers(language='de',all_fields=True) + registry.load_predefined_recognizers() + recognizers = registry.get_recognizers(language='en', all_fields=True) + # 1 custom recognizer in english + 14 predefined + assert len(recognizers) == 1 + 14 + + def test_get_recognizers_all_fields(self): + registry = self.get_mock_recognizer_registry() + recognizers = registry.get_recognizers(language='de', all_fields=True) assert len(recognizers) == 2 def test_get_recognizers_one_language_one_entity(self): registry = self.get_mock_recognizer_registry() - recognizers = registry.get_recognizers(language='de', entities=["PERSON"]) + recognizers = registry.get_recognizers( + language='de', entities=["PERSON"]) assert len(recognizers) == 1 def test_get_recognizers_unsupported_language(self): @@ -42,52 +115,100 @@ def test_get_recognizers_unsupported_language(self): def test_get_recognizers_specific_language_and_entity(self): registry = self.get_mock_recognizer_registry() - recognizers = registry.get_recognizers(language='he', entities=["PERSON"]) + recognizers = registry.get_recognizers( + language='he', entities=["PERSON"]) assert len(recognizers) == 1 - def test_load_pattern_recognizer_from_dict(self): - pattern_recognizer = self.get_mock_pattern_recognizer("ar", "ENTITY", "a") - pattern_recognizer.name = "123" - registry = self.get_mock_recognizer_registry() - registry.add_pattern_recognizer_from_dict(pattern_recognizer.to_dict()) - - recognizers = registry.get_recognizers(entities=["ENTITY"], language="ar") - - assert recognizers[0].to_dict() == pattern_recognizer.to_dict() - - def test_load_pattern_recognizer_from_dict_already_defined_throws_exception(self): - pattern_recognizer1 = self.get_mock_pattern_recognizer("ar", "ENTITY", "a") - pattern_recognizer1.name = "MyRecognizer" - registry = self.get_mock_recognizer_registry() - registry.add_pattern_recognizer_from_dict(pattern_recognizer1.to_dict()) - - pattern_recognizer2 = self.get_mock_pattern_recognizer("em", "ENTITY3", "a") - pattern_recognizer2.name = "MyRecognizer" - with pytest.raises(ValueError): - registry.add_pattern_recognizer_from_dict(pattern_recognizer2.to_dict()) - - def test_remove_pattern_recognizer_not_found_exception(self): - pattern_recognizer1 = self.get_mock_pattern_recognizer("ar", "ENTITY", "a") - pattern_recognizer1.name = "MyRecognizer" - registry = self.get_mock_recognizer_registry() - registry.add_pattern_recognizer_from_dict(pattern_recognizer1.to_dict()) - - with pytest.raises(ValueError): - registry.remove_recognizer("NumeroUnoRecognizer") - - def test_remove_pattern_recognizer_removed(self): - pattern_recognizer1 = self.get_mock_pattern_recognizer("ar", "ENTITY", "MyRecognizer") - registry = self.get_mock_recognizer_registry() - registry.add_pattern_recognizer_from_dict(pattern_recognizer1.to_dict()) - - assert len(registry.recognizers) == 6 - - registry.remove_recognizer("MyRecognizer") - - assert len(registry.recognizers) == 5 + # Test that the the cache is working as expected, i.e iff hash + # changed then need to reload from the store + def test_cache_logic(self): + pattern = Pattern("rocket pattern", r'\W*(rocket)\W*', 0.8) + pattern_recognizer = PatternRecognizer("ROCKET", + name="Rocket recognizer", + patterns=[pattern]) + + # Negative flow + recognizers_store_api_mock = RecognizerStoreApiMock() + recognizer_registry = RecognizerRegistry(recognizers_store_api_mock) + custom_recognizers = recognizer_registry.get_custom_recognizers() + # Nothing should be returned + assert len(custom_recognizers) == 0 + # Since no hash was returned, then no access to storage is expected + assert recognizers_store_api_mock.times_accessed_storage == 0 + + # Add a new recognizer + recognizers_store_api_mock.add_custom_pattern_recognizer( + pattern_recognizer, + skip_hash_update=True) + + # Since the hash wasn't updated the recognizers are stale from the cache + # without the newly added one + custom_recognizers = recognizer_registry.get_custom_recognizers() + assert len(custom_recognizers) == 0 + # And we also didn't accessed the underlying storage + assert recognizers_store_api_mock.times_accessed_storage == 0 + + # Positive flow + # Now do the same only this time update the hash so it should work properly + recognizers_store_api_mock = RecognizerStoreApiMock() + recognizer_registry = RecognizerRegistry(recognizers_store_api_mock) + + recognizer_registry.get_custom_recognizers() + assert recognizers_store_api_mock.times_accessed_storage == 0 + recognizers_store_api_mock.add_custom_pattern_recognizer( + pattern_recognizer, + skip_hash_update=False) + custom_recognizers = recognizer_registry.get_custom_recognizers() + assert len(custom_recognizers) == 1 + # Accessed again + assert recognizers_store_api_mock.times_accessed_storage == 1 + + def test_add_pattern_recognizer(self): + pattern = Pattern("rocket pattern", r'\W*(rocket)\W*', 0.8) + pattern_recognizer = PatternRecognizer("ROCKET", + name="Rocket recognizer", + patterns=[pattern]) + + # Make sure the analyzer doesn't get this entity + recognizers_store_api_mock = RecognizerStoreApiMock() + recognizer_registry = RecognizerRegistry(recognizers_store_api_mock) + recognizers = recognizer_registry.get_custom_recognizers() + assert len(recognizers) == 0 + + # Add a new recognizer for the word "rocket" (case insensitive) + recognizers_store_api_mock.add_custom_pattern_recognizer( + pattern_recognizer) + + recognizers = recognizer_registry.get_custom_recognizers() + assert len(recognizers) == 1 + assert recognizers[0].patterns[0].name == "rocket pattern" + assert recognizers[0].name == "Rocket recognizer" + + def test_remove_pattern_recognizer(self): + pattern = Pattern("spaceship pattern", r'\W*(spaceship)\W*', 0.8) + pattern_recognizer = PatternRecognizer("SPACESHIP", + name="Spaceship recognizer", + patterns=[pattern]) + # Make sure the analyzer doesn't get this entity + recognizers_store_api_mock = RecognizerStoreApiMock() + recognizer_registry = RecognizerRegistry(recognizers_store_api_mock) + + # Expects zero custom recognizers + recognizers = recognizer_registry.get_custom_recognizers() + assert len(recognizers) == 0 + + # Add a new recognizer for the word "rocket" (case insensitive) + recognizers_store_api_mock.add_custom_pattern_recognizer( + pattern_recognizer) + + # Expects one custom recognizer + recognizers = recognizer_registry.get_custom_recognizers() + assert len(recognizers) == 1 - for recognizer in registry.recognizers: - if recognizer.name == "MyRecognizer": - assert False + # Remove recognizer + recognizers_store_api_mock.remove_recognizer( + "Spaceship recognizer") - assert True + # Expects zero custom recognizers + recognizers = recognizer_registry.get_custom_recognizers() + assert len(recognizers) == 0 diff --git a/presidio-api/cmd/presidio-api/api/api.go b/presidio-api/cmd/presidio-api/api/api.go index c2abbddcb..f10c38572 100644 --- a/presidio-api/cmd/presidio-api/api/api.go +++ b/presidio-api/cmd/presidio-api/api/api.go @@ -50,4 +50,5 @@ func (api *API) SetupGRPCServices() { api.Services.SetupAnonymizerImageService() api.Services.SetupSchedulerService() api.Services.SetupOCRService() + api.Services.SetupRecognizerStoreService() } diff --git a/presidio-api/cmd/presidio-api/api/mocks/mocks.go b/presidio-api/cmd/presidio-api/api/mocks/mocks.go index 0aaf8e1f3..5e0db14e5 100644 --- a/presidio-api/cmd/presidio-api/api/mocks/mocks.go +++ b/presidio-api/cmd/presidio-api/api/mocks/mocks.go @@ -2,6 +2,7 @@ package mocks import ( "context" + "crypto/md5" types "github.com/Microsoft/presidio-genproto/golang" "github.com/Microsoft/presidio/pkg/presidio" @@ -26,6 +27,11 @@ type AnonymizerImageMockedObject struct { mock.Mock } +//RecognizersStoreMockedObject recognizers store mock +type RecognizersStoreMockedObject struct { + mock.Mock +} + //OcrMockedObject anonymizer mock type OcrMockedObject struct { mock.Mock @@ -193,3 +199,155 @@ func (m *TemplateMockedObject) DeleteTemplate(project string, action string, id args := m.Mock.Called() return args.Error(1) } + +//GetRecognizersStoreGetMockResult get recognizers mock response +func GetRecognizersStoreGetMockResult() *types.RecognizersGetResponse { + + pattern := &types.Pattern{ + Name: "FirstPattern", + Regex: "myregex", + Score: 0.1} + patternArr := []*types.Pattern{} + patternArr = append(patternArr, pattern) + + recognizer := &types.PatternRecognizer{ + Name: "MockRecognizer", + Entity: "SPACESHIP", + Language: "he", + Patterns: patternArr, + } + + recognizersArr := []*types.PatternRecognizer{} + recognizersArr = append(recognizersArr, recognizer) + + return &types.RecognizersGetResponse{ + Recognizers: recognizersArr, + } +} + +//GetRecognizersStoreGetAllMockResult get recognizers mock response +func GetRecognizersStoreGetAllMockResult() *types.RecognizersGetResponse { + + pattern := &types.Pattern{ + Name: "FirstPattern", + Regex: "myregex", + Score: 0.1} + patternArr := []*types.Pattern{} + patternArr = append(patternArr, pattern) + + recognizer := &types.PatternRecognizer{ + Name: "MockRecognizer", + Entity: "SPACESHIP", + Language: "he", + Patterns: patternArr, + } + + recognizer2 := &types.PatternRecognizer{ + Name: "MockRecognizer2", + Entity: "SPACESHIP", + Language: "he", + Patterns: patternArr, + } + + recognizersArr := []*types.PatternRecognizer{} + recognizersArr = append(recognizersArr, recognizer) + recognizersArr = append(recognizersArr, recognizer2) + + return &types.RecognizersGetResponse{ + Recognizers: recognizersArr, + } +} + +//GetRecognizersStoreInsertOrUpdateMockResult get recognizers mock response +func GetRecognizersStoreInsertOrUpdateMockResult() *types.RecognizersStoreResponse { + return &types.RecognizersStoreResponse{} +} + +//GetRecognizersStoreDeleteMockResult get recognizers mock response +func GetRecognizersStoreDeleteMockResult() *types.RecognizersStoreResponse { + return &types.RecognizersStoreResponse{} +} + +//GetRecognizersStoreGetHashMockResult get recognizers mock response +func GetRecognizersStoreGetHashMockResult() *types.RecognizerHashResponse { + h := md5.New() + bytesHash := h.Sum([]byte("recognizers objects will be here...")) + return &types.RecognizerHashResponse{RecognizersHash: string(bytesHash)} +} + +//GetRecognizersStoreServiceMock get service mock +func GetRecognizersStoreServiceMock( + expectedGetResult *types.RecognizersGetResponse, + expectedGetAllResult *types.RecognizersGetResponse, + expectedInsertOrUpdateAllResult *types.RecognizersStoreResponse, + expectedDeleteResult *types.RecognizersStoreResponse, + expectedHashResult *types.RecognizerHashResponse) types.RecognizersStoreServiceClient { + service := &RecognizersStoreMockedObject{} + service.On("ApplyGet", mock.Anything, mock.Anything, mock.Anything).Return(expectedGetResult, nil) + service.On("ApplyGetAll", mock.Anything, mock.Anything, mock.Anything).Return(expectedGetAllResult, nil) + service.On("ApplyInsert", mock.Anything, mock.Anything, mock.Anything).Return(expectedInsertOrUpdateAllResult, nil) + service.On("ApplyUpdate", mock.Anything, mock.Anything, mock.Anything).Return(expectedInsertOrUpdateAllResult, nil) + service.On("ApplyDelete", mock.Anything, mock.Anything, mock.Anything).Return(expectedDeleteResult, nil) + service.On("ApplyGetHash", mock.Anything, mock.Anything, mock.Anything).Return(expectedHashResult, nil) + return service +} + +//ApplyGet recognizers mock +func (m *RecognizersStoreMockedObject) ApplyGet(ctx context.Context, r *types.RecognizerGetRequest, opts ...grpc.CallOption) (*types.RecognizersGetResponse, error) { + args := m.Mock.Called() + var result *types.RecognizersGetResponse + if args.Get(0) != nil { + result = args.Get(0).(*types.RecognizersGetResponse) + } + return result, args.Error(1) +} + +//ApplyGetAll recognizers mock +func (m *RecognizersStoreMockedObject) ApplyGetAll(ctx context.Context, r *types.RecognizersGetAllRequest, opts ...grpc.CallOption) (*types.RecognizersGetResponse, error) { + args := m.Mock.Called() + var result *types.RecognizersGetResponse + if args.Get(0) != nil { + result = args.Get(0).(*types.RecognizersGetResponse) + } + return result, args.Error(1) +} + +//ApplyInsert recognizers mock +func (m *RecognizersStoreMockedObject) ApplyInsert(ctx context.Context, r *types.RecognizerInsertOrUpdateRequest, opts ...grpc.CallOption) (*types.RecognizersStoreResponse, error) { + args := m.Mock.Called() + var result *types.RecognizersStoreResponse + if args.Get(0) != nil { + result = args.Get(0).(*types.RecognizersStoreResponse) + } + return result, args.Error(1) +} + +//ApplyUpdate recognizers mock +func (m *RecognizersStoreMockedObject) ApplyUpdate(ctx context.Context, r *types.RecognizerInsertOrUpdateRequest, opts ...grpc.CallOption) (*types.RecognizersStoreResponse, error) { + args := m.Mock.Called() + var result *types.RecognizersStoreResponse + if args.Get(0) != nil { + result = args.Get(0).(*types.RecognizersStoreResponse) + } + return result, args.Error(1) +} + +//ApplyDelete recognizers mock +func (m *RecognizersStoreMockedObject) ApplyDelete(ctx context.Context, r *types.RecognizerDeleteRequest, opts ...grpc.CallOption) (*types.RecognizersStoreResponse, error) { + args := m.Mock.Called() + var result *types.RecognizersStoreResponse + if args.Get(0) != nil { + result = args.Get(0).(*types.RecognizersStoreResponse) + } + return result, args.Error(1) +} + +//ApplyGetHash recognizers mock +func (m *RecognizersStoreMockedObject) ApplyGetHash(ctx context.Context, r *types.RecognizerGetHashRequest, opts ...grpc.CallOption) (*types.RecognizerHashResponse, error) { + args := m.Mock.Called() + var result *types.RecognizerHashResponse + if args.Get(0) != nil { + result = args.Get(0).(*types.RecognizerHashResponse) + } + return result, args.Error(1) +} diff --git a/presidio-api/cmd/presidio-api/api/recognizers/recognizers.go b/presidio-api/cmd/presidio-api/api/recognizers/recognizers.go new file mode 100644 index 000000000..ff97884e3 --- /dev/null +++ b/presidio-api/cmd/presidio-api/api/recognizers/recognizers.go @@ -0,0 +1,76 @@ +package recognizers + +import ( + "context" + + types "github.com/Microsoft/presidio-genproto/golang" + + store "github.com/Microsoft/presidio/presidio-api/cmd/presidio-api/api" +) + +//InsertRecognizer Inserts a new custom pattern recognizer via the Recognizer +// store service +func InsertRecognizer(ctx context.Context, + api *store.API, + request *types.RecognizerInsertOrUpdateRequest, +) (string, error) { + _, err := api.Services.InsertRecognizer(ctx, request.Value) + if err != nil { + return "", err + } + return "Recognizer was inserted successfully", nil +} + +// UpdateRecognizer updates an existing recognizer via the Recognizer +// store service +func UpdateRecognizer(ctx context.Context, + api *store.API, + request *types.RecognizerInsertOrUpdateRequest, +) (string, error) { + _, err := api.Services.UpdateRecognizer(ctx, request.Value) + if err != nil { + return "", err + } + return "Recognizer was updated successfully", nil +} + +// DeleteRecognizer deletes an existing recognizer via the Recognizer +// store service +func DeleteRecognizer(ctx context.Context, + api *store.API, + request *types.RecognizerDeleteRequest, +) (string, error) { + _, err := api.Services.DeleteRecognizer(ctx, request.Name) + if err != nil { + return "", err + } + return "Recognizer was deleted successfully", nil +} + +// GetRecognizer retrieves an existing recognizer via the Recognizer +// store service +func GetRecognizer(ctx context.Context, + api *store.API, + request *types.RecognizerGetRequest, +) ([]*types.PatternRecognizer, error) { + res, err := api.Services.GetRecognizer(ctx, request.Name) + if err != nil { + return nil, err + } + + return res.Recognizers, nil +} + +// GetAllRecognizers retrieves all existing recognizers via the Recognizer +// store service +func GetAllRecognizers(ctx context.Context, + api *store.API, + request *types.RecognizersGetAllRequest, +) ([]*types.PatternRecognizer, error) { + res, err := api.Services.GetAllRecognizers(ctx) + if err != nil { + return nil, err + } + + return res.Recognizers, nil +} diff --git a/presidio-api/cmd/presidio-api/api/recognizers/recognizers_test.go b/presidio-api/cmd/presidio-api/api/recognizers/recognizers_test.go new file mode 100644 index 000000000..b1ab6e0d9 --- /dev/null +++ b/presidio-api/cmd/presidio-api/api/recognizers/recognizers_test.go @@ -0,0 +1,113 @@ +package recognizers + +import ( + "context" + + "github.com/stretchr/testify/assert" + + types "github.com/Microsoft/presidio-genproto/golang" + "github.com/Microsoft/presidio/pkg/presidio/services" + + store "github.com/Microsoft/presidio/presidio-api/cmd/presidio-api/api" + "github.com/Microsoft/presidio/presidio-api/cmd/presidio-api/api/mocks" + + "testing" +) + +func setupMockServices() *store.API { + srv := &services.Services{ + AnalyzerService: mocks.GetAnalyzeServiceMock(mocks.GetAnalyzerMockResult()), + AnonymizeService: mocks.GetAnonymizerServiceMock(mocks.GetAnonymizerMockResult()), + RecognizersStoreService: mocks.GetRecognizersStoreServiceMock( + mocks.GetRecognizersStoreGetMockResult(), + mocks.GetRecognizersStoreGetAllMockResult(), + mocks.GetRecognizersStoreInsertOrUpdateMockResult(), + mocks.GetRecognizersStoreDeleteMockResult(), + mocks.GetRecognizersStoreGetHashMockResult()), + } + + api := &store.API{ + Services: srv, + Templates: mocks.GetTemplateMock(), + } + return api +} + +func createInsertOrUpdateRequest() types.RecognizerInsertOrUpdateRequest { + // Insert a new pattern recognizer + p := &types.Pattern{} + p.Score = 0.123 + p.Regex = "*FindMe*" + p.Name = "findme" + patternArr := []*types.Pattern{} + patternArr = append(patternArr, p) + + r := types.RecognizerInsertOrUpdateRequest{} + newRecognizer := types.PatternRecognizer{ + Name: "DemoRecognizer1", + Patterns: patternArr, + Entity: "DEMO_ITEMS", + Language: "en"} + r.Value = &newRecognizer + return r +} + +func TestInsertRecognizer(t *testing.T) { + + api := setupMockServices() + + r := createInsertOrUpdateRequest() + _, err := InsertRecognizer(context.Background(), api, &r) + + assert.NoError(t, err) +} + +func TestUpdateRecognizer(t *testing.T) { + + api := setupMockServices() + + r := createInsertOrUpdateRequest() + _, err := UpdateRecognizer(context.Background(), api, &r) + + assert.NoError(t, err) +} + +func TestDeleteRecognizer(t *testing.T) { + + api := setupMockServices() + + r := types.RecognizerDeleteRequest{Name: "myname"} + _, err := DeleteRecognizer(context.Background(), api, &r) + + assert.NoError(t, err) +} + +func TestGetRecognizer(t *testing.T) { + + api := setupMockServices() + + r := types.RecognizerGetRequest{Name: "myname"} + results, err := GetRecognizer(context.Background(), api, &r) + + assert.NoError(t, err) + mockResult := mocks.GetRecognizersStoreGetMockResult().Recognizers + assert.Equal(t, mockResult, results) + // Verify single result + + assert.Equal(t, 1, len(results)) +} + +func TestGetAllRecognizer(t *testing.T) { + + api := setupMockServices() + + r := types.RecognizersGetAllRequest{} + results, err := GetAllRecognizers(context.Background(), api, &r) + + assert.NoError(t, err) + mockResult := mocks.GetRecognizersStoreGetAllMockResult().Recognizers + assert.Equal(t, mockResult, results) + + // Verify multiple results + assert.Equal(t, 2, len(results)) +} diff --git a/presidio-api/cmd/presidio-api/api/templates/templates.go b/presidio-api/cmd/presidio-api/api/templates/templates.go index c251316ec..f2c570c4d 100644 --- a/presidio-api/cmd/presidio-api/api/templates/templates.go +++ b/presidio-api/cmd/presidio-api/api/templates/templates.go @@ -28,7 +28,7 @@ func PostActionTemplate(api *store.API, project, action, id, value string) (stri if err != nil { return "", err } - return "Template added successfully ", nil + return "Template added successfully", nil } //PutActionTemplate update template diff --git a/presidio-api/cmd/presidio-api/main.go b/presidio-api/cmd/presidio-api/main.go index 797b05e06..d085fb737 100644 --- a/presidio-api/cmd/presidio-api/main.go +++ b/presidio-api/cmd/presidio-api/main.go @@ -28,6 +28,7 @@ func main() { pflag.String(platform.AnonymizerImageSvcAddress, "", "Anonymizer image service address (optional)") pflag.String(platform.OcrSvcAddress, "", "ocr service address (optional)") pflag.String(platform.SchedulerSvcAddress, "", "Scheduler service address (optional)") + pflag.String(platform.RecognizersStoreSvcAddress, "localhost:3004", "Recognizers store service address") pflag.String(platform.RedisURL, "", "Redis address (optional)") pflag.String(platform.RedisPassword, "", "Redis db password (optional)") pflag.Int(platform.RedisDb, 0, "Redis db (optional)") @@ -124,6 +125,25 @@ func setupHTTPServer(port int, loglevel string) { projects.POST("/schedule-streams-job", scheduleStreamJob) } + // recognizers group + // /api/v1/analyzer/recognizers + recognizers := v1.Group("/analyzer/recognizers") + { + // Get an existing recognizer + recognizers.GET(":id", getRecognizer) + + // Get all existing recognizers + recognizers.GET("/", getAllRecognizers) + + // Insert a new recognizer + recognizers.POST(":id", insertRecognizer) + + // Update an existing recognizer + recognizers.PUT(":id", updateRecognizer) + + // DELETE a recognizer + recognizers.DELETE(":id", deleteRecognizer) + } } server.Start(r) } diff --git a/presidio-api/cmd/presidio-api/methods.go b/presidio-api/cmd/presidio-api/methods.go index 9a7c29153..92cead4ce 100644 --- a/presidio-api/cmd/presidio-api/methods.go +++ b/presidio-api/cmd/presidio-api/methods.go @@ -14,6 +14,7 @@ import ( "github.com/Microsoft/presidio/presidio-api/cmd/presidio-api/api/analyze" "github.com/Microsoft/presidio/presidio-api/cmd/presidio-api/api/anonymize" ai "github.com/Microsoft/presidio/presidio-api/cmd/presidio-api/api/anonymize-image" + "github.com/Microsoft/presidio/presidio-api/cmd/presidio-api/api/recognizers" scj "github.com/Microsoft/presidio/presidio-api/cmd/presidio-api/api/scanner-cron-job" sj "github.com/Microsoft/presidio/presidio-api/cmd/presidio-api/api/stream-job" "github.com/Microsoft/presidio/presidio-api/cmd/presidio-api/api/templates" @@ -241,3 +242,70 @@ func openFile(header *multipart.FileHeader) ([]byte, error) { return bt, nil } + +func insertRecognizer(c *gin.Context) { + var request *types.RecognizerInsertOrUpdateRequest + id := c.Param("id") + if c.Bind(&request) == nil { + request.Value.Name = id + result, err := recognizers.InsertRecognizer( + c, api, request) + if err != nil { + server.AbortWithError(c, http.StatusBadRequest, err) + return + } + server.WriteResponse(c, http.StatusOK, result) + } +} + +func updateRecognizer(c *gin.Context) { + var request *types.RecognizerInsertOrUpdateRequest + id := c.Param("id") + if c.Bind(&request) == nil { + request.Value.Name = id + result, err := recognizers.UpdateRecognizer( + c, api, request) + if err != nil { + server.AbortWithError(c, http.StatusBadRequest, err) + return + } + server.WriteResponse(c, http.StatusOK, result) + } +} + +func deleteRecognizer(c *gin.Context) { + var request types.RecognizerDeleteRequest + id := c.Param("id") + request.Name = id + result, err := recognizers.DeleteRecognizer( + c, api, &request) + if err != nil { + server.AbortWithError(c, http.StatusBadRequest, err) + return + } + server.WriteResponse(c, http.StatusOK, result) +} + +func getRecognizer(c *gin.Context) { + var request types.RecognizerGetRequest + id := c.Param("id") + request.Name = id + result, err := recognizers.GetRecognizer( + c, api, &request) + if err != nil { + server.AbortWithError(c, http.StatusBadRequest, err) + return + } + server.WriteResponse(c, http.StatusOK, result) +} + +func getAllRecognizers(c *gin.Context) { + var request *types.RecognizersGetAllRequest + result, err := recognizers.GetAllRecognizers( + c, api, request) + if err != nil { + server.AbortWithError(c, http.StatusBadRequest, err) + return + } + server.WriteResponse(c, http.StatusOK, result) +} diff --git a/presidio-recognizers-store/Dockerfile b/presidio-recognizers-store/Dockerfile new file mode 100644 index 000000000..d299b96ef --- /dev/null +++ b/presidio-recognizers-store/Dockerfile @@ -0,0 +1,22 @@ +ARG REGISTRY=presidio.azurecr.io +FROM ${REGISTRY}/presidio-golang-base AS build-env + +ARG NAME=presidio-recognizers-store +ARG PRESIDIOPATH=${GOPATH}/src/github.com/Microsoft/presidio +ARG VERSION=latest + +WORKDIR ${PRESIDIOPATH}/${NAME}/cmd/${NAME} + +RUN go clean + +RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 && go build -ldflags '-X github.com/Microsoft/presidio/pkg/version.Version=${VERSION}' -o /usr/bin/${NAME} + +#---------------------------- + +FROM alpine:3.8 + +ARG NAME=presidio-recognizers-store + +WORKDIR /usr/bin/ +COPY --from=build-env /usr/bin/${NAME} /usr/bin/ +CMD /usr/bin/presidio-recognizers-store \ No newline at end of file diff --git a/presidio-recognizers-store/cmd/presidio-recognizers-store/main.go b/presidio-recognizers-store/cmd/presidio-recognizers-store/main.go new file mode 100644 index 000000000..a5766eefe --- /dev/null +++ b/presidio-recognizers-store/cmd/presidio-recognizers-store/main.go @@ -0,0 +1,372 @@ +package main + +import ( + "context" + "crypto/md5" + "encoding/json" + "errors" + "sync" + + "google.golang.org/grpc/reflection" + + "flag" + + "github.com/spf13/pflag" + "github.com/spf13/viper" + + types "github.com/Microsoft/presidio-genproto/golang" + + "encoding/hex" + + "github.com/Microsoft/presidio/pkg/cache" + log "github.com/Microsoft/presidio/pkg/logger" + "github.com/Microsoft/presidio/pkg/platform" + "github.com/Microsoft/presidio/pkg/presidio/services" + "github.com/Microsoft/presidio/pkg/rpc" +) + +type server struct{} + +var ( + settings *platform.Settings + recognizersStore cache.Cache +) + +var ( + recognizersKey = "custom_recognizers" + hashKey = "custom_recognizers:hash" +) + +var mutex sync.RWMutex + +func main() { + + pflag.Int(platform.GrpcPort, 3004, "GRPC listen port") + pflag.String(platform.RedisURL, "localhost:6379", "Redis address") + pflag.String(platform.RedisPassword, "", "Redis db password (optional)") + pflag.Int(platform.RedisDb, 0, "Redis db (optional)") + pflag.Bool(platform.RedisSSL, false, "Redis ssl (optional)") + pflag.String(platform.PresidioNamespace, "", "Presidio Kubernetes namespace (optional)") + pflag.String("log_level", "info", "Log level - debug/info/warn/error") + + pflag.CommandLine.AddGoFlagSet(flag.CommandLine) + pflag.Parse() + viper.BindPFlags(pflag.CommandLine) + + settings = platform.GetSettings() + log.CreateLogger(settings.LogLevel) + + svc := services.New(settings) + + if settings.RedisURL != "" { + recognizersStore = svc.SetupCache() + } + + lis, s := rpc.SetupClient(settings.GrpcPort) + + types.RegisterRecognizersStoreServiceServer(s, &server{}) + reflection.Register(s) + + if err := s.Serve(lis); err != nil { + log.Fatal(err.Error()) + } +} + +// Acquires (or block until acquires) the lock to be able to write custom +// recognizers +func writersLock() { + log.Info("Acquiring lock") + mutex.Lock() + log.Info("Acquired") +} + +// Releases the writers lock +func writersUnlock() { + log.Info("Releasing lock") + mutex.Unlock() + log.Info("Released") +} + +// setValue sets the recognizers array in the store and updates the hash +func setValue(value []byte) error { + err := recognizersStore.Set(recognizersKey, string(value)) + if err != nil { + log.Error(err.Error()) + return err + } + + hashVal := md5.Sum(value) + calculatedHash := hex.EncodeToString(hashVal[:]) + log.Info("Updating hash: " + calculatedHash) + err = recognizersStore.Set(hashKey, calculatedHash) + if err != nil { + log.Error(err.Error()) + return err + } + + return nil +} + +func getExistingRecognizers() ([]types.PatternRecognizer, error) { + recognizersArr := make([]types.PatternRecognizer, 0) + existingItems, err := recognizersStore.Get(recognizersKey) + if err == nil && existingItems != "" { + log.Info("Found existing recognizers...") + var recognizers []types.PatternRecognizer + err = json.Unmarshal([]byte(existingItems), &recognizers) + if err != nil { + return nil, err + } + recognizersArr = append(recognizersArr, recognizers...) + } + return recognizersArr, nil +} + +// InsertOrUpdateRecognizer inserts (or updated) a recognizer to the store +func insertOrUpdateRecognizer(value string, isUpdate bool) error { + if recognizersStore == nil { + return errors.New("cache is missing") + } + + // When updating the underlying storage we want to ensure mutual + // exclusivness. To avoid situtation of two concurrent updates that creates + // a race condition. Resulting in a custom recognizer data loss + writersLock() + defer writersUnlock() + existingRecognizersArr, err := getExistingRecognizers() + if err != nil { + return err + } + + var newRecognizer types.PatternRecognizer + json.Unmarshal([]byte(value), &newRecognizer) + + if isUpdate == false { + // Insert new item, verify doesn't exists and append + for _, element := range existingRecognizersArr { + if element.Name == newRecognizer.Name { + return errors.New( + "custom pattern recognizer with name '" + + element.Name + "' already exists") + } + } + + // verified, append to result array + existingRecognizersArr = append(existingRecognizersArr, newRecognizer) + } else { + // Update, find and update + found := false + for i, element := range existingRecognizersArr { + if element.Name == newRecognizer.Name { + found = true + + log.Info("Found recognizer to be updated") + // remove the 'old' value + existingRecognizersArr = append(existingRecognizersArr[:i], + existingRecognizersArr[i+1:]...) + // append the new one + existingRecognizersArr = append(existingRecognizersArr, + newRecognizer) + break + } + } + if found == false { + return errors.New("custom pattern recognizer with name '" + + newRecognizer.Name + "' was not found") + } + } + + recognizersBytes, _ := json.Marshal(existingRecognizersArr) + return setValue(recognizersBytes) +} + +//applyGet returns a single recognizer +func applyGet(r *types.RecognizerGetRequest) (*types.RecognizersGetResponse, error) { + existingItems, err := recognizersStore.Get(recognizersKey) + if err != nil { + log.Error(err.Error()) + return nil, err + } + + // Find wanted recognizer + if existingItems != "" { + var recognizers []types.PatternRecognizer + err = json.Unmarshal([]byte(existingItems), &recognizers) + if err != nil { + return nil, err + } + + for _, element := range recognizers { + if element.Name == r.Name { + var res []*types.PatternRecognizer + res = append(res, &element) + return &types.RecognizersGetResponse{Recognizers: res}, nil + } + } + } + + // Failed to find wanted recognizer + return nil, errors.New("recognizer with name " + r.Name + " was not found") +} + +//applyGetAll returns all the stored recognizers +func applyGetAll(r *types.RecognizersGetAllRequest) (*types.RecognizersGetResponse, error) { + + if recognizersStore == nil { + return nil, errors.New("cache is missing") + } + + log.Info("Loading recognizers from underlying storage") + itemsStr, err := recognizersStore.Get(recognizersKey) + + if err != nil { + return nil, err + } + + if err == nil && itemsStr != "" { + var items []types.PatternRecognizer + var res []*types.PatternRecognizer + byteItems := []byte(itemsStr) + json.Unmarshal(byteItems, &items) + for i := range items { + res = append(res, &items[i]) + } + + result := &types.RecognizersGetResponse{Recognizers: res} + log.Info("Returning %d recognizers", len(res)) + return result, nil + } else if err == nil { + log.Info("No recognizers were found") + return &types.RecognizersGetResponse{}, nil + } + + return nil, err +} + +//applyInsertOrUpdate inserts or updates a recognizer in the store +func applyInsertOrUpdate(r *types.RecognizerInsertOrUpdateRequest, isUpdate bool) (*types.RecognizersStoreResponse, error) { + + itemBytes, err := json.Marshal(r.Value) + if err != nil { + return nil, err + } + + err = insertOrUpdateRecognizer(string(itemBytes), isUpdate) + return &types.RecognizersStoreResponse{}, err +} + +//applyDelete deletes a recognizer +func applyDelete(r *types.RecognizerDeleteRequest) (*types.RecognizersStoreResponse, error) { + if recognizersStore == nil { + return nil, + errors.New("cache is missing") + } + + // When updating the underlying storage we want to ensure mutual + // exclusivness. To avoid situtation of two concurrent updates that creates + // a race condition. Resulting in a custom recognizer data loss + writersLock() + defer writersUnlock() + existingRecognizersArr, err := getExistingRecognizers() + if err != nil { + return nil, err + } + + found := false + for i, element := range existingRecognizersArr { + if element.Name == r.Name { + log.Info("Found recognizer '" + r.Name + "'. Deleting") + found = true + existingRecognizersArr = append(existingRecognizersArr[:i], + existingRecognizersArr[i+1:]...) + } + } + if found == false { + notFoundErrMsg := "Failed to find recognizer '" + r.Name + "'" + log.Error(notFoundErrMsg) + return nil, errors.New(notFoundErrMsg) + } + + recognizersBytes, _ := json.Marshal(existingRecognizersArr) + return &types.RecognizersStoreResponse{}, setValue(recognizersBytes) +} + +//applyGetHash returns the hash of the stored recognizers +func applyGetHash() (*types.RecognizerHashResponse, error) { + if recognizersStore == nil { + return nil, errors.New("cache is missing") + } + + hash, err := recognizersStore.Get(hashKey) + if err != nil || hash == "" { + errMsg := "Failed to find the latest hash" + log.Error(errMsg) + return &types.RecognizerHashResponse{}, errors.New(errMsg) + } + + return &types.RecognizerHashResponse{RecognizersHash: hash}, nil +} + +// Server methods + +func (s *server) ApplyGet(ctx context.Context, r *types.RecognizerGetRequest) (*types.RecognizersGetResponse, error) { + response, err := applyGet(r) + if err != nil { + log.Error(err.Error()) + return &types.RecognizersGetResponse{}, err + } + + return response, nil +} + +func (s *server) ApplyGetAll(ctx context.Context, r *types.RecognizersGetAllRequest) (*types.RecognizersGetResponse, error) { + response, err := applyGetAll(r) + if err != nil { + log.Error(err.Error()) + return &types.RecognizersGetResponse{}, err + } + + return response, nil +} + +func (s *server) ApplyInsert(ctx context.Context, r *types.RecognizerInsertOrUpdateRequest) (*types.RecognizersStoreResponse, error) { + response, err := applyInsertOrUpdate(r, false) + if err != nil { + log.Error(err.Error()) + return &types.RecognizersStoreResponse{}, err + } + + return response, nil +} + +func (s *server) ApplyUpdate(ctx context.Context, r *types.RecognizerInsertOrUpdateRequest) (*types.RecognizersStoreResponse, error) { + response, err := applyInsertOrUpdate(r, true) + if err != nil { + log.Error(err.Error()) + return &types.RecognizersStoreResponse{}, err + } + + return response, nil +} + +func (s *server) ApplyDelete(ctx context.Context, r *types.RecognizerDeleteRequest) (*types.RecognizersStoreResponse, error) { + response, err := applyDelete(r) + if err != nil { + log.Error(err.Error()) + return &types.RecognizersStoreResponse{}, err + } + + return response, nil +} + +func (s *server) ApplyGetHash(ctx context.Context, + r *types.RecognizerGetHashRequest) ( + *types.RecognizerHashResponse, error) { + response, err := applyGetHash() + if err != nil { + log.Error(err.Error()) + return &types.RecognizerHashResponse{}, err + } + + return response, nil +} diff --git a/presidio-recognizers-store/cmd/presidio-recognizers-store/recognizers_store_test.go b/presidio-recognizers-store/cmd/presidio-recognizers-store/recognizers_store_test.go new file mode 100644 index 000000000..0b972e3cb --- /dev/null +++ b/presidio-recognizers-store/cmd/presidio-recognizers-store/recognizers_store_test.go @@ -0,0 +1,269 @@ +package main + +import ( + "os" + + "github.com/stretchr/testify/assert" + + types "github.com/Microsoft/presidio-genproto/golang" + "github.com/Microsoft/presidio/pkg/cache/mock" + "github.com/Microsoft/presidio/pkg/platform" + + "testing" +) + +// createNewRecognizer creates a single dummy recognizer with a given name +func createNewRecognizer(name string) types.PatternRecognizer { + p := &types.Pattern{} + p.Score = 0.123 + p.Regex = "*FindMe*" + p.Name = "findme" + patternArr := []*types.Pattern{} + patternArr = append(patternArr, p) + + newRecognizer := types.PatternRecognizer{ + Name: name, + Patterns: patternArr, + Entity: "DEMO_ITEMS", + Language: "en"} + + return newRecognizer +} + +func TestMain(m *testing.M) { + os.Setenv("REDIS_URL", "fake_redis") + settings = platform.GetSettings() + os.Exit(m.Run()) +} + +// Insert and Get, verify it worked +func TestInsertAndGetRecognizer(t *testing.T) { + // Mock the store with a fake redis + recognizersStore = mock.New() + + // Insert a new pattern recognizer + newRecognizer := createNewRecognizer("DemoRecognizer1") + r := &types.RecognizerInsertOrUpdateRequest{} + r.Value = &newRecognizer + _, err := applyInsertOrUpdate(r, false) + assert.NoError(t, err) + + // Verify returned object is as expected + getRequest := &types.RecognizerGetRequest{Name: newRecognizer.Name} + getResponse, err := applyGet(getRequest) + assert.NoError(t, err) + assert.Equal(t, len(getResponse.Recognizers), 1) + + // Verify that the item is exactly as expected + assert.Equal(t, &newRecognizer, getResponse.Recognizers[0]) +} + +// Try to update a non-existing item and expect to fail +func TestUpdateOfNonExisting(t *testing.T) { + // Mock the store with a fake redis + recognizersStore = mock.New() + + r := &types.RecognizerInsertOrUpdateRequest{} + // Try to update, expect to fail as this item does not exists + _, err := applyInsertOrUpdate(r, true) + assert.Error(t, err) +} + +// Try to insert the same item again and expect to fail (on the second time) +func TestConflictingInserts(t *testing.T) { + // Mock the store with a fake redis + recognizersStore = mock.New() + + // Store is empty... + rawValues, err := recognizersStore.Get(recognizersKey) + assert.NoError(t, err) + assert.Equal(t, rawValues, "") + + // Insert a new pattern recognizer + recognizer1 := createNewRecognizer("DemoRecognizer1") + r := &types.RecognizerInsertOrUpdateRequest{} + r.Value = &recognizer1 + // Insert, should be fine... + _, err = applyInsertOrUpdate(r, false) + assert.NoError(t, err) + // This should fail as it already exists + _, err = applyInsertOrUpdate(r, false) + assert.Error(t, err) + + // Verify just one item was returned (even though we got an error, make sure + // that indeed just one item is returned) + getRequest := &types.RecognizersGetAllRequest{} + getResponse, err := applyGetAll(getRequest) + assert.NoError(t, err) + assert.Equal(t, len(getResponse.Recognizers), 1) +} + +// Try to insert several different items and expect to succeed +func TestMultipleDifferentInserts(t *testing.T) { + // Mock the store with a fake redis + recognizersStore = mock.New() + + // Store is empty... + rawValues, err := recognizersStore.Get(recognizersKey) + assert.NoError(t, err) + assert.Equal(t, rawValues, "") + + // Insert a new pattern recognizer + recognizer1 := createNewRecognizer("DemoRecognizer1") + r := &types.RecognizerInsertOrUpdateRequest{} + r.Value = &recognizer1 + _, err = applyInsertOrUpdate(r, false) + assert.NoError(t, err) + + recognizer2 := createNewRecognizer("DemoRecognizer2") + r = &types.RecognizerInsertOrUpdateRequest{} + r.Value = &recognizer2 + _, err = applyInsertOrUpdate(r, false) + assert.NoError(t, err) + + // Verify returned object is as expected + getRequest := &types.RecognizersGetAllRequest{} + getResponse, err := applyGetAll(getRequest) + assert.NoError(t, err) + assert.Equal(t, len(getResponse.Recognizers), 2) +} + +// Delete the only existing item and expect to succeed +func TestDeleteOnlyRecognizer(t *testing.T) { + // Mock the store with a fake redis + recognizersStore = mock.New() + + // Store is empty... + rawValues, err := recognizersStore.Get(recognizersKey) + assert.NoError(t, err) + assert.Equal(t, rawValues, "") + + // Insert a new pattern recognizer + recognizer1 := createNewRecognizer("DemoRecognizer1") + r := &types.RecognizerInsertOrUpdateRequest{} + r.Value = &recognizer1 + _, err = applyInsertOrUpdate(r, false) + assert.NoError(t, err) + + // Delete + deleteRequest := &types.RecognizerDeleteRequest{Name: recognizer1.Name} + _, err = applyDelete(deleteRequest) + assert.NoError(t, err) + + // Get should fail as it was already deleted + getRequest := &types.RecognizerGetRequest{Name: recognizer1.Name} + _, err = applyGet(getRequest) + assert.Error(t, err) + + getAllRequest := &types.RecognizersGetAllRequest{} + res, err := applyGetAll(getAllRequest) + assert.NoError(t, err) + assert.Equal(t, len(res.Recognizers), 0) +} + +func TestDeleteRecognizer(t *testing.T) { + // Mock the store with a fake redis + recognizersStore = mock.New() + + // Store is empty... + rawValues, err := recognizersStore.Get(recognizersKey) + assert.NoError(t, err) + assert.Equal(t, rawValues, "") + + // Insert a new pattern recognizer + recognizer1 := createNewRecognizer("DemoRecognizer1") + r := &types.RecognizerInsertOrUpdateRequest{} + r.Value = &recognizer1 + _, err = applyInsertOrUpdate(r, false) + assert.NoError(t, err) + + // Insert a second item + recognizer2 := createNewRecognizer("DemoRecognizer2") + r = &types.RecognizerInsertOrUpdateRequest{} + r.Value = &recognizer2 + _, err = applyInsertOrUpdate(r, false) + assert.NoError(t, err) + + // Get should succeed with 2 values + getRequest := &types.RecognizersGetAllRequest{} + getResponse, err := applyGetAll(getRequest) + assert.NoError(t, err) + assert.Equal(t, len(getResponse.Recognizers), 2) + + // Delete + deleteRequest := &types.RecognizerDeleteRequest{Name: "DemoRecognizer1"} + _, err = applyDelete(deleteRequest) + assert.NoError(t, err) + + // Get should succeed with just 1 value + getRequest = &types.RecognizersGetAllRequest{} + getResponse, err = applyGetAll(getRequest) + assert.NoError(t, err) + assert.Equal(t, len(getResponse.Recognizers), 1) + assert.Equal(t, getResponse.Recognizers[0].Name, "DemoRecognizer2") +} + +// Try to delete a non-existing item and expect to fail +func TestDeleteNonExistingRecognizer(t *testing.T) { + // Mock the store with a fake redis + recognizersStore = mock.New() + + // Store is empty... + rawValues, err := recognizersStore.Get(recognizersKey) + assert.NoError(t, err) + assert.Equal(t, rawValues, "") + + // Delete + deleteRequest := &types.RecognizerDeleteRequest{Name: "someName"} + _, err = applyDelete(deleteRequest) + assert.Error(t, err) +} + +// Try to get the latest hash and fail as the store is empty +func TestHashDoesNotExists(t *testing.T) { + // Mock the store with a fake redis + recognizersStore = mock.New() + + // Store is empty... + res, err := applyGetHash() + assert.Error(t, err) + assert.Equal(t, res, &types.RecognizerHashResponse{}) +} + +// Try to get the latest hash +func TestGetHash(t *testing.T) { + // Mock the store with a fake redis + recognizersStore = mock.New() + + // Store is empty... + res, err := applyGetHash() + assert.Error(t, err) + assert.Equal(t, res, &types.RecognizerHashResponse{}) + + // Now, insert an item + // Insert a new pattern recognizer + newRecognizer1 := createNewRecognizer("DemoRecognizer1") + r := &types.RecognizerInsertOrUpdateRequest{} + r.Value = &newRecognizer1 + _, err = applyInsertOrUpdate(r, false) + assert.NoError(t, err) + + // Get hash again, it should succeed and value should not be empty + res, err = applyGetHash() + assert.NoError(t, err) + assert.NotEqual(t, res, &types.RecognizerHashResponse{}) + + // Now, update the store, get the hash again, make sure the hash + // is not as the previous one --> was updated + newRecognizer1.Language = "de" + r.Value = &newRecognizer1 + _, err = applyInsertOrUpdate(r, true /* update */) + assert.NoError(t, err) + + // Get hash again + res2, err2 := applyGetHash() + assert.NoError(t, err2) + assert.NotEqual(t, res2, &types.RecognizerHashResponse{}) + // this and previous one are different + assert.NotEqual(t, res.RecognizersHash, res2.RecognizersHash) +} diff --git a/tests/functional_recognizers_test.go b/tests/functional_recognizers_test.go new file mode 100644 index 000000000..b9d9fdb0b --- /dev/null +++ b/tests/functional_recognizers_test.go @@ -0,0 +1,148 @@ +// +build functional + +package tests + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + types "github.com/Microsoft/presidio-genproto/golang" +) + +// TestAddRecognizerAndAnalyze tests the custom recognizers logic. +// 1) It add a new custom recognizer and then use it to +// to analyze text +// 2) Updates the recognizer and verify the new version is used +// 3) Delete the recognizer and verify the results +func TestAddRecognizerAndAnalyze(t *testing.T) { + // Add a custom recognizer and use it + payload := generatePayload("new-custom-pattern-recognizer.json") + invokeHTTPRequest(t, "/api/v1/analyzer/recognizers/newrec1", "POST", payload) + + payload = generatePayload("analyze-custom-recognizer-template.json") + invokeHTTPRequest(t, "/api/v1/templates/test/analyze/test-custom", "POST", payload) + + payload = generatePayload("analyze-custom-recognizer-request.json") + results := invokeHTTPRequest(t, "/api/v1/projects/test/analyze", "POST", payload) + + expectedResults := []*types.AnalyzeResult{ + { + Location: &types.Location{ + Start: 7, End: 16, Length: 9, + }, + }, + { + Location: &types.Location{ + Start: 17, End: 24, Length: 7, + }, + }, + { + Location: &types.Location{ + Start: 28, End: 35, Length: 7, + }, + }, + { + Location: &types.Location{ + Start: 62, End: 69, Length: 7, + }, + }, + } + + verifyResults(t, results, expectedResults) + + // sleeping to make sure the timestamp changes + time.Sleep(2 * time.Second) + + // Update the recognizer and expect a new entity to be found + updatePayload := generatePayload("update-custom-pattern-recognizer.json") + invokeHTTPRequest(t, "/api/v1/analyzer/recognizers/newrec1", "PUT", updatePayload) + + newAnalyzePayload := generatePayload("analyze-custom-recognizer-request.json") + newResults := invokeHTTPRequest(t, "/api/v1/projects/test/analyze", "POST", newAnalyzePayload) + + updatedExpectedResults := []*types.AnalyzeResult{ + { + Location: &types.Location{ + Start: 7, End: 16, Length: 9, + }, + }, + { + Location: &types.Location{ + Start: 17, End: 24, Length: 7, + }, + }, + { + Location: &types.Location{ + Start: 28, End: 35, Length: 7, + }, + }, + { + Location: &types.Location{ + Start: 42, End: 52, Length: 10, + }, + }, + { + Location: &types.Location{ + Start: 62, End: 69, Length: 7, + }, + }, + } + + verifyResults(t, newResults, updatedExpectedResults) + + // sleeping to make sure the timestamp changes + time.Sleep(2 * time.Second) + + // Now, delete this recognizer and expect all the 'rockets' not + // to appear + invokeHTTPRequest(t, "/api/v1/analyzer/recognizers/newrec1", "DELETE", []byte("")) + + deletedAnalyzePayload := generatePayload("analyze-custom-recognizer-request.json") + newResults = invokeHTTPRequest(t, "/api/v1/projects/test/analyze", "POST", deletedAnalyzePayload) + + deletedExpectedResults := []*types.AnalyzeResult{ + { + Location: &types.Location{ + Start: 7, End: 16, Length: 9, + }, + }, + { + Location: &types.Location{ + Start: 17, End: 24, Length: 7, + }, + }, + { + Location: &types.Location{ + Start: 28, End: 35, Length: 7, + }, + }, + } + + // Verify after the deletion no 'rockets' entities + verifyResults(t, newResults, deletedExpectedResults) +} + +func verifyResults(t *testing.T, + returnedResults string, expectedResults []*types.AnalyzeResult) { + + returnedResult := []*types.AnalyzeResult{} + err := json.Unmarshal([]byte(returnedResults), &returnedResult) + assert.NoError(t, err) + + assert.Equal(t, len(expectedResults), len(returnedResult)) + // we don't know the exact order so we need to verify the results + for _, expectedItem := range expectedResults { + found := false + for _, returnedItem := range returnedResult { + if returnedItem.Location.Start == expectedItem.Location.Start && + returnedItem.Location.End == expectedItem.Location.End && + returnedItem.Location.Length == expectedItem.Location.Length { + found = true + } + } + assert.Equal(t, true, found) + } +} diff --git a/tests/testdata/analyze-custom-recognizer-request.json b/tests/testdata/analyze-custom-recognizer-request.json new file mode 100644 index 000000000..411ec544e --- /dev/null +++ b/tests/testdata/analyze-custom-recognizer-request.json @@ -0,0 +1,4 @@ +{ + "text": "We met yesterday morning in Seattle and we launched an awesome rocket", + "AnalyzeTemplateId": "test-custom" +} \ No newline at end of file diff --git a/tests/testdata/analyze-custom-recognizer-template.json b/tests/testdata/analyze-custom-recognizer-template.json new file mode 100644 index 000000000..5bbe8e94b --- /dev/null +++ b/tests/testdata/analyze-custom-recognizer-template.json @@ -0,0 +1,19 @@ +{ + "fields": [ + { + "name": "PHONE_NUMBER" + }, + { + "name": "LOCATION" + }, + { + "name": "DATE_TIME" + }, + { + "name": "CREDIT_CARD" + }, + { + "name": "ROCKETS" + } + ] +} \ No newline at end of file diff --git a/tests/testdata/new-custom-pattern-recognizer.json b/tests/testdata/new-custom-pattern-recognizer.json new file mode 100644 index 000000000..d44d6cfbc --- /dev/null +++ b/tests/testdata/new-custom-pattern-recognizer.json @@ -0,0 +1,18 @@ +{ + "value": { + "entity": "ROCKETS", + "language": "en", + "patterns": [ + { + "name": "rocket-recognizer", + "regex": "\\W*(rocket)\\W*", + "score": 1 + }, + { + "name": "projectile-recognizer", + "regex": "\\W*(projectile)\\W*", + "score": 1 + } + ] + } +} \ No newline at end of file diff --git a/tests/testdata/update-custom-pattern-recognizer.json b/tests/testdata/update-custom-pattern-recognizer.json new file mode 100644 index 000000000..764b52bef --- /dev/null +++ b/tests/testdata/update-custom-pattern-recognizer.json @@ -0,0 +1,18 @@ +{ + "value": { + "entity": "ROCKETS", + "language": "en", + "patterns": [ + { + "name": "rocket-recognizer", + "regex": "\\W*(rocket)\\W*", + "score": 1 + }, + { + "name": "verb-recognizer", + "regex": "\\W*(launched)\\W*", + "score": 1 + } + ] + } +} \ No newline at end of file From b3b6dee2b3e72b8e71dae069ae187b5bf317e804 Mon Sep 17 00:00:00 2001 From: Omri Mendels Date: Thu, 4 Apr 2019 13:01:44 +0300 Subject: [PATCH 12/75] Entities list for all fields true (#111) Bug fix with all_fields = True --- presidio-analyzer/analyzer/analyzer_engine.py | 24 ++++++++++++-- .../tests/test_analyzer_engine.py | 31 ++++++++++++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/presidio-analyzer/analyzer/analyzer_engine.py b/presidio-analyzer/analyzer/analyzer_engine.py index 1276e240d..0522b556e 100644 --- a/presidio-analyzer/analyzer/analyzer_engine.py +++ b/presidio-analyzer/analyzer/analyzer_engine.py @@ -5,7 +5,6 @@ import analyze_pb2_grpc import common_pb2 - from analyzer import RecognizerRegistry # noqa: F401 loglevel = os.environ.get("LOG_LEVEL", "INFO") @@ -85,11 +84,23 @@ def analyze(self, text, entities, language, all_fields): of the requested language :return: an array of the found entities in the text """ + recognizers = self.registry.get_recognizers(language=language, entities=entities, all_fields=all_fields) - results = [] + if all_fields: + if entities: + raise ValueError("Cannot have both all_fields=True " + "and a populated list of entities. " + "Either have all_fields set to True " + "and entities are empty, or all_fields " + "is False and entities is populated") + # Since all_fields=True, list all entities by going + # over all recognizers + entities = self.__list_entities(recognizers) + + results = [] for recognizer in recognizers: # Lazy loading of the relevant recognizers if not recognizer.is_loaded: @@ -102,6 +113,15 @@ def analyze(self, text, entities, language, all_fields): return AnalyzerEngine.__remove_duplicates(results) + @staticmethod + def __list_entities(recognizers): + entities = [] + for recognizer in recognizers: + ents = [entity for entity in recognizer.supported_entities] + entities.extend(ents) + + return list(set(entities)) + @staticmethod def __convert_fields_to_entities(fields): # Convert fields to entities - will be changed once the API diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index 9371d0c13..fe8e78dbd 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -234,5 +234,34 @@ def test_when_allFields_is_true_return_all_fields(self): request.text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090 " \ "Domain: microsoft.com" response = analyze_engine.Apply(request, None) + returned_entities = [field.field.name for field in response.analyzeResults] + assert response.analyzeResults is not None - assert len(response.analyzeResults) is 3 + assert "CREDIT_CARD" in returned_entities + assert "PHONE_NUMBER" in returned_entities + assert "DOMAIN_NAME" in returned_entities + + def test_when_allFields_is_true_full_recognizers_list_return_all_fields(self): + analyze_engine = AnalyzerEngine(RecognizerRegistry()) + request = AnalyzeRequest() + request.analyzeTemplate.allFields = True + request.text = "My name is David and I live in Seattle." \ + "Domain: microsoft.com " + response = analyze_engine.Apply(request, None) + returned_entities = [field.field.name for field in response.analyzeResults] + assert response.analyzeResults is not None + assert "PERSON" in returned_entities + assert "LOCATION" in returned_entities + assert "DOMAIN_NAME" in returned_entities + + def test_when_allFields_is_true_and_entities_not_empty_exception(self): + analyze_engine = AnalyzerEngine(RecognizerRegistry()) + request = AnalyzeRequest() + request.text = "My name is David and I live in Seattle." \ + "Domain: microsoft.com " + request.analyzeTemplate.allFields = True + new_field = request.analyzeTemplate.fields.add() + new_field.name = 'CREDIT_CARD' + new_field.minScore = '0.5' + with pytest.raises(ValueError): + analyze_engine.Apply(request, None) From 39c2c4591aa7c6a3b7fe06ef0aac03329c8b78ad Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Fri, 19 Apr 2019 13:03:16 +0300 Subject: [PATCH 13/75] (Re)Enable context support in analyzer (#114) This PR introduces estimation of surrounding words (aka context). It also introduces generic NLP classes for accessing metadata extracted by an NLP engine (specifically tokens, lemmas, NER, stopwords and punctuation). SpacyRecognizer no longer runs spacy but only processes the metadata processed by the NLP Engine. In the current implementation, Spacy is the implementation of the NLP engine. --- .../templates/analyzer-deployment.yaml | 4 +- presidio-analyzer/analyzer/analyzer_engine.py | 61 ++--- .../analyzer/entity_recognizer.py | 217 +++++++++++++++++- .../analyzer/nlp_engine/__init__.py | 4 + .../analyzer/nlp_engine/nlp_artifacts.py | 35 +++ .../analyzer/nlp_engine/nlp_engine.py | 25 ++ .../analyzer/nlp_engine/spacy_nlp_engine.py | 54 +++++ .../analyzer/pattern_recognizer.py | 29 ++- .../spacy_recognizer.py | 19 +- .../us_phone_recognizer.py | 25 +- .../recognizer_registry.py | 4 +- presidio-analyzer/requirements.txt | 2 +- presidio-analyzer/setup.py | 2 +- .../tests/data/context_sentences_tests.txt | 65 ++++++ presidio-analyzer/tests/mocks/__init__.py | 1 + .../tests/mocks/nlp_engine_mock.py | 18 ++ .../tests/test_analyzer_engine.py | 42 +++- .../tests/test_context_support.py | 92 ++++++++ presidio-analyzer/tests/test_ip_recognizer.py | 18 +- .../tests/test_spacy_recognizer.py | 171 +++++++------- .../tests/test_us_bank_recognizer.py | 28 --- .../test_us_driver_license_recognizer.py | 80 +++---- .../tests/test_us_itin_recognizer.py | 49 +--- .../tests/test_us_passport_recognizer.py | 9 - .../tests/test_us_phone_recognizer.py | 50 +--- .../tests/test_us_ssn_recognizer.py | 58 +---- 26 files changed, 775 insertions(+), 387 deletions(-) create mode 100644 presidio-analyzer/analyzer/nlp_engine/__init__.py create mode 100644 presidio-analyzer/analyzer/nlp_engine/nlp_artifacts.py create mode 100644 presidio-analyzer/analyzer/nlp_engine/nlp_engine.py create mode 100644 presidio-analyzer/analyzer/nlp_engine/spacy_nlp_engine.py create mode 100644 presidio-analyzer/tests/data/context_sentences_tests.txt create mode 100644 presidio-analyzer/tests/mocks/__init__.py create mode 100644 presidio-analyzer/tests/mocks/nlp_engine_mock.py create mode 100644 presidio-analyzer/tests/test_context_support.py diff --git a/charts/presidio/templates/analyzer-deployment.yaml b/charts/presidio/templates/analyzer-deployment.yaml index 5448f8d59..47d7dc3c9 100644 --- a/charts/presidio/templates/analyzer-deployment.yaml +++ b/charts/presidio/templates/analyzer-deployment.yaml @@ -26,10 +26,10 @@ spec: - containerPort: {{ .Values.analyzer.service.internalPort }} resources: requests: - memory: "1500Mi" + memory: "2000Mi" cpu: "1500m" limits: - memory: "3000Mi" + memory: "5000Mi" cpu: "2000m" env: - name: PRESIDIO_NAMESPACE diff --git a/presidio-analyzer/analyzer/analyzer_engine.py b/presidio-analyzer/analyzer/analyzer_engine.py index 0522b556e..3dddf8df1 100644 --- a/presidio-analyzer/analyzer/analyzer_engine.py +++ b/presidio-analyzer/analyzer/analyzer_engine.py @@ -5,7 +5,8 @@ import analyze_pb2_grpc import common_pb2 -from analyzer import RecognizerRegistry # noqa: F401 +from analyzer import RecognizerRegistry +from analyzer.nlp_engine import SpacyNlpEngine loglevel = os.environ.get("LOG_LEVEL", "INFO") logging.basicConfig( @@ -16,11 +17,33 @@ class AnalyzerEngine(analyze_pb2_grpc.AnalyzeServiceServicer): - def __init__(self, registry=RecognizerRegistry()): - # load all recognizers + def __init__(self, registry=RecognizerRegistry(), + nlp_engine=SpacyNlpEngine()): + # load nlp module + self.nlp_engine = nlp_engine + # prepare registry self.registry = registry + # load all recognizers registry.load_predefined_recognizers() + # pylint: disable=unused-argument + def Apply(self, request, context): + logging.info("Starting Apply") + entities = AnalyzerEngine.__convert_fields_to_entities( + request.analyzeTemplate.fields) + language = AnalyzerEngine.get_language_from_request(request) + results = self.analyze(request.text, entities, language, + request.analyzeTemplate.allFields) + + # Create Analyze Response Object + response = analyze_pb2.AnalyzeResponse() + + # pylint: disable=no-member + response.analyzeResults.extend( + AnalyzerEngine.__convert_results_to_proto(results)) + logging.info("Found %d results", len(results)) + return response + @staticmethod def __remove_duplicates(results): # bug# 597: Analyzer remove duplicates doesn't handle all cases of one @@ -39,7 +62,7 @@ def __remove_duplicates(results): # If result is equal to or substring of # one of the other results if result.start >= filtered.start \ - and result.end <= filtered.end: + and result.end <= filtered.end: valid_result = False break @@ -48,24 +71,6 @@ def __remove_duplicates(results): return filtered_results - # pylint: disable=unused-argument - def Apply(self, request, context): - logging.info("Starting Apply") - entities = AnalyzerEngine.__convert_fields_to_entities( - request.analyzeTemplate.fields) - language = AnalyzerEngine.get_language_from_request(request) - results = self.analyze(request.text, entities, language, - request.analyzeTemplate.allFields) - - # Create Analyze Response Object - response = analyze_pb2.AnalyzeResponse() - - # pylint: disable=no-member - response.analyzeResults.extend( - AnalyzerEngine.__convert_results_to_proto(results)) - logging.info("Found %d results", len(results)) - return response - @classmethod def get_language_from_request(cls, request): language = request.analyzeTemplate.language @@ -96,10 +101,13 @@ def analyze(self, text, entities, language, all_fields): "Either have all_fields set to True " "and entities are empty, or all_fields " "is False and entities is populated") - # Since all_fields=True, list all entities by going + # Since all_fields=True, list all entities by iterating # over all recognizers entities = self.__list_entities(recognizers) + # run the nlp pipeline over the given text, store the results in + # a NlpArtifacts instance + nlp_artifacts = self.nlp_engine.process_text(text, language) results = [] for recognizer in recognizers: # Lazy loading of the relevant recognizers @@ -107,9 +115,10 @@ def analyze(self, text, entities, language, all_fields): recognizer.load() recognizer.is_loaded = True - r = recognizer.analyze(text, entities) - if r is not None: - results.extend(r) + # analyze using the current recognizer and append the results + current_results = recognizer.analyze(text, entities, nlp_artifacts) + if current_results: + results.extend(current_results) return AnalyzerEngine.__remove_duplicates(results) diff --git a/presidio-analyzer/analyzer/entity_recognizer.py b/presidio-analyzer/analyzer/entity_recognizer.py index 76f1ca07d..40aad817f 100644 --- a/presidio-analyzer/analyzer/entity_recognizer.py +++ b/presidio-analyzer/analyzer/entity_recognizer.py @@ -1,11 +1,17 @@ import logging import os from abc import abstractmethod +import copy class EntityRecognizer: MIN_SCORE = 0 MAX_SCORE = 1.0 + CONTEXT_SIMILARITY_THRESHOLD = 0.65 + CONTEXT_SIMILARITY_FACTOR = 0.35 + MIN_SCORE_WITH_CONTEXT_SIMILARITY = 0.6 + CONTEXT_PREFIX_COUNT = 5 + CONTEXT_SUFFIX_COUNT = 0 def __init__(self, supported_entities, name=None, supported_language="en", version="0.0.1"): @@ -45,13 +51,16 @@ def load(self): """ @abstractmethod - def analyze(self, text, entities): + def analyze(self, text, entities, nlp_artifacts): """ This is the core method for analyzing text, assuming entities are the subset of the supported entities types. :param text: The text to be analyzed :param entities: The list of entities to be detected + :param nlp_artifacts: Value of type NlpArtifacts. + A group of attributes which are the result of + some NLP process over the matching text :return: list of RecognizerResult :rtype: [RecognizerResult] """ @@ -86,3 +95,209 @@ def to_dict(self): @classmethod def from_dict(cls, entity_recognizer_dict): return cls(**entity_recognizer_dict) + + def enhance_using_context(self, text, raw_results, + nlp_artifacts, predefined_context_words): + """ using the surrounding words of the actual word matches, look + for specific strings that if found contribute to the score + of the result, improving the confidence that the match is + indeed of that PII entity type + + :param text: The actual text that was analyzed + :param raw_results: Recognizer results which didn't take + context into consideration + :param nlp_artifacts: The nlp artifacts contains elements + such as lemmatized tokens for better + accuracy of the context enhancement process + :param predefined_context_words: The words the current recognizer + supports (words to lookup) + """ + # create a deep copy of the results object so we can manipulate it + results = copy.deepcopy(raw_results) + + # Sanity + if nlp_artifacts is None: + self.logger.warning('[%s]. NLP artifacts were not provided', + self.name) + return results + if predefined_context_words is None or predefined_context_words == []: + self.logger.info("recognizer '%s' does not support context " + "enhancement", self.name) + return results + + for result in results: + # extract lemmatized context from the surronding of the match + context = self.__extract_context( + nlp_artifacts=nlp_artifacts, + word=text[result.start:result.end], + start=result.start) + + context_similarity = self.__calculate_context_similarity( + context, predefined_context_words) + if context_similarity >= \ + self.CONTEXT_SIMILARITY_THRESHOLD: + result.score += \ + context_similarity * self.CONTEXT_SIMILARITY_FACTOR + result.score = max( + result.score, + self.MIN_SCORE_WITH_CONTEXT_SIMILARITY) + result.score = min( + result.score, + EntityRecognizer.MAX_SCORE) + return results + + @staticmethod + def __context_to_keywords(context): + return context.split(' ') + + def __calculate_context_similarity(self, + context_text, + context_list): + """Context similarity is 1 if there's exact match between a keyword in + context_text and any keyword in context_list + + :param context_text a string of the prefix and suffix of the found + match + :param context_list a list of words considered as context keywords + """ + + if context_list is None: + return 0 + + lemmatized_keywords = self.__context_to_keywords(context_text) + if lemmatized_keywords is None: + return 0 + + similarity = 0.0 + for context_keyword in lemmatized_keywords: + if context_keyword in context_list: + self.logger.info("Found context keyword '%s'", context_keyword) + similarity = 1 + break + + return similarity + + @staticmethod + def __add_n_words(index, + n_words, + lemmas, + lemmatized_filtered_keywords, + prefix, + is_backward): + """ Prepare a string of context words, which surrounds a lemma + at a given index. The words will be collected only if exist + in the filtered array + + :param index: index of the lemma that its surrounding words + :param n_words: number of words to take + :param lemmas: array of lemmas + :param lemmatized_filtered_keywords: the array of filter + lemmas, + :param prefix: string to be attached to the results as a prefix + :param is_backward: if true take the preceeding words, if false, + take the successing words + """ + i = index + # collect at most n words + remaining = n_words + while 0 <= i < len(lemmas) and remaining > 0: + if lemmas[i] in lemmatized_filtered_keywords: + remaining -= 1 + prefix += ' ' + lemmas[i] + if is_backward: + i -= 1 + else: + i += 1 + return prefix + + def __add_n_words_forward(self, + index, + n_words, + lemmas, + lemmatized_filtered_keywords, + prefix): + return self.__add_n_words( + index, + n_words, + lemmas, + lemmatized_filtered_keywords, + prefix, + False) + + def __add_n_words_backward(self, + index, + n_words, + lemmas, + lemmatized_filtered_keywords, + prefix): + return self. __add_n_words( + index, + n_words, + lemmas, + lemmatized_filtered_keywords, + prefix, + True) + + def __extract_context(self, nlp_artifacts, word, start): + """ Extracts words surronding another given word. + The text from which the context is extracted is given in the nlp + doc + :param nlp_artifacts: An abstraction layer which holds different + items which are result of a NLP pipeline + execution on a given text + :param word: The word to look for context around + :param start: The start index of the word in the original text + """ + + if not nlp_artifacts.tokens: + self.logger.info('Skipping context extraction due to ' + 'lack of NLP artifacts') + # if there are no nlp artifacts, this is ok, we can + # extract context and we return a valid, yet empty + # context + return '' + + # Get the already prepared words in the given text, in their + # LEMMATIZED version + lemmatized_keywords = nlp_artifacts.keywords + + found = False + # we use the known start index of the original word to find the actual + # token at that index, we are not checking for equivilance since the + # token might be just a substring of that word (e.g. for phone number + # 555-124564 the first token might be just '555') + # Note: we are iterating over the original tokens (not the lemmatized) + tokens = nlp_artifacts.tokens + tokens_indices = nlp_artifacts.tokens_indices + for i in range(len(nlp_artifacts.tokens)): + if ((tokens_indices[i] == start) or + (tokens_indices[i] < start < + tokens_indices[i] + len(tokens[i]))): + # found the interesting token, the one that around it + # we take n words, we save the matching lemma + found = True + break + + if not found: + raise ValueError("Did not find word '" + word + "' " + "in the list of tokens altough it " + "is expected to be found") + + # index i belongs to the PII entity, take the preceding n words + # and the successing m words into a context string + context_str = '' + context_str = \ + self.__add_n_words_backward(i, + EntityRecognizer.CONTEXT_PREFIX_COUNT, + nlp_artifacts.lemmas, + lemmatized_keywords, + context_str) + context_str = \ + self.__add_n_words_forward(i, + EntityRecognizer.CONTEXT_PREFIX_COUNT, + nlp_artifacts.lemmas, + lemmatized_keywords, + context_str) + + self.logger.debug('Context sentence is: %s', context_str) + return context_str diff --git a/presidio-analyzer/analyzer/nlp_engine/__init__.py b/presidio-analyzer/analyzer/nlp_engine/__init__.py new file mode 100644 index 000000000..7d76e60c9 --- /dev/null +++ b/presidio-analyzer/analyzer/nlp_engine/__init__.py @@ -0,0 +1,4 @@ +# pylint: disable=unused-import +from .nlp_artifacts import NlpArtifacts # noqa: F401 +from .nlp_engine import NlpEngine # noqa: F401 +from .spacy_nlp_engine import SpacyNlpEngine # noqa: F401 diff --git a/presidio-analyzer/analyzer/nlp_engine/nlp_artifacts.py b/presidio-analyzer/analyzer/nlp_engine/nlp_artifacts.py new file mode 100644 index 000000000..ed18ff1ee --- /dev/null +++ b/presidio-analyzer/analyzer/nlp_engine/nlp_artifacts.py @@ -0,0 +1,35 @@ +class NlpArtifacts(): + """ NlpArtifacts is an abstraction layer over the results of an NLP pipeline + processing over a given text, it holds attributes such as entities, + tokens and lemmas which can be used by any recognizer + """ + + # pylint: disable=abstract-method, unused-argument + def __init__(self, entities, tokens, tokens_indices, lemmas, nlp_engine, + language): + self.entities = entities + self.tokens = tokens + self.lemmas = lemmas + self.tokens_indices = tokens_indices + self.keywords = self.set_keywords(nlp_engine, lemmas, language) + + @staticmethod + def set_keywords(nlp_engine, lemmas, language): + if not nlp_engine: + return [] + + keywords = [k.lower() for k in lemmas if + not nlp_engine.is_stopword(k, language) and + not nlp_engine.is_punct(k, language) and + k != '-PRON-' and + k != 'be'] + + # best effort, try even further to break tokens into sub tokens, + # this can result in reducing false negatives + keywords = [i.split(':') for i in keywords] + + # splitting the list can, if happened, will result in list of lists, + # we flatten the list + keywords = \ + [item for sublist in keywords for item in sublist] + return keywords diff --git a/presidio-analyzer/analyzer/nlp_engine/nlp_engine.py b/presidio-analyzer/analyzer/nlp_engine/nlp_engine.py new file mode 100644 index 000000000..70e8d98e5 --- /dev/null +++ b/presidio-analyzer/analyzer/nlp_engine/nlp_engine.py @@ -0,0 +1,25 @@ +from abc import ABC, abstractmethod + + +class NlpEngine(ABC): + """ NlpEngine is an abstraction layer over the nlp module. + It provides processing functionality as well as other queries + on tokens + """ + + @abstractmethod + def process_text(self, text, language): + """ Execute the NLP pipeline on the given text and language + """ + + @abstractmethod + def is_stopword(self, word, language): + """ returns true if the given word is a stop word + (within the given language) + """ + + @abstractmethod + def is_punct(self, word, language): + """ returns true if the given word is a punctuation word + (within the given language) + """ diff --git a/presidio-analyzer/analyzer/nlp_engine/spacy_nlp_engine.py b/presidio-analyzer/analyzer/nlp_engine/spacy_nlp_engine.py new file mode 100644 index 000000000..f94782cc5 --- /dev/null +++ b/presidio-analyzer/analyzer/nlp_engine/spacy_nlp_engine.py @@ -0,0 +1,54 @@ +import logging +import os + +import spacy + +from analyzer.nlp_engine import NlpArtifacts, NlpEngine + + +class SpacyNlpEngine(NlpEngine): + """ SpacyNlpEngine is an abstraction layer over the nlp module. + It provides processing functionality as well as other queries + on tokens. + The SpacyNlpEngine uses SpaCy as its NLP module + """ + + def __init__(self): + loglevel = os.environ.get("LOG_LEVEL", "INFO") + self.logger = logging.getLogger(__name__) + self.logger.setLevel(loglevel) + + self.logger.info("Loading NLP model...") + self.nlp = {"en": spacy.load("en_core_web_lg", + disable=['parser', 'tagger'])} + + def process_text(self, text, language): + """ Execute the SpaCy NLP pipeline on the given text + and language + """ + doc = self.nlp[language](text) + return self.doc_to_nlp_artifact(doc, language) + + def is_stopword(self, word, language): + """ returns true if the given word is a stop word + (within the given language) + """ + return self.nlp[language].vocab[word].is_stop + + def is_punct(self, word, language): + """ returns true if the given word is a punctuation word + (within the given language) + """ + return self.nlp[language].vocab[word].is_punct + + def get_nlp(self, language): + return self.nlp[language] + + def doc_to_nlp_artifact(self, doc, language): + tokens = [token.text for token in doc] + lemmas = [token.lemma_ for token in doc] + tokens_indices = [token.idx for token in doc] + entities = doc.ents + return NlpArtifacts(entities=entities, tokens=tokens, + tokens_indices=tokens_indices, lemmas=lemmas, + nlp_engine=self, language=language) diff --git a/presidio-analyzer/analyzer/pattern_recognizer.py b/presidio-analyzer/analyzer/pattern_recognizer.py index d8b218887..9dabf2e80 100644 --- a/presidio-analyzer/analyzer/pattern_recognizer.py +++ b/presidio-analyzer/analyzer/pattern_recognizer.py @@ -1,7 +1,9 @@ import datetime -from analyzer import LocalRecognizer, Pattern, RecognizerResult -from analyzer import EntityRecognizer +from analyzer import LocalRecognizer, \ + Pattern, \ + RecognizerResult, \ + EntityRecognizer # Import 're2' regex engine if installed, if not- import 'regex' try: @@ -12,8 +14,8 @@ class PatternRecognizer(LocalRecognizer): - def __init__(self, supported_entity, name=None, supported_language='en', - patterns=None, + def __init__(self, supported_entity, name=None, + supported_language='en', patterns=None, black_list=None, context=None, version="0.0.1"): """ :param patterns: the list of patterns to detect @@ -50,13 +52,21 @@ def __init__(self, supported_entity, name=None, supported_language='en', def load(self): pass - def analyze(self, text, entities): + # pylint: disable=unused-argument + def analyze(self, text, entities, nlp_artifacts=None): results = [] if self.patterns: pattern_result = self.__analyze_patterns(text) - if pattern_result: + if pattern_result and self.context: + # try to improve the results score using the surrounding + # context words + enhanced_result = \ + self.enhance_using_context( + text, pattern_result, nlp_artifacts, self.context) + results.extend(enhanced_result) + elif pattern_result: results.extend(pattern_result) return results @@ -118,11 +128,12 @@ def __analyze_patterns(self, text): if current_match == '': continue + score = pattern.score + res = RecognizerResult(self.supported_entities[0], start, end, - pattern.score) + score) res = self.validate_result(current_match, res) - - if res and res.score != EntityRecognizer.MIN_SCORE: + if res and res.score > EntityRecognizer.MIN_SCORE: results.append(res) return results diff --git a/presidio-analyzer/analyzer/predefined_recognizers/spacy_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/spacy_recognizer.py index 560ce0d0b..0f9dac2a3 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/spacy_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/spacy_recognizer.py @@ -1,4 +1,3 @@ -import spacy from analyzer import RecognizerResult, LocalRecognizer NER_STRENGTH = 0.85 @@ -12,17 +11,23 @@ def __init__(self): supported_language='en') def load(self): - # Load spaCy English lg model - self.logger.info("Loading NLP model...") - self.nlp = spacy.load("en_core_web_lg", disable=['parser', 'tagger']) + # no need to load anything as the analyze method already receives + # preprocessed nlp artifacts + pass - def analyze(self, text, entities): - doc = self.nlp(text) + # pylint: disable=unused-argument + def analyze(self, text, entities, nlp_artifacts=None): results = [] + if not nlp_artifacts: + self.logger.warning( + "Skipping SpaCy, nlp artifacts not provided...") + return results + + ner_entities = nlp_artifacts.entities for entity in entities: if entity in self.supported_entities: - for ent in doc.ents: + for ent in ner_entities: if SpacyRecognizer.__check_label(entity, ent.label_): results.append( RecognizerResult(entity, ent.start_char, diff --git a/presidio-analyzer/analyzer/predefined_recognizers/us_phone_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/us_phone_recognizer.py index 3050055c2..f7b162816 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/us_phone_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/us_phone_recognizer.py @@ -13,10 +13,6 @@ # one to indicate at least one digit or one '*' # pylint: disable=line-too-long,abstract-method -STRONG_REGEX = r'(\(\d{3}\)\s*\d{3}[-\.\s]??\d{4}|d{3}[-\.\s]\d{3}[-\.\s]\d{4})' # noqa: E501 -MEDIUM_REGEX = r'\b(\d{3}[-\.\s]\d{3}[-\.\s]??\d{4})\b' -WEAK_REGEX = r'(\b\d{10}\b)' - CONTEXT = ["phone", "number", "telephone", "cell", "mobile", "call"] @@ -25,9 +21,24 @@ class UsPhoneRecognizer(PatternRecognizer): Recognizes US Phone numbers using regex """ + STRONG_REGEX_SCORE = 0.7 + MEDIUM_REGEX_SCORE = 0.5 + WEAK_REGEX_SCORE = 0.05 + + STRONG_REGEX = \ + r'(\(\d{3}\)\s*\d{3}[-\.\s]??\d{4}|d{3}[-\.\s]\d{3}[-\.\s]\\d{4})' + MEDIUM_REGEX = r'\b(\d{3}[-\.\s]\d{3}[-\.\s]??\d{4})\b' + WEAK_REGEX = r'(\b\d{10}\b)' + def __init__(self): - patterns = [Pattern('Phone (strong)', STRONG_REGEX, 0.7), - Pattern('Phone (medium)', MEDIUM_REGEX, 0.5), - Pattern('Phone (weak)', WEAK_REGEX, 0.05)] + patterns = [Pattern('Phone (strong)', + UsPhoneRecognizer.STRONG_REGEX, + UsPhoneRecognizer.STRONG_REGEX_SCORE), + Pattern('Phone (medium)', + UsPhoneRecognizer.MEDIUM_REGEX, + UsPhoneRecognizer.MEDIUM_REGEX_SCORE), + Pattern('Phone (weak)', + UsPhoneRecognizer.WEAK_REGEX, + UsPhoneRecognizer.WEAK_REGEX_SCORE)] super().__init__(supported_entity="PHONE_NUMBER", patterns=patterns, context=CONTEXT) diff --git a/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py b/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py index 94b7b3077..96709108c 100644 --- a/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py +++ b/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py @@ -50,13 +50,13 @@ def load_predefined_recognizers(self): # time consuming to load self.recognizers.extend([ CreditCardRecognizer(), - SpacyRecognizer(), CryptoRecognizer(), DomainRecognizer(), EmailRecognizer(), IbanRecognizer(), IpRecognizer(), NhsRecognizer(), UsBankRecognizer(), UsLicenseRecognizer(), UsItinRecognizer(), UsPassportRecognizer(), - UsPhoneRecognizer(), UsSsnRecognizer()]) + UsPhoneRecognizer(), UsSsnRecognizer(), + SpacyRecognizer()]) def get_recognizers(self, language, entities=None, all_fields=False): """ diff --git a/presidio-analyzer/requirements.txt b/presidio-analyzer/requirements.txt index 2e10138e1..54f05e911 100644 --- a/presidio-analyzer/requirements.txt +++ b/presidio-analyzer/requirements.txt @@ -1,5 +1,5 @@ cython -https://github.com/explosion/spacy-models/releases/download/en_core_web_lg-2.0.0/en_core_web_lg-2.0.0.tar.gz +https://github.com/explosion/spacy-models/releases/download/en_core_web_lg-2.1.0/en_core_web_lg-2.1.0.tar.gz https://github.com/torosent/pyre2/archive/release/0.2.23.zip grpcio protobuf diff --git a/presidio-analyzer/setup.py b/presidio-analyzer/setup.py index 3b7d32096..b4a917232 100644 --- a/presidio-analyzer/setup.py +++ b/presidio-analyzer/setup.py @@ -15,7 +15,7 @@ ], install_requires=[ 'grpcio>=1.13.0', 'cython>=0.28.5', 'protobuf>=3.6.0', - 'tldextract>=2.2.0', 'knack>=0.4.2', 'spacy>=2.0.18' + 'tldextract>=2.2.0', 'knack>=0.4.2', 'spacy>=2.1.3' ], include_package_data=True, license='MIT', diff --git a/presidio-analyzer/tests/data/context_sentences_tests.txt b/presidio-analyzer/tests/data/context_sentences_tests.txt new file mode 100644 index 000000000..ddd9ba793 --- /dev/null +++ b/presidio-analyzer/tests/data/context_sentences_tests.txt @@ -0,0 +1,65 @@ +# This file contains sentences with relevant context words that should be resulting in a better score +# Each item has the following structure: +# +# 1) Entity type +# 2) Sentence + +IP_ADDRESS +my ip: 192.168.0.1 + +US_SSN +my ssn is 078-051120 07805-1120 + +US_SSN +my social security number is 078051120 + +US_SSN +my social security number is 078-05-1120 + +US_SSN +my social security number is 078051120 + +PHONE_NUMBER +my phone number is (425) 882-9090 + +PHONE_NUMBER +my phone number is:(425) 882-9090 + +PHONE_NUMBER +my phone number is 425 8829090 + +PHONE_NUMBER +try one of these phones 052 5552606 074-7111234 + +PHONE_NUMBER +my phone number is 4258829090 + +US_ITIN +my itin: 911701234 + +US_ITIN +my itin is 911-70-1234 + +US_ITIN +my taxpayer id is 911-701234 91170-1234 + +US_DRIVER_LICENSE +my driver license number: AA1B2**9ABA7 + +US_DRIVER_LICENSE +my driver license is H12234567 + +US_DRIVER_LICENSE +my driver license is: 1234567901234 + +US_BANK_NUMBER +my bank account number is 912803456 + +US_BANK_NUMBER +my banking account number is 912803456 + +US_PASSPORT +my passport number is 912803456 + +US_PASSPORT +my passport number is 912803456 and now there are a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot of other words \ No newline at end of file diff --git a/presidio-analyzer/tests/mocks/__init__.py b/presidio-analyzer/tests/mocks/__init__.py new file mode 100644 index 000000000..47866a4f4 --- /dev/null +++ b/presidio-analyzer/tests/mocks/__init__.py @@ -0,0 +1 @@ +from .nlp_engine_mock import MockNlpEngine \ No newline at end of file diff --git a/presidio-analyzer/tests/mocks/nlp_engine_mock.py b/presidio-analyzer/tests/mocks/nlp_engine_mock.py new file mode 100644 index 000000000..28fcb6679 --- /dev/null +++ b/presidio-analyzer/tests/mocks/nlp_engine_mock.py @@ -0,0 +1,18 @@ +from analyzer.nlp_engine import NlpEngine + + +class MockNlpEngine(NlpEngine): + + def __init__(self, stopwords, punct_words, nlp_artifacts): + self.stopwords = stopwords + self.punct_words = punct_words + self.nlp_artifacts = nlp_artifacts + + def is_stopword(self, word, language): + return word in self.stopwords + + def is_punct(self, word, language): + return word in self.punct_words + + def process_text(self, text, language): + return self.nlp_artifacts diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index fe8e78dbd..d0249d2b2 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -1,9 +1,8 @@ from unittest import TestCase from analyzer.entity_recognizer import EntityRecognizer -import json +import os import hashlib -import time import pytest from assertions import assert_result @@ -12,10 +11,13 @@ from analyzer import AnalyzerEngine, PatternRecognizer, Pattern, \ RecognizerResult, RecognizerRegistry from analyzer.predefined_recognizers import CreditCardRecognizer, \ - UsPhoneRecognizer, DomainRecognizer + UsPhoneRecognizer, DomainRecognizer, UsItinRecognizer, \ + UsLicenseRecognizer, UsBankRecognizer, UsPassportRecognizer from analyzer.recognizer_registry.recognizers_store_api \ import RecognizerStoreApi # noqa: F401 - +from analyzer.nlp_engine import SpacyNlpEngine, NlpArtifacts +from analyzer.predefined_recognizers import IpRecognizer, UsSsnRecognizer +from tests.mocks import MockNlpEngine class RecognizerStoreApiMock(RecognizerStoreApi): """ @@ -61,7 +63,6 @@ def remove_recognizer(self, name): m.update(recognizer.name.encode('utf-8')) self.latest_hash = m.digest() - class MockRecognizerRegistry(RecognizerRegistry): """ A mock that acts as a recognizers registry @@ -76,12 +77,21 @@ def load_recognizers(self, path): DomainRecognizer()]) +ip_recognizer = IpRecognizer() +us_ssn_recognizer = UsSsnRecognizer() +phone_recognizer = UsPhoneRecognizer() +us_itin_recognizer = UsItinRecognizer() +us_license_recognizer = UsLicenseRecognizer() +us_bank_recognizer = UsBankRecognizer() +us_passport_recognizer = UsPassportRecognizer() + class TestAnalyzerEngine(TestCase): def __init__(self, *args, **kwargs): super(TestAnalyzerEngine, self).__init__(*args, **kwargs) self.loaded_registry = MockRecognizerRegistry(RecognizerStoreApiMock()) - self.loaded_analyzer_engine = AnalyzerEngine(self.loaded_registry) + mock_nlp_artifacts = NlpArtifacts([], [], [], [], None, "en") + self.loaded_analyzer_engine = AnalyzerEngine(self.loaded_registry, MockNlpEngine(stopwords=[], punct_words=[], nlp_artifacts=mock_nlp_artifacts)) def test_analyze_with_predefined_recognizers_return_results(self): text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090" @@ -98,13 +108,18 @@ def test_analyze_with_multiple_predefined_recognizers(self): text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090" language = "en" entities = ["CREDIT_CARD", "PHONE_NUMBER"] - results = self.loaded_analyzer_engine.analyze( - text, entities, language, all_fields=False) + + # This analyzer engine is different from the global one, as this one + # also loads SpaCy so it can detect the phone number entity + analyzer_engine_with_spacy = AnalyzerEngine(self.loaded_registry) + results = analyzer_engine_with_spacy.analyze(text, entities, language, all_fields=False) assert len(results) == 2 assert_result(results[0], "CREDIT_CARD", 14, 33, EntityRecognizer.MAX_SCORE) - assert_result(results[1], "PHONE_NUMBER", 48, 59, 0.5) + expected_score = UsPhoneRecognizer.MEDIUM_REGEX_SCORE + \ + PatternRecognizer.CONTEXT_SIMILARITY_FACTOR # 0.5 + 0.35 = 0.85 + assert_result(results[1], "PHONE_NUMBER", 48, 59, expected_score) def test_analyze_without_entities(self): with pytest.raises(ValueError): @@ -234,7 +249,8 @@ def test_when_allFields_is_true_return_all_fields(self): request.text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090 " \ "Domain: microsoft.com" response = analyze_engine.Apply(request, None) - returned_entities = [field.field.name for field in response.analyzeResults] + returned_entities = [ + field.field.name for field in response.analyzeResults] assert response.analyzeResults is not None assert "CREDIT_CARD" in returned_entities @@ -248,14 +264,15 @@ def test_when_allFields_is_true_full_recognizers_list_return_all_fields(self): request.text = "My name is David and I live in Seattle." \ "Domain: microsoft.com " response = analyze_engine.Apply(request, None) - returned_entities = [field.field.name for field in response.analyzeResults] + returned_entities = [ + field.field.name for field in response.analyzeResults] assert response.analyzeResults is not None assert "PERSON" in returned_entities assert "LOCATION" in returned_entities assert "DOMAIN_NAME" in returned_entities def test_when_allFields_is_true_and_entities_not_empty_exception(self): - analyze_engine = AnalyzerEngine(RecognizerRegistry()) + analyze_engine = AnalyzerEngine(registry=RecognizerRegistry()) request = AnalyzeRequest() request.text = "My name is David and I live in Seattle." \ "Domain: microsoft.com " @@ -265,3 +282,4 @@ def test_when_allFields_is_true_and_entities_not_empty_exception(self): new_field.minScore = '0.5' with pytest.raises(ValueError): analyze_engine.Apply(request, None) + \ No newline at end of file diff --git a/presidio-analyzer/tests/test_context_support.py b/presidio-analyzer/tests/test_context_support.py new file mode 100644 index 000000000..b88462006 --- /dev/null +++ b/presidio-analyzer/tests/test_context_support.py @@ -0,0 +1,92 @@ +from unittest import TestCase + +import os +import pytest + +from analyzer.predefined_recognizers import CreditCardRecognizer, \ + UsPhoneRecognizer, DomainRecognizer, UsItinRecognizer, \ + UsLicenseRecognizer, UsBankRecognizer, UsPassportRecognizer, \ + IpRecognizer, UsSsnRecognizer +from analyzer.nlp_engine import SpacyNlpEngine, NlpArtifacts + +ip_recognizer = IpRecognizer() +us_ssn_recognizer = UsSsnRecognizer() +phone_recognizer = UsPhoneRecognizer() +us_itin_recognizer = UsItinRecognizer() +us_license_recognizer = UsLicenseRecognizer() +us_bank_recognizer = UsBankRecognizer() +us_passport_recognizer = UsPassportRecognizer() + +@pytest.fixture(scope="class") +def sentences_with_context(request): + """ Loads up a group of sentences with relevant context words + """ + + path = os.path.dirname(__file__) + '/data/context_sentences_tests.txt' + f = open(path, "r") + if not f.mode == 'r': + return [] + content = f.read() + f.close() + + lines = content.split('\n') + # remove empty lines + lines = list(filter(lambda k: k.strip(), lines)) + # remove comments + lines = list(filter(lambda k: k[0] != '#', lines)) + + test_items = [] + for i in range(len(lines)): + if i % 2 == 1: + continue + recognizer = None + entity_type = lines[i].strip() + if entity_type == "IP_ADDRESS": + recognizer = ip_recognizer + elif entity_type == "US_SSN": + recognizer = us_ssn_recognizer + elif entity_type == "PHONE_NUMBER": + recognizer = phone_recognizer + elif entity_type == "US_ITIN": + recognizer = us_itin_recognizer + elif entity_type == "US_DRIVER_LICENSE": + recognizer = us_license_recognizer + elif entity_type == "US_BANK_NUMBER": + recognizer = us_bank_recognizer + elif entity_type == "US_PASSPORT": + recognizer = us_passport_recognizer + else: + # will fail the test in its turn + print("bad type: ", entity_type) + return [] + test_items.append((lines[i+1].strip(), + recognizer, + [lines[i].strip()])) + # Currently we have 20 sentences, this is a sanity + if not len(test_items) == 20: + raise ValueError("context sentences not as expected") + + request.cls.context_sentences = test_items + +@pytest.mark.usefixtures("sentences_with_context") +class TestContextSupport(TestCase): + + def __init__(self, *args, **kwargs): + super(TestContextSupport, self).__init__(*args, **kwargs) + + # Context tests + def test_text_with_context_improves_score(self): + nlp_engine = SpacyNlpEngine() + mock_nlp_artifacts = NlpArtifacts([], [], [], [], None, "en") + + for item in self.context_sentences: + text = item[0] + recognizer = item[1] + entities = item[2] + nlp_artifacts = nlp_engine.process_text(text, "en") + results_without_context = recognizer.analyze(text, entities, mock_nlp_artifacts) + results_with_context = recognizer.analyze(text, entities, nlp_artifacts) + + assert(len(results_without_context) == len(results_with_context)) + for i in range(len(results_with_context)): + assert(results_without_context[i].score < results_with_context[i].score) diff --git a/presidio-analyzer/tests/test_ip_recognizer.py b/presidio-analyzer/tests/test_ip_recognizer.py index 1b945c9cc..c28c0c5d3 100644 --- a/presidio-analyzer/tests/test_ip_recognizer.py +++ b/presidio-analyzer/tests/test_ip_recognizer.py @@ -15,20 +15,8 @@ def test_valid_ipv4(self): results = ip_recognizer.analyze(context + ip, entities) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[0], 14, 25, 0.6, 0.81) - - ''' - TODO: enable with task #582 re-support context model in analyzer - - def test_valid_ipv4_with_exact_context(self): - ip = '192.168.0.1' - context = 'my ip: ' - results = ip_recognizer.analyze(context + ip, entities) - - assert len(results) == 1 - assert 0.79 < results[0].score < 1 - ''' - + assert_result_within_score_range( + results[0], entities[0], 14, 25, 0.6, 0.81) def test_invalid_ipv4(self): ip = '192.168.0' @@ -37,7 +25,6 @@ def test_invalid_ipv4(self): assert len(results) == 0 - ''' TODO: fix ipv6 regex def test_valid_ipv6(self): @@ -60,7 +47,6 @@ def test_valid_ipv6_with_exact_context(self): assert results[0].score > 0.79 and results[0].score < 1 ''' - def test_invalid_ipv6(self): ip = '684D:1111:222:3333:4444:5555:77' results = ip_recognizer.analyze('the ip is ' + ip, entities) diff --git a/presidio-analyzer/tests/test_spacy_recognizer.py b/presidio-analyzer/tests/test_spacy_recognizer.py index 932de4cd6..d4ef63444 100644 --- a/presidio-analyzer/tests/test_spacy_recognizer.py +++ b/presidio-analyzer/tests/test_spacy_recognizer.py @@ -1,17 +1,21 @@ from unittest import TestCase from assertions import assert_result, assert_result_within_score_range + +from analyzer.nlp_engine import SpacyNlpEngine from analyzer.predefined_recognizers import SpacyRecognizer from analyzer.entity_recognizer import EntityRecognizer +from analyzer.nlp_engine import NlpArtifacts NER_STRENGTH = 0.85 +nlp_engine = SpacyNlpEngine() spacy_recognizer = SpacyRecognizer() entities = ["PERSON", "DATE_TIME"] class TestSpacyRecognizer(TestCase): -# Test Name Entity + # Test Name Entity # Bug #617 : Spacy Recognizer doesn't recognize Dan as PERSON even though online spacy demo indicates that it does # See http://textanalysisonline.com/spacy-named-entity-recognition-ner # def test_person_first_name(self): @@ -23,15 +27,17 @@ class TestSpacyRecognizer(TestCase): def test_person_first_name_with_context(self): name = 'Dan' - context= 'my name is' - results = spacy_recognizer.analyze('{} {}'.format(context, name), entities) + context = 'my name is' + text = '{} {}'.format(context, name) + results = self.prepare_and_analyze(nlp_engine, text) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[0], 11, 14, NER_STRENGTH, EntityRecognizer.MAX_SCORE) + assert_result_within_score_range( + results[0], entities[0], 11, 14, NER_STRENGTH, EntityRecognizer.MAX_SCORE) def test_person_full_name(self): - name = 'Dan Tailor' - results = spacy_recognizer.analyze(name, entities) + text = 'Dan Tailor' + results = self.prepare_and_analyze(nlp_engine, text) assert len(results) == 1 assert_result(results[0], entities[0], 0, 10, NER_STRENGTH) @@ -39,17 +45,19 @@ def test_person_full_name(self): def test_person_full_name_with_context(self): name = 'John Oliver' context = ' is the funniest comedian' - results = spacy_recognizer.analyze('{} {}'.format(name, context), entities) + text = '{} {}'.format(name, context) + results = self.prepare_and_analyze(nlp_engine, text) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[0], 0, 11, NER_STRENGTH, EntityRecognizer.MAX_SCORE) + assert_result_within_score_range( + results[0], entities[0], 0, 11, NER_STRENGTH, EntityRecognizer.MAX_SCORE) def test_person_last_name(self): - name = 'Tailor' - results = spacy_recognizer.analyze(name, entities) + text = 'Tailor' + results = self.prepare_and_analyze(nlp_engine, text) assert len(results) == 0 - + # Bug #617 : Spacy Recognizer doesn't recognize Mr. Tailor as PERSON even though online spacy visualizer indicates that it does # See http://textanalysisonline.com/spacy-named-entity-recognition-ner # def test_person_title_with_last_name(self): @@ -58,7 +66,7 @@ def test_person_last_name(self): # assert len(results) == 1 # assert_result(results[0], entities[0], 0, 9, NER_STRENGTH) - + # Bug #617 : Spacy Recognizer doesn't recognize Mr. Tailor as PERSON even though online spacy visualizer indicates that it does # See http://textanalysisonline.com/spacy-named-entity-recognition-ner # def test_person_title_with_last_name_with_context_and_time(self): @@ -71,121 +79,118 @@ def test_person_last_name(self): # assert_result_within_score_range(results[0], entities[0], 17, 23, NER_STRENGTH, EntityRecognizer.MAX_SCORE) def test_person_full_middle_name(self): - name = 'Richard Milhous Nixon' - results = spacy_recognizer.analyze(name, entities) + text = 'Richard Milhous Nixon' + results = self.prepare_and_analyze(nlp_engine, text) assert len(results) == 1 assert_result(results[0], entities[0], 0, 21, NER_STRENGTH) def test_person_full_name_with_middle_letter(self): - name = 'Richard M. Nixon' - results = spacy_recognizer.analyze(name, entities) + text = 'Richard M. Nixon' + results = self.prepare_and_analyze(nlp_engine, text) assert len(results) == 1 assert_result(results[0], entities[0], 0, 16, NER_STRENGTH) def test_person_full_name_complex(self): - name = 'Richard (Ric) C. Henderson' - results = spacy_recognizer.analyze(name, entities) - - assert len(results) == 1 - assert_result(results[0], entities[0], 0, 26, NER_STRENGTH) - - def test_person_last_name_is_also_a_date_expected_person_only(self): - name = 'Dan May' - results = spacy_recognizer.analyze(name, entities) + text = 'Richard (Ric) C. Henderson' + results = self.prepare_and_analyze(nlp_engine, text) + + assert len(results) == 3 + # Richard + assert text[results[0].start:results[0].end] == "Richard" + assert_result(results[0], entities[0], 0, 7, NER_STRENGTH) + # Ric + assert text[results[1].start:results[1].end] == "Ric" + assert_result(results[1], entities[0], 9, 12, NER_STRENGTH) + # C. Henderson + assert text[results[2].start:results[2].end] == "C. Henderson" + assert_result(results[2], entities[0], 14, 26, NER_STRENGTH) - assert len(results) == 1 - assert_result(results[0], entities[0], 0, 7, NER_STRENGTH, ) - - # Bug #617 : Spacy Recognizer doesn't recognize Dan May as PERSON even though online spacy demo indicates that it does - # See http://textanalysisonline.com/spacy-named-entity-recognition-ner def test_person_last_name_is_also_a_date_with_context_expected_person_only(self): name = 'Dan May' context = "has a bank account" - results = spacy_recognizer.analyze('{} {}'.format(name, context), entities) + text = '{} {}'.format(name, context) + results = self.prepare_and_analyze(nlp_engine, text) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[0], 0, 7, NER_STRENGTH, EntityRecognizer.MAX_SCORE) + print(results[0].score) + print(results[0].entity_type) + print(text[results[0].start:results[0].end]) + assert_result_within_score_range( + results[0], entities[0], 0, 7, NER_STRENGTH, EntityRecognizer.MAX_SCORE) - # Bug #617 : Spacy Recognizer doesn't recognize Mr. May as PERSON even though online spacy demo indicates that it does - # See http://textanalysisonline.com/spacy-named-entity-recognition-ner - # def test_person_title_and_last_name_is_also_a_date_expected_person_only(self): - # name = 'Mr. May' - # results = spacy_recognizer.analyze(name, entities) + def test_person_title_and_last_name_is_also_a_date_expected_person_only(self): + text = 'Mr. May' + results = self.prepare_and_analyze(nlp_engine, text) - # assert len(results) == 1 - # assert_result(results[0], entities[0], 4, 7, NER_STRENGTH) - - # Bug #617 : Spacy Recognizer doesn't recognize Mr. May as PERSON even though online spacy demo indicates that it does - # See http://textanalysisonline.com/spacy-named-entity-recognition-ner - # def test_person_title_and_last_name_is_also_a_date_with_context_expected_person_only(self): - # name = 'Mr. May' - # context = "They call me" - # results = spacy_recognizer.analyze('{} {}'.format(context, name), entities) + assert len(results) == 1 + assert_result(results[0], entities[0], 4, 7, NER_STRENGTH) - # assert len(results) == 1 - # assert_result_within_score_range(results[0], entities[0], 17, 20, NER_STRENGTH, EntityRecognizer.MAX_SCORE) + def test_person_title_and_last_name_is_also_a_date_with_context_expected_person_only(self): + name = 'Mr. May' + context = "They call me" + text = '{} {}'.format(context, name) + results = self.prepare_and_analyze(nlp_engine, text) + assert len(results) == 1 + assert_result_within_score_range(results[0], entities[0], 17, 20, NER_STRENGTH, EntityRecognizer.MAX_SCORE) -#Test DATE_TIME Entity +# Test DATE_TIME Entity def test_date_time_year(self): - date = '1972' - results = spacy_recognizer.analyze(date, entities) + text = '1972' + results = self.prepare_and_analyze(nlp_engine, text) assert len(results) == 1 assert_result(results[0], entities[1], 0, 4, NER_STRENGTH) - + def test_date_time_year_with_context(self): date = '1972' context = 'I bought my car in' - results = spacy_recognizer.analyze('{} {}'.format(context, date), entities) + text = '{} {}'.format(context, date) + results = self.prepare_and_analyze(nlp_engine, text) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[1], 19, 23, NER_STRENGTH, EntityRecognizer.MAX_SCORE) + assert_result_within_score_range( + results[0], entities[1], 19, 23, NER_STRENGTH, EntityRecognizer.MAX_SCORE) - # Bug #617 : Spacy Recognizer doesn't recognize May as DATE_TIME even though online spacy demo indicates that it does - # See http://textanalysisonline.com/spacy-named-entity-recognition-ner - # def test_date_time_month(self): - # date = 'May' - # results = spacy_recognizer.analyze(date, entities) - - # assert len(results) == 1 - # assert_result_within_score_range(results[0], entities[1], 0, 3, NER_STRENGTH, EntityRecognizer.MAX_SCORE) - def test_date_time_month_with_context(self): date = 'May' context = 'I bought my car in' - results = spacy_recognizer.analyze('{} {}'.format(context, date), entities) + text = '{} {}'.format(context, date) + results = self.prepare_and_analyze(nlp_engine, text) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[1], 19, 22, NER_STRENGTH, EntityRecognizer.MAX_SCORE) + assert_result_within_score_range( + results[0], entities[1], 19, 22, NER_STRENGTH, EntityRecognizer.MAX_SCORE) def test_date_time_day_in_month(self): - date = 'May 1st' - results = spacy_recognizer.analyze(date, entities) - - assert len(results) == 1 - assert_result_within_score_range(results[0], entities[1], 0, 7, NER_STRENGTH, EntityRecognizer.MAX_SCORE) - - def test_date_time_day_in_month_with_context(self): - date = 'May 1st' - context = 'I bought my car on ' - results = spacy_recognizer.analyze('{} {}'.format(context, date), entities) + text = 'May 1st' + results = self.prepare_and_analyze(nlp_engine, text) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[1], 19, 26, NER_STRENGTH, EntityRecognizer.MAX_SCORE) + assert_result_within_score_range( + results[0], entities[1], 0, 7, NER_STRENGTH, EntityRecognizer.MAX_SCORE) def test_date_time_full_date(self): - date = 'May 1st, 1977' - results = spacy_recognizer.analyze(date, entities) + text = 'May 1st, 1977' + results = self.prepare_and_analyze(nlp_engine, text) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[1], 0, 13, NER_STRENGTH, EntityRecognizer.MAX_SCORE) - - def test_date_time_day_in_month_with_context(self): + assert_result_within_score_range( + results[0], entities[1], 0, 13, NER_STRENGTH, EntityRecognizer.MAX_SCORE) + + def test_date_time_day_in_month_with_year_with_context(self): date = 'May 1st, 1977' context = 'I bought my car on' - results = spacy_recognizer.analyze('{} {}'.format(context, date), entities) + text = '{} {}'.format(context, date) + results = self.prepare_and_analyze(nlp_engine, text) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[1], 19, 32, NER_STRENGTH, EntityRecognizer.MAX_SCORE) + assert_result_within_score_range( + results[0], entities[1], 19, 32, NER_STRENGTH, EntityRecognizer.MAX_SCORE) + + def prepare_and_analyze(self, nlp, text): + nlp_artifacts = nlp.process_text(text, "en") + results = spacy_recognizer.analyze( + text, entities, nlp_artifacts) + return results diff --git a/presidio-analyzer/tests/test_us_bank_recognizer.py b/presidio-analyzer/tests/test_us_bank_recognizer.py index 0dcd51c7e..08754253c 100644 --- a/presidio-analyzer/tests/test_us_bank_recognizer.py +++ b/presidio-analyzer/tests/test_us_bank_recognizer.py @@ -15,37 +15,9 @@ def test_us_bank_account_invalid_number(self): assert len(results) == 0 - def test_us_bank_account_no_context(self): num = '945456787654' results = us_bank_recognizer.analyze(num, entities) assert len(results) == 1 assert_result(results[0], entities[0], 0, 12, 0.05) - - # TODO: enable with task #582 re-support context model in analyzer - # def test_us_passport_with_exact_context(self): - # num = '912803456' - # context = 'my bank account number is ' - # results = us_bank_recognizer.analyze(context + num, entities) - # - # assert len(results) == 1 - # assert 0.49 < results[0].score < 0.61 - # - # - # def test_us_passport_with_exact_context_no_space(self): - # num = '912803456' - # context = 'my bank account number is:' - # results = us_bank_recognizer.analyze(context + num, entities) - # - # assert len(results) == 1 - # assert 0.49 < results[0].score < 0.61 - # - # - # def test_us_passport_with_lemma_context(self): - # num = '912803456' - # context = 'my banking account number is ' - # results = us_bank_recognizer.analyze(context + num, entities) - # - # assert len(results) == 1 - # assert 0.49 < results[0].score < 0.61 diff --git a/presidio-analyzer/tests/test_us_driver_license_recognizer.py b/presidio-analyzer/tests/test_us_driver_license_recognizer.py index 43b922521..5289c4d9e 100644 --- a/presidio-analyzer/tests/test_us_driver_license_recognizer.py +++ b/presidio-analyzer/tests/test_us_driver_license_recognizer.py @@ -1,5 +1,5 @@ from unittest import TestCase - +import os from assertions import assert_result, assert_result_within_score_range from analyzer.predefined_recognizers import UsLicenseRecognizer @@ -12,22 +12,14 @@ class TestUsLicenseRecognizer(TestCase): def test_valid_us_driver_license_weak_WA(self): num1 = 'AA1B2**9ABA7' num2 = 'A*1234AB*CD9' - results = us_license_recognizer.analyze('{} {}'.format(num1, num2), entities) + results = us_license_recognizer.analyze( + '{} {}'.format(num1, num2), entities) assert len(results) == 2 - assert_result_within_score_range(results[0], entities[0], 0, 12, 0.3, 0.4) - assert_result_within_score_range(results[1], entities[0], 13, 25, 0.3, 0.4) - - # TODO: enable with task #582 re-support context model in analyzer - # def test_valid_us_driver_license_weak_WA_exact_context(self): - # num = 'AA1B2**9ABA7' - # context = 'my driver license number: ' - # results = us_license_recognizer.analyze(context + num, entities) - # - # assert len(results) == 2 - # assert 0.55 < results[0].score < 0.91 - # # These is a duplicate result that will be removed by the analyzer - # assert 0 < results[1].score < 0.1 + assert_result_within_score_range( + results[0], entities[0], 0, 12, 0.3, 0.4) + assert_result_within_score_range( + results[1], entities[0], 13, 25, 0.3, 0.4) def test_invalid_us_driver_license_weak_WA(self): num = '3A1B2**9ABA7' @@ -41,21 +33,12 @@ def test_invalid_us_driver_license_weak_WA(self): # 14}|[A-Z][0-9]{18}|[A-Z][0-9]{6}R|[A-Z][0-9]{9}|[A-Z][0-9]{1,12}|[0-9]{9}[A-Z]|[A-Z]{2}[0-9]{6}[A-Z]|[0-9]{8}[ # A-Z]{2}|[0-9]{3}[A-Z]{2}[0-9]{4}|[A-Z][0-9][A-Z][0-9][A-Z]|[0-9]{7,8}[A-Z])\b' - # TODO: enable with task #582 re-support context model in analyzer - # def test_valid_us_driver_license_weak_alphanumeric(self): - # num = 'H12234567' - # results = us_license_recognizer.analyze(num, entities) - # - # assert len(results) == 1 - # assert 0.29 < results[0].score < 0.49 - # - # def test_valid_us_driver_license_weak_alphanumeric_exact_context(self): - # num = 'H12234567' - # context = 'my driver license is ' - # results = us_license_recognizer.analyze(context + num, entities) - # - # assert len(results) == 1 - # assert 0.59 < results[0].score < 0.91 + def test_valid_us_driver_license_weak_alphanumeric(self): + num = 'H12234567' + results = us_license_recognizer.analyze(num, entities) + + assert len(results) == 1 + assert 0.29 < results[0].score < 0.49 # Task #603: Support keyphrases ''' This test fails, since 'license' is a match and driver is a context. @@ -76,28 +59,21 @@ def test_invalid_us_driver_license(self): # Driver License - Digits (very weak) - 0.05 # Regex: r'\b([0-9]{1,9}|[0-9]{4,10}|[0-9]{6,10}|[0-9]{1,12}|[0-9]{12,14}|[0-9]{16})\b' - # TODO: enable with task #582 re-support context model in analyzer - # def test_valid_us_driver_license_very_weak_digits(self): - # num = '123456789 1234567890 12345679012 123456790123 1234567901234' - # results = us_license_recognizer.analyze(num, entities) - # - # assert len(results) == 5 - # for result in results: - # assert 0 < result.score < 0.1 - - # def test_valid_us_driver_license_very_weak_digits_exact_context(self): - # num = '1234567901234' - # context = 'my driver license is: ' - # results = us_license_recognizer.analyze(context + num, entities) - # - # assert len(results) == 1 - # assert 0.55 < results[0].score < 0.91 - # def test_load_from_file(self): - # path = os.path.dirname(__file__) + '/data/demo.txt' - # text_file = open(path, 'r') - # text = text_file.read() - # results = us_license_recognizer.analyze(text, entities) - # assert len(results) == 1 + def test_valid_us_driver_license_very_weak_digits(self): + num = '123456789 1234567890 12345679012 123456790123 1234567901234' + results = us_license_recognizer.analyze(num, entities) + + assert len(results) == 5 + for result in results: + assert 0 < result.score < 0.02 + + def test_load_from_file(self): + path = os.path.dirname(__file__) + '/data/demo.txt' + text_file = open(path, 'r') + text = text_file.read() + results = us_license_recognizer.analyze(text, entities) + + assert len(results) == 23 # Driver License - Letters (very weak) - 0.00 # Regex: r'\b([A-Z]{7,9}\b' diff --git a/presidio-analyzer/tests/test_us_itin_recognizer.py b/presidio-analyzer/tests/test_us_itin_recognizer.py index 144228f0f..313b7936f 100644 --- a/presidio-analyzer/tests/test_us_itin_recognizer.py +++ b/presidio-analyzer/tests/test_us_itin_recognizer.py @@ -12,60 +12,34 @@ class TestUsItinRecognizer(TestCase): def test_valid_us_itin_very_weak_match(self): num1 = '911-701234' num2 = '91170-1234' - results = us_itin_recognizer.analyze('{} {}'.format(num1, num2), entities) + results = us_itin_recognizer.analyze( + '{} {}'.format(num1, num2), entities) assert len(results) == 2 assert results[0].score != 0 - assert_result_within_score_range(results[0], entities[0], 0, 10, 0, 0.3) + assert_result_within_score_range( + results[0], entities[0], 0, 10, 0, 0.3) assert results[1].score != 0 - assert_result_within_score_range(results[1], entities[0], 11, 21, 0, 0.3) - + assert_result_within_score_range( + results[1], entities[0], 11, 21, 0, 0.3) def test_valid_us_itin_weak_match(self): num = '911701234' results = us_itin_recognizer.analyze(num, entities) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[0], 0, 9, 0.3, 0.4) - + assert_result_within_score_range( + results[0], entities[0], 0, 9, 0.3, 0.4) def test_valid_us_itin_medium_match(self): num = '911-70-1234' results = us_itin_recognizer.analyze(num, entities) assert len(results) == 1 - assert_result_within_score_range(results[0], entities[0], 0, 11, 0.5, 0.6) - - # TODO: enable with task #582 re-support context model in analyzer - # def test_valid_us_itin_very_weak_match_exact_context(self): - # num1 = '911-701234' - # num2 = '91170-1234' - # context = "my taxpayer id is" - # results = us_itin_recognizer.analyze('{} {} {}'.format(context, num1, num2), entities) - # - # assert len(results) == 2 - # assert 0.59 < results[0].score < 0.7 - # assert 0.50 < results[1].score < 0.7 - # - # - # def test_valid_us_itin_weak_match_exact_context(self): - # num = '911701234' - # context = "my itin:" - # results = us_itin_recognizer.analyze('{} {}'.format(context, num), entities) - # - # assert len(results) == 1 - # assert 0.5 < results[0].score < 0.65 - # - # - # def test_valid_us_itin_medium_match_exact_context(self): - # num = '911-70-1234' - # context = "my itin is" - # results = us_itin_recognizer.analyze('{} {}'.format(context, num), entities) - # - # assert len(results) == 1 - # assert 0.6 < results[0].score < 0.9 + assert_result_within_score_range( + results[0], entities[0], 0, 11, 0.5, 0.6) def test_invalid_us_itin(self): num = '911-89-1234' @@ -76,6 +50,7 @@ def test_invalid_us_itin(self): def test_invalid_us_itin_exact_context(self): num = '911-89-1234' context = "my taxpayer id" - results = us_itin_recognizer.analyze('{} {}'.format(context, num), entities) + results = us_itin_recognizer.analyze( + '{} {}'.format(context, num), entities) assert len(results) == 0 diff --git a/presidio-analyzer/tests/test_us_passport_recognizer.py b/presidio-analyzer/tests/test_us_passport_recognizer.py index 92f0483f8..fda20e162 100644 --- a/presidio-analyzer/tests/test_us_passport_recognizer.py +++ b/presidio-analyzer/tests/test_us_passport_recognizer.py @@ -17,15 +17,6 @@ def test_valid_us_passport_no_context(self): assert results[0].score != 0 assert_result_within_score_range(results[0], entities[0], 0, 9, 0, 0.1) - # Task #582 re-support context model in analyzer - # def test_valid_us_passport_with_exact_context(self): - # num = '912803456' - # context = 'my passport number is ' - # results = us_passport_recognizer.analyze(context + num, entities) - # - # assert len(results) == 1 - # assert 0.49 < results[0].score < 0.71 - # Task #603: Support keyphrases: Should pass after handling keyphrases, e.g. "travel document" or "travel permit" # def test_valid_us_passport_with_exact_context_phrase(): diff --git a/presidio-analyzer/tests/test_us_phone_recognizer.py b/presidio-analyzer/tests/test_us_phone_recognizer.py index 7fa134b35..cae6114cd 100644 --- a/presidio-analyzer/tests/test_us_phone_recognizer.py +++ b/presidio-analyzer/tests/test_us_phone_recognizer.py @@ -16,25 +16,8 @@ def test_phone_number_strong_match_no_context(self): assert len(results) == 1 assert results[0].score != 1 - assert_result_within_score_range(results[0], entities[0], 0, 14, 0.7, EntityRecognizer.MAX_SCORE) - - # TODO: enable with task #582 re-support context model in analyzer - # def test_phone_number_strong_match_with_phone_context(self): - # number = '(425) 882-9090' - # context = 'my phone number is ' - # results = phone_recognizer.analyze(context + number, entities) - # - # assert len(results) == 1 - # assert results[0].score == 1 - # - # - # def test_phone_number_strong_match_with_phone_context_no_space(self): - # number = '(425) 882-9090' - # context = 'my phone number is:' - # results = phone_recognizer.analyze(context + number, entities) - # - # assert len(results) == 1 - # assert results[0].score == 1 + assert_result_within_score_range( + results[0], entities[0], 0, 14, 0.7, EntityRecognizer.MAX_SCORE) def test_phone_in_guid(self): number = '110bcd25-a55d-453a-8046-1297901ea002' @@ -75,35 +58,6 @@ def test_phone_number_medium_match_no_context(self): assert results[0].start == 0 assert results[0].end == 11 - # # TODO: enable with task #582 re-support context model in analyzer - # def test_phone_number_medium_match_with_phone_context(self): - # number = '425 8829090' - # context = 'my phone number is ' - # results = phone_recognizer.analyze(context + number, entities) - # - # assert len(results) == 1 - # assert 0.75 < results[0].score < 0.9 - # - # - # def test_phone_number_weak_match_with_phone_context(self): - # number = '4258829090' - # context = 'my phone number is ' - # results = phone_recognizer.analyze(context + number, entities) - # - # assert len(results) == 1 - # assert 0.59 < results[0].score < 0.81 - # - # - # def test_phone_numbers_lemma_context_phones(self): - # number1 = '052 5552606' - # number2 = '074-7111234' - # results = phone_recognizer.analyze( - # 'try one of these phones ' + number1 + ' ' + number2, entities) - # - # assert len(results) == 2 - # assert 0.75 < results[0].score < 0.9 - # assert 0.75 < results[0].score < 0.9 - ''' This test fails since available is not close enough to phone --> requires experimentation with language model def test_phone_number_medium_match_with_similar_context(self): diff --git a/presidio-analyzer/tests/test_us_ssn_recognizer.py b/presidio-analyzer/tests/test_us_ssn_recognizer.py index d3c6fe153..a0bf8740a 100644 --- a/presidio-analyzer/tests/test_us_ssn_recognizer.py +++ b/presidio-analyzer/tests/test_us_ssn_recognizer.py @@ -12,15 +12,18 @@ class TestUsSsnRecognizer(TestCase): def test_valid_us_ssn_very_weak_match(self): num1 = '078-051120' num2 = '07805-1120' - results = us_ssn_recognizer.analyze('{} {}'.format(num1, num2), entities) + results = us_ssn_recognizer.analyze( + '{} {}'.format(num1, num2), entities) assert len(results) == 2 - + assert results[0].score != 0 - assert_result_within_score_range(results[0], entities[0], 0, 10, 0, 0.3) - + assert_result_within_score_range( + results[0], entities[0], 0, 10, 0, 0.3) + assert results[0].score != 0 - assert_result_within_score_range(results[1], entities[0], 11, 21, 0, 0.3) + assert_result_within_score_range( + results[1], entities[0], 11, 21, 0, 0.3) def test_valid_us_ssn_weak_match(self): num = '078051120' @@ -28,7 +31,8 @@ def test_valid_us_ssn_weak_match(self): assert len(results) == 1 assert results[0].score != 0 - assert_result_within_score_range(results[0], entities[0], 0, 9, 0.3, 0.4) + assert_result_within_score_range( + results[0], entities[0], 0, 9, 0.3, 0.4) def test_valid_us_ssn_medium_match(self): num = '078-05-1120' @@ -36,50 +40,12 @@ def test_valid_us_ssn_medium_match(self): assert len(results) == 1 assert results[0].score != 0 - assert_result_within_score_range(results[0], entities[0], 0, 11, 0.5, 0.6) + assert_result_within_score_range( + results[0], entities[0], 0, 11, 0.5, 0.6) assert 0.49 < results[0].score < 0.6 - - # # TODO: enable with task #582 re-support context model in analyzer - # def test_valid_us_ssn_very_weak_match_exact_context(self): - # num1 = '078-051120' - # num2 = '07805-1120' - # context = "my ssn is " - # results = us_ssn_recognizer.analyze('{} {} {}'.format(context, num1, num2), entities) - # - # assert len(results) == 2 - # assert 0.59 < results[0].score < 0.7 - # assert 0.59 < results[1].score < 0.7 - # - # - # def test_valid_us_ssn_weak_match_exact_context(self): - # num = '078051120' - # context = "my social security number is " - # results = us_ssn_recognizer.analyze(context + num, entities) - # - # assert len(results) == 1 - # assert 0.5 < results[0].score < 0.65 - # - # - # def test_valid_us_ssn_medium_match_exact_context(self): - # num = '078-05-1120' - # context = "my social security number is " - # results = us_ssn_recognizer.analyze(context + num, entities) - # - # assert len(results) == 1 - # assert 0.6 < results[0].score < 0.9 - - def test_invalid_us_ssn(self): num = '078-05-11201' results = us_ssn_recognizer.analyze(num, entities) assert len(results) == 0 - - - def test_invalid_us_ssn_exact_context(self): - num = '078-05-11201' - context = "my ssn is " - results = us_ssn_recognizer.analyze(context + num, entities) - - assert len(results) == 0 From 54c8280e6c40c32cbfa4221a9c6c3aa922faa54b Mon Sep 17 00:00:00 2001 From: Itye Richter Date: Mon, 29 Apr 2019 09:38:03 +0300 Subject: [PATCH 14/75] Add public path to makefile (#121) * updated docker file to support correct spacy version * added public path for push release * Update Dockerfile.python.deps * replaced registry with public mcr --- Makefile | 2 ++ charts/presidio/values.yaml | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index ce4ab3fe0..81f8d1301 100644 --- a/Makefile +++ b/Makefile @@ -79,8 +79,10 @@ endif docker pull $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/$*:$(RELEASE_VERSION) docker push $(DOCKER_REGISTRY)/$*:$(RELEASE_VERSION) + docker push $(DOCKER_REGISTRY)/public/$*:$(RELEASE_VERSION) docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/$*:latest docker push $(DOCKER_REGISTRY)/$*:latest + docker push $(DOCKER_REGISTRY)/public/$*:latest # All non-functional tests .PHONY: test diff --git a/charts/presidio/values.yaml b/charts/presidio/values.yaml index ebb8bc3df..172b22033 100644 --- a/charts/presidio/values.yaml +++ b/charts/presidio/values.yaml @@ -1,4 +1,4 @@ -registry: presidio.azurecr.io +registry: mcr.microsoft.com/presidio/ # Image pull secret #privateRegistry: acr-auth From 44a24070ab6e5945d9ee10309b69df8ec57190d4 Mon Sep 17 00:00:00 2001 From: Itye Richter Date: Mon, 29 Apr 2019 10:11:46 +0300 Subject: [PATCH 15/75] updated docs and samples based on new changes in dev (#120) * updated docs and samples based on new changes in dev * updated text change suggestion * reverted registry change --- README.MD | 4 ++-- docs/field_types.md | 8 ++++++++ docs/tutorial_service.md | 4 ++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.MD b/README.MD index 8c1cc84d4..921f365bf 100644 --- a/README.MD +++ b/README.MD @@ -107,7 +107,7 @@ The script will install Presidio on your cluster using the default values. 1. Analyze text ```sh - $ echo -n '{"text":"John Smith lives in New York. We met yesterday morning in Seattle. I called him before on (212) 555-1234 to verify the appointment. He also told me that his drivers license is AC333991", "analyzeTemplate":{"fields":[]} }' | http /api/v1/projects//analyze + $ echo -n '{"text":"John Smith lives in New York. We met yesterday morning in Seattle. I called him before on (212) 555-1234 to verify the appointment. He also told me that his drivers license is AC333991", "analyzeTemplate":{"allFields":true} }' | http /api/v1/projects//analyze ``` ***Sample 2*** @@ -116,7 +116,7 @@ You can also create reusable templates 1. Create an analyzer project ```sh - $ echo -n '{"fields":[]}' | http /api/v1/templates//analyze/ + $ echo -n '{"allFields":true}' | http /api/v1/templates//analyze/ ``` 2. Analyze text diff --git a/docs/field_types.md b/docs/field_types.md index c88261824..33efc1809 100644 --- a/docs/field_types.md +++ b/docs/field_types.md @@ -232,4 +232,12 @@ +
    +

    All fields

    +In case you need Presidio to return all possible PII entities, in your Presidio deployment, you need to set the `allFields` flag to `true`. + +For example: +```json +"analyzeTemplate":{"allFields":true} +```
    \ No newline at end of file diff --git a/docs/tutorial_service.md b/docs/tutorial_service.md index f35c2eb75..4a3868ae8 100644 --- a/docs/tutorial_service.md +++ b/docs/tutorial_service.md @@ -4,7 +4,7 @@ 1. Analyze text ```sh - echo -n '{"text":"John Smith lives in New York. We met yesterday morning in Seattle. I called him before on (212) 555-1234 to verify the appointment. He also told me that his drivers license is AC111921", "analyzeTemplate":{"fields":[]} }' | http /api/v1/projects//analyze + echo -n '{"text":"John Smith lives in New York. We met yesterday morning in Seattle. I called him before on (212) 555-1234 to verify the appointment. He also told me that his drivers license is AC111921", "analyzeTemplate":{"allFields":true} }' | http /api/v1/projects//analyze ``` ***Sample 2*** @@ -13,7 +13,7 @@ You can also create reusable templates 1. Create an analyzer project ```sh - echo -n '{"fields":[]}' | http /api/v1/templates//analyze/ + echo -n '{"allFields":true}' | http /api/v1/templates//analyze/ ``` 2. Analyze text From c19ae21e889ffcb3a692d3811094b0b79d31fd00 Mon Sep 17 00:00:00 2001 From: Itye Richter Date: Mon, 29 Apr 2019 14:31:24 +0300 Subject: [PATCH 16/75] a post-upgrade e2e test (#112) Runs e2e tests on the newly deployed code --- Dockerfile.python.deps | 2 +- Makefile | 4 +- charts/presidio/templates/_helpers.tpl | 3 + .../templates/tester-job-deployment.yaml | 45 +++ charts/presidio/values.yaml | 8 + pkg/platform/platform.go | 5 + .../presidio-api/api/analyze/analyze_test.go | 16 ++ presidio-tester/Dockerfile | 23 ++ .../analyze-custom-recognizer-request.json | 0 .../analyze-custom-recognizer-template.json | 2 +- .../testdata/analyze-image-template.json | 0 .../testdata/analyze-request.json | 0 .../testdata/analyze-response.json | 57 ++++ .../testdata/analyze-template.json | 9 +- .../testdata/anonymize-image-template.json | 0 .../testdata/anonymize-request.json | 0 .../testdata/anonymize-response.json | 3 + .../testdata/anonymize-template.json | 0 .../new-custom-pattern-recognizer.json | 2 +- .../presidio-tester}/testdata/ocr-result.json | 0 .../presidio-tester}/testdata/ocr-result.png | Bin .../presidio-tester}/testdata/ocr-test.png | Bin .../update-custom-pattern-recognizer.json | 2 +- presidio-tester/cmd/presidio-tester/tester.go | 258 ++++++++++++++++++ tests/common/common.go | 94 +++++++ tests/functional_http_test.go | 109 ++------ tests/functional_recognizers_test.go | 27 +- tests/integration_anonymize_image_test.go | 7 +- tests/integration_tesseract_test.go | 5 +- 29 files changed, 562 insertions(+), 119 deletions(-) create mode 100644 charts/presidio/templates/tester-job-deployment.yaml create mode 100644 presidio-tester/Dockerfile rename {tests => presidio-tester/cmd/presidio-tester}/testdata/analyze-custom-recognizer-request.json (100%) rename {tests => presidio-tester/cmd/presidio-tester}/testdata/analyze-custom-recognizer-template.json (89%) rename {tests => presidio-tester/cmd/presidio-tester}/testdata/analyze-image-template.json (100%) rename {tests => presidio-tester/cmd/presidio-tester}/testdata/analyze-request.json (100%) create mode 100644 presidio-tester/cmd/presidio-tester/testdata/analyze-response.json rename {tests => presidio-tester/cmd/presidio-tester}/testdata/analyze-template.json (71%) rename {tests => presidio-tester/cmd/presidio-tester}/testdata/anonymize-image-template.json (100%) rename {tests => presidio-tester/cmd/presidio-tester}/testdata/anonymize-request.json (100%) create mode 100644 presidio-tester/cmd/presidio-tester/testdata/anonymize-response.json rename {tests => presidio-tester/cmd/presidio-tester}/testdata/anonymize-template.json (100%) rename {tests => presidio-tester/cmd/presidio-tester}/testdata/new-custom-pattern-recognizer.json (92%) rename {tests => presidio-tester/cmd/presidio-tester}/testdata/ocr-result.json (100%) rename {tests => presidio-tester/cmd/presidio-tester}/testdata/ocr-result.png (100%) rename {tests => presidio-tester/cmd/presidio-tester}/testdata/ocr-test.png (100%) rename {tests => presidio-tester/cmd/presidio-tester}/testdata/update-custom-pattern-recognizer.json (92%) create mode 100644 presidio-tester/cmd/presidio-tester/tester.go create mode 100644 tests/common/common.go diff --git a/Dockerfile.python.deps b/Dockerfile.python.deps index 96025f341..6814b1dcd 100644 --- a/Dockerfile.python.deps +++ b/Dockerfile.python.deps @@ -13,4 +13,4 @@ RUN apk --update add --no-cache g++ && \ cd re2 && make install && cd .. && rm -rf re2 && rm re2.tar.gz && \ pip install --no-cache-dir cython && \ pip install --no-cache-dir -r requirements.txt && \ - apk del build_deps + apk del build_deps \ No newline at end of file diff --git a/Makefile b/Makefile index 81f8d1301..f69b68ec6 100644 --- a/Makefile +++ b/Makefile @@ -2,8 +2,8 @@ DOCKER_REGISTRY ?= presidio.azurecr.io DOCKER_BUILD_FLAGS := LDFLAGS := -BINS = presidio-anonymizer presidio-ocr presidio-anonymizer-image presidio-api presidio-scheduler presidio-datasink presidio-collector presidio-recognizers-store -IMAGES = presidio-anonymizer presidio-ocr presidio-anonymizer-image presidio-api presidio-scheduler presidio-datasink presidio-collector presidio-analyzer presidio-recognizers-store +BINS = presidio-anonymizer presidio-ocr presidio-anonymizer-image presidio-api presidio-scheduler presidio-datasink presidio-collector presidio-recognizers-store presidio-tester +IMAGES = presidio-anonymizer presidio-ocr presidio-anonymizer-image presidio-api presidio-scheduler presidio-datasink presidio-collector presidio-analyzer presidio-recognizers-store presidio-tester GOLANG_DEPS = presidio-golang-deps PYTHON_DEPS = presidio-python-deps GOLANG_BASE = presidio-golang-base diff --git a/charts/presidio/templates/_helpers.tpl b/charts/presidio/templates/_helpers.tpl index 5d4f360ba..eba7534fd 100644 --- a/charts/presidio/templates/_helpers.tpl +++ b/charts/presidio/templates/_helpers.tpl @@ -36,6 +36,9 @@ We truncate at 63 chars because some Kubernetes name fields are limited to this {{- define "presidio.recognizersstore.fullname" -}} {{ include "presidio.fullname" . | printf "%s-recognizersstore" }} {{- end -}} +{{- define "presidio.tester.fullname" -}} +{{ include "presidio.fullname" . | printf "%s-tester" }} +{{- end -}} {{- define "presidio.analyzer.address" -}} {{template "presidio.analyzer.fullname" .}}:{{.Values.analyzer.service.externalPort}} diff --git a/charts/presidio/templates/tester-job-deployment.yaml b/charts/presidio/templates/tester-job-deployment.yaml new file mode 100644 index 000000000..6cedb79e3 --- /dev/null +++ b/charts/presidio/templates/tester-job-deployment.yaml @@ -0,0 +1,45 @@ +{{- if .Values.tester.enabled -}} +{{ $fullname := include "presidio.tester.fullname" . }} +apiVersion: batch/v1 +kind: Job +metadata: + name: "{{ $fullname }}-post-install-hook" + labels: + app.kubernetes.io/managed-by: {{.Release.Service | quote }} + app.kubernetes.io/instance: {{.Release.Name | quote }} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" + annotations: + # This is what defines this resource as a hook. Without this line, the + # job is considered part of the release. + "helm.sh/hook": post-upgrade + "helm.sh/hook-weight": "-5" + "helm.sh/hook-delete-policy": hook-succeeded +spec: + template: + metadata: + name: "{{ $fullname }}-post-install-hook" + labels: + app: {{ $fullname }} + app.kubernetes.io/managed-by: {{.Release.Service | quote }} + app.kubernetes.io/instance: {{.Release.Name | quote }} + helm.sh/chart: "{{.Chart.Name}}-{{.Chart.Version}}" + spec: + restartPolicy: Never + containers: + - name: {{ $fullname }} + image: "{{ .Values.registry }}/{{ .Values.tester.name }}:{{ default .Chart.AppVersion .Values.tag }}" + imagePullPolicy: {{ default "IfNotPresent" .Values.tester.imagePullPolicy }} + ports: + - containerPort: {{ .Values.tester.service.internalPort }} + env: + - name: PRESIDIO_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: WEB_PORT + value: {{ .Values.api.service.internalPort | quote }} + - name: API_SVC_ADDRESS + value: {{ template "presidio.api.fullname" . }} + {{ if .Values.privateRegistry }}imagePullSecrets: + - name: {{.Values.privateRegistry}}{{ end }} +{{- end -}} \ No newline at end of file diff --git a/charts/presidio/values.yaml b/charts/presidio/values.yaml index 172b22033..f75692b4e 100644 --- a/charts/presidio/values.yaml +++ b/charts/presidio/values.yaml @@ -81,6 +81,14 @@ recognizersstore: externalPort: 3004 internalPort: 3004 +tester: + enabled: false + name: presidio-tester + imagePullPolicy: Always + service: + type: ClusterIP + externalPort: 3001 + internalPort: 3001 collector: name: presidio-collector diff --git a/pkg/platform/platform.go b/pkg/platform/platform.go index 6d4cef47f..15ddd8f51 100644 --- a/pkg/platform/platform.go +++ b/pkg/platform/platform.go @@ -57,6 +57,7 @@ type Settings struct { OcrSvcAddress string SchedulerSvcAddress string RecognizersStoreSvcAddress string + APISvcAddress string RedisURL string RedisPassword string RedisDB int @@ -105,6 +106,9 @@ const SchedulerSvcAddress = "scheduler_svc_address" //RecognizersStoreSvcAddress recognizers store service address const RecognizersStoreSvcAddress = "recognizers_store_svc_address" +//APISvcAddress api service address +const APISvcAddress = "api_svc_address" + //RedisURL redis address const RedisURL = "redis_url" @@ -158,6 +162,7 @@ func GetSettings() *Settings { OcrSvcAddress: getTrimmedEnv(OcrSvcAddress), SchedulerSvcAddress: getTrimmedEnv(SchedulerSvcAddress), RecognizersStoreSvcAddress: getTrimmedEnv(RecognizersStoreSvcAddress), + APISvcAddress: getTrimmedEnv(APISvcAddress), RedisURL: getTrimmedEnv(RedisURL), RedisDB: viper.GetInt(strings.ToUpper(RedisDb)), RedisSSL: viper.GetBool(strings.ToUpper(RedisSSL)), diff --git a/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go b/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go index 059e127fb..cb187eba8 100644 --- a/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go +++ b/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go @@ -88,3 +88,19 @@ func TestLanguageCode(t *testing.T) { Analyze(context.Background(), api, analyzeAPIRequest, project) assert.Equal(t, "langtest", analyzeAPIRequest.AnalyzeTemplate.Language) } + +func TestAllFields(t *testing.T) { + + api := setupMockServices() + + project := "tests" + analyzeAPIRequest := &types.AnalyzeApiRequest{ + Text: "My number is (555) 253-0000 and email johnsnow@foo.com", + AnalyzeTemplate: &types.AnalyzeTemplate{ + Language: "en", + AllFields: true}, + } + results, err := Analyze(context.Background(), api, analyzeAPIRequest, project) + assert.NoError(t, err) + assert.Equal(t, 2, len(results)) +} diff --git a/presidio-tester/Dockerfile b/presidio-tester/Dockerfile new file mode 100644 index 000000000..ac4959ee3 --- /dev/null +++ b/presidio-tester/Dockerfile @@ -0,0 +1,23 @@ +ARG REGISTRY=presidio.azurecr.io + +FROM ${REGISTRY}/presidio-golang-base AS build-env + +ARG NAME=presidio-tester +ARG PRESIDIOPATH=${GOPATH}/src/github.com/Microsoft/presidio +ARG VERSION=latest + + +WORKDIR ${PRESIDIOPATH}/${NAME}/cmd/${NAME} +RUN GOOS=linux GOARCH=amd64 CGO_ENABLED=0 && go build -ldflags '-X github.com/Microsoft/presidio/pkg/version.Version=${VERSION}' -o /usr/bin/${NAME} +RUN cp -r ${PRESIDIOPATH}/${NAME}/cmd/${NAME}/testdata /usr/bin/testdata + +#---------------------------- + +FROM alpine:3.8 + +ARG NAME=presidio-tester +WORKDIR /usr/bin/ +COPY --from=build-env /usr/bin/${NAME} /usr/bin/ +COPY --from=build-env /usr/bin/testdata /usr/bin/testdata + +CMD /usr/bin/presidio-tester \ No newline at end of file diff --git a/tests/testdata/analyze-custom-recognizer-request.json b/presidio-tester/cmd/presidio-tester/testdata/analyze-custom-recognizer-request.json similarity index 100% rename from tests/testdata/analyze-custom-recognizer-request.json rename to presidio-tester/cmd/presidio-tester/testdata/analyze-custom-recognizer-request.json diff --git a/tests/testdata/analyze-custom-recognizer-template.json b/presidio-tester/cmd/presidio-tester/testdata/analyze-custom-recognizer-template.json similarity index 89% rename from tests/testdata/analyze-custom-recognizer-template.json rename to presidio-tester/cmd/presidio-tester/testdata/analyze-custom-recognizer-template.json index 5bbe8e94b..b456e20ef 100644 --- a/tests/testdata/analyze-custom-recognizer-template.json +++ b/presidio-tester/cmd/presidio-tester/testdata/analyze-custom-recognizer-template.json @@ -13,7 +13,7 @@ "name": "CREDIT_CARD" }, { - "name": "ROCKETS" + "name": "ROCKET" } ] } \ No newline at end of file diff --git a/tests/testdata/analyze-image-template.json b/presidio-tester/cmd/presidio-tester/testdata/analyze-image-template.json similarity index 100% rename from tests/testdata/analyze-image-template.json rename to presidio-tester/cmd/presidio-tester/testdata/analyze-image-template.json diff --git a/tests/testdata/analyze-request.json b/presidio-tester/cmd/presidio-tester/testdata/analyze-request.json similarity index 100% rename from tests/testdata/analyze-request.json rename to presidio-tester/cmd/presidio-tester/testdata/analyze-request.json diff --git a/presidio-tester/cmd/presidio-tester/testdata/analyze-response.json b/presidio-tester/cmd/presidio-tester/testdata/analyze-response.json new file mode 100644 index 000000000..f6788a539 --- /dev/null +++ b/presidio-tester/cmd/presidio-tester/testdata/analyze-response.json @@ -0,0 +1,57 @@ +[ + { + "field": { + "name": "PHONE_NUMBER" + }, + "score": 1, + "location": { + "start": 60, + "end": 74, + "length": 14 + } + }, + { + "field": { + "name": "CREDIT_CARD" + }, + "score": 1, + "location": { + "start": 112, + "end": 127, + "length": 15 + } + }, + { + "field": { + "name": "DATE_TIME" + }, + "score": 0.85, + "location": { + "start": 7, + "end": 16, + "length": 9 + } + }, + { + "field": { + "name": "DATE_TIME" + }, + "score": 0.85, + "location": { + "start": 17, + "end": 24, + "length": 7 + } + }, + { + "field": { + "name": "LOCATION" + }, + "score": 0.85, + "location": { + "start": 28, + "end": 35, + "length": 7 + } + } +] \ No newline at end of file diff --git a/tests/testdata/analyze-template.json b/presidio-tester/cmd/presidio-tester/testdata/analyze-template.json similarity index 71% rename from tests/testdata/analyze-template.json rename to presidio-tester/cmd/presidio-tester/testdata/analyze-template.json index 394ec2c13..8383619d0 100644 --- a/tests/testdata/analyze-template.json +++ b/presidio-tester/cmd/presidio-tester/testdata/analyze-template.json @@ -1,9 +1,12 @@ { - "fields": [{ + "fields": [ + { "name": "PHONE_NUMBER" - }, { + }, + { "name": "LOCATION" - }, { + }, + { "name": "DATE_TIME" }, { diff --git a/tests/testdata/anonymize-image-template.json b/presidio-tester/cmd/presidio-tester/testdata/anonymize-image-template.json similarity index 100% rename from tests/testdata/anonymize-image-template.json rename to presidio-tester/cmd/presidio-tester/testdata/anonymize-image-template.json diff --git a/tests/testdata/anonymize-request.json b/presidio-tester/cmd/presidio-tester/testdata/anonymize-request.json similarity index 100% rename from tests/testdata/anonymize-request.json rename to presidio-tester/cmd/presidio-tester/testdata/anonymize-request.json diff --git a/presidio-tester/cmd/presidio-tester/testdata/anonymize-response.json b/presidio-tester/cmd/presidio-tester/testdata/anonymize-response.json new file mode 100644 index 000000000..b990a1878 --- /dev/null +++ b/presidio-tester/cmd/presidio-tester/testdata/anonymize-response.json @@ -0,0 +1,3 @@ +{ + "text": "my phone number is \u003cphone-number\u003e and my credit card is " +} \ No newline at end of file diff --git a/tests/testdata/anonymize-template.json b/presidio-tester/cmd/presidio-tester/testdata/anonymize-template.json similarity index 100% rename from tests/testdata/anonymize-template.json rename to presidio-tester/cmd/presidio-tester/testdata/anonymize-template.json diff --git a/tests/testdata/new-custom-pattern-recognizer.json b/presidio-tester/cmd/presidio-tester/testdata/new-custom-pattern-recognizer.json similarity index 92% rename from tests/testdata/new-custom-pattern-recognizer.json rename to presidio-tester/cmd/presidio-tester/testdata/new-custom-pattern-recognizer.json index d44d6cfbc..b4b15c144 100644 --- a/tests/testdata/new-custom-pattern-recognizer.json +++ b/presidio-tester/cmd/presidio-tester/testdata/new-custom-pattern-recognizer.json @@ -1,6 +1,6 @@ { "value": { - "entity": "ROCKETS", + "entity": "ROCKET", "language": "en", "patterns": [ { diff --git a/tests/testdata/ocr-result.json b/presidio-tester/cmd/presidio-tester/testdata/ocr-result.json similarity index 100% rename from tests/testdata/ocr-result.json rename to presidio-tester/cmd/presidio-tester/testdata/ocr-result.json diff --git a/tests/testdata/ocr-result.png b/presidio-tester/cmd/presidio-tester/testdata/ocr-result.png similarity index 100% rename from tests/testdata/ocr-result.png rename to presidio-tester/cmd/presidio-tester/testdata/ocr-result.png diff --git a/tests/testdata/ocr-test.png b/presidio-tester/cmd/presidio-tester/testdata/ocr-test.png similarity index 100% rename from tests/testdata/ocr-test.png rename to presidio-tester/cmd/presidio-tester/testdata/ocr-test.png diff --git a/tests/testdata/update-custom-pattern-recognizer.json b/presidio-tester/cmd/presidio-tester/testdata/update-custom-pattern-recognizer.json similarity index 92% rename from tests/testdata/update-custom-pattern-recognizer.json rename to presidio-tester/cmd/presidio-tester/testdata/update-custom-pattern-recognizer.json index 764b52bef..48d9f80ae 100644 --- a/tests/testdata/update-custom-pattern-recognizer.json +++ b/presidio-tester/cmd/presidio-tester/testdata/update-custom-pattern-recognizer.json @@ -1,6 +1,6 @@ { "value": { - "entity": "ROCKETS", + "entity": "ROCKET", "language": "en", "patterns": [ { diff --git a/presidio-tester/cmd/presidio-tester/tester.go b/presidio-tester/cmd/presidio-tester/tester.go new file mode 100644 index 000000000..aadbbbfab --- /dev/null +++ b/presidio-tester/cmd/presidio-tester/tester.go @@ -0,0 +1,258 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "net/url" + "os" + "strconv" + "strings" + + log "github.com/Microsoft/presidio/pkg/logger" + + "github.com/Microsoft/presidio/pkg/platform" + + "github.com/spf13/pflag" + "github.com/spf13/viper" +) + +// APIEndPoint api endpoint +var APIEndPoint = "" + +func main() { + pflag.Int(platform.WebPort, 8080, "API Port") + pflag.String(platform.APISvcAddress, "127.0.0.1", "Api Service Endpoint") + pflag.String("log_level", "info", "Log level - debug/info/warn/error") + + pflag.CommandLine.AddGoFlagSet(flag.CommandLine) + pflag.Parse() + viper.BindPFlags(pflag.CommandLine) + + settings := platform.GetSettings() + APIEndPoint = settings.APISvcAddress + ":" + strconv.Itoa(settings.WebPort) + + log.Info("Starting e2e test") + TestAddTemplate() + TestDeleteTemplate() + TestAnalyzer() + TestAnonymizer() + TestImageAnonymizer() + log.Info("Ended e2e test") +} + +func generatePayload(name string) []byte { + payload, err := ioutil.ReadFile("./testdata/" + name) + if err != nil { + panic(err) + } + return payload +} + +func invokeHTTPRequest(path string, method string, payload []byte) string { + + request := &http.Request{ + Method: method, + URL: &url.URL{Scheme: "http", Host: APIEndPoint, Path: path}, + Body: ioutil.NopCloser(bytes.NewReader(payload)), + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + } + + resp, err := http.DefaultClient.Do(request) + if err != nil { + panic(err) + } + if resp.StatusCode >= 300 { + log.Error("%s %s: expected status code smaller than 300 , got '%d'\n", request.Method, request.URL.String(), resp.StatusCode) + panic("Bad status code") + } + defer resp.Body.Close() + bodyBytes, _ := ioutil.ReadAll(resp.Body) + return string(bodyBytes) +} + +func invokeHTTPUpload(path string, values map[string]io.Reader) []byte { + + var b bytes.Buffer + w := multipart.NewWriter(&b) + for key, r := range values { + var fw io.Writer + var err error + if x, ok := r.(io.Closer); ok { + defer x.Close() + } + // Add an image file + if x, ok := r.(*os.File); ok { + fw, err = w.CreateFormFile(key, x.Name()) + + } else { + // Add other fields + fw, err = w.CreateFormField(key) + } + if err != nil { + panic(err) + } + _, err = io.Copy(fw, r) + if err != nil { + panic(err) + } + } + + w.Close() + + req, err := http.NewRequest("POST", "http://"+APIEndPoint+path, &b) + if err != nil { + panic(err) + } + req.Header.Set("Content-Type", w.FormDataContentType()) + + res, err := http.DefaultClient.Do(req) + if err != nil { + panic(err) + } + if res.StatusCode != http.StatusOK { + log.Error("bad status: %s", res.Status) + panic("Bad status code") + } + defer res.Body.Close() + bodyBytes, _ := ioutil.ReadAll(res.Body) + return bodyBytes +} + +// TestAddTemplate Test Add Template +func TestAddTemplate() { + log.Info("Start TestAddTemplate") + log.Info("Adding analyze template") + payload := generatePayload("analyze-template.json") + var res = invokeHTTPRequest("/api/v1/templates/test/analyze/test", "POST", payload) + log.Info(res) + log.Info("Adding anonymize template") + payload = generatePayload("anonymize-template.json") + res = invokeHTTPRequest("/api/v1/templates/test/anonymize/test", "POST", payload) + log.Info(res) + log.Info("End TestAddTemplate") +} + +//TestDeleteTemplate Test Delete Template +func TestDeleteTemplate() { + log.Info("Start TestDeleteTemplate") + log.Info("Delete analyze template") + var res = invokeHTTPRequest("/api/v1/templates/test/analyze/test", "DELETE", []byte("")) + log.Info(res) + log.Info("Delete anonymize template") + res = invokeHTTPRequest("/api/v1/templates/test/anonymize/test", "DELETE", []byte("")) + log.Info(res) + log.Info("End TestDeleteTemplate") +} + +//TestAnalyzer Test Analyzer +func TestAnalyzer() { + log.Info("Start TestAnalyzer") + TestAddTemplate() + payload := generatePayload("analyze-request.json") + log.Info("Analyze request") + var res = invokeHTTPRequest("/api/v1/projects/test/analyze", "POST", payload) + log.Info(res) + + //Convert to json and compare specific properties + var resultJSON, expectedJSON []anresponse + if err := json.Unmarshal([]byte(res), &resultJSON); err != nil { + panic(err) + } + + var expectedResponseBytes = generatePayload("analyze-response.json") + if err := json.Unmarshal(expectedResponseBytes, &expectedJSON); err != nil { + panic(err) + } + + for i := 0; i < len(resultJSON); i++ { + if resultJSON[i].Field.Name != expectedJSON[i].Field.Name { + panic("Result field.name is different than expected") + } + if resultJSON[i].Location.Start != expectedJSON[i].Location.Start { + panic("Result location.start is different than expected") + } + if resultJSON[i].Location.End != expectedJSON[i].Location.End { + panic("Result location.end is different than expected") + } + if resultJSON[i].Location.Length != expectedJSON[i].Location.Length { + panic("Result location.length is different than expected") + } + } + + log.Info("End TestAnalyzer") +} + +//TestAnonymizer Test Anonymizer +func TestAnonymizer() { + log.Info("Start TestAnonymizer") + TestAddTemplate() + payload := generatePayload("anonymize-request.json") + log.Info("anonymize request") + var res = invokeHTTPRequest("/api/v1/projects/test/anonymize", "POST", payload) + log.Info(res) + + //prase json and compare results + var resultJSON, expectedJSON map[string]string + if err := json.Unmarshal([]byte(res), &resultJSON); err != nil { + panic(err) + } + + var expectedResponseBytes = generatePayload("anonymize-response.json") + if err := json.Unmarshal(expectedResponseBytes, &expectedJSON); err != nil { + panic(err) + } + + if resultJSON["text"] != expectedJSON["text"] { + panic("Result text is different than expected") + } + log.Info("End TestAnonymizer") +} + +//TestImageAnonymizer Test Image Anonymizer +func TestImageAnonymizer() { + log.Info("Start TestImageAnonymizer") + file, err := os.Open("./testdata/ocr-test.png") + if err != nil { + panic(err) + } + payload := map[string]io.Reader{ + "file": file, + "analyzeTemplate": strings.NewReader((string)(generatePayload("analyze-image-template.json"))), + "anonymizeImageTemplate": strings.NewReader((string)(generatePayload("anonymize-image-template.json"))), + "imageType": strings.NewReader("image/png"), + "detectionType": strings.NewReader("OCR"), + } + + log.Info("Sending anonymize image request") + result := invokeHTTPUpload("/api/v1/projects/test/anonymize-image", payload) + savedOutputImage, err := ioutil.ReadFile("./testdata/ocr-result.png") + if err != nil { + panic(err) + } + if len(savedOutputImage) != len(result) { + log.Error("Images are not equal") + panic("Images are not equal") + } + log.Info("End TestImageAnonymizer") +} + +type anresponse struct { + Field field `json:"field"` + Score float64 `json:"score"` + Location location `json:"location"` +} +type field struct { + Name string `json:"name"` +} +type location struct { + Start int `json:"start"` + End int `json:"end"` + Length int `json:"length"` +} diff --git a/tests/common/common.go b/tests/common/common.go new file mode 100644 index 000000000..68aebc556 --- /dev/null +++ b/tests/common/common.go @@ -0,0 +1,94 @@ +package common + +import ( + "bytes" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "net/url" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestDataPath test data path +const TestDataPath string = "../presidio-tester/cmd/presidio-tester/testdata/" +const host = "localhost:8080" + +//GeneratePayload generate payload +func GeneratePayload(name string) []byte { + payload, err := ioutil.ReadFile(TestDataPath + name) + if err != nil { + panic(err) + } + return payload +} + +//InvokeHTTPRequest invoke http request +func InvokeHTTPRequest(t *testing.T, path string, method string, payload []byte) string { + + request := &http.Request{ + Method: method, + URL: &url.URL{Scheme: "http", Host: host, Path: path}, + Body: ioutil.NopCloser(bytes.NewReader(payload)), + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + } + + resp, err := http.DefaultClient.Do(request) + assert.NoError(t, err) + + if resp.StatusCode >= 300 { + t.Errorf("%s %s: expected status code smaller than 300 , got '%d'\n", request.Method, request.URL.String(), resp.StatusCode) + } + defer resp.Body.Close() + bodyBytes, _ := ioutil.ReadAll(resp.Body) + return string(bodyBytes) +} + +//InvokeHTTPUpload invoke upload +func InvokeHTTPUpload(t *testing.T, path string, values map[string]io.Reader) []byte { + + var b bytes.Buffer + w := multipart.NewWriter(&b) + for key, r := range values { + var fw io.Writer + var err error + if x, ok := r.(io.Closer); ok { + defer x.Close() + } + // Add an image file + if x, ok := r.(*os.File); ok { + fw, err = w.CreateFormFile(key, x.Name()) + assert.NoError(t, err) + + } else { + // Add other fields + fw, err = w.CreateFormField(key) + assert.NoError(t, err) + + } + _, err = io.Copy(fw, r) + assert.NoError(t, err) + + } + + w.Close() + + req, err := http.NewRequest("POST", "http://"+host+path, &b) + assert.NoError(t, err) + req.Header.Set("Content-Type", w.FormDataContentType()) + + res, err := http.DefaultClient.Do(req) + assert.NoError(t, err) + + if res.StatusCode != http.StatusOK { + t.Errorf("bad status: %s", res.Status) + } + defer res.Body.Close() + bodyBytes, _ := ioutil.ReadAll(res.Body) + return bodyBytes +} diff --git a/tests/functional_http_test.go b/tests/functional_http_test.go index 86a1e3311..2af721b80 100644 --- a/tests/functional_http_test.go +++ b/tests/functional_http_test.go @@ -3,132 +3,57 @@ package tests import ( - "bytes" "io" "io/ioutil" - "mime/multipart" - "net/http" - "net/url" "os" "strings" "testing" "github.com/stretchr/testify/assert" -) - -func generatePayload(name string) []byte { - payload, err := ioutil.ReadFile("./testdata/" + name) - if err != nil { - panic(err) - } - return payload -} - -func invokeHTTPRequest(t *testing.T, path string, method string, payload []byte) string { - - request := &http.Request{ - Method: method, - URL: &url.URL{Scheme: "http", Host: "localhost:8080", Path: path}, - Body: ioutil.NopCloser(bytes.NewReader(payload)), - Header: http.Header{ - "Content-Type": []string{"application/json"}, - }, - } - - resp, err := http.DefaultClient.Do(request) - assert.NoError(t, err) - - if resp.StatusCode >= 300 { - t.Errorf("%s %s: expected status code smaller than 300 , got '%d'\n", request.Method, request.URL.String(), resp.StatusCode) - } - defer resp.Body.Close() - bodyBytes, _ := ioutil.ReadAll(resp.Body) - return string(bodyBytes) -} - -func invokeHTTPUpload(t *testing.T, path string, values map[string]io.Reader) []byte { - var b bytes.Buffer - w := multipart.NewWriter(&b) - for key, r := range values { - var fw io.Writer - var err error - if x, ok := r.(io.Closer); ok { - defer x.Close() - } - // Add an image file - if x, ok := r.(*os.File); ok { - fw, err = w.CreateFormFile(key, x.Name()) - assert.NoError(t, err) - - } else { - // Add other fields - fw, err = w.CreateFormField(key) - assert.NoError(t, err) - - } - _, err = io.Copy(fw, r) - assert.NoError(t, err) - - } - - w.Close() - - req, err := http.NewRequest("POST", "http://localhost:8080"+path, &b) - assert.NoError(t, err) - req.Header.Set("Content-Type", w.FormDataContentType()) - - res, err := http.DefaultClient.Do(req) - assert.NoError(t, err) - - if res.StatusCode != http.StatusOK { - t.Errorf("bad status: %s", res.Status) - } - defer res.Body.Close() - bodyBytes, _ := ioutil.ReadAll(res.Body) - return bodyBytes -} + "github.com/Microsoft/presidio/tests/common" +) func TestAddTemplate(t *testing.T) { - payload := generatePayload("analyze-template.json") - invokeHTTPRequest(t, "/api/v1/templates/test/analyze/test", "POST", payload) + payload := common.GeneratePayload("analyze-template.json") + common.InvokeHTTPRequest(t, "/api/v1/templates/test/analyze/test", "POST", payload) - payload = generatePayload("anonymize-template.json") - invokeHTTPRequest(t, "/api/v1/templates/test/anonymize/test", "POST", payload) + payload = common.GeneratePayload("anonymize-template.json") + common.InvokeHTTPRequest(t, "/api/v1/templates/test/anonymize/test", "POST", payload) } func TestDeleteTemplate(t *testing.T) { - invokeHTTPRequest(t, "/api/v1/templates/test/analyze/test", "DELETE", []byte("")) - invokeHTTPRequest(t, "/api/v1/templates/test/anonymize/test", "DELETE", []byte("")) + common.InvokeHTTPRequest(t, "/api/v1/templates/test/analyze/test", "DELETE", []byte("")) + common.InvokeHTTPRequest(t, "/api/v1/templates/test/anonymize/test", "DELETE", []byte("")) } func TestAnalyzer(t *testing.T) { TestAddTemplate(t) - payload := generatePayload("analyze-request.json") - invokeHTTPRequest(t, "/api/v1/projects/test/analyze", "POST", payload) + payload := common.GeneratePayload("analyze-request.json") + common.InvokeHTTPRequest(t, "/api/v1/projects/test/analyze", "POST", payload) } func TestAnonymizer(t *testing.T) { TestAddTemplate(t) - payload := generatePayload("anonymize-request.json") - invokeHTTPRequest(t, "/api/v1/projects/test/anonymize", "POST", payload) + payload := common.GeneratePayload("anonymize-request.json") + common.InvokeHTTPRequest(t, "/api/v1/projects/test/anonymize", "POST", payload) } func TestImageAnonymizer(t *testing.T) { - file, err := os.Open("./testdata/ocr-test.png") + file, err := os.Open(common.TestDataPath + "ocr-test.png") assert.NoError(t, err) payload := map[string]io.Reader{ "file": file, - "analyzeTemplate": strings.NewReader((string)(generatePayload("analyze-image-template.json"))), - "anonymizeImageTemplate": strings.NewReader((string)(generatePayload("anonymize-image-template.json"))), + "analyzeTemplate": strings.NewReader((string)(common.GeneratePayload("analyze-image-template.json"))), + "anonymizeImageTemplate": strings.NewReader((string)(common.GeneratePayload("anonymize-image-template.json"))), "imageType": strings.NewReader("image/png"), "detectionType": strings.NewReader("OCR"), } - result := invokeHTTPUpload(t, "/api/v1/projects/test/anonymize-image", payload) - savedOutputImage, err := ioutil.ReadFile("./testdata/ocr-result.png") + result := common.InvokeHTTPUpload(t, "/api/v1/projects/test/anonymize-image", payload) + savedOutputImage, err := ioutil.ReadFile(common.TestDataPath + "ocr-result.png") assert.NoError(t, err) assert.Equal(t, len(savedOutputImage), len(result)) diff --git a/tests/functional_recognizers_test.go b/tests/functional_recognizers_test.go index b9d9fdb0b..0f3e685fb 100644 --- a/tests/functional_recognizers_test.go +++ b/tests/functional_recognizers_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" types "github.com/Microsoft/presidio-genproto/golang" + "github.com/Microsoft/presidio/tests/common" ) // TestAddRecognizerAndAnalyze tests the custom recognizers logic. @@ -19,14 +20,14 @@ import ( // 3) Delete the recognizer and verify the results func TestAddRecognizerAndAnalyze(t *testing.T) { // Add a custom recognizer and use it - payload := generatePayload("new-custom-pattern-recognizer.json") - invokeHTTPRequest(t, "/api/v1/analyzer/recognizers/newrec1", "POST", payload) + payload := common.GeneratePayload("new-custom-pattern-recognizer.json") + common.InvokeHTTPRequest(t, "/api/v1/analyzer/recognizers/newrec1", "POST", payload) - payload = generatePayload("analyze-custom-recognizer-template.json") - invokeHTTPRequest(t, "/api/v1/templates/test/analyze/test-custom", "POST", payload) + payload = common.GeneratePayload("analyze-custom-recognizer-template.json") + common.InvokeHTTPRequest(t, "/api/v1/templates/test/analyze/test-custom", "POST", payload) - payload = generatePayload("analyze-custom-recognizer-request.json") - results := invokeHTTPRequest(t, "/api/v1/projects/test/analyze", "POST", payload) + payload = common.GeneratePayload("analyze-custom-recognizer-request.json") + results := common.InvokeHTTPRequest(t, "/api/v1/projects/test/analyze", "POST", payload) expectedResults := []*types.AnalyzeResult{ { @@ -57,11 +58,11 @@ func TestAddRecognizerAndAnalyze(t *testing.T) { time.Sleep(2 * time.Second) // Update the recognizer and expect a new entity to be found - updatePayload := generatePayload("update-custom-pattern-recognizer.json") - invokeHTTPRequest(t, "/api/v1/analyzer/recognizers/newrec1", "PUT", updatePayload) + updatePayload := common.GeneratePayload("update-custom-pattern-recognizer.json") + common.InvokeHTTPRequest(t, "/api/v1/analyzer/recognizers/newrec1", "PUT", updatePayload) - newAnalyzePayload := generatePayload("analyze-custom-recognizer-request.json") - newResults := invokeHTTPRequest(t, "/api/v1/projects/test/analyze", "POST", newAnalyzePayload) + newAnalyzePayload := common.GeneratePayload("analyze-custom-recognizer-request.json") + newResults := common.InvokeHTTPRequest(t, "/api/v1/projects/test/analyze", "POST", newAnalyzePayload) updatedExpectedResults := []*types.AnalyzeResult{ { @@ -98,10 +99,10 @@ func TestAddRecognizerAndAnalyze(t *testing.T) { // Now, delete this recognizer and expect all the 'rockets' not // to appear - invokeHTTPRequest(t, "/api/v1/analyzer/recognizers/newrec1", "DELETE", []byte("")) + common.InvokeHTTPRequest(t, "/api/v1/analyzer/recognizers/newrec1", "DELETE", []byte("")) - deletedAnalyzePayload := generatePayload("analyze-custom-recognizer-request.json") - newResults = invokeHTTPRequest(t, "/api/v1/projects/test/analyze", "POST", deletedAnalyzePayload) + deletedAnalyzePayload := common.GeneratePayload("analyze-custom-recognizer-request.json") + newResults = common.InvokeHTTPRequest(t, "/api/v1/projects/test/analyze", "POST", deletedAnalyzePayload) deletedExpectedResults := []*types.AnalyzeResult{ { diff --git a/tests/integration_anonymize_image_test.go b/tests/integration_anonymize_image_test.go index 1a468680d..86fefaca5 100644 --- a/tests/integration_anonymize_image_test.go +++ b/tests/integration_anonymize_image_test.go @@ -10,16 +10,17 @@ import ( types "github.com/Microsoft/presidio-genproto/golang" "github.com/Microsoft/presidio/presidio-anonymizer-image/cmd/presidio-anonymizer-image/anonymizer" + "github.com/Microsoft/presidio/tests/common" "testing" ) func TestAnonymizeImage(t *testing.T) { - content, err := ioutil.ReadFile("./testdata/ocr-test.png") + content, err := ioutil.ReadFile(common.TestDataPath + "ocr-test.png") assert.NoError(t, err) - jcontent, err := ioutil.ReadFile("./testdata/ocr-result.json") + jcontent, err := ioutil.ReadFile(common.TestDataPath + "ocr-result.json") assert.NoError(t, err) image := &types.Image{ @@ -74,7 +75,7 @@ func TestAnonymizeImage(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, result) - savedOutputImage, err := ioutil.ReadFile("./testdata/ocr-result.png") + savedOutputImage, err := ioutil.ReadFile(common.TestDataPath + "ocr-result.png") assert.NoError(t, err) assert.Equal(t, len(savedOutputImage), len(result.Data)) diff --git a/tests/integration_tesseract_test.go b/tests/integration_tesseract_test.go index bc0118d0c..4f2db6069 100644 --- a/tests/integration_tesseract_test.go +++ b/tests/integration_tesseract_test.go @@ -12,11 +12,12 @@ import ( types "github.com/Microsoft/presidio-genproto/golang" "github.com/Microsoft/presidio/presidio-ocr/cmd/presidio-ocr/ocr" + "github.com/Microsoft/presidio/tests/common" ) func TestOCR(t *testing.T) { - content, err := ioutil.ReadFile("./testdata/ocr-test.png") + content, err := ioutil.ReadFile(common.TestDataPath + "ocr-test.png") assert.NoError(t, err) image := &types.Image{ @@ -27,7 +28,7 @@ func TestOCR(t *testing.T) { assert.NoError(t, err) assert.NotEqual(t, "", result.Text) - savedJSONResult, err := ioutil.ReadFile("./testdata/ocr-result.json") + savedJSONResult, err := ioutil.ReadFile(common.TestDataPath + "ocr-result.json") assert.NoError(t, err) jsonResult, _ := json.Marshal(result) assert.Equal(t, savedJSONResult, jsonResult) From c05477058297430d819d34ea1fa433f1a967bc22 Mon Sep 17 00:00:00 2001 From: balteravishay Date: Tue, 30 Apr 2019 15:30:47 +0300 Subject: [PATCH 17/75] Build deps - adding build number to python&golang dependency containers (#122) * adding presidio-dependency images versioning * updating installation notes with dependency label * add CI as code * presidio deps ci yaml file * add triggers to deps CI yaml * separate CIs for golang and python * adding presidio-dependency images versioning * updating installation notes with dependency label --- Dockerfile.golang.base | 3 ++- Makefile | 20 +++++++++++++------ docs/development.md | 27 ++++++++++++++++++-------- docs/install.md | 3 ++- pipelines/presidio-golang-deps-CI.yaml | 27 ++++++++++++++++++++++++++ pipelines/presidio-python-deps-CI.yaml | 26 +++++++++++++++++++++++++ presidio-analyzer/Dockerfile | 3 ++- 7 files changed, 92 insertions(+), 17 deletions(-) create mode 100644 pipelines/presidio-golang-deps-CI.yaml create mode 100644 pipelines/presidio-python-deps-CI.yaml diff --git a/Dockerfile.golang.base b/Dockerfile.golang.base index e23acccd4..1824e6b91 100644 --- a/Dockerfile.golang.base +++ b/Dockerfile.golang.base @@ -1,6 +1,7 @@ ARG REGISTRY=presidio.azurecr.io +ARG PRESIDIO_DEPS_LABEL=latest -FROM ${REGISTRY}/presidio-golang-deps +FROM ${REGISTRY}/presidio-golang-deps:${PRESIDIO_DEPS_LABEL} WORKDIR $GOPATH/src/github.com/Microsoft/presidio ADD . $GOPATH/src/github.com/Microsoft/presidio diff --git a/Makefile b/Makefile index f69b68ec6..6271e3c52 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ GOLANG_BASE = presidio-golang-base GIT_TAG = $(shell git describe --tags --always 2>/dev/null) VERSION ?= ${GIT_TAG} PRESIDIO_LABEL := $(if $(PRESIDIO_LABEL),$(PRESIDIO_LABEL),$(VERSION)) +PRESIDIO_DEPS_LABEL := $(if $(PRESIDIO_DEPS_LABEL),$(PRESIDIO_DEPS_LABEL),'latest') LDFLAGS += -X github.com/Microsoft/presidio/pkg/version.Version=$(VERSION) CX_OSES = linux windows darwin @@ -27,14 +28,14 @@ $(BINS): vendor .PHONY: docker-build-deps docker-build-deps: - -docker pull $(DOCKER_REGISTRY)/$(GOLANG_DEPS) - -docker pull $(DOCKER_REGISTRY)/$(PYTHON_DEPS) - docker build -t $(DOCKER_REGISTRY)/$(GOLANG_DEPS) -f Dockerfile.golang.deps . - docker build -t $(DOCKER_REGISTRY)/$(PYTHON_DEPS) -f Dockerfile.python.deps . + -docker pull $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) + -docker pull $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) + docker build -t $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) -f Dockerfile.golang.deps . + docker build -t $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) -f Dockerfile.python.deps . .PHONY: docker-build-base docker-build-base: - docker build --build-arg REGISTRY=$(DOCKER_REGISTRY) -t $(DOCKER_REGISTRY)/$(GOLANG_BASE) -f Dockerfile.golang.base . + docker build --build-arg REGISTRY=$(DOCKER_REGISTRY) --build-arg PRESIDIO_DEPS_LABEL=$(PRESIDIO_DEPS_LABEL) -t $(DOCKER_REGISTRY)/$(GOLANG_BASE) -f Dockerfile.golang.base . # To use docker-build, you need to have Docker installed and configured. You should also set @@ -44,13 +45,20 @@ docker-build: docker-build-base docker-build: $(addsuffix -dimage,$(IMAGES)) %-dimage: - docker build $(DOCKER_BUILD_FLAGS) --build-arg REGISTRY=$(DOCKER_REGISTRY) --build-arg VERSION=$(VERSION) -t $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) -f $*/Dockerfile . + docker build $(DOCKER_BUILD_FLAGS) --build-arg REGISTRY=$(DOCKER_REGISTRY) --build-arg VERSION=$(VERSION) --build-arg PRESIDIO_DEPS_LABEL=$(PRESIDIO_DEPS_LABEL) -t $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) -f $*/Dockerfile . # You must be logged into DOCKER_REGISTRY before you can push. .PHONY: docker-push-deps docker-push-deps: + docker push $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) + docker push $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) +ifneq ($(PRESIDIO_DEPS_LABEL),latest) + docker image tag $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) $(DOCKER_REGISTRY)/$(PYTHON_DEPS):latest + docker image tag $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) $(DOCKER_REGISTRY)/$(GOLANG_DEPS):latest docker push $(DOCKER_REGISTRY)/$(PYTHON_DEPS):latest docker push $(DOCKER_REGISTRY)/$(GOLANG_DEPS):latest +endif + # push with the given label .PHONY: docker-push diff --git a/docs/development.md b/docs/development.md index 7dbe5f9c7..44edf1422 100644 --- a/docs/development.md +++ b/docs/development.md @@ -34,16 +34,12 @@ 6. Install the Python packages for the analyzer in the `presidio-analyzer` folder ```sh + $ pip3 install cython $ pip3 install -r requirements.txt $ pip3 install -r requirements-dev.txt ``` - **Note:** If you encounter errors with `pyre2` than install `cython` first - - ```sh - $ pip3 install cython - ``` - + 7. Install [tesseract](https://github.com/tesseract-ocr/tesseract/wiki) OCR framework. 8. Protobuf generator tools (Optional) @@ -64,9 +60,24 @@ ## Development notes +- Optional: set version numbers (docker labels): + ```sh + $ export PRESIDIO_LABEL=[presidio label] + $ export PRESIDIO_DEPS_LABEL=[presidio dependencies label] + ``` + if not set, they will default to 'latest', and may cause consistency issues when pushed to a shared registry. + +- Optional - set registry name: + + ```sh + $ export DOCKER_REGISTRY=[registry login server] + ``` + if not set, the default is presidio's internal registry. + - Build the bins with `make build` -- Build the the Docker image with `make docker-build` -- Push the Docker images with `make docker-push` +- Build the base containers with `make docker-build-deps DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_DEPS_LABEL=${PRESIDIO_DEPS_LABEL}` +- Build the the Docker image with `make docker-build DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_DEPS_LABEL=${PRESIDIO_DEPS_LABEL} PRESIDIO_LABEL=${PRESIDIO_LABEL}` +- Push the Docker images with `make docker-push DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_LABEL=${PRESIDIO_LABEL}` - Run the tests with `make test` - Adding a file in go requires the `make go-format` command before running and building the service. - Run functional tests with `make test-functional` diff --git a/docs/install.md b/docs/install.md index fca689785..591351d59 100644 --- a/docs/install.md +++ b/docs/install.md @@ -9,7 +9,8 @@ You can install Presidio as a service in [Kubernetes](https://kubernetes.io/) or $ export DOCKER_REGISTRY=presidio $ export PRESIDIO_LABEL=latest -$ make DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_LABEL=${PRESIDIO_LABEL} docker-build-deps +$ export PRESIDIO_DEPS_LABEL=latest +$ make DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_LABEL=${PRESIDIO_LABEL} PRESIDIO_DEPS_LABEL=${PRESIDIO_DEPS_LABEL} docker-build-deps $ make DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_LABEL=${PRESIDIO_LABEL} docker-build # Run the containers diff --git a/pipelines/presidio-golang-deps-CI.yaml b/pipelines/presidio-golang-deps-CI.yaml new file mode 100644 index 000000000..01f86b80b --- /dev/null +++ b/pipelines/presidio-golang-deps-CI.yaml @@ -0,0 +1,27 @@ +trigger: # Build triggers + batch: true # batch changes if true, start a new build for every push if false + branches: + include: # branch names which will trigger a build + - development + paths: + include: # file paths which must match to trigger a build + - /Dockerfile.golang.deps + - /Gopkg.lock + - /Gopkg.toml + +jobs: # A job is a collection of steps to be run by an agent +- job: BuildGolangDeps + timeoutInMinutes: 30 # how long to run the job before automatically cancelling + pool: + vmImage: 'ubuntu-latest' + + steps: + - task: Docker@2 + displayName: 'Build and Push' + inputs: + containerRegistry: $(registry) # input registry name + repository: presidio-golang-deps # name of image to build + Dockerfile: Dockerfile.golang.deps # dockerfile path + tags: | # image tags + $(Build.BuildId) + latest diff --git a/pipelines/presidio-python-deps-CI.yaml b/pipelines/presidio-python-deps-CI.yaml new file mode 100644 index 000000000..9c7a27dd6 --- /dev/null +++ b/pipelines/presidio-python-deps-CI.yaml @@ -0,0 +1,26 @@ +trigger: # Build triggers + batch: true # batch changes if true, start a new build for every push if false + branches: + include: # branch names which will trigger a build + - development + paths: + include: # file paths which must match to trigger a build + - /Dockerfile.python.deps + - /presidio-analyzer/requirements.txt + +jobs: # A job is a collection of steps to be run by an agent +- job: BuildPythonDeps + timeoutInMinutes: 120 # how long to run the job before automatically cancelling + pool: + vmImage: 'ubuntu-latest' + + steps: + - task: Docker@2 + displayName: 'Build and Push' + inputs: + containerRegistry: $(registry) # input registry name + repository: presidio-python-deps # name of image to build + Dockerfile: Dockerfile.python.deps # dockerfile path + tags: | # image tags + $(Build.BuildId) + latest \ No newline at end of file diff --git a/presidio-analyzer/Dockerfile b/presidio-analyzer/Dockerfile index 5a71b5865..8d3f9b17a 100644 --- a/presidio-analyzer/Dockerfile +++ b/presidio-analyzer/Dockerfile @@ -1,6 +1,7 @@ ARG REGISTRY=presidio.azurecr.io +ARG PRESIDIO_DEPS_LABEL=latest -FROM ${REGISTRY}/presidio-python-deps +FROM ${REGISTRY}/presidio-python-deps:${PRESIDIO_DEPS_LABEL} ARG NAME=presidio-analyzer WORKDIR /usr/bin/${NAME} From bbbec61bd6a6a6bcee72bf204b2d07bd42bdaf03 Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Tue, 30 Apr 2019 20:26:21 +0300 Subject: [PATCH 18/75] Bug fixes - based on bugbash (#126) * Bug 906 - CONTEXT_SUFFIX_COUNT is not used and PREFIX is used instead * Bug 909 - Context not working with upper case / mixed case * Bug 908 - support context with substrings * Bug 907 - Context window size is off by 1 index --- charts/presidio/values.yaml | 2 +- .../analyzer/entity_recognizer.py | 44 ++++++++++++------- .../tests/data/context_sentences_tests.txt | 22 +++++++++- .../tests/test_context_support.py | 4 +- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/charts/presidio/values.yaml b/charts/presidio/values.yaml index f75692b4e..8075ef57e 100644 --- a/charts/presidio/values.yaml +++ b/charts/presidio/values.yaml @@ -1,4 +1,4 @@ -registry: mcr.microsoft.com/presidio/ +registry: mcr.microsoft.com # Image pull secret #privateRegistry: acr-auth diff --git a/presidio-analyzer/analyzer/entity_recognizer.py b/presidio-analyzer/analyzer/entity_recognizer.py index 40aad817f..2d9f55f30 100644 --- a/presidio-analyzer/analyzer/entity_recognizer.py +++ b/presidio-analyzer/analyzer/entity_recognizer.py @@ -156,22 +156,32 @@ def __calculate_context_similarity(self, """Context similarity is 1 if there's exact match between a keyword in context_text and any keyword in context_list - :param context_text a string of the prefix and suffix of the found - match + :param context_text words before and after the matched enitity within + a specified window size :param context_list a list of words considered as context keywords + manually specified by the recognizer's author """ + # If the context list is empty, no need to continue if context_list is None: return 0 + # Take the context text and break it into individual keywords lemmatized_keywords = self.__context_to_keywords(context_text) if lemmatized_keywords is None: return 0 similarity = 0.0 - for context_keyword in lemmatized_keywords: - if context_keyword in context_list: - self.logger.info("Found context keyword '%s'", context_keyword) + for predefined_context_word in context_list: + # result == true only if any of the predefined context words + # is found exactly or as a substring in any of the collected + # context words + result = \ + next((True for keyword in lemmatized_keywords + if predefined_context_word in keyword), False) + if result: + self.logger.debug("Found context keyword '%s'", + predefined_context_word) similarity = 1 break @@ -188,7 +198,7 @@ def __add_n_words(index, at a given index. The words will be collected only if exist in the filtered array - :param index: index of the lemma that its surrounding words + :param index: index of the lemma that its surrounding words we want :param n_words: number of words to take :param lemmas: array of lemmas :param lemmatized_filtered_keywords: the array of filter @@ -198,16 +208,20 @@ def __add_n_words(index, take the successing words """ i = index - # collect at most n words - remaining = n_words + # The entity itself is no intrest to us...however we want to + # consider it anyway for cases were it is attached with no spaces + # to an interesting context word, so we allow it and add 1 to + # the number of collected words + + # collect at most n words (in lower case) + remaining = n_words + 1 while 0 <= i < len(lemmas) and remaining > 0: - if lemmas[i] in lemmatized_filtered_keywords: + lower_lemma = lemmas[i].lower() + if lower_lemma in lemmatized_filtered_keywords: remaining -= 1 - prefix += ' ' + lemmas[i] - if is_backward: - i -= 1 - else: - i += 1 + prefix += ' ' + lower_lemma + + i = i-1 if is_backward else i+1 return prefix def __add_n_words_forward(self, @@ -294,7 +308,7 @@ def __extract_context(self, nlp_artifacts, word, start): context_str) context_str = \ self.__add_n_words_forward(i, - EntityRecognizer.CONTEXT_PREFIX_COUNT, + EntityRecognizer.CONTEXT_SUFFIX_COUNT, nlp_artifacts.lemmas, lemmatized_keywords, context_str) diff --git a/presidio-analyzer/tests/data/context_sentences_tests.txt b/presidio-analyzer/tests/data/context_sentences_tests.txt index ddd9ba793..65214af9b 100644 --- a/presidio-analyzer/tests/data/context_sentences_tests.txt +++ b/presidio-analyzer/tests/data/context_sentences_tests.txt @@ -52,6 +52,14 @@ my driver license is H12234567 US_DRIVER_LICENSE my driver license is: 1234567901234 +# Verify Upper case works +US_DRIVER_LICENSE +my DRIVER LICENSE is: 7774567901234 + +# Verify Mixed case works +US_DRIVER_LICENSE +my DrIvEr LiCeNsE is: 7774567901234 + US_BANK_NUMBER my bank account number is 912803456 @@ -61,5 +69,17 @@ my banking account number is 912803456 US_PASSPORT my passport number is 912803456 +# Verify that substring is also found (e.g. forgotten spaces) +US_PASSPORT +my passportnumber is 912803456 + +US_PASSPORT +my passport number is 912803456 and now there are a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot of other words + +# Verify that within the max window size (currently 5) the context is picked up +US_PASSPORT +my passport number is hi bye hello 912803456 + +# Verify adjacent context words US_PASSPORT -my passport number is 912803456 and now there are a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot a lot of other words \ No newline at end of file +mypassportnumberis912803456 \ No newline at end of file diff --git a/presidio-analyzer/tests/test_context_support.py b/presidio-analyzer/tests/test_context_support.py index b88462006..b6023080f 100644 --- a/presidio-analyzer/tests/test_context_support.py +++ b/presidio-analyzer/tests/test_context_support.py @@ -62,8 +62,8 @@ def sentences_with_context(request): test_items.append((lines[i+1].strip(), recognizer, [lines[i].strip()])) - # Currently we have 20 sentences, this is a sanity - if not len(test_items) == 20: + # Currently we have 25 sentences, this is a sanity + if not len(test_items) == 25: raise ValueError("context sentences not as expected") request.cls.context_sentences = test_items From 956ae904f2c27a69b360fb65cc4cd483212ba4e2 Mon Sep 17 00:00:00 2001 From: balteravishay Date: Wed, 1 May 2019 12:07:12 +0300 Subject: [PATCH 19/75] Revert yaml (#137) * Revert "Build deps - adding build number to python&golang dependency containers (#122)" This reverts commit c05477058297430d819d34ea1fa433f1a967bc22. --- Dockerfile.golang.base | 3 +-- Makefile | 20 ++++++------------- docs/development.md | 27 ++++++++------------------ docs/install.md | 3 +-- pipelines/presidio-golang-deps-CI.yaml | 27 -------------------------- pipelines/presidio-python-deps-CI.yaml | 26 ------------------------- presidio-analyzer/Dockerfile | 3 +-- 7 files changed, 17 insertions(+), 92 deletions(-) delete mode 100644 pipelines/presidio-golang-deps-CI.yaml delete mode 100644 pipelines/presidio-python-deps-CI.yaml diff --git a/Dockerfile.golang.base b/Dockerfile.golang.base index 1824e6b91..e23acccd4 100644 --- a/Dockerfile.golang.base +++ b/Dockerfile.golang.base @@ -1,7 +1,6 @@ ARG REGISTRY=presidio.azurecr.io -ARG PRESIDIO_DEPS_LABEL=latest -FROM ${REGISTRY}/presidio-golang-deps:${PRESIDIO_DEPS_LABEL} +FROM ${REGISTRY}/presidio-golang-deps WORKDIR $GOPATH/src/github.com/Microsoft/presidio ADD . $GOPATH/src/github.com/Microsoft/presidio diff --git a/Makefile b/Makefile index 6271e3c52..f69b68ec6 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,6 @@ GOLANG_BASE = presidio-golang-base GIT_TAG = $(shell git describe --tags --always 2>/dev/null) VERSION ?= ${GIT_TAG} PRESIDIO_LABEL := $(if $(PRESIDIO_LABEL),$(PRESIDIO_LABEL),$(VERSION)) -PRESIDIO_DEPS_LABEL := $(if $(PRESIDIO_DEPS_LABEL),$(PRESIDIO_DEPS_LABEL),'latest') LDFLAGS += -X github.com/Microsoft/presidio/pkg/version.Version=$(VERSION) CX_OSES = linux windows darwin @@ -28,14 +27,14 @@ $(BINS): vendor .PHONY: docker-build-deps docker-build-deps: - -docker pull $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) - -docker pull $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) - docker build -t $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) -f Dockerfile.golang.deps . - docker build -t $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) -f Dockerfile.python.deps . + -docker pull $(DOCKER_REGISTRY)/$(GOLANG_DEPS) + -docker pull $(DOCKER_REGISTRY)/$(PYTHON_DEPS) + docker build -t $(DOCKER_REGISTRY)/$(GOLANG_DEPS) -f Dockerfile.golang.deps . + docker build -t $(DOCKER_REGISTRY)/$(PYTHON_DEPS) -f Dockerfile.python.deps . .PHONY: docker-build-base docker-build-base: - docker build --build-arg REGISTRY=$(DOCKER_REGISTRY) --build-arg PRESIDIO_DEPS_LABEL=$(PRESIDIO_DEPS_LABEL) -t $(DOCKER_REGISTRY)/$(GOLANG_BASE) -f Dockerfile.golang.base . + docker build --build-arg REGISTRY=$(DOCKER_REGISTRY) -t $(DOCKER_REGISTRY)/$(GOLANG_BASE) -f Dockerfile.golang.base . # To use docker-build, you need to have Docker installed and configured. You should also set @@ -45,20 +44,13 @@ docker-build: docker-build-base docker-build: $(addsuffix -dimage,$(IMAGES)) %-dimage: - docker build $(DOCKER_BUILD_FLAGS) --build-arg REGISTRY=$(DOCKER_REGISTRY) --build-arg VERSION=$(VERSION) --build-arg PRESIDIO_DEPS_LABEL=$(PRESIDIO_DEPS_LABEL) -t $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) -f $*/Dockerfile . + docker build $(DOCKER_BUILD_FLAGS) --build-arg REGISTRY=$(DOCKER_REGISTRY) --build-arg VERSION=$(VERSION) -t $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) -f $*/Dockerfile . # You must be logged into DOCKER_REGISTRY before you can push. .PHONY: docker-push-deps docker-push-deps: - docker push $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) - docker push $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) -ifneq ($(PRESIDIO_DEPS_LABEL),latest) - docker image tag $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) $(DOCKER_REGISTRY)/$(PYTHON_DEPS):latest - docker image tag $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) $(DOCKER_REGISTRY)/$(GOLANG_DEPS):latest docker push $(DOCKER_REGISTRY)/$(PYTHON_DEPS):latest docker push $(DOCKER_REGISTRY)/$(GOLANG_DEPS):latest -endif - # push with the given label .PHONY: docker-push diff --git a/docs/development.md b/docs/development.md index 44edf1422..7dbe5f9c7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -34,12 +34,16 @@ 6. Install the Python packages for the analyzer in the `presidio-analyzer` folder ```sh - $ pip3 install cython $ pip3 install -r requirements.txt $ pip3 install -r requirements-dev.txt ``` - + **Note:** If you encounter errors with `pyre2` than install `cython` first + + ```sh + $ pip3 install cython + ``` + 7. Install [tesseract](https://github.com/tesseract-ocr/tesseract/wiki) OCR framework. 8. Protobuf generator tools (Optional) @@ -60,24 +64,9 @@ ## Development notes -- Optional: set version numbers (docker labels): - ```sh - $ export PRESIDIO_LABEL=[presidio label] - $ export PRESIDIO_DEPS_LABEL=[presidio dependencies label] - ``` - if not set, they will default to 'latest', and may cause consistency issues when pushed to a shared registry. - -- Optional - set registry name: - - ```sh - $ export DOCKER_REGISTRY=[registry login server] - ``` - if not set, the default is presidio's internal registry. - - Build the bins with `make build` -- Build the base containers with `make docker-build-deps DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_DEPS_LABEL=${PRESIDIO_DEPS_LABEL}` -- Build the the Docker image with `make docker-build DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_DEPS_LABEL=${PRESIDIO_DEPS_LABEL} PRESIDIO_LABEL=${PRESIDIO_LABEL}` -- Push the Docker images with `make docker-push DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_LABEL=${PRESIDIO_LABEL}` +- Build the the Docker image with `make docker-build` +- Push the Docker images with `make docker-push` - Run the tests with `make test` - Adding a file in go requires the `make go-format` command before running and building the service. - Run functional tests with `make test-functional` diff --git a/docs/install.md b/docs/install.md index 591351d59..fca689785 100644 --- a/docs/install.md +++ b/docs/install.md @@ -9,8 +9,7 @@ You can install Presidio as a service in [Kubernetes](https://kubernetes.io/) or $ export DOCKER_REGISTRY=presidio $ export PRESIDIO_LABEL=latest -$ export PRESIDIO_DEPS_LABEL=latest -$ make DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_LABEL=${PRESIDIO_LABEL} PRESIDIO_DEPS_LABEL=${PRESIDIO_DEPS_LABEL} docker-build-deps +$ make DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_LABEL=${PRESIDIO_LABEL} docker-build-deps $ make DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_LABEL=${PRESIDIO_LABEL} docker-build # Run the containers diff --git a/pipelines/presidio-golang-deps-CI.yaml b/pipelines/presidio-golang-deps-CI.yaml deleted file mode 100644 index 01f86b80b..000000000 --- a/pipelines/presidio-golang-deps-CI.yaml +++ /dev/null @@ -1,27 +0,0 @@ -trigger: # Build triggers - batch: true # batch changes if true, start a new build for every push if false - branches: - include: # branch names which will trigger a build - - development - paths: - include: # file paths which must match to trigger a build - - /Dockerfile.golang.deps - - /Gopkg.lock - - /Gopkg.toml - -jobs: # A job is a collection of steps to be run by an agent -- job: BuildGolangDeps - timeoutInMinutes: 30 # how long to run the job before automatically cancelling - pool: - vmImage: 'ubuntu-latest' - - steps: - - task: Docker@2 - displayName: 'Build and Push' - inputs: - containerRegistry: $(registry) # input registry name - repository: presidio-golang-deps # name of image to build - Dockerfile: Dockerfile.golang.deps # dockerfile path - tags: | # image tags - $(Build.BuildId) - latest diff --git a/pipelines/presidio-python-deps-CI.yaml b/pipelines/presidio-python-deps-CI.yaml deleted file mode 100644 index 9c7a27dd6..000000000 --- a/pipelines/presidio-python-deps-CI.yaml +++ /dev/null @@ -1,26 +0,0 @@ -trigger: # Build triggers - batch: true # batch changes if true, start a new build for every push if false - branches: - include: # branch names which will trigger a build - - development - paths: - include: # file paths which must match to trigger a build - - /Dockerfile.python.deps - - /presidio-analyzer/requirements.txt - -jobs: # A job is a collection of steps to be run by an agent -- job: BuildPythonDeps - timeoutInMinutes: 120 # how long to run the job before automatically cancelling - pool: - vmImage: 'ubuntu-latest' - - steps: - - task: Docker@2 - displayName: 'Build and Push' - inputs: - containerRegistry: $(registry) # input registry name - repository: presidio-python-deps # name of image to build - Dockerfile: Dockerfile.python.deps # dockerfile path - tags: | # image tags - $(Build.BuildId) - latest \ No newline at end of file diff --git a/presidio-analyzer/Dockerfile b/presidio-analyzer/Dockerfile index 8d3f9b17a..5a71b5865 100644 --- a/presidio-analyzer/Dockerfile +++ b/presidio-analyzer/Dockerfile @@ -1,7 +1,6 @@ ARG REGISTRY=presidio.azurecr.io -ARG PRESIDIO_DEPS_LABEL=latest -FROM ${REGISTRY}/presidio-python-deps:${PRESIDIO_DEPS_LABEL} +FROM ${REGISTRY}/presidio-python-deps ARG NAME=presidio-analyzer WORKDIR /usr/bin/${NAME} From ababc0c0c95dd8f93504ba5c829173c1ff5883df Mon Sep 17 00:00:00 2001 From: Itye Richter Date: Wed, 1 May 2019 12:10:31 +0300 Subject: [PATCH 20/75] Change deployment script to mcr (#130) * updated docker file to support correct spacy version * changed registry to mcr * fixed the mcr url --- Dockerfile.python.deps | 2 +- deployment/deploy-presidio.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile.python.deps b/Dockerfile.python.deps index 6814b1dcd..96025f341 100644 --- a/Dockerfile.python.deps +++ b/Dockerfile.python.deps @@ -13,4 +13,4 @@ RUN apk --update add --no-cache g++ && \ cd re2 && make install && cd .. && rm -rf re2 && rm re2.tar.gz && \ pip install --no-cache-dir cython && \ pip install --no-cache-dir -r requirements.txt && \ - apk del build_deps \ No newline at end of file + apk del build_deps diff --git a/deployment/deploy-presidio.sh b/deployment/deploy-presidio.sh index 021a9998a..cce9989fd 100755 --- a/deployment/deploy-presidio.sh +++ b/deployment/deploy-presidio.sh @@ -1,5 +1,5 @@ #!/bin/bash -REGISTRY=${1:-presidio.azurecr.io} +REGISTRY=${1:-mcr.microsoft.com} TAG=${2:-latest} helm install --name redis stable/redis --set usePassword=false,rbac.create=true --namespace presidio-system --wait helm install --name presidio-demo --set registry=$REGISTRY,tag=$TAG ../charts/presidio --namespace presidio From 5e8be072f68d2e9201b72e7f9c3ad2fd56e5fcc4 Mon Sep 17 00:00:00 2001 From: Omri Mendels Date: Wed, 1 May 2019 12:31:37 +0300 Subject: [PATCH 21/75] Updated readme and documentation(#125) --- AUTHORS | 7 + README.MD | 241 +++++++++++------- docs/assets/before-after.png | Bin 0 -> 75476 bytes docs/assets/findings.png | Bin 0 -> 84105 bytes docs/assets/ocr-example.png | Bin 0 -> 352395 bytes docs/custom_fields.md | 136 ++++++++++ docs/development.md | 24 +- docs/field_types.md | 11 +- docs/index.md | 19 +- docs/install.md | 32 +-- docs/overview.md | 83 +++--- ...rial_framework.md => tutorial_analyzer.md} | 25 +- docs/tutorial_service.md | 72 ++++-- presidio-analyzer/README.MD | 39 +++ 14 files changed, 498 insertions(+), 191 deletions(-) create mode 100644 docs/assets/before-after.png create mode 100644 docs/assets/findings.png create mode 100644 docs/assets/ocr-example.png create mode 100644 docs/custom_fields.md rename docs/{tutorial_framework.md => tutorial_analyzer.md} (52%) create mode 100644 presidio-analyzer/README.MD diff --git a/AUTHORS b/AUTHORS index d502c7935..874f9f7a0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,4 +1,11 @@ +Original authors: - Tomer Rosenthal - @torosent - Limor Lahiani - @limorl - Ilana Kantorov - @ilanak - Elad Iwanir - @eladiw + +Current authors: +- Elad Iwanir - @eladiw +- Itye Richter - @itye-msft +- Avishay Balter - @balteravishay +- Omri Mendels - @omri374 diff --git a/README.MD b/README.MD index 921f365bf..b622110ce 100644 --- a/README.MD +++ b/README.MD @@ -1,54 +1,80 @@ +# Presidio - Data protection and anonymization API + +**Context aware, pluggable and customizable PII anonymization service for text and images.** + +--- + [![Build status](https://dev.azure.com/csedevil/Presidio/_apis/build/status/Presidio-CI)](https://dev.azure.com/csedevil/Presidio/_build/latest?definitionId=48) [![Go Report Card](https://goreportcard.com/badge/github.com/Microsoft/presidio)](https://goreportcard.com/report/github.com/Microsoft/presidio) [![MIT license](https://img.shields.io/badge/license-MIT-brightgreen.svg)](http://opensource.org/licenses/MIT) -![](https://img.shields.io/github/release/Microsoft/presidio.svg) +![Release](https://img.shields.io/github/release/Microsoft/presidio.svg) ---- +## Description -# Presidio - Data Protection API +Presidio *(Origin from Latin praesidium ‘protection, garrison’)* helps to ensure sensitive text is properly managed and governed. It provides fast ***analytics*** and ***anonymization*** for sensitive text such as credit card numbers, names, locations, social security numbers, bitcoin wallets, US phone numbers and financial data. +Presidio analyzes the text using predefined or custom recognizers to identify entities, patterns, formats, and checksums with relevant context. Presidio leverages docker and kubernetes for workloads at scale. -**Context aware, pluggable and customizable data protection and PII anonymization service for text and images** +### Why use presidio? -## Description +Presidio can be integrated into any data pipeline for intelligent PII scrubbing. It is open-source, transparent and scalable. Additionally, PII anonymization use-cases often require a different set of PII entities to be detected, some of which are domain or business specific. Presidio allows you to **customize or add new PII recognizers** via API or code to best fit your anonymization needs. -Presidio *(Origin from Latin praesidium ‘protection, garrison’)* helps to ensure sensitive text is properly managed and governed. It provides fast ***analytics*** and ***anonymization*** for sensitive text such as credit card numbers, bitcoin wallets, names, locations, social security numbers, US phone numbers and financial data. -Presidio analyzes the text using predefined analyzers to identify patterns, formats, and checksums with relevant context. +:warning: Presidio can help identify sensitive/PII data in un/structured text. However, because Presidio is using trained ML models, there is no guarantee that Presidio will find all sensitive information. Consequently, additional systems and protections should be employed. -You can find a more detailed list [here](https://microsoft.github.io/presidio/field_types.html) +## Demo -:warning: ***Presidio can help identify sensitive/PII data in un/structured text. However, because Presidio is using trained ML models, there is no guarantee that Presidio will find all sensitive information. Consequently, additional systems and protections should be employed.*** +[Try Presidio with your own data](https://presidio-demo.westeurope.cloudapp.azure.com/) ## Features -***Free text anonymization*** +***Unstsructured text anonymization*** + +Presidio automatically detects Personal-Identifiable Information (PII) in unstructured text, annonymizes it based on one or more anonymization mechanisms, and returns a string with no personal identifiable data. +For example: + +[![Image1](docs/assets/before-after.png)](docs/assets/before-after.png) + +For each PII entity, presidio returns a confidence score: + +[![Image2](docs/assets/findings.png)](docs/assets/findings.png) + +***Text anonymization in images*** (beta) -[![Image1](https://user-images.githubusercontent.com/17064840/50557166-2048ca80-0ceb-11e9-9153-d39a3f507d32.png)](https://user-images.githubusercontent.com/17064840/50557166-2048ca80-0ceb-11e9-9153-d39a3f507d32.png) +Presidio uses OCR to detect text in images. It further allows the redaction of the text from the original image. -***Text anonymization in images*** +[![Image3](docs/assets/ocr-example.png)](docs/assets/ocr-example.png) -[![Image2](https://user-images.githubusercontent.com/17064840/50557215-bc72d180-0ceb-11e9-8c92-4fbc01bbcb2a.png)](https://user-images.githubusercontent.com/17064840/50557215-bc72d180-0ceb-11e9-8c92-4fbc01bbcb2a.png) +## Learn more +More information could be found in the [Presidio documentation](docs/index.md). -* Text analytics - Predefined analyzers with customizable fields. -* Probability scores - Customize the sensitive text detection threshold. -* Anonymization - Anonymize sensitive text and images -* Workflow and pipeline integration - Monitor your data with periodic scans or events of/from: - 1. Storage solutions - * Azure Blob Storage - * S3 - * Google Cloud Storage - 2. Databases - * MySQL - * PostgreSQL - * Sql Server - * Oracle - 3. Streaming platforms - * Kafka - * Azure Events Hubs +- [Installation guide](docs/install.md) +- [Supported field types](docs/field_types.md) +- [Database and storage scanner](docs/tutorial_scheduler.md) +- [Architecture](docs/design.md) +- [Setting up a development environment](docs/development.md) +- [Adding new PII recognizers](docs/custom_fields.md) - and export the results for further analytics: - 1. Storage solutions - 2. Databases - 3. Streaming platforms +## Input and output + +Presidio accepts multiple sources and targets for data annonymization. Specifically: + +1. Storage solutions + * Azure Blob Storage + * S3 + * Google Cloud Storage + +2. Databases + * MySQL + * PostgreSQL + * Sql Server + * Oracle + +3. Streaming platforms + * Kafka + * Azure Events Hubs + +4. REST requests + +It then can export the results to file storage, databases or streaming platforms. ## The Technology Stack @@ -58,109 +84,138 @@ Presidio leverages: * [spaCy](https://spacy.io/) * [Redis](https://redis.io/) * [GRPC](https://grpc.io) - -The [design document](https://microsoft.github.io/presidio/design.html) introduces Presidio concepts and architecture. +* [re2](https://github.com/google/re2) ## Quickstart -1. Install [Presidio](https://microsoft.github.io/presidio/install.html) -2. Create a Presidio project -3. Start using the Presidio analyze and anonymize services - -## Single click deployment using the default values - -The script will install Presidio on your Kubenetes cluster. Prerequesites: +1. Install [Presidio](docs/install.html) +2. Decide on a name for your Presidio project. In the following examples the project name is ``. +3. Start using the Presidio analyze and anonymize services. -1. A Kubernetes cluster. -2. `kubectl` installed - * verify you can communicate with the cluster by running: - ``` sh - kubectl version - ``` -3. Local `helm` client. -4. Recent presidio repo is cloned on your local machine. - -### Installation Steps - -1. Navigate into `\deployment` from command line. - -2. If You have helm installed, but havn't run `helm init`, execute [deploy-helm.sh](deploy-helm.sh) in the command line. It will install `tiller` (helm server side) on your cluster. +## Samples -3. Grant the Kubernetes cluster access to the container registry - * If using Azure Kubernetes Service, follow these instructions to [grant the AKS cluster access to the ACR.](https://docs.microsoft.com/en-us/azure/container-registry/container-registry-auth-aks) +**Note:** Examples are made with [HTTPie](https://httpie.org/) -4. If you already have `helm` and `tiller` configured, or if you installed it in the previous step, execute [deploy-presidio.sh](deploy-presidio.sh) in the command line as follows: +***Sample 1:*** Simple text analysis ```sh -deploy-presidio.sh +echo -n '{"text":"John Smith lives in New York. We met yesterday morning in Seattle. I called him before on (212) 555-1234 to verify the appointment. He also told me that his drivers license is AC333991", "analyzeTemplate":{"allFields":true} }' | http /api/v1/projects//analyze ``` -The script will install Presidio on your cluster using the default values. ->Note: You can edit the file to use your own container registry and image. - -## Samples +--- +***Sample 2:*** Create reusable templates +1. Create an analyzer template: -**Note:** Examples are made with [HTTPie](https://httpie.org/) + ```sh + echo -n '{"allFields":true}' | http /api/v1/templates//analyze/ + ``` -***Sample 1*** +2. Analyze text: -1. Analyze text ```sh - $ echo -n '{"text":"John Smith lives in New York. We met yesterday morning in Seattle. I called him before on (212) 555-1234 to verify the appointment. He also told me that his drivers license is AC333991", "analyzeTemplate":{"allFields":true} }' | http /api/v1/projects//analyze + echo -n '{"text":"my credit card number is 2970-84746760-9907 345954225667833 4961-2765-5327-5913", "AnalyzeTemplateId":"" }' | http /api/v1/projects//analyze ``` -***Sample 2*** +--- +***Sample 3:*** Detect specific entities -You can also create reusable templates +1. Create an analyzer project with a specific set of entities: -1. Create an analyzer project ```sh - $ echo -n '{"allFields":true}' | http /api/v1/templates//analyze/ + echo -n '{"fields":[{"name":"PHONE_NUMBER"}, {"name":"LOCATION"}, {"name":"DATE_TIME"}]}' | http /api/v1/templates//analyze/ ``` -2. Analyze text +2. Analyze text: + ```sh - $ echo -n '{"text":"my credit card number is 2970-84746760-9907 345954225667833 4961-2765-5327-5913", "AnalyzeTemplateId":"" }' | http /api/v1/projects//analyze + echo -n '{"text":"We met yesterday morning in Seattle and his phone number is (212) 555 1234", "AnalyzeTemplateId":"" }' | http /api/v1/projects//analyze ``` -***Sample 3*** +--- +***Sample 4:*** Custom anonymization + +1. Create an anonymizer template (This template replaces values in PHONE_NUMBER and redacts CREDIT_CARD): -1. Create an analyzer project ```sh - $ echo -n '{"fields":[{"name":"PHONE_NUMBER"}, {"name":"LOCATION"}, {"name":"DATE_TIME"}]}' | http /api/v1/templates//analyze/ + echo -n '{"fieldTypeTransformations":[{"fields":[{"name":"PHONE_NUMBER"}],"transformation":{"replaceValue":{"newValue":"\u003cphone-number\u003e"}}},{"fields":[{"name":"CREDIT_CARD"}],"transformation":{"redactValue":{}}}]}' | http /api/v1/templates//anonymize/ ``` -2. Analyze text +2. Anonymize text: + ```sh - $ echo -n '{"text":"We met yesterday morning in Seattle and his phone number is (212) 555 1234", "AnalyzeTemplateId":"" }' | http /api/v1/projects//analyze + echo -n '{"text":"my phone number is 057-555-2323 and my credit card is 4961-2765-5327-5913", "AnalyzeTemplateId":"", "AnonymizeTemplateId":"" }' | http /api/v1/projects//anonymize ``` -***Sample 4*** +--- +***Sample 5:*** Add custom PII entity recognizer + +This sample shows how to add an new regex recognizer via API. +This simple recognizer identifies the word "rocket" in a text and tags it as a "ROCKET entity. + +1. Add a custom recognizer -1. Create an anonymizer template (This template replaces values in PHONE_NUMBER and redacts CREDIT_CARD) ```sh - $ echo -n '{"fieldTypeTransformations":[{"fields":[{"name":"PHONE_NUMBER"}],"transformation":{"replaceValue":{"newValue":"\u003cphone-number\u003e"}}},{"fields":[{"name":"CREDIT_CARD"}],"transformation":{"redactValue":{}}}]}' | http /api/v1/templates//anonymize/ + echo -n {"value": {"entity": "ROCKET","language": "en", "patterns": [{"name": "rocket-regex","regex": "\\W*(rocket)\\W*","score": 1}]}} | http /api/v1/analyzer/recognizers/rocket ``` -2. Anonymize text +2. Analyze text: + ```sh - $ echo -n '{"text":"my phone number is 057-555-2323 and my credit card is 4961-2765-5327-5913", "AnalyzeTemplateId":"", "AnonymizeTemplateId":"" }' | http /api/v1/projects//anonymize + echo -n '{"text":"They sent a rocket to the moon!", "analyzeTemplate":{"allFields":true} }' | http /api/v1/projects//analyze ``` -***Sample 5 (Image anonymization)*** +--- +***Sample 6:*** Image anonymization + +1. Create an anonymizer image template (This template redacts values with black color): -1. Create an anonymizer image template (This template redact values with black color) ```sh - $ echo -n '{"fieldTypeGraphics":[{"graphic":{"fillColorValue":{"blue":0,"red":0,"green":0}}}]}' | http /api/v1/templates//anonymize-image/ + echo -n '{"fieldTypeGraphics":[{"graphic":{"fillColorValue":{"blue":0,"red":0,"green":0}}}]}' | http /api/v1/templates//anonymize-image/ ``` -2. Anonymize image +2. Anonymize image: + ```sh - $ http -f POST /api/v1/projects//anonymize-image detectionType='OCR' analyzeTemplateId='' anonymizeImageTemplateId='' imageType='image/png' file@~/test-ocr.png > test-output.png + http -f POST /api/v1/projects//anonymize-image detectionType='OCR' analyzeTemplateId='' anonymizeImageTemplateId='' imageType='image/png' file@~/test-ocr.png > test-output.png ``` -### Current Features Status +--- + +## Single click deployment using the default values + +The script will install Presidio on your Kubenetes cluster. Prerequesites: + +1. A Kubernetes cluster with [RBAC](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) enabled +2. `kubectl` installed + * verify you can communicate with the cluster by running: + + ``` sh + kubectl version + ``` + +3. Local `helm` client. +4. Recent presidio repo is cloned on your local machine. + +### Installation Steps + +1. Navigate into `\deployment` from command line. + +2. If You have helm installed, but havn't run `helm init`, execute [deploy-helm.sh](deploy-helm.sh) in the command line. It will install `tiller` (helm server side) on your cluster, and grant it sufficient permissions. + +3. Grant the Kubernetes cluster access to the container registry + * If using Azure Kubernetes Service, follow these instructions to [grant the AKS cluster access to the ACR.](https://docs.microsoft.com/en-us/azure/container-registry/container-registry-auth-aks) + +4. If you already have `helm` and `tiller` configured, or if you installed it in the previous step, execute [deploy-presidio.sh](deploy-presidio.sh) in the command line as follows: + +```sh +deploy-presidio.sh +``` + +The script will install Presidio on your cluster using the default values. +>Note: You can edit the file to use your own container registry and image. + +### Current input/output components status | Module | Feature | Status | |---------------------|----------------------|------------------------| @@ -185,8 +240,8 @@ You can also create reusable templates | Datasink (output) | Google Cloud Storage | :x: | * :white_check_mark: - Working -* :large_orange_diamond: - Partially working -* :x: - Not working yet but we are on it :wink: +* :large_orange_diamond: - Partially supported (alpha) +* :x: - Not supported yet # Contributing @@ -200,4 +255,4 @@ provided by the bot. You will only need to do this once across all repos using o This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or -contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. \ No newline at end of file +contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. diff --git a/docs/assets/before-after.png b/docs/assets/before-after.png new file mode 100644 index 0000000000000000000000000000000000000000..fdb4a5e35ea18b4c19787140da21d79cd60ac716 GIT binary patch literal 75476 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%0D)Q^dQd_Ndmg<#|k6vY)}(Yfqu4 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 y|IPg|Zb#%FWxH|<{j;7q46xHp;smZMAl-oB3eJ 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 literal 0 HcmV?d00001 diff --git a/docs/assets/ocr-example.png b/docs/assets/ocr-example.png new file mode 100644 index 0000000000000000000000000000000000000000..83f06d6c0cf2db092b5d888392345dbb362a145c GIT binary patch literal 352395 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

    u3hNKxC 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 z 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°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

    ->@UmPGKO~^z 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

    brkbwsttnhO`z$rey%w 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 Vi=?;k6CwDE/api/v1/analyzer/recognizers/ + ``` + + b. Getting all recognizers + + ```sh + GET /api/v1/analyzer/recognizers + ``` + + c. Creating a new recoginzer + + ```sh + POST /api/v1/analyzer/recognizers/ + ``` + + ### Request structure: + + #### Example json: + + ```json + { + "value": { + "entity": "ROCKET", + "language": "en", + "patterns": [ + { + "name": "rocket-recognizer", + "regex": "\\W*(rocket)\\W*", + "score": 1 + }, + { + "name": "projectile-recognizer", + "regex": "\\W*(projectile)\\W*", + "score": 1 + } + ] + } + } + ``` + + #### Description: + + | Field | Description | Optional | + | -------------- | ----------------------------------------------------------------- | ---------- | + | `value` | The recognizer json object | no | + + Recognizer format: + + | Field | Description | Optional | + | -------------- | ----------------------------------------------------------------- | ---------- | + | `entity` | The name of the new field. e.g. 'ROCKET' | no | + | `language` | The supported language | no | + | `patterns` | A list of regular expressions objects | yes | + | `blacklist` | A list of words to be identified as PII entities e.g. ["Mr","Mrs","Ms","Miss"] | yes | + | `contextPhrases` | A list of words to be used for improving confidence, in case they are found in vicinity to an identified entity e.g. ["credit-card","credit","cc","amex"] | yes | + + A request should provide either `patterns` or `blacklist` as input. + + + Regular expression format: + + | Field | Description | Optional | + | -------------- | ----------------------------------------------------------------- | ---------- | + | `name` | The name of this pattern | no | + | `regex` | A regular expression | no | + | `score` | The score given to entities detected by this recognizer | no | + + d. Update a recoginzer + + ```sh + PUT /api/v1/analyzer/recognizers/ + ``` + + Payload is similar to the one described in `Creating new recognizer`. + + e. Delete a recoginzer + + ```sh + DELETE /api/v1/analyzer/recognizers/ + ``` + + f. Using the custom field + + After creating a new recognizer, either explicitly state in the templates the newly added entity name, or set allFields to true. For example: + + i. `allFields=True`: + + ```sh + echo -n '{"text":"They sent a rocket to the moon!", "analyzeTemplate":{"allFields":true} }' | http /api/v1/projects//analyze + ``` + + ii. Specifically define the recognizers to be used: + + ```sh + echo -n '{"text":"They sent a rocket to the moon!", "analyzeTemplate":{"fields":[{"name": "ROCKET"}]}}' | http /api/v1/projects//analyze + ``` + +2. Custom recognizer by code + + Code based recognizers are written in Python and are a part of the [presidio-analyzer](../presidio-analyzer) module. The main modules in `presidio-analyzer` are the `AnalyzerEngine` and the `RecognizerRegistry`. The `AnalyzerEngine` is in charge of calling each requested recognizer. the `RecognizerRegistry` is in charge of providing the list of predefined and custom recognizers for analysis. + + In order to implement a new recognizer by code, follow these two steps: + + a. Implement the abstract recognizer class: + + Create a new Python class which implements [LocalRecognizer](../presidio-analyzer/analyzer/local_recognizer.py). `LocalRecognizer` implements the base [EntityRecognizer](../presidio-analyzer/analyzer/entity_recognizer.py) class. All local recognizers run locally together with all other predefined recognizers as a part of the `presidio-analyzer` Python process. In contrast, `RemoteRecognizer` is a placeholder for recognizers that are external to the `presidio-analyzer` service, for example on a different microservice. + + The `EntityRecognizer` abstract class requires the implementation the following methods: + + i. initializing a model. Occurs when the `presidio-analyzer` process starts: + + ```python + def load(self) + ``` + + ii. analyze: The main function to be called for getting entities out of the new recognizer: + + ```python + def analyze(self, text, entities, nlp_artifacts): + ``` + + The `analyze` method should return a list of [RecognizerResult](../presidio-analyzer/analyzer/recognizer_result.py). Refer to the [code documentation](../presidio-analyzer/analyzer/entity_recognizer.py) for more information. + + b. Reference and add the new class to the `RecognizerRegistry` module, in the `load_predefined_recognizers` method, which registers all code based recognizers. + + + \ No newline at end of file diff --git a/docs/development.md b/docs/development.md index 7dbe5f9c7..fe618ef41 100644 --- a/docs/development.md +++ b/docs/development.md @@ -10,7 +10,7 @@ 2. Redis ```sh - $ docker run --name dev-redis -d -p 6379:6379 redis + docker run --name dev-redis -d -p 6379:6379 redis ``` 3. Install go 1.11 and Python 3.7 @@ -18,24 +18,24 @@ 4. Install the golang packages via [dep](https://github.com/golang/dep/releases) ```sh - $ dep ensure + dep ensure ``` 5. Build and install [re2](https://github.com/google/re2) ```sh - $ re2_version="2018-12-01" - $ wget -O re2.tar.gz https://github.com/google/re2/archive/${re2_version}.tar.gz - $ mkdir re2 - $ tar --extract --file "re2.tar.gz" --directory "re2" --strip-components 1 - $ cd re2 && make install + re2_version="2018-12-01" + wget -O re2.tar.gz https://github.com/google/re2/archive/${re2_version}.tar.gz + mkdir re2 + tar --extract --file "re2.tar.gz" --directory "re2" --strip-components 1 + cd re2 && make install ``` 6. Install the Python packages for the analyzer in the `presidio-analyzer` folder ```sh - $ pip3 install -r requirements.txt - $ pip3 install -r requirements-dev.txt + pip3 install -r requirements.txt + pip3 install -r requirements-dev.txt ``` **Note:** If you encounter errors with `pyre2` than install `cython` first @@ -55,11 +55,11 @@ To generate proto files, clone [presidio-genproto](https://github.com/Microsoft/presidio-genproto) and run the following commands in `$GOPATH/src/github.com/Microsoft/presidio-genproto/src` folder ```sh - $ python -m grpc_tools.protoc -I . --python_out=../python --grpc_python_out=../python ./*.proto + python -m grpc_tools.protoc -I . --python_out=../python --grpc_python_out=../python ./*.proto ``` ```sh - $ protoc -I . --go_out=plugins=grpc:../golang ./*.proto + protoc -I . --go_out=plugins=grpc:../golang ./*.proto ``` ## Development notes @@ -95,4 +95,4 @@ ```sh wrk -t2 -c2 -d30s -s post.lua http:///api/v1/projects//analyze - ``` + ``` \ No newline at end of file diff --git a/docs/field_types.md b/docs/field_types.md index 33efc1809..8684a67a6 100644 --- a/docs/field_types.md +++ b/docs/field_types.md @@ -1,4 +1,10 @@ -

    Supported Fields

    +# Supported Fields +Presidio contains predefined recognizers for PII entities (fields). This page describes the different entities Presidio can detect and the method Presidio employs to detect those. + +In addition, Presidio allows you to add custom fields by API or code. For more information, refer to the [custom fields documentation]("custom_fields.md"). + + +

    Global

    @@ -240,4 +246,7 @@ For example: ```json "analyzeTemplate":{"allFields":true} ``` +
    +

    Custom fields

    +Presidio supports custom fields. Refer to the custom fields documentation to learn more.
    \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 366b7121f..3e6380f58 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,19 +1,18 @@ # Presidio Documentation Everything you need to know about Presidio. -Browse the docs here: [https://microsoft.github.io/presidio/](https://microsoft.github.io/presidio/) ## Getting Started -New to Presidio or to DLP systems in general? Well, you came to the right place: read this material to quickly get up and running. +New to Presidio? Read this material to quickly get up and running. ## Table of Contents -- [Presidio overview](overview.md) -- [Install Guide](install.md) -- [Supported Field Types](field_types.md) -- [Framework Tutorial](tutorial_framework.md) -- [Service Tutorial](tutorial_service.md) -- [Database and Storage scanner](tutorial_scheduler.md) -- [Design](design.md) -- [Develop Presidio](development.md) \ No newline at end of file +- [Installation guide](install.md) +- [Supported field types](field_types.md) +- [Database and storage scanner](tutorial_scheduler.md) +- [Architecture](design.md) +- [Setting up a development environment](development.md) +- [Analyzer service tutorial](tutorial_analyzer.md) +- [Calling the different services](tutorial_service.md) +- [Adding custom fields](custom_fields.md) diff --git a/docs/install.md b/docs/install.md index fca689785..81a62b43b 100644 --- a/docs/install.md +++ b/docs/install.md @@ -7,21 +7,21 @@ You can install Presidio as a service in [Kubernetes](https://kubernetes.io/) or ```sh # Build the images -$ export DOCKER_REGISTRY=presidio -$ export PRESIDIO_LABEL=latest -$ make DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_LABEL=${PRESIDIO_LABEL} docker-build-deps -$ make DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_LABEL=${PRESIDIO_LABEL} docker-build +export DOCKER_REGISTRY=presidio +export PRESIDIO_LABEL=latest +make DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_LABEL=${PRESIDIO_LABEL} docker-build-deps +make DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_LABEL=${PRESIDIO_LABEL} docker-build # Run the containers -$ docker network create mynetwork -$ docker run --rm --name redis --network mynetwork -d -p 6379:6379 redis -$ docker run --rm --name presidio-analyzer --network mynetwork -d -p 3000:3000 -e GRPC_PORT=3000 ${DOCKER_REGISTRY}/presidio-analyzer:${PRESIDIO_LABEL} -$ docker run --rm --name presidio-anonymizer --network mynetwork -d -p 3001:3001 -e GRPC_PORT=3001 ${DOCKER_REGISTRY}/presidio-anonymizer:${PRESIDIO_LABEL} -$ docker run --rm --name presidio-recognizers-store --network mynetwork -d -p 3004:3004 -e GRPC_PORT=3004 -e REDIS_URL=redis:6379 ${DOCKER_REGISTRY}/presidio-recognizers-store:${PRESIDIO_LABEL} +docker network create mynetwork +docker run --rm --name redis --network mynetwork -d -p 6379:6379 redis +docker run --rm --name presidio-analyzer --network mynetwork -d -p 3000:3000 -e GRPC_PORT=3000 ${DOCKER_REGISTRY}/presidio-analyzer:${PRESIDIO_LABEL} +docker run --rm --name presidio-anonymizer --network mynetwork -d -p 3001:3001 -e GRPC_PORT=3001 ${DOCKER_REGISTRY}/presidio-anonymizer:${PRESIDIO_LABEL} +docker run --rm --name presidio-recognizers-store --network mynetwork -d -p 3004:3004 -e GRPC_PORT=3004 -e REDIS_URL=redis:6379 ${DOCKER_REGISTRY}/presidio-recognizers-store:${PRESIDIO_LABEL} -$ sleep 30 # Wait for the analyzer model to load -$ docker run --rm --name presidio-api --network mynetwork -d -p 8080:8080 -e WEB_PORT=8080 -e ANALYZER_SVC_ADDRESS=presidio-analyzer:3000 -e ANONYMIZER_SVC_ADDRESS=presidio-anonymizer:3001 -e RECOGNIZERS_STORE_SVC_ADDRESS=presidio-recognizers-store:3004 ${DOCKER_REGISTRY}/presidio-api:${PRESIDIO_LABEL} +sleep 30 # Wait for the analyzer model to load +docker run --rm --name presidio-api --network mynetwork -d -p 8080:8080 -e WEB_PORT=8080 -e ANALYZER_SVC_ADDRESS=presidio-analyzer:3000 -e ANONYMIZER_SVC_ADDRESS=presidio-anonymizer:3001 -e RECOGNIZERS_STORE_SVC_ADDRESS=presidio-recognizers-store:3004 ${DOCKER_REGISTRY}/presidio-api:${PRESIDIO_LABEL} ``` --- @@ -40,7 +40,7 @@ $ docker run --rm --name presidio-api --network mynetwork -d -p 8080:8080 -e WEB 2. Install [Redis](https://hub.kubeapps.com/charts/stable/redis) (Cache for storage and database scanners) ```sh - $ helm install --name redis stable/redis --set usePassword=false,rbac.create=true --namespace presidio-system + helm install --name redis stable/redis --set usePassword=false,rbac.create=true --namespace presidio-system ``` 3. Optional - Ingress controller for presidio API. @@ -53,9 +53,5 @@ $ docker run --rm --name presidio-api --network mynetwork -d -p 8080:8080 -e WEB ```sh # Based on the DOCKER_REGISTRY and PRESIDIO_LABEL from the previous steps - $ helm install --name presidio-demo --set registry=${DOCKER_REGISTRY},tag=${PRESIDIO_LABEL} . --namespace presidio - ``` - ---- - -Prev: [Overview](overview.md) `|` Next: [Field Types](field_types.md) \ No newline at end of file + helm install --name presidio-demo --set registry=${DOCKER_REGISTRY},tag=${PRESIDIO_LABEL} . --namespace presidio + ``` \ No newline at end of file diff --git a/docs/overview.md b/docs/overview.md index 72ae1632d..a1116eb28 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -4,45 +4,68 @@ ## Description -Presidio *(Origin from Latin praesidium ‘protection, garrison’)* helps to ensure sensitive text is properly managed and governed. It provides fast ***analytics*** and ***anonymization*** for sensitive text such as credit card numbers, bitcoin wallets, names, locations, social security numbers, US phone numbers and financial data. -Presidio analyzes the text using predefined analyzers to identify patterns, formats, and checksums with relevant context. +Presidio *(Origin from Latin praesidium ‘protection, garrison’)* helps to ensure sensitive text is properly managed and governed. It provides fast ***analytics*** and ***anonymization*** for sensitive text such as credit card numbers, names, locations, social security numbers, bitcoin wallets, US phone numbers and financial data. +Presidio analyzes the text using predefined or custom recognizers to identify entities, patterns, formats, and checksums with relevant context. Presidio leverages docker and kubernetes for workloads at scale. Refer to the [design page](design.md) for more information. -You can find a more detailed list [here](https://microsoft.github.io/presidio/field_types.html) +### Why use presidio? -:warning: ***Presidio can help identify sensitive/PII data in un/structured text. However, because Presidio is using trained ML models, there is no guarantee that Presidio will find all sensitive information. Consequently, additional systems and protections should be employed.*** +Presidio can be integrated into any data pipeline for intelligent PII scrubbing. It is open-source, transparent and scalable. Additionally, PII anonymization use-cases often require a different set of PII entities to be detected, some of which are domain or business specific. Presidio allows you to **customize or add new PII recognizers** via API or code to best fit your anonymization needs. + +:warning: Presidio can help identify sensitive/PII data in un/structured text. However, because Presidio is using trained ML models, there is no guarantee that Presidio will find all sensitive information. Consequently, additional systems and protections should be employed. + +## Demo + +[Try Presidio with your own data](https://presidio-demo.westeurope.cloudapp.azure.com/) ## Features -***Free text anonymization*** +***Unstsructured text anonymization*** + +Presidio automatically detects Personal-Identifiable Information (PII) in unstructured text, annonymizes it based on one or more anonymization mechanisms, and returns a string with no personal identifiable data. +For example: + +[![Image1](assets/before-after.png)](assets/before-after.png) + +For each PII entity, presidio returns a confidence score: + +[![Image2](assets/findings.png)](assets/findings.png) + +***Text anonymization in images*** (beta) + +Presidio uses OCR to detect text in images. It further allows the redaction of the text from the original image. + +[![Image3](assets/ocr-example.png)](assets/ocr-example.png) + +## Input and output + +Presidio accepts multiple sources and targets for data annonymization. Specifically: + +1. Storage solutions + * Azure Blob Storage + * S3 + * Google Cloud Storage + +2. Databases + * MySQL + * PostgreSQL + * Sql Server + * Oracle -[![Image1](https://user-images.githubusercontent.com/17064840/50557166-2048ca80-0ceb-11e9-9153-d39a3f507d32.png)](https://user-images.githubusercontent.com/17064840/50557166-2048ca80-0ceb-11e9-9153-d39a3f507d32.png) +3. Streaming platforms + * Kafka + * Azure Events Hubs -***Text anonymization in images*** +4. REST requests -[![Image2](https://user-images.githubusercontent.com/17064840/50557215-bc72d180-0ceb-11e9-8c92-4fbc01bbcb2a.png)](https://user-images.githubusercontent.com/17064840/50557215-bc72d180-0ceb-11e9-8c92-4fbc01bbcb2a.png) +It then can export the results to file storage, databases or streaming platforms. -* Text analytics - Predefined analyzers with customizable fields. -* Probability scores - Customize the sensitive text detection threshold. -* Anonymization - Anonymize sensitive text and images -* Workflow and pipeline integration - Monitor your data with periodic scans or events of/from: - 1. Storage solutions - * Azure Blob Storage - * S3 - * Google Cloud Storage - 2. Databases - * MySQL - * PostgreSQL - * Sql Server - * Oracle - 3. Streaming platforms - * Kafka - * Azure Events Hubs +## The Technology Stack - and export the results for further analytics: - 1. Storage solutions - 2. Databases - 3. Streaming platforms +Presidio leverages: ---- +* [Kubernetes](https://kubernetes.io/) +* [spaCy](https://spacy.io/) +* [Redis](https://redis.io/) +* [GRPC](https://grpc.io) -Prev: [Docs Index](index.md) `|` Next: [Install Guide](install.md) \ No newline at end of file +The [design document](design.md) introduces Presidio's concepts and architecture. \ No newline at end of file diff --git a/docs/tutorial_framework.md b/docs/tutorial_analyzer.md similarity index 52% rename from docs/tutorial_framework.md rename to docs/tutorial_analyzer.md index ac974e81d..996ffbcfe 100644 --- a/docs/tutorial_framework.md +++ b/docs/tutorial_analyzer.md @@ -1,17 +1,19 @@ -# Framework Tutorial +# Using the Analyzer service Throughout this tutorial, we’ll walk you through the creation of a basic request to the analyzer and anonymizer components. See [Install Presidio](install.md#L5) for a tutorial on how to install Presidio. -## Analyze your text data +## Analyze your textual data + +Analysis could be performed either by using Presidio as a deployed service (Method 1), or using the `presidio-analyzer` python package (Method 2). ### Method 1 First, we need to serve our model. We can do that very easily with (Takes about 10 seconds to load) ```sh - $ ./presidio-analyzer serve + ./presidio-analyzer serve ``` Now that our model is up and running, we can send PII text to it. @@ -19,7 +21,7 @@ Now that our model is up and running, we can send PII text to it. *From another shell* ```sh - $ ./presidio-analyzer analyze --text "John Smith drivers license is AC432223" --fields "PERSON" "US_DRIVER_LICENSE" + ./presidio-analyzer analyze --text "John Smith drivers license is AC432223" --fields "PERSON" "US_DRIVER_LICENSE" ``` The expected result is: @@ -57,9 +59,18 @@ The expected result is: ### Method 2 -Use the analyzer Python code by importing `matcher.py` from `presidio-analyzer/analyzer` +Use the analyzer Python code by importing `analyzer_engine.py` from `presidio-analyzer/analyzer` ```python -match = matcher.Matcher() -results = self.match.analyze_text(text, fields) +from analyzer import AnalyzerEngine + +analyzer = AnalyzerEngine() +results = analyzer.analyze(text="My phone number is 212-555-5555", + entities=["PHONE_NUMBER"], + language='en', + all_fields=False) +print( + ["Entity: {ent}, score: {score}\n".format(ent=res.entity_type, + score=res.score) + for res in results]) ``` \ No newline at end of file diff --git a/docs/tutorial_service.md b/docs/tutorial_service.md index 4a3868ae8..7b313ff79 100644 --- a/docs/tutorial_service.md +++ b/docs/tutorial_service.md @@ -1,58 +1,90 @@ +# Samples + **Note:** Examples are made with [HTTPie](https://httpie.org/) -***Sample 1*** +***Sample 1:*** Simple text analysis -1. Analyze text - ```sh - echo -n '{"text":"John Smith lives in New York. We met yesterday morning in Seattle. I called him before on (212) 555-1234 to verify the appointment. He also told me that his drivers license is AC111921", "analyzeTemplate":{"allFields":true} }' | http /api/v1/projects//analyze - ``` +```sh +echo -n '{"text":"John Smith lives in New York. We met yesterday morning in Seattle. I called him before on (212) 555-1234 to verify the appointment. He also told me that his drivers license is AC333991", "analyzeTemplate":{"allFields":true} }' | http /api/v1/projects//analyze +``` -***Sample 2*** +--- +***Sample 2:*** Create reusable templates -You can also create reusable templates +1. Create an analyzer template: -1. Create an analyzer project ```sh echo -n '{"allFields":true}' | http /api/v1/templates//analyze/ ``` -2. Analyze text +2. Analyze text: + ```sh echo -n '{"text":"my credit card number is 2970-84746760-9907 345954225667833 4961-2765-5327-5913", "AnalyzeTemplateId":"" }' | http /api/v1/projects//analyze ``` -***Sample 3*** +--- +***Sample 3:*** Detect specific entities + +1. Create an analyzer project with a specific set of entities: -1. Create an analyzer project ```sh echo -n '{"fields":[{"name":"PHONE_NUMBER"}, {"name":"LOCATION"}, {"name":"DATE_TIME"}]}' | http /api/v1/templates//analyze/ ``` -2. Analyze text +2. Analyze text: + ```sh echo -n '{"text":"We met yesterday morning in Seattle and his phone number is (212) 555 1234", "AnalyzeTemplateId":"" }' | http /api/v1/projects//analyze ``` -***Sample 4*** +--- +***Sample 4:*** Custom anonymization + +1. Create an anonymizer template (This template replaces values in PHONE_NUMBER and redacts CREDIT_CARD): -1. Create an anonymizer template (This template replaces values in PHONE_NUMBER and redacts CREDIT_CARD) ```sh echo -n '{"fieldTypeTransformations":[{"fields":[{"name":"PHONE_NUMBER"}],"transformation":{"replaceValue":{"newValue":"\u003cphone-number\u003e"}}},{"fields":[{"name":"CREDIT_CARD"}],"transformation":{"redactValue":{}}}]}' | http /api/v1/templates//anonymize/ ``` -2. Anonymize text +2. Anonymize text: + ```sh echo -n '{"text":"my phone number is 057-555-2323 and my credit card is 4961-2765-5327-5913", "AnalyzeTemplateId":"", "AnonymizeTemplateId":"" }' | http /api/v1/projects//anonymize ``` -***Sample 5 (Image anonymization)*** +--- +***Sample 5:*** Add custom PII entity recognizer + +This sample shows how to add an new regex recognizer via API. +This simple recognizer identifies the word "rocket" in a text and tags it as a "ROCKET" entity. See the [custom fields documentation](docs/custom_fields.md) for more info. + +1. Add a custom recognizer + + ```sh + echo -n {"value": {"entity": "ROCKET","language": "en", "patterns": [{"name": "rocket-regex","regex": "\\W*(rocket)\\W*","score": 1}]}} | http /api/v1/analyzer/recognizers/rocket + ``` + +2. Analyze text: + + ```sh + echo -n '{"text":"They sent a rocket to the moon!", "analyzeTemplate":{"allFields":true} }' | http /api/v1/projects//analyze + ``` + + +--- +***Sample 6:*** Image anonymization + +1. Create an anonymizer image template (This template redacts values with black color): -1. Create an anonymizer image template (This template redact values with black color) ```sh echo -n '{"fieldTypeGraphics":[{"graphic":{"fillColorValue":{"blue":0,"red":0,"green":0}}}]}' | http /api/v1/templates//anonymize-image/ ``` -2. Anonymize image +2. Anonymize image: + ```sh - http -f POST /api/v1/projects//anonymize-image detectionType='OCR' analyzeTemplateId='' anonymizeTemplateId='' imageType='image/png' file@~/test-ocr.png > test-output.png - ``` \ No newline at end of file + http -f POST /api/v1/projects//anonymize-image detectionType='OCR' analyzeTemplateId='' anonymizeImageTemplateId='' imageType='image/png' file@~/test-ocr.png > test-output.png + ``` + +--- diff --git a/presidio-analyzer/README.MD b/presidio-analyzer/README.MD new file mode 100644 index 000000000..4031617c1 --- /dev/null +++ b/presidio-analyzer/README.MD @@ -0,0 +1,39 @@ +# Presidio analyzer + +## Description +The presidio-analyzer is a Python based service which does the actual detection of PII entities. + +The main modules in presidio-analyzer are the AnalyzerEngine and the RecognizerRegistry. The AnalyzerEngine is in charge of calling each requested recognizer. the RecognizerRegistry is in charge of providing the list of predefined and custom recognizers for analysis. + +## Extending the analyzer for additional PII entities by introducing new code-based recognizers + +Code based recognizers are written in Python and are a part of the presidio-analyzer module. +The main modules in `presidio-analyzer` are the `AnalyzerEngine` and the `RecognizerRegistry`. +The `AnalyzerEngine` is in charge of calling each requested recognizer. +The `RecognizerRegistry` is in charge of providing the list of predefined and custom recognizers for analysis. + +In order to implement a new recognizer by code, follow these two steps: + +a. Implement the abstract recognizer class: + +Create a new Python class which implements [LocalRecognizer](analyzer/local_recognizer.py). +`LocalRecognizer` implements the base [EntityRecognizer](analyzer/entity_recognizer.py) class. +All local recognizers run locally together with all other predefined recognizers as a part of the `presidio-analyzer` Python process. In contrast, `RemoteRecognizer` is a placeholder for recognizers that are external to the `presidio-analyzer` service, for example on a different microservice. + +The `EntityRecognizer` abstract class requires the implementation the following methods: + +i. initializing a model. Occurs when the `presidio-analyzer` process starts: + +```python +def load(self) +``` + +ii. analyze: The main function to be called for getting entities out of the new recognizer: + +```python +def analyze(self, text, entities, nlp_artifacts): +``` + +The `analyze` method should return a list of [RecognizerResult](analyzer/recognizer_result.py). Refer to the [code documentation](analyzer/entity_recognizer.py) for more information. + +b. Reference and add the new class to the `RecognizerRegistry` module, in the `load_predefined_recognizers` method, which registers all code based recognizers. From bc1f75756f4e0e216c5e2a5a49cba909087e31f9 Mon Sep 17 00:00:00 2001 From: Omri Mendels Date: Wed, 1 May 2019 12:34:32 +0300 Subject: [PATCH 22/75] Update README.MD --- README.MD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.MD b/README.MD index b622110ce..27b327dbc 100644 --- a/README.MD +++ b/README.MD @@ -88,7 +88,7 @@ Presidio leverages: ## Quickstart -1. Install [Presidio](docs/install.html) +1. Install [Presidio](docs/install.md) 2. Decide on a name for your Presidio project. In the following examples the project name is ``. 3. Start using the Presidio analyze and anonymize services. From 8cc94c1d67017c30d12a4d4261d761a80e474935 Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Wed, 1 May 2019 13:09:15 +0300 Subject: [PATCH 23/75] Bug 911 fix (#131) * Bug 911 fix When the persistent recognizers store is empty there should be no error log line when requesting for the hash value --- .../recognizer_registry/recognizer_registry.py | 2 +- .../recognizer_registry/recognizers_store_api.py | 9 ++++++--- presidio-analyzer/tests/test_recognizer_registry.py | 2 +- .../cmd/presidio-recognizers-store/main.go | 10 ++++++++-- .../recognizers_store_test.go | 4 ++-- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py b/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py index 96709108c..28325ff9e 100644 --- a/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py +++ b/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py @@ -125,7 +125,7 @@ def get_custom_recognizers(self): latest_hash = self.store_api.get_latest_hash() # is update time is not set, no custom recognizers in storage, skip - if latest_hash != "": + if latest_hash: logging.info( "Persistent storage has hash: %s", latest_hash) diff --git a/presidio-analyzer/analyzer/recognizer_registry/recognizers_store_api.py b/presidio-analyzer/analyzer/recognizer_registry/recognizers_store_api.py index ab9030f86..4219ca2e1 100644 --- a/presidio-analyzer/analyzer/recognizer_registry/recognizers_store_api.py +++ b/presidio-analyzer/analyzer/recognizer_registry/recognizers_store_api.py @@ -39,10 +39,13 @@ def get_latest_hash(self): last_hash = self.rs_stub.ApplyGetHash( hash_request).recognizersHash except grpc.RpcError: - logging.info("Failed to get recognizers hash") - return "" + logging.error("Failed to get recognizers hash") + return None - logging.info("Latest hash found in store is: %d", last_hash) + if not last_hash: + logging.info("Recognizers hash was not found in store") + else: + logging.info("Latest hash found in store is: %s", last_hash) return last_hash def get_all_recognizers(self): diff --git a/presidio-analyzer/tests/test_recognizer_registry.py b/presidio-analyzer/tests/test_recognizer_registry.py index 58329ece4..5a2e14288 100644 --- a/presidio-analyzer/tests/test_recognizer_registry.py +++ b/presidio-analyzer/tests/test_recognizer_registry.py @@ -17,7 +17,7 @@ class RecognizerStoreApiMock(RecognizerStoreApi): """ def __init__(self): - self.latest_hash = "" + self.latest_hash = None self.recognizers = [] self.times_accessed_storage = 0 diff --git a/presidio-recognizers-store/cmd/presidio-recognizers-store/main.go b/presidio-recognizers-store/cmd/presidio-recognizers-store/main.go index a5766eefe..2155b0e5e 100644 --- a/presidio-recognizers-store/cmd/presidio-recognizers-store/main.go +++ b/presidio-recognizers-store/cmd/presidio-recognizers-store/main.go @@ -298,10 +298,16 @@ func applyGetHash() (*types.RecognizerHashResponse, error) { } hash, err := recognizersStore.Get(hashKey) - if err != nil || hash == "" { + if err != nil { errMsg := "Failed to find the latest hash" log.Error(errMsg) - return &types.RecognizerHashResponse{}, errors.New(errMsg) + return nil, errors.New(errMsg) + } + + // no error, however hash was not found, this means the redis is still + // empty. + if hash == "" { + return &types.RecognizerHashResponse{}, nil } return &types.RecognizerHashResponse{RecognizersHash: hash}, nil diff --git a/presidio-recognizers-store/cmd/presidio-recognizers-store/recognizers_store_test.go b/presidio-recognizers-store/cmd/presidio-recognizers-store/recognizers_store_test.go index 0b972e3cb..54761c3dd 100644 --- a/presidio-recognizers-store/cmd/presidio-recognizers-store/recognizers_store_test.go +++ b/presidio-recognizers-store/cmd/presidio-recognizers-store/recognizers_store_test.go @@ -226,7 +226,7 @@ func TestHashDoesNotExists(t *testing.T) { // Store is empty... res, err := applyGetHash() - assert.Error(t, err) + assert.NoError(t, err) assert.Equal(t, res, &types.RecognizerHashResponse{}) } @@ -237,7 +237,7 @@ func TestGetHash(t *testing.T) { // Store is empty... res, err := applyGetHash() - assert.Error(t, err) + assert.NoError(t, err) assert.Equal(t, res, &types.RecognizerHashResponse{}) // Now, insert an item From 339299db6a7f82eb48a893ba955cce6c38c660ee Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Wed, 1 May 2019 14:17:55 +0300 Subject: [PATCH 24/75] bug fix - mcr latest tag was not correct --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index f69b68ec6..a1bc1ff6a 100644 --- a/Makefile +++ b/Makefile @@ -78,9 +78,12 @@ ifeq ($(RELEASE_VERSION),) endif docker pull $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/$*:$(RELEASE_VERSION) + docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/public/$*:$(RELEASE_VERSION) docker push $(DOCKER_REGISTRY)/$*:$(RELEASE_VERSION) docker push $(DOCKER_REGISTRY)/public/$*:$(RELEASE_VERSION) + docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/$*:latest + docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/public/$*:latest docker push $(DOCKER_REGISTRY)/$*:latest docker push $(DOCKER_REGISTRY)/public/$*:latest From 8eaaabbc241d447cda77595c1091c73a64326700 Mon Sep 17 00:00:00 2001 From: Omri Mendels Date: Wed, 1 May 2019 14:44:15 +0300 Subject: [PATCH 25/75] Update README.MD --- README.MD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.MD b/README.MD index 27b327dbc..afd02d133 100644 --- a/README.MD +++ b/README.MD @@ -4,7 +4,7 @@ --- -[![Build status](https://dev.azure.com/csedevil/Presidio/_apis/build/status/Presidio-CI)](https://dev.azure.com/csedevil/Presidio/_build/latest?definitionId=48) +[![Build status](https://dev.azure.com/csedevil/Presidio/_apis/build/status/Presidio-CI)](https://dev.azure.com/csedevil/Presidio/_build/latest?definitionId=74) [![Go Report Card](https://goreportcard.com/badge/github.com/Microsoft/presidio)](https://goreportcard.com/report/github.com/Microsoft/presidio) [![MIT license](https://img.shields.io/badge/license-MIT-brightgreen.svg)](http://opensource.org/licenses/MIT) ![Release](https://img.shields.io/github/release/Microsoft/presidio.svg) From 33aefdd3cb81ec9417d27028acb6f73744f24df4 Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Wed, 1 May 2019 17:14:45 +0300 Subject: [PATCH 26/75] Bug 932 - fix iban recognizer which failed due to surrounding text --- .../analyzer/predefined_recognizers/iban_recognizer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py index a9410df49..817e659b7 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py @@ -10,7 +10,7 @@ except ImportError: import regex as re -IBAN_GENERIC_REGEX = u'^[A-Z]{2}[0-9]{2}[ ]?([a-zA-Z0-9][ ]?){11,28}$' +IBAN_GENERIC_REGEX = r'\b[A-Z]{2}[0-9]{2}[ ]?([a-zA-Z0-9][ ]?){11,28}\b' IBAN_GENERIC_SCORE = 0.5 CONTEXT = ["iban", "bank", "transaction"] From 99ff63955207bca64c17cb458b4ffe8aa984e229 Mon Sep 17 00:00:00 2001 From: Omri Mendels Date: Thu, 2 May 2019 09:48:04 +0300 Subject: [PATCH 27/75] fixed typo --- docs/development.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/development.md b/docs/development.md index fe618ef41..a564ad728 100644 --- a/docs/development.md +++ b/docs/development.md @@ -71,7 +71,7 @@ - Adding a file in go requires the `make go-format` command before running and building the service. - Run functional tests with `make test-functional` -### Set the following environnement variables +### Set the following environment variables #### presidio-analyzer @@ -95,4 +95,4 @@ ```sh wrk -t2 -c2 -d30s -s post.lua http:///api/v1/projects//analyze - ``` \ No newline at end of file + ``` From 6fad6d435e2a4ec03497f4423e7f9c66c5c095e1 Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Wed, 15 May 2019 10:53:43 +0100 Subject: [PATCH 28/75] Fix context bug where indices were off --- presidio-analyzer/analyzer/entity_recognizer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/presidio-analyzer/analyzer/entity_recognizer.py b/presidio-analyzer/analyzer/entity_recognizer.py index 2d9f55f30..b34845707 100644 --- a/presidio-analyzer/analyzer/entity_recognizer.py +++ b/presidio-analyzer/analyzer/entity_recognizer.py @@ -285,7 +285,7 @@ def __extract_context(self, nlp_artifacts, word, start): tokens_indices = nlp_artifacts.tokens_indices for i in range(len(nlp_artifacts.tokens)): if ((tokens_indices[i] == start) or - (tokens_indices[i] < start < + (tokens_indices[i] <= start <= tokens_indices[i] + len(tokens[i]))): # found the interesting token, the one that around it # we take n words, we save the matching lemma From b2a4419033229d1f8ff0425cd05d8e9e7bd692bd Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Thu, 16 May 2019 13:19:24 +0100 Subject: [PATCH 29/75] update --- presidio-analyzer/analyzer/entity_recognizer.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/presidio-analyzer/analyzer/entity_recognizer.py b/presidio-analyzer/analyzer/entity_recognizer.py index b34845707..3699946e8 100644 --- a/presidio-analyzer/analyzer/entity_recognizer.py +++ b/presidio-analyzer/analyzer/entity_recognizer.py @@ -284,9 +284,11 @@ def __extract_context(self, nlp_artifacts, word, start): tokens = nlp_artifacts.tokens tokens_indices = nlp_artifacts.tokens_indices for i in range(len(nlp_artifacts.tokens)): + # Either we found a token with the exact location, or + # we take a token which its characters indices covers + # the index we are looking for. if ((tokens_indices[i] == start) or - (tokens_indices[i] <= start <= - tokens_indices[i] + len(tokens[i]))): + (start < tokens_indices[i] + len(tokens[i]))): # found the interesting token, the one that around it # we take n words, we save the matching lemma found = True From adae5285429c277ef1955d827e4d41863500e224 Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Thu, 16 May 2019 13:57:43 +0100 Subject: [PATCH 30/75] adding UT --- .../tests/test_context_support.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/presidio-analyzer/tests/test_context_support.py b/presidio-analyzer/tests/test_context_support.py index b6023080f..c09324fda 100644 --- a/presidio-analyzer/tests/test_context_support.py +++ b/presidio-analyzer/tests/test_context_support.py @@ -3,6 +3,7 @@ import os import pytest +from analyzer import PatternRecognizer, Pattern from analyzer.predefined_recognizers import CreditCardRecognizer, \ UsPhoneRecognizer, DomainRecognizer, UsItinRecognizer, \ UsLicenseRecognizer, UsBankRecognizer, UsPassportRecognizer, \ @@ -90,3 +91,23 @@ def test_text_with_context_improves_score(self): assert(len(results_without_context) == len(results_with_context)) for i in range(len(results_with_context)): assert(results_without_context[i].score < results_with_context[i].score) + + def test_text_with_context_improves_score_custom_recognizer(self): + nlp_engine = SpacyNlpEngine() + mock_nlp_artifacts = NlpArtifacts([], [], [], [], None, "en") + + rocket_recognizer = PatternRecognizer(supported_entity="ROCKET", + name="rocketrecognizer", + context=["cool"], + patterns=[Pattern("rocketpattern", + "\\s+(rocket)", + 0.3)]) + text = "hi, this is a cool ROCKET" + recognizer = rocket_recognizer + entities = ["ROCKET"] + nlp_artifacts = nlp_engine.process_text(text, "en") + results_without_context = recognizer.analyze(text, entities, mock_nlp_artifacts) + results_with_context = recognizer.analyze(text, entities, nlp_artifacts) + assert(len(results_without_context) == len(results_with_context)) + for i in range(len(results_with_context)): + assert(results_without_context[i].score < results_with_context[i].score) From b4a46e9d0cfa0d2b9afe361001c5958f375a1e5e Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Mon, 20 May 2019 15:03:00 +0300 Subject: [PATCH 31/75] elaborating documentation --- presidio-analyzer/analyzer/entity_recognizer.py | 4 +++- presidio-analyzer/tests/test_context_support.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/presidio-analyzer/analyzer/entity_recognizer.py b/presidio-analyzer/analyzer/entity_recognizer.py index 3699946e8..92330d9e2 100644 --- a/presidio-analyzer/analyzer/entity_recognizer.py +++ b/presidio-analyzer/analyzer/entity_recognizer.py @@ -279,7 +279,9 @@ def __extract_context(self, nlp_artifacts, word, start): # we use the known start index of the original word to find the actual # token at that index, we are not checking for equivilance since the # token might be just a substring of that word (e.g. for phone number - # 555-124564 the first token might be just '555') + # 555-124564 the first token might be just '555' or for a match like ' + # rocket' the actual token will just be 'rocket' hence the misalignment + # of indices) # Note: we are iterating over the original tokens (not the lemmatized) tokens = nlp_artifacts.tokens tokens_indices = nlp_artifacts.tokens_indices diff --git a/presidio-analyzer/tests/test_context_support.py b/presidio-analyzer/tests/test_context_support.py index c09324fda..8f2c311a5 100644 --- a/presidio-analyzer/tests/test_context_support.py +++ b/presidio-analyzer/tests/test_context_support.py @@ -92,10 +92,15 @@ def test_text_with_context_improves_score(self): for i in range(len(results_with_context)): assert(results_without_context[i].score < results_with_context[i].score) - def test_text_with_context_improves_score_custom_recognizer(self): + def test_context_custom_recognizer(self): nlp_engine = SpacyNlpEngine() mock_nlp_artifacts = NlpArtifacts([], [], [], [], None, "en") + # This test checks that a custom recognizer is also enhanced by context. + # However this test also verifies a specific case in which the pattern also + # includes a preceeding space (' rocket'). This in turn cause for a misalignment + # between the tokens and the regex match (the token will be just 'rocket'). + # This misalignment is handled in order to find the correct context window. rocket_recognizer = PatternRecognizer(supported_entity="ROCKET", name="rocketrecognizer", context=["cool"], From 461a1493531f75c6ef017034c7077c668949bd6d Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Tue, 21 May 2019 15:16:43 +0300 Subject: [PATCH 32/75] adding a UT --- .../analyzer/entity_recognizer.py | 65 +++++++++++-------- .../tests/test_entity_recognizer.py | 11 ++++ 2 files changed, 49 insertions(+), 27 deletions(-) diff --git a/presidio-analyzer/analyzer/entity_recognizer.py b/presidio-analyzer/analyzer/entity_recognizer.py index 92330d9e2..1f4dae7ef 100644 --- a/presidio-analyzer/analyzer/entity_recognizer.py +++ b/presidio-analyzer/analyzer/entity_recognizer.py @@ -252,6 +252,34 @@ def __add_n_words_backward(self, prefix, True) + @staticmethod + def find_index_of_match_token(word, start, tokens, tokens_indices): + found = False + # we use the known start index of the original word to find the actual + # token at that index, we are not checking for equivilance since the + # token might be just a substring of that word (e.g. for phone number + # 555-124564 the first token might be just '555' or for a match like ' + # rocket' the actual token will just be 'rocket' hence the misalignment + # of indices) + # Note: we are iterating over the original tokens (not the lemmatized) + i = -1 + for i, token in enumerate(tokens, 0): + # Either we found a token with the exact location, or + # we take a token which its characters indices covers + # the index we are looking for. + if ((tokens_indices[i] == start) or + (start < tokens_indices[i] + len(token))): + # found the interesting token, the one that around it + # we take n words, we save the matching lemma + found = True + break + + if not found: + raise ValueError("Did not find word '" + word + "' " + "in the list of tokens altough it " + "is expected to be found") + return i + def __extract_context(self, nlp_artifacts, word, start): """ Extracts words surronding another given word. The text from which the context is extracted is given in the nlp @@ -275,43 +303,26 @@ def __extract_context(self, nlp_artifacts, word, start): # LEMMATIZED version lemmatized_keywords = nlp_artifacts.keywords - found = False - # we use the known start index of the original word to find the actual - # token at that index, we are not checking for equivilance since the - # token might be just a substring of that word (e.g. for phone number - # 555-124564 the first token might be just '555' or for a match like ' - # rocket' the actual token will just be 'rocket' hence the misalignment - # of indices) - # Note: we are iterating over the original tokens (not the lemmatized) - tokens = nlp_artifacts.tokens - tokens_indices = nlp_artifacts.tokens_indices - for i in range(len(nlp_artifacts.tokens)): - # Either we found a token with the exact location, or - # we take a token which its characters indices covers - # the index we are looking for. - if ((tokens_indices[i] == start) or - (start < tokens_indices[i] + len(tokens[i]))): - # found the interesting token, the one that around it - # we take n words, we save the matching lemma - found = True - break - - if not found: - raise ValueError("Did not find word '" + word + "' " - "in the list of tokens altough it " - "is expected to be found") + # since the list of tokens is not necessarily aligned + # with the actual index of the match, we look for the + # token index which corresponds to the match + token_index = EntityRecognizer.find_index_of_match_token( + word, + start, + nlp_artifacts.tokens, + nlp_artifacts.tokens_indices) # index i belongs to the PII entity, take the preceding n words # and the successing m words into a context string context_str = '' context_str = \ - self.__add_n_words_backward(i, + self.__add_n_words_backward(token_index, EntityRecognizer.CONTEXT_PREFIX_COUNT, nlp_artifacts.lemmas, lemmatized_keywords, context_str) context_str = \ - self.__add_n_words_forward(i, + self.__add_n_words_forward(token_index, EntityRecognizer.CONTEXT_SUFFIX_COUNT, nlp_artifacts.lemmas, lemmatized_keywords, diff --git a/presidio-analyzer/tests/test_entity_recognizer.py b/presidio-analyzer/tests/test_entity_recognizer.py index 5df7d7265..948681d90 100644 --- a/presidio-analyzer/tests/test_entity_recognizer.py +++ b/presidio-analyzer/tests/test_entity_recognizer.py @@ -24,3 +24,14 @@ def test_from_dict_returns_instance(self): assert entity_rec.supported_entities == ["A", "B", "C"] assert entity_rec.supported_language == "he" assert entity_rec.version == "0.0.1" + + def test_index_finding(self): + # This test uses a simulated recognize result for the following + # text: "my phone number is:(425) 882-9090" + match = "(425) 882-9090" + # the start index of the match + start = 19 + tokens = ['my', 'phone', 'number', 'is:(425', ')', '882', '-', '9090'] + tokens_indices = [0, 3, 9, 16, 23, 25, 28, 29] + index = EntityRecognizer.find_index_of_match_token(match, start, tokens, tokens_indices) + assert index == 3 From 0cf97fc18719b019d79af92f06861fb1c2e88026 Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Wed, 22 May 2019 09:48:23 +0300 Subject: [PATCH 33/75] adding context list to the custom recognizer integration test --- .../testdata/new-custom-pattern-recognizer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/presidio-tester/cmd/presidio-tester/testdata/new-custom-pattern-recognizer.json b/presidio-tester/cmd/presidio-tester/testdata/new-custom-pattern-recognizer.json index b4b15c144..a68478232 100644 --- a/presidio-tester/cmd/presidio-tester/testdata/new-custom-pattern-recognizer.json +++ b/presidio-tester/cmd/presidio-tester/testdata/new-custom-pattern-recognizer.json @@ -13,6 +13,7 @@ "regex": "\\W*(projectile)\\W*", "score": 1 } - ] + ], + "contextPhrases": ["launch", "fire"] } } \ No newline at end of file From 88252a4f94e7132b88f941f0cd79648839a2bb9c Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Sun, 5 May 2019 16:34:09 +0300 Subject: [PATCH 34/75] Python packages are now installed using Pipenv instead of requirements files --- Dockerfile.python.deps | 16 +- presidio-analyzer/Dockerfile | 10 +- presidio-analyzer/Pipenv_Readme.md | 53 +++ presidio-analyzer/Pipfile | 20 + presidio-analyzer/Pipfile.lock | 625 +++++++++++++++++++++++++ presidio-analyzer/requirements-dev.txt | 3 - presidio-analyzer/requirements.txt | 7 - 7 files changed, 716 insertions(+), 18 deletions(-) create mode 100644 presidio-analyzer/Pipenv_Readme.md create mode 100644 presidio-analyzer/Pipfile create mode 100644 presidio-analyzer/Pipfile.lock delete mode 100644 presidio-analyzer/requirements-dev.txt delete mode 100644 presidio-analyzer/requirements.txt diff --git a/Dockerfile.python.deps b/Dockerfile.python.deps index 96025f341..1b68c9fe1 100644 --- a/Dockerfile.python.deps +++ b/Dockerfile.python.deps @@ -2,15 +2,25 @@ FROM python:3.7.1-alpine3.8 ARG re2_version="2018-12-01" ARG NAME=presidio-analyzer -COPY ./${NAME}/requirements.txt /usr/bin/${NAME}/requirements.txt +COPY ./${NAME}/Pipfile /usr/bin/${NAME}/Pipfile +COPY ./${NAME}/Pipfile.lock /usr/bin/${NAME}/Pipfile.lock WORKDIR /usr/bin/${NAME} +RUN apk add make automake gcc g++ subversion python3-dev + RUN apk --update add --no-cache g++ && \ apk --update add --no-cache --virtual build_deps make tar wget clang && \ wget -O re2.tar.gz https://github.com/google/re2/archive/${re2_version}.tar.gz && \ mkdir re2 && tar --extract --file "re2.tar.gz" --directory "re2" --strip-components 1 && \ cd re2 && make install && cd .. && rm -rf re2 && rm re2.tar.gz && \ - pip install --no-cache-dir cython && \ - pip install --no-cache-dir -r requirements.txt && \ apk del build_deps + +# Making sure we have pipenv +RUN pip3 install pipenv +# Updating setuptools +RUN pip3 install --upgrade setuptools +# Installing specified packages from Pipfile.lock +RUN pipenv sync +# Print to screen the installed packages for easy debugging +RUN pipenv run pip freeze diff --git a/presidio-analyzer/Dockerfile b/presidio-analyzer/Dockerfile index 5a71b5865..d001a7983 100644 --- a/presidio-analyzer/Dockerfile +++ b/presidio-analyzer/Dockerfile @@ -6,10 +6,10 @@ ARG NAME=presidio-analyzer WORKDIR /usr/bin/${NAME} ADD ./${NAME} /usr/bin/${NAME} -RUN pip install --no-cache-dir -r requirements-dev.txt && \ - pylint analyzer && \ - flake8 analyzer --exclude "*pb2*.py" && \ - pytest --log-cli-level=0 +RUN pipenv install --dev --sequential && \ + pipenv run pylint analyzer && \ + pipenv run flake8 analyzer --exclude "*pb2*.py" && \ + pipenv run pytest --log-cli-level=0 #---------------------------- @@ -19,4 +19,4 @@ ARG NAME=presidio-analyzer ADD ./${NAME}/analyzer /usr/bin/${NAME}/analyzer WORKDIR /usr/bin/${NAME}/analyzer -CMD python __main__.py serve --env-grpc-port \ No newline at end of file +CMD pipenv run python __main__.py serve --env-grpc-port \ No newline at end of file diff --git a/presidio-analyzer/Pipenv_Readme.md b/presidio-analyzer/Pipenv_Readme.md new file mode 100644 index 000000000..8f7838055 --- /dev/null +++ b/presidio-analyzer/Pipenv_Readme.md @@ -0,0 +1,53 @@ +# Presidio analyzer - Pipenv installation +[Pipenv](https://pipenv.readthedocs.io/en/latest/) is a Python workflow manager, handling dependencies and environment for python packages. This tutorial describes how to install presidio-analyzer locally for development purposes. +### 1. Install pipenv +#### Using Pip: +``` +$ pip install --user pipenv +``` +#### Homebrew +``` +$ brew install pipenv +``` + +Additional installation instructions: https://pipenv.readthedocs.io/en/latest/install/#installing-pipenv + + +### 2. Create virtualenv for the project +From the python project's root folder, run: +``` +$ pipenv shell +``` + +### 3. Install all requirements in the Pipfile, including dev requirements + +``` +$ pipenv install --dev --sequential +``` + +### 4. Run all tests +``` +$ pipenv run pytest +``` + +### 5. To run arbitrary scripts within the virtual env, start the command with `pipenv run`. For example: +1. `pipenv run flake8 analyzer --exclude "*pb2*.py"` +2. `pipenv run pylint analyzer` +3. `pipenv run pip freeze` + +## General pipenv instructions +Pipenv documentation: https://pipenv.readthedocs.io/en/latest/ + +Useful Pipenv commands: +``` +$ pipenv -h +``` + +General flow for adding a new dependency: +1. Manually add dependency name to Pipfile. +2. Create a new Pipfile.lock and update environment: +``` +$ pipenv update --sequential +``` + + `pipenv update` runs the `lock` command as well as the `sync` command which installs all requirements in the lock file into the current virtual environment. `--sequential` installs the dependencies sequentially to increase reproducibility. \ No newline at end of file diff --git a/presidio-analyzer/Pipfile b/presidio-analyzer/Pipfile new file mode 100644 index 000000000..61f9d1252 --- /dev/null +++ b/presidio-analyzer/Pipfile @@ -0,0 +1,20 @@ +[[source]] +url = "https://pypi.python.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +cython = "*" +spacy = "*" +en_core_web_lg = {file = "https://github.com/explosion/spacy-models/releases/download/en_core_web_lg-2.1.0/en_core_web_lg-2.1.0.tar.gz"} +regex = "*" +pyre2 = {file = "https://github.com/torosent/pyre2/archive/release/0.2.23.zip"} +grpcio = "*" +protobuf = "*" +tldextract = "*" +knack = "*" + +[dev-packages] +pytest = "*" +flake8= "*" +pylint = {version = "==2.3.1"} diff --git a/presidio-analyzer/Pipfile.lock b/presidio-analyzer/Pipfile.lock new file mode 100644 index 000000000..0eb5b069a --- /dev/null +++ b/presidio-analyzer/Pipfile.lock @@ -0,0 +1,625 @@ +{ + "_meta": { + "hash": { + "sha256": "be7df2b6a129090a66e0049544cdeae3425b032a6333d2d2991aa8e0e26725d2" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.python.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "argcomplete": { + "hashes": [ + "sha256:59909d0ce5be1a46e2fb4e4fa5b714f6d605151ce88c468afb42d800879a6e6d", + "sha256:94423d1a56cdec2ef47699e02c9a48cf8827b9c4465b836c0cefb30afe85e59a" + ], + "version": "==1.9.5" + }, + "blis": { + "hashes": [ + "sha256:039129410a338be8db8cf48c54334bd7c30da7e72bad2741e59313b1d242814b", + "sha256:058f9109aaea9d4f88cb623a44994d96c8cf36448de3e1bd30210628d6b52e9e", + "sha256:278d7b95e56cf82a6bef91cd8283eadc9401f2d3bdbbf2cdfdb605cf9081c36e", + "sha256:2d4ca1508fd6229c7994fc17ba324083a5b83f66612c8ea62623a41a1768b030", + "sha256:51a54bad6175e9b154beeb628a879ed492ee2247c9e40c77bdf6fc772145130c", + "sha256:886b313f96d4e268a0587e98c1637d963c73defa8de51e2e6b0d0bd00f16afbb", + "sha256:9f12e6f1e4b10dbb1e0e34e98f60e8435058a60d544a009cb761351fe1d12cad", + "sha256:a54d4fa1908d586f8bce9851a453cb89d1542e9aca65b8b88e9bb9432d626f80", + "sha256:b9d6cef13d95e3752320cd942df25e09160a6f9dfc3d7b41af7cdc772ab18270", + "sha256:d571464d195a950e60bf1547c8914d4da50952e06a0f38cea7b0829d0a4b985a", + "sha256:d616d64c85e6be92d69a1410dc58146cb9603fd1eb148f9ee512b8fddfd789f6", + "sha256:e477c7eaacf7dcccbb190a29559579efb287ecf5c2a9a7a6f9acb0452899f033", + "sha256:e6ae1986625af86f90f111f9d2d284b9e45fddfe56cf40524cdd9417a6a33b87" + ], + "version": "==0.2.4" + }, + "certifi": { + "hashes": [ + "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5", + "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae" + ], + "version": "==2019.3.9" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "colorama": { + "hashes": [ + "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", + "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" + ], + "version": "==0.4.1" + }, + "cymem": { + "hashes": [ + "sha256:081c652ae1aff4759813e93a2fc4df4ba410ce214a0e542988e24c62110d4cd0", + "sha256:0e447fa4cb6dccd0b96257a798370a17bef3ec254a527230058e41816a777c04", + "sha256:2c8267dcb15cc6ab318f01ceaf16b8440c0386ae44014d5b22fefe5b0398d05c", + "sha256:46141111eedbb5b0d8c9386b00226a15f5727a1202b9095f4363d425f259267e", + "sha256:4994c1f3e948bd58a6e38c905221680563b851983a15f1f01e5ff415d560d153", + "sha256:584872fd3df176e50c90e37aaca6cb731ac0abcdea4f5b8ad77c30674cfaaa99", + "sha256:6e3194135b21bb268030f3473beb8b674b356c330a9fa185dced2f5006cbd5ba", + "sha256:71710ee0e946a6bd33c86dd9e71f95ad584c65e8bb02615f00ceb0d8348fb303", + "sha256:741957f541fb8322de5a8c711d5d58f80d684225d2aec32fec92484cac931a52", + "sha256:7f01ba6153427811cd7d35630081c69b32c188a1d330599a826ef3bf17edbd7c", + "sha256:8d96e95902e781950d7c255b19364a1ed50a204843d63dd386b0abc5e6df5e44", + "sha256:8dd169ece1629ec4db1a592321e3ae0a9bb62fda2052a351fc36871f314c3569", + "sha256:8e6ad29636edd559b0dfe0a19c5cb5e6257461a5df90839e8c7710ddb005f4b4", + "sha256:9935b233882732f03fd0fadbeb9e9aa672edcdd126e6d52c36d60adf1def8ea5", + "sha256:a38b3229782411e4b23240f5f90000c4e7a834af88ed8763c66f8e4603db6b51", + "sha256:a5966b3171bad9c84a2b19dccda5ab37ae8437c0709a6b72cb42b64ea76a4bd3", + "sha256:ab88b1534f06df07262d9bc5efb3ba07948cdbe9a363eb9eaa4ad42fae6c7b5e", + "sha256:b08b0dd7adafbff9f0fd7dc8dcad5f3ce6f23c126c81ad8d1666880cc94e6974", + "sha256:ba47b571d480c0b76d282ff1634372070031d4998a46ae5d8305d49563b74ca6", + "sha256:bf049dc9cf0d3aa4a48ba514b7f1699fb6f35b18ad8c6f018bd13e0bccd9d30c", + "sha256:c46a122c524a3270ac5249f590ac2f75f1a83692a3d3a03479cea49de72a0a89", + "sha256:c63337aa7e1ad4ec182cc7847c6d85390589fbbf1f9f67d1fde8133a9acb7fa8", + "sha256:ec51273ea08a2c6389bc4dd6b5183354826d916b149a041f2f274431166191bc" + ], + "version": "==2.0.2" + }, + "cython": { + "hashes": [ + "sha256:0ce8f6c789c907472c9084a44b625eba76a85d0189513de1497ab102a9d39ef8", + "sha256:0d67964b747ac09758ba31fe25da2f66f575437df5f121ff481889a7a4485f56", + "sha256:1630823619a87a814e5c1fa9f96544272ce4f94a037a34093fbec74989342328", + "sha256:1a4c634bb049c8482b7a4f3121330de1f1c1f66eac3570e1e885b0c392b6a451", + "sha256:1ec91cc09e9f9a2c3173606232adccc68f3d14be1a15a8c5dc6ab97b47b31528", + "sha256:237a8fdd8333f7248718875d930d1e963ffa519fefeb0756d01d91cbfadab0bc", + "sha256:28a308cbfdf9b7bb44def918ad4a26b2d25a0095fa2f123addda33a32f308d00", + "sha256:2fe3dde34fa125abf29996580d0182c18b8a240d7fa46d10984cc28d27808731", + "sha256:30bda294346afa78c49a343e26f3ab2ad701e09f6a6373f579593f0cfcb1235a", + "sha256:33d27ea23e12bf0d420e40c20308c03ef192d312e187c1f72f385edd9bd6d570", + "sha256:34d24d9370a6089cdd5afe56aa3c4af456e6400f8b4abb030491710ee765bafc", + "sha256:4e4877c2b96fae90f26ee528a87b9347872472b71c6913715ca15c8fe86a68c9", + "sha256:50d6f1f26702e5f2a19890c7bc3de00f9b8a0ec131b52edccd56a60d02519649", + "sha256:55d081162191b7c11c7bfcb7c68e913827dfd5de6ecdbab1b99dab190586c1e8", + "sha256:59d339c7f99920ff7e1d9d162ea309b35775172e4bab9553f1b968cd43b21d6d", + "sha256:6cf4d10df9edc040c955fca708bbd65234920e44c30fccd057ecf3128efb31ad", + "sha256:6ec362539e2a6cf2329cd9820dec64868d8f0babe0d8dc5deff6c87a84d13f68", + "sha256:7edc61a17c14b6e54d5317b0300d2da23d94a719c466f93cafa3b666b058c43b", + "sha256:8e37fc4db3f2c4e7e1ed98fe4fe313f1b7202df985de4ee1451d2e331332afae", + "sha256:b8c996bde5852545507bff45af44328fa48a7b22b5bec2f43083f0b8d1024fd9", + "sha256:bf9c16f3d46af82f89fdefc0d64b2fb02f899c20da64548a8ea336beefcf8d23", + "sha256:c1038aba898bed34ab1b5ddb0d3f9c9ae33b0649387ab9ffe6d0af677f66bfc1", + "sha256:d405649c1bfc42e20d86178257658a859a3217b6e6d950ee8cb76353fcea9c39", + "sha256:db6eeb20a3bd60e1cdcf6ce9a784bc82aec6ab891c800dc5d7824d5cfbfe77f2", + "sha256:e382f8cb40dca45c3b439359028a4b60e74e22d391dc2deb360c0b8239d6ddc0", + "sha256:f3f6c09e2c76f2537d61f907702dd921b04d1c3972f01d5530ef1f748f22bd89", + "sha256:f749287087f67957c020e1de26906e88b8b0c4ea588facb7349c115a63346f67", + "sha256:f86b96e014732c0d1ded2c1f51444c80176a98c21856d0da533db4e4aef54070" + ], + "index": "pypi", + "version": "==0.29.7" + }, + "en-core-web-lg": { + "file": "https://github.com/explosion/spacy-models/releases/download/en_core_web_lg-2.1.0/en_core_web_lg-2.1.0.tar.gz" + }, + "grpcio": { + "hashes": [ + "sha256:0442f7d0c527ceab6a76159937ae8109941eace90ec00cb1bd08fc4f3179e52e", + "sha256:051957d0f61f4dec90868a54ee969228409926a0a19fd8ed7b4a0e50388effee", + "sha256:0d262794b2339770d5378a5717f8ddbfb68e409974582f0503272b90b7cc79bd", + "sha256:142693dc8bd427c595d030f75bf8d01c843d9ccb659499e8507ad22da832e9cf", + "sha256:18d44515a3fd3a71442abb5a1c65fc1909d859c13cda50c974cbc69742a80cea", + "sha256:1d50674bdffa18ea6143e0df9a1b97cdeab583ce5dd1cabda3502ee75215065c", + "sha256:3945335a5b8332995415c5f03da1a5f6e36da6ede819a611e2cbb093cf752bdd", + "sha256:3a9603ff14070524f4c69634afad6b280b07ad9f8c2c346c4b2290306e1928ac", + "sha256:52861aac5c1dcf4c841eb555b257cfb56d0c840a286495078382f538d0a34d6a", + "sha256:53c512c7c8af9cb9e3e1cc5ce5e4a5fb2f2e7695e69219f90016bc602abe2f3b", + "sha256:57ea92c9b81015e5f2cc355e53f08a4e661b78a207857311c7b8c55137a43b29", + "sha256:5f8574c9e42d1917e41cdedc6312682a96e4547114c7bb0f3de125199a58b3d6", + "sha256:638ff1a45dd7a226b2b9390296a111142363fe2b5503499f3987d599bce0683c", + "sha256:64fe0dc897f1f19a6500948862857cb3b97247be997bc47b4dbade42f8af5f97", + "sha256:67920ec7d2de89845e5232aed41271ef53e1a362c8ffb84f6a6c6e644a75ce3a", + "sha256:714cddc170efeedf6312d8534ef7f52dcf20dd8f5fb7c5e425c2b6819ac1b9ec", + "sha256:7edf33e929b1666ff68bfc280b9021a862ab423d0e6306889cc2bc7c907dfc27", + "sha256:84eb47b1a47e206e78f453fb92a155ed0d18d2ca8747f5c67e4b50b9c37180a7", + "sha256:8a6289e5c38318cba75115f0bf88be166ead40c83c10dd81ace52f1ab5dc1eab", + "sha256:8bd5b8c3c8872da748dc8810b664699a5f1d49f2c9ab2b205b96ec9fe06741ad", + "sha256:93e7672348d4c68ac570c499a794ff4453a1928c39cbe708472a0e1b77176411", + "sha256:9d37fb214674f0f194a80df5ad0b9c9b9f2fa5c5408ceaf0fc796e57588404d9", + "sha256:9de6746a749634004499bac773ad9877d84d826aca2dc14ba4ebd3cd9f64ed74", + "sha256:9e530c69d6e566ca985193a63363af36a7560a23f4979df6e392bb1bdf05caed", + "sha256:b37f36da8f4d0bf07d53eb34395b68f5e0dc0bcee207affde9ba29bbf6bd6ced", + "sha256:cf9b57d139e44eab294ab31eb0181150d877440a8a321bb4422e2c09f6c7a7d9", + "sha256:dd716aab42be3d1fde74577e42b6319b6399b07d418e49b653e0e1bcd88399bc", + "sha256:dea43aa864edc3b3d8de1f6e40144119fbccdf04525b3ece4fef9392b6eed436", + "sha256:e6cbd27559ff91c98991b8ec4ef19f394bf9056d6897aabb9af79568307181d3", + "sha256:f58e3377da8e8e453068dffc00d17691a97ffd1c3a5a7460b890cf83a9ca6edf", + "sha256:f938fdfb780a0658d04e1d727b4fb470490087c56cb31ba75cb54fb4bea515bd", + "sha256:fee4accad7a113004aef226b851f0494c01fc8d281fdebd74468f19cc45354a0" + ], + "index": "pypi", + "version": "==1.20.1" + }, + "idna": { + "hashes": [ + "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", + "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + ], + "version": "==2.8" + }, + "jmespath": { + "hashes": [ + "sha256:3720a4b1bd659dd2eecad0666459b9788813e032b83e7ba58578e48254e0a0e6", + "sha256:bde2aef6f44302dfb30320115b17d030798de8c4110e28d5cf6cf91a7a31074c" + ], + "version": "==0.9.4" + }, + "jsonschema": { + "hashes": [ + "sha256:000e68abd33c972a5248544925a0cae7d1125f9bf6c58280d37546b946769a08", + "sha256:6ff5f3180870836cae40f06fa10419f557208175f13ad7bc26caa77beb1f6e02" + ], + "version": "==2.6.0" + }, + "knack": { + "hashes": [ + "sha256:2a4b4d86c4700dd6714e5b4ca6bbca6baf2c827d9de28ca2b66640988c1b6ff4", + "sha256:7f17d4a1b34ea76821d3504f5f0f8c1b75bd9f08497db6a5864677214ac76adc" + ], + "index": "pypi", + "version": "==0.6.1" + }, + "murmurhash": { + "hashes": [ + "sha256:27b908fe4bdb426f4e4e4a8821acbe0302915b2945e035ec9d8ca513e2a74b1f", + "sha256:33405103fa8cde15d72ee525a03d5cfe2c7e4901133819754810986e29627d68", + "sha256:386a9eed3cb27cb2cd4394b6521275ba04552642c2d9cab5c9fb42aa5a3325c0", + "sha256:3af36a0dc9f13f6892d9b8b39a6a3ccf216cae5bce38adc7c2d145677987772f", + "sha256:717196a04cdc80cc3103a3da17b2415a8a5e1d0d578b7079259386bf153b3258", + "sha256:8a4ed95cd3456b43ea301679c7c39ade43fc18b844b37d0ba0ac0d6acbff8e0c", + "sha256:a6c071b4b498bcea16a8dc8590cad81fa8d43821f34c74bc00f96499e2527073", + "sha256:b0afe329701b59d02e56bc6cee7325af83e3fee9c299c615fc1df3202b4f886f", + "sha256:ba766343bdbcb928039b8fff609e80ae7a5fd5ed7a4fc5af822224b63e0cbaff", + "sha256:bf33490514d308bcc27ed240cb3eb114f1ec31af031535cd8f27659a7049bd52", + "sha256:c7a646f6b07b033642b4f52ae2e45efd8b80780b3b90e8092a0cec935fbf81e2", + "sha256:d696c394ebd164ca80b5871e2e9ad2f9fdbb81bd3c552c1d5f1e8ee694e6204a", + "sha256:fe344face8d30a5a6aa26e5acf288aa2a8f0f32e05efdda3d314b4bf289ec2af" + ], + "version": "==1.0.2" + }, + "numpy": { + "hashes": [ + "sha256:0e2eed77804b2a6a88741f8fcac02c5499bba3953ec9c71e8b217fad4912c56c", + "sha256:1c666f04553ef70fda54adf097dbae7080645435fc273e2397f26bbf1d127bbb", + "sha256:1f46532afa7b2903bfb1b79becca2954c0a04389d19e03dc73f06b039048ac40", + "sha256:315fa1b1dfc16ae0f03f8fd1c55f23fd15368710f641d570236f3d78af55e340", + "sha256:3d5fcea4f5ed40c3280791d54da3ad2ecf896f4c87c877b113576b8280c59441", + "sha256:48241759b99d60aba63b0e590332c600fc4b46ad597c9b0a53f350b871ef0634", + "sha256:4b4f2924b36d857cf302aec369caac61e43500c17eeef0d7baacad1084c0ee84", + "sha256:54fe3b7ed9e7eb928bbc4318f954d133851865f062fa4bbb02ef8940bc67b5d2", + "sha256:5a8f021c70e6206c317974c93eaaf9bc2b56295b6b1cacccf88846e44a1f33fc", + "sha256:754a6be26d938e6ca91942804eb209307b73f806a1721176278a6038869a1686", + "sha256:771147e654e8b95eea1293174a94f34e2e77d5729ad44aefb62fbf8a79747a15", + "sha256:78a6f89da87eeb48014ec652a65c4ffde370c036d780a995edaeb121d3625621", + "sha256:7fde5c2a3a682a9e101e61d97696687ebdba47637611378b4127fe7e47fdf2bf", + "sha256:80d99399c97f646e873dd8ce87c38cfdbb668956bbc39bc1e6cac4b515bba2a0", + "sha256:88a72c1e45a0ae24d1f249a529d9f71fe82e6fa6a3fd61414b829396ec585900", + "sha256:a4f4460877a16ac73302a9c077ca545498d9fe64e6a81398d8e1a67e4695e3df", + "sha256:a61255a765b3ac73ee4b110b28fccfbf758c985677f526c2b4b39c48cc4b509d", + "sha256:ab4896a8c910b9a04c0142871d8800c76c8a2e5ff44763513e1dd9d9631ce897", + "sha256:abbd6b1c2ef6199f4b7ca9f818eb6b31f17b73a6110aadc4e4298c3f00fab24e", + "sha256:b16d88da290334e33ea992c56492326ea3b06233a00a1855414360b77ca72f26", + "sha256:b78a1defedb0e8f6ae1eb55fa6ac74ab42acc4569c3a2eacc2a407ee5d42ebcb", + "sha256:cfef82c43b8b29ca436560d51b2251d5117818a8d1fb74a8384a83c096745dad", + "sha256:d160e57731fcdec2beda807ebcabf39823c47e9409485b5a3a1db3a8c6ce763e" + ], + "version": "==1.16.3" + }, + "plac": { + "hashes": [ + "sha256:854693ad90367e8267112ffbb8955f57d6fdeac3191791dc9ffce80f87fd2370", + "sha256:ba3f719a018175f0a15a6b04e6cc79c25fd563d348aacd320c3644d2a9baf89b" + ], + "version": "==0.9.6" + }, + "preshed": { + "hashes": [ + "sha256:0c9af79c7b825793f987d477627efb81afd23384ac791bebbc88a257342a77ab", + "sha256:0ebc79431154bc5d12f97b3c93bc350af941702a44f0761dfcd395e970d693f8", + "sha256:102e71dc841c979b2ece44ab05b2b0aa39c8039493ddac40dd22cf23e2484063", + "sha256:15145b24eded01426544be829a6395d6c99e2d62f5f3b88a6e19087ebeef7237", + "sha256:195674dfb4bcf18b26e448feaabdf61adcf028ae69ecaa075c0bdfaf62a19671", + "sha256:38f7fbef59f89d3b2c8c3b102f9a7360cd73a33c829fdeb101c615b18ecc4686", + "sha256:3aa411233dc230247ea4c4558062e5b2d59d41c697107a45fddbfe03e63f3e77", + "sha256:3b8c7b607e6dce0843544cfe4f05355db0516fce8eca0c37d6b5f4f3680493bf", + "sha256:4bda4153d46a603bc6ea65380dfa091d46700f664cb906c7f26a469be6c2a503", + "sha256:541d7ed765d67512d6f9fa24fd01cc1d7a51c7ff2646362924f4db46813b485a", + "sha256:593d23b9f851ae7a4d519ca4489dd2b352d833e08f5d35795d42a591b8badb54", + "sha256:7f6fb8f4108abe958af892847ed50abe6f45aaf45a87853cc8154a7203e75d84", + "sha256:7ff7f18af1f19ea666ac4fbf48842e6acd900fbfdc26bb9aad02f353ff932386", + "sha256:9c0d503d8693bf1e08e0fa1cecbcd3253146abaa9a7501d7d583a72edd29fdd1", + "sha256:9cefe818a97134c0ddf22ef76fced1c841ebd137c2895251c5d1310276c234b5", + "sha256:9e603916a95dc524081d54c0a135611e6f68d787185d5df2b5ab3f076c3d1bd4", + "sha256:a2acacceac79aa6d4b65125e20c7de78fbca1340a251854c87967acef1795490", + "sha256:a3d592e7b265b4faf08c9b4d7493b9e8604e0ba8858cc9bd8c9aee41d3df2a3a", + "sha256:b2030e68c6f539e6dd7bfcea032940042739ef05d50a2eb1d7af24e038971b0f", + "sha256:bc894dc14d8567a5d6a1cded0a701da7fbb360b2124237fe8acde85333825aef", + "sha256:c21d4d10cc0248ba3facbbbfbe63211ce921478a3d5db6de34de39ee1b3484e1", + "sha256:dae01c74313965c487e0ec839e5f28d0c7df9bfd1d978aa5bada3f72ff20a9e5", + "sha256:ee8068035684a4b382bebb3a3f270799360545baff9742b85e627a0a889e6850" + ], + "version": "==2.0.1" + }, + "protobuf": { + "hashes": [ + "sha256:21e395d7959551e759d604940a115c51c6347d90a475c9baf471a1a86b5604a9", + "sha256:57e05e16955aee9e6a0389fcbd58d8289dd2420e47df1a1096b3a232c26eb2dd", + "sha256:67819e8e48a74c68d87f25cad9f40edfe2faf278cdba5ca73173211b9213b8c9", + "sha256:75da7d43a2c8a13b0bc7238ab3c8ae217cbfd5979d33b01e98e1f78defb2d060", + "sha256:78e08371e236f193ce947712c072542ff19d0043ab5318c2ea46bbc2aaebdca6", + "sha256:7ee5b595db5abb0096e8c4755e69c20dfad38b2d0bcc9bc7bafc652d2496b471", + "sha256:86260ecfe7a66c0e9d82d2c61f86a14aa974d340d159b829b26f35f710f615db", + "sha256:92c77db4bd33ea4ee5f15152a835273f2338a5246b2cbb84bab5d0d7f6e9ba94", + "sha256:9c7b90943e0e188394b4f068926a759e3b4f63738190d1ab3d500d53b9ce7614", + "sha256:a77f217ea50b2542bae5b318f7acee50d9fc8c95dd6d3656eaeff646f7cab5ee", + "sha256:ad589ed1d1f83db22df867b10e01fe445516a5a4d7cfa37fe3590a5f6cfc508b", + "sha256:b06a794901bf573f4b2af87e6139e5cd36ac7c91ac85d7ae3fe5b5f6fc317513", + "sha256:bd8592cc5f8b4371d0bad92543370d4658dc41a5ccaaf105597eb5524c616291", + "sha256:be48e5a6248a928ec43adf2bea037073e5da692c0b3c10b34f9904793bd63138", + "sha256:cc5eb13f5ccc4b1b642cc147c2cdd121a34278b341c7a4d79e91182fff425836", + "sha256:cd3b0e0ad69b74ee55e7c321f52a98effed2b4f4cc9a10f3683d869de00590d5", + "sha256:d6e88c4920660aa75c0c2c4b53407aef5efd9a6e0ca7d2fc84d79aba2ccbda3a", + "sha256:ec3c49b6d247152e19110c3a53d9bb4cf917747882017f70796460728b02722e" + ], + "index": "pypi", + "version": "==3.7.1" + }, + "pygments": { + "hashes": [ + "sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a", + "sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d" + ], + "version": "==2.3.1" + }, + "pyre2": { + "file": "https://github.com/torosent/pyre2/archive/release/0.2.23.zip" + }, + "pyyaml": { + "hashes": [ + "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c", + "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95", + "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2", + "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4", + "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad", + "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba", + "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1", + "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e", + "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673", + "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13", + "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19" + ], + "version": "==5.1" + }, + "regex": { + "hashes": [ + "sha256:020429dcf9b76cc7648a99c81b3a70154e45afebc81e0b85364457fe83b525e4", + "sha256:0552802b1c3f3c7e4fee8c85e904a13c48226020aa1a0593246888a1ac55aaaf", + "sha256:308965a80b92e1fec263ac1e4f1094317809a72bc4d26be2ec8a5fd026301175", + "sha256:4d627feef04eb626397aa7bdec772774f53d63a1dc7cc5ee4d1bd2786a769d19", + "sha256:93d1f9fcb1d25e0b4bd622eeba95b080262e7f8f55e5b43c76b8a5677e67334c", + "sha256:c3859bbf29b1345d694f069ddfe53d6907b0393fda5e3794c800ad02902d78e9", + "sha256:d56ce4c7b1a189094b9bee3b81c4aeb3f1ba3e375e91627ec8561b6ab483d0a8", + "sha256:ebc5ef4e10fa3312fa1967dc0a894e6bd985a046768171f042ac3974fadc9680", + "sha256:f9cd39066048066a4abe4c18fb213bc541339728005e72263f023742fb912585" + ], + "index": "pypi", + "version": "==2019.4.14" + }, + "requests": { + "hashes": [ + "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", + "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" + ], + "version": "==2.21.0" + }, + "requests-file": { + "hashes": [ + "sha256:75c175eed739270aec3c5279ffd74e6527dada275c5c0d76b5817e9c86bb7dea", + "sha256:8f04aa6201bacda0567e7ac7f677f1499b0fc76b22140c54bc06edf1ba92e2fa" + ], + "version": "==1.4.3" + }, + "six": { + "hashes": [ + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + ], + "version": "==1.12.0" + }, + "spacy": { + "hashes": [ + "sha256:0fe2e5905f2f5b41be3ebea40626f70bea567a7a2cda9c244109fffe8d964429", + "sha256:30f0f09074bf115a0384691e8ba3d64aab431192b3095a13312a93d0e8a71c07", + "sha256:6a82612f0e75c11d541002f49375d80b4800c967e5d2b402d5a8dd40b6c57ae6", + "sha256:74066ac969a587d16d00d65318c1baa3c3e9215e6858d0c81ce2823320fe09dc", + "sha256:b1b86ddf6142fa2782b2e0269d040430ae5696eb0224f3e99408897cac7bb506", + "sha256:be8a7c89461ac22d261e19e1d3eb35752d8ff3e52452af076b303561bb166408", + "sha256:e6522e1242a5a5f12ef7e55f74df020b5deea59f7d1e7b6e69298301e3c0badd", + "sha256:eb699f54bf6d131df701e6dbbef9e91b74a065a42c9d2850964282b3c14560bb", + "sha256:f385942c5b2c8cf07e4a56871f88a49d4c8a9145fcd731c455e39fb5af9b12ba" + ], + "index": "pypi", + "version": "==2.1.3" + }, + "srsly": { + "hashes": [ + "sha256:02ea974c4b80f9ffdea4f953ffece5a8715e4e4b37d09192ab65cf4edfbf74d1", + "sha256:061ade35556e51b2e1da6f8552be7a6327d2d02b69edf0aacc9f5c4319d495f1", + "sha256:1bf6af7a86f34969a3997da09fc8c2f72ee02cd74ff40035e37c2f968776fa23", + "sha256:1e4ef85bf133e384f465865ba4e0a14a52c4f2e4b46c763faf100339a06f09c4", + "sha256:850399e43f4cefdcac7a913363b120ea084cb02fcfdbbde1bd37444804d7def4", + "sha256:977aa6e5fd3f7e9d1c8fe7aeed841dfe3ede75dfce04255d4c670e663faaef2a", + "sha256:abdc5b46866648b123517550582dc4c4b767b816ae54c44e5973bbebc3f0dab4", + "sha256:ac0dbe6e715e1fe3536397a9e65ec8f3c624c99f45b6f30e87d220071ef84721", + "sha256:b8646f0f7cf6fd1de4919ab456d9c030e09e74f741a0cecc941363414109ccdc", + "sha256:b9dc81339c1ab969057e790d7b2a56fd4da87336785bd671c86520e8272e3663", + "sha256:d7c91f59edc2ceeca70adf1b0a46d337234ff4fb7ca2b579ca41885f011b329f", + "sha256:d906a2a3df1cac2cb4bf382b8aaf14e22df2ca3758eba0d3049723c851c8ebf0", + "sha256:ecec49c9cdaae4594011666dd654e1e044e552f63bb3a62a1849c65a92ee302e", + "sha256:ef7897050c04a313f2db99c9bcaf2f0c3c75609677683ca5a6e1e7a515325d72" + ], + "version": "==0.0.5" + }, + "tabulate": { + "hashes": [ + "sha256:8af07a39377cee1103a5c8b3330a421c2d99b9141e9cc5ddd2e3263fea416943" + ], + "version": "==0.8.3" + }, + "thinc": { + "hashes": [ + "sha256:12c003b804fb93c64261a5010df0129f942234adb8f45d489a355a5315e06acf", + "sha256:17f9ada01f1f77a5560bc16ec5a650dca08356b50727ded0df19f0dfb4a32a25", + "sha256:26c9d54ffd90753feebbc462ae59939a9e3d2485ef24ed3dc1861c9b486fdbbe", + "sha256:3258161fc2cefa4082f099dec3748f1dcef5e920df5e9d82258ea6ffec280b9a", + "sha256:38a83b928cdc49c994852538f639b2a889681a0589c44b1a6fc3c899e5f36893", + "sha256:3e76101a733bbb0b97d44bdbcb407678b9e2b487047acb6f4c19b72909a6b12f", + "sha256:412f107c458d2951711b4d3ec53587244cd3acc032944e855f49cf94a1adc36e", + "sha256:4948c10c61e627950900cdccf506eb7398d2b28f33cf72bb4b5d9c5c572925e7", + "sha256:a8b2d7713a7dfc0b18b5c16db58ab6e015df14e4fbed0249ed49e630b2d6a86f", + "sha256:ec99c2c65962157c7ee7b947d29f2775291860b81cba62c5bd9f92fdeca2d137", + "sha256:f2386e66042218f19e511692926cef00a9646a3104d2efddfb5bec7b0388a83b", + "sha256:fc0b37733591315afddee45823d4f6740f9b0567c1ba57a3a3c319669d1fcbad" + ], + "version": "==7.0.4" + }, + "tldextract": { + "hashes": [ + "sha256:2c1c5d9d454f79734b4f3da0d603856dd9f820753410a3e9abf0a0c9fde33e97", + "sha256:b72bef6013de67c7fa181250bc2c2e089a994d259c09ca95a9771f2f97e29ed1" + ], + "index": "pypi", + "version": "==2.2.1" + }, + "tqdm": { + "hashes": [ + "sha256:d385c95361699e5cf7622485d9b9eae2d4864b21cd5a2374a9c381ffed701021", + "sha256:e22977e3ebe961f72362f6ddfb9197cc531c9737aaf5f607ef09740c849ecd05" + ], + "version": "==4.31.1" + }, + "urllib3": { + "hashes": [ + "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4", + "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb" + ], + "version": "==1.24.3" + }, + "wasabi": { + "hashes": [ + "sha256:b4fbee9dd0c8f5cff6554c0463c565e2d52b7c844d7eccb477d29a6ff8567750", + "sha256:f92c83e728bf1db6dc859ffc861afa328d2da8ef0c7a19300e5fb1bd5762b277" + ], + "version": "==0.2.2" + } + }, + "develop": { + "astroid": { + "hashes": [ + "sha256:6560e1e1749f68c64a4b5dee4e091fce798d2f0d84ebe638cf0e0585a343acf4", + "sha256:b65db1bbaac9f9f4d190199bb8680af6f6f84fd3769a5ea883df8a91fe68b4c4" + ], + "version": "==2.2.5" + }, + "atomicwrites": { + "hashes": [ + "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", + "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" + ], + "version": "==1.3.0" + }, + "attrs": { + "hashes": [ + "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", + "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" + ], + "version": "==19.1.0" + }, + "entrypoints": { + "hashes": [ + "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", + "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" + ], + "version": "==0.3" + }, + "flake8": { + "hashes": [ + "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", + "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8" + ], + "index": "pypi", + "version": "==3.7.7" + }, + "isort": { + "hashes": [ + "sha256:1349c6f7c2a0f7539f5f2ace51a9a8e4a37086ce4de6f78f5f53fb041d0a3cd5", + "sha256:f09911f6eb114e5592abe635aded8bf3d2c3144ebcfcaf81ee32e7af7b7d1870" + ], + "version": "==4.3.18" + }, + "lazy-object-proxy": { + "hashes": [ + "sha256:0ce34342b419bd8f018e6666bfef729aec3edf62345a53b537a4dcc115746a33", + "sha256:1b668120716eb7ee21d8a38815e5eb3bb8211117d9a90b0f8e21722c0758cc39", + "sha256:209615b0fe4624d79e50220ce3310ca1a9445fd8e6d3572a896e7f9146bbf019", + "sha256:27bf62cb2b1a2068d443ff7097ee33393f8483b570b475db8ebf7e1cba64f088", + "sha256:27ea6fd1c02dcc78172a82fc37fcc0992a94e4cecf53cb6d73f11749825bd98b", + "sha256:2c1b21b44ac9beb0fc848d3993924147ba45c4ebc24be19825e57aabbe74a99e", + "sha256:2df72ab12046a3496a92476020a1a0abf78b2a7db9ff4dc2036b8dd980203ae6", + "sha256:320ffd3de9699d3892048baee45ebfbbf9388a7d65d832d7e580243ade426d2b", + "sha256:50e3b9a464d5d08cc5227413db0d1c4707b6172e4d4d915c1c70e4de0bbff1f5", + "sha256:5276db7ff62bb7b52f77f1f51ed58850e315154249aceb42e7f4c611f0f847ff", + "sha256:61a6cf00dcb1a7f0c773ed4acc509cb636af2d6337a08f362413c76b2b47a8dd", + "sha256:6ae6c4cb59f199d8827c5a07546b2ab7e85d262acaccaacd49b62f53f7c456f7", + "sha256:7661d401d60d8bf15bb5da39e4dd72f5d764c5aff5a86ef52a042506e3e970ff", + "sha256:7bd527f36a605c914efca5d3d014170b2cb184723e423d26b1fb2fd9108e264d", + "sha256:7cb54db3535c8686ea12e9535eb087d32421184eacc6939ef15ef50f83a5e7e2", + "sha256:7f3a2d740291f7f2c111d86a1c4851b70fb000a6c8883a59660d95ad57b9df35", + "sha256:81304b7d8e9c824d058087dcb89144842c8e0dea6d281c031f59f0acf66963d4", + "sha256:933947e8b4fbe617a51528b09851685138b49d511af0b6c0da2539115d6d4514", + "sha256:94223d7f060301b3a8c09c9b3bc3294b56b2188e7d8179c762a1cda72c979252", + "sha256:ab3ca49afcb47058393b0122428358d2fbe0408cf99f1b58b295cfeb4ed39109", + "sha256:bd6292f565ca46dee4e737ebcc20742e3b5be2b01556dafe169f6c65d088875f", + "sha256:cb924aa3e4a3fb644d0c463cad5bc2572649a6a3f68a7f8e4fbe44aaa6d77e4c", + "sha256:d0fc7a286feac9077ec52a927fc9fe8fe2fabab95426722be4c953c9a8bede92", + "sha256:ddc34786490a6e4ec0a855d401034cbd1242ef186c20d79d2166d6a4bd449577", + "sha256:e34b155e36fa9da7e1b7c738ed7767fc9491a62ec6af70fe9da4a057759edc2d", + "sha256:e5b9e8f6bda48460b7b143c3821b21b452cb3a835e6bbd5dd33aa0c8d3f5137d", + "sha256:e81ebf6c5ee9684be8f2c87563880f93eedd56dd2b6146d8a725b50b7e5adb0f", + "sha256:eb91be369f945f10d3a49f5f9be8b3d0b93a4c2be8f8a5b83b0571b8123e0a7a", + "sha256:f460d1ceb0e4a5dcb2a652db0904224f367c9b3c1470d5a7683c0480e582468b" + ], + "version": "==1.3.1" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "more-itertools": { + "hashes": [ + "sha256:2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7", + "sha256:c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a" + ], + "markers": "python_version > '2.7'", + "version": "==7.0.0" + }, + "pluggy": { + "hashes": [ + "sha256:19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f", + "sha256:84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746" + ], + "version": "==0.9.0" + }, + "py": { + "hashes": [ + "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", + "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" + ], + "version": "==1.8.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", + "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" + ], + "version": "==2.5.0" + }, + "pyflakes": { + "hashes": [ + "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", + "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" + ], + "version": "==2.1.1" + }, + "pylint": { + "hashes": [ + "sha256:5d77031694a5fb97ea95e828c8d10fc770a1df6eb3906067aaed42201a8a6a09", + "sha256:723e3db49555abaf9bf79dc474c6b9e2935ad82230b10c1138a71ea41ac0fff1" + ], + "index": "pypi", + "version": "==2.3.1" + }, + "pytest": { + "hashes": [ + "sha256:3773f4c235918987d51daf1db66d51c99fac654c81d6f2f709a046ab446d5e5d", + "sha256:b7802283b70ca24d7119b32915efa7c409982f59913c1a6c0640aacf118b95f5" + ], + "index": "pypi", + "version": "==4.4.1" + }, + "six": { + "hashes": [ + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + ], + "version": "==1.12.0" + }, + "typed-ast": { + "hashes": [ + "sha256:132eae51d6ef3ff4a8c47c393a4ef5ebf0d1aecc96880eb5d6c8ceab7017cc9b", + "sha256:18141c1484ab8784006c839be8b985cfc82a2e9725837b0ecfa0203f71c4e39d", + "sha256:2baf617f5bbbfe73fd8846463f5aeafc912b5ee247f410700245d68525ec584a", + "sha256:3d90063f2cbbe39177e9b4d888e45777012652d6110156845b828908c51ae462", + "sha256:4304b2218b842d610aa1a1d87e1dc9559597969acc62ce717ee4dfeaa44d7eee", + "sha256:4983ede548ffc3541bae49a82675996497348e55bafd1554dc4e4a5d6eda541a", + "sha256:5315f4509c1476718a4825f45a203b82d7fdf2a6f5f0c8f166435975b1c9f7d4", + "sha256:6cdfb1b49d5345f7c2b90d638822d16ba62dc82f7616e9b4caa10b72f3f16649", + "sha256:7b325f12635598c604690efd7a0197d0b94b7d7778498e76e0710cd582fd1c7a", + "sha256:8d3b0e3b8626615826f9a626548057c5275a9733512b137984a68ba1598d3d2f", + "sha256:8f8631160c79f53081bd23446525db0bc4c5616f78d04021e6e434b286493fd7", + "sha256:912de10965f3dc89da23936f1cc4ed60764f712e5fa603a09dd904f88c996760", + "sha256:b010c07b975fe853c65d7bbe9d4ac62f1c69086750a574f6292597763781ba18", + "sha256:c908c10505904c48081a5415a1e295d8403e353e0c14c42b6d67f8f97fae6616", + "sha256:c94dd3807c0c0610f7c76f078119f4ea48235a953512752b9175f9f98f5ae2bd", + "sha256:ce65dee7594a84c466e79d7fb7d3303e7295d16a83c22c7c4037071b059e2c21", + "sha256:eaa9cfcb221a8a4c2889be6f93da141ac777eb8819f077e1d09fb12d00a09a93", + "sha256:f3376bc31bad66d46d44b4e6522c5c21976bf9bca4ef5987bb2bf727f4506cbb", + "sha256:f9202fa138544e13a4ec1a6792c35834250a85958fde1251b6a22e07d1260ae7" + ], + "markers": "implementation_name == 'cpython'", + "version": "==1.3.5" + }, + "wrapt": { + "hashes": [ + "sha256:4aea003270831cceb8a90ff27c4031da6ead7ec1886023b80ce0dfe0adf61533" + ], + "version": "==1.11.1" + } + } +} diff --git a/presidio-analyzer/requirements-dev.txt b/presidio-analyzer/requirements-dev.txt deleted file mode 100644 index 1c052a6d3..000000000 --- a/presidio-analyzer/requirements-dev.txt +++ /dev/null @@ -1,3 +0,0 @@ -pytest -flake8 -pylint==2.3.1 \ No newline at end of file diff --git a/presidio-analyzer/requirements.txt b/presidio-analyzer/requirements.txt deleted file mode 100644 index 54f05e911..000000000 --- a/presidio-analyzer/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -cython -https://github.com/explosion/spacy-models/releases/download/en_core_web_lg-2.1.0/en_core_web_lg-2.1.0.tar.gz -https://github.com/torosent/pyre2/archive/release/0.2.23.zip -grpcio -protobuf -tldextract -knack \ No newline at end of file From 8c148fe3fb8af8c5491862861fbff90535fb30f3 Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Mon, 6 May 2019 18:36:28 +0300 Subject: [PATCH 35/75] Update docs to reflect pipenv changes --- docs/development.md | 10 ++-------- .../Pipenv_Readme.md => docs/pipenv_readme.md | 0 2 files changed, 2 insertions(+), 8 deletions(-) rename presidio-analyzer/Pipenv_Readme.md => docs/pipenv_readme.md (100%) diff --git a/docs/development.md b/docs/development.md index a564ad728..b93b4109a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -34,16 +34,10 @@ 6. Install the Python packages for the analyzer in the `presidio-analyzer` folder ```sh - pip3 install -r requirements.txt - pip3 install -r requirements-dev.txt - ``` - - **Note:** If you encounter errors with `pyre2` than install `cython` first - - ```sh - $ pip3 install cython + pipenv install --dev --sequential ``` + For additional information regarding Pipenv click [here](pipenv_readme.md) 7. Install [tesseract](https://github.com/tesseract-ocr/tesseract/wiki) OCR framework. 8. Protobuf generator tools (Optional) diff --git a/presidio-analyzer/Pipenv_Readme.md b/docs/pipenv_readme.md similarity index 100% rename from presidio-analyzer/Pipenv_Readme.md rename to docs/pipenv_readme.md From 11ddd4a173ec5a26a2d00dfeaf6aa24e7a3b3372 Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Tue, 7 May 2019 09:59:13 +0300 Subject: [PATCH 36/75] cleaning up --- Dockerfile.python.deps | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile.python.deps b/Dockerfile.python.deps index 1b68c9fe1..4e26f3054 100644 --- a/Dockerfile.python.deps +++ b/Dockerfile.python.deps @@ -7,14 +7,12 @@ COPY ./${NAME}/Pipfile.lock /usr/bin/${NAME}/Pipfile.lock WORKDIR /usr/bin/${NAME} -RUN apk add make automake gcc g++ subversion python3-dev - RUN apk --update add --no-cache g++ && \ apk --update add --no-cache --virtual build_deps make tar wget clang && \ wget -O re2.tar.gz https://github.com/google/re2/archive/${re2_version}.tar.gz && \ mkdir re2 && tar --extract --file "re2.tar.gz" --directory "re2" --strip-components 1 && \ cd re2 && make install && cd .. && rm -rf re2 && rm re2.tar.gz && \ - apk del build_deps + apk add make automake gcc g++ subversion python3-dev # Making sure we have pipenv RUN pip3 install pipenv @@ -24,3 +22,5 @@ RUN pip3 install --upgrade setuptools RUN pipenv sync # Print to screen the installed packages for easy debugging RUN pipenv run pip freeze + +RUN apk del build_deps From f624b33eefa6fbc9f91687793071e5fce59e62f8 Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Tue, 7 May 2019 13:27:16 +0300 Subject: [PATCH 37/75] wip --- Dockerfile.python.deps | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.python.deps b/Dockerfile.python.deps index 4e26f3054..bd286c542 100644 --- a/Dockerfile.python.deps +++ b/Dockerfile.python.deps @@ -12,7 +12,7 @@ RUN apk --update add --no-cache g++ && \ wget -O re2.tar.gz https://github.com/google/re2/archive/${re2_version}.tar.gz && \ mkdir re2 && tar --extract --file "re2.tar.gz" --directory "re2" --strip-components 1 && \ cd re2 && make install && cd .. && rm -rf re2 && rm re2.tar.gz && \ - apk add make automake gcc g++ subversion python3-dev + apk add --virtual build_deps make automake gcc g++ subversion python3-dev # Making sure we have pipenv RUN pip3 install pipenv From d38a8cb8b04c2163e888035a0ae7e44a4fb6b4fd Mon Sep 17 00:00:00 2001 From: Itye Richter Date: Tue, 7 May 2019 15:56:08 +0300 Subject: [PATCH 38/75] expanded the windows support doc --- docs/development.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/docs/development.md b/docs/development.md index b93b4109a..b59978356 100644 --- a/docs/development.md +++ b/docs/development.md @@ -82,6 +82,42 @@ - `ANALYZER_SVC_ADDRESS`: `localhost:3001`, Analyzer address - `ANONYMIZER_SVC_ADDRESS`: `localhost:3002`, Anonymizer address +### Developing only for Presidio Analyzer under Windows environment +Run locally the core services Presidio needs to operate: +``` +docker run --rm --name test-redis --network testnetwork -d -p 6379:6379 redis +docker run --rm --name test-presidio-anonymizer --network testnetwork -d -p 3001:3001 -e GRPC_PORT=3001 mcr.microsoft.com/presidio-anonymizer:latest +docker run --rm --name test-presidio-recognizers-store --network testnetwork -d -p 3004:3004 -e GRPC_PORT=3004 -e REDIS_URL=test-redis:6379 mcr.microsoft.com/presidio-recognizers-store:latest +``` +Naviagate to `\presidio-analyzer\` + +Install the python packages if didn't do so yet: +```sh + pipenv install --dev --sequential +``` + +Execute the virtual env: +``` +pipenv shell +``` + +To simply run unit tests, execute: +``` +pytest --log-cli-level=0 +``` + +If you want to experiment with `analyze` requests, navigate into the `analyzer` folder and start serving the analyzer service: +```sh +python __main__.py serve --grpc-port 3000 +``` + +In a new `pipenv shell` window you can run `analyze` requests, for example: +``` +python __main__.py analyze --text "John Smith drivers license is AC432223" --fields "PERSON" "US_DRIVER_LICENSE" --grpc-port 3000 +``` + + + ## Load test 1. Edit `post.lua`. Change the template name From 805e39cdc22df401f1413987b2fa068d113dc108 Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Mon, 20 May 2019 15:22:10 +0300 Subject: [PATCH 39/75] update document --- docs/pipenv_readme.md | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/docs/pipenv_readme.md b/docs/pipenv_readme.md index 8f7838055..924a74e22 100644 --- a/docs/pipenv_readme.md +++ b/docs/pipenv_readme.md @@ -12,25 +12,18 @@ $ brew install pipenv Additional installation instructions: https://pipenv.readthedocs.io/en/latest/install/#installing-pipenv - -### 2. Create virtualenv for the project -From the python project's root folder, run: -``` -$ pipenv shell -``` - -### 3. Install all requirements in the Pipfile, including dev requirements - +### 2. Create virtualenv for the project & Install all requirements in the Pipfile, including dev requirements +rom the python project's root folder, run: ``` $ pipenv install --dev --sequential ``` -### 4. Run all tests +### 3. Run all tests ``` $ pipenv run pytest ``` -### 5. To run arbitrary scripts within the virtual env, start the command with `pipenv run`. For example: +### 4. To run arbitrary scripts within the virtual env, start the command with `pipenv run`. For example: 1. `pipenv run flake8 analyzer --exclude "*pb2*.py"` 2. `pipenv run pylint analyzer` 3. `pipenv run pip freeze` From 53f232bac5c6e30accd4875fdc0acdd7a4cb4da0 Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Mon, 20 May 2019 17:06:19 +0300 Subject: [PATCH 40/75] update doc with pipenv shell option --- docs/pipenv_readme.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/pipenv_readme.md b/docs/pipenv_readme.md index 924a74e22..66f502f07 100644 --- a/docs/pipenv_readme.md +++ b/docs/pipenv_readme.md @@ -28,6 +28,15 @@ $ pipenv run pytest 2. `pipenv run pylint analyzer` 3. `pipenv run pip freeze` +#### Alternatively, activate the virtual environment and use the commands without the 'pipenv run' prefix +``` +$ pipenv shell +$ pytest +$ pylint analyzer +$ pip freeze +``` + + ## General pipenv instructions Pipenv documentation: https://pipenv.readthedocs.io/en/latest/ From 7a8f3ba40e3dfae2e820622487b84f1a68bd50ac Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Wed, 22 May 2019 14:46:15 +0300 Subject: [PATCH 41/75] Updating docs --- docs/development.md | 80 +++++++++++++++++++++++++++++-------------- docs/pipenv_readme.md | 39 --------------------- 2 files changed, 54 insertions(+), 65 deletions(-) diff --git a/docs/development.md b/docs/development.md index b59978356..a0c35365f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,7 +1,7 @@ # Development -## Setting up the environment +## Setting up the environment - Golang 1. Docker @@ -21,7 +21,27 @@ dep ensure ``` -5. Build and install [re2](https://github.com/google/re2) +5. Install [tesseract](https://github.com/tesseract-ocr/tesseract/wiki) OCR framework. + +6. Protobuf generator tools (Optional) + + - `https://github.com/golang/protobuf` + + - `https://grpc.io/docs/tutorials/basic/python.html` + + To generate proto files, clone [presidio-genproto](https://github.com/Microsoft/presidio-genproto) and run the following commands in `$GOPATH/src/github.com/Microsoft/presidio-genproto/src` folder + + ```sh + python -m grpc_tools.protoc -I . --python_out=../python --grpc_python_out=../python ./*.proto + ``` + + ```sh + protoc -I . --go_out=plugins=grpc:../golang ./*.proto + ``` + +## Setting up the environment - Python + +1. Build and install [re2](https://github.com/google/re2) ```sh re2_version="2018-12-01" @@ -31,29 +51,42 @@ cd re2 && make install ``` -6. Install the Python packages for the analyzer in the `presidio-analyzer` folder +2. Install pipenv - ```sh - pipenv install --dev --sequential + [Pipenv](https://pipenv.readthedocs.io/en/latest/) is a Python workflow manager, handling dependencies and environment for python packages, it is used in the Presidio's Analyzer project as the dependencies manager + #### Using Pip3: + ``` + $ pip3 install --user pipenv + ``` + #### Homebrew + ``` + $ brew install pipenv ``` - For additional information regarding Pipenv click [here](pipenv_readme.md) -7. Install [tesseract](https://github.com/tesseract-ocr/tesseract/wiki) OCR framework. - -8. Protobuf generator tools (Optional) + Additional installation instructions: https://pipenv.readthedocs.io/en/latest/install/#installing-pipenv - - `https://github.com/golang/protobuf` +3. Create virtualenv for the project & Install all requirements in the Pipfile, including dev requirements +Install the Python packages for the analyzer in the `presidio-analyzer` folder, run: + ``` + $ pipenv install --dev --sequential + ``` - - `https://grpc.io/docs/tutorials/basic/python.html` +4. Run all tests + ``` + $ pipenv run pytest + ``` - To generate proto files, clone [presidio-genproto](https://github.com/Microsoft/presidio-genproto) and run the following commands in `$GOPATH/src/github.com/Microsoft/presidio-genproto/src` folder +5. To run arbitrary scripts within the virtual env, start the command with `pipenv run`. For example: + 1. `pipenv run flake8 analyzer --exclude "*pb2*.py"` + 2. `pipenv run pylint analyzer` + 3. `pipenv run pip freeze` - ```sh - python -m grpc_tools.protoc -I . --python_out=../python --grpc_python_out=../python ./*.proto + #### Alternatively, activate the virtual environment and use the commands without the 'pipenv run' prefix ``` - - ```sh - protoc -I . --go_out=plugins=grpc:../golang ./*.proto + $ pipenv shell + $ pytest + $ pylint analyzer + $ pip freeze ``` ## Development notes @@ -64,7 +97,7 @@ - Run the tests with `make test` - Adding a file in go requires the `make go-format` command before running and building the service. - Run functional tests with `make test-functional` - +- Updating python dependencies [instructions](./pipenv_readme.md) ### Set the following environment variables #### presidio-analyzer @@ -96,24 +129,19 @@ Install the python packages if didn't do so yet: pipenv install --dev --sequential ``` -Execute the virtual env: -``` -pipenv shell -``` - To simply run unit tests, execute: ``` -pytest --log-cli-level=0 +pipenv run pytest --log-cli-level=0 ``` If you want to experiment with `analyze` requests, navigate into the `analyzer` folder and start serving the analyzer service: ```sh -python __main__.py serve --grpc-port 3000 +pipenv run python __main__.py serve --grpc-port 3000 ``` In a new `pipenv shell` window you can run `analyze` requests, for example: ``` -python __main__.py analyze --text "John Smith drivers license is AC432223" --fields "PERSON" "US_DRIVER_LICENSE" --grpc-port 3000 +pipenv run python __main__.py analyze --text "John Smith drivers license is AC432223" --fields "PERSON" "US_DRIVER_LICENSE" --grpc-port 3000 ``` diff --git a/docs/pipenv_readme.md b/docs/pipenv_readme.md index 66f502f07..6635fa907 100644 --- a/docs/pipenv_readme.md +++ b/docs/pipenv_readme.md @@ -1,42 +1,3 @@ -# Presidio analyzer - Pipenv installation -[Pipenv](https://pipenv.readthedocs.io/en/latest/) is a Python workflow manager, handling dependencies and environment for python packages. This tutorial describes how to install presidio-analyzer locally for development purposes. -### 1. Install pipenv -#### Using Pip: -``` -$ pip install --user pipenv -``` -#### Homebrew -``` -$ brew install pipenv -``` - -Additional installation instructions: https://pipenv.readthedocs.io/en/latest/install/#installing-pipenv - -### 2. Create virtualenv for the project & Install all requirements in the Pipfile, including dev requirements -rom the python project's root folder, run: -``` -$ pipenv install --dev --sequential -``` - -### 3. Run all tests -``` -$ pipenv run pytest -``` - -### 4. To run arbitrary scripts within the virtual env, start the command with `pipenv run`. For example: -1. `pipenv run flake8 analyzer --exclude "*pb2*.py"` -2. `pipenv run pylint analyzer` -3. `pipenv run pip freeze` - -#### Alternatively, activate the virtual environment and use the commands without the 'pipenv run' prefix -``` -$ pipenv shell -$ pytest -$ pylint analyzer -$ pip freeze -``` - - ## General pipenv instructions Pipenv documentation: https://pipenv.readthedocs.io/en/latest/ From cf6cb8ba31d1219be2a45dd13d27f773c10bfd0c Mon Sep 17 00:00:00 2001 From: Omri Mendels Date: Wed, 22 May 2019 15:18:29 +0300 Subject: [PATCH 42/75] Minor documentation changes --- docs/development.md | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/docs/development.md b/docs/development.md index a0c35365f..d865f68be 100644 --- a/docs/development.md +++ b/docs/development.md @@ -56,11 +56,11 @@ [Pipenv](https://pipenv.readthedocs.io/en/latest/) is a Python workflow manager, handling dependencies and environment for python packages, it is used in the Presidio's Analyzer project as the dependencies manager #### Using Pip3: ``` - $ pip3 install --user pipenv + pip3 install --user pipenv ``` #### Homebrew ``` - $ brew install pipenv + brew install pipenv ``` Additional installation instructions: https://pipenv.readthedocs.io/en/latest/install/#installing-pipenv @@ -68,12 +68,12 @@ 3. Create virtualenv for the project & Install all requirements in the Pipfile, including dev requirements Install the Python packages for the analyzer in the `presidio-analyzer` folder, run: ``` - $ pipenv install --dev --sequential + pipenv install --dev --sequential ``` 4. Run all tests ``` - $ pipenv run pytest + pipenv run pytest ``` 5. To run arbitrary scripts within the virtual env, start the command with `pipenv run`. For example: @@ -81,12 +81,19 @@ Install the Python packages for the analyzer in the `presidio-analyzer` folder, 2. `pipenv run pylint analyzer` 3. `pipenv run pip freeze` - #### Alternatively, activate the virtual environment and use the commands without the 'pipenv run' prefix +#### Alternatively, activate the virtual environment and use the commands by starting a pipenv shell: + +1. Start shell: + ``` - $ pipenv shell - $ pytest - $ pylint analyzer - $ pip freeze + pipenv shell + ``` +2. Run commands in the shell + + ``` + pytest + pylint analyzer + pip freeze ``` ## Development notes @@ -126,7 +133,7 @@ Naviagate to `\presidio-analyzer\` Install the python packages if didn't do so yet: ```sh - pipenv install --dev --sequential +pipenv install --dev --sequential ``` To simply run unit tests, execute: From 292f954b344d1d4a56b6eaa47e411877f92ca4b1 Mon Sep 17 00:00:00 2001 From: Avishay balter Date: Thu, 23 May 2019 14:59:56 +0300 Subject: [PATCH 43/75] update docs --- docs/development.md | 20 ++++++++++++++++++-- docs/install.md | 10 ++++++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/docs/development.md b/docs/development.md index d865f68be..51290815d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -99,12 +99,14 @@ Install the Python packages for the analyzer in the `presidio-analyzer` folder, ## Development notes - Build the bins with `make build` -- Build the the Docker image with `make docker-build` -- Push the Docker images with `make docker-push` +- Build the base containers with `make docker-build-deps DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_DEPS_LABEL=${PRESIDIO_DEPS_LABEL}` +- Build the the Docker image with `make docker-build DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_DEPS_LABEL=${PRESIDIO_DEPS_LABEL} PRESIDIO_LABEL=${PRESIDIO_LABEL}` +- Push the Docker images with `make docker-push DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_LABEL=${PRESIDIO_LABEL}` - Run the tests with `make test` - Adding a file in go requires the `make go-format` command before running and building the service. - Run functional tests with `make test-functional` - Updating python dependencies [instructions](./pipenv_readme.md) + ### Set the following environment variables #### presidio-analyzer @@ -161,3 +163,17 @@ pipenv run python __main__.py analyze --text "John Smith drivers license is AC43 ```sh wrk -t2 -c2 -d30s -s post.lua http:///api/v1/projects//analyze ``` + + +## Running in kubernetes + +1. If deploying from a private registry, verify that Kubernetes has access to the [Docker Registry](https://docs.microsoft.com/en-us/azure/container-registry/container-registry-auth-aks). + +2. If using a Kubernetes secert to manage the registry authentication, make sure it is registered under 'presidio' namespace + +### Further configuration + +Edit [charts/presidio/values.yaml](../charts/presidio/values.yaml) to: +- Setup secret name (for private registries) +- Change presidio services version +- Change default scale \ No newline at end of file diff --git a/docs/install.md b/docs/install.md index 81a62b43b..2483941cc 100644 --- a/docs/install.md +++ b/docs/install.md @@ -33,7 +33,11 @@ docker run --rm --name presidio-api --network mynetwork -d -p 8080:8080 -e WEB_P - Kubernetes 1.9+ with RBAC enabled. - Helm -### Installation +### Default installation using pre-made scripts + +Follow the installation guide at the [Readme page](https://github.com/Microsoft/presidio/blob/master/README.MD) + +### Step-by step installation with customizable parameters 1. Install [Helm](https://github.com/kubernetes/helm) with [RBAC](https://github.com/kubernetes/helm/blob/master/docs/rbac.md#tiller-and-role-based-access-control) @@ -54,4 +58,6 @@ docker run --rm --name presidio-api --network mynetwork -d -p 8080:8080 -e WEB_P ```sh # Based on the DOCKER_REGISTRY and PRESIDIO_LABEL from the previous steps helm install --name presidio-demo --set registry=${DOCKER_REGISTRY},tag=${PRESIDIO_LABEL} . --namespace presidio - ``` \ No newline at end of file + ``` + +6. For more options over the deployment, follow the [Development guide](https://github.com/Microsoft/presidio/blob/master/docs/development.md) \ No newline at end of file From bf3b4d95e19fa210c4567a207a63c17a5724ff70 Mon Sep 17 00:00:00 2001 From: Avishay balter Date: Thu, 23 May 2019 15:02:15 +0300 Subject: [PATCH 44/75] update dev --- docs/development.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/development.md b/docs/development.md index 51290815d..164058bf3 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,4 +1,3 @@ - # Development ## Setting up the environment - Golang From 5d0732883912998948de38908369f127823ef39f Mon Sep 17 00:00:00 2001 From: Avishay balter Date: Thu, 23 May 2019 15:11:28 +0300 Subject: [PATCH 45/75] fix k8s labels --- charts/presidio/templates/analyzer-service.yaml | 2 ++ charts/presidio/templates/anonymizer-image-service.yaml | 2 ++ charts/presidio/templates/anonymizer-service.yaml | 2 ++ charts/presidio/templates/api-ingress.yaml | 1 + charts/presidio/templates/api-service.yaml | 2 ++ charts/presidio/templates/ocr-service.yaml | 2 ++ charts/presidio/templates/recognizers-store-service.yaml | 2 ++ charts/presidio/templates/scheduler-service.yaml | 2 ++ charts/presidio/values.yaml | 9 ++++++++- 9 files changed, 23 insertions(+), 1 deletion(-) diff --git a/charts/presidio/templates/analyzer-service.yaml b/charts/presidio/templates/analyzer-service.yaml index 13917b98c..5a2f6935e 100644 --- a/charts/presidio/templates/analyzer-service.yaml +++ b/charts/presidio/templates/analyzer-service.yaml @@ -4,6 +4,8 @@ kind: Service metadata: name: {{ $fullname }} labels: + app: {{ $fullname }} + service: {{ $fullname }} chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}" release: "{{ .Release.Name }}" heritage: "{{ .Release.Service }}" diff --git a/charts/presidio/templates/anonymizer-image-service.yaml b/charts/presidio/templates/anonymizer-image-service.yaml index aa14d8c73..e9b045cf2 100644 --- a/charts/presidio/templates/anonymizer-image-service.yaml +++ b/charts/presidio/templates/anonymizer-image-service.yaml @@ -4,6 +4,8 @@ kind: Service metadata: name: {{ $fullname }} labels: + app: {{ $fullname }} + service: {{ $fullname }} chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}" release: "{{ .Release.Name }}" heritage: "{{ .Release.Service }}" diff --git a/charts/presidio/templates/anonymizer-service.yaml b/charts/presidio/templates/anonymizer-service.yaml index 85954a425..1cd466820 100644 --- a/charts/presidio/templates/anonymizer-service.yaml +++ b/charts/presidio/templates/anonymizer-service.yaml @@ -4,6 +4,8 @@ kind: Service metadata: name: {{ $fullname }} labels: + app: {{ $fullname }} + service: {{ $fullname }} chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}" release: "{{ .Release.Name }}" heritage: "{{ .Release.Service }}" diff --git a/charts/presidio/templates/api-ingress.yaml b/charts/presidio/templates/api-ingress.yaml index 11d6c60f3..82fad7fa3 100644 --- a/charts/presidio/templates/api-ingress.yaml +++ b/charts/presidio/templates/api-ingress.yaml @@ -12,6 +12,7 @@ metadata: heritage: {{ .Release.Service }} annotations: kubernetes.io/ingress.class: {{ .Values.api.ingress.class }} + nginx.ingress.kubernetes.io/rewrite-target: / spec: rules: - http: diff --git a/charts/presidio/templates/api-service.yaml b/charts/presidio/templates/api-service.yaml index 50a7f24fa..7d5ea7962 100644 --- a/charts/presidio/templates/api-service.yaml +++ b/charts/presidio/templates/api-service.yaml @@ -4,6 +4,8 @@ kind: Service metadata: name: {{ $fullname }} labels: + app: {{ $fullname }} + service: {{ $fullname }} chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}" release: "{{ .Release.Name }}" heritage: "{{ .Release.Service }}" diff --git a/charts/presidio/templates/ocr-service.yaml b/charts/presidio/templates/ocr-service.yaml index 6716f7f45..65f23fdb3 100644 --- a/charts/presidio/templates/ocr-service.yaml +++ b/charts/presidio/templates/ocr-service.yaml @@ -4,6 +4,8 @@ kind: Service metadata: name: {{ $fullname }} labels: + app: {{ $fullname }} + service: {{ $fullname }} chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}" release: "{{ .Release.Name }}" heritage: "{{ .Release.Service }}" diff --git a/charts/presidio/templates/recognizers-store-service.yaml b/charts/presidio/templates/recognizers-store-service.yaml index 8d69c390b..32feee776 100644 --- a/charts/presidio/templates/recognizers-store-service.yaml +++ b/charts/presidio/templates/recognizers-store-service.yaml @@ -4,6 +4,8 @@ kind: Service metadata: name: {{ $fullname }} labels: + app: {{ $fullname }} + service: {{ $fullname }} chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}" release: "{{ .Release.Name }}" heritage: "{{ .Release.Service }}" diff --git a/charts/presidio/templates/scheduler-service.yaml b/charts/presidio/templates/scheduler-service.yaml index 7a090add1..ce46e7e81 100644 --- a/charts/presidio/templates/scheduler-service.yaml +++ b/charts/presidio/templates/scheduler-service.yaml @@ -4,6 +4,8 @@ kind: Service metadata: name: {{ $fullname }} labels: + app: {{ $fullname }} + service: {{ $fullname }} chart: "{{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}" release: "{{ .Release.Name }}" heritage: "{{ .Release.Service }}" diff --git a/charts/presidio/values.yaml b/charts/presidio/values.yaml index 8075ef57e..7abcf148a 100644 --- a/charts/presidio/values.yaml +++ b/charts/presidio/values.yaml @@ -1,5 +1,5 @@ registry: mcr.microsoft.com - + # Image pull secret #privateRegistry: acr-auth tag: latest @@ -22,6 +22,7 @@ api: type: ClusterIP externalPort: 8080 internalPort: 8080 + name: http # Configure liveness probes except `httpGet` and the belongings #livenessProbe: # initialDelaySeconds: 20 @@ -40,6 +41,7 @@ analyzer: type: ClusterIP externalPort: 3001 internalPort: 3001 + name: grpc anonymizer: name: presidio-anonymizer @@ -48,6 +50,7 @@ anonymizer: type: ClusterIP externalPort: 3001 internalPort: 3001 + name: grpc anonymizerimage: name: presidio-anonymizer-image @@ -56,6 +59,7 @@ anonymizerimage: type: ClusterIP externalPort: 3001 internalPort: 3001 + name: grpc ocr: name: presidio-ocr @@ -64,6 +68,7 @@ ocr: type: ClusterIP externalPort: 3001 internalPort: 3001 + name: grpc scheduler: name: presidio-scheduler @@ -72,6 +77,7 @@ scheduler: type: ClusterIP externalPort: 3001 internalPort: 3001 + name: grpc recognizersstore: name: presidio-recognizers-store @@ -80,6 +86,7 @@ recognizersstore: type: ClusterIP externalPort: 3004 internalPort: 3004 + name: grpc tester: enabled: false From c60f8bdd408b13d5bd6864e39590663ee0d62634 Mon Sep 17 00:00:00 2001 From: Avishay balter Date: Thu, 23 May 2019 15:21:23 +0300 Subject: [PATCH 46/75] CI as code --- Dockerfile.golang.base | 5 +- Makefile | 52 ++++++++--- docs/build_release.md | 59 ++++++++++++ docs/index.md | 1 + pipelines/CI-deps.yaml | 89 +++++++++++++++++++ pipelines/CI-presidio.yaml | 72 +++++++++++++++ .../templates/presidio-build-template.yaml | 85 ++++++++++++++++++ presidio-analyzer/Dockerfile | 3 +- 8 files changed, 352 insertions(+), 14 deletions(-) create mode 100644 docs/build_release.md create mode 100644 pipelines/CI-deps.yaml create mode 100644 pipelines/CI-presidio.yaml create mode 100644 pipelines/templates/presidio-build-template.yaml diff --git a/Dockerfile.golang.base b/Dockerfile.golang.base index e23acccd4..e2b9155ad 100644 --- a/Dockerfile.golang.base +++ b/Dockerfile.golang.base @@ -1,9 +1,10 @@ ARG REGISTRY=presidio.azurecr.io +ARG PRESIDIO_DEPS_LABEL=latest -FROM ${REGISTRY}/presidio-golang-deps +FROM ${REGISTRY}/presidio-golang-deps:${PRESIDIO_DEPS_LABEL} WORKDIR $GOPATH/src/github.com/Microsoft/presidio ADD . $GOPATH/src/github.com/Microsoft/presidio RUN dep ensure -RUN make go-test +RUN make go-test \ No newline at end of file diff --git a/Makefile b/Makefile index a1bc1ff6a..5d7d07531 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,7 @@ GOLANG_BASE = presidio-golang-base GIT_TAG = $(shell git describe --tags --always 2>/dev/null) VERSION ?= ${GIT_TAG} PRESIDIO_LABEL := $(if $(PRESIDIO_LABEL),$(PRESIDIO_LABEL),$(VERSION)) +PRESIDIO_DEPS_LABEL := $(if $(PRESIDIO_DEPS_LABEL),$(PRESIDIO_DEPS_LABEL),'latest') LDFLAGS += -X github.com/Microsoft/presidio/pkg/version.Version=$(VERSION) CX_OSES = linux windows darwin @@ -27,14 +28,14 @@ $(BINS): vendor .PHONY: docker-build-deps docker-build-deps: - -docker pull $(DOCKER_REGISTRY)/$(GOLANG_DEPS) - -docker pull $(DOCKER_REGISTRY)/$(PYTHON_DEPS) - docker build -t $(DOCKER_REGISTRY)/$(GOLANG_DEPS) -f Dockerfile.golang.deps . - docker build -t $(DOCKER_REGISTRY)/$(PYTHON_DEPS) -f Dockerfile.python.deps . + -docker pull $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) + -docker pull $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) + docker build -t $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) -f Dockerfile.golang.deps . + docker build -t $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) -f Dockerfile.python.deps . .PHONY: docker-build-base docker-build-base: - docker build --build-arg REGISTRY=$(DOCKER_REGISTRY) -t $(DOCKER_REGISTRY)/$(GOLANG_BASE) -f Dockerfile.golang.base . + docker build --build-arg REGISTRY=$(DOCKER_REGISTRY) --build-arg PRESIDIO_DEPS_LABEL=$(PRESIDIO_DEPS_LABEL) -t $(DOCKER_REGISTRY)/$(GOLANG_BASE) -f Dockerfile.golang.base . # To use docker-build, you need to have Docker installed and configured. You should also set @@ -44,13 +45,34 @@ docker-build: docker-build-base docker-build: $(addsuffix -dimage,$(IMAGES)) %-dimage: - docker build $(DOCKER_BUILD_FLAGS) --build-arg REGISTRY=$(DOCKER_REGISTRY) --build-arg VERSION=$(VERSION) -t $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) -f $*/Dockerfile . + docker build $(DOCKER_BUILD_FLAGS) --build-arg REGISTRY=$(DOCKER_REGISTRY) --build-arg VERSION=$(VERSION) --build-arg PRESIDIO_DEPS_LABEL=$(PRESIDIO_DEPS_LABEL) -t $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) -f $*/Dockerfile . # You must be logged into DOCKER_REGISTRY before you can push. .PHONY: docker-push-deps docker-push-deps: + docker push $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) + docker push $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) + +.PHONY: docker-push-latest-deps +docker-push-latest-deps: + docker image tag $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) $(DOCKER_REGISTRY)/$(PYTHON_DEPS):latest + docker image tag $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) $(DOCKER_REGISTRY)/$(GOLANG_DEPS):latest docker push $(DOCKER_REGISTRY)/$(PYTHON_DEPS):latest - docker push $(DOCKER_REGISTRY)/$(GOLANG_DEPS):latest + docker push $(DOCKER_REGISTRY)/$(GOLANG_DEPS):latest + +PHONY: docker-push-latest-dev-deps +docker-push-latest-dev-deps: + docker image tag $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) $(DOCKER_REGISTRY)/$(PYTHON_DEPS):latest-dev + docker image tag $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) $(DOCKER_REGISTRY)/$(GOLANG_DEPS):latest-dev + docker push $(DOCKER_REGISTRY)/$(PYTHON_DEPS):latest-dev + docker push $(DOCKER_REGISTRY)/$(GOLANG_DEPS):latest-dev + +PHONY: docker-push-latest-branch-deps +docker-push-latest-branch-deps: + docker image tag $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_BRANCH_LABEL) + docker image tag $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_BRANCH_LABEL) + docker push $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_BRANCH_LABEL) + docker push $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_BRANCH_LABEL) # push with the given label .PHONY: docker-push @@ -68,6 +90,15 @@ docker-push-latest-dev: $(addsuffix -push-latest-dev,$(IMAGES)) docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/$*:latest-dev docker push $(DOCKER_REGISTRY)/$*:latest-dev +.PHONY: docker-push-latest-branch +docker-push-latest-branch: $(addsuffix -push-latest-branch,$(IMAGES)) + + +%-push-latest-branch: + docker pull $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) + docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/$*:$(PRESIDIO_BRANCH_LABEL) + docker push $(DOCKER_REGISTRY)/$*:$(PRESIDIO_BRANCH_LABEL) + # pull an existing image tag, tag it again with a provided release tag and 'latest' tag .PHONY: docker-push-release docker-push-release: $(addsuffix -push-release,$(IMAGES)) @@ -79,11 +110,10 @@ endif docker pull $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/$*:$(RELEASE_VERSION) docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/public/$*:$(RELEASE_VERSION) - docker push $(DOCKER_REGISTRY)/$*:$(RELEASE_VERSION) - docker push $(DOCKER_REGISTRY)/public/$*:$(RELEASE_VERSION) - - docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/$*:latest docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/public/$*:latest + docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/$*:latest + docker push $(DOCKER_REGISTRY)/$*:$(RELEASE_VERSION) + docker push $(DOCKER_REGISTRY)/public/$*:$(RELEASE_VERSION) docker push $(DOCKER_REGISTRY)/$*:latest docker push $(DOCKER_REGISTRY)/public/$*:latest diff --git a/docs/build_release.md b/docs/build_release.md new file mode 100644 index 000000000..45e0c8d95 --- /dev/null +++ b/docs/build_release.md @@ -0,0 +1,59 @@ +# Presidio build and release + +Presidio continuous processes govern its integrity and stability through the dev, test and and release phases. +The project currently supports [Azure Pipelines](https://azure.microsoft.com/en-us/services/devops/pipelines/) using YAML pipelines which can be easily imported to any Azure Pipelines instance.
    + +## Presidio Azure Devops Pipelines + +Azure Pipelines [YAML Schema](https://docs.microsoft.com/en-us/azure/devops/pipelines/yaml-schema?view=azure-devops&tabs=schema) allows for code reuse using [templates](https://docs.microsoft.com/en-us/azure/devops/pipelines/process/templates?view=azure-devops).
    + +***[Presidio Build and Push template](../pipelines/templates/presidio-build-template.yaml)*** - build, test and push presidio images + +- Push and publish steps only taken when build is not triggered by a PR. +- Parameters: + - registry_parameter - the name of container registry service connection + - registry_name_parameter - the full name of the registry login + - deps_label_parameter - the base image (deps) label to be used for container build + +The following pipelines are provided: + +***[Deps CI Pipeline](../pipelines/CI-deps.yaml)*** - build and test persidio and its base images for golang and python. + +- Base images are always pushed with build-id (in PR and CI) +- After a succesful build of presidio images, deps is tagged and pushed according to current branch +- Presidio images are built using deps label for the current build-id and pushed according to branch strategy (see Presidio-CI pipeline below) +- Golang and Python deps are build in parallel using different [pipeline stages](https://docs.microsoft.com/en-us/azure/devops/pipelines/process/stages?view=azure-devops&tabs=yaml) + +***[Presidio CI Pipeline](../pipelines/CI-presidio.yaml)*** - build and test persidio services images based on pre-built deps labels. + +- Depending on branch, images will be pushed with build-id and: + - **master branch** - using deps "latest" label and pushed with "latest" tag + - **development branch** - using deps "latest-dev" label and pushed with "latest-dev" tag + - **feature branch** - using deps "latest-dev" label, or other if overriden manualy during build, and pushed with branch-name tag. +- Artifacts generated by a PR CI are not pushed to container registry +- Presidio CI triggered by build-dependency to deps-CI will use deps images label with the build-id of the triggering CI +- Feature branches are not supported for build-dependency. after CI-deps completes, a manual trigger of CI-Presidio is required. + +### Note that the following settings have to be set in Azure Pipelines, for each imported pipeline: + +#### Variables +* ***registry*** - a docker registry service endpoint to your private docker registry +* ***registry_name*** - the name of the registry +* ***dependency_default*** - override dependency label in a Presidio-CI build. + + +### import a pipeline to Azure Devops + +* Sign in to your Azure DevOps organization and navigate to your project. + +* In your project, navigate to the Pipelines page. Then choose the action to create a new pipeline. + +* Walk through the steps of the wizard by first selecting 'Use the classic editor, and select GitHub as the location of your source code. + +* You might be redirected to GitHub to sign in. If so, enter your GitHub credentials. + +* When the list of repositories appears, select presidio repository. + +* Point Azure Pipelines to the relevant yaml definition you'd like to import. Set the pipeline's name, the required triggers and variables and Select Save and run. + +* A new run is started. Wait for the run to finish. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 3e6380f58..92af3c891 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,3 +16,4 @@ New to Presidio? Read this material to quickly get up and running. - [Analyzer service tutorial](tutorial_analyzer.md) - [Calling the different services](tutorial_service.md) - [Adding custom fields](custom_fields.md) +- [Presidio Build and Release](build_release.md) \ No newline at end of file diff --git a/pipelines/CI-deps.yaml b/pipelines/CI-deps.yaml new file mode 100644 index 000000000..f7788105a --- /dev/null +++ b/pipelines/CI-deps.yaml @@ -0,0 +1,89 @@ +variables: + GOBIN: '$(GOPATH)/bin' + GOPATH: '$(system.defaultWorkingDirectory)/gopath' + MODULEPATH: '$(GOPATH)/src/github.com/$(build.repository.name)' + GOROOT: '/usr/local/go1.11' + MASTER_BRANCH_LABEL: latest # tag name of master branch + DEV_BRANCH_LABEL: latest-dev # tag name of development branch + +trigger: + batch: true + branches: + include: + - development + - master + paths: + include: + - Dockerfile.golang.deps + - Gopkg.lock + - Gopkg.toml + - Dockerfile.python.deps + - presidio-analyzer/Pipfile + - presidio-analyzer/Pipfile.lock + exclude: + - '*.md' +pr: + branches: + include: + - development + - masters + paths: + include: + - Dockerfile.golang.deps + - Gopkg.lock + - Gopkg.toml + - Dockerfile.python.deps + - presidio-analyzer/Pipfile + - presidio-analyzer/Pipfile.lock + exclude: + - '*.md' +stages: + - stage: GolangDeps # Build golang deps + jobs: + + - job: BuildGolangDeps + timeoutInMinutes: 15 # how long to run the job before automatically cancelling + pool: + vmImage: 'ubuntu-latest' + steps: + - task: Docker@2 + displayName: 'Build and Push Golang dependencies' + inputs: + containerRegistry: $(registry) # input registry name + repository: presidio-golang-deps + Dockerfile: Dockerfile.golang.deps + tags: | + $(Build.BuildId) + - stage: PythonDeps # Build python deps + dependsOn: [] # this removes the implicit dependency on previous stage and causes this to run in parallel + jobs: # + + - job: BuildPytonDeps + timeoutInMinutes: 90 + pool: + vmImage: 'ubuntu-latest' + steps: + - task: Docker@2 + displayName: 'Build and Push Python dependencies' + inputs: + containerRegistry: $(registry) # input registry name + repository: 'presidio-python-deps' + Dockerfile: Dockerfile.python.deps + tags: | # image tags + $(Build.BuildId) + - stage: BuildAndPushPresidio + dependsOn: + - GolangDeps + - PythonDeps + jobs: + - job: BuildAndPushPresidio + timeoutInMinutes: 90 + pool: + vmImage: 'ubuntu-latest' + steps: + - template: templates/presidio-build-template.yaml # Template reference + parameters: + registry_name_parameter: $(registry_name) # input parameter + registry_parameter: $(registry) # input parameter + deps_label_parameter: $(Build.BuildId) + diff --git a/pipelines/CI-presidio.yaml b/pipelines/CI-presidio.yaml new file mode 100644 index 000000000..4233701e2 --- /dev/null +++ b/pipelines/CI-presidio.yaml @@ -0,0 +1,72 @@ +variables: + GOBIN: '$(GOPATH)/bin' + GOPATH: '$(system.defaultWorkingDirectory)/gopath' + MODULEPATH: '$(GOPATH)/src/github.com/$(build.repository.name)' + GOROOT: '/usr/local/go1.11' + MASTER_BRANCH_LABEL: latest # tag name of master branch + DEV_BRANCH_LABEL: latest-dev # tag name of development branch + DEFAULT_DEPS_LABEL: latest-dev + +trigger: # CI triggers + batch: false + branches: + include: + - master + - development + paths: + exclude: + - Dockerfile.golang.deps + - Gopkg.lock + - Gopkg.toml + - Dockerfile.python.deps + - presidio-analyzer/requirements.txt + - '*.md' +pr: # PR triggers + branches: + include: + - development + - masters + paths: + exclude: + - Dockerfile.golang.deps + - Gopkg.lock + - Gopkg.toml + - Dockerfile.python.deps + - presidio-analyzer/Pipfile + - presidio-analyzer/Pipfile.lock + - '*.md' + +jobs: +- job: BuildAndPushPresidioMaster + timeoutInMinutes: 90 + condition: eq(variables['Build.SourceBranchName'], 'master') + pool: + vmImage: 'ubuntu-latest' + steps: + - template: templates/presidio-build-template.yaml # Template reference + parameters: + registry_name_parameter: $(registry_name) # input parameter + registry_parameter: $(registry) # input parameter + deps_label_parameter: $(MASTER_BRANCH_LABEL) +- job: BuildAndPushPresidioDevelopment + timeoutInMinutes: 90 + condition: eq(variables['Build.SourceBranchName'], 'development') + pool: + vmImage: 'ubuntu-latest' + steps: + - template: templates/presidio-build-template.yaml # Template reference + parameters: + registry_name_parameter: $(registry_name) # input parameter + registry_parameter: $(registry) # input parameter + deps_label_parameter: $(DEV_BRANCH_LABEL) +- job: BuildAndPushPresidioFeature + timeoutInMinutes: 90 + condition: and(ne(variables['Build.SourceBranchName'], 'development'), ne(variables['Build.SourceBranchName'], 'master')) + pool: + vmImage: 'ubuntu-latest' + steps: + - template: templates/presidio-build-template.yaml # Template reference + parameters: + registry_name_parameter: $(registry_name) # input parameter + registry_parameter: $(registry) # input parameter + deps_label_parameter: $(dependency_default) # input parameter \ No newline at end of file diff --git a/pipelines/templates/presidio-build-template.yaml b/pipelines/templates/presidio-build-template.yaml new file mode 100644 index 000000000..0bbe0c5d5 --- /dev/null +++ b/pipelines/templates/presidio-build-template.yaml @@ -0,0 +1,85 @@ +parameters: + registry_parameter: '' # container registry service connection name + registry_name_parameter: '' # container registry URI + deps_label_parameter: 'dependency_default' # presidio deps label: if not provided will use the default +steps: +- bash: | + if [[ $DEPS_LABEL_TAG == *"dependency_default"* ]]; then + echo '##vso[task.setvariable variable=DEPS_LABEL]$(DEFAULT_DEPS_LABEL)' + echo 'deps label is empty, setting to default' + else + echo '##vso[task.setvariable variable=DEPS_LABEL]${{ parameters.deps_label_parameter }}' + echo 'deps label is not empty:' $DEPS_LABEL_TAG + fi + displayName: 'Setup deps label if empty' + env: + DEPS_LABEL_TAG: ${{ parameters.deps_label_parameter }} +- task: Docker@2 + inputs: + containerRegistry: ${{ parameters.registry_parameter }} + command: 'login' +- bash: | + mkdir -p '$(GOBIN)' + mkdir -p '$(GOPATH)/pkg' + mkdir -p '$(MODULEPATH)' + shopt -s extglob + mv !(gopath) '$(MODULEPATH)' + echo '##vso[task.prependpath]$(GOBIN)' + echo '##vso[task.prependpath]$(GOROOT)/bin' + displayName: 'Setup Go Env' +- bash: | + curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh + dep ensure + make DOCKER_REGISTRY=$REGISTRY_NAME PRESIDIO_LABEL=$(Build.BuildID) PRESIDIO_DEPS_LABEL=$DEPS_LABEL test-functional + env: + REGISTRY_NAME: ${{ parameters.registry_name_parameter }} + workingDirectory: '$(MODULEPATH)' + displayName: 'Build & Test ' + +### pushing and publishing on non PR only +- task: CopyFiles@2 + displayName: 'Copy Chart to Artifact Staging Directory' + condition: ne(variables['Build.Reason'], 'PullRequest') + inputs: + Contents: '**/presidio/charts/**' + TargetFolder: charts + CleanTargetFolder: true + flattenFolders: true +- task: PublishBuildArtifacts@1 + displayName: 'Publish Artifact: dropchart' + condition: ne(variables['Build.Reason'], 'PullRequest') + inputs: + PathtoPublish: charts + ArtifactName: dropchart + +- bash: | + make DOCKER_REGISTRY=$REGISTRY_NAME PRESIDIO_LABEL=$(Build.BuildID) docker-push + env: + REGISTRY_NAME: ${{ parameters.registry_name_parameter }} + displayName: 'Push Docker Images - BuildId label' # push with build-id label - all branches + condition: ne(variables['Build.Reason'], 'PullRequest') + workingDirectory: '$(MODULEPATH)' +- bash: | + make DOCKER_REGISTRY=$REGISTRY_NAME PRESIDIO_LABEL=$(Build.BuildID) PRESIDIO_DEPS_LABEL=$(DEPS_LABEL) docker-push-latest-deps + make DOCKER_REGISTRY=$REGISTRY_NAME PRESIDIO_LABEL=$(Build.BuildId) RELEASE_VERSION='$(RELEASE_NAME)' docker-push-release + env: + REGISTRY_NAME: ${{ parameters.registry_name_parameter }} + displayName: 'Push Docker Images - Master Branch' # push with latest label - master branch + condition: and( ne(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranchName'], 'master')) + workingDirectory: '$(MODULEPATH)' +- bash: | + make DOCKER_REGISTRY=$REGISTRY_NAME PRESIDIO_LABEL=$(Build.BuildID) PRESIDIO_DEPS_LABEL=$(DEPS_LABEL) docker-push-latest-dev-deps + make DOCKER_REGISTRY=$REGISTRY_NAME PRESIDIO_LABEL=$(Build.BuildId) docker-push-latest-dev + env: + REGISTRY_NAME: ${{ parameters.registry_name_parameter }} + displayName: 'Push Docker Images - Development Branch' # push with latest-dev label - development branch + condition: and(ne(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranchName'], 'development')) + workingDirectory: '$(MODULEPATH)' +- bash: | + make DOCKER_REGISTRY=$REGISTRY_NAME PRESIDIO_LABEL=$(Build.BuildID) PRESIDIO_DEPS_LABEL=$(DEPS_LABEL) PRESIDIO_BRANCH_LABEL=$(Build.SourceBranchName) docker-push-latest-branch-deps + make DOCKER_REGISTRY=$REGISTRY_NAME PRESIDIO_LABEL=$(Build.BuildID) PRESIDIO_DEPS_LABEL=$(DEPS_LABEL) PRESIDIO_BRANCH_LABEL=$(Build.SourceBranchName) docker-push-latest-branch + env: + REGISTRY_NAME: ${{ parameters.registry_name_parameter }} + displayName: 'Push Docker Images - Feature Branch' # push with branch-name label - feature branch + condition: and(ne(variables['Build.Reason'], 'PullRequest'), and(ne(variables['Build.SourceBranchName'], 'development'), ne(variables['Build.SourceBranchName'], 'master'))) + workingDirectory: '$(MODULEPATH)' \ No newline at end of file diff --git a/presidio-analyzer/Dockerfile b/presidio-analyzer/Dockerfile index d001a7983..4f8cee97a 100644 --- a/presidio-analyzer/Dockerfile +++ b/presidio-analyzer/Dockerfile @@ -1,6 +1,7 @@ ARG REGISTRY=presidio.azurecr.io +ARG PRESIDIO_DEPS_LABEL=latest -FROM ${REGISTRY}/presidio-python-deps +FROM ${REGISTRY}/presidio-python-deps:${PRESIDIO_DEPS_LABEL} ARG NAME=presidio-analyzer WORKDIR /usr/bin/${NAME} From 8c097565aa5f2be7a9d9393cb74792c3f1b00634 Mon Sep 17 00:00:00 2001 From: Avishay balter Date: Sun, 26 May 2019 11:42:39 +0300 Subject: [PATCH 47/75] code review fixes --- docs/build_release.md | 6 +++--- pipelines/CI-presidio.yaml | 21 ++++++++++--------- .../templates/presidio-build-template.yaml | 4 ++-- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/build_release.md b/docs/build_release.md index 45e0c8d95..10a223d92 100644 --- a/docs/build_release.md +++ b/docs/build_release.md @@ -37,9 +37,9 @@ The following pipelines are provided: ### Note that the following settings have to be set in Azure Pipelines, for each imported pipeline: #### Variables -* ***registry*** - a docker registry service endpoint to your private docker registry -* ***registry_name*** - the name of the registry -* ***dependency_default*** - override dependency label in a Presidio-CI build. +* ***REGISTRY*** - a docker registry service endpoint to your private docker registry +* ***REGISTRY_NAME*** - the name of the registry +* ***DEPENDENCY_DEFAULT*** - override dependency label in a Presidio-CI build. ### import a pipeline to Azure Devops diff --git a/pipelines/CI-presidio.yaml b/pipelines/CI-presidio.yaml index 4233701e2..b2336f34c 100644 --- a/pipelines/CI-presidio.yaml +++ b/pipelines/CI-presidio.yaml @@ -18,14 +18,15 @@ trigger: # CI triggers - Dockerfile.golang.deps - Gopkg.lock - Gopkg.toml - - Dockerfile.python.deps - - presidio-analyzer/requirements.txt + - Dockerfile.python.deps + - presidio-analyzer/Pipfile + - presidio-analyzer/Pipfile.lock - '*.md' pr: # PR triggers branches: include: - development - - masters + - master paths: exclude: - Dockerfile.golang.deps @@ -45,8 +46,8 @@ jobs: steps: - template: templates/presidio-build-template.yaml # Template reference parameters: - registry_name_parameter: $(registry_name) # input parameter - registry_parameter: $(registry) # input parameter + registry_name_parameter: $(REGISTRY_NAME) # input parameter + registry_parameter: $(REGISTRY) # input parameter deps_label_parameter: $(MASTER_BRANCH_LABEL) - job: BuildAndPushPresidioDevelopment timeoutInMinutes: 90 @@ -56,8 +57,8 @@ jobs: steps: - template: templates/presidio-build-template.yaml # Template reference parameters: - registry_name_parameter: $(registry_name) # input parameter - registry_parameter: $(registry) # input parameter + registry_name_parameter: $(REGISTRY_NAME) # input parameter + registry_parameter: $(REGISTRY) # input parameter deps_label_parameter: $(DEV_BRANCH_LABEL) - job: BuildAndPushPresidioFeature timeoutInMinutes: 90 @@ -67,6 +68,6 @@ jobs: steps: - template: templates/presidio-build-template.yaml # Template reference parameters: - registry_name_parameter: $(registry_name) # input parameter - registry_parameter: $(registry) # input parameter - deps_label_parameter: $(dependency_default) # input parameter \ No newline at end of file + registry_name_parameter: $(REGISTRY_NAME) # input parameter + registry_parameter: $(REGISTRY) # input parameter + deps_label_parameter: $(DEPENDENCY_DEFAULT) # input parameter \ No newline at end of file diff --git a/pipelines/templates/presidio-build-template.yaml b/pipelines/templates/presidio-build-template.yaml index 0bbe0c5d5..eace48a98 100644 --- a/pipelines/templates/presidio-build-template.yaml +++ b/pipelines/templates/presidio-build-template.yaml @@ -1,10 +1,10 @@ parameters: registry_parameter: '' # container registry service connection name registry_name_parameter: '' # container registry URI - deps_label_parameter: 'dependency_default' # presidio deps label: if not provided will use the default + deps_label_parameter: 'DEPENDENCY_DEFAULT' # presidio deps label: if not provided will use the default steps: - bash: | - if [[ $DEPS_LABEL_TAG == *"dependency_default"* ]]; then + if [[ $DEPS_LABEL_TAG == *"DEPENDENCY_DEFAULT"* ]]; then echo '##vso[task.setvariable variable=DEPS_LABEL]$(DEFAULT_DEPS_LABEL)' echo 'deps label is empty, setting to default' else From 20d4b8f52fbd221b6ef0af1c765b0d193744fddd Mon Sep 17 00:00:00 2001 From: Avishay balter Date: Sun, 26 May 2019 11:51:16 +0300 Subject: [PATCH 48/75] change to master --- pipelines/CI-deps.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pipelines/CI-deps.yaml b/pipelines/CI-deps.yaml index f7788105a..aae7da08c 100644 --- a/pipelines/CI-deps.yaml +++ b/pipelines/CI-deps.yaml @@ -26,7 +26,7 @@ pr: branches: include: - development - - masters + - master paths: include: - Dockerfile.golang.deps From 8217f30a2ae85f7a807fae06b8282fa2509030d3 Mon Sep 17 00:00:00 2001 From: Avishay balter Date: Sun, 26 May 2019 12:10:12 +0300 Subject: [PATCH 49/75] fix charts location --- pipelines/templates/presidio-build-template.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pipelines/templates/presidio-build-template.yaml b/pipelines/templates/presidio-build-template.yaml index eace48a98..fd31c4696 100644 --- a/pipelines/templates/presidio-build-template.yaml +++ b/pipelines/templates/presidio-build-template.yaml @@ -41,10 +41,9 @@ steps: displayName: 'Copy Chart to Artifact Staging Directory' condition: ne(variables['Build.Reason'], 'PullRequest') inputs: - Contents: '**/presidio/charts/**' + Contents: '**/charts/**' TargetFolder: charts CleanTargetFolder: true - flattenFolders: true - task: PublishBuildArtifacts@1 displayName: 'Publish Artifact: dropchart' condition: ne(variables['Build.Reason'], 'PullRequest') From 6b6008d50e6d2791b4e30f857aeaede6fd334959 Mon Sep 17 00:00:00 2001 From: Avishay balter Date: Sun, 26 May 2019 15:17:05 +0300 Subject: [PATCH 50/75] change error if release version not set to warning --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5d7d07531..8fab45eac 100644 --- a/Makefile +++ b/Makefile @@ -105,7 +105,7 @@ docker-push-release: $(addsuffix -push-release,$(IMAGES)) %-push-release: ifeq ($(RELEASE_VERSION),) - $(error RELEASE_VERSION is not set) + $(warning RELEASE_VERSION is not set) endif docker pull $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/$*:$(RELEASE_VERSION) From 126b0ec345262d14d677807d5ab1cba7231da432 Mon Sep 17 00:00:00 2001 From: Avishay balter Date: Sun, 26 May 2019 19:40:29 +0300 Subject: [PATCH 51/75] fix var names --- pipelines/CI-deps.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pipelines/CI-deps.yaml b/pipelines/CI-deps.yaml index aae7da08c..010e36b1a 100644 --- a/pipelines/CI-deps.yaml +++ b/pipelines/CI-deps.yaml @@ -49,7 +49,7 @@ stages: - task: Docker@2 displayName: 'Build and Push Golang dependencies' inputs: - containerRegistry: $(registry) # input registry name + containerRegistry: $(REGISTRY) # input registry name repository: presidio-golang-deps Dockerfile: Dockerfile.golang.deps tags: | @@ -66,7 +66,7 @@ stages: - task: Docker@2 displayName: 'Build and Push Python dependencies' inputs: - containerRegistry: $(registry) # input registry name + containerRegistry: $(REGISTRY) # input registry name repository: 'presidio-python-deps' Dockerfile: Dockerfile.python.deps tags: | # image tags @@ -83,7 +83,7 @@ stages: steps: - template: templates/presidio-build-template.yaml # Template reference parameters: - registry_name_parameter: $(registry_name) # input parameter - registry_parameter: $(registry) # input parameter + registry_name_parameter: $(REGISTRY_NAME) # input parameter + registry_parameter: $(REGISTRY) # input parameter deps_label_parameter: $(Build.BuildId) From b52dafd051adaba05441b4097f9b063aa089aba9 Mon Sep 17 00:00:00 2001 From: Itye Richter Date: Tue, 28 May 2019 15:51:14 +0300 Subject: [PATCH 52/75] fix makefile bug. added else-elseif where it was missing --- Makefile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 8fab45eac..25c3cd2ae 100644 --- a/Makefile +++ b/Makefile @@ -106,7 +106,7 @@ docker-push-release: $(addsuffix -push-release,$(IMAGES)) %-push-release: ifeq ($(RELEASE_VERSION),) $(warning RELEASE_VERSION is not set) -endif +else docker pull $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/$*:$(RELEASE_VERSION) docker image tag $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) $(DOCKER_REGISTRY)/public/$*:$(RELEASE_VERSION) @@ -116,6 +116,7 @@ endif docker push $(DOCKER_REGISTRY)/public/$*:$(RELEASE_VERSION) docker push $(DOCKER_REGISTRY)/$*:latest docker push $(DOCKER_REGISTRY)/public/$*:latest +endif # All non-functional tests .PHONY: test From 90a98b759a8654429a764a352d52c215d909708c Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Thu, 30 May 2019 10:05:47 +0300 Subject: [PATCH 53/75] Fixing merge conflicts from master (#171) * Fixed typo on string #74 (#103) * Default transformation (#94) * This commit introduce the support of default transformation for undeclared fields in the anonymizer, which can be overrided by the user if supplied another default within the anonymizer template. * Streams bug fixes (#92) * Deployment script (#95) New simplified deployment scripts + unified tags * Analyzer redesign + supporting custom recognizers This commit is the first part of the redesign of the analyzer service, and contains the following: 1. Separates spacy and recognizers logic to different files. 2. Implements a base class for all the recognizers,(which in future custom recognizers will inherit) 3. Moves the analyzer logic from main to analyzer_engine class 4. Removes the detected text from the analyzer result. Future commits will contain the following: 1. Dynamic loading of the pre-defined recognizers. [link](https://dev.azure.com/csedevil/Presidio-internal/_sprints/taskboard/Presidio%20Crew/Presidio-internal/02%20-%20Testable%20custom%20models) 2. Add new pattern recognizer via api call, [work item](https://dev.azure.com/csedevil/Presidio-internal/_sprints/taskboard/Presidio%20Crew/Presidio-internal/02%20-%20Testable%20custom%20models) 3. Improve remove duplicates logic [bug](https://dev.azure.com/csedevil/Presidio-internal/_workitems/edit/597) and [bug](https://dev.azure.com/csedevil/Presidio-internal/_workitems/edit/596/) 4. Re-support context model. [work item](https://dev.azure.com/csedevil/Presidio-internal/_sprints/taskboard/Presidio%20Crew/Presidio-internal/02%20-%20Testable%20custom%20models) Current Design: ![image](https://user-images.githubusercontent.com/13463870/52433948-edc69480-2b16-11e9-98d7-8923fdc9fb8a.png) * Presidio support for language code in template (#98) Configure a language code on the request level and not the field level. All requests should have one language * Fix Bug #604 - Refactor test assertions + some pylint fixes (#100) * Fix Bug #604 - Refactor test assertions + some pylint fixes * fix spaces in spacy_recognizer.py * Update test_spacy_recognizer.py Fix PR comment regarding Bug 617 * fixed bug: changed 'push' to 'pull' in Makefile (#102) * Bug666 - Adding pylint to the Analyzer microservice (#105) * Adding pylint for the analyzer microservice * Ll bug610 - Fix bug in IBAN recognizers + additional test fixes (#101) * Fix Bug 610: iban recognizer and fix additional tests * Ignoring 0 score patterns, removed predefined 0 score patterns from code. * All fields (#107) support for requesting all fields (entities) and language refactor * New functionality: Support custom pattern recognizers (#104) Adding a new service for persisting recognizers Adding API support for adding, removing and listing recognizers Analyzer service now calls the recognizers store to get new recognizers to be used during analysis * Entities list for all fields true (#111) Bug fix with all_fields = True * (Re)Enable context support in analyzer (#114) This PR introduces estimation of surrounding words (aka context). It also introduces generic NLP classes for accessing metadata extracted by an NLP engine (specifically tokens, lemmas, NER, stopwords and punctuation). SpacyRecognizer no longer runs spacy but only processes the metadata processed by the NLP Engine. In the current implementation, Spacy is the implementation of the NLP engine. * Add public path to makefile (#121) * updated docker file to support correct spacy version * added public path for push release * Update Dockerfile.python.deps * replaced registry with public mcr * updated docs and samples based on new changes in dev (#120) * updated docs and samples based on new changes in dev * updated text change suggestion * reverted registry change * a post-upgrade e2e test (#112) Runs e2e tests on the newly deployed code * Build deps - adding build number to python&golang dependency containers (#122) * adding presidio-dependency images versioning * updating installation notes with dependency label * add CI as code * presidio deps ci yaml file * add triggers to deps CI yaml * separate CIs for golang and python * adding presidio-dependency images versioning * updating installation notes with dependency label * Bug fixes - based on bugbash (#126) * Bug 906 - CONTEXT_SUFFIX_COUNT is not used and PREFIX is used instead * Bug 909 - Context not working with upper case / mixed case * Bug 908 - support context with substrings * Bug 907 - Context window size is off by 1 index * Revert yaml (#137) * Revert "Build deps - adding build number to python&golang dependency containers (#122)" This reverts commit c05477058297430d819d34ea1fa433f1a967bc22. * Change deployment script to mcr (#130) * updated docker file to support correct spacy version * changed registry to mcr * fixed the mcr url * Updated readme and documentation(#125) * Update README.MD * Bug 911 fix (#131) * Bug 911 fix When the persistent recognizers store is empty there should be no error log line when requesting for the hash value * changed genproto branch to master. * Updating genproto to master branch * Updated CI badge for new Presidio-CI pipeline * bug fix - mcr latest tag was not correct --- docs/development.md | 2 +- docs/index.md | 2 +- docs/install.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/development.md b/docs/development.md index 164058bf3..1fd2c786f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -175,4 +175,4 @@ pipenv run python __main__.py analyze --text "John Smith drivers license is AC43 Edit [charts/presidio/values.yaml](../charts/presidio/values.yaml) to: - Setup secret name (for private registries) - Change presidio services version -- Change default scale \ No newline at end of file +- Change default scale diff --git a/docs/index.md b/docs/index.md index 92af3c891..326662eba 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,4 +16,4 @@ New to Presidio? Read this material to quickly get up and running. - [Analyzer service tutorial](tutorial_analyzer.md) - [Calling the different services](tutorial_service.md) - [Adding custom fields](custom_fields.md) -- [Presidio Build and Release](build_release.md) \ No newline at end of file +- [Presidio Build and Release](build_release.md) diff --git a/docs/install.md b/docs/install.md index 2483941cc..362c97e3b 100644 --- a/docs/install.md +++ b/docs/install.md @@ -60,4 +60,4 @@ Follow the installation guide at the [Readme page](https://github.com/Microsoft/ helm install --name presidio-demo --set registry=${DOCKER_REGISTRY},tag=${PRESIDIO_LABEL} . --namespace presidio ``` -6. For more options over the deployment, follow the [Development guide](https://github.com/Microsoft/presidio/blob/master/docs/development.md) \ No newline at end of file +6. For more options over the deployment, follow the [Development guide](https://github.com/Microsoft/presidio/blob/master/docs/development.md) From c3f36db1d70861edf098168c9050ef5c4d254f9d Mon Sep 17 00:00:00 2001 From: balteravishay Date: Sun, 2 Jun 2019 09:36:35 +0300 Subject: [PATCH 54/75] remove push deps with default tag (#175) --- Makefile | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Makefile b/Makefile index 25c3cd2ae..342cd10dd 100644 --- a/Makefile +++ b/Makefile @@ -48,11 +48,6 @@ docker-build: $(addsuffix -dimage,$(IMAGES)) docker build $(DOCKER_BUILD_FLAGS) --build-arg REGISTRY=$(DOCKER_REGISTRY) --build-arg VERSION=$(VERSION) --build-arg PRESIDIO_DEPS_LABEL=$(PRESIDIO_DEPS_LABEL) -t $(DOCKER_REGISTRY)/$*:$(PRESIDIO_LABEL) -f $*/Dockerfile . # You must be logged into DOCKER_REGISTRY before you can push. -.PHONY: docker-push-deps -docker-push-deps: - docker push $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) - docker push $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) - .PHONY: docker-push-latest-deps docker-push-latest-deps: docker image tag $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) $(DOCKER_REGISTRY)/$(PYTHON_DEPS):latest From a0baa917681476fd0e8b2fa00d8bef67e9c5976a Mon Sep 17 00:00:00 2001 From: balteravishay Date: Thu, 6 Jun 2019 11:27:03 +0300 Subject: [PATCH 55/75] add deps label to analyzer image (#182) --- presidio-analyzer/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/presidio-analyzer/Dockerfile b/presidio-analyzer/Dockerfile index 4f8cee97a..0373c6b2a 100644 --- a/presidio-analyzer/Dockerfile +++ b/presidio-analyzer/Dockerfile @@ -14,7 +14,7 @@ RUN pipenv install --dev --sequential && \ #---------------------------- -FROM ${REGISTRY}/presidio-python-deps +FROM ${REGISTRY}/presidio-python-deps:${PRESIDIO_DEPS_LABEL} ARG NAME=presidio-analyzer ADD ./${NAME}/analyzer /usr/bin/${NAME}/analyzer From 2abda65646af1413e5b335b91a2998801399c123 Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Mon, 10 Jun 2019 17:30:25 +0300 Subject: [PATCH 56/75] Merging back master into dev (#181) Merging master v0.3 to dev From fecbe57d95fbb00f74567b0961c388b980a41b16 Mon Sep 17 00:00:00 2001 From: balteravishay Date: Thu, 13 Jun 2019 21:09:22 +0300 Subject: [PATCH 57/75] Logging docs (#176) Documentation and configuration for ingress and logging --- charts/presidio/templates/api-ingress.yaml | 4 +- .../presidio/templates/api-istio-gateway.yaml | 38 ++++ charts/presidio/values.yaml | 3 +- docs/index.md | 3 +- docs/install.md | 4 + docs/monitoring_logging.md | 193 ++++++++++++++++++ 6 files changed, 241 insertions(+), 4 deletions(-) create mode 100644 charts/presidio/templates/api-istio-gateway.yaml create mode 100644 docs/monitoring_logging.md diff --git a/charts/presidio/templates/api-ingress.yaml b/charts/presidio/templates/api-ingress.yaml index 82fad7fa3..10a5ebf13 100644 --- a/charts/presidio/templates/api-ingress.yaml +++ b/charts/presidio/templates/api-ingress.yaml @@ -1,4 +1,4 @@ -{{- if .Values.api.ingress.enabled -}} +{{- if and (.Values.api.ingress.enabled) (or (eq .Values.api.ingress.class "nginx") (eq .Values.api.ingress.class "traefik")) -}} {{- $serviceName := include "presidio.api.fullname" . -}} {{- $servicePort := .Values.api.service.externalPort -}} apiVersion: extensions/v1beta1 @@ -21,4 +21,4 @@ spec: backend: serviceName: {{ $serviceName }} servicePort: {{ $servicePort }} -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/charts/presidio/templates/api-istio-gateway.yaml b/charts/presidio/templates/api-istio-gateway.yaml new file mode 100644 index 000000000..92f6222b3 --- /dev/null +++ b/charts/presidio/templates/api-istio-gateway.yaml @@ -0,0 +1,38 @@ +{{- if and (.Values.api.ingress.enabled) (eq .Values.api.ingress.class "istio") -}} +{{- $serviceName := include "presidio.api.fullname" . -}} +{{- $servicePort := .Values.api.service.externalPort -}} +{{- $servicePortName := .Values.api.service.name -}} +apiVersion: networking.istio.io/v1alpha3 +kind: Gateway +metadata: + name: {{ $serviceName }} +spec: + selector: + istio: ingressgateway # use istio default controller + servers: + - port: + number: 80 + name: http + protocol: HTTP + hosts: + - "*" +--- +apiVersion: networking.istio.io/v1alpha3 +kind: VirtualService +metadata: + name: {{ $serviceName }} +spec: + hosts: + - "*" + gateways: + - {{ $serviceName }} + http: + - match: + - uri: + prefix: /api/ + route: + - destination: + host: {{ $serviceName }} + port: + number: {{ $servicePort }} +{{- end -}} \ No newline at end of file diff --git a/charts/presidio/values.yaml b/charts/presidio/values.yaml index 7abcf148a..1d9e5cfd5 100644 --- a/charts/presidio/values.yaml +++ b/charts/presidio/values.yaml @@ -30,8 +30,9 @@ api: #readinessProbe: # initialDelaySeconds: 20 + # supported types are nginx, traefik and istio ingress: - enabled: true + enabled: false class: nginx analyzer: diff --git a/docs/index.md b/docs/index.md index 326662eba..245f81157 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,4 +16,5 @@ New to Presidio? Read this material to quickly get up and running. - [Analyzer service tutorial](tutorial_analyzer.md) - [Calling the different services](tutorial_service.md) - [Adding custom fields](custom_fields.md) -- [Presidio Build and Release](build_release.md) +- [Presidio build and release](build_release.md) +- [Presidio logging and monitoring design concepts](monitoring_logging.md) \ No newline at end of file diff --git a/docs/install.md b/docs/install.md index 362c97e3b..597667b63 100644 --- a/docs/install.md +++ b/docs/install.md @@ -50,6 +50,10 @@ Follow the installation guide at the [Readme page](https://github.com/Microsoft/ 3. Optional - Ingress controller for presidio API. - [Traefik](https://docs.traefik.io/user-guide/kubernetes/) - [NGINX](https://docs.microsoft.com/en-us/azure/aks/ingress-tls) + - [Istio](https://istio.io/docs/tasks/traffic-management/ingress/) + + **Note** that presidio is not deployed with an ingress controller by default. + to change this behavior, deploy the helm chart with *api.ingress.enabled=true* and specify they type of ingress controller to be used with *api.ingress.class=nginx* (supported classes are: nginx, traefik or istio). 4. Verify that Redis and Traefik/NGINX are installed correctly diff --git a/docs/monitoring_logging.md b/docs/monitoring_logging.md new file mode 100644 index 000000000..243412a3f --- /dev/null +++ b/docs/monitoring_logging.md @@ -0,0 +1,193 @@ +# Presidio logging and monitoring design concepts + +Presidio accommodates several ways to collect logs, metrics, and traces using cloud-native standards enabled by its runtime, kubnernetes. +The following document describes some use-cases suited for different environments and requirements from the logging system which have been tested by the presidio team. + +## Logging and Monitoring Basics + +- ***Logs*** are text-based records of events that occur while the application is running. +- ***Metrics*** are numerical values that can be analyzed. Different types of metrics include: + - **Node-level and Container metrics** including CPU, memory, network, disk, and file system usage. + - **Application metrics** include any metrics that are relevant to understanding the behavior of a service as well as custom metrics that are specific to the domain + - **Dependent service metrics** include external services or endpoints statistics for latency and error rate. +- ***Distributed Tracing*** - is used to profile and monitor applications built using a microservices architecture using a correlation ID. + +Visit the [Architecture center](https://docs.microsoft.com/en-us/azure/architecture/microservices/logging-monitoring) to learn about implementing logging and monitoring in microservices. + +## Technology Options + +The following section covers three logging technology stacks that include potential scenarios in public and private clouds: +- Azure Monitor +- EFK (Elastic, FluentD, Kibana) +- Kubernetes service mesh (Istio and Linkerd) + +### Azure Kubernetes Service (AKS) and Azure Monitor logging and metrics +When deploying presidio to AKS, [Azure Monitor](https://docs.microsoft.com/en-us/azure/azure-monitor/overview) provides the easiest way to manage and query logs and metrics using OOTB tooling. +There are a number of ways to enable Azure Monitor on either a new or an exising cluster using the portal, CLI and Terraform, [read more](https://docs.microsoft.com/en-us/azure/azure-monitor/insights/container-insights-onboard). + +##### Enabling Azure Monitor on AKS using the CLI + + +```sh +az aks enable-addons -a monitoring -n MyExistingManagedCluster -g MyExistingManagedClusterRG +``` + +##### Example - Viewing Analyzer Logs + +Run the following KQL query in Azure Logs: + +```sql +let startTimestamp = ago(1d); +let ContainerIDs = KubePodInventory +| where TimeGenerated > startTimestamp +| where ClusterId =~ "[YOUR_CLUSTER_ID]" +| distinct ContainerID; +ContainerLog +| where ContainerID in (ContainerIDs) and Image contains "analyzer" +| project LogEntrySource, LogEntry, TimeGenerated, Computer, Image, Name, ContainerID +| order by TimeGenerated desc +| limit 200 +``` + +### Logging with Elasticsearch, Kibana, FluentD + +Logs in presidio are outputted to stderr and stdout as a standard of logging in 12 factor/microservices applications. +to store logs for long term retention and exploration during failures and RCA, use [elasticsearch](https://github.com/elastic/elasticsearch) or other document databases that are optimized to act as a search engine (solr, splunk, etc). elasticsearch logs are easily queried and visualized using [kibana](https://github.com/elastic/kibana) or [grafana](https://github.com/grafana/grafana). +Shipping logs from a microservices platform such as kubernetes to the logs database is done using a logs processor\forwarder such as the CNCF project [FluentD](https://www.fluentd.org/). + +##### Enabling EFK on AKS + +The following section describes deploying an EFK (Elastic, FluentD, Kibana) stack to a development AKS cluster. +**Note that:** the following scripts do **not** fit a production environment in terms of security and scale. + +- Install elasticsearch + +```sh +helm install stable/elasticsearch --name=elasticsearch --namespace logging --set client.replicas=1 --set master.replicas=1 --set cluster.env.MINIMUM_MASTER_NODES=1 --set cluster.env.RECOVER_AFTER_MASTER_NODES=1 --set cluster.env.EXPECTED_MASTER_NODES=1 --set data.replicas=1 --set data.heapSize=300m --set master.persistence.storageClass=managed-premium --set data.persistence.storageClass=managed-premium +``` + +- Install fluent-bit (a lightweight fluentD log-forwarder) + +```sh +helm install stable/fluent-bit --name=fluent-bit --namespace=logging --set backend.type=es --set backend.es.host=elasticsearch-client +``` + +- Install kibana + +```sh +helm install stable/kibana --version 3.0.0 --name=kibana --namespace=logging --set env.ELASTICSEARCH_URL=http://elasticsearch-client:9200 --set files."kibana\.yml"."elasticsearch\.hosts"=http://elasticsearch-client:9200 --set service.type=NodePort --set service.nodePort=31000 +``` + +##### Example - Viewing Analyzer Logs + +- Open the kinana dashbaord + +```sh +kubectl -n logging port-forward $(kubectl -n logging get pod -l app=kibana -o jsonpath='{.items[0].metadata.name}') 5601:5601 +``` + +- Open your browser at http://localhost:5601 + +- After initilization of kibana index, switch to the "Discover" tab and search for presidio specific logs. + +- Search for 'presidio-analyzer' to view logs generated by the analyzer and different recognizers. + +### Service Level Metrics and Distributed Tracing + +Metrics and tracing provided by kubernetes and by the applications deployed in the cluster are best suited to be exported to a time-series database such as CNCF Prometheus, shipping of metrics and traces to the database is done using a log forwarder such as FluentD. +When using a service mesh such as istio or linkerd, cluster and service level telemetry are shipped to the database by the mesh using the sidecar containers, and adding distributed correlation-ID to identify the flow of events across services. + +### Using Istio + +##### Enabling Istio on AKS + +To enable istio on AKS, refer to the [aks documentation](https://docs.microsoft.com/en-us/azure/aks/istio-install). +To enable istio on your kubernetes cluster, refer to the official [quick-start guide](https://istio.io/docs/setup/kubernetes/install/kubernetes/) or your specific kubernetes hosting solution guide. + +##### Example - Presidio Service Metrics + +- Make sure presidio namespace is tagged for istio sidecar injection and that presidio is deployed using the istio ingress. + + ```sh + kubectl label namespace presidio istio-injection=enabled + + export REGISTRY=mcr.microsoft.com + export TAG=latest + + helm install --name presidio-demo --set registry=$REGISTRY,tag=$TAG,api.ingress.enabled=true,api.ingress.class=istio charts/presidio --namespace presidio + ``` + +- Open the grafana dashbaord + + ```sh + kubectl -n istio-system port-forward $(kubectl -n istio-system get pod -l app=grafana -o jsonpath='{.items[0].metadata.name}') 3000:3000 + ``` + +- Open your browser at http://localhost:3000 + +- Istio's grafana comes with several built in dashabords, for instance: + + * **[Istio Mesh Dashboard](http://localhost:3000/dashboard/db/istio-mesh-dashboard)** - global view of the Mesh along with services and workloads in the mesh. + * **[Service Dashboards](http://localhost:3000/dashboard/db/istio-service-dashboard)** - metrics for the service and then client workloads (workloads that are calling this service) and service workloads (workloads that are providing this service) for that service. + * **[Workload Dashboards](http://localhost:3000/dashboard/db/istio-workload-dashboard)** - metrics for each workload and then inbound workloads (workloads that are sending request to this workload) and outbound services (services to which this workload send requests) for that workload. + + +- Alternatively, open Prometheus dashabord to query the database directly + +```sh +kubectl -n istio-system port-forward $(kubectl -n istio-system get pod -l app=prometheus -o jsonpath='{.items[0].metadata.name}') 9090:9090 +``` + +- Open your browser at http://localhost:9090 + +-Search Prometheus for presidio containers telemetry + +##### Example - Presidio Service Dependecies + +- Open the [Kiali](https://www.kiali.io/) service mesh observability dashboard + +```sh +kubectl port-forward -n istio-system $(kubectl get pod -n istio-system -l app=kiali -o jsonpath='{.items[0].metadata.name}') 20001:20001 +``` + +- Open your browser at http://localhost:20001/kiali/console/ + +- View workload, application and service health and the dependency graph between presidio services with network and performance KPIs. + + +##### Example - Presidio Distributed Metrics + +- Open the [Jaeger](https://www.jaegertracing.io/) e2e distributed tracing tool + +```sh +kubectl port-forward -n istio-system $(kubectl get pod -n istio-system -l app=jaeger -o jsonpath='{.items[0].metadata.name}') 16686:16686 +``` + +- Open your browser at http://localhost:16686 + +- Note that jaeger has a sample rate of around 1/100, tracing may take time to show. + +- Use the search tab to find specific requests and the dependencies tab to view presidio service relations. + +### Using Linkerd + +##### Enabling Linkerd on AKS + +To enable linkerd on your kubernetes cluster, refer to the official [quick-start guide](https://linkerd.io/2/getting-started/) or your specific kubernetes hosting solution guide. + +##### Example - Presidio Service Mesh Dashbaord and Metrics + +- Open the linkerd dashbaord + +```sh +linkerd dashboard +``` + +- The browser is opened + +- Linkerd dashbaord are built on top of prometheus and provide an overview of services health and KPIs. + grafana is opened when clicking a service for greater insights, featuring the following dashbaords: + + * **Top Line Metrics** - "golden" KPIs for top services + * **Deployment Detail** - per deployment KPIs + * **Pod Details** - per pod KPIs From a07870567c243046051639db76f38f83e02aa989 Mon Sep 17 00:00:00 2001 From: balteravishay Date: Sun, 7 Jul 2019 18:08:55 +0300 Subject: [PATCH 58/75] load spacy nlp engine once (#188) Prevent Spacy from loading twice. As a result, decrease memory requirements. --- .../presidio/templates/analyzer-deployment.yaml | 4 ++-- docs/custom_fields.md | 3 +-- docs/install.md | 1 + presidio-analyzer/analyzer/__main__.py | 7 +++++-- presidio-analyzer/analyzer/analyzer_engine.py | 13 ++++++++----- presidio-analyzer/tests/test_analyzer_engine.py | 16 ++++++++-------- 6 files changed, 25 insertions(+), 19 deletions(-) diff --git a/charts/presidio/templates/analyzer-deployment.yaml b/charts/presidio/templates/analyzer-deployment.yaml index 47d7dc3c9..5448f8d59 100644 --- a/charts/presidio/templates/analyzer-deployment.yaml +++ b/charts/presidio/templates/analyzer-deployment.yaml @@ -26,10 +26,10 @@ spec: - containerPort: {{ .Values.analyzer.service.internalPort }} resources: requests: - memory: "2000Mi" + memory: "1500Mi" cpu: "1500m" limits: - memory: "5000Mi" + memory: "3000Mi" cpu: "2000m" env: - name: PRESIDIO_NAMESPACE diff --git a/docs/custom_fields.md b/docs/custom_fields.md index c16fe98c2..700bacb0d 100644 --- a/docs/custom_fields.md +++ b/docs/custom_fields.md @@ -132,5 +132,4 @@ Presidio supports custom fields using either online via a simple REST API or by b. Reference and add the new class to the `RecognizerRegistry` module, in the `load_predefined_recognizers` method, which registers all code based recognizers. - - \ No newline at end of file + c. Note that if by adding the new recognizer, the memory or CPU consumption of the analyzer is expected to grow (such as in the case of adding a new model based recognizer), you should consider updating the pod's resources allocation in [analyzer-deployment.yaml](../charts/presidio/templates/analyzer-deployment.yaml) diff --git a/docs/install.md b/docs/install.md index 597667b63..6a4736e2d 100644 --- a/docs/install.md +++ b/docs/install.md @@ -31,6 +31,7 @@ docker run --rm --name presidio-api --network mynetwork -d -p 8080:8080 -e WEB_P ### Requirements - Kubernetes 1.9+ with RBAC enabled. + - Note the pod's resources requirements (CPU and memory) and plan the cluster accordingly. - Helm ### Default installation using pre-made scripts diff --git a/presidio-analyzer/analyzer/__main__.py b/presidio-analyzer/analyzer/__main__.py index 14f6145e7..ea65c756f 100644 --- a/presidio-analyzer/analyzer/__main__.py +++ b/presidio-analyzer/analyzer/__main__.py @@ -18,6 +18,8 @@ sys.path.append(path.dirname(path.dirname(path.abspath(__file__)))) from analyzer_engine import AnalyzerEngine # noqa +from recognizer_registry.recognizer_registry import RecognizerRegistry # noqa +from nlp_engine.spacy_nlp_engine import SpacyNlpEngine # noqa WELCOME_MESSAGE = r""" @@ -61,9 +63,10 @@ def __init__(self, cli_ctx=None): def serve_command_handler(env_grpc_port=False, grpc_port=3000): server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) - + registry = RecognizerRegistry() + nlp_engine = SpacyNlpEngine() analyze_pb2_grpc.add_AnalyzeServiceServicer_to_server( - AnalyzerEngine(), server) + AnalyzerEngine(registry, nlp_engine), server) if env_grpc_port: port = os.environ.get('GRPC_PORT') diff --git a/presidio-analyzer/analyzer/analyzer_engine.py b/presidio-analyzer/analyzer/analyzer_engine.py index 3dddf8df1..9a474708e 100644 --- a/presidio-analyzer/analyzer/analyzer_engine.py +++ b/presidio-analyzer/analyzer/analyzer_engine.py @@ -5,9 +5,6 @@ import analyze_pb2_grpc import common_pb2 -from analyzer import RecognizerRegistry -from analyzer.nlp_engine import SpacyNlpEngine - loglevel = os.environ.get("LOG_LEVEL", "INFO") logging.basicConfig( format='%(asctime)s:%(levelname)s:%(message)s', level=loglevel) @@ -17,9 +14,15 @@ class AnalyzerEngine(analyze_pb2_grpc.AnalyzeServiceServicer): - def __init__(self, registry=RecognizerRegistry(), - nlp_engine=SpacyNlpEngine()): + def __init__(self, registry=None, nlp_engine=None): + if not nlp_engine: + from analyzer.nlp_engine import SpacyNlpEngine + nlp_engine = SpacyNlpEngine() + if not registry: + from analyzer import RecognizerRegistry + registry = RecognizerRegistry() # load nlp module + self.nlp_engine = nlp_engine # prepare registry self.registry = registry diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index d0249d2b2..6dbb49a6e 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -111,7 +111,7 @@ def test_analyze_with_multiple_predefined_recognizers(self): # This analyzer engine is different from the global one, as this one # also loads SpaCy so it can detect the phone number entity - analyzer_engine_with_spacy = AnalyzerEngine(self.loaded_registry) + analyzer_engine_with_spacy = AnalyzerEngine(registry=self.loaded_registry, nlp_engine=SpacyNlpEngine()) results = analyzer_engine_with_spacy.analyze(text, entities, language, all_fields=False) assert len(results) == 2 @@ -164,8 +164,8 @@ def test_added_pattern_recognizer_works(self): # Make sure the analyzer doesn't get this entity recognizers_store_api_mock = RecognizerStoreApiMock() - analyze_engine = AnalyzerEngine( - MockRecognizerRegistry(recognizers_store_api_mock)) + analyze_engine = AnalyzerEngine(registry= + MockRecognizerRegistry(recognizers_store_api_mock), nlp_engine=SpacyNlpEngine()) text = "rocket is my favorite transportation" entities = ["CREDIT_CARD", "ROCKET"] @@ -193,8 +193,8 @@ def test_removed_pattern_recognizer_doesnt_work(self): # Make sure the analyzer doesn't get this entity recognizers_store_api_mock = RecognizerStoreApiMock() - analyze_engine = AnalyzerEngine(MockRecognizerRegistry( - recognizers_store_api_mock)) + analyze_engine = AnalyzerEngine(registry= MockRecognizerRegistry( + recognizers_store_api_mock), nlp_engine=SpacyNlpEngine()) text = "spaceship is my favorite transportation" entities = ["CREDIT_CARD", "SPACESHIP"] @@ -243,7 +243,7 @@ def test_apply_with_no_language_returns_default(self): assert response.analyzeResults is not None def test_when_allFields_is_true_return_all_fields(self): - analyze_engine = AnalyzerEngine(MockRecognizerRegistry()) + analyze_engine = AnalyzerEngine(registry=MockRecognizerRegistry(), nlp_engine=SpacyNlpEngine()) request = AnalyzeRequest() request.analyzeTemplate.allFields = True request.text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090 " \ @@ -258,7 +258,7 @@ def test_when_allFields_is_true_return_all_fields(self): assert "DOMAIN_NAME" in returned_entities def test_when_allFields_is_true_full_recognizers_list_return_all_fields(self): - analyze_engine = AnalyzerEngine(RecognizerRegistry()) + analyze_engine = AnalyzerEngine(registry=RecognizerRegistry(), nlp_engine=SpacyNlpEngine()) request = AnalyzeRequest() request.analyzeTemplate.allFields = True request.text = "My name is David and I live in Seattle." \ @@ -272,7 +272,7 @@ def test_when_allFields_is_true_full_recognizers_list_return_all_fields(self): assert "DOMAIN_NAME" in returned_entities def test_when_allFields_is_true_and_entities_not_empty_exception(self): - analyze_engine = AnalyzerEngine(registry=RecognizerRegistry()) + analyze_engine = AnalyzerEngine(registry=RecognizerRegistry(), nlp_engine=SpacyNlpEngine()) request = AnalyzeRequest() request.text = "My name is David and I live in Seattle." \ "Domain: microsoft.com " From b18ba4cbc663e594dee72a4de24bd65a718d319b Mon Sep 17 00:00:00 2001 From: balteravishay Date: Wed, 10 Jul 2019 23:51:24 +0300 Subject: [PATCH 59/75] Fix empty results (#189) Fixes a bug in which the API returns 400 whenever the analyzer service didn't find any entity. --- .../cmd/presidio-api/api/analyze/analyze.go | 2 +- .../presidio-api/api/analyze/analyze_test.go | 28 +++++++++++++++++++ .../cmd/presidio-api/api/mocks/mocks.go | 7 +++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/presidio-api/cmd/presidio-api/api/analyze/analyze.go b/presidio-api/cmd/presidio-api/api/analyze/analyze.go index ed69a6e8b..41512d3c8 100644 --- a/presidio-api/cmd/presidio-api/api/analyze/analyze.go +++ b/presidio-api/cmd/presidio-api/api/analyze/analyze.go @@ -28,7 +28,7 @@ func Analyze(ctx context.Context, api *store.API, analyzeAPIRequest *types.Analy return nil, err } if res == nil { - return nil, fmt.Errorf("No results") + return []*types.AnalyzeResult{}, err } return res, err diff --git a/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go b/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go index cb187eba8..4559b5c31 100644 --- a/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go +++ b/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go @@ -24,6 +24,18 @@ func setupMockServices() *store.API { return api } +func setupEmptyResponseMockServices() *store.API { + srv := &services.Services{ + AnalyzerService: mocks.GetAnalyzeServiceMock(mocks.GetAnalyzerMockEmptyResult()), + } + + api := &store.API{ + Services: srv, + Templates: mocks.GetTemplateMock(), + } + return api +} + func TestAnalyzeWithTemplateId(t *testing.T) { api := setupMockServices() @@ -104,3 +116,19 @@ func TestAllFields(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 2, len(results)) } + +func TestAnalyzeWhenNoEntitiesFoundThenExpectEmptyResponse(t *testing.T) { + + api := setupEmptyResponseMockServices() + + project := "tests" + noResultsanalyzeAPIRequest := &types.AnalyzeApiRequest{ + Text: "hello world", + AnalyzeTemplate: &types.AnalyzeTemplate{ + Language: "en", + AllFields: true}, + } + results, err := Analyze(context.Background(), api, noResultsanalyzeAPIRequest, project) + assert.NoError(t, err) + assert.Equal(t, 0, len(results)) +} diff --git a/presidio-api/cmd/presidio-api/api/mocks/mocks.go b/presidio-api/cmd/presidio-api/api/mocks/mocks.go index 5e0db14e5..5cbe37e82 100644 --- a/presidio-api/cmd/presidio-api/api/mocks/mocks.go +++ b/presidio-api/cmd/presidio-api/api/mocks/mocks.go @@ -42,6 +42,13 @@ type TemplateMockedObject struct { mock.Mock } +//GetAnalyzerMockEmptyResult get analyzer mock empty response +func GetAnalyzerMockEmptyResult() *types.AnalyzeResponse { + return &types.AnalyzeResponse{ + AnalyzeResults: nil, + } +} + //GetAnalyzerMockResult get analyzer mock response func GetAnalyzerMockResult() *types.AnalyzeResponse { location := &types.Location{ From 6997d3dedef6b7f729e0d29966edf86f4b383cb2 Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Mon, 15 Jul 2019 17:57:53 +0300 Subject: [PATCH 60/75] Interpretability information tracing (#185) Exposing system decisions using an app tracer. Specifically, NLP engine outputs (NLP artifacts), recognizer results and context words. --- .gitignore | 1 + Gopkg.lock | 407 ++++++++++-------- docs/index.md | 1 + docs/interpretability_logs.md | 57 +++ pkg/presidio/presidio.go | 2 +- pkg/presidio/services/services.go | 6 +- pkg/server/server.go | 13 +- presidio-analyzer/analyzer/__init__.py | 1 + presidio-analyzer/analyzer/__main__.py | 17 +- .../analyzer/analysis_explanation.py | 51 +++ presidio-analyzer/analyzer/analyze_pb2.py | 15 +- presidio-analyzer/analyzer/analyzer_engine.py | 52 ++- presidio-analyzer/analyzer/app_tracer.py | 25 ++ .../analyzer/entity_recognizer.py | 38 +- presidio-analyzer/analyzer/logger.py | 80 ++++ .../analyzer/nlp_engine/nlp_artifacts.py | 3 + .../analyzer/nlp_engine/spacy_nlp_engine.py | 11 +- .../analyzer/pattern_recognizer.py | 57 ++- .../credit_card_recognizer.py | 11 +- .../crypto_recognizer.py | 9 +- .../domain_recognizer.py | 9 +- .../email_recognizer.py | 10 +- .../predefined_recognizers/iban_recognizer.py | 14 +- .../spacy_recognizer.py | 24 +- .../uk_nhs_recognizer.py | 6 +- presidio-analyzer/analyzer/presidio-analyzer | 0 .../recognizer_registry.py | 6 +- .../analyzer/recognizer_result.py | 16 +- presidio-analyzer/analyzer/template_pb2.py | 264 ++++++------ presidio-analyzer/tests/mocks/__init__.py | 3 +- .../tests/mocks/app_tracer_mock.py | 39 ++ .../tests/test_analyzer_engine.py | 56 ++- .../tests/test_pattern_recognizer.py | 4 +- .../tests/test_recognizer_registry.py | 9 +- .../cmd/presidio-api/api/analyze/analyze.go | 4 +- .../presidio-api/api/analyze/analyze_test.go | 20 +- .../api/anonymize-image/anonymize-image.go | 6 +- .../presidio-api/api/anonymize/anonymize.go | 2 +- .../cmd/presidio-api/api/mocks/mocks.go | 1 + presidio-api/cmd/presidio-api/methods.go | 6 +- .../presidio-collector/processor/processor.go | 22 +- 41 files changed, 908 insertions(+), 470 deletions(-) create mode 100644 docs/interpretability_logs.md create mode 100644 presidio-analyzer/analyzer/analysis_explanation.py create mode 100644 presidio-analyzer/analyzer/app_tracer.py create mode 100644 presidio-analyzer/analyzer/logger.py mode change 100644 => 100755 presidio-analyzer/analyzer/presidio-analyzer create mode 100644 presidio-analyzer/tests/mocks/app_tracer_mock.py diff --git a/.gitignore b/.gitignore index 2ebfd0e14..a1e47c933 100644 --- a/.gitignore +++ b/.gitignore @@ -89,6 +89,7 @@ venv/ ENV/ env.bak/ venv.bak/ +*venv/ # Spyder project settings .spyderproject diff --git a/Gopkg.lock b/Gopkg.lock index d53958a77..b512c2ccd 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -2,20 +2,20 @@ [[projects]] - digest = "1:b62a3c5b37db602bf1158e921da1a762315a4c37855fd418a14498aa87a342d5" + digest = "1:3d3a509c5ba327e8573bb57f9da8430c63a46a06886eb1d2ffc8af4e76f31c72" name = "cloud.google.com/go" packages = ["civil"] pruneopts = "UT" - revision = "0ebda48a7f143b1cce9eb37a8c1106ac762a3430" - version = "v0.34.0" + revision = "457ea5c15ccf3b87db582c450e80101989da35f7" + version = "v0.40.0" [[projects]] - digest = "1:b92928b73320648b38c93cacb9082c0fe3f8ac3383ad9bd537eef62c380e0e7a" + digest = "1:6b1426cad7057b717351eacf5b6fe70f053f11aac1ce254bbf2fd72c031719eb" name = "contrib.go.opencensus.io/exporter/ocagent" packages = ["."] pruneopts = "UT" - revision = "00af367e65149ff1f2f4b93bbfbb84fd9297170d" - version = "v0.2.0" + revision = "dcb33c7f3b7cfe67e8a2cea10207ede1b7c40764" + version = "v0.4.12" [[projects]] digest = "1:487dc37a77bbba996bf4ddae0bff1c69fde98027d507e75eca317ca7c94483c3" @@ -39,7 +39,7 @@ version = "v1.1.4" [[projects]] - digest = "1:b7ae7b7962d3c2656f3eb7d8543932de1cb31ba28dc1433b5ce74613f9694318" + digest = "1:88a413bd074ae78a3dfd59b8458439a17c393e859b234b4fcf2d2088da7907e9" name = "github.com/Azure/azure-event-hubs-go" packages = [ ".", @@ -48,16 +48,16 @@ "storage", ] pruneopts = "UT" - revision = "aca3e9cfe138951ffb815665621095482a674ee9" - version = "v1.1.2" + revision = "d3d1b70a113ea8a9f2d7443ff7d99ab40f9b6eca" + version = "v1.3.1" [[projects]] - digest = "1:d2ccb697dc13c8fbffafa37baae97594d5592ae8f7e113471084137315536e2b" + digest = "1:279540310125d2b219920588d7e2edb2a85b3317b528839166e896ce6b6f211c" name = "github.com/Azure/azure-pipeline-go" packages = ["pipeline"] pruneopts = "UT" - revision = "b8e3409182fd52e74f7d7bdfbff5833591b3b655" - version = "v0.1.8" + revision = "55fedc85a614dcd0e942a66f302ae3efb83d563c" + version = "v0.1.9" [[projects]] digest = "1:fd0485bc9bbf77bbfefed5b67fc45899b130c78b544127d5f1efde7a0b768b0b" @@ -73,15 +73,15 @@ version = "v23.2.0" [[projects]] - digest = "1:b8ac7e4464ce21f7487c663aa69b1b3437740bb10ab12d4dc7aa9b02422571a1" + digest = "1:b15d5bdadce5d98f1e06353508a4029fccfeb68761957b3a2a8cb38ebb8caca4" name = "github.com/Azure/azure-storage-blob-go" packages = ["azblob"] pruneopts = "UT" - revision = "45d0c5e3638e2b539942f93c48e419f4f2fc62e4" - version = "0.4.0" + revision = "678206e7e6e55abf0265a6440135e92005176ebf" + version = "v0.6.0" [[projects]] - digest = "1:9e8ebf3883dce8687221c4022bdb3b0bfdcde430b911be9cc7e52478a026893c" + digest = "1:c1e04f3b97fbb6e25891c10d3d2d906dcd5807774a3a68d0e32e2b7bda9b5c7f" name = "github.com/Azure/go-autorest" packages = [ "autorest", @@ -94,35 +94,35 @@ "tracing", ] pruneopts = "UT" - revision = "be17756531f50014397912b7aa557ec335e39b98" - version = "v11.3.0" + revision = "562d3769ef2f0f56bc52749babd3e88367e28588" + version = "v11.9.0" [[projects]] - digest = "1:ed77032e4241e3b8329c9304d66452ed196e795876e14be677a546f36b94e67a" + digest = "1:6d8a3b164679872fa5a4c44559235f7fb109c7b5cd0f456a2159d579b76cc9ba" name = "github.com/DataDog/zstd" packages = ["."] pruneopts = "UT" - revision = "c7161f8c63c045cbc7ca051dcc969dd0e4054de2" - version = "v1.3.5" + revision = "809b919c325d7887bff7bd876162af73db53e878" + version = "v1.4.0" [[projects]] branch = "development" - digest = "1:8d270b7938356d0f7262169aaff9a272a1b747ede5bcd5040bb5cc9c012e57e9" + digest = "1:4dc3b1c103bec54029b98720ce692277552b630fa0d95cda3abe8d0c760b4947" name = "github.com/Microsoft/presidio-genproto" packages = ["golang"] pruneopts = "UT" - revision = "992d0127ad644ffcde2e6e1185f772a07ea14273" + revision = "3863dc4f6f6836fb0a638ac9849f7b374dd6f94c" [[projects]] - digest = "1:a59a467c541a1bf8b06e4fad6113028c959be6573b78ceca9f8020cd0d2127fc" + digest = "1:2ec153af6a806c3d63d4299f2549bcb29d75d9703097341be309a46db3481488" name = "github.com/Shopify/sarama" packages = ["."] pruneopts = "UT" - revision = "879f631812a30a580659e8035e7cda9994bb99ac" - version = "v1.20.0" + revision = "ea9ab1c316850bee881a07bb2555ee8a685cd4b6" + version = "v1.22.1" [[projects]] - digest = "1:355da6e69ecab4bb211ddd598a3c26d4c156802dac22722953734b7f792289e6" + digest = "1:927a701594a3a785378229c81bde8e83349bc7bd78c44a6ae103f37fd6da7d01" name = "github.com/aws/aws-sdk-go" packages = [ "aws", @@ -152,6 +152,7 @@ "private/protocol", "private/protocol/eventstream", "private/protocol/eventstream/eventstreamapi", + "private/protocol/json/jsonutil", "private/protocol/query", "private/protocol/query/queryutil", "private/protocol/rest", @@ -161,8 +162,8 @@ "service/sts", ] pruneopts = "UT" - revision = "1f8a24693bc965514ee0d7aadbabe0ceed184a88" - version = "v1.16.16" + revision = "b0b59fd2ceb03908e5d3bcd1449b46ce75508f4b" + version = "v1.20.7" [[projects]] digest = "1:526d64d0a3ac6c24875724a9355895be56a21f89a5d3ab5ba88d91244269a7d8" @@ -181,17 +182,19 @@ version = "v1.2.1" [[projects]] - digest = "1:65b0d980b428a6ad4425f2df4cd5410edd81f044cf527bd1c345368444649e58" + digest = "1:fdb4ed936abeecb46a8c27dcac83f75c05c87a46d9ec7711411eb785c213fa02" name = "github.com/census-instrumentation/opencensus-proto" packages = [ "gen-go/agent/common/v1", + "gen-go/agent/metrics/v1", "gen-go/agent/trace/v1", + "gen-go/metrics/v1", "gen-go/resource/v1", "gen-go/trace/v1", ] pruneopts = "UT" - revision = "7f2434bc10da710debe5c4315ed6d4df454b4024" - version = "v0.1.0" + revision = "a105b96453fe85139acc07b68de48f2cbdd71249" + version = "v0.2.0" [[projects]] digest = "1:ffe9824d294da03b391f44e1ae8281281b4afc1bdaa9588c9097785e3af10cec" @@ -203,14 +206,14 @@ [[projects]] branch = "master" - digest = "1:0fd9da444782c2defb1352dc098f55b8b42c538787e29e45677a1dc40ff0ab11" + digest = "1:2e702b60af5efe4b3b0e3b40fddfb6beba6adca1e0a7ccd25cc5c21b5e7df9ef" name = "github.com/denisenkom/go-mssqldb" packages = [ ".", "internal/cp", ] pruneopts = "UT" - revision = "4e0d7dc8888fbb59764060e99b7b68e77a6f9698" + revision = "eb9f6a1743f30383c8168cc520ca5db1f744d6f4" [[projects]] digest = "1:76dc72490af7174349349838f2fe118996381b31ea83243812a97e5a0fd5ed55" @@ -222,19 +225,19 @@ [[projects]] branch = "master" - digest = "1:7d0b66300f67891562442ba782b7927859bf9274afd36c4651371262396bbb65" + digest = "1:6695eade5deeae68a4fc0755a005cc894ee7b47f46cdfe75340cd7803b0d23d1" name = "github.com/disintegration/imaging" packages = ["."] pruneopts = "UT" - revision = "9458da53d1e65e098d48467a4317c403327e4424" + revision = "465faf0892b5c7b3325643b0e47282e1331672e7" [[projects]] digest = "1:1f0c7ab489b407a7f8f9ad16c25a504d28ab461517a971d341388a56156c1bd7" name = "github.com/eapache/go-resiliency" packages = ["breaker"] pruneopts = "UT" - revision = "ea41b0fad31007accc7f806884dcdf3da98b79ce" - version = "v1.1.0" + revision = "5efd2ed019fd331ec2defc6f3bd98882f1e3e636" + version = "v1.2.0" [[projects]] branch = "master" @@ -253,12 +256,12 @@ version = "v1.1.0" [[projects]] - digest = "1:f1f2bd73c025d24c3b93abf6364bccb802cf2fdedaa44360804c67800e8fab8d" + digest = "1:ac425d784b13d49b37a5bbed3ce022677f8f3073b216f05d6adcb9303e27fa0f" name = "github.com/evanphx/json-patch" packages = ["."] pruneopts = "UT" - revision = "72bf35d0ff611848c1dc9df0f976c81192392fa5" - version = "v4.1.0" + revision = "026c730a0dcc5d11f93f1cf1cc65b01247ea7b6f" + version = "v4.5.0" [[projects]] digest = "1:abeb38ade3f32a92943e5be54f55ed6d6e3b6602761d74b4aab4c9dd45c18abd" @@ -270,43 +273,43 @@ [[projects]] branch = "master" - digest = "1:237e20f314113702902d275bf57103693f8a4d3bfcf43a4cd02163ee3430c90e" + digest = "1:cc93dd54278f6c0dd4b43588b3cf2c07b09b2a6dd4e2617edf81aa8946054c1e" name = "github.com/gin-contrib/cors" packages = ["."] pruneopts = "UT" - revision = "5e7acb10687f94a88d0d8e96297818fff2da8f88" + revision = "5f50d4fb4e0306dcacc6f8e9bea2dcee784dbbdf" [[projects]] - branch = "master" - digest = "1:36fe9527deed01d2a317617e59304eb2c4ce9f8a24115bcc5c2e37b3aee5bae4" + digest = "1:3ee1d175a75b911a659fbd860060874c4f503e793c5870d13e5a0ede529a63cf" name = "github.com/gin-contrib/sse" packages = ["."] pruneopts = "UT" - revision = "22d885f9ecc78bf4ee5d72b937e4bbcdc58e8cae" + revision = "54d8467d122d380a14768b6b4e5cd7ca4755938f" + version = "v0.1.0" [[projects]] branch = "master" - digest = "1:264ce7a5d411d8d4304965d87ba016e379ed2d6bce26e62340112c108a786f38" + digest = "1:27283820a5e1b25ce0363e099eb233183a5de7f76177eba1688a71e605cad06c" name = "github.com/gin-contrib/zap" packages = ["."] pruneopts = "UT" - revision = "0672bb1dbf3af725a3d294a73bd92dab67cb8adc" + revision = "3cc18cd8fce3ca00df79ca44f73fed1ed5d1fe2f" [[projects]] - digest = "1:d5083934eb25e45d17f72ffa86cae3814f4a9d6c073c4f16b64147169b245606" + digest = "1:d8bd2a337f6ff2188e08f72c614f2f3f0fd48e6a7b37a071b197e427d77d3a47" name = "github.com/gin-gonic/gin" packages = [ ".", "binding", - "json", + "internal/json", "render", ] pruneopts = "UT" - revision = "b869fe1415e4b9eb52f247441830d502aece2d4d" - version = "v1.3.0" + revision = "b75d67cd51eb53c3c3a2fc406524c940021ffbda" + version = "v1.4.0" [[projects]] - digest = "1:ad53d1f710522a38d1f0e5e0a55a194b1c6b2cd8e84313568e43523271f0cf62" + digest = "1:c950e574951c7199fb3d990d0e7a61996f40f8e646ba7cf8a557878d4c737f53" name = "github.com/go-redis/redis" packages = [ ".", @@ -318,94 +321,83 @@ "internal/util", ] pruneopts = "UT" - revision = "22be8a3eaf992c828cecb69dc07348313bf08d2e" - version = "v6.15.1" + revision = "75795aa4236dc7341eefac3bbe945e68c99ef9df" + version = "v6.15.3" [[projects]] branch = "master" - digest = "1:23dca8a35ce10bf5caf35e92f6a2b5e633f55cbd28259c2207eb31d5b44b0c02" + digest = "1:783d985ffb1affe60a210e8620e25a8cd41475940ce19a3a7bab25c795b899fe" name = "github.com/go-sql-driver/mysql" packages = ["."] pruneopts = "UT" - revision = "c45f530f8e7fe40f4687eaa50d0c8c5f1b66f9e0" - -[[projects]] - digest = "1:436e8c1845d92384995e9c93470f639b886dbbc4b49c7babf544f9cc06361198" - name = "github.com/go-xorm/builder" - packages = ["."] - pruneopts = "UT" - revision = "03eb88feccce3e477c318ce7f6f1b386544ab20b" - version = "v0.3.3" - -[[projects]] - digest = "1:ec14b8c3b10e27599d7053a97bd28ef36e59cc4247f83b474bca43aaa971eab9" - name = "github.com/go-xorm/core" - packages = ["."] - pruneopts = "UT" - revision = "c10e21e7e1cec20e09398f2dfae385e58c8df555" - version = "v0.6.0" + revision = "877a9775f06853f611fb2d4e817d92479242d1cd" [[projects]] branch = "master" - digest = "1:683ffbf5c4f58c718a45c517884bf34110d8ddcb0f5a2b8309ce1630215fb5b3" + digest = "1:245c431f1b323b7c23de483f61bcae360c5278d191cb8e23327476a2c160c79f" name = "github.com/go-xorm/xorm" packages = ["."] pruneopts = "UT" - revision = "1cd2662be938bfee0e34af92fe448513e0560fb1" + revision = "4c806608ab1d39d93b8bdc2778acdf2c42735c04" [[projects]] - digest = "1:b402bb9a24d108a9405a6f34675091b036c8b056aac843bf6ef2389a65c5cf48" + digest = "1:4d02824a56d268f74a6b6fdd944b20b58a77c3d70e81008b3ee0c4f1a6777340" name = "github.com/gogo/protobuf" packages = [ "proto", "sortkeys", ] pruneopts = "UT" - revision = "4cbf7e384e768b4e01799441fdf2a706a5635ae7" - version = "v1.2.0" + revision = "ba06b47c162d49f2af050fb4c75bcbc86a159d5c" + version = "v1.2.1" [[projects]] branch = "master" - digest = "1:97239b8255df64c18138842365b135975e7402112beb593e139de1b91303d5bc" + digest = "1:420701248ee765a9945373092ec9b57a3a21099ce975063dbac100d228ec0bfe" name = "github.com/golang/protobuf" packages = [ + "jsonpb", "proto", "protoc-gen-go/descriptor", + "protoc-gen-go/generator", + "protoc-gen-go/generator/internal/remap", + "protoc-gen-go/plugin", "ptypes", "ptypes/any", "ptypes/duration", + "ptypes/struct", "ptypes/timestamp", "ptypes/wrappers", ] pruneopts = "UT" - revision = "347cf4a86c1cb8d262994d8ef5924d4576c5b331" + revision = "b285ee9cfc6c881bb20c0d8dc73370ea9b9ec90f" [[projects]] - branch = "master" - digest = "1:4a0c6bb4805508a6287675fac876be2ac1182539ca8a32468d8128882e9d5009" + digest = "1:e4f5819333ac698d294fe04dbf640f84719658d5c7ce195b10060cc37292ce79" name = "github.com/golang/snappy" packages = ["."] pruneopts = "UT" - revision = "2e65f85255dbc3072edf28d6b5b8efc472979f5a" + revision = "2a8bb927dd31d8daada140a5d09578521ce5c36a" + version = "v0.0.1" [[projects]] - branch = "master" digest = "1:0bfbe13936953a98ae3cfe8ed6670d396ad81edf069a806d2f6515d7bb6950df" name = "github.com/google/btree" packages = ["."] pruneopts = "UT" revision = "4030bb1f1f0c35b30ca7009e9ebd06849dd45306" + version = "v1.0.0" [[projects]] - branch = "master" - digest = "1:3ee90c0d94da31b442dde97c99635aaafec68d0b8a3c12ee2075c6bdabeec6bb" + digest = "1:a6181aca1fd5e27103f9a920876f29ac72854df7345a39f3b01e61c8c94cc8af" name = "github.com/google/gofuzz" packages = ["."] pruneopts = "UT" - revision = "24818f796faf91cd76ec7bddd72458fbced7a6c1" + revision = "f140a6486e521aad38f5917de355cbf147cc0496" + version = "v1.0.0" [[projects]] - digest = "1:65c4414eeb350c47b8de71110150d0ea8a281835b1f386eacaa3ad7325929c21" + digest = "1:d1a3774c1f8336a21669d6da87a7bafb4d6171a84752268b7011e767d6722c2b" name = "github.com/googleapis/gnostic" packages = [ "OpenAPIv2", @@ -413,19 +405,19 @@ "extensions", ] pruneopts = "UT" - revision = "7c663266750e7d82587642f65e60bc4083f1f84e" - version = "v0.2.0" + revision = "e73c7ec21d36ddb0711cb36d1502d18363b5c2c9" + version = "v0.3.0" [[projects]] branch = "master" - digest = "1:86c1210529e69d69860f2bb3ee9ccce0b595aa3f9165e7dd1388e5c612915888" + digest = "1:5fc0e23b254a1bd7d8d2d42fa093ba33471d08f52fe04afd3713adabb5888dc3" name = "github.com/gregjones/httpcache" packages = [ ".", "diskcache", ] pruneopts = "UT" - revision = "c63ab54fda8f77302f8d414e19933f2b6026a089" + revision = "901d90724c7919163f472a9812253fb26761123d" [[projects]] branch = "master" @@ -437,7 +429,27 @@ "util/metautils", ] pruneopts = "UT" - revision = "4832df01553a810b8e3404b95743d01c9ab5313f" + revision = "27f3801344b24dd6e9c608692368947f674a8298" + +[[projects]] + digest = "1:c20c9a82345346a19916a0086e61ea97425172036a32b8a8975490da6a129fda" + name = "github.com/grpc-ecosystem/grpc-gateway" + packages = [ + "internal", + "runtime", + "utilities", + ] + pruneopts = "UT" + revision = "cd0c8ef3533e9c04e6520cac37a81fe262fb0b34" + version = "v1.9.2" + +[[projects]] + digest = "1:67474f760e9ac3799f740db2c489e6423a4cde45520673ec123ac831ad849cb8" + name = "github.com/hashicorp/golang-lru" + packages = ["simplelru"] + pruneopts = "UT" + revision = "7087cb70de9f7a8bc0a10c375cb0d2280a8edf9c" + version = "v0.5.1" [[projects]] digest = "1:c0d19ab64b32ce9fe5cf4ddceba78d5bc9807f0016db6b1183599da3dcc24d10" @@ -459,12 +471,12 @@ version = "v1.0.0" [[projects]] - digest = "1:8eb1de8112c9924d59bf1d3e5c26f5eaa2bfc2a5fcbb92dc1c2e4546d695f277" + digest = "1:a0cefd27d12712af4b5018dc7046f245e1e3b5760e2e848c30b171b570708f9b" name = "github.com/imdario/mergo" packages = ["."] pruneopts = "UT" - revision = "9f23e2d6bd2a77f959b2bf6acdbefd708a83a4a4" - version = "v0.3.6" + revision = "7c29201646fa3de8506f701213473dd407f19646" + version = "v0.3.7" [[projects]] digest = "1:bb81097a5b62634f3e9fec1014657855610c82d19b9a40c17612e32651e35dca" @@ -482,12 +494,12 @@ version = "1.0.0" [[projects]] - digest = "1:3e551bbb3a7c0ab2a2bf4660e7fcad16db089fdcfbb44b0199e62838038623ea" + digest = "1:f5a2051c55d05548d2d4fd23d244027b59fbd943217df8aa3b5e170ac2fd6e1b" name = "github.com/json-iterator/go" packages = ["."] pruneopts = "UT" - revision = "1624edc4454b8682399def8740d46db5e4362ba4" - version = "v1.1.5" + revision = "0ff49de124c6f76f8494e194af75bde0f1a49a29" + version = "v1.1.6" [[projects]] branch = "master" @@ -499,22 +511,23 @@ [[projects]] branch = "master" - digest = "1:7cefc4f7f6a411c2598d3344563e4d23fd4e4d88fd1591831fe39cccff41ad28" + digest = "1:2abaafc9cb59897a71c84f1daf4131d59c0dd349c671206274ace759730fc1a0" name = "github.com/lib/pq" packages = [ ".", "oid", + "scram", ] pruneopts = "UT" - revision = "9eb73efc1fcc404148b56765b0d3f61d9a5ef8ee" + revision = "2ff3cb3adc01768e0a552b3a02575a6df38a9bea" [[projects]] - digest = "1:c568d7727aa262c32bdf8a3f7db83614f7af0ed661474b24588de635c20024c7" + digest = "1:5a0ef768465592efca0412f7e838cdc0826712f8447e70e6ccc52eb441e9ab13" name = "github.com/magiconair/properties" packages = ["."] pruneopts = "UT" - revision = "c2353362d570a7bfa228149c62842019201cfb71" - version = "v1.8.0" + revision = "de8848e004dd33dc07a2947b3d76f618a7fc7ef1" + version = "v1.8.1" [[projects]] digest = "1:4e878df5f4e9fd625bf9c9aac77ef7cbfa4a74c01265505527c23470c0e40300" @@ -525,12 +538,12 @@ version = "v1.1.0" [[projects]] - digest = "1:0981502f9816113c9c8c4ac301583841855c8cf4da8c72f696b3ebedf6d0e4e5" + digest = "1:9b90c7639a41697f3d4ad12d7d67dfacc9a7a4a6e0bbfae4fc72d0da57c28871" name = "github.com/mattn/go-isatty" packages = ["."] pruneopts = "UT" - revision = "6ca4dbf54d38eea1a992b3c722a76a5d1c4cb25c" - version = "v0.0.4" + revision = "1311e847b0cb909da63b5fecfb5370aa66236465" + version = "v0.0.8" [[projects]] digest = "1:4a49346ca45376a2bba679ca0e83bec949d780d4e927931317904bad482943ec" @@ -565,47 +578,47 @@ version = "1.0.1" [[projects]] - digest = "1:6411dc2c8891eb05c1d0599abf571e81f72cbc97ebd223b44d45712f4f1799c2" + digest = "1:f3b5bf575d84780832516c53d135b4917fe891ca67c472959578b7e09277e4b2" name = "github.com/otiai10/gosseract" packages = ["."] pruneopts = "UT" - revision = "b026a6fd291f00736db60738f4e83a79d26359cb" - version = "v2.2.0" + revision = "5bb1d6fc20fa3fafb3236d6c93c393369e4b38d9" + version = "v2.2.1" [[projects]] - digest = "1:95741de3af260a92cc5c7f3f3061e85273f5a81b5db20d4bd68da74bd521675e" + digest = "1:93131d8002d7025da13582877c32d1fc302486775a1b06f62241741006428c5e" name = "github.com/pelletier/go-toml" packages = ["."] pruneopts = "UT" - revision = "c01d1270ff3e442a8a57cddc1c92dc1138598194" - version = "v1.2.0" + revision = "728039f679cbcd4f6a54e080d2219a4c4928c546" + version = "v1.4.0" [[projects]] branch = "master" - digest = "1:3bf17a6e6eaa6ad24152148a631d18662f7212e21637c2699bff3369b7f00fa2" + digest = "1:89da0f0574bc94cfd0ac8b59af67bf76cdd110d503df2721006b9f0492394333" name = "github.com/petar/GoLLRB" packages = ["llrb"] pruneopts = "UT" - revision = "53be0d36a84c2a886ca057d34b6aa4468df9ccb4" + revision = "33fb24c13b99c46c93183c291836c573ac382536" [[projects]] - digest = "1:0e7775ebbcf00d8dd28ac663614af924411c868dca3d5aa762af0fae3808d852" + digest = "1:a8c2725121694dfbf6d552fb86fe6b46e3e7135ea05db580c28695b916162aad" name = "github.com/peterbourgon/diskv" packages = ["."] pruneopts = "UT" - revision = "5f041e8faa004a95c88a202771f4cc3e991971e6" - version = "v2.0.1" + revision = "0be1b92a6df0e4f5cb0a5d15fb7f643d0ad93ce6" + version = "v3.0.0" [[projects]] - digest = "1:e39a5ee8fcbec487f8fc68863ef95f2b025e0739b0e4aa55558a2b4cf8f0ecf0" + digest = "1:259f9b7645983a7a823318d78aa96dec68af8891f706493ac1ec04d819cb977c" name = "github.com/pierrec/lz4" packages = [ ".", "internal/xxh32", ] pruneopts = "UT" - revision = "635575b42742856941dbc767b44905bb9ba083f6" - version = "v2.0.7" + revision = "d705d4371bfccdf47f10e45584e896026c83616f" + version = "v2.2.3" [[projects]] digest = "1:cf31692c14422fa27c83a05292eb5cbe0fb2775972e8f1f8446a71549bd8980b" @@ -652,15 +665,15 @@ version = "v1.2.0" [[projects]] - digest = "1:d707dbc1330c0ed177d4642d6ae102d5e2c847ebd0eb84562d0dc4f024531cfc" + digest = "1:bb495ec276ab82d3dd08504bbc0594a65de8c3b22c6f2aaa92d05b73fbf3a82e" name = "github.com/spf13/afero" packages = [ ".", "mem", ] pruneopts = "UT" - revision = "a5d6946387efe7d64d09dcba68cdd523dc1273a3" - version = "v1.2.0" + revision = "588a75ec4f32903aa5e39a2619ba6a4631e28424" + version = "v1.2.2" [[projects]] digest = "1:08d65904057412fc0270fc4812a1c90c594186819243160dc779a402d4b6d0bc" @@ -671,12 +684,12 @@ version = "v1.3.0" [[projects]] - digest = "1:68ea4e23713989dc20b1bded5d9da2c5f9be14ff9885beef481848edd18c26cb" + digest = "1:1b753ec16506f5864d26a28b43703c58831255059644351bbcb019b843950900" name = "github.com/spf13/jwalterweatherman" packages = ["."] pruneopts = "UT" - revision = "4a4406e478ca629068e7768fc33f3f044173c0a6" - version = "v1.0.0" + revision = "94f6ae3ed3bceceafa716478c5fbf8d29ca601a1" + version = "v1.1.0" [[projects]] digest = "1:c1b1102241e7f645bc8e0c22ae352e8f0dc6484b6cb4d132fa9f24174e0119e2" @@ -687,20 +700,20 @@ version = "v1.0.3" [[projects]] - digest = "1:de37e343c64582d7026bf8ab6ac5b22a72eac54f3a57020db31524affed9f423" + digest = "1:11118bd196646c6515fea3d6c43f66162833c6ae4939bfb229b9956d91c6cf17" name = "github.com/spf13/viper" packages = ["."] pruneopts = "UT" - revision = "6d33b5a963d922d182c91e8a1c88d81fd150cfd4" - version = "v1.3.1" + revision = "b5bf975e5823809fb22c7644d008757f78a4259e" + version = "v1.4.0" [[projects]] branch = "master" - digest = "1:525ac3364813b4688df380594e562133e07830dfce0722effda64b37634c13d0" + digest = "1:d6bb6f3240a488ffe5bb6952b513569d009927dcb20ff94885f87b76cef2b698" name = "github.com/streadway/amqp" packages = ["."] pruneopts = "UT" - revision = "a314942b2fd9dde7a3f70ba3f1062848ce6eb392" + revision = "75d898a42a940fbc854dfd1a4199eabdc00cf024" [[projects]] digest = "1:ac83cf90d08b63ad5f7e020ef480d319ae890c208f8524622a2f3136e2686b02" @@ -722,24 +735,27 @@ version = "v1.3.0" [[projects]] - digest = "1:03aa6e485e528acb119fb32901cf99582c380225fc7d5a02758e08b180cb56c3" + digest = "1:d0072748c62defde1ad99dde77f6ffce492a0e5aea9204077e497c7edfb86653" name = "github.com/ugorji/go" packages = ["codec"] pruneopts = "UT" - revision = "b4c50a2b199d93b13dc15e78929cfb23bfdf21ab" - version = "v1.1.1" + revision = "2adff0894ba3bc2eeb9f9aea45fefd49802e1a13" + version = "v1.1.4" [[projects]] - digest = "1:2ae8314c44cd413cfdb5b1df082b350116dd8d2fff973e62c01b285b7affd89e" + digest = "1:4c93890bbbb5016505e856cb06b5c5a2ff5b7217584d33f2a9071ebef4b5d473" name = "go.opencensus.io" packages = [ ".", - "exemplar", "internal", "internal/tagencoding", + "metric/metricdata", + "metric/metricproducer", + "plugin/ocgrpc", "plugin/ochttp", "plugin/ochttp/propagation/b3", "plugin/ochttp/propagation/tracecontext", + "resource", "stats", "stats/internal", "stats/view", @@ -750,16 +766,16 @@ "trace/tracestate", ] pruneopts = "UT" - revision = "b7bf3cdb64150a8c8c53b769fdeb2ba581bd4d4b" - version = "v0.18.0" + revision = "43463a80402d8447b7fce0d2c58edf1687ff0b58" + version = "v0.19.3" [[projects]] - digest = "1:3c1a69cdae3501bf75e76d0d86dc6f2b0a7421bc205c0cb7b96b19eed464a34d" + digest = "1:a5158647b553c61877aa9ae74f4015000294e47981e6b8b07525edcbb0747c81" name = "go.uber.org/atomic" packages = ["."] pruneopts = "UT" - revision = "1ea20fb1cbb1cc08cbd0d913a96dead89aa18289" - version = "v1.3.2" + revision = "df976f2515e274675050de7b3f42545de80594fd" + version = "v1.4.0" [[projects]] digest = "1:60bf2a5e347af463c42ed31a493d817f8a72f102543060ed992754e689805d1a" @@ -770,7 +786,7 @@ version = "v1.1.0" [[projects]] - digest = "1:adccce69c151272d5053505aee552c6a1ac4e7bf6d18f0206ed7453187f6284d" + digest = "1:2a1fe9905518611e9ce56cd7aaefb35fca78d8a721ef5eb6540e5fdd436f45bb" name = "go.uber.org/zap" packages = [ ".", @@ -782,12 +798,12 @@ "zaptest/observer", ] pruneopts = "UT" - revision = "ff33455a0e382e8a81d14dd7c922020b6b5e7982" - version = "v1.9.1" + revision = "27376062155ad36be76b0f12cf1572a221d3a48c" + version = "v1.10.0" [[projects]] branch = "master" - digest = "1:189a9d376615810591d8682d1ef59f24c7130478257de2d9be0160d7b365be7c" + digest = "1:dc803ed7501a41bdf346841d923546876f666b250a83db19e1b6bd2aa9901938" name = "golang.org/x/crypto" packages = [ "md4", @@ -796,23 +812,24 @@ "ssh/terminal", ] pruneopts = "UT" - revision = "ff983b9c42bc9fbf91556e191cc8efb585c16908" + revision = "cc06ce4a13d484c0101a9e92913248488a75786d" [[projects]] branch = "master" - digest = "1:acadbbf02e5c744709c60ac929e63258a69a3404ab36ed0c1a245cc28f056220" + digest = "1:7f184383ff6745f500aa38f11a7bcb1a49e7a388e6a2ea853b6c529c98090e1a" name = "golang.org/x/image" packages = [ "bmp", + "ccitt", "tiff", "tiff/lzw", ] pruneopts = "UT" - revision = "cd38e8056d9b27bb2f265effa37fb0ea6b8a7f0f" + revision = "7e034cad644213bc79b336b52fce73624259aeca" [[projects]] branch = "master" - digest = "1:8ecb828bb550a8c6b7d75b8261a42c369461311616ebe5451966d067f5f993bf" + digest = "1:92bb0e8dc506ab66c513d409a6f7fe182be85127cf4093742d4e993b43e0ef9c" name = "golang.org/x/net" packages = [ "context", @@ -821,53 +838,58 @@ "http2", "http2/hpack", "idna", + "internal/socks", "internal/timeseries", + "proxy", "trace", + "websocket", ] pruneopts = "UT" - revision = "be1c187aa6c66b9daa1d9461c228d17e9dd2cab7" + revision = "3b0461eec859c4b73bb64fdc8285971fd33e3938" [[projects]] branch = "master" - digest = "1:5276e08fe6a1dfdb65b4f46a2e5d5c9e00be6e499105e441049c3c04a0c83b36" + digest = "1:8d1c112fb1679fa097e9a9255a786ee47383fa2549a3da71bcb1334a693ebcfe" name = "golang.org/x/oauth2" packages = [ ".", "internal", ] pruneopts = "UT" - revision = "d668ce993890a79bda886613ee587a69dd5da7a6" + revision = "0f29369cfe4552d0e4bcddc57cc75f4d7e672a33" [[projects]] branch = "master" - digest = "1:04a5b0e4138f98eef79ce12a955a420ee358e9f787044cc3a553ac3c3ade997e" + digest = "1:a2fc247e64b5dafd3251f12d396ec85f163d5bb38763c4997856addddf6e78d8" name = "golang.org/x/sync" packages = [ "errgroup", "semaphore", ] pruneopts = "UT" - revision = "37e7f081c4d4c64e13b10787722085407fe5d15f" + revision = "112230192c580c3556b8cee6403af37a4fc5f28c" [[projects]] branch = "master" - digest = "1:5ee4df7ab18e945607ac822de8d10b180baea263b5e8676a1041727543b9c1e4" + digest = "1:fe40fbf915905f8a2397b321b3f10190edbdf5d293f087d01d7eb3a6d1a4adca" name = "golang.org/x/sys" packages = [ "unix", "windows", ] pruneopts = "UT" - revision = "48ac38b7c8cbedd50b1613c0fccacfc7d88dfcdf" + revision = "c5567b49c5d04a5f83870795b8c0e2df43a8ce32" [[projects]] - digest = "1:a2ab62866c75542dd18d2b069fec854577a20211d7c0ea6ae746072a1dccdd18" + digest = "1:8d8faad6b12a3a4c819a3f9618cb6ee1fa1cfc33253abeeea8b55336721e3405" name = "golang.org/x/text" packages = [ "collate", "collate/build", "internal/colltab", "internal/gen", + "internal/language", + "internal/language/compact", "internal/tag", "internal/triegen", "internal/ucd", @@ -880,8 +902,8 @@ "unicode/rangetable", ] pruneopts = "UT" - revision = "f21a4dfb5e38f5895301dc265a8def02365cc3d0" - version = "v0.3.0" + revision = "342b2e1fbaa52c93f31447ad2c6abc048c63e475" + version = "v0.3.2" [[projects]] branch = "master" @@ -889,18 +911,18 @@ name = "golang.org/x/time" packages = ["rate"] pruneopts = "UT" - revision = "85acf8d2951cb2a3bde7632f9ff273ef0379bcbd" + revision = "9d24e82272b4f38b78bc8cff74fa936d31ccd8ef" [[projects]] digest = "1:5f003878aabe31d7f6b842d4de32b41c46c214bb629bb485387dbcce1edf5643" name = "google.golang.org/api" packages = ["support/bundler"] pruneopts = "UT" - revision = "19e022d8cf43ce81f046bae8cc18c5397cc7732f" - version = "v0.1.0" + revision = "02490b97dff7cfde1995bd77de808fd27053bc87" + version = "v0.7.0" [[projects]] - digest = "1:9e29a0ec029d012437d88da3ccccf18adcdce069cab08d462056c2c6bb006505" + digest = "1:7e8b9c5ae49011b12ae8473834ac1a7bb8ac029ba201270c723e4c280c9e4855" name = "google.golang.org/appengine" packages = [ "cloudsql", @@ -913,19 +935,23 @@ "urlfetch", ] pruneopts = "UT" - revision = "e9657d882bb81064595ca3b56cbe2546bbabf7b1" - version = "v1.4.0" + revision = "b2f4a3cf3c67576a2ee09e1fe62656a5086ce880" + version = "v1.6.1" [[projects]] branch = "master" - digest = "1:077c1c599507b3b3e9156d17d36e1e61928ee9b53a5b420f10f28ebd4a0b275c" + digest = "1:3565a93b7692277a5dea355bc47bd6315754f3246ed07a224be6aec28972a805" name = "google.golang.org/genproto" - packages = ["googleapis/rpc/status"] + packages = [ + "googleapis/api/httpbody", + "googleapis/rpc/status", + "protobuf/field_mask", + ] pruneopts = "UT" - revision = "ae2f86662275e140f395167f1dab7081a5bd5fa8" + revision = "6af8c5fc6601ab6b41cd32742a65ce2f5bd9db57" [[projects]] - digest = "1:8c8ed249fa6a8db070bf2082f02052c697695fa5e1558b4e28dd0fb5f15f70a2" + digest = "1:456a209c8f2449983f13bad0eb015b6169dbbfe90cf38be241259ae1f715df47" name = "google.golang.org/grpc" packages = [ ".", @@ -942,6 +968,7 @@ "grpclog", "internal", "internal/backoff", + "internal/balancerload", "internal/binarylog", "internal/channelz", "internal/envconfig", @@ -963,8 +990,8 @@ "tap", ] pruneopts = "UT" - revision = "df014850f6dee74ba2fc94874043a9f3f75fbfd8" - version = "v1.17.0" + revision = "501c41df7f472c740d0674ff27122f3f48c80ce7" + version = "v1.21.1" [[projects]] digest = "1:cbc72c4c4886a918d6ab4b95e347ffe259846260f99ebdd8a198c2331cf2b2e9" @@ -1033,7 +1060,7 @@ [[projects]] branch = "release-1.13" - digest = "1:1ff3647c207e3f7a6b96f2669f4dbab7b7ce8dc4c0a5371dff0b634143ac28df" + digest = "1:daf8a959a8731c620d4c6ba4cd41b9ed6477bfc81dc7ab4fe1e47c8209811ee8" name = "k8s.io/apimachinery" packages = [ "pkg/api/errors", @@ -1076,7 +1103,7 @@ "third_party/forked/golang/reflect", ] pruneopts = "UT" - revision = "2b1284ed4c93a43499e781493253e2ac5959c4fd" + revision = "86fb29eff6288413d76bd8506874fddd9fccdff0" [[projects]] digest = "1:509f442b58ab9907cb05c7410f48f9ee6795402caef5dd53d19ad493543593d2" @@ -1178,31 +1205,31 @@ version = "v10.0.0" [[projects]] - digest = "1:e2999bf1bb6eddc2a6aa03fe5e6629120a53088926520ca3b4765f77d7ff7eab" + digest = "1:c283ca5951eb7d723d3300762f96ff94c2ea11eaceb788279e2b7327f92e4f2a" name = "k8s.io/klog" packages = ["."] pruneopts = "UT" - revision = "a5bc97fbc634d635061f3146511332c7e313a55a" - version = "v0.1.0" + revision = "d98d8acdac006fb39831f1b25640813fef9c314f" + version = "v0.3.3" [[projects]] branch = "master" - digest = "1:03a96603922fc1f6895ae083e1e16d943b55ef0656b56965351bd87e7d90485f" + digest = "1:22abb5d4204ab1a0dcc9cda64906a31c43965ff5159e8b9f766c9d2a162dbed5" name = "k8s.io/kube-openapi" packages = ["pkg/util/proto"] pruneopts = "UT" - revision = "0317810137be915b9cf888946c6e115c1bfac693" + revision = "db7b694dc208eead64d38030265f702db593fcf2" [[projects]] - digest = "1:936255313723e7ba7e67aa01e8e0517e90195bd401cdee0a63c4c96d57d0425d" + digest = "1:e58fa5292aca459bdd6feaf01ccc591b4a6f21fbc1fa8d9975209135cc6c4816" name = "pack.ag/amqp" packages = [ ".", "internal/testconn", ] pruneopts = "UT" - revision = "a77984cb83aafae2bc3fcdf6f0ef75c93b87eea5" - version = "v0.10.2" + revision = "279d72ee259701e0e0e58d98a52a82ba172a2f5f" + version = "v0.11.2" [[projects]] digest = "1:7719608fe0b52a4ece56c2dde37bedd95b938677d1ab0f84b8a7852e4c59f849" @@ -1212,6 +1239,22 @@ revision = "fd68e9863619f6ec2fdd8625fe1f02e7c877e480" version = "v1.1.0" +[[projects]] + digest = "1:69760bb625770798aa43843e5a493097a9f864646697f5ee29015cc0565524a6" + name = "xorm.io/builder" + packages = ["."] + pruneopts = "UT" + revision = "5175e98d9e97da33b5b8234760b151d867cb2620" + version = "v0.3.5" + +[[projects]] + digest = "1:3a0128b50d38343b108e97b5b7d86d384df2cbd06ec7db69cb37e62d1e349009" + name = "xorm.io/core" + packages = ["."] + pruneopts = "UT" + revision = "a31f53637037b3461649b8a9a03e13b26d31f12d" + version = "v0.6.3" + [solve-meta] analyzer-name = "dep" analyzer-version = 1 diff --git a/docs/index.md b/docs/index.md index 245f81157..209446210 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,4 +17,5 @@ New to Presidio? Read this material to quickly get up and running. - [Calling the different services](tutorial_service.md) - [Adding custom fields](custom_fields.md) - [Presidio build and release](build_release.md) +- [Interpretability traces](interpretability_logs.md) - [Presidio logging and monitoring design concepts](monitoring_logging.md) \ No newline at end of file diff --git a/docs/interpretability_logs.md b/docs/interpretability_logs.md new file mode 100644 index 000000000..20b63d45b --- /dev/null +++ b/docs/interpretability_logs.md @@ -0,0 +1,57 @@ +# Interpretability Traces + +## Background +Presidio offers interpretability traces, which allows you to investigate a specific api request, by exposing a `correlation-id` as part of the api response headers. + +The interpretability traces explain why a specific PII was detected. For example: which recognizer detected the entity, which regex / ML model were used, which context words improved the score, etc. + +## How it works +The current implementation of the `App Tracer` class writes the traces into the `stdout`. This can be easily customized to have your traces written to different destination of your choice. + +Each trace contains a `correlation-id` which correlates to a specific api request. The api returns a `x-correlation-id` header which you can use to the `correlation-id` and query the `stdout` logs. + +By having the traces written into the `stdout` it's very easy to configure a monitoring solution to ease the process of reading processing the tracing logs in a distributed system. Read our [monitoring guide](monitoring_logging.md) for more information. + +## Examples +For the a request with the following text: +``` +My name is Bart Simpson, my Credit card is: 4095-2609-9393-4932, my phone is 425 8829090 +``` + +The following traces will be written: +``` +[2019-07-14 14:22:32,409][InterpretabilityMock][INFO][00000000-0000-0000-0000-000000000000][nlp artifacts:{'entities': (Bart Simpson, 4095, 425), 'tokens': ['My', 'name', 'is', 'Bart', 'Simpson', ',', 'my', 'Credit', 'card', 'is', ':', '4095', '-', '2609', '-', '9393', '-', '4932', ',', ' ', 'my', 'phone', 'is', '425', '8829090'], 'lemmas': ['My', 'name', 'be', 'Bart', 'Simpson', ',', 'my', 'Credit', 'card', 'be', ':', '4095', '-', '2609', '-', '9393', '-', '4932', ',', ' ', 'my', 'phone', 'be', '425', '8829090'], 'tokens_indices': [0, 3, 8, 11, 16, 23, 25, 28, 35, 40, 42, 44, 48, 49, 53, 54, 58, 59, 63, 65, 66, 69, 75, 78, 82], 'keywords': ['bart', 'simpson', 'credit', 'card', '4095', '2609', '9393', '4932', ' ', 'phone', '425', '8829090']}] + +[2019-07-14 14:22:32,417][InterpretabilityMock][INFO][00000000-0000-0000-0000-000000000000][["{'entity_type': 'CREDIT_CARD', 'start': 44, 'end': 63, 'score': 1.0, 'analysis_explanation': {'recognizer': 'CreditCardRecognizer', 'pattern_name': 'All Credit Cards (weak)', 'pattern': '\\\\b((4\\\\d{3})|(5[0-5]\\\\d{2})|(6\\\\d{3})|(1\\\\d{3})|(3\\\\d{3}))[- ]?(\\\\d{3,4})[- ]?(\\\\d{3,4})[- ]?(\\\\d{3,5})\\\\b', 'original_score': 0.3, 'score': 1.0, 'textual_explanation': None, 'score_context_improvement': 0.7, 'supportive_context_word': 'credit', 'validation_result': True}}", "{'entity_type': 'PERSON', 'start': 11, 'end': 23, 'score': 0.85, 'analysis_explanation': {'recognizer': 'SpacyRecognizer', 'pattern_name': None, 'pattern': None, 'original_score': 0.85, 'score': 0.85, 'textual_explanation': \"Identified as PERSON by Spacy's Named Entity Recognition\", 'score_context_improvement': 0, 'supportive_context_word': '', 'validation_result': None}}", "{'entity_type': 'PHONE_NUMBER', 'start': 78, 'end': 89, 'score': 0.85, 'analysis_explanation': {'recognizer': 'UsPhoneRecognizer', 'pattern_name': 'Phone (medium)', 'pattern': '\\\\b(\\\\d{3}[-\\\\.\\\\s]\\\\d{3}[-\\\\.\\\\s]??\\\\d{4})\\\\b', 'original_score': 0.5, 'score': 0.85, 'textual_explanation': None, 'score_context_improvement': 0.35, 'supportive_context_word': 'phone', 'validation_result': None}}"]] +``` + +The format of the traces is: `[Date Time][Interpretability][Log Level][Unique Correlation ID][Trace Message]` + +## Custom traces +Currently the traces are written automatically. It means that when you add a new recognizer, a generic interpretability traces will be written. + +However, it's possible to write custom data to the traces if you wish to. + +For exmple, the [spacy_recognizer.py](https://github.com/microsoft/presidio/blob/master/presidio-analyzer/analyzer/predefined_recognizers/spacy_recognizer.py) implemented a custom trace as follows: +```python +SPACY_DEFAULT_EXPLANATION = "Identified as {} by Spacy's Named Entity Recognition" + +def build_spacy_explanation(recognizer_name, original_score, entity): + explanation = AnalysisExplanation( + recognizer=recognizer_name, + original_score=original_score, + textual_explanation=SPACY_DEFAULT_EXPLANATION.format(entity)) + return explanation +``` + +The `textual_explanation` field in `AnalysisExplanation` class allows you to add your own custom text into the final trace which will be written. + +## Enabling/Disabling Traces +Interpretability traces are enabled by default. Disable App Tracing by setting the `enabled` constructor parameter to `False`. +PII entities are not stored in the Traces by default. Enable it by either set an evironment variable `ENABLE_TRACE_PII` to `True`, or you can set it directly in the command line, using the `enable-trace-pii` argument as follows: +```bash +pipenv run python __main__.py serve --grpc-port 3001 --enable-trace-pii True +``` + +## Notes +* Interpretability traces explain why PIIs were detected, but not why they were not detected. diff --git a/pkg/presidio/presidio.go b/pkg/presidio/presidio.go index 5c26d8f60..0f4b1cd31 100644 --- a/pkg/presidio/presidio.go +++ b/pkg/presidio/presidio.go @@ -19,7 +19,7 @@ type ServicesAPI interface { SetupDatasinkService() SetupRecognizerStoreService() SetupCache() cache.Cache - AnalyzeItem(ctx context.Context, text string, template *types.AnalyzeTemplate) ([]*types.AnalyzeResult, error) + AnalyzeItem(ctx context.Context, text string, template *types.AnalyzeTemplate) (*types.AnalyzeResponse, error) AnonymizeItem(ctx context.Context, analyzeResults []*types.AnalyzeResult, text string, anonymizeTemplate *types.AnonymizeTemplate) (*types.AnonymizeResponse, error) AnonymizeImageItem(ctx context.Context, image *types.Image, analyzeResults []*types.AnalyzeResult, diff --git a/pkg/presidio/services/services.go b/pkg/presidio/services/services.go index e09407c41..a79262d43 100644 --- a/pkg/presidio/services/services.go +++ b/pkg/presidio/services/services.go @@ -249,18 +249,18 @@ func (services *Services) GetRecognizersHash( } //AnalyzeItem - search for PII -func (services *Services) AnalyzeItem(ctx context.Context, text string, template *types.AnalyzeTemplate) ([]*types.AnalyzeResult, error) { +func (services *Services) AnalyzeItem(ctx context.Context, text string, template *types.AnalyzeTemplate) (*types.AnalyzeResponse, error) { analyzeRequest := &types.AnalyzeRequest{ AnalyzeTemplate: template, Text: text, } - results, err := services.AnalyzerService.Apply(ctx, analyzeRequest) + response, err := services.AnalyzerService.Apply(ctx, analyzeRequest) if err != nil { return nil, err } - return results.AnalyzeResults, nil + return response, nil } //AnonymizeItem - anonymize text diff --git a/pkg/server/server.go b/pkg/server/server.go index f320edf42..23d435cf3 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -10,7 +10,7 @@ import ( "time" "github.com/gin-contrib/cors" - "github.com/gin-contrib/zap" + ginzap "github.com/gin-contrib/zap" "github.com/gin-gonic/gin" "golang.org/x/sync/errgroup" @@ -93,6 +93,17 @@ func WriteResponse( c.JSON(statusCode, responseBody) } +//WriteResponseWithRequestID writes a response and adds a request id header +func WriteResponseWithRequestID( + c *gin.Context, + statusCode int, + requestID string, + responseBody interface{}, +) { + c.Header("X-Correlation-Id", requestID) + WriteResponse(c, statusCode, responseBody) +} + //AbortWithError aborts the request and returns the error in the response body func AbortWithError(c *gin.Context, statusCode int, diff --git a/presidio-analyzer/analyzer/__init__.py b/presidio-analyzer/analyzer/__init__.py index 46daf1f7a..eb2c386d3 100644 --- a/presidio-analyzer/analyzer/__init__.py +++ b/presidio-analyzer/analyzer/__init__.py @@ -6,6 +6,7 @@ sys.path.append(os.path.dirname(os.path.dirname( os.path.abspath(__file__))) + "/analyzer") +from analyzer.analysis_explanation import AnalysisExplanation # noqa from analyzer.pattern import Pattern # noqa: F401 from analyzer.entity_recognizer import EntityRecognizer # noqa: F401 from analyzer.local_recognizer import LocalRecognizer # noqa: F401 diff --git a/presidio-analyzer/analyzer/__main__.py b/presidio-analyzer/analyzer/__main__.py index ea65c756f..510f00217 100644 --- a/presidio-analyzer/analyzer/__main__.py +++ b/presidio-analyzer/analyzer/__main__.py @@ -60,13 +60,19 @@ def __init__(self, cli_ctx=None): welcome_message=WELCOME_MESSAGE) -def serve_command_handler(env_grpc_port=False, grpc_port=3000): +def serve_command_handler(enable_trace_pii, + env_grpc_port=False, + grpc_port=3000): server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + registry = RecognizerRegistry() nlp_engine = SpacyNlpEngine() analyze_pb2_grpc.add_AnalyzeServiceServicer_to_server( - AnalyzerEngine(registry, nlp_engine), server) + AnalyzerEngine(registry=registry, + nlp_engine=nlp_engine, + enable_trace_pii=enable_trace_pii), + server) if env_grpc_port: port = os.environ.get('GRPC_PORT') @@ -111,8 +117,15 @@ def load_command_table(self, args): return super(CommandsLoader, self).load_command_table(args) def load_arguments(self, command): + enable_trace_pii = os.environ.get('ENABLE_TRACE_PII') + if enable_trace_pii is None: + enable_trace_pii = False + with ArgumentsContext(self, 'serve') as ac: ac.argument('env_grpc_port', default=False, required=False) + ac.argument('enable_trace_pii', + default=enable_trace_pii, + required=False) ac.argument('grpc_port', default=3001, type=int, required=False) with ArgumentsContext(self, 'analyze') as ac: ac.argument('env_grpc_port', default=False, required=False) diff --git a/presidio-analyzer/analyzer/analysis_explanation.py b/presidio-analyzer/analyzer/analysis_explanation.py new file mode 100644 index 000000000..2c9142469 --- /dev/null +++ b/presidio-analyzer/analyzer/analysis_explanation.py @@ -0,0 +1,51 @@ +class AnalysisExplanation: + + # pylint: disable=too-many-instance-attributes + def __init__(self, recognizer, original_score, pattern_name=None, + pattern=None, validation_result=None, + textual_explanation=None): + """ + AnalysisExplanation is a class that holds tracing information + to explain why PII entities where indentified as such + :param recognizer: name of recognizer that made the decision + :param original_score: recognizer's confidence in result + :param pattern_name: name of pattern + (if decision was made by a PatternRecognizer) + :param pattern: regex pattern that was applied (if PatternRecognizer) + :param validation_result: result of a validation (e.g. checksum) + :param textual_explanation: Free text for describing + a decision of a logic or model + """ + + self.recognizer = recognizer + self.pattern_name = pattern_name + self.pattern = pattern + self.original_score = original_score + self.score = original_score + self.textual_explanation = textual_explanation + self.score_context_improvement = 0 + self.supportive_context_word = '' + self.validation_result = validation_result + + def __repr__(self): + return str(self.__dict__) + + def set_improved_score(self, score): + """ Updated the score of the entity and compute the + improvment fromt the original scoree + """ + self.score = score + self.score_context_improvement = self.score - self.original_score + + def set_supportive_context_word(self, word): + """ Sets the context word which helped increase the score + """ + self.supportive_context_word = word + + def append_textual_explanation_line(self, text): + """Appends a new line to textual_explanation field""" + if self.textual_explanation is None: + self.textual_explanation = text + else: + self.textual_explanation = "{}\n{}".format( + self.textual_explanation, text) diff --git a/presidio-analyzer/analyzer/analyze_pb2.py b/presidio-analyzer/analyzer/analyze_pb2.py index 7f5a1aeff..d5d10bec9 100644 --- a/presidio-analyzer/analyzer/analyze_pb2.py +++ b/presidio-analyzer/analyzer/analyze_pb2.py @@ -21,7 +21,7 @@ name='analyze.proto', package='types', syntax='proto3', - serialized_pb=_b('\n\ranalyze.proto\x12\x05types\x1a\x0c\x63ommon.proto\x1a\x0etemplate.proto\"m\n\x11\x41nalyzeApiRequest\x12\x0c\n\x04text\x18\x01 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x02 \x01(\t\x12/\n\x0f\x61nalyzeTemplate\x18\x03 \x01(\x0b\x32\x16.types.AnalyzeTemplate\"O\n\x0e\x41nalyzeRequest\x12\x0c\n\x04text\x18\x01 \x01(\t\x12/\n\x0f\x61nalyzeTemplate\x18\x02 \x01(\x0b\x32\x16.types.AnalyzeTemplate\"?\n\x0f\x41nalyzeResponse\x12,\n\x0e\x61nalyzeResults\x18\x01 \x03(\x0b\x32\x14.types.AnalyzeResult2J\n\x0e\x41nalyzeService\x12\x38\n\x05\x41pply\x12\x15.types.AnalyzeRequest\x1a\x16.types.AnalyzeResponse\"\x00\x62\x06proto3') + serialized_pb=_b('\n\ranalyze.proto\x12\x05types\x1a\x0c\x63ommon.proto\x1a\x0etemplate.proto\"m\n\x11\x41nalyzeApiRequest\x12\x0c\n\x04text\x18\x01 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x02 \x01(\t\x12/\n\x0f\x61nalyzeTemplate\x18\x03 \x01(\x0b\x32\x16.types.AnalyzeTemplate\"O\n\x0e\x41nalyzeRequest\x12\x0c\n\x04text\x18\x01 \x01(\t\x12/\n\x0f\x61nalyzeTemplate\x18\x02 \x01(\x0b\x32\x16.types.AnalyzeTemplate\"R\n\x0f\x41nalyzeResponse\x12,\n\x0e\x61nalyzeResults\x18\x01 \x03(\x0b\x32\x14.types.AnalyzeResult\x12\x11\n\trequestId\x18\x02 \x01(\t2J\n\x0e\x41nalyzeService\x12\x38\n\x05\x41pply\x12\x15.types.AnalyzeRequest\x1a\x16.types.AnalyzeResponse\"\x00\x62\x06proto3') , dependencies=[common__pb2.DESCRIPTOR,template__pb2.DESCRIPTOR,]) @@ -125,6 +125,13 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='requestId', full_name='types.AnalyzeResponse.requestId', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -138,7 +145,7 @@ oneofs=[ ], serialized_start=246, - serialized_end=309, + serialized_end=328, ) _ANALYZEAPIREQUEST.fields_by_name['analyzeTemplate'].message_type = template__pb2._ANALYZETEMPLATE @@ -178,8 +185,8 @@ file=DESCRIPTOR, index=0, options=None, - serialized_start=311, - serialized_end=385, + serialized_start=330, + serialized_end=404, methods=[ _descriptor.MethodDescriptor( name='Apply', diff --git a/presidio-analyzer/analyzer/analyzer_engine.py b/presidio-analyzer/analyzer/analyzer_engine.py index 9a474708e..08b4a87e7 100644 --- a/presidio-analyzer/analyzer/analyzer_engine.py +++ b/presidio-analyzer/analyzer/analyzer_engine.py @@ -1,26 +1,30 @@ -import logging -import os +import json +import uuid import analyze_pb2 import analyze_pb2_grpc import common_pb2 -loglevel = os.environ.get("LOG_LEVEL", "INFO") -logging.basicConfig( - format='%(asctime)s:%(levelname)s:%(message)s', level=loglevel) +from analyzer.logger import Logger +from analyzer.app_tracer import AppTracer + DEFAULT_LANGUAGE = "en" +logger = Logger() class AnalyzerEngine(analyze_pb2_grpc.AnalyzeServiceServicer): - def __init__(self, registry=None, nlp_engine=None): + def __init__(self, registry=None, nlp_engine=None, + app_tracer=None, enable_trace_pii=False): if not nlp_engine: from analyzer.nlp_engine import SpacyNlpEngine nlp_engine = SpacyNlpEngine() if not registry: from analyzer import RecognizerRegistry registry = RecognizerRegistry() + if not app_tracer: + app_tracer = AppTracer() # load nlp module self.nlp_engine = nlp_engine @@ -28,23 +32,32 @@ def __init__(self, registry=None, nlp_engine=None): self.registry = registry # load all recognizers registry.load_predefined_recognizers() + self.app_tracer = app_tracer + self.enable_trace_pii = enable_trace_pii # pylint: disable=unused-argument def Apply(self, request, context): - logging.info("Starting Apply") + logger.info("Starting Analyzer's Apply") + entities = AnalyzerEngine.__convert_fields_to_entities( request.analyzeTemplate.fields) language = AnalyzerEngine.get_language_from_request(request) - results = self.analyze(request.text, entities, language, + + # correlation is used to group all traces related to on request + correlation_id = str(uuid.uuid4()) + results = self.analyze(correlation_id, request.text, + entities, language, request.analyzeTemplate.allFields) # Create Analyze Response Object response = analyze_pb2.AnalyzeResponse() + response.requestId = correlation_id # pylint: disable=no-member response.analyzeResults.extend( AnalyzerEngine.__convert_results_to_proto(results)) - logging.info("Found %d results", len(results)) + + logger.info("Found %d results", len(results)) return response @staticmethod @@ -81,10 +94,11 @@ def get_language_from_request(cls, request): language = DEFAULT_LANGUAGE return language - def analyze(self, text, entities, language, all_fields): + def analyze(self, correlation_id, text, entities, language, all_fields): """ analyzes the requested text, searching for the given entities in the given language + :param correlation_id: cross call ID for this request :param text: the text to analyze :param entities: the text to search :param language: the language of the text @@ -93,9 +107,10 @@ def analyze(self, text, entities, language, all_fields): :return: an array of the found entities in the text """ - recognizers = self.registry.get_recognizers(language=language, - entities=entities, - all_fields=all_fields) + recognizers = self.registry.get_recognizers( + language=language, + entities=entities, + all_fields=all_fields) if all_fields: if entities: @@ -111,6 +126,11 @@ def analyze(self, text, entities, language, all_fields): # run the nlp pipeline over the given text, store the results in # a NlpArtifacts instance nlp_artifacts = self.nlp_engine.process_text(text, language) + + if self.enable_trace_pii: + self.app_tracer.trace(correlation_id, "nlp artifacts:" + + nlp_artifacts.to_json()) + results = [] for recognizer in recognizers: # Lazy loading of the relevant recognizers @@ -123,7 +143,11 @@ def analyze(self, text, entities, language, all_fields): if current_results: results.extend(current_results) - return AnalyzerEngine.__remove_duplicates(results) + results = AnalyzerEngine.__remove_duplicates(results) + self.app_tracer.trace(correlation_id, json.dumps( + [result.to_json() for result in results])) + + return results @staticmethod def __list_entities(recognizers): diff --git a/presidio-analyzer/analyzer/app_tracer.py b/presidio-analyzer/analyzer/app_tracer.py new file mode 100644 index 000000000..0154eaeaf --- /dev/null +++ b/presidio-analyzer/analyzer/app_tracer.py @@ -0,0 +1,25 @@ +from analyzer.logger import Logger + + +class AppTracer: + """This class provides the ability to log/trace the system's decisions, + such as which modules were used for detection, + which logic was utilized, what results were given and potentially why. + This can be useful for analyzing the detection accuracy of the system.""" + def __init__(self, enabled=True): + + self.logger = Logger('Interpretability') + self.logger.set_level("INFO") + self.enabled = enabled + + def trace(self, request_id, trace_data): + """ + Writes a value associated with a decision + for a specific request into the trace, + for further inspection if needed. + :param request_id: A unique ID, to correlate across calls. + :param trace_data: A string to write. + :return: + """ + if self.enabled: + self.logger.info("[%s][%s]", request_id, trace_data) diff --git a/presidio-analyzer/analyzer/entity_recognizer.py b/presidio-analyzer/analyzer/entity_recognizer.py index 1f4dae7ef..faab378fa 100644 --- a/presidio-analyzer/analyzer/entity_recognizer.py +++ b/presidio-analyzer/analyzer/entity_recognizer.py @@ -1,8 +1,8 @@ -import logging -import os from abc import abstractmethod import copy +from analyzer.logger import Logger + class EntityRecognizer: MIN_SCORE = 0 @@ -36,11 +36,9 @@ def __init__(self, supported_entities, name=None, supported_language="en", self.version = version self.is_loaded = False - loglevel = os.environ.get("LOG_LEVEL", "INFO") - self.logger = logging.getLogger(__name__) - self.logger.setLevel(loglevel) + self.logger = Logger() self.load() - logging.info("Loaded recognizer: %s", self.name) + self.logger.info("Loaded recognizer: %s", self.name) self.is_loaded = True @abstractmethod @@ -132,28 +130,34 @@ def enhance_using_context(self, text, raw_results, word=text[result.start:result.end], start=result.start) - context_similarity = self.__calculate_context_similarity( + supportive_context_word = self.__find_supportive_context_word( context, predefined_context_words) - if context_similarity >= \ - self.CONTEXT_SIMILARITY_THRESHOLD: + if supportive_context_word != "": result.score += \ - context_similarity * self.CONTEXT_SIMILARITY_FACTOR + self.CONTEXT_SIMILARITY_FACTOR result.score = max( result.score, self.MIN_SCORE_WITH_CONTEXT_SIMILARITY) result.score = min( result.score, EntityRecognizer.MAX_SCORE) + + # Update the explainability object with context information + # helped improving the score + result.analysis_explanation.set_supportive_context_word( + supportive_context_word) + result.analysis_explanation.set_improved_score(result.score) return results @staticmethod def __context_to_keywords(context): return context.split(' ') - def __calculate_context_similarity(self, + def __find_supportive_context_word(self, context_text, context_list): - """Context similarity is 1 if there's exact match between a keyword in + """A word is considered a supportive context word if + there's exact match between a keyword in context_text and any keyword in context_list :param context_text words before and after the matched enitity within @@ -162,16 +166,16 @@ def __calculate_context_similarity(self, manually specified by the recognizer's author """ + word = "" # If the context list is empty, no need to continue if context_list is None: - return 0 + return word # Take the context text and break it into individual keywords lemmatized_keywords = self.__context_to_keywords(context_text) if lemmatized_keywords is None: - return 0 + return word - similarity = 0.0 for predefined_context_word in context_list: # result == true only if any of the predefined context words # is found exactly or as a substring in any of the collected @@ -182,10 +186,10 @@ def __calculate_context_similarity(self, if result: self.logger.debug("Found context keyword '%s'", predefined_context_word) - similarity = 1 + word = predefined_context_word break - return similarity + return word @staticmethod def __add_n_words(index, diff --git a/presidio-analyzer/analyzer/logger.py b/presidio-analyzer/analyzer/logger.py new file mode 100644 index 000000000..12598ca61 --- /dev/null +++ b/presidio-analyzer/analyzer/logger.py @@ -0,0 +1,80 @@ +import logging +import os + + +class Logger: + """A wrapper class for logger""" + def __init__(self, logger_name=None): + if logger_name: + logger = logging.getLogger(logger_name) + else: + logger = logging.getLogger() + + if not logger.handlers: + loglevel = os.environ.get("LOG_LEVEL", "INFO") + ch = logging.StreamHandler() + formatter = logging.Formatter( + '[%(asctime)s][%(name)s][%(levelname)s]%(message)s') + ch.setFormatter(formatter) + logger.addHandler(ch) + logger.setLevel(loglevel) + + self.__logger = logger + + def set_level(self, level): + self.__logger.setLevel(level) + + def debug(self, msg, *args, **kwargs): + """ + Log 'msg % args' with severity 'DEBUG'. + + To pass exception information, use the keyword argument exc_info with + a true value, e.g. + + logger.debug("Houston, we have a %s", "thorny problem", exc_info=1) + """ + self.__logger.debug(msg, *args, **kwargs) + + def info(self, msg, *args, **kwargs): + """ + Log 'msg % args' with severity 'INFO'. + + To pass exception information, use the keyword argument exc_info with + a true value, e.g. + + logger.info("Houston, we have a %s", "interesting problem", exc_info=1) + """ + self.__logger.info(msg, *args, **kwargs) + + def warning(self, msg, *args, **kwargs): + """ + Log 'msg % args' with severity 'WARNING'. + + To pass exception information, use the keyword argument exc_info with + a true value, e.g. + + logger.warning("Houston, we have a %s", "bit of a problem", exc_info=1) + """ + self.__logger.warning(msg, *args, **kwargs) + + def error(self, msg, *args, **kwargs): + """ + Log 'msg % args' with severity 'ERROR'. + + To pass exception information, use the keyword argument exc_info with + a true value, e.g. + + logger.error("Houston, we have a %s", "major problem", exc_info=1) + """ + self.__logger.error(msg, *args, **kwargs) + + def critical(self, msg, *args, **kwargs): + """ + Log 'msg % args' with severity 'CRITICAL'. + + To pass exception information, use the keyword argument exc_info with + a true value, e.g. + + logger.critical("Houston, we have a %s", "major disaster", exc_info=1) + """ + self.__logger.critical(msg, *args, **kwargs) diff --git a/presidio-analyzer/analyzer/nlp_engine/nlp_artifacts.py b/presidio-analyzer/analyzer/nlp_engine/nlp_artifacts.py index ed18ff1ee..276bc2414 100644 --- a/presidio-analyzer/analyzer/nlp_engine/nlp_artifacts.py +++ b/presidio-analyzer/analyzer/nlp_engine/nlp_artifacts.py @@ -33,3 +33,6 @@ def set_keywords(nlp_engine, lemmas, language): keywords = \ [item for sublist in keywords for item in sublist] return keywords + + def to_json(self): + return str(self.__dict__) diff --git a/presidio-analyzer/analyzer/nlp_engine/spacy_nlp_engine.py b/presidio-analyzer/analyzer/nlp_engine/spacy_nlp_engine.py index f94782cc5..0c968fd7b 100644 --- a/presidio-analyzer/analyzer/nlp_engine/spacy_nlp_engine.py +++ b/presidio-analyzer/analyzer/nlp_engine/spacy_nlp_engine.py @@ -1,9 +1,8 @@ -import logging -import os - import spacy +from analyzer.logger import Logger from analyzer.nlp_engine import NlpArtifacts, NlpEngine +logger = Logger() class SpacyNlpEngine(NlpEngine): @@ -14,11 +13,7 @@ class SpacyNlpEngine(NlpEngine): """ def __init__(self): - loglevel = os.environ.get("LOG_LEVEL", "INFO") - self.logger = logging.getLogger(__name__) - self.logger.setLevel(loglevel) - - self.logger.info("Loading NLP model...") + logger.info("Loading NLP model...") self.nlp = {"en": spacy.load("en_core_web_lg", disable=['parser', 'tagger'])} diff --git a/presidio-analyzer/analyzer/pattern_recognizer.py b/presidio-analyzer/analyzer/pattern_recognizer.py index 9dabf2e80..0b0cb8637 100644 --- a/presidio-analyzer/analyzer/pattern_recognizer.py +++ b/presidio-analyzer/analyzer/pattern_recognizer.py @@ -3,7 +3,8 @@ from analyzer import LocalRecognizer, \ Pattern, \ RecognizerResult, \ - EntityRecognizer + EntityRecognizer, \ + AnalysisExplanation # Import 're2' regex engine if installed, if not- import 'regex' try: @@ -83,21 +84,31 @@ def __black_list_to_regex(black_list): regex = r"(?:^|(?<= ))(" + '|'.join(black_list) + r")(?:(?= )|$)" return Pattern(name="black_list", regex=regex, score=1.0) - # pylint: disable=unused-argument, no-self-use - def validate_result(self, pattern_text, pattern_result): + # pylint: disable=unused-argument, no-self-use, assignment-from-none + def validate_result(self, pattern_text): """ Validates the pattern logic, for example by running checksum on a detected pattern. :param pattern_text: the text to validated. Only the part in text that was detected by the regex engine - :param pattern_result: The output of a specific pattern - detector that needs to be validated - :return: the updated result of the pattern. - For example, if a validation logic increased or decreased the score - that was given by a regex pattern. + :return: A bool indicating whether the validation was successful. """ - return pattern_result + return None + + @staticmethod + def build_regex_explanation( + recognizer_name, + pattern_name, + pattern, + original_score, + validation_result): + explanation = AnalysisExplanation(recognizer=recognizer_name, + original_score=original_score, + pattern_name=pattern_name, + pattern=pattern, + validation_result=validation_result) + return explanation def __analyze_patterns(self, text): """ @@ -130,11 +141,29 @@ def __analyze_patterns(self, text): score = pattern.score - res = RecognizerResult(self.supported_entities[0], start, end, - score) - res = self.validate_result(current_match, res) - if res and res.score > EntityRecognizer.MIN_SCORE: - results.append(res) + validation_result = self.validate_result(current_match) + description = PatternRecognizer.build_regex_explanation( + self.name, + pattern.name, + pattern.regex, + score, + validation_result + ) + pattern_result = RecognizerResult( + self.supported_entities[0], + start, + end, + score, + description) + + if validation_result is not None: + if validation_result: + pattern_result.score = EntityRecognizer.MAX_SCORE + else: + pattern_result.score = EntityRecognizer.MIN_SCORE + + if pattern_result.score > EntityRecognizer.MIN_SCORE: + results.append(pattern_result) return results diff --git a/presidio-analyzer/analyzer/predefined_recognizers/credit_card_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/credit_card_recognizer.py index 1b5312e25..aa3654243 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/credit_card_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/credit_card_recognizer.py @@ -1,6 +1,5 @@ from analyzer import Pattern from analyzer import PatternRecognizer -from analyzer.entity_recognizer import EntityRecognizer # pylint: disable=line-too-long REGEX = r'\b((4\d{3})|(5[0-5]\d{2})|(6\d{3})|(1\d{3})|(3\d{3}))[- ]?(\d{3,4})[- ]?(\d{3,4})[- ]?(\d{3,5})\b' # noqa: E501 @@ -30,15 +29,11 @@ def __init__(self): super().__init__(supported_entity="CREDIT_CARD", patterns=patterns, context=CONTEXT) - def validate_result(self, pattern_text, pattern_result): + def validate_result(self, pattern_text): sanitized_value = CreditCardRecognizer.__sanitize_value(pattern_text) - res = CreditCardRecognizer.__luhn_checksum(sanitized_value) - if res == 0: - pattern_result.score = EntityRecognizer.MAX_SCORE - else: - pattern_result.score = EntityRecognizer.MIN_SCORE + checksum = CreditCardRecognizer.__luhn_checksum(sanitized_value) - return pattern_result + return checksum == 0 @staticmethod def __luhn_checksum(sanitized_value): diff --git a/presidio-analyzer/analyzer/predefined_recognizers/crypto_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/crypto_recognizer.py index a6e7b4cf1..69c57cf3f 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/crypto_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/crypto_recognizer.py @@ -1,7 +1,6 @@ from hashlib import sha256 from analyzer import Pattern from analyzer import PatternRecognizer -from analyzer.entity_recognizer import EntityRecognizer # Copied from: # http://rosettacode.org/wiki/Bitcoin/address_validation#Python @@ -19,12 +18,12 @@ def __init__(self): super().__init__(supported_entity="CRYPTO", patterns=patterns, context=CONTEXT) - def validate_result(self, pattern_text, pattern_result): + def validate_result(self, pattern_text): # try: bcbytes = CryptoRecognizer.__decode_base58(pattern_text, 25) - if bcbytes[-4:] == sha256(sha256(bcbytes[:-4]).digest()).digest()[:4]: - pattern_result.score = EntityRecognizer.MAX_SCORE - return pattern_result + result = bcbytes[-4:] == sha256(sha256(bcbytes[:-4]) + .digest()).digest()[:4] + return result @staticmethod def __decode_base58(bc, length): diff --git a/presidio-analyzer/analyzer/predefined_recognizers/domain_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/domain_recognizer.py index 94c553ece..1a578c330 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/domain_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/domain_recognizer.py @@ -2,7 +2,6 @@ from analyzer import Pattern from analyzer import PatternRecognizer -from analyzer.entity_recognizer import EntityRecognizer # pylint: disable=line-too-long REGEX = r'\b(((([a-zA-Z0-9])|([a-zA-Z0-9][a-zA-Z0-9\-]{0,86}[a-zA-Z0-9]))\.(([a-zA-Z0-9])|([a-zA-Z0-9][a-zA-Z0-9\-]{0,73}[a-zA-Z0-9]))\.(([a-zA-Z0-9]{2,12}\.[a-zA-Z0-9]{2,12})|([a-zA-Z0-9]{2,25})))|((([a-zA-Z0-9])|([a-zA-Z0-9][a-zA-Z0-9\-]{0,162}[a-zA-Z0-9]))\.(([a-zA-Z0-9]{2,12}\.[a-zA-Z0-9]{2,12})|([a-zA-Z0-9]{2,25}))))\b' # noqa: E501' # noqa: E501 @@ -19,10 +18,6 @@ def __init__(self): super().__init__(supported_entity="DOMAIN_NAME", patterns=patterns, context=CONTEXT) - def validate_result(self, pattern_text, pattern_result): + def validate_result(self, pattern_text): result = tldextract.extract(pattern_text) - if result.fqdn != '': - pattern_result.score = EntityRecognizer.MAX_SCORE - else: - pattern_result.score = EntityRecognizer.MIN_SCORE - return pattern_result + return result.fqdn != '' diff --git a/presidio-analyzer/analyzer/predefined_recognizers/email_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/email_recognizer.py index 444dacbf2..5925608a4 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/email_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/email_recognizer.py @@ -2,7 +2,6 @@ from analyzer import Pattern from analyzer import PatternRecognizer -from analyzer.entity_recognizer import EntityRecognizer # pylint: disable=line-too-long REGEX = r"\b((([!#$%&'*+\-/=?^_`{|}~\w])|([!#$%&'*+\-/=?^_`{|}~\w][!#$%&'*+\-/=?^_`{|}~\.\w]{0,}[!#$%&'*+\-/=?^_`{|}~\w]))[@]\w+([-.]\w+)*\.\w+([-.]\w+)*)\b" # noqa: E501 @@ -19,11 +18,6 @@ def __init__(self): super().__init__(supported_entity="EMAIL_ADDRESS", patterns=patterns, context=CONTEXT) - def validate_result(self, pattern_text, pattern_result): + def validate_result(self, pattern_text): result = tldextract.extract(pattern_text) - - if result.fqdn != '': - pattern_result.score = EntityRecognizer.MAX_SCORE - else: - pattern_result.score = EntityRecognizer.MIN_SCORE - return pattern_result + return result.fqdn != '' diff --git a/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py index 817e659b7..4a39c102d 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/iban_recognizer.py @@ -2,7 +2,6 @@ from analyzer.predefined_recognizers.iban_patterns import regex_per_country from analyzer import Pattern, PatternRecognizer -from analyzer.entity_recognizer import EntityRecognizer # Import 're2' regex engine if installed, if not- import 'regex' try: @@ -33,19 +32,18 @@ def __init__(self): patterns=patterns, context=CONTEXT) - def validate_result(self, pattern_text, pattern_result): + def validate_result(self, pattern_text): pattern_text = pattern_text.replace(' ', '') is_valid_checksum = (IbanRecognizer.__generate_iban_check_digits( pattern_text) == pattern_text[2:4]) - - score = EntityRecognizer.MIN_SCORE + # score = EntityRecognizer.MIN_SCORE + result = False if is_valid_checksum: if IbanRecognizer.__is_valid_format(pattern_text): - score = EntityRecognizer.MAX_SCORE + result = True elif IbanRecognizer.__is_valid_format(pattern_text.upper()): - score = IBAN_GENERIC_SCORE - pattern_result.score = score - return pattern_result + result = None + return result @staticmethod def __number_iban(iban): diff --git a/presidio-analyzer/analyzer/predefined_recognizers/spacy_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/spacy_recognizer.py index 0f9dac2a3..213221145 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/spacy_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/spacy_recognizer.py @@ -1,7 +1,9 @@ -from analyzer import RecognizerResult, LocalRecognizer +from analyzer import RecognizerResult, LocalRecognizer, AnalysisExplanation NER_STRENGTH = 0.85 SUPPORTED_ENTITIES = ["DATE_TIME", "NRP", "LOCATION", "PERSON"] +SPACY_DEFAULT_EXPLANATION = \ + "Identified as {} by Spacy's Named Entity Recognition" class SpacyRecognizer(LocalRecognizer): @@ -15,7 +17,16 @@ def load(self): # preprocessed nlp artifacts pass + @staticmethod + def build_spacy_explanation(recognizer_name, original_score, entity): + explanation = AnalysisExplanation( + recognizer=recognizer_name, + original_score=original_score, + textual_explanation=SPACY_DEFAULT_EXPLANATION.format(entity)) + return explanation + # pylint: disable=unused-argument + def analyze(self, text, entities, nlp_artifacts=None): results = [] if not nlp_artifacts: @@ -29,9 +40,14 @@ def analyze(self, text, entities, nlp_artifacts=None): if entity in self.supported_entities: for ent in ner_entities: if SpacyRecognizer.__check_label(entity, ent.label_): - results.append( - RecognizerResult(entity, ent.start_char, - ent.end_char, NER_STRENGTH)) + explanation = SpacyRecognizer.build_spacy_explanation( + self.__class__.__name__, + NER_STRENGTH, + ent.label_) + spacy_result = RecognizerResult( + entity, ent.start_char, + ent.end_char, NER_STRENGTH, explanation) + results.append(spacy_result) return results diff --git a/presidio-analyzer/analyzer/predefined_recognizers/uk_nhs_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/uk_nhs_recognizer.py index 2b3dc287d..0aa5fd8e9 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/uk_nhs_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/uk_nhs_recognizer.py @@ -18,9 +18,8 @@ def __init__(self): super().__init__(supported_entity="UK_NHS", patterns=patterns, context=CONTEXT) - def validate_result(self, pattern_text, pattern_result): + def validate_result(self, pattern_text): text = NhsRecognizer.__sanitize_value(pattern_text) - multiplier = 10 total = 0 for c in text: @@ -31,8 +30,7 @@ def validate_result(self, pattern_text, pattern_result): remainder = total % 11 check_digit = 11 - remainder - pattern_result.score = 1.0 if check_digit == 11 else 0 - return pattern_result + return check_digit == 11 @staticmethod def __sanitize_value(text): diff --git a/presidio-analyzer/analyzer/presidio-analyzer b/presidio-analyzer/analyzer/presidio-analyzer old mode 100644 new mode 100755 diff --git a/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py b/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py index 28325ff9e..776f30a78 100644 --- a/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py +++ b/presidio-analyzer/analyzer/recognizer_registry/recognizer_registry.py @@ -58,7 +58,8 @@ def load_predefined_recognizers(self): UsPhoneRecognizer(), UsSsnRecognizer(), SpacyRecognizer()]) - def get_recognizers(self, language, entities=None, all_fields=False): + def get_recognizers(self, language, entities=None, + all_fields=False): """ Returns a list of the recognizer, which supports the specified name and language. @@ -94,7 +95,8 @@ def get_recognizers(self, language, entities=None, all_fields=False): if not subset: logging.warning("Entity %s doesn't have the corresponding" " recognizer in language : %s", - entity, language) + entity, + language) else: to_return.extend(subset) diff --git a/presidio-analyzer/analyzer/recognizer_result.py b/presidio-analyzer/analyzer/recognizer_result.py index 4667dcbfc..6261451c1 100644 --- a/presidio-analyzer/analyzer/recognizer_result.py +++ b/presidio-analyzer/analyzer/recognizer_result.py @@ -1,6 +1,10 @@ +from . import AnalysisExplanation + + class RecognizerResult: - def __init__(self, entity_type, start, end, score): + def __init__(self, entity_type, start, end, score, + analysis_explanation: AnalysisExplanation = None): """ Recognizer Result represents the findings of the detected entity of the analyzer in the text. @@ -8,8 +12,18 @@ def __init__(self, entity_type, start, end, score): :param start: the start location of the detected entity :param end: the end location of the detected entity :param score: the score of the detection + :param analysis_explanation: contains the explanation of why this + entity was identified """ self.entity_type = entity_type self.start = start self.end = end self.score = score + self.analysis_explanation = analysis_explanation + + def append_analysis_explenation_text(self, text): + if self.analysis_explanation: + self.analysis_explanation.append_textual_explanation_line(text) + + def to_json(self): + return str(self.__dict__) diff --git a/presidio-analyzer/analyzer/template_pb2.py b/presidio-analyzer/analyzer/template_pb2.py index 2bf704b0b..9ac1c51f7 100644 --- a/presidio-analyzer/analyzer/template_pb2.py +++ b/presidio-analyzer/analyzer/template_pb2.py @@ -7,6 +7,7 @@ from google.protobuf import message as _message from google.protobuf import reflection as _reflection from google.protobuf import symbol_database as _symbol_database +from google.protobuf import descriptor_pb2 # @@protoc_insertion_point(imports) _sym_db = _symbol_database.Default() @@ -19,7 +20,6 @@ name='template.proto', package='types', syntax='proto3', - serialized_options=None, serialized_pb=_b('\n\x0etemplate.proto\x12\x05types\x1a\x0c\x63ommon.proto\"\x98\x01\n\x0f\x41nalyzeTemplate\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12\x11\n\tallFields\x18\x02 \x01(\x08\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\x12\x12\n\ncreateTime\x18\x04 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x05 \x01(\t\x12\x10\n\x08language\x18\x06 \x01(\t\"\xca\x01\n\x11\x41nonymizeTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12@\n\x18\x66ieldTypeTransformations\x18\x04 \x03(\x0b\x32\x1e.types.FieldTypeTransformation\x12\x34\n\x15\x64\x65\x66\x61ultTransformation\x18\x05 \x01(\x0b\x32\x15.types.Transformation\"g\n\x12JsonSchemaTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12\x12\n\njsonSchema\x18\x04 \x01(\t\"k\n\x17\x46ieldTypeTransformation\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12-\n\x0etransformation\x18\x02 \x01(\x0b\x32\x15.types.Transformation\"\xd1\x01\n\x0eTransformation\x12)\n\x0creplaceValue\x18\x02 \x01(\x0b\x32\x13.types.ReplaceValue\x12\'\n\x0bredactValue\x18\x03 \x01(\x0b\x32\x12.types.RedactValue\x12#\n\thashValue\x18\x04 \x01(\x0b\x32\x10.types.HashValue\x12#\n\tmaskValue\x18\x05 \x01(\x0b\x32\x10.types.MaskValue\x12!\n\x08\x66PEValue\x18\x06 \x01(\x0b\x32\x0f.types.FPEValue\" \n\x0cReplaceValue\x12\x10\n\x08newValue\x18\x01 \x01(\t\"\r\n\x0bRedactValue\"\x0b\n\tHashValue\"K\n\tMaskValue\x12\x18\n\x10maskingCharacter\x18\x01 \x01(\t\x12\x13\n\x0b\x63harsToMask\x18\x02 \x01(\x05\x12\x0f\n\x07\x66romEnd\x18\x03 \x01(\x08\"7\n\x08\x46PEValue\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05tweak\x18\x02 \x01(\t\x12\x0f\n\x07\x64\x65\x63rypt\x18\x03 \x01(\x08\"E\n\x08\x44\x42\x43onfig\x12\x18\n\x10\x63onnectionString\x18\x01 \x01(\t\x12\x11\n\ttableName\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\"\x8f\x01\n\x08\x44\x61tasink\x12!\n\x08\x64\x62\x43onfig\x18\x01 \x01(\x0b\x32\x0f.types.DBConfig\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\"}\n\x10\x44\x61tasinkTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12(\n\x0f\x61nalyzeDatasink\x18\x02 \x03(\x0b\x32\x0f.types.Datasink\x12*\n\x11\x61nonymizeDatasink\x18\x03 \x03(\x0b\x32\x0f.types.Datasink\"S\n\x11\x42lobStorageConfig\x12\x13\n\x0b\x61\x63\x63ountName\x18\x01 \x01(\t\x12\x12\n\naccountKey\x18\x02 \x01(\t\x12\x15\n\rcontainerName\x18\x03 \x01(\t\"e\n\x08S3Config\x12\x10\n\x08\x61\x63\x63\x65ssId\x18\x01 \x01(\t\x12\x11\n\taccessKey\x18\x02 \x01(\t\x12\x0e\n\x06region\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\x12\x10\n\x08\x65ndpoint\x18\x05 \x01(\t\"Z\n\x13GoogleStorageConfig\x12\x0c\n\x04json\x18\x01 \x01(\t\x12\x11\n\tprojectId\x18\x02 \x01(\t\x12\x0e\n\x06scopes\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\"\xa5\x01\n\x12\x43loudStorageConfig\x12\x33\n\x11\x62lobStorageConfig\x18\x01 \x01(\x0b\x32\x18.types.BlobStorageConfig\x12!\n\x08s3Config\x18\x02 \x01(\x0b\x32\x0f.types.S3Config\x12\x37\n\x13GoogleStorageConfig\x18\x03 \x01(\x0b\x32\x1a.types.GoogleStorageConfig\"r\n\x0cStreamConfig\x12\'\n\x0bkafkaConfig\x18\x01 \x01(\x0b\x32\x12.types.KafkaConfig\x12!\n\x08\x65hConfig\x18\x02 \x01(\x0b\x32\x0f.types.EHConfig\x12\x16\n\x0epartitionCount\x18\x03 \x01(\x05\"Y\n\x0bKafkaConfig\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x14\n\x0csaslUsername\x18\x03 \x01(\t\x12\x14\n\x0csaslPassword\x18\x04 \x01(\t\"\xcb\x01\n\x08\x45HConfig\x12\x13\n\x0b\x65hNamespace\x18\x01 \x01(\t\x12\x0e\n\x06\x65hName\x18\x02 \x01(\t\x12\x1a\n\x12\x65hConnectionString\x18\x03 \x01(\t\x12\x11\n\tehKeyName\x18\x04 \x01(\t\x12\x12\n\nehKeyValue\x18\x05 \x01(\t\x12\x1f\n\x17storageAccountNameValue\x18\x06 \x01(\t\x12\x1e\n\x16storageAccountKeyValue\x18\x07 \x01(\t\x12\x16\n\x0e\x63ontainerValue\x18\x08 \x01(\t\"\xb2\x01\n\x0eStreamTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\"Z\n\x0cScanTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\"\xc8\x01\n\x16ScannerCronJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x1f\n\x07trigger\x18\x03 \x01(\x0b\x32\x0e.types.Trigger\x12\x16\n\x0escanTemplateId\x18\x04 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x05 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x06 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x07 \x01(\t\"\xa6\x01\n\x12StreamsJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x19\n\x11streamsTemplateId\x18\x03 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\",\n\x07Trigger\x12!\n\x08schedule\x18\x01 \x01(\x0b\x32\x0f.types.Schedule\"$\n\x08Schedule\x12\x18\n\x10recurrencePeriod\x18\x01 \x01(\t\"\x8b\x01\n\x16\x41nonymizeImageTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12\x32\n\x11\x66ieldTypeGraphics\x18\x04 \x03(\x0b\x32\x17.types.FieldTypeGraphic\"V\n\x10\x46ieldTypeGraphic\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12\x1f\n\x07graphic\x18\x02 \x01(\x0b\x32\x0e.types.Graphic\"8\n\x07Graphic\x12-\n\x0e\x66illColorValue\x18\x01 \x01(\x0b\x32\x15.types.FillColorValue\":\n\x0e\x46illColorValue\x12\x0b\n\x03red\x18\x01 \x01(\x01\x12\r\n\x05green\x18\x02 \x01(\x01\x12\x0c\n\x04\x62lue\x18\x03 \x01(\x01\x62\x06proto3') , dependencies=[common__pb2.DESCRIPTOR,]) @@ -40,49 +40,49 @@ has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='allFields', full_name='types.AnalyzeTemplate.allFields', index=1, number=2, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='description', full_name='types.AnalyzeTemplate.description', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='createTime', full_name='types.AnalyzeTemplate.createTime', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='modifiedTime', full_name='types.AnalyzeTemplate.modifiedTime', index=4, number=5, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='language', full_name='types.AnalyzeTemplate.language', index=5, number=6, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -106,42 +106,42 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='createTime', full_name='types.AnonymizeTemplate.createTime', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='modifiedTime', full_name='types.AnonymizeTemplate.modifiedTime', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='fieldTypeTransformations', full_name='types.AnonymizeTemplate.fieldTypeTransformations', index=3, number=4, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='defaultTransformation', full_name='types.AnonymizeTemplate.defaultTransformation', index=4, number=5, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -165,35 +165,35 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='createTime', full_name='types.JsonSchemaTemplate.createTime', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='modifiedTime', full_name='types.JsonSchemaTemplate.modifiedTime', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='jsonSchema', full_name='types.JsonSchemaTemplate.jsonSchema', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -217,21 +217,21 @@ has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='transformation', full_name='types.FieldTypeTransformation.transformation', index=1, number=2, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -255,42 +255,42 @@ has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='redactValue', full_name='types.Transformation.redactValue', index=1, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='hashValue', full_name='types.Transformation.hashValue', index=2, number=4, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='maskValue', full_name='types.Transformation.maskValue', index=3, number=5, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='fPEValue', full_name='types.Transformation.fPEValue', index=4, number=6, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -314,14 +314,14 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -345,7 +345,7 @@ nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -369,7 +369,7 @@ nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -393,28 +393,28 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='charsToMask', full_name='types.MaskValue.charsToMask', index=1, number=2, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='fromEnd', full_name='types.MaskValue.fromEnd', index=2, number=3, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -438,28 +438,28 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='tweak', full_name='types.FPEValue.tweak', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='decrypt', full_name='types.FPEValue.decrypt', index=2, number=3, type=8, cpp_type=7, label=1, has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -483,28 +483,28 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='tableName', full_name='types.DBConfig.tableName', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='type', full_name='types.DBConfig.type', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -528,28 +528,28 @@ has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='cloudStorageConfig', full_name='types.Datasink.cloudStorageConfig', index=1, number=2, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='streamConfig', full_name='types.Datasink.streamConfig', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -573,28 +573,28 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='analyzeDatasink', full_name='types.DatasinkTemplate.analyzeDatasink', index=1, number=2, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='anonymizeDatasink', full_name='types.DatasinkTemplate.anonymizeDatasink', index=2, number=3, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -618,28 +618,28 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='accountKey', full_name='types.BlobStorageConfig.accountKey', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='containerName', full_name='types.BlobStorageConfig.containerName', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -663,42 +663,42 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='accessKey', full_name='types.S3Config.accessKey', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='region', full_name='types.S3Config.region', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='bucketName', full_name='types.S3Config.bucketName', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='endpoint', full_name='types.S3Config.endpoint', index=4, number=5, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -722,35 +722,35 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='projectId', full_name='types.GoogleStorageConfig.projectId', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='scopes', full_name='types.GoogleStorageConfig.scopes', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='bucketName', full_name='types.GoogleStorageConfig.bucketName', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -774,28 +774,28 @@ has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='s3Config', full_name='types.CloudStorageConfig.s3Config', index=1, number=2, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='GoogleStorageConfig', full_name='types.CloudStorageConfig.GoogleStorageConfig', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -819,28 +819,28 @@ has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='ehConfig', full_name='types.StreamConfig.ehConfig', index=1, number=2, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='partitionCount', full_name='types.StreamConfig.partitionCount', index=2, number=3, type=5, cpp_type=1, label=1, has_default_value=False, default_value=0, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -864,35 +864,35 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='topic', full_name='types.KafkaConfig.topic', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='saslUsername', full_name='types.KafkaConfig.saslUsername', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='saslPassword', full_name='types.KafkaConfig.saslPassword', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -916,63 +916,63 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='ehName', full_name='types.EHConfig.ehName', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='ehConnectionString', full_name='types.EHConfig.ehConnectionString', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='ehKeyName', full_name='types.EHConfig.ehKeyName', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='ehKeyValue', full_name='types.EHConfig.ehKeyValue', index=4, number=5, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='storageAccountNameValue', full_name='types.EHConfig.storageAccountNameValue', index=5, number=6, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='storageAccountKeyValue', full_name='types.EHConfig.storageAccountKeyValue', index=6, number=7, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='containerValue', full_name='types.EHConfig.containerValue', index=7, number=8, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -996,49 +996,49 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='description', full_name='types.StreamTemplate.description', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='streamConfig', full_name='types.StreamTemplate.streamConfig', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='analyzeTemplateId', full_name='types.StreamTemplate.analyzeTemplateId', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='anonymizeTemplateId', full_name='types.StreamTemplate.anonymizeTemplateId', index=4, number=5, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='datasinkTemplateId', full_name='types.StreamTemplate.datasinkTemplateId', index=5, number=6, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -1062,21 +1062,21 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='cloudStorageConfig', full_name='types.ScanTemplate.cloudStorageConfig', index=1, number=2, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -1100,56 +1100,56 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='description', full_name='types.ScannerCronJobTemplate.description', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='trigger', full_name='types.ScannerCronJobTemplate.trigger', index=2, number=3, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='scanTemplateId', full_name='types.ScannerCronJobTemplate.scanTemplateId', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='analyzeTemplateId', full_name='types.ScannerCronJobTemplate.analyzeTemplateId', index=4, number=5, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='anonymizeTemplateId', full_name='types.ScannerCronJobTemplate.anonymizeTemplateId', index=5, number=6, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='datasinkTemplateId', full_name='types.ScannerCronJobTemplate.datasinkTemplateId', index=6, number=7, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -1173,49 +1173,49 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='description', full_name='types.StreamsJobTemplate.description', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='streamsTemplateId', full_name='types.StreamsJobTemplate.streamsTemplateId', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='analyzeTemplateId', full_name='types.StreamsJobTemplate.analyzeTemplateId', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='anonymizeTemplateId', full_name='types.StreamsJobTemplate.anonymizeTemplateId', index=4, number=5, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='datasinkTemplateId', full_name='types.StreamsJobTemplate.datasinkTemplateId', index=5, number=6, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -1239,14 +1239,14 @@ has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -1270,14 +1270,14 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -1301,35 +1301,35 @@ has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='createTime', full_name='types.AnonymizeImageTemplate.createTime', index=1, number=2, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='modifiedTime', full_name='types.AnonymizeImageTemplate.modifiedTime', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='fieldTypeGraphics', full_name='types.AnonymizeImageTemplate.fieldTypeGraphics', index=3, number=4, type=11, cpp_type=10, label=3, has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -1353,21 +1353,21 @@ has_default_value=False, default_value=[], message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='graphic', full_name='types.FieldTypeGraphic.graphic', index=1, number=2, type=11, cpp_type=10, label=1, has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -1391,14 +1391,14 @@ has_default_value=False, default_value=None, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], @@ -1422,28 +1422,28 @@ has_default_value=False, default_value=float(0), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='green', full_name='types.FillColorValue.green', index=1, number=2, type=1, cpp_type=5, label=1, has_default_value=False, default_value=float(0), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( name='blue', full_name='types.FillColorValue.blue', index=2, number=3, type=1, cpp_type=5, label=1, has_default_value=False, default_value=float(0), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, - serialized_options=None, file=DESCRIPTOR), + options=None, file=DESCRIPTOR), ], extensions=[ ], nested_types=[], enum_types=[ ], - serialized_options=None, + options=None, is_extendable=False, syntax='proto3', extension_ranges=[], diff --git a/presidio-analyzer/tests/mocks/__init__.py b/presidio-analyzer/tests/mocks/__init__.py index 47866a4f4..1a12a4858 100644 --- a/presidio-analyzer/tests/mocks/__init__.py +++ b/presidio-analyzer/tests/mocks/__init__.py @@ -1 +1,2 @@ -from .nlp_engine_mock import MockNlpEngine \ No newline at end of file +from .nlp_engine_mock import MockNlpEngine +from tests.mocks import app_tracer_mock diff --git a/presidio-analyzer/tests/mocks/app_tracer_mock.py b/presidio-analyzer/tests/mocks/app_tracer_mock.py new file mode 100644 index 000000000..0e9dd4db7 --- /dev/null +++ b/presidio-analyzer/tests/mocks/app_tracer_mock.py @@ -0,0 +1,39 @@ +import logging + + +class AppTracerMock: + + def __init__(self, enable_interpretability=True): + + logger = logging.getLogger('InterpretabilityMock') + if not logger.handlers: + ch = logging.StreamHandler() + formatter = logging.Formatter( + '[%(asctime)s][%(name)s][%(levelname)s]%(message)s') + ch.setFormatter(formatter) + logger.addHandler(ch) + logger.setLevel(logging.INFO) + logger.propagate = False + + self.logger = logger + self.last_trace = None + self.enable_interpretability = enable_interpretability + self.msg_counter = 0 + + def trace(self, request_id, trace_data): + """ + Writes interpretability trace + :param request_id: A unique ID, to correlate across calls. + :param trace_data: A string to write. + :return: + """ + if self.enable_interpretability: + self.last_trace = "[{}][{}]".format(request_id, trace_data) + self.logger.info("[%s][%s]", request_id, trace_data) + self.msg_counter = self.msg_counter + 1 + + def get_last_trace(self): + return self.last_trace + + def get_msg_counter(self): + return self.msg_counter diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index 6dbb49a6e..409d7a74e 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -9,7 +9,7 @@ from analyzer.analyze_pb2 import AnalyzeRequest from analyzer import AnalyzerEngine, PatternRecognizer, Pattern, \ - RecognizerResult, RecognizerRegistry + RecognizerResult, RecognizerRegistry, AnalysisExplanation from analyzer.predefined_recognizers import CreditCardRecognizer, \ UsPhoneRecognizer, DomainRecognizer, UsItinRecognizer, \ UsLicenseRecognizer, UsBankRecognizer, UsPassportRecognizer @@ -18,6 +18,8 @@ from analyzer.nlp_engine import SpacyNlpEngine, NlpArtifacts from analyzer.predefined_recognizers import IpRecognizer, UsSsnRecognizer from tests.mocks import MockNlpEngine +from tests.mocks.app_tracer_mock import AppTracerMock + class RecognizerStoreApiMock(RecognizerStoreApi): """ @@ -85,19 +87,23 @@ def load_recognizers(self, path): us_bank_recognizer = UsBankRecognizer() us_passport_recognizer = UsPassportRecognizer() + class TestAnalyzerEngine(TestCase): def __init__(self, *args, **kwargs): super(TestAnalyzerEngine, self).__init__(*args, **kwargs) self.loaded_registry = MockRecognizerRegistry(RecognizerStoreApiMock()) mock_nlp_artifacts = NlpArtifacts([], [], [], [], None, "en") - self.loaded_analyzer_engine = AnalyzerEngine(self.loaded_registry, MockNlpEngine(stopwords=[], punct_words=[], nlp_artifacts=mock_nlp_artifacts)) + self.app_tracer = AppTracerMock(enable_interpretability=True) + self.loaded_analyzer_engine = AnalyzerEngine(self.loaded_registry, MockNlpEngine(stopwords=[], punct_words=[], nlp_artifacts=mock_nlp_artifacts), app_tracer=self.app_tracer, enable_trace_pii=True) + self.unit_test_guid = "00000000-0000-0000-0000-000000000000" def test_analyze_with_predefined_recognizers_return_results(self): text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090" language = "en" entities = ["CREDIT_CARD"] results = self.loaded_analyzer_engine.analyze( + self.unit_test_guid, text, entities, language, all_fields=False) assert len(results) == 1 @@ -111,8 +117,9 @@ def test_analyze_with_multiple_predefined_recognizers(self): # This analyzer engine is different from the global one, as this one # also loads SpaCy so it can detect the phone number entity + analyzer_engine_with_spacy = AnalyzerEngine(registry=self.loaded_registry, nlp_engine=SpacyNlpEngine()) - results = analyzer_engine_with_spacy.analyze(text, entities, language, all_fields=False) + results = analyzer_engine_with_spacy.analyze(self.unit_test_guid, text, entities, language, all_fields=False) assert len(results) == 2 assert_result(results[0], "CREDIT_CARD", 14, @@ -126,14 +133,14 @@ def test_analyze_without_entities(self): language = "en" text = " Credit card: 4095-2609-9393-4932, my name is John Oliver, DateTime: September 18 Domain: microsoft.com" entities = [] - self.loaded_analyzer_engine.analyze( + self.loaded_analyzer_engine.analyze(self.unit_test_guid, text, entities, language, all_fields=False) def test_analyze_with_empty_text(self): language = "en" text = "" entities = ["CREDIT_CARD", "PHONE_NUMBER"] - results = self.loaded_analyzer_engine.analyze( + results = self.loaded_analyzer_engine.analyze(self.unit_test_guid, text, entities, language, all_fields=False) assert len(results) == 0 @@ -143,13 +150,23 @@ def test_analyze_with_unsupported_language(self): language = "de" text = "" entities = ["CREDIT_CARD", "PHONE_NUMBER"] - self.loaded_analyzer_engine.analyze( + self.loaded_analyzer_engine.analyze(self.unit_test_guid, text, entities, language, all_fields=False) def test_remove_duplicates(self): # test same result with different score will return only the highest - arr = [RecognizerResult(start=0, end=5, score=0.1, entity_type="x"), - RecognizerResult(start=0, end=5, score=0.5, entity_type="x")] + arr = [RecognizerResult(start=0, end=5, score=0.1, entity_type="x", + analysis_explanation=AnalysisExplanation(recognizer='test', + original_score=0, + pattern_name='test', + pattern='test', + validation_result=None)), + RecognizerResult(start=0, end=5, score=0.5, entity_type="x", + analysis_explanation=AnalysisExplanation(recognizer='test', + original_score=0, + pattern_name='test', + pattern='test', + validation_result=None))] results = AnalyzerEngine._AnalyzerEngine__remove_duplicates(arr) assert len(results) == 1 assert results[0].score == 0.5 @@ -169,7 +186,7 @@ def test_added_pattern_recognizer_works(self): text = "rocket is my favorite transportation" entities = ["CREDIT_CARD", "ROCKET"] - results = analyze_engine.analyze(text=text, entities=entities, + results = analyze_engine.analyze(self.unit_test_guid, text=text, entities=entities, language='en', all_fields=False) assert len(results) == 0 @@ -179,7 +196,7 @@ def test_added_pattern_recognizer_works(self): pattern_recognizer) # Check that the entity is recognized: - results = analyze_engine.analyze(text=text, entities=entities, + results = analyze_engine.analyze(self.unit_test_guid, text=text, entities=entities, language='en', all_fields=False) assert len(results) == 1 @@ -198,7 +215,7 @@ def test_removed_pattern_recognizer_doesnt_work(self): text = "spaceship is my favorite transportation" entities = ["CREDIT_CARD", "SPACESHIP"] - results = analyze_engine.analyze(text=text, entities=entities, + results = analyze_engine.analyze(self.unit_test_guid, text=text, entities=entities, language='en', all_fields=False) assert len(results) == 0 @@ -207,7 +224,7 @@ def test_removed_pattern_recognizer_doesnt_work(self): recognizers_store_api_mock.add_custom_pattern_recognizer( pattern_recognizer) # Check that the entity is recognized: - results = analyze_engine.analyze(text=text, entities=entities, + results = analyze_engine.analyze(self.unit_test_guid, text=text, entities=entities, language='en', all_fields=False) assert len(results) == 1 assert_result(results[0], "SPACESHIP", 0, 10, 0.8) @@ -216,7 +233,7 @@ def test_removed_pattern_recognizer_doesnt_work(self): recognizers_store_api_mock.remove_recognizer( "Spaceship recognizer") # Test again to see we didn't get any results - results = analyze_engine.analyze(text=text, entities=entities, + results = analyze_engine.analyze(self.unit_test_guid, text=text, entities=entities, language='en', all_fields=False) assert len(results) == 0 @@ -282,4 +299,15 @@ def test_when_allFields_is_true_and_entities_not_empty_exception(self): new_field.minScore = '0.5' with pytest.raises(ValueError): analyze_engine.Apply(request, None) - \ No newline at end of file + + def test_when_analyze_then_apptracer_has_value(self): + text = "My name is Bart Simpson, and Credit card: 4095-2609-9393-4932, my phone is 425 8829090" + language = "en" + entities = ["CREDIT_CARD", "PHONE_NUMBER", "PERSON"] + analyzer_engine_with_spacy = AnalyzerEngine(self.loaded_registry, app_tracer=self.app_tracer, enable_trace_pii=True) + results = analyzer_engine_with_spacy.analyze(self.unit_test_guid, text, entities, language, all_fields=False) + assert len(results) == 3 + for result in results: + assert result.analysis_explanation is not None + assert self.app_tracer.get_msg_counter() == 2 + assert self.app_tracer.get_last_trace() is not None diff --git a/presidio-analyzer/tests/test_pattern_recognizer.py b/presidio-analyzer/tests/test_pattern_recognizer.py index 4bf051c2b..53421c835 100644 --- a/presidio-analyzer/tests/test_pattern_recognizer.py +++ b/presidio-analyzer/tests/test_pattern_recognizer.py @@ -10,8 +10,8 @@ class MockRecognizer(PatternRecognizer): - def validate_result(self, pattern_text, pattern_result): - return pattern_result + def validate_result(self, pattern_text): + return True def __init__(self, entity, patterns, black_list, name, context): super().__init__(supported_entity=entity, diff --git a/presidio-analyzer/tests/test_recognizer_registry.py b/presidio-analyzer/tests/test_recognizer_registry.py index 5a2e14288..578bdd1b5 100644 --- a/presidio-analyzer/tests/test_recognizer_registry.py +++ b/presidio-analyzer/tests/test_recognizer_registry.py @@ -49,7 +49,7 @@ def add_custom_pattern_recognizer(self, new_recognizer, self.latest_hash = m.digest() def remove_recognizer(self, name): - logging.info("removing recognizer " + name) + logging.info("removing recognizer %s", name) for i in self.recognizers: if i.name == name: self.recognizers.remove(i) @@ -60,6 +60,10 @@ def remove_recognizer(self, name): class TestRecognizerRegistry(TestCase): + def __init__(self, *args, **kwargs): + super(TestRecognizerRegistry, self).__init__(*args, **kwargs) + self.request_id = "UT" + def test_dummy(self): assert 1 == 1 @@ -111,7 +115,8 @@ def test_get_recognizers_one_language_one_entity(self): def test_get_recognizers_unsupported_language(self): with pytest.raises(ValueError): registry = self.get_mock_recognizer_registry() - registry.get_recognizers(language='brrrr', entities=["PERSON"]) + registry.get_recognizers( + language='brrrr', entities=["PERSON"]) def test_get_recognizers_specific_language_and_entity(self): registry = self.get_mock_recognizer_registry() diff --git a/presidio-api/cmd/presidio-api/api/analyze/analyze.go b/presidio-api/cmd/presidio-api/api/analyze/analyze.go index 41512d3c8..28157859e 100644 --- a/presidio-api/cmd/presidio-api/api/analyze/analyze.go +++ b/presidio-api/cmd/presidio-api/api/analyze/analyze.go @@ -10,7 +10,7 @@ import ( ) //Analyze text -func Analyze(ctx context.Context, api *store.API, analyzeAPIRequest *types.AnalyzeApiRequest, project string) ([]*types.AnalyzeResult, error) { +func Analyze(ctx context.Context, api *store.API, analyzeAPIRequest *types.AnalyzeApiRequest, project string) (*types.AnalyzeResponse, error) { if analyzeAPIRequest.AnalyzeTemplateId == "" && analyzeAPIRequest.AnalyzeTemplate == nil { return nil, fmt.Errorf("Analyze template is missing or empty") @@ -28,7 +28,7 @@ func Analyze(ctx context.Context, api *store.API, analyzeAPIRequest *types.Analy return nil, err } if res == nil { - return []*types.AnalyzeResult{}, err + return &types.AnalyzeResponse{}, err } return res, err diff --git a/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go b/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go index 4559b5c31..a3253f48c 100644 --- a/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go +++ b/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + uuid "github.com/satori/go.uuid" "github.com/stretchr/testify/assert" types "github.com/Microsoft/presidio-genproto/golang" @@ -46,9 +47,9 @@ func TestAnalyzeWithTemplateId(t *testing.T) { AnalyzeTemplateId: "test", AnalyzeTemplate: &types.AnalyzeTemplate{}, } - results, err := Analyze(context.Background(), api, analyzeAPIRequest, project) + response, err := Analyze(context.Background(), api, analyzeAPIRequest, project) assert.NoError(t, err) - assert.Equal(t, 2, len(results)) + assert.Equal(t, 2, len(response.AnalyzeResults)) } func TestAnalyzeWithTemplateStruct(t *testing.T) { @@ -69,9 +70,9 @@ func TestAnalyzeWithTemplateStruct(t *testing.T) { }, }, } - results, err := Analyze(context.Background(), api, analyzeAPIRequest, project) + response, err := Analyze(context.Background(), api, analyzeAPIRequest, project) assert.NoError(t, err) - assert.Equal(t, 2, len(results)) + assert.Equal(t, 2, len(response.AnalyzeResults)) } func TestAnalyzeWithNoTemplate(t *testing.T) { @@ -112,9 +113,12 @@ func TestAllFields(t *testing.T) { Language: "en", AllFields: true}, } - results, err := Analyze(context.Background(), api, analyzeAPIRequest, project) + response, err := Analyze(context.Background(), api, analyzeAPIRequest, project) + assert.NoError(t, err) + assert.Equal(t, 2, len(response.AnalyzeResults)) + assert.NotEqual(t, "", response.RequestId) + _, err = uuid.FromString(response.RequestId) assert.NoError(t, err) - assert.Equal(t, 2, len(results)) } func TestAnalyzeWhenNoEntitiesFoundThenExpectEmptyResponse(t *testing.T) { @@ -128,7 +132,7 @@ func TestAnalyzeWhenNoEntitiesFoundThenExpectEmptyResponse(t *testing.T) { Language: "en", AllFields: true}, } - results, err := Analyze(context.Background(), api, noResultsanalyzeAPIRequest, project) + response, err := Analyze(context.Background(), api, noResultsanalyzeAPIRequest, project) assert.NoError(t, err) - assert.Equal(t, 0, len(results)) + assert.Equal(t, 0, len(response.AnalyzeResults)) } diff --git a/presidio-api/cmd/presidio-api/api/anonymize-image/anonymize-image.go b/presidio-api/cmd/presidio-api/api/anonymize-image/anonymize-image.go index 5227f8ae9..76e9259c1 100644 --- a/presidio-api/cmd/presidio-api/api/anonymize-image/anonymize-image.go +++ b/presidio-api/cmd/presidio-api/api/anonymize-image/anonymize-image.go @@ -118,14 +118,14 @@ func applyPresidioOCR(ctx context.Context, services presidio.ServicesAPI, image image.Text = ocrRes.Image.Text image.Boundingboxes = ocrRes.Image.Boundingboxes - analyzeResults, err := services.AnalyzeItem(ctx, ocrRes.Image.Text, analyzeTemplate) + analyzeResponse, err := services.AnalyzeItem(ctx, ocrRes.Image.Text, analyzeTemplate) if err != nil { return nil, err } - if analyzeResults == nil { + if analyzeResponse == nil || analyzeResponse.AnalyzeResults == nil { return nil, fmt.Errorf("No PII content found in image") } - return analyzeResults, nil + return analyzeResponse.AnalyzeResults, nil } diff --git a/presidio-api/cmd/presidio-api/api/anonymize/anonymize.go b/presidio-api/cmd/presidio-api/api/anonymize/anonymize.go index 49af70d46..35b2c3f49 100644 --- a/presidio-api/cmd/presidio-api/api/anonymize/anonymize.go +++ b/presidio-api/cmd/presidio-api/api/anonymize/anonymize.go @@ -35,7 +35,7 @@ func Anonymize(ctx context.Context, api *store.API, anonymizeAPIRequest *types.A return nil, fmt.Errorf("No analyze results") } - anonymizeRes, err := api.Services.AnonymizeItem(ctx, analyzeRes, anonymizeAPIRequest.Text, anonymizeAPIRequest.AnonymizeTemplate) + anonymizeRes, err := api.Services.AnonymizeItem(ctx, analyzeRes.AnalyzeResults, anonymizeAPIRequest.Text, anonymizeAPIRequest.AnonymizeTemplate) if err != nil { return nil, err } else if anonymizeRes == nil { diff --git a/presidio-api/cmd/presidio-api/api/mocks/mocks.go b/presidio-api/cmd/presidio-api/api/mocks/mocks.go index 5cbe37e82..4619870f8 100644 --- a/presidio-api/cmd/presidio-api/api/mocks/mocks.go +++ b/presidio-api/cmd/presidio-api/api/mocks/mocks.go @@ -69,6 +69,7 @@ func GetAnalyzerMockResult() *types.AnalyzeResponse { }, } return &types.AnalyzeResponse{ + RequestId: "21020352-c0bd-4af6-81e0-f1d53f34f2cb", AnalyzeResults: results, } } diff --git a/presidio-api/cmd/presidio-api/methods.go b/presidio-api/cmd/presidio-api/methods.go index 92cead4ce..23bbb67cf 100644 --- a/presidio-api/cmd/presidio-api/methods.go +++ b/presidio-api/cmd/presidio-api/methods.go @@ -95,7 +95,11 @@ func analyzeText(c *gin.Context) { server.AbortWithError(c, http.StatusBadRequest, err) return } - server.WriteResponse(c, http.StatusOK, result) + server.WriteResponseWithRequestID( + c, + http.StatusOK, + result.RequestId, + result.AnalyzeResults) } } diff --git a/presidio-collector/cmd/presidio-collector/processor/processor.go b/presidio-collector/cmd/presidio-collector/processor/processor.go index 4da71c128..ab6fa9636 100644 --- a/presidio-collector/cmd/presidio-collector/processor/processor.go +++ b/presidio-collector/cmd/presidio-collector/processor/processor.go @@ -18,26 +18,26 @@ import ( func ReceiveEventsFromStream(st stream.Stream, services presidio.ServicesAPI, streamRequest *types.StreamRequest) error { return st.Receive(func(ctx context.Context, partition string, sequence string, text string) error { - analyzerResult, err := services.AnalyzeItem(ctx, text, streamRequest.AnalyzeTemplate) + analyzerResponse, err := services.AnalyzeItem(ctx, text, streamRequest.AnalyzeTemplate) if err != nil { err = fmt.Errorf("error analyzing message: %s, error: %q", text, err.Error()) return err } - if len(analyzerResult) > 0 { - anonymizerResult, err := services.AnonymizeItem(ctx, analyzerResult, text, streamRequest.AnonymizeTemplate) + if len(analyzerResponse.AnalyzeResults) > 0 { + anonymizerResult, err := services.AnonymizeItem(ctx, analyzerResponse.AnalyzeResults, text, streamRequest.AnonymizeTemplate) if err != nil { err = fmt.Errorf("error anonymizing item: %s/%s, error: %q", partition, sequence, err.Error()) return err } - err = services.SendResultToDatasink(ctx, analyzerResult, anonymizerResult, fmt.Sprintf("%s/%s", partition, sequence)) + err = services.SendResultToDatasink(ctx, analyzerResponse.AnalyzeResults, anonymizerResult, fmt.Sprintf("%s/%s", partition, sequence)) if err != nil { err = fmt.Errorf("error sending message to datasink: %s/%s, error: %q", partition, sequence, err.Error()) return err } - log.Debug("%d results were sent to the datasink successfully", len(analyzerResult)) + log.Debug("%d results were sent to the datasink successfully", len(analyzerResponse.AnalyzeResults)) } return nil @@ -47,7 +47,7 @@ func ReceiveEventsFromStream(st stream.Stream, services presidio.ServicesAPI, st //ScanStorage .. func ScanStorage(ctx context.Context, scan scanner.Scanner, cache cache.Cache, services presidio.ServicesAPI, scanRequest *types.ScanRequest) error { return scan.Scan(func(item interface{}) error { - var analyzerResult []*types.AnalyzeResult + var analyzerResult *types.AnalyzeResponse //[]*types.AnalyzeResult scanItem := scanner.CreateItem(scanRequest, item) itemPath := scanItem.GetPath() @@ -78,19 +78,19 @@ func ScanStorage(ctx context.Context, scan scanner.Scanner, cache cache.Cache, s if err != nil { return err } - log.Debug("analyzed %d results", len(analyzerResult)) + log.Debug("analyzed %d results", len(analyzerResult.AnalyzeResults)) - if len(analyzerResult) > 0 { - anonymizerResult, err := services.AnonymizeItem(ctx, analyzerResult, content, scanRequest.AnonymizeTemplate) + if len(analyzerResult.AnalyzeResults) > 0 { + anonymizerResult, err := services.AnonymizeItem(ctx, analyzerResult.AnalyzeResults, content, scanRequest.AnonymizeTemplate) if err != nil { return err } - err = services.SendResultToDatasink(ctx, analyzerResult, anonymizerResult, itemPath) + err = services.SendResultToDatasink(ctx, analyzerResult.AnalyzeResults, anonymizerResult, itemPath) if err != nil { return err } - log.Info("%d results were sent to the datasink successfully", len(analyzerResult)) + log.Info("%d results were sent to the datasink successfully", len(analyzerResult.AnalyzeResults)) } writeItemToCache(uniqueID, itemPath, cache) From 001b050d17d68d8f3c749e2ac2ab5d3cc0662ca1 Mon Sep 17 00:00:00 2001 From: Omri Mendels Date: Thu, 25 Jul 2019 21:25:07 +0300 Subject: [PATCH 61/75] added threshold support for low scores (#190) --- Gopkg.lock | 4 +- presidio-analyzer/analyzer/analyzer_engine.py | 100 +++++++-- presidio-analyzer/analyzer/template_pb2.py | 127 +++++------ .../tests/mocks/nlp_engine_mock.py | 9 +- .../tests/test_analyzer_engine.py | 202 ++++++++++++++---- 5 files changed, 320 insertions(+), 122 deletions(-) diff --git a/Gopkg.lock b/Gopkg.lock index b512c2ccd..46602d8bb 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -107,11 +107,11 @@ [[projects]] branch = "development" - digest = "1:4dc3b1c103bec54029b98720ce692277552b630fa0d95cda3abe8d0c760b4947" + digest = "1:c79ff0c1cb49b28ad509306045dd8d5e46072354c0b4dbaff75434f3e630ce8d" name = "github.com/Microsoft/presidio-genproto" packages = ["golang"] pruneopts = "UT" - revision = "3863dc4f6f6836fb0a638ac9849f7b374dd6f94c" + revision = "db15bf6b1589b71e88d3fab12188c42d5364789c" [[projects]] digest = "1:2ec153af6a806c3d63d4299f2549bcb29d75d9703097341be309a46db3481488" diff --git a/presidio-analyzer/analyzer/analyzer_engine.py b/presidio-analyzer/analyzer/analyzer_engine.py index 08b4a87e7..f7ff270f9 100644 --- a/presidio-analyzer/analyzer/analyzer_engine.py +++ b/presidio-analyzer/analyzer/analyzer_engine.py @@ -8,7 +8,6 @@ from analyzer.logger import Logger from analyzer.app_tracer import AppTracer - DEFAULT_LANGUAGE = "en" logger = Logger() @@ -16,7 +15,21 @@ class AnalyzerEngine(analyze_pb2_grpc.AnalyzeServiceServicer): def __init__(self, registry=None, nlp_engine=None, - app_tracer=None, enable_trace_pii=False): + app_tracer=None, enable_trace_pii=False, + default_score_threshold=None): + """ + AnalyzerEngine class: Orchestrating the detection of PII entities + and all related logic + :param registry: instance of type RecognizerRegistry + :param nlp_engine: instance of type NlpEngine + (for example SpacyNlpEngine) + :param app_tracer: instance of type AppTracer, + used to trace the logic used during each request + :param enable_trace_pii: bool, + defines whether PII values should be traced or not. + :param default_score_threshold: Minimum confidence value + for detected entities to be returned + """ if not nlp_engine: from analyzer.nlp_engine import SpacyNlpEngine nlp_engine = SpacyNlpEngine() @@ -25,29 +38,47 @@ def __init__(self, registry=None, nlp_engine=None, registry = RecognizerRegistry() if not app_tracer: app_tracer = AppTracer() - # load nlp module + # load nlp module self.nlp_engine = nlp_engine # prepare registry self.registry = registry # load all recognizers - registry.load_predefined_recognizers() + if not registry.recognizers: + registry.load_predefined_recognizers() + self.app_tracer = app_tracer self.enable_trace_pii = enable_trace_pii + if default_score_threshold is None: + self.default_score_threshold = 0 + else: + self.default_score_threshold = default_score_threshold + # pylint: disable=unused-argument def Apply(self, request, context): + """ + GRPC entry point to Presidio-Analyzer + :param request: Presidio Analyzer resuest of type AnalyzeRequest + :param context: + :return: List of [AnalyzeResult] + """ logger.info("Starting Analyzer's Apply") entities = AnalyzerEngine.__convert_fields_to_entities( request.analyzeTemplate.fields) language = AnalyzerEngine.get_language_from_request(request) + threshold = request.analyzeTemplate.resultsScoreThreshold + all_fields = request.analyzeTemplate.allFields + # correlation is used to group all traces related to on request + correlation_id = str(uuid.uuid4()) results = self.analyze(correlation_id, request.text, entities, language, - request.analyzeTemplate.allFields) + all_fields, + threshold) # Create Analyze Response Object response = analyze_pb2.AnalyzeResponse() @@ -62,6 +93,12 @@ def Apply(self, request, context): @staticmethod def __remove_duplicates(results): + """ + Removes each result which has a span contained in a + result's span with ahigher score + :param results: List[RecognizerResult] + :return: List[RecognizerResult] + """ # bug# 597: Analyzer remove duplicates doesn't handle all cases of one # result as a substring of the other results = sorted(results, @@ -78,7 +115,8 @@ def __remove_duplicates(results): # If result is equal to or substring of # one of the other results if result.start >= filtered.start \ - and result.end <= filtered.end: + and result.end <= filtered.end \ + and result.entity_type == filtered.entity_type: valid_result = False break @@ -87,6 +125,23 @@ def __remove_duplicates(results): return filtered_results + def __remove_low_scores(self, results, score_threshold=None): + """ + Removes results for which the confidence is lower than the threshold + :param results: List of RecognizerResult + :param score_threshold: float value for minimum possible confidence + :return: List[RecognizerResult] + """ + if score_threshold is None: + score_threshold = self.default_score_threshold + + new_results = [] + for result in results: + if result.score >= score_threshold: + new_results.append(result) + + return new_results + @classmethod def get_language_from_request(cls, request): language = request.analyzeTemplate.language @@ -94,7 +149,8 @@ def get_language_from_request(cls, request): language = DEFAULT_LANGUAGE return language - def analyze(self, correlation_id, text, entities, language, all_fields): + def analyze(self, correlation_id, text, entities, language, all_fields, + score_threshold=None): """ analyzes the requested text, searching for the given entities in the given language @@ -104,6 +160,8 @@ def analyze(self, correlation_id, text, entities, language, all_fields): :param language: the language of the text :param all_fields: a Flag to return all fields of the requested language + :param score_threshold: A minimum value for which + to return an identified entity :return: an array of the found entities in the text """ @@ -143,14 +201,23 @@ def analyze(self, correlation_id, text, entities, language, all_fields): if current_results: results.extend(current_results) - results = AnalyzerEngine.__remove_duplicates(results) self.app_tracer.trace(correlation_id, json.dumps( [result.to_json() for result in results])) + # Remove duplicates or low score results + results = AnalyzerEngine.__remove_duplicates(results) + results = self.__remove_low_scores(results, score_threshold) + return results @staticmethod def __list_entities(recognizers): + """ + Returns a List[str] of unique entity names supported + by the provided recognizers + :param recognizers: list of EntityRecognizer + :return: List[str] + """ entities = [] for recognizer in recognizers: ents = [entity for entity in recognizer.supported_entities] @@ -160,15 +227,20 @@ def __list_entities(recognizers): @staticmethod def __convert_fields_to_entities(fields): - # Convert fields to entities - will be changed once the API - # will be changed - entities = [] - for field in fields: - entities.append(field.name) - return entities + """ + Converts the Field object to the name of the entity + :param fields: List of Fields in AnalyzeTemplate + :return: List[str] with field names + """ + return [field.name for field in fields] @staticmethod def __convert_results_to_proto(results): + """ + Converts a List[RecognizerResult] to List[AnalyzeResult] + :param results: List[RecognizerResult] + :return: List[AnalyzeResult] + """ proto_results = [] for result in results: res = common_pb2.AnalyzeResult() diff --git a/presidio-analyzer/analyzer/template_pb2.py b/presidio-analyzer/analyzer/template_pb2.py index 9ac1c51f7..4d726d78b 100644 --- a/presidio-analyzer/analyzer/template_pb2.py +++ b/presidio-analyzer/analyzer/template_pb2.py @@ -20,7 +20,7 @@ name='template.proto', package='types', syntax='proto3', - serialized_pb=_b('\n\x0etemplate.proto\x12\x05types\x1a\x0c\x63ommon.proto\"\x98\x01\n\x0f\x41nalyzeTemplate\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12\x11\n\tallFields\x18\x02 \x01(\x08\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\x12\x12\n\ncreateTime\x18\x04 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x05 \x01(\t\x12\x10\n\x08language\x18\x06 \x01(\t\"\xca\x01\n\x11\x41nonymizeTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12@\n\x18\x66ieldTypeTransformations\x18\x04 \x03(\x0b\x32\x1e.types.FieldTypeTransformation\x12\x34\n\x15\x64\x65\x66\x61ultTransformation\x18\x05 \x01(\x0b\x32\x15.types.Transformation\"g\n\x12JsonSchemaTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12\x12\n\njsonSchema\x18\x04 \x01(\t\"k\n\x17\x46ieldTypeTransformation\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12-\n\x0etransformation\x18\x02 \x01(\x0b\x32\x15.types.Transformation\"\xd1\x01\n\x0eTransformation\x12)\n\x0creplaceValue\x18\x02 \x01(\x0b\x32\x13.types.ReplaceValue\x12\'\n\x0bredactValue\x18\x03 \x01(\x0b\x32\x12.types.RedactValue\x12#\n\thashValue\x18\x04 \x01(\x0b\x32\x10.types.HashValue\x12#\n\tmaskValue\x18\x05 \x01(\x0b\x32\x10.types.MaskValue\x12!\n\x08\x66PEValue\x18\x06 \x01(\x0b\x32\x0f.types.FPEValue\" \n\x0cReplaceValue\x12\x10\n\x08newValue\x18\x01 \x01(\t\"\r\n\x0bRedactValue\"\x0b\n\tHashValue\"K\n\tMaskValue\x12\x18\n\x10maskingCharacter\x18\x01 \x01(\t\x12\x13\n\x0b\x63harsToMask\x18\x02 \x01(\x05\x12\x0f\n\x07\x66romEnd\x18\x03 \x01(\x08\"7\n\x08\x46PEValue\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05tweak\x18\x02 \x01(\t\x12\x0f\n\x07\x64\x65\x63rypt\x18\x03 \x01(\x08\"E\n\x08\x44\x42\x43onfig\x12\x18\n\x10\x63onnectionString\x18\x01 \x01(\t\x12\x11\n\ttableName\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\"\x8f\x01\n\x08\x44\x61tasink\x12!\n\x08\x64\x62\x43onfig\x18\x01 \x01(\x0b\x32\x0f.types.DBConfig\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\"}\n\x10\x44\x61tasinkTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12(\n\x0f\x61nalyzeDatasink\x18\x02 \x03(\x0b\x32\x0f.types.Datasink\x12*\n\x11\x61nonymizeDatasink\x18\x03 \x03(\x0b\x32\x0f.types.Datasink\"S\n\x11\x42lobStorageConfig\x12\x13\n\x0b\x61\x63\x63ountName\x18\x01 \x01(\t\x12\x12\n\naccountKey\x18\x02 \x01(\t\x12\x15\n\rcontainerName\x18\x03 \x01(\t\"e\n\x08S3Config\x12\x10\n\x08\x61\x63\x63\x65ssId\x18\x01 \x01(\t\x12\x11\n\taccessKey\x18\x02 \x01(\t\x12\x0e\n\x06region\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\x12\x10\n\x08\x65ndpoint\x18\x05 \x01(\t\"Z\n\x13GoogleStorageConfig\x12\x0c\n\x04json\x18\x01 \x01(\t\x12\x11\n\tprojectId\x18\x02 \x01(\t\x12\x0e\n\x06scopes\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\"\xa5\x01\n\x12\x43loudStorageConfig\x12\x33\n\x11\x62lobStorageConfig\x18\x01 \x01(\x0b\x32\x18.types.BlobStorageConfig\x12!\n\x08s3Config\x18\x02 \x01(\x0b\x32\x0f.types.S3Config\x12\x37\n\x13GoogleStorageConfig\x18\x03 \x01(\x0b\x32\x1a.types.GoogleStorageConfig\"r\n\x0cStreamConfig\x12\'\n\x0bkafkaConfig\x18\x01 \x01(\x0b\x32\x12.types.KafkaConfig\x12!\n\x08\x65hConfig\x18\x02 \x01(\x0b\x32\x0f.types.EHConfig\x12\x16\n\x0epartitionCount\x18\x03 \x01(\x05\"Y\n\x0bKafkaConfig\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x14\n\x0csaslUsername\x18\x03 \x01(\t\x12\x14\n\x0csaslPassword\x18\x04 \x01(\t\"\xcb\x01\n\x08\x45HConfig\x12\x13\n\x0b\x65hNamespace\x18\x01 \x01(\t\x12\x0e\n\x06\x65hName\x18\x02 \x01(\t\x12\x1a\n\x12\x65hConnectionString\x18\x03 \x01(\t\x12\x11\n\tehKeyName\x18\x04 \x01(\t\x12\x12\n\nehKeyValue\x18\x05 \x01(\t\x12\x1f\n\x17storageAccountNameValue\x18\x06 \x01(\t\x12\x1e\n\x16storageAccountKeyValue\x18\x07 \x01(\t\x12\x16\n\x0e\x63ontainerValue\x18\x08 \x01(\t\"\xb2\x01\n\x0eStreamTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\"Z\n\x0cScanTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\"\xc8\x01\n\x16ScannerCronJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x1f\n\x07trigger\x18\x03 \x01(\x0b\x32\x0e.types.Trigger\x12\x16\n\x0escanTemplateId\x18\x04 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x05 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x06 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x07 \x01(\t\"\xa6\x01\n\x12StreamsJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x19\n\x11streamsTemplateId\x18\x03 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\",\n\x07Trigger\x12!\n\x08schedule\x18\x01 \x01(\x0b\x32\x0f.types.Schedule\"$\n\x08Schedule\x12\x18\n\x10recurrencePeriod\x18\x01 \x01(\t\"\x8b\x01\n\x16\x41nonymizeImageTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12\x32\n\x11\x66ieldTypeGraphics\x18\x04 \x03(\x0b\x32\x17.types.FieldTypeGraphic\"V\n\x10\x46ieldTypeGraphic\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12\x1f\n\x07graphic\x18\x02 \x01(\x0b\x32\x0e.types.Graphic\"8\n\x07Graphic\x12-\n\x0e\x66illColorValue\x18\x01 \x01(\x0b\x32\x15.types.FillColorValue\":\n\x0e\x46illColorValue\x12\x0b\n\x03red\x18\x01 \x01(\x01\x12\r\n\x05green\x18\x02 \x01(\x01\x12\x0c\n\x04\x62lue\x18\x03 \x01(\x01\x62\x06proto3') + serialized_pb=_b('\n\x0etemplate.proto\x12\x05types\x1a\x0c\x63ommon.proto\"\xb7\x01\n\x0f\x41nalyzeTemplate\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12\x11\n\tallFields\x18\x02 \x01(\x08\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\x12\x12\n\ncreateTime\x18\x04 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x05 \x01(\t\x12\x10\n\x08language\x18\x06 \x01(\t\x12\x1d\n\x15resultsScoreThreshold\x18\x07 \x01(\x02\"\xca\x01\n\x11\x41nonymizeTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12@\n\x18\x66ieldTypeTransformations\x18\x04 \x03(\x0b\x32\x1e.types.FieldTypeTransformation\x12\x34\n\x15\x64\x65\x66\x61ultTransformation\x18\x05 \x01(\x0b\x32\x15.types.Transformation\"g\n\x12JsonSchemaTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12\x12\n\njsonSchema\x18\x04 \x01(\t\"k\n\x17\x46ieldTypeTransformation\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12-\n\x0etransformation\x18\x02 \x01(\x0b\x32\x15.types.Transformation\"\xd1\x01\n\x0eTransformation\x12)\n\x0creplaceValue\x18\x02 \x01(\x0b\x32\x13.types.ReplaceValue\x12\'\n\x0bredactValue\x18\x03 \x01(\x0b\x32\x12.types.RedactValue\x12#\n\thashValue\x18\x04 \x01(\x0b\x32\x10.types.HashValue\x12#\n\tmaskValue\x18\x05 \x01(\x0b\x32\x10.types.MaskValue\x12!\n\x08\x66PEValue\x18\x06 \x01(\x0b\x32\x0f.types.FPEValue\" \n\x0cReplaceValue\x12\x10\n\x08newValue\x18\x01 \x01(\t\"\r\n\x0bRedactValue\"\x0b\n\tHashValue\"K\n\tMaskValue\x12\x18\n\x10maskingCharacter\x18\x01 \x01(\t\x12\x13\n\x0b\x63harsToMask\x18\x02 \x01(\x05\x12\x0f\n\x07\x66romEnd\x18\x03 \x01(\x08\"7\n\x08\x46PEValue\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05tweak\x18\x02 \x01(\t\x12\x0f\n\x07\x64\x65\x63rypt\x18\x03 \x01(\x08\"E\n\x08\x44\x42\x43onfig\x12\x18\n\x10\x63onnectionString\x18\x01 \x01(\t\x12\x11\n\ttableName\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\"\x8f\x01\n\x08\x44\x61tasink\x12!\n\x08\x64\x62\x43onfig\x18\x01 \x01(\x0b\x32\x0f.types.DBConfig\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\"}\n\x10\x44\x61tasinkTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12(\n\x0f\x61nalyzeDatasink\x18\x02 \x03(\x0b\x32\x0f.types.Datasink\x12*\n\x11\x61nonymizeDatasink\x18\x03 \x03(\x0b\x32\x0f.types.Datasink\"S\n\x11\x42lobStorageConfig\x12\x13\n\x0b\x61\x63\x63ountName\x18\x01 \x01(\t\x12\x12\n\naccountKey\x18\x02 \x01(\t\x12\x15\n\rcontainerName\x18\x03 \x01(\t\"e\n\x08S3Config\x12\x10\n\x08\x61\x63\x63\x65ssId\x18\x01 \x01(\t\x12\x11\n\taccessKey\x18\x02 \x01(\t\x12\x0e\n\x06region\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\x12\x10\n\x08\x65ndpoint\x18\x05 \x01(\t\"Z\n\x13GoogleStorageConfig\x12\x0c\n\x04json\x18\x01 \x01(\t\x12\x11\n\tprojectId\x18\x02 \x01(\t\x12\x0e\n\x06scopes\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\"\xa5\x01\n\x12\x43loudStorageConfig\x12\x33\n\x11\x62lobStorageConfig\x18\x01 \x01(\x0b\x32\x18.types.BlobStorageConfig\x12!\n\x08s3Config\x18\x02 \x01(\x0b\x32\x0f.types.S3Config\x12\x37\n\x13GoogleStorageConfig\x18\x03 \x01(\x0b\x32\x1a.types.GoogleStorageConfig\"r\n\x0cStreamConfig\x12\'\n\x0bkafkaConfig\x18\x01 \x01(\x0b\x32\x12.types.KafkaConfig\x12!\n\x08\x65hConfig\x18\x02 \x01(\x0b\x32\x0f.types.EHConfig\x12\x16\n\x0epartitionCount\x18\x03 \x01(\x05\"Y\n\x0bKafkaConfig\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x14\n\x0csaslUsername\x18\x03 \x01(\t\x12\x14\n\x0csaslPassword\x18\x04 \x01(\t\"\xcb\x01\n\x08\x45HConfig\x12\x13\n\x0b\x65hNamespace\x18\x01 \x01(\t\x12\x0e\n\x06\x65hName\x18\x02 \x01(\t\x12\x1a\n\x12\x65hConnectionString\x18\x03 \x01(\t\x12\x11\n\tehKeyName\x18\x04 \x01(\t\x12\x12\n\nehKeyValue\x18\x05 \x01(\t\x12\x1f\n\x17storageAccountNameValue\x18\x06 \x01(\t\x12\x1e\n\x16storageAccountKeyValue\x18\x07 \x01(\t\x12\x16\n\x0e\x63ontainerValue\x18\x08 \x01(\t\"\xb2\x01\n\x0eStreamTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\"Z\n\x0cScanTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\"\xc8\x01\n\x16ScannerCronJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x1f\n\x07trigger\x18\x03 \x01(\x0b\x32\x0e.types.Trigger\x12\x16\n\x0escanTemplateId\x18\x04 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x05 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x06 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x07 \x01(\t\"\xa6\x01\n\x12StreamsJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x19\n\x11streamsTemplateId\x18\x03 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\",\n\x07Trigger\x12!\n\x08schedule\x18\x01 \x01(\x0b\x32\x0f.types.Schedule\"$\n\x08Schedule\x12\x18\n\x10recurrencePeriod\x18\x01 \x01(\t\"\x8b\x01\n\x16\x41nonymizeImageTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12\x32\n\x11\x66ieldTypeGraphics\x18\x04 \x03(\x0b\x32\x17.types.FieldTypeGraphic\"V\n\x10\x46ieldTypeGraphic\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12\x1f\n\x07graphic\x18\x02 \x01(\x0b\x32\x0e.types.Graphic\"8\n\x07Graphic\x12-\n\x0e\x66illColorValue\x18\x01 \x01(\x0b\x32\x15.types.FillColorValue\":\n\x0e\x46illColorValue\x12\x0b\n\x03red\x18\x01 \x01(\x01\x12\r\n\x05green\x18\x02 \x01(\x01\x12\x0c\n\x04\x62lue\x18\x03 \x01(\x01\x62\x06proto3') , dependencies=[common__pb2.DESCRIPTOR,]) @@ -76,6 +76,13 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='resultsScoreThreshold', full_name='types.AnalyzeTemplate.resultsScoreThreshold', index=6, + number=7, type=2, cpp_type=6, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -89,7 +96,7 @@ oneofs=[ ], serialized_start=40, - serialized_end=192, + serialized_end=223, ) @@ -147,8 +154,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=195, - serialized_end=397, + serialized_start=226, + serialized_end=428, ) @@ -199,8 +206,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=399, - serialized_end=502, + serialized_start=430, + serialized_end=533, ) @@ -237,8 +244,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=504, - serialized_end=611, + serialized_start=535, + serialized_end=642, ) @@ -296,8 +303,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=614, - serialized_end=823, + serialized_start=645, + serialized_end=854, ) @@ -327,8 +334,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=825, - serialized_end=857, + serialized_start=856, + serialized_end=888, ) @@ -351,8 +358,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=859, - serialized_end=872, + serialized_start=890, + serialized_end=903, ) @@ -375,8 +382,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=874, - serialized_end=885, + serialized_start=905, + serialized_end=916, ) @@ -420,8 +427,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=887, - serialized_end=962, + serialized_start=918, + serialized_end=993, ) @@ -465,8 +472,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=964, - serialized_end=1019, + serialized_start=995, + serialized_end=1050, ) @@ -510,8 +517,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1021, - serialized_end=1090, + serialized_start=1052, + serialized_end=1121, ) @@ -555,8 +562,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1093, - serialized_end=1236, + serialized_start=1124, + serialized_end=1267, ) @@ -600,8 +607,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1238, - serialized_end=1363, + serialized_start=1269, + serialized_end=1394, ) @@ -645,8 +652,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1365, - serialized_end=1448, + serialized_start=1396, + serialized_end=1479, ) @@ -704,8 +711,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1450, - serialized_end=1551, + serialized_start=1481, + serialized_end=1582, ) @@ -756,8 +763,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1553, - serialized_end=1643, + serialized_start=1584, + serialized_end=1674, ) @@ -801,8 +808,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1646, - serialized_end=1811, + serialized_start=1677, + serialized_end=1842, ) @@ -846,8 +853,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1813, - serialized_end=1927, + serialized_start=1844, + serialized_end=1958, ) @@ -898,8 +905,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1929, - serialized_end=2018, + serialized_start=1960, + serialized_end=2049, ) @@ -978,8 +985,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2021, - serialized_end=2224, + serialized_start=2052, + serialized_end=2255, ) @@ -1044,8 +1051,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2227, - serialized_end=2405, + serialized_start=2258, + serialized_end=2436, ) @@ -1082,8 +1089,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2407, - serialized_end=2497, + serialized_start=2438, + serialized_end=2528, ) @@ -1155,8 +1162,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2500, - serialized_end=2700, + serialized_start=2531, + serialized_end=2731, ) @@ -1221,8 +1228,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2703, - serialized_end=2869, + serialized_start=2734, + serialized_end=2900, ) @@ -1252,8 +1259,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2871, - serialized_end=2915, + serialized_start=2902, + serialized_end=2946, ) @@ -1283,8 +1290,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2917, - serialized_end=2953, + serialized_start=2948, + serialized_end=2984, ) @@ -1335,8 +1342,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2956, - serialized_end=3095, + serialized_start=2987, + serialized_end=3126, ) @@ -1373,8 +1380,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3097, - serialized_end=3183, + serialized_start=3128, + serialized_end=3214, ) @@ -1404,8 +1411,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3185, - serialized_end=3241, + serialized_start=3216, + serialized_end=3272, ) @@ -1449,8 +1456,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=3243, - serialized_end=3301, + serialized_start=3274, + serialized_end=3332, ) _ANALYZETEMPLATE.fields_by_name['fields'].message_type = common__pb2._FIELDTYPES diff --git a/presidio-analyzer/tests/mocks/nlp_engine_mock.py b/presidio-analyzer/tests/mocks/nlp_engine_mock.py index 28fcb6679..6b87534e4 100644 --- a/presidio-analyzer/tests/mocks/nlp_engine_mock.py +++ b/presidio-analyzer/tests/mocks/nlp_engine_mock.py @@ -1,12 +1,15 @@ -from analyzer.nlp_engine import NlpEngine +from analyzer.nlp_engine import NlpEngine, NlpArtifacts class MockNlpEngine(NlpEngine): - def __init__(self, stopwords, punct_words, nlp_artifacts): + def __init__(self, stopwords=[], punct_words=[], nlp_artifacts=None): self.stopwords = stopwords self.punct_words = punct_words - self.nlp_artifacts = nlp_artifacts + if nlp_artifacts is None: + self.nlp_artifacts = NlpArtifacts([], [], [], [], None, "en") + else: + self.nlp_artifacts = nlp_artifacts def is_stopword(self, word, language): return word in self.stopwords diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index 409d7a74e..4886b963e 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -36,11 +36,15 @@ def get_latest_hash(self): def get_all_recognizers(self): return self.recognizers - def add_custom_pattern_recognizer(self, new_recognizer, skip_hash_update=False): + def add_custom_pattern_recognizer(self, new_recognizer, + skip_hash_update=False): patterns = [] for pat in new_recognizer.patterns: patterns.extend([Pattern(pat.name, pat.regex, pat.score)]) - new_custom_recognizer = PatternRecognizer(name=new_recognizer.name, supported_entity=new_recognizer.supported_entities[0], + new_custom_recognizer = PatternRecognizer(name=new_recognizer.name, + supported_entity= + new_recognizer.supported_entities[ + 0], supported_language=new_recognizer.supported_language, black_list=new_recognizer.black_list, context=new_recognizer.context, @@ -65,6 +69,7 @@ def remove_recognizer(self, name): m.update(recognizer.name.encode('utf-8')) self.latest_hash = m.digest() + class MockRecognizerRegistry(RecognizerRegistry): """ A mock that acts as a recognizers registry @@ -79,13 +84,7 @@ def load_recognizers(self, path): DomainRecognizer()]) -ip_recognizer = IpRecognizer() -us_ssn_recognizer = UsSsnRecognizer() -phone_recognizer = UsPhoneRecognizer() -us_itin_recognizer = UsItinRecognizer() -us_license_recognizer = UsLicenseRecognizer() -us_bank_recognizer = UsBankRecognizer() -us_passport_recognizer = UsPassportRecognizer() +loaded_spacy_nlp_engine = SpacyNlpEngine() class TestAnalyzerEngine(TestCase): @@ -95,7 +94,12 @@ def __init__(self, *args, **kwargs): self.loaded_registry = MockRecognizerRegistry(RecognizerStoreApiMock()) mock_nlp_artifacts = NlpArtifacts([], [], [], [], None, "en") self.app_tracer = AppTracerMock(enable_interpretability=True) - self.loaded_analyzer_engine = AnalyzerEngine(self.loaded_registry, MockNlpEngine(stopwords=[], punct_words=[], nlp_artifacts=mock_nlp_artifacts), app_tracer=self.app_tracer, enable_trace_pii=True) + self.loaded_analyzer_engine = AnalyzerEngine(self.loaded_registry, + MockNlpEngine(stopwords=[], + punct_words=[], + nlp_artifacts=mock_nlp_artifacts), + app_tracer=self.app_tracer, + enable_trace_pii=True) self.unit_test_guid = "00000000-0000-0000-0000-000000000000" def test_analyze_with_predefined_recognizers_return_results(self): @@ -116,16 +120,19 @@ def test_analyze_with_multiple_predefined_recognizers(self): entities = ["CREDIT_CARD", "PHONE_NUMBER"] # This analyzer engine is different from the global one, as this one - # also loads SpaCy so it can detect the phone number entity + # also loads SpaCy so it can use the context words - analyzer_engine_with_spacy = AnalyzerEngine(registry=self.loaded_registry, nlp_engine=SpacyNlpEngine()) - results = analyzer_engine_with_spacy.analyze(self.unit_test_guid, text, entities, language, all_fields=False) + analyzer_engine_with_spacy = AnalyzerEngine( + registry=self.loaded_registry, nlp_engine=loaded_spacy_nlp_engine) + results = analyzer_engine_with_spacy.analyze(self.unit_test_guid, text, + entities, language, + all_fields=False) assert len(results) == 2 assert_result(results[0], "CREDIT_CARD", 14, 33, EntityRecognizer.MAX_SCORE) expected_score = UsPhoneRecognizer.MEDIUM_REGEX_SCORE + \ - PatternRecognizer.CONTEXT_SIMILARITY_FACTOR # 0.5 + 0.35 = 0.85 + PatternRecognizer.CONTEXT_SIMILARITY_FACTOR # 0.5 + 0.35 = 0.85 assert_result(results[1], "PHONE_NUMBER", 48, 59, expected_score) def test_analyze_without_entities(self): @@ -134,14 +141,16 @@ def test_analyze_without_entities(self): text = " Credit card: 4095-2609-9393-4932, my name is John Oliver, DateTime: September 18 Domain: microsoft.com" entities = [] self.loaded_analyzer_engine.analyze(self.unit_test_guid, - text, entities, language, all_fields=False) + text, entities, language, + all_fields=False) def test_analyze_with_empty_text(self): language = "en" text = "" entities = ["CREDIT_CARD", "PHONE_NUMBER"] results = self.loaded_analyzer_engine.analyze(self.unit_test_guid, - text, entities, language, all_fields=False) + text, entities, language, + all_fields=False) assert len(results) == 0 @@ -151,28 +160,50 @@ def test_analyze_with_unsupported_language(self): text = "" entities = ["CREDIT_CARD", "PHONE_NUMBER"] self.loaded_analyzer_engine.analyze(self.unit_test_guid, - text, entities, language, all_fields=False) + text, entities, language, + all_fields=False) def test_remove_duplicates(self): # test same result with different score will return only the highest arr = [RecognizerResult(start=0, end=5, score=0.1, entity_type="x", - analysis_explanation=AnalysisExplanation(recognizer='test', - original_score=0, - pattern_name='test', - pattern='test', - validation_result=None)), + analysis_explanation=AnalysisExplanation( + recognizer='test', + original_score=0, + pattern_name='test', + pattern='test', + validation_result=None)), RecognizerResult(start=0, end=5, score=0.5, entity_type="x", - analysis_explanation=AnalysisExplanation(recognizer='test', - original_score=0, - pattern_name='test', - pattern='test', - validation_result=None))] + analysis_explanation=AnalysisExplanation( + recognizer='test', + original_score=0, + pattern_name='test', + pattern='test', + validation_result=None))] results = AnalyzerEngine._AnalyzerEngine__remove_duplicates(arr) assert len(results) == 1 assert results[0].score == 0.5 # TODO: add more cases with bug: # bug# 597: Analyzer remove duplicates doesn't handle all cases of one result as a substring of the other + def test_remove_duplicates_different_entity_no_removal(self): + # test same result with different score will return only the highest + arr = [RecognizerResult(start=0, end=5, score=0.1, entity_type="x", + analysis_explanation=AnalysisExplanation( + recognizer='test', + original_score=0, + pattern_name='test', + pattern='test', + validation_result=None)), + RecognizerResult(start=0, end=5, score=0.5, entity_type="y", + analysis_explanation=AnalysisExplanation( + recognizer='test', + original_score=0, + pattern_name='test', + pattern='test', + validation_result=None))] + results = AnalyzerEngine._AnalyzerEngine__remove_duplicates(arr) + assert len(results) == 2 + def test_added_pattern_recognizer_works(self): pattern = Pattern("rocket pattern", r'\W*(rocket)\W*', 0.8) pattern_recognizer = PatternRecognizer("ROCKET", @@ -182,11 +213,14 @@ def test_added_pattern_recognizer_works(self): # Make sure the analyzer doesn't get this entity recognizers_store_api_mock = RecognizerStoreApiMock() analyze_engine = AnalyzerEngine(registry= - MockRecognizerRegistry(recognizers_store_api_mock), nlp_engine=SpacyNlpEngine()) + MockRecognizerRegistry( + recognizers_store_api_mock), + nlp_engine=MockNlpEngine()) text = "rocket is my favorite transportation" entities = ["CREDIT_CARD", "ROCKET"] - results = analyze_engine.analyze(self.unit_test_guid, text=text, entities=entities, + results = analyze_engine.analyze(self.unit_test_guid, text=text, + entities=entities, language='en', all_fields=False) assert len(results) == 0 @@ -196,7 +230,8 @@ def test_added_pattern_recognizer_works(self): pattern_recognizer) # Check that the entity is recognized: - results = analyze_engine.analyze(self.unit_test_guid, text=text, entities=entities, + results = analyze_engine.analyze(self.unit_test_guid, text=text, + entities=entities, language='en', all_fields=False) assert len(results) == 1 @@ -210,12 +245,13 @@ def test_removed_pattern_recognizer_doesnt_work(self): # Make sure the analyzer doesn't get this entity recognizers_store_api_mock = RecognizerStoreApiMock() - analyze_engine = AnalyzerEngine(registry= MockRecognizerRegistry( - recognizers_store_api_mock), nlp_engine=SpacyNlpEngine()) + analyze_engine = AnalyzerEngine(registry=MockRecognizerRegistry( + recognizers_store_api_mock), nlp_engine=MockNlpEngine()) text = "spaceship is my favorite transportation" entities = ["CREDIT_CARD", "SPACESHIP"] - results = analyze_engine.analyze(self.unit_test_guid, text=text, entities=entities, + results = analyze_engine.analyze(self.unit_test_guid, text=text, + entities=entities, language='en', all_fields=False) assert len(results) == 0 @@ -224,7 +260,8 @@ def test_removed_pattern_recognizer_doesnt_work(self): recognizers_store_api_mock.add_custom_pattern_recognizer( pattern_recognizer) # Check that the entity is recognized: - results = analyze_engine.analyze(self.unit_test_guid, text=text, entities=entities, + results = analyze_engine.analyze(self.unit_test_guid, text=text, + entities=entities, language='en', all_fields=False) assert len(results) == 1 assert_result(results[0], "SPACESHIP", 0, 10, 0.8) @@ -233,7 +270,8 @@ def test_removed_pattern_recognizer_doesnt_work(self): recognizers_store_api_mock.remove_recognizer( "Spaceship recognizer") # Test again to see we didn't get any results - results = analyze_engine.analyze(self.unit_test_guid, text=text, entities=entities, + results = analyze_engine.analyze(self.unit_test_guid, text=text, + entities=entities, language='en', all_fields=False) assert len(results) == 0 @@ -241,6 +279,7 @@ def test_removed_pattern_recognizer_doesnt_work(self): def test_apply_with_language_returns_correct_response(self): request = AnalyzeRequest() request.analyzeTemplate.language = 'en' + request.analyzeTemplate.resultsScoreThreshold = 0 new_field = request.analyzeTemplate.fields.add() new_field.name = 'CREDIT_CARD' new_field.minScore = '0.5' @@ -252,6 +291,7 @@ def test_apply_with_language_returns_correct_response(self): def test_apply_with_no_language_returns_default(self): request = AnalyzeRequest() request.analyzeTemplate.language = '' + request.analyzeTemplate.resultsScoreThreshold = 0 new_field = request.analyzeTemplate.fields.add() new_field.name = 'CREDIT_CARD' new_field.minScore = '0.5' @@ -260,11 +300,13 @@ def test_apply_with_no_language_returns_default(self): assert response.analyzeResults is not None def test_when_allFields_is_true_return_all_fields(self): - analyze_engine = AnalyzerEngine(registry=MockRecognizerRegistry(), nlp_engine=SpacyNlpEngine()) + analyze_engine = AnalyzerEngine(registry=MockRecognizerRegistry(), + nlp_engine=MockNlpEngine()) request = AnalyzeRequest() request.analyzeTemplate.allFields = True + request.analyzeTemplate.resultsScoreThreshold = 0 request.text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090 " \ - "Domain: microsoft.com" + "Domain: microsoft.com" response = analyze_engine.Apply(request, None) returned_entities = [ field.field.name for field in response.analyzeResults] @@ -274,12 +316,14 @@ def test_when_allFields_is_true_return_all_fields(self): assert "PHONE_NUMBER" in returned_entities assert "DOMAIN_NAME" in returned_entities - def test_when_allFields_is_true_full_recognizers_list_return_all_fields(self): - analyze_engine = AnalyzerEngine(registry=RecognizerRegistry(), nlp_engine=SpacyNlpEngine()) + def test_when_allFields_is_true_full_recognizers_list_return_all_fields( + self): + analyze_engine = AnalyzerEngine(registry=RecognizerRegistry(), + nlp_engine=loaded_spacy_nlp_engine) request = AnalyzeRequest() request.analyzeTemplate.allFields = True request.text = "My name is David and I live in Seattle." \ - "Domain: microsoft.com " + "Domain: microsoft.com " response = analyze_engine.Apply(request, None) returned_entities = [ field.field.name for field in response.analyzeResults] @@ -289,7 +333,8 @@ def test_when_allFields_is_true_full_recognizers_list_return_all_fields(self): assert "DOMAIN_NAME" in returned_entities def test_when_allFields_is_true_and_entities_not_empty_exception(self): - analyze_engine = AnalyzerEngine(registry=RecognizerRegistry(), nlp_engine=SpacyNlpEngine()) + analyze_engine = AnalyzerEngine(registry=RecognizerRegistry(), + nlp_engine=MockNlpEngine()) request = AnalyzeRequest() request.text = "My name is David and I live in Seattle." \ "Domain: microsoft.com " @@ -304,10 +349,81 @@ def test_when_analyze_then_apptracer_has_value(self): text = "My name is Bart Simpson, and Credit card: 4095-2609-9393-4932, my phone is 425 8829090" language = "en" entities = ["CREDIT_CARD", "PHONE_NUMBER", "PERSON"] - analyzer_engine_with_spacy = AnalyzerEngine(self.loaded_registry, app_tracer=self.app_tracer, enable_trace_pii=True) - results = analyzer_engine_with_spacy.analyze(self.unit_test_guid, text, entities, language, all_fields=False) + analyzer_engine_with_spacy = AnalyzerEngine(self.loaded_registry, + app_tracer=self.app_tracer, + enable_trace_pii=True) + results = analyzer_engine_with_spacy.analyze(self.unit_test_guid, text, + entities, language, + all_fields=False) assert len(results) == 3 for result in results: assert result.analysis_explanation is not None assert self.app_tracer.get_msg_counter() == 2 assert self.app_tracer.get_last_trace() is not None + + def test_when_threshold_is_zero_all_results_pass(self): + text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090" + language = "en" + entities = ["CREDIT_CARD", "PHONE_NUMBER"] + + # This analyzer engine is different from the global one, as this one + # also loads SpaCy so it can detect the phone number entity + + analyzer_engine = AnalyzerEngine( + registry=self.loaded_registry, nlp_engine=MockNlpEngine()) + results = analyzer_engine.analyze(self.unit_test_guid, text, + entities, language, + all_fields=False, + score_threshold=0) + + assert len(results) == 2 + + def test_when_threshold_is_more_than_half_only_credit_card_passes(self): + text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090" + language = "en" + entities = ["CREDIT_CARD", "PHONE_NUMBER"] + + # This analyzer engine is different from the global one, as this one + # also loads SpaCy so it can detect the phone number entity + + analyzer_engine = AnalyzerEngine( + registry=self.loaded_registry, nlp_engine=MockNlpEngine()) + results = analyzer_engine.analyze(self.unit_test_guid, text, + entities, language, + all_fields=False, + score_threshold=0.51) + + assert len(results) == 1 + + def test_when_default_threshold_is_more_than_half_only_one_passes(self): + text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090" + language = "en" + entities = ["CREDIT_CARD", "PHONE_NUMBER"] + + # This analyzer engine is different from the global one, as this one + # also loads SpaCy so it can detect the phone number entity + + analyzer_engine = AnalyzerEngine( + registry=self.loaded_registry, nlp_engine=MockNlpEngine(), + default_score_threshold=0.7) + results = analyzer_engine.analyze(self.unit_test_guid, text, + entities, language, + all_fields=False) + + assert len(results) == 1 + + def test_when_default_threshold_is_zero_all_results_pass(self): + text = " Credit card: 4095-2609-9393-4932, my phone is 425 8829090" + language = "en" + entities = ["CREDIT_CARD", "PHONE_NUMBER"] + + # This analyzer engine is different from the global one, as this one + # also loads SpaCy so it can detect the phone number entity + + analyzer_engine = AnalyzerEngine( + registry=self.loaded_registry, nlp_engine=MockNlpEngine()) + results = analyzer_engine.analyze(self.unit_test_guid, text, + entities, language, + all_fields=False) + + assert len(results) == 2 \ No newline at end of file From 8578f9543fcc10b22cda435094455cf3c69a619a Mon Sep 17 00:00:00 2001 From: Omri Mendels Date: Sat, 24 Aug 2019 18:53:55 +0300 Subject: [PATCH 62/75] code + documentation for a standalone Presidio Analyzer installation using Wheel (#200) * code + documentation for creating a whl file * added comment about lazy download of Spacy model --- docs/install.md | 83 +++++++++++++++++++ presidio-analyzer/VERSION | 1 + presidio-analyzer/analyzer/analyzer_engine.py | 21 +++-- .../analyzer/nlp_engine/spacy_nlp_engine.py | 4 + presidio-analyzer/setup.py | 25 ++++-- .../tests/test_analyzer_engine.py | 9 +- 6 files changed, 125 insertions(+), 18 deletions(-) create mode 100644 presidio-analyzer/VERSION diff --git a/docs/install.md b/docs/install.md index 6a4736e2d..df7a6b82f 100644 --- a/docs/install.md +++ b/docs/install.md @@ -66,3 +66,86 @@ Follow the installation guide at the [Readme page](https://github.com/Microsoft/ ``` 6. For more options over the deployment, follow the [Development guide](https://github.com/Microsoft/presidio/blob/master/docs/development.md) + +## Install presidio-analyzer as a Python package +If you're interested in running the analyzer alone, you can install it as a standalone python package by packaging it into a `wheel` file. + +#### Creating the wheel file: +In the presidio-analyzer folder, run: + +```sh +python setup.py bdist_wheel +``` + +#### Installing the wheel file +1. Copy the created wheel file (from the `dist` folder of presidio-analyzer) into a clean virtual environment + +2. install `wheel` package + +```sh +pip install wheel +``` + +2. Install the presidio-analyzer wheel file + +```sh +pip install WHEEL_FILE +``` + +Where `WHEEL_FILE` is the path to the created wheel file + +3. Install the Spacy model from Github (not installed during the standard installation) + +```sh +pip install https://github.com/explosion/spacy-models/releases/download/en_core_web_lg-2.1.0/en_core_web_lg-2.1.0.tar.gz +``` + +Note that if you skip this step, the Spacy model would install lazily during the first call to the `AnalyzerEngine` + + +4. *Optional* : install `re2` and `pyre2`: + +- Install [re2](https://github.com/google/re2): + + ```sh + re2_version="2018-12-01" + wget -O re2.tar.gz https://github.com/google/re2/archive/${re2_version}.tar.gz + mkdir re2 + tar --extract --file "re2.tar.gz" --directory "re2" --strip-components 1 + cd re2 && make install + ``` + +- Install `pyre2`'s fork: + + ``` + pip install https://github.com/torosent/pyre2/archive/release/0.2.23.zip + ``` + + Note: If you don't install `re2`, Presidio will use the `regex` package for regular expressions handling + +5. Test the installation + + To test, run Python on the virtual env you've installed the presidio-analyzer in. + Then, make sure this code returns an answer: + + ```python + from analyzer import AnalyzerEngine + + engine = AnalyzerEngine() + + text = "My name is David and I live in Miami" + + response = engine.analyze(correlation_id=0, + text = text, + entities=[], + language='en', + all_fields=True, + score_threshold=0.5) + + for item in response: + print("Start = {}, end = {}, entity = {}, confidence = {}".format(item.start, + item.end, + item.entity_type, + item.score)) + + ``` diff --git a/presidio-analyzer/VERSION b/presidio-analyzer/VERSION new file mode 100644 index 000000000..8a9ecc2ea --- /dev/null +++ b/presidio-analyzer/VERSION @@ -0,0 +1 @@ +0.0.1 \ No newline at end of file diff --git a/presidio-analyzer/analyzer/analyzer_engine.py b/presidio-analyzer/analyzer/analyzer_engine.py index f7ff270f9..7157bf14c 100644 --- a/presidio-analyzer/analyzer/analyzer_engine.py +++ b/presidio-analyzer/analyzer/analyzer_engine.py @@ -75,10 +75,13 @@ def Apply(self, request, context): # correlation is used to group all traces related to on request correlation_id = str(uuid.uuid4()) - results = self.analyze(correlation_id, request.text, - entities, language, - all_fields, - threshold) + results = self.analyze(correlation_id=correlation_id, + text=request.text, + entities=entities, + language=language, + all_fields=all_fields, + score_threshold=threshold, + trace=True) # Create Analyze Response Object response = analyze_pb2.AnalyzeResponse() @@ -150,7 +153,7 @@ def get_language_from_request(cls, request): return language def analyze(self, correlation_id, text, entities, language, all_fields, - score_threshold=None): + score_threshold=None, trace=False): """ analyzes the requested text, searching for the given entities in the given language @@ -162,6 +165,7 @@ def analyze(self, correlation_id, text, entities, language, all_fields, of the requested language :param score_threshold: A minimum value for which to return an identified entity + :param trace: Should tracing of the response occur or not :return: an array of the found entities in the text """ @@ -185,7 +189,7 @@ def analyze(self, correlation_id, text, entities, language, all_fields, # a NlpArtifacts instance nlp_artifacts = self.nlp_engine.process_text(text, language) - if self.enable_trace_pii: + if self.enable_trace_pii and trace: self.app_tracer.trace(correlation_id, "nlp artifacts:" + nlp_artifacts.to_json()) @@ -201,8 +205,9 @@ def analyze(self, correlation_id, text, entities, language, all_fields, if current_results: results.extend(current_results) - self.app_tracer.trace(correlation_id, json.dumps( - [result.to_json() for result in results])) + if trace: + self.app_tracer.trace(correlation_id, json.dumps( + [result.to_json() for result in results])) # Remove duplicates or low score results results = AnalyzerEngine.__remove_duplicates(results) diff --git a/presidio-analyzer/analyzer/nlp_engine/spacy_nlp_engine.py b/presidio-analyzer/analyzer/nlp_engine/spacy_nlp_engine.py index 0c968fd7b..f30685a1b 100644 --- a/presidio-analyzer/analyzer/nlp_engine/spacy_nlp_engine.py +++ b/presidio-analyzer/analyzer/nlp_engine/spacy_nlp_engine.py @@ -1,4 +1,5 @@ import spacy +from spacy.cli import download from analyzer.logger import Logger from analyzer.nlp_engine import NlpArtifacts, NlpEngine @@ -14,6 +15,9 @@ class SpacyNlpEngine(NlpEngine): def __init__(self): logger.info("Loading NLP model...") + + # Download model lazily if it wasn't previously installed + download('en_core_web_lg') self.nlp = {"en": spacy.load("en_core_web_lg", disable=['parser', 'tagger'])} diff --git a/presidio-analyzer/setup.py b/presidio-analyzer/setup.py index b4a917232..670d4d66b 100644 --- a/presidio-analyzer/setup.py +++ b/presidio-analyzer/setup.py @@ -1,22 +1,33 @@ import setuptools import os.path +from os import path + +__version__ = "" +this_directory = path.abspath(path.dirname(__file__)) +with open(os.path.join(this_directory, 'VERSION')) as version_file: + __version__ = version_file.read().strip() setuptools.setup( name="presidio_analyzer", - version="0.1.0", - author="Presidio team", - author_email="torosent@microsoft.com", + version=__version__, description="Presidio analyzer package", # long_description=long_description, # long_description_content_type="text/markdown", url="https://github.com/Microsoft/presidio", packages=[ - 'analyzer', 'analyzer.predefined_recognizers' + 'analyzer', 'analyzer.predefined_recognizers', 'analyzer.nlp_engine', + 'analyzer.recognizer_registry' ], + trusted_host=['pypi.org'], + tests_require=['pytest', 'flake8', 'pylint==2.3.1'], install_requires=[ - 'grpcio>=1.13.0', 'cython>=0.28.5', 'protobuf>=3.6.0', - 'tldextract>=2.2.0', 'knack>=0.4.2', 'spacy>=2.1.3' - ], + 'cython==0.29.10', + 'spacy==2.1.4', + 'regex==2019.6.8', + 'grpcio==1.21.1', + 'protobuf==3.8.0', + 'tldextract==2.2.1', + 'knack==0.6.2'], include_package_data=True, license='MIT', scripts=[ diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index 4886b963e..5cbca3993 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -352,9 +352,12 @@ def test_when_analyze_then_apptracer_has_value(self): analyzer_engine_with_spacy = AnalyzerEngine(self.loaded_registry, app_tracer=self.app_tracer, enable_trace_pii=True) - results = analyzer_engine_with_spacy.analyze(self.unit_test_guid, text, - entities, language, - all_fields=False) + results = analyzer_engine_with_spacy.analyze(correlation_id=self.unit_test_guid, + text=text, + entities=entities, + language=language, + all_fields=False, + trace=True) assert len(results) == 3 for result in results: assert result.analysis_explanation is not None From cbcfe5ff5319e9688b1356aa41d7ac38c9aa96c5 Mon Sep 17 00:00:00 2001 From: Omri Mendels Date: Sat, 24 Aug 2019 19:53:12 +0300 Subject: [PATCH 63/75] Added information on updating genproto (#199) * Added information on updating genproto --- docs/development.md | 58 ++++++++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 17 deletions(-) diff --git a/docs/development.md b/docs/development.md index 1fd2c786f..7adf1d63c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -22,25 +22,9 @@ 5. Install [tesseract](https://github.com/tesseract-ocr/tesseract/wiki) OCR framework. -6. Protobuf generator tools (Optional) - - - `https://github.com/golang/protobuf` - - - `https://grpc.io/docs/tutorials/basic/python.html` - - To generate proto files, clone [presidio-genproto](https://github.com/Microsoft/presidio-genproto) and run the following commands in `$GOPATH/src/github.com/Microsoft/presidio-genproto/src` folder - - ```sh - python -m grpc_tools.protoc -I . --python_out=../python --grpc_python_out=../python ./*.proto - ``` - - ```sh - protoc -I . --go_out=plugins=grpc:../golang ./*.proto - ``` - ## Setting up the environment - Python -1. Build and install [re2](https://github.com/google/re2) +1. Build and install [re2](https://github.com/google/re2) (Optional. Presidio will use `regex` instead of `pyre2` if `re2` is not installed) ```sh re2_version="2018-12-01" @@ -94,6 +78,46 @@ Install the Python packages for the analyzer in the `presidio-analyzer` folder, pylint analyzer pip freeze ``` + +## Changing Presidio's API +Presidio leverages [protobuf](https://github.com/golang/protobuf) to create API classes and services across multiple environments. The proto files are stored on a different [Github repo](https://github.com/Microsoft/presidio-genproto) + +Follow these steps to change Presidio's API: +1. Fork the [presidio-genproto](https://github.com/Microsoft/presidio-genproto) repo into `YOUR_ORG/presidio-genproto` +2. Clone the repo into the `$GOPATH/src/github.com/YOUR_ORG/presidio-genproto` folder +3. Make the desired changes to the .proto files in /src +4. Make sure you have [protobuf](https://github.com/golang/protobuf) installed +5. Generate the Go and Python files. Run the following commands in the `src` folder of `presidio-genproto`: + + ```sh + python -m grpc_tools.protoc -I . --python_out=../python --grpc_python_out=../python ./*.proto + + protoc -I . --go_out=plugins=grpc:../golang ./*.proto + ``` + + 5. Copy all the files in the `python` folder into `presidio-analyzer/analyzer`. All generated files end with `*pb2.py` or `*pb2_grpc.py` + 6. Change the constraint on `Gopkg.toml` which directs to the location of `presidio-genproto` +From: + +```yaml +[[constraint]] + branch = "master" + name = "github.com/Microsoft/presidio-genproto" +``` + +To: + +```yaml +[[constraint]] + branch = "YOUR_GENPROTO_BRANCH" + name = "github.com/YOUR_ORG/presidio-genproto" + +``` + 7. Update `Gopkg.lock` by calling `dep ensure` or `dep ensure --update github.com/YOUR_ORG/presidio-genproto` + 8. Push all the changes (generated python files, `Gopkg.toml` and `Gopkg.lock` into your presidio repo + +For more info, see https://grpc.io/docs/tutorials/basic/python.html + ## Development notes From aedb5c881bcb43ca1a28ff88bff249ee50ade1d5 Mon Sep 17 00:00:00 2001 From: Louis-Michel Couture Date: Fri, 30 Aug 2019 13:44:07 -0400 Subject: [PATCH 64/75] Fix typo in readme (#202) --- README.MD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.MD b/README.MD index afd02d133..a1df5116a 100644 --- a/README.MD +++ b/README.MD @@ -26,7 +26,7 @@ Presidio can be integrated into any data pipeline for intelligent PII scrubbing. ## Features -***Unstsructured text anonymization*** +***Unstructured text anonymization*** Presidio automatically detects Personal-Identifiable Information (PII) in unstructured text, annonymizes it based on one or more anonymization mechanisms, and returns a string with no personal identifiable data. For example: From aaa219e6d68d6eceda8d6a2dc44b1395ff72da06 Mon Sep 17 00:00:00 2001 From: omri374 Date: Tue, 3 Sep 2019 15:03:44 +0300 Subject: [PATCH 65/75] more merge fixes --- .../templates/analyzer-deployment.yaml | 4 +- presidio-analyzer/analyzer/template_pb2.py | 403 +++++++++++++++--- 2 files changed, 349 insertions(+), 58 deletions(-) diff --git a/charts/presidio/templates/analyzer-deployment.yaml b/charts/presidio/templates/analyzer-deployment.yaml index 47d7dc3c9..5448f8d59 100644 --- a/charts/presidio/templates/analyzer-deployment.yaml +++ b/charts/presidio/templates/analyzer-deployment.yaml @@ -26,10 +26,10 @@ spec: - containerPort: {{ .Values.analyzer.service.internalPort }} resources: requests: - memory: "2000Mi" + memory: "1500Mi" cpu: "1500m" limits: - memory: "5000Mi" + memory: "3000Mi" cpu: "2000m" env: - name: PRESIDIO_NAMESPACE diff --git a/presidio-analyzer/analyzer/template_pb2.py b/presidio-analyzer/analyzer/template_pb2.py index a40a2febe..4d726d78b 100644 --- a/presidio-analyzer/analyzer/template_pb2.py +++ b/presidio-analyzer/analyzer/template_pb2.py @@ -20,7 +20,7 @@ name='template.proto', package='types', syntax='proto3', - serialized_pb=_b('\n\x0etemplate.proto\x12\x05types\x1a\x0c\x63ommon.proto\"s\n\x0f\x41nalyzeTemplate\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x12\n\ncreateTime\x18\x03 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x04 \x01(\t\"\x94\x01\n\x11\x41nonymizeTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12@\n\x18\x66ieldTypeTransformations\x18\x04 \x03(\x0b\x32\x1e.types.FieldTypeTransformation\"k\n\x17\x46ieldTypeTransformation\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12-\n\x0etransformation\x18\x02 \x01(\x0b\x32\x15.types.Transformation\"\xd1\x01\n\x0eTransformation\x12)\n\x0creplaceValue\x18\x02 \x01(\x0b\x32\x13.types.ReplaceValue\x12\'\n\x0bredactValue\x18\x03 \x01(\x0b\x32\x12.types.RedactValue\x12#\n\thashValue\x18\x04 \x01(\x0b\x32\x10.types.HashValue\x12#\n\tmaskValue\x18\x05 \x01(\x0b\x32\x10.types.MaskValue\x12!\n\x08\x66PEValue\x18\x06 \x01(\x0b\x32\x0f.types.FPEValue\" \n\x0cReplaceValue\x12\x10\n\x08newValue\x18\x01 \x01(\t\"\r\n\x0bRedactValue\"\x0b\n\tHashValue\"K\n\tMaskValue\x12\x18\n\x10maskingCharacter\x18\x01 \x01(\t\x12\x13\n\x0b\x63harsToMask\x18\x02 \x01(\x05\x12\x0f\n\x07\x66romEnd\x18\x03 \x01(\x08\"7\n\x08\x46PEValue\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05tweak\x18\x02 \x01(\t\x12\x0f\n\x07\x64\x65\x63rypt\x18\x03 \x01(\x08\"E\n\x08\x44\x42\x43onfig\x12\x18\n\x10\x63onnectionString\x18\x01 \x01(\t\x12\x11\n\ttableName\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\"\x8f\x01\n\x08\x44\x61tasink\x12!\n\x08\x64\x62\x43onfig\x18\x01 \x01(\x0b\x32\x0f.types.DBConfig\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\"}\n\x10\x44\x61tasinkTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12(\n\x0f\x61nalyzeDatasink\x18\x02 \x03(\x0b\x32\x0f.types.Datasink\x12*\n\x11\x61nonymizeDatasink\x18\x03 \x03(\x0b\x32\x0f.types.Datasink\"S\n\x11\x42lobStorageConfig\x12\x13\n\x0b\x61\x63\x63ountName\x18\x01 \x01(\t\x12\x12\n\naccountKey\x18\x02 \x01(\t\x12\x15\n\rcontainerName\x18\x03 \x01(\t\"e\n\x08S3Config\x12\x10\n\x08\x61\x63\x63\x65ssId\x18\x01 \x01(\t\x12\x11\n\taccessKey\x18\x02 \x01(\t\x12\x0e\n\x06region\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\x12\x10\n\x08\x65ndpoint\x18\x05 \x01(\t\"Z\n\x13GoogleStorageConfig\x12\x0c\n\x04json\x18\x01 \x01(\t\x12\x11\n\tprojectId\x18\x02 \x01(\t\x12\x0e\n\x06scopes\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\"\xa5\x01\n\x12\x43loudStorageConfig\x12\x33\n\x11\x62lobStorageConfig\x18\x01 \x01(\x0b\x32\x18.types.BlobStorageConfig\x12!\n\x08s3Config\x18\x02 \x01(\x0b\x32\x0f.types.S3Config\x12\x37\n\x13GoogleStorageConfig\x18\x03 \x01(\x0b\x32\x1a.types.GoogleStorageConfig\"r\n\x0cStreamConfig\x12\'\n\x0bkafkaConfig\x18\x01 \x01(\x0b\x32\x12.types.KafkaConfig\x12!\n\x08\x65hConfig\x18\x02 \x01(\x0b\x32\x0f.types.EHConfig\x12\x16\n\x0epartitionCount\x18\x03 \x01(\x05\"Y\n\x0bKafkaConfig\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x14\n\x0csaslUsername\x18\x03 \x01(\t\x12\x14\n\x0csaslPassword\x18\x04 \x01(\t\"\xcb\x01\n\x08\x45HConfig\x12\x13\n\x0b\x65hNamespace\x18\x01 \x01(\t\x12\x0e\n\x06\x65hName\x18\x02 \x01(\t\x12\x1a\n\x12\x65hConnectionString\x18\x03 \x01(\t\x12\x11\n\tehKeyName\x18\x04 \x01(\t\x12\x12\n\nehKeyValue\x18\x05 \x01(\t\x12\x1f\n\x17storageAccountNameValue\x18\x06 \x01(\t\x12\x1e\n\x16storageAccountKeyValue\x18\x07 \x01(\t\x12\x16\n\x0e\x63ontainerValue\x18\x08 \x01(\t\"\xb2\x01\n\x0eStreamTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\"Z\n\x0cScanTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\"\xc8\x01\n\x16ScannerCronJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x1f\n\x07trigger\x18\x03 \x01(\x0b\x32\x0e.types.Trigger\x12\x16\n\x0escanTemplateId\x18\x04 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x05 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x06 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x07 \x01(\t\"\xa6\x01\n\x12StreamsJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x19\n\x11streamsTemplateId\x18\x03 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\",\n\x07Trigger\x12!\n\x08schedule\x18\x01 \x01(\x0b\x32\x0f.types.Schedule\"$\n\x08Schedule\x12\x18\n\x10recurrencePeriod\x18\x01 \x01(\tb\x06proto3') + serialized_pb=_b('\n\x0etemplate.proto\x12\x05types\x1a\x0c\x63ommon.proto\"\xb7\x01\n\x0f\x41nalyzeTemplate\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12\x11\n\tallFields\x18\x02 \x01(\x08\x12\x13\n\x0b\x64\x65scription\x18\x03 \x01(\t\x12\x12\n\ncreateTime\x18\x04 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x05 \x01(\t\x12\x10\n\x08language\x18\x06 \x01(\t\x12\x1d\n\x15resultsScoreThreshold\x18\x07 \x01(\x02\"\xca\x01\n\x11\x41nonymizeTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12@\n\x18\x66ieldTypeTransformations\x18\x04 \x03(\x0b\x32\x1e.types.FieldTypeTransformation\x12\x34\n\x15\x64\x65\x66\x61ultTransformation\x18\x05 \x01(\x0b\x32\x15.types.Transformation\"g\n\x12JsonSchemaTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12\x12\n\njsonSchema\x18\x04 \x01(\t\"k\n\x17\x46ieldTypeTransformation\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12-\n\x0etransformation\x18\x02 \x01(\x0b\x32\x15.types.Transformation\"\xd1\x01\n\x0eTransformation\x12)\n\x0creplaceValue\x18\x02 \x01(\x0b\x32\x13.types.ReplaceValue\x12\'\n\x0bredactValue\x18\x03 \x01(\x0b\x32\x12.types.RedactValue\x12#\n\thashValue\x18\x04 \x01(\x0b\x32\x10.types.HashValue\x12#\n\tmaskValue\x18\x05 \x01(\x0b\x32\x10.types.MaskValue\x12!\n\x08\x66PEValue\x18\x06 \x01(\x0b\x32\x0f.types.FPEValue\" \n\x0cReplaceValue\x12\x10\n\x08newValue\x18\x01 \x01(\t\"\r\n\x0bRedactValue\"\x0b\n\tHashValue\"K\n\tMaskValue\x12\x18\n\x10maskingCharacter\x18\x01 \x01(\t\x12\x13\n\x0b\x63harsToMask\x18\x02 \x01(\x05\x12\x0f\n\x07\x66romEnd\x18\x03 \x01(\x08\"7\n\x08\x46PEValue\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05tweak\x18\x02 \x01(\t\x12\x0f\n\x07\x64\x65\x63rypt\x18\x03 \x01(\x08\"E\n\x08\x44\x42\x43onfig\x12\x18\n\x10\x63onnectionString\x18\x01 \x01(\t\x12\x11\n\ttableName\x18\x02 \x01(\t\x12\x0c\n\x04type\x18\x03 \x01(\t\"\x8f\x01\n\x08\x44\x61tasink\x12!\n\x08\x64\x62\x43onfig\x18\x01 \x01(\x0b\x32\x0f.types.DBConfig\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\"}\n\x10\x44\x61tasinkTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12(\n\x0f\x61nalyzeDatasink\x18\x02 \x03(\x0b\x32\x0f.types.Datasink\x12*\n\x11\x61nonymizeDatasink\x18\x03 \x03(\x0b\x32\x0f.types.Datasink\"S\n\x11\x42lobStorageConfig\x12\x13\n\x0b\x61\x63\x63ountName\x18\x01 \x01(\t\x12\x12\n\naccountKey\x18\x02 \x01(\t\x12\x15\n\rcontainerName\x18\x03 \x01(\t\"e\n\x08S3Config\x12\x10\n\x08\x61\x63\x63\x65ssId\x18\x01 \x01(\t\x12\x11\n\taccessKey\x18\x02 \x01(\t\x12\x0e\n\x06region\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\x12\x10\n\x08\x65ndpoint\x18\x05 \x01(\t\"Z\n\x13GoogleStorageConfig\x12\x0c\n\x04json\x18\x01 \x01(\t\x12\x11\n\tprojectId\x18\x02 \x01(\t\x12\x0e\n\x06scopes\x18\x03 \x01(\t\x12\x12\n\nbucketName\x18\x04 \x01(\t\"\xa5\x01\n\x12\x43loudStorageConfig\x12\x33\n\x11\x62lobStorageConfig\x18\x01 \x01(\x0b\x32\x18.types.BlobStorageConfig\x12!\n\x08s3Config\x18\x02 \x01(\x0b\x32\x0f.types.S3Config\x12\x37\n\x13GoogleStorageConfig\x18\x03 \x01(\x0b\x32\x1a.types.GoogleStorageConfig\"r\n\x0cStreamConfig\x12\'\n\x0bkafkaConfig\x18\x01 \x01(\x0b\x32\x12.types.KafkaConfig\x12!\n\x08\x65hConfig\x18\x02 \x01(\x0b\x32\x0f.types.EHConfig\x12\x16\n\x0epartitionCount\x18\x03 \x01(\x05\"Y\n\x0bKafkaConfig\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\r\n\x05topic\x18\x02 \x01(\t\x12\x14\n\x0csaslUsername\x18\x03 \x01(\t\x12\x14\n\x0csaslPassword\x18\x04 \x01(\t\"\xcb\x01\n\x08\x45HConfig\x12\x13\n\x0b\x65hNamespace\x18\x01 \x01(\t\x12\x0e\n\x06\x65hName\x18\x02 \x01(\t\x12\x1a\n\x12\x65hConnectionString\x18\x03 \x01(\t\x12\x11\n\tehKeyName\x18\x04 \x01(\t\x12\x12\n\nehKeyValue\x18\x05 \x01(\t\x12\x1f\n\x17storageAccountNameValue\x18\x06 \x01(\t\x12\x1e\n\x16storageAccountKeyValue\x18\x07 \x01(\t\x12\x16\n\x0e\x63ontainerValue\x18\x08 \x01(\t\"\xb2\x01\n\x0eStreamTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12)\n\x0cstreamConfig\x18\x03 \x01(\x0b\x32\x13.types.StreamConfig\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\"Z\n\x0cScanTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x35\n\x12\x63loudStorageConfig\x18\x02 \x01(\x0b\x32\x19.types.CloudStorageConfig\"\xc8\x01\n\x16ScannerCronJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x1f\n\x07trigger\x18\x03 \x01(\x0b\x32\x0e.types.Trigger\x12\x16\n\x0escanTemplateId\x18\x04 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x05 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x06 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x07 \x01(\t\"\xa6\x01\n\x12StreamsJobTemplate\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\x12\x19\n\x11streamsTemplateId\x18\x03 \x01(\t\x12\x19\n\x11\x61nalyzeTemplateId\x18\x04 \x01(\t\x12\x1b\n\x13\x61nonymizeTemplateId\x18\x05 \x01(\t\x12\x1a\n\x12\x64\x61tasinkTemplateId\x18\x06 \x01(\t\",\n\x07Trigger\x12!\n\x08schedule\x18\x01 \x01(\x0b\x32\x0f.types.Schedule\"$\n\x08Schedule\x12\x18\n\x10recurrencePeriod\x18\x01 \x01(\t\"\x8b\x01\n\x16\x41nonymizeImageTemplate\x12\x13\n\x0b\x64\x65scription\x18\x01 \x01(\t\x12\x12\n\ncreateTime\x18\x02 \x01(\t\x12\x14\n\x0cmodifiedTime\x18\x03 \x01(\t\x12\x32\n\x11\x66ieldTypeGraphics\x18\x04 \x03(\x0b\x32\x17.types.FieldTypeGraphic\"V\n\x10\x46ieldTypeGraphic\x12!\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x11.types.FieldTypes\x12\x1f\n\x07graphic\x18\x02 \x01(\x0b\x32\x0e.types.Graphic\"8\n\x07Graphic\x12-\n\x0e\x66illColorValue\x18\x01 \x01(\x0b\x32\x15.types.FillColorValue\":\n\x0e\x46illColorValue\x12\x0b\n\x03red\x18\x01 \x01(\x01\x12\r\n\x05green\x18\x02 \x01(\x01\x12\x0c\n\x04\x62lue\x18\x03 \x01(\x01\x62\x06proto3') , dependencies=[common__pb2.DESCRIPTOR,]) @@ -42,26 +42,47 @@ is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( - name='description', full_name='types.AnalyzeTemplate.description', index=1, - number=2, type=9, cpp_type=9, label=1, - has_default_value=False, default_value=_b("").decode('utf-8'), + name='allFields', full_name='types.AnalyzeTemplate.allFields', index=1, + number=2, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( - name='createTime', full_name='types.AnalyzeTemplate.createTime', index=2, + name='description', full_name='types.AnalyzeTemplate.description', index=2, number=3, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), _descriptor.FieldDescriptor( - name='modifiedTime', full_name='types.AnalyzeTemplate.modifiedTime', index=3, + name='createTime', full_name='types.AnalyzeTemplate.createTime', index=3, number=4, type=9, cpp_type=9, label=1, has_default_value=False, default_value=_b("").decode('utf-8'), message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='modifiedTime', full_name='types.AnalyzeTemplate.modifiedTime', index=4, + number=5, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='language', full_name='types.AnalyzeTemplate.language', index=5, + number=6, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='resultsScoreThreshold', full_name='types.AnalyzeTemplate.resultsScoreThreshold', index=6, + number=7, type=2, cpp_type=6, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -74,8 +95,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=39, - serialized_end=154, + serialized_start=40, + serialized_end=223, ) @@ -114,6 +135,13 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='defaultTransformation', full_name='types.AnonymizeTemplate.defaultTransformation', index=4, + number=5, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -126,8 +154,60 @@ extension_ranges=[], oneofs=[ ], - serialized_start=157, - serialized_end=305, + serialized_start=226, + serialized_end=428, +) + + +_JSONSCHEMATEMPLATE = _descriptor.Descriptor( + name='JsonSchemaTemplate', + full_name='types.JsonSchemaTemplate', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='description', full_name='types.JsonSchemaTemplate.description', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='createTime', full_name='types.JsonSchemaTemplate.createTime', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='modifiedTime', full_name='types.JsonSchemaTemplate.modifiedTime', index=2, + number=3, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='jsonSchema', full_name='types.JsonSchemaTemplate.jsonSchema', index=3, + number=4, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=430, + serialized_end=533, ) @@ -164,8 +244,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=307, - serialized_end=414, + serialized_start=535, + serialized_end=642, ) @@ -223,8 +303,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=417, - serialized_end=626, + serialized_start=645, + serialized_end=854, ) @@ -254,8 +334,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=628, - serialized_end=660, + serialized_start=856, + serialized_end=888, ) @@ -278,8 +358,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=662, - serialized_end=675, + serialized_start=890, + serialized_end=903, ) @@ -302,8 +382,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=677, - serialized_end=688, + serialized_start=905, + serialized_end=916, ) @@ -347,8 +427,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=690, - serialized_end=765, + serialized_start=918, + serialized_end=993, ) @@ -392,8 +472,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=767, - serialized_end=822, + serialized_start=995, + serialized_end=1050, ) @@ -437,8 +517,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=824, - serialized_end=893, + serialized_start=1052, + serialized_end=1121, ) @@ -482,8 +562,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=896, - serialized_end=1039, + serialized_start=1124, + serialized_end=1267, ) @@ -527,8 +607,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1041, - serialized_end=1166, + serialized_start=1269, + serialized_end=1394, ) @@ -572,8 +652,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1168, - serialized_end=1251, + serialized_start=1396, + serialized_end=1479, ) @@ -631,8 +711,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1253, - serialized_end=1354, + serialized_start=1481, + serialized_end=1582, ) @@ -683,8 +763,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1356, - serialized_end=1446, + serialized_start=1584, + serialized_end=1674, ) @@ -728,8 +808,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1449, - serialized_end=1614, + serialized_start=1677, + serialized_end=1842, ) @@ -773,8 +853,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1616, - serialized_end=1730, + serialized_start=1844, + serialized_end=1958, ) @@ -825,8 +905,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1732, - serialized_end=1821, + serialized_start=1960, + serialized_end=2049, ) @@ -905,8 +985,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1824, - serialized_end=2027, + serialized_start=2052, + serialized_end=2255, ) @@ -971,8 +1051,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2030, - serialized_end=2208, + serialized_start=2258, + serialized_end=2436, ) @@ -1009,8 +1089,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2210, - serialized_end=2300, + serialized_start=2438, + serialized_end=2528, ) @@ -1082,8 +1162,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2303, - serialized_end=2503, + serialized_start=2531, + serialized_end=2731, ) @@ -1148,8 +1228,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2506, - serialized_end=2672, + serialized_start=2734, + serialized_end=2900, ) @@ -1179,8 +1259,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2674, - serialized_end=2718, + serialized_start=2902, + serialized_end=2946, ) @@ -1210,12 +1290,179 @@ extension_ranges=[], oneofs=[ ], - serialized_start=2720, - serialized_end=2756, + serialized_start=2948, + serialized_end=2984, +) + + +_ANONYMIZEIMAGETEMPLATE = _descriptor.Descriptor( + name='AnonymizeImageTemplate', + full_name='types.AnonymizeImageTemplate', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='description', full_name='types.AnonymizeImageTemplate.description', index=0, + number=1, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='createTime', full_name='types.AnonymizeImageTemplate.createTime', index=1, + number=2, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='modifiedTime', full_name='types.AnonymizeImageTemplate.modifiedTime', index=2, + number=3, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='fieldTypeGraphics', full_name='types.AnonymizeImageTemplate.fieldTypeGraphics', index=3, + number=4, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=2987, + serialized_end=3126, +) + + +_FIELDTYPEGRAPHIC = _descriptor.Descriptor( + name='FieldTypeGraphic', + full_name='types.FieldTypeGraphic', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='fields', full_name='types.FieldTypeGraphic.fields', index=0, + number=1, type=11, cpp_type=10, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='graphic', full_name='types.FieldTypeGraphic.graphic', index=1, + number=2, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=3128, + serialized_end=3214, +) + + +_GRAPHIC = _descriptor.Descriptor( + name='Graphic', + full_name='types.Graphic', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='fillColorValue', full_name='types.Graphic.fillColorValue', index=0, + number=1, type=11, cpp_type=10, label=1, + has_default_value=False, default_value=None, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=3216, + serialized_end=3272, +) + + +_FILLCOLORVALUE = _descriptor.Descriptor( + name='FillColorValue', + full_name='types.FillColorValue', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='red', full_name='types.FillColorValue.red', index=0, + number=1, type=1, cpp_type=5, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='green', full_name='types.FillColorValue.green', index=1, + number=2, type=1, cpp_type=5, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='blue', full_name='types.FillColorValue.blue', index=2, + number=3, type=1, cpp_type=5, label=1, + has_default_value=False, default_value=float(0), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=3274, + serialized_end=3332, ) _ANALYZETEMPLATE.fields_by_name['fields'].message_type = common__pb2._FIELDTYPES _ANONYMIZETEMPLATE.fields_by_name['fieldTypeTransformations'].message_type = _FIELDTYPETRANSFORMATION +_ANONYMIZETEMPLATE.fields_by_name['defaultTransformation'].message_type = _TRANSFORMATION _FIELDTYPETRANSFORMATION.fields_by_name['fields'].message_type = common__pb2._FIELDTYPES _FIELDTYPETRANSFORMATION.fields_by_name['transformation'].message_type = _TRANSFORMATION _TRANSFORMATION.fields_by_name['replaceValue'].message_type = _REPLACEVALUE @@ -1237,8 +1484,13 @@ _SCANTEMPLATE.fields_by_name['cloudStorageConfig'].message_type = _CLOUDSTORAGECONFIG _SCANNERCRONJOBTEMPLATE.fields_by_name['trigger'].message_type = _TRIGGER _TRIGGER.fields_by_name['schedule'].message_type = _SCHEDULE +_ANONYMIZEIMAGETEMPLATE.fields_by_name['fieldTypeGraphics'].message_type = _FIELDTYPEGRAPHIC +_FIELDTYPEGRAPHIC.fields_by_name['fields'].message_type = common__pb2._FIELDTYPES +_FIELDTYPEGRAPHIC.fields_by_name['graphic'].message_type = _GRAPHIC +_GRAPHIC.fields_by_name['fillColorValue'].message_type = _FILLCOLORVALUE DESCRIPTOR.message_types_by_name['AnalyzeTemplate'] = _ANALYZETEMPLATE DESCRIPTOR.message_types_by_name['AnonymizeTemplate'] = _ANONYMIZETEMPLATE +DESCRIPTOR.message_types_by_name['JsonSchemaTemplate'] = _JSONSCHEMATEMPLATE DESCRIPTOR.message_types_by_name['FieldTypeTransformation'] = _FIELDTYPETRANSFORMATION DESCRIPTOR.message_types_by_name['Transformation'] = _TRANSFORMATION DESCRIPTOR.message_types_by_name['ReplaceValue'] = _REPLACEVALUE @@ -1262,6 +1514,10 @@ DESCRIPTOR.message_types_by_name['StreamsJobTemplate'] = _STREAMSJOBTEMPLATE DESCRIPTOR.message_types_by_name['Trigger'] = _TRIGGER DESCRIPTOR.message_types_by_name['Schedule'] = _SCHEDULE +DESCRIPTOR.message_types_by_name['AnonymizeImageTemplate'] = _ANONYMIZEIMAGETEMPLATE +DESCRIPTOR.message_types_by_name['FieldTypeGraphic'] = _FIELDTYPEGRAPHIC +DESCRIPTOR.message_types_by_name['Graphic'] = _GRAPHIC +DESCRIPTOR.message_types_by_name['FillColorValue'] = _FILLCOLORVALUE _sym_db.RegisterFileDescriptor(DESCRIPTOR) AnalyzeTemplate = _reflection.GeneratedProtocolMessageType('AnalyzeTemplate', (_message.Message,), dict( @@ -1278,6 +1534,13 @@ )) _sym_db.RegisterMessage(AnonymizeTemplate) +JsonSchemaTemplate = _reflection.GeneratedProtocolMessageType('JsonSchemaTemplate', (_message.Message,), dict( + DESCRIPTOR = _JSONSCHEMATEMPLATE, + __module__ = 'template_pb2' + # @@protoc_insertion_point(class_scope:types.JsonSchemaTemplate) + )) +_sym_db.RegisterMessage(JsonSchemaTemplate) + FieldTypeTransformation = _reflection.GeneratedProtocolMessageType('FieldTypeTransformation', (_message.Message,), dict( DESCRIPTOR = _FIELDTYPETRANSFORMATION, __module__ = 'template_pb2' @@ -1439,5 +1702,33 @@ )) _sym_db.RegisterMessage(Schedule) +AnonymizeImageTemplate = _reflection.GeneratedProtocolMessageType('AnonymizeImageTemplate', (_message.Message,), dict( + DESCRIPTOR = _ANONYMIZEIMAGETEMPLATE, + __module__ = 'template_pb2' + # @@protoc_insertion_point(class_scope:types.AnonymizeImageTemplate) + )) +_sym_db.RegisterMessage(AnonymizeImageTemplate) + +FieldTypeGraphic = _reflection.GeneratedProtocolMessageType('FieldTypeGraphic', (_message.Message,), dict( + DESCRIPTOR = _FIELDTYPEGRAPHIC, + __module__ = 'template_pb2' + # @@protoc_insertion_point(class_scope:types.FieldTypeGraphic) + )) +_sym_db.RegisterMessage(FieldTypeGraphic) + +Graphic = _reflection.GeneratedProtocolMessageType('Graphic', (_message.Message,), dict( + DESCRIPTOR = _GRAPHIC, + __module__ = 'template_pb2' + # @@protoc_insertion_point(class_scope:types.Graphic) + )) +_sym_db.RegisterMessage(Graphic) + +FillColorValue = _reflection.GeneratedProtocolMessageType('FillColorValue', (_message.Message,), dict( + DESCRIPTOR = _FILLCOLORVALUE, + __module__ = 'template_pb2' + # @@protoc_insertion_point(class_scope:types.FillColorValue) + )) +_sym_db.RegisterMessage(FillColorValue) + # @@protoc_insertion_point(module_scope) From 54e780f85ff23b03a7f1f303f55f810cab6a43c9 Mon Sep 17 00:00:00 2001 From: Elad Iwanir <13205761+eladiw@users.noreply.github.com> Date: Tue, 3 Sep 2019 15:24:02 +0300 Subject: [PATCH 66/75] reference presidio genproto master branch --- Gopkg.lock | 4 ++-- Gopkg.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gopkg.lock b/Gopkg.lock index 46602d8bb..168ef6faa 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -106,12 +106,12 @@ version = "v1.4.0" [[projects]] - branch = "development" + branch = "master" digest = "1:c79ff0c1cb49b28ad509306045dd8d5e46072354c0b4dbaff75434f3e630ce8d" name = "github.com/Microsoft/presidio-genproto" packages = ["golang"] pruneopts = "UT" - revision = "db15bf6b1589b71e88d3fab12188c42d5364789c" + revision = "1734e2635c253f79e4c44398315d92fe9d084601" [[projects]] digest = "1:2ec153af6a806c3d63d4299f2549bcb29d75d9703097341be309a46db3481488" diff --git a/Gopkg.toml b/Gopkg.toml index 61662a565..272f9c82e 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -52,7 +52,7 @@ name = "github.com/korovkin/limiter" [[constraint]] - branch = "development" + branch = "master" name = "github.com/Microsoft/presidio-genproto" [[constraint]] From 1f5c31e60aee8dc988ba7c3917b8e3a8d30813f2 Mon Sep 17 00:00:00 2001 From: omri374 Date: Sun, 8 Sep 2019 14:28:50 +0300 Subject: [PATCH 67/75] Removed very weak US_DRIVER_LICENSE regex, and removed unnecessary test that only checks how many driver license entities are on the presidio demo website (dependent on threshold) --- presidio-analyzer/Pipfile.lock | 625 ------------------ .../us_driver_license_recognizer.py | 7 +- presidio-analyzer/tests/data/demo.txt | 4 +- .../test_us_driver_license_recognizer.py | 11 - 4 files changed, 4 insertions(+), 643 deletions(-) delete mode 100644 presidio-analyzer/Pipfile.lock diff --git a/presidio-analyzer/Pipfile.lock b/presidio-analyzer/Pipfile.lock deleted file mode 100644 index 0eb5b069a..000000000 --- a/presidio-analyzer/Pipfile.lock +++ /dev/null @@ -1,625 +0,0 @@ -{ - "_meta": { - "hash": { - "sha256": "be7df2b6a129090a66e0049544cdeae3425b032a6333d2d2991aa8e0e26725d2" - }, - "pipfile-spec": 6, - "requires": {}, - "sources": [ - { - "name": "pypi", - "url": "https://pypi.python.org/simple", - "verify_ssl": true - } - ] - }, - "default": { - "argcomplete": { - "hashes": [ - "sha256:59909d0ce5be1a46e2fb4e4fa5b714f6d605151ce88c468afb42d800879a6e6d", - "sha256:94423d1a56cdec2ef47699e02c9a48cf8827b9c4465b836c0cefb30afe85e59a" - ], - "version": "==1.9.5" - }, - "blis": { - "hashes": [ - "sha256:039129410a338be8db8cf48c54334bd7c30da7e72bad2741e59313b1d242814b", - "sha256:058f9109aaea9d4f88cb623a44994d96c8cf36448de3e1bd30210628d6b52e9e", - "sha256:278d7b95e56cf82a6bef91cd8283eadc9401f2d3bdbbf2cdfdb605cf9081c36e", - "sha256:2d4ca1508fd6229c7994fc17ba324083a5b83f66612c8ea62623a41a1768b030", - "sha256:51a54bad6175e9b154beeb628a879ed492ee2247c9e40c77bdf6fc772145130c", - "sha256:886b313f96d4e268a0587e98c1637d963c73defa8de51e2e6b0d0bd00f16afbb", - "sha256:9f12e6f1e4b10dbb1e0e34e98f60e8435058a60d544a009cb761351fe1d12cad", - "sha256:a54d4fa1908d586f8bce9851a453cb89d1542e9aca65b8b88e9bb9432d626f80", - "sha256:b9d6cef13d95e3752320cd942df25e09160a6f9dfc3d7b41af7cdc772ab18270", - "sha256:d571464d195a950e60bf1547c8914d4da50952e06a0f38cea7b0829d0a4b985a", - "sha256:d616d64c85e6be92d69a1410dc58146cb9603fd1eb148f9ee512b8fddfd789f6", - "sha256:e477c7eaacf7dcccbb190a29559579efb287ecf5c2a9a7a6f9acb0452899f033", - "sha256:e6ae1986625af86f90f111f9d2d284b9e45fddfe56cf40524cdd9417a6a33b87" - ], - "version": "==0.2.4" - }, - "certifi": { - "hashes": [ - "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5", - "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae" - ], - "version": "==2019.3.9" - }, - "chardet": { - "hashes": [ - "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", - "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" - ], - "version": "==3.0.4" - }, - "colorama": { - "hashes": [ - "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", - "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" - ], - "version": "==0.4.1" - }, - "cymem": { - "hashes": [ - "sha256:081c652ae1aff4759813e93a2fc4df4ba410ce214a0e542988e24c62110d4cd0", - "sha256:0e447fa4cb6dccd0b96257a798370a17bef3ec254a527230058e41816a777c04", - "sha256:2c8267dcb15cc6ab318f01ceaf16b8440c0386ae44014d5b22fefe5b0398d05c", - "sha256:46141111eedbb5b0d8c9386b00226a15f5727a1202b9095f4363d425f259267e", - "sha256:4994c1f3e948bd58a6e38c905221680563b851983a15f1f01e5ff415d560d153", - "sha256:584872fd3df176e50c90e37aaca6cb731ac0abcdea4f5b8ad77c30674cfaaa99", - "sha256:6e3194135b21bb268030f3473beb8b674b356c330a9fa185dced2f5006cbd5ba", - "sha256:71710ee0e946a6bd33c86dd9e71f95ad584c65e8bb02615f00ceb0d8348fb303", - "sha256:741957f541fb8322de5a8c711d5d58f80d684225d2aec32fec92484cac931a52", - "sha256:7f01ba6153427811cd7d35630081c69b32c188a1d330599a826ef3bf17edbd7c", - "sha256:8d96e95902e781950d7c255b19364a1ed50a204843d63dd386b0abc5e6df5e44", - "sha256:8dd169ece1629ec4db1a592321e3ae0a9bb62fda2052a351fc36871f314c3569", - "sha256:8e6ad29636edd559b0dfe0a19c5cb5e6257461a5df90839e8c7710ddb005f4b4", - "sha256:9935b233882732f03fd0fadbeb9e9aa672edcdd126e6d52c36d60adf1def8ea5", - "sha256:a38b3229782411e4b23240f5f90000c4e7a834af88ed8763c66f8e4603db6b51", - "sha256:a5966b3171bad9c84a2b19dccda5ab37ae8437c0709a6b72cb42b64ea76a4bd3", - "sha256:ab88b1534f06df07262d9bc5efb3ba07948cdbe9a363eb9eaa4ad42fae6c7b5e", - "sha256:b08b0dd7adafbff9f0fd7dc8dcad5f3ce6f23c126c81ad8d1666880cc94e6974", - "sha256:ba47b571d480c0b76d282ff1634372070031d4998a46ae5d8305d49563b74ca6", - "sha256:bf049dc9cf0d3aa4a48ba514b7f1699fb6f35b18ad8c6f018bd13e0bccd9d30c", - "sha256:c46a122c524a3270ac5249f590ac2f75f1a83692a3d3a03479cea49de72a0a89", - "sha256:c63337aa7e1ad4ec182cc7847c6d85390589fbbf1f9f67d1fde8133a9acb7fa8", - "sha256:ec51273ea08a2c6389bc4dd6b5183354826d916b149a041f2f274431166191bc" - ], - "version": "==2.0.2" - }, - "cython": { - "hashes": [ - "sha256:0ce8f6c789c907472c9084a44b625eba76a85d0189513de1497ab102a9d39ef8", - "sha256:0d67964b747ac09758ba31fe25da2f66f575437df5f121ff481889a7a4485f56", - "sha256:1630823619a87a814e5c1fa9f96544272ce4f94a037a34093fbec74989342328", - "sha256:1a4c634bb049c8482b7a4f3121330de1f1c1f66eac3570e1e885b0c392b6a451", - "sha256:1ec91cc09e9f9a2c3173606232adccc68f3d14be1a15a8c5dc6ab97b47b31528", - "sha256:237a8fdd8333f7248718875d930d1e963ffa519fefeb0756d01d91cbfadab0bc", - "sha256:28a308cbfdf9b7bb44def918ad4a26b2d25a0095fa2f123addda33a32f308d00", - "sha256:2fe3dde34fa125abf29996580d0182c18b8a240d7fa46d10984cc28d27808731", - "sha256:30bda294346afa78c49a343e26f3ab2ad701e09f6a6373f579593f0cfcb1235a", - "sha256:33d27ea23e12bf0d420e40c20308c03ef192d312e187c1f72f385edd9bd6d570", - "sha256:34d24d9370a6089cdd5afe56aa3c4af456e6400f8b4abb030491710ee765bafc", - "sha256:4e4877c2b96fae90f26ee528a87b9347872472b71c6913715ca15c8fe86a68c9", - "sha256:50d6f1f26702e5f2a19890c7bc3de00f9b8a0ec131b52edccd56a60d02519649", - "sha256:55d081162191b7c11c7bfcb7c68e913827dfd5de6ecdbab1b99dab190586c1e8", - "sha256:59d339c7f99920ff7e1d9d162ea309b35775172e4bab9553f1b968cd43b21d6d", - "sha256:6cf4d10df9edc040c955fca708bbd65234920e44c30fccd057ecf3128efb31ad", - "sha256:6ec362539e2a6cf2329cd9820dec64868d8f0babe0d8dc5deff6c87a84d13f68", - "sha256:7edc61a17c14b6e54d5317b0300d2da23d94a719c466f93cafa3b666b058c43b", - "sha256:8e37fc4db3f2c4e7e1ed98fe4fe313f1b7202df985de4ee1451d2e331332afae", - "sha256:b8c996bde5852545507bff45af44328fa48a7b22b5bec2f43083f0b8d1024fd9", - "sha256:bf9c16f3d46af82f89fdefc0d64b2fb02f899c20da64548a8ea336beefcf8d23", - "sha256:c1038aba898bed34ab1b5ddb0d3f9c9ae33b0649387ab9ffe6d0af677f66bfc1", - "sha256:d405649c1bfc42e20d86178257658a859a3217b6e6d950ee8cb76353fcea9c39", - "sha256:db6eeb20a3bd60e1cdcf6ce9a784bc82aec6ab891c800dc5d7824d5cfbfe77f2", - "sha256:e382f8cb40dca45c3b439359028a4b60e74e22d391dc2deb360c0b8239d6ddc0", - "sha256:f3f6c09e2c76f2537d61f907702dd921b04d1c3972f01d5530ef1f748f22bd89", - "sha256:f749287087f67957c020e1de26906e88b8b0c4ea588facb7349c115a63346f67", - "sha256:f86b96e014732c0d1ded2c1f51444c80176a98c21856d0da533db4e4aef54070" - ], - "index": "pypi", - "version": "==0.29.7" - }, - "en-core-web-lg": { - "file": "https://github.com/explosion/spacy-models/releases/download/en_core_web_lg-2.1.0/en_core_web_lg-2.1.0.tar.gz" - }, - "grpcio": { - "hashes": [ - "sha256:0442f7d0c527ceab6a76159937ae8109941eace90ec00cb1bd08fc4f3179e52e", - "sha256:051957d0f61f4dec90868a54ee969228409926a0a19fd8ed7b4a0e50388effee", - "sha256:0d262794b2339770d5378a5717f8ddbfb68e409974582f0503272b90b7cc79bd", - "sha256:142693dc8bd427c595d030f75bf8d01c843d9ccb659499e8507ad22da832e9cf", - "sha256:18d44515a3fd3a71442abb5a1c65fc1909d859c13cda50c974cbc69742a80cea", - "sha256:1d50674bdffa18ea6143e0df9a1b97cdeab583ce5dd1cabda3502ee75215065c", - "sha256:3945335a5b8332995415c5f03da1a5f6e36da6ede819a611e2cbb093cf752bdd", - "sha256:3a9603ff14070524f4c69634afad6b280b07ad9f8c2c346c4b2290306e1928ac", - "sha256:52861aac5c1dcf4c841eb555b257cfb56d0c840a286495078382f538d0a34d6a", - "sha256:53c512c7c8af9cb9e3e1cc5ce5e4a5fb2f2e7695e69219f90016bc602abe2f3b", - "sha256:57ea92c9b81015e5f2cc355e53f08a4e661b78a207857311c7b8c55137a43b29", - "sha256:5f8574c9e42d1917e41cdedc6312682a96e4547114c7bb0f3de125199a58b3d6", - "sha256:638ff1a45dd7a226b2b9390296a111142363fe2b5503499f3987d599bce0683c", - "sha256:64fe0dc897f1f19a6500948862857cb3b97247be997bc47b4dbade42f8af5f97", - "sha256:67920ec7d2de89845e5232aed41271ef53e1a362c8ffb84f6a6c6e644a75ce3a", - "sha256:714cddc170efeedf6312d8534ef7f52dcf20dd8f5fb7c5e425c2b6819ac1b9ec", - "sha256:7edf33e929b1666ff68bfc280b9021a862ab423d0e6306889cc2bc7c907dfc27", - "sha256:84eb47b1a47e206e78f453fb92a155ed0d18d2ca8747f5c67e4b50b9c37180a7", - "sha256:8a6289e5c38318cba75115f0bf88be166ead40c83c10dd81ace52f1ab5dc1eab", - "sha256:8bd5b8c3c8872da748dc8810b664699a5f1d49f2c9ab2b205b96ec9fe06741ad", - "sha256:93e7672348d4c68ac570c499a794ff4453a1928c39cbe708472a0e1b77176411", - "sha256:9d37fb214674f0f194a80df5ad0b9c9b9f2fa5c5408ceaf0fc796e57588404d9", - "sha256:9de6746a749634004499bac773ad9877d84d826aca2dc14ba4ebd3cd9f64ed74", - "sha256:9e530c69d6e566ca985193a63363af36a7560a23f4979df6e392bb1bdf05caed", - "sha256:b37f36da8f4d0bf07d53eb34395b68f5e0dc0bcee207affde9ba29bbf6bd6ced", - "sha256:cf9b57d139e44eab294ab31eb0181150d877440a8a321bb4422e2c09f6c7a7d9", - "sha256:dd716aab42be3d1fde74577e42b6319b6399b07d418e49b653e0e1bcd88399bc", - "sha256:dea43aa864edc3b3d8de1f6e40144119fbccdf04525b3ece4fef9392b6eed436", - "sha256:e6cbd27559ff91c98991b8ec4ef19f394bf9056d6897aabb9af79568307181d3", - "sha256:f58e3377da8e8e453068dffc00d17691a97ffd1c3a5a7460b890cf83a9ca6edf", - "sha256:f938fdfb780a0658d04e1d727b4fb470490087c56cb31ba75cb54fb4bea515bd", - "sha256:fee4accad7a113004aef226b851f0494c01fc8d281fdebd74468f19cc45354a0" - ], - "index": "pypi", - "version": "==1.20.1" - }, - "idna": { - "hashes": [ - "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", - "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" - ], - "version": "==2.8" - }, - "jmespath": { - "hashes": [ - "sha256:3720a4b1bd659dd2eecad0666459b9788813e032b83e7ba58578e48254e0a0e6", - "sha256:bde2aef6f44302dfb30320115b17d030798de8c4110e28d5cf6cf91a7a31074c" - ], - "version": "==0.9.4" - }, - "jsonschema": { - "hashes": [ - "sha256:000e68abd33c972a5248544925a0cae7d1125f9bf6c58280d37546b946769a08", - "sha256:6ff5f3180870836cae40f06fa10419f557208175f13ad7bc26caa77beb1f6e02" - ], - "version": "==2.6.0" - }, - "knack": { - "hashes": [ - "sha256:2a4b4d86c4700dd6714e5b4ca6bbca6baf2c827d9de28ca2b66640988c1b6ff4", - "sha256:7f17d4a1b34ea76821d3504f5f0f8c1b75bd9f08497db6a5864677214ac76adc" - ], - "index": "pypi", - "version": "==0.6.1" - }, - "murmurhash": { - "hashes": [ - "sha256:27b908fe4bdb426f4e4e4a8821acbe0302915b2945e035ec9d8ca513e2a74b1f", - "sha256:33405103fa8cde15d72ee525a03d5cfe2c7e4901133819754810986e29627d68", - "sha256:386a9eed3cb27cb2cd4394b6521275ba04552642c2d9cab5c9fb42aa5a3325c0", - "sha256:3af36a0dc9f13f6892d9b8b39a6a3ccf216cae5bce38adc7c2d145677987772f", - "sha256:717196a04cdc80cc3103a3da17b2415a8a5e1d0d578b7079259386bf153b3258", - "sha256:8a4ed95cd3456b43ea301679c7c39ade43fc18b844b37d0ba0ac0d6acbff8e0c", - "sha256:a6c071b4b498bcea16a8dc8590cad81fa8d43821f34c74bc00f96499e2527073", - "sha256:b0afe329701b59d02e56bc6cee7325af83e3fee9c299c615fc1df3202b4f886f", - "sha256:ba766343bdbcb928039b8fff609e80ae7a5fd5ed7a4fc5af822224b63e0cbaff", - "sha256:bf33490514d308bcc27ed240cb3eb114f1ec31af031535cd8f27659a7049bd52", - "sha256:c7a646f6b07b033642b4f52ae2e45efd8b80780b3b90e8092a0cec935fbf81e2", - "sha256:d696c394ebd164ca80b5871e2e9ad2f9fdbb81bd3c552c1d5f1e8ee694e6204a", - "sha256:fe344face8d30a5a6aa26e5acf288aa2a8f0f32e05efdda3d314b4bf289ec2af" - ], - "version": "==1.0.2" - }, - "numpy": { - "hashes": [ - "sha256:0e2eed77804b2a6a88741f8fcac02c5499bba3953ec9c71e8b217fad4912c56c", - "sha256:1c666f04553ef70fda54adf097dbae7080645435fc273e2397f26bbf1d127bbb", - "sha256:1f46532afa7b2903bfb1b79becca2954c0a04389d19e03dc73f06b039048ac40", - "sha256:315fa1b1dfc16ae0f03f8fd1c55f23fd15368710f641d570236f3d78af55e340", - "sha256:3d5fcea4f5ed40c3280791d54da3ad2ecf896f4c87c877b113576b8280c59441", - "sha256:48241759b99d60aba63b0e590332c600fc4b46ad597c9b0a53f350b871ef0634", - "sha256:4b4f2924b36d857cf302aec369caac61e43500c17eeef0d7baacad1084c0ee84", - "sha256:54fe3b7ed9e7eb928bbc4318f954d133851865f062fa4bbb02ef8940bc67b5d2", - "sha256:5a8f021c70e6206c317974c93eaaf9bc2b56295b6b1cacccf88846e44a1f33fc", - "sha256:754a6be26d938e6ca91942804eb209307b73f806a1721176278a6038869a1686", - "sha256:771147e654e8b95eea1293174a94f34e2e77d5729ad44aefb62fbf8a79747a15", - "sha256:78a6f89da87eeb48014ec652a65c4ffde370c036d780a995edaeb121d3625621", - "sha256:7fde5c2a3a682a9e101e61d97696687ebdba47637611378b4127fe7e47fdf2bf", - "sha256:80d99399c97f646e873dd8ce87c38cfdbb668956bbc39bc1e6cac4b515bba2a0", - "sha256:88a72c1e45a0ae24d1f249a529d9f71fe82e6fa6a3fd61414b829396ec585900", - "sha256:a4f4460877a16ac73302a9c077ca545498d9fe64e6a81398d8e1a67e4695e3df", - "sha256:a61255a765b3ac73ee4b110b28fccfbf758c985677f526c2b4b39c48cc4b509d", - "sha256:ab4896a8c910b9a04c0142871d8800c76c8a2e5ff44763513e1dd9d9631ce897", - "sha256:abbd6b1c2ef6199f4b7ca9f818eb6b31f17b73a6110aadc4e4298c3f00fab24e", - "sha256:b16d88da290334e33ea992c56492326ea3b06233a00a1855414360b77ca72f26", - "sha256:b78a1defedb0e8f6ae1eb55fa6ac74ab42acc4569c3a2eacc2a407ee5d42ebcb", - "sha256:cfef82c43b8b29ca436560d51b2251d5117818a8d1fb74a8384a83c096745dad", - "sha256:d160e57731fcdec2beda807ebcabf39823c47e9409485b5a3a1db3a8c6ce763e" - ], - "version": "==1.16.3" - }, - "plac": { - "hashes": [ - "sha256:854693ad90367e8267112ffbb8955f57d6fdeac3191791dc9ffce80f87fd2370", - "sha256:ba3f719a018175f0a15a6b04e6cc79c25fd563d348aacd320c3644d2a9baf89b" - ], - "version": "==0.9.6" - }, - "preshed": { - "hashes": [ - "sha256:0c9af79c7b825793f987d477627efb81afd23384ac791bebbc88a257342a77ab", - "sha256:0ebc79431154bc5d12f97b3c93bc350af941702a44f0761dfcd395e970d693f8", - "sha256:102e71dc841c979b2ece44ab05b2b0aa39c8039493ddac40dd22cf23e2484063", - "sha256:15145b24eded01426544be829a6395d6c99e2d62f5f3b88a6e19087ebeef7237", - "sha256:195674dfb4bcf18b26e448feaabdf61adcf028ae69ecaa075c0bdfaf62a19671", - "sha256:38f7fbef59f89d3b2c8c3b102f9a7360cd73a33c829fdeb101c615b18ecc4686", - "sha256:3aa411233dc230247ea4c4558062e5b2d59d41c697107a45fddbfe03e63f3e77", - "sha256:3b8c7b607e6dce0843544cfe4f05355db0516fce8eca0c37d6b5f4f3680493bf", - "sha256:4bda4153d46a603bc6ea65380dfa091d46700f664cb906c7f26a469be6c2a503", - "sha256:541d7ed765d67512d6f9fa24fd01cc1d7a51c7ff2646362924f4db46813b485a", - "sha256:593d23b9f851ae7a4d519ca4489dd2b352d833e08f5d35795d42a591b8badb54", - "sha256:7f6fb8f4108abe958af892847ed50abe6f45aaf45a87853cc8154a7203e75d84", - "sha256:7ff7f18af1f19ea666ac4fbf48842e6acd900fbfdc26bb9aad02f353ff932386", - "sha256:9c0d503d8693bf1e08e0fa1cecbcd3253146abaa9a7501d7d583a72edd29fdd1", - "sha256:9cefe818a97134c0ddf22ef76fced1c841ebd137c2895251c5d1310276c234b5", - "sha256:9e603916a95dc524081d54c0a135611e6f68d787185d5df2b5ab3f076c3d1bd4", - "sha256:a2acacceac79aa6d4b65125e20c7de78fbca1340a251854c87967acef1795490", - "sha256:a3d592e7b265b4faf08c9b4d7493b9e8604e0ba8858cc9bd8c9aee41d3df2a3a", - "sha256:b2030e68c6f539e6dd7bfcea032940042739ef05d50a2eb1d7af24e038971b0f", - "sha256:bc894dc14d8567a5d6a1cded0a701da7fbb360b2124237fe8acde85333825aef", - "sha256:c21d4d10cc0248ba3facbbbfbe63211ce921478a3d5db6de34de39ee1b3484e1", - "sha256:dae01c74313965c487e0ec839e5f28d0c7df9bfd1d978aa5bada3f72ff20a9e5", - "sha256:ee8068035684a4b382bebb3a3f270799360545baff9742b85e627a0a889e6850" - ], - "version": "==2.0.1" - }, - "protobuf": { - "hashes": [ - "sha256:21e395d7959551e759d604940a115c51c6347d90a475c9baf471a1a86b5604a9", - "sha256:57e05e16955aee9e6a0389fcbd58d8289dd2420e47df1a1096b3a232c26eb2dd", - "sha256:67819e8e48a74c68d87f25cad9f40edfe2faf278cdba5ca73173211b9213b8c9", - "sha256:75da7d43a2c8a13b0bc7238ab3c8ae217cbfd5979d33b01e98e1f78defb2d060", - "sha256:78e08371e236f193ce947712c072542ff19d0043ab5318c2ea46bbc2aaebdca6", - "sha256:7ee5b595db5abb0096e8c4755e69c20dfad38b2d0bcc9bc7bafc652d2496b471", - "sha256:86260ecfe7a66c0e9d82d2c61f86a14aa974d340d159b829b26f35f710f615db", - "sha256:92c77db4bd33ea4ee5f15152a835273f2338a5246b2cbb84bab5d0d7f6e9ba94", - "sha256:9c7b90943e0e188394b4f068926a759e3b4f63738190d1ab3d500d53b9ce7614", - "sha256:a77f217ea50b2542bae5b318f7acee50d9fc8c95dd6d3656eaeff646f7cab5ee", - "sha256:ad589ed1d1f83db22df867b10e01fe445516a5a4d7cfa37fe3590a5f6cfc508b", - "sha256:b06a794901bf573f4b2af87e6139e5cd36ac7c91ac85d7ae3fe5b5f6fc317513", - "sha256:bd8592cc5f8b4371d0bad92543370d4658dc41a5ccaaf105597eb5524c616291", - "sha256:be48e5a6248a928ec43adf2bea037073e5da692c0b3c10b34f9904793bd63138", - "sha256:cc5eb13f5ccc4b1b642cc147c2cdd121a34278b341c7a4d79e91182fff425836", - "sha256:cd3b0e0ad69b74ee55e7c321f52a98effed2b4f4cc9a10f3683d869de00590d5", - "sha256:d6e88c4920660aa75c0c2c4b53407aef5efd9a6e0ca7d2fc84d79aba2ccbda3a", - "sha256:ec3c49b6d247152e19110c3a53d9bb4cf917747882017f70796460728b02722e" - ], - "index": "pypi", - "version": "==3.7.1" - }, - "pygments": { - "hashes": [ - "sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a", - "sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d" - ], - "version": "==2.3.1" - }, - "pyre2": { - "file": "https://github.com/torosent/pyre2/archive/release/0.2.23.zip" - }, - "pyyaml": { - "hashes": [ - "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c", - "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95", - "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2", - "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4", - "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad", - "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba", - "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1", - "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e", - "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673", - "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13", - "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19" - ], - "version": "==5.1" - }, - "regex": { - "hashes": [ - "sha256:020429dcf9b76cc7648a99c81b3a70154e45afebc81e0b85364457fe83b525e4", - "sha256:0552802b1c3f3c7e4fee8c85e904a13c48226020aa1a0593246888a1ac55aaaf", - "sha256:308965a80b92e1fec263ac1e4f1094317809a72bc4d26be2ec8a5fd026301175", - "sha256:4d627feef04eb626397aa7bdec772774f53d63a1dc7cc5ee4d1bd2786a769d19", - "sha256:93d1f9fcb1d25e0b4bd622eeba95b080262e7f8f55e5b43c76b8a5677e67334c", - "sha256:c3859bbf29b1345d694f069ddfe53d6907b0393fda5e3794c800ad02902d78e9", - "sha256:d56ce4c7b1a189094b9bee3b81c4aeb3f1ba3e375e91627ec8561b6ab483d0a8", - "sha256:ebc5ef4e10fa3312fa1967dc0a894e6bd985a046768171f042ac3974fadc9680", - "sha256:f9cd39066048066a4abe4c18fb213bc541339728005e72263f023742fb912585" - ], - "index": "pypi", - "version": "==2019.4.14" - }, - "requests": { - "hashes": [ - "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", - "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" - ], - "version": "==2.21.0" - }, - "requests-file": { - "hashes": [ - "sha256:75c175eed739270aec3c5279ffd74e6527dada275c5c0d76b5817e9c86bb7dea", - "sha256:8f04aa6201bacda0567e7ac7f677f1499b0fc76b22140c54bc06edf1ba92e2fa" - ], - "version": "==1.4.3" - }, - "six": { - "hashes": [ - "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", - "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" - ], - "version": "==1.12.0" - }, - "spacy": { - "hashes": [ - "sha256:0fe2e5905f2f5b41be3ebea40626f70bea567a7a2cda9c244109fffe8d964429", - "sha256:30f0f09074bf115a0384691e8ba3d64aab431192b3095a13312a93d0e8a71c07", - "sha256:6a82612f0e75c11d541002f49375d80b4800c967e5d2b402d5a8dd40b6c57ae6", - "sha256:74066ac969a587d16d00d65318c1baa3c3e9215e6858d0c81ce2823320fe09dc", - "sha256:b1b86ddf6142fa2782b2e0269d040430ae5696eb0224f3e99408897cac7bb506", - "sha256:be8a7c89461ac22d261e19e1d3eb35752d8ff3e52452af076b303561bb166408", - "sha256:e6522e1242a5a5f12ef7e55f74df020b5deea59f7d1e7b6e69298301e3c0badd", - "sha256:eb699f54bf6d131df701e6dbbef9e91b74a065a42c9d2850964282b3c14560bb", - "sha256:f385942c5b2c8cf07e4a56871f88a49d4c8a9145fcd731c455e39fb5af9b12ba" - ], - "index": "pypi", - "version": "==2.1.3" - }, - "srsly": { - "hashes": [ - "sha256:02ea974c4b80f9ffdea4f953ffece5a8715e4e4b37d09192ab65cf4edfbf74d1", - "sha256:061ade35556e51b2e1da6f8552be7a6327d2d02b69edf0aacc9f5c4319d495f1", - "sha256:1bf6af7a86f34969a3997da09fc8c2f72ee02cd74ff40035e37c2f968776fa23", - "sha256:1e4ef85bf133e384f465865ba4e0a14a52c4f2e4b46c763faf100339a06f09c4", - "sha256:850399e43f4cefdcac7a913363b120ea084cb02fcfdbbde1bd37444804d7def4", - "sha256:977aa6e5fd3f7e9d1c8fe7aeed841dfe3ede75dfce04255d4c670e663faaef2a", - "sha256:abdc5b46866648b123517550582dc4c4b767b816ae54c44e5973bbebc3f0dab4", - "sha256:ac0dbe6e715e1fe3536397a9e65ec8f3c624c99f45b6f30e87d220071ef84721", - "sha256:b8646f0f7cf6fd1de4919ab456d9c030e09e74f741a0cecc941363414109ccdc", - "sha256:b9dc81339c1ab969057e790d7b2a56fd4da87336785bd671c86520e8272e3663", - "sha256:d7c91f59edc2ceeca70adf1b0a46d337234ff4fb7ca2b579ca41885f011b329f", - "sha256:d906a2a3df1cac2cb4bf382b8aaf14e22df2ca3758eba0d3049723c851c8ebf0", - "sha256:ecec49c9cdaae4594011666dd654e1e044e552f63bb3a62a1849c65a92ee302e", - "sha256:ef7897050c04a313f2db99c9bcaf2f0c3c75609677683ca5a6e1e7a515325d72" - ], - "version": "==0.0.5" - }, - "tabulate": { - "hashes": [ - "sha256:8af07a39377cee1103a5c8b3330a421c2d99b9141e9cc5ddd2e3263fea416943" - ], - "version": "==0.8.3" - }, - "thinc": { - "hashes": [ - "sha256:12c003b804fb93c64261a5010df0129f942234adb8f45d489a355a5315e06acf", - "sha256:17f9ada01f1f77a5560bc16ec5a650dca08356b50727ded0df19f0dfb4a32a25", - "sha256:26c9d54ffd90753feebbc462ae59939a9e3d2485ef24ed3dc1861c9b486fdbbe", - "sha256:3258161fc2cefa4082f099dec3748f1dcef5e920df5e9d82258ea6ffec280b9a", - "sha256:38a83b928cdc49c994852538f639b2a889681a0589c44b1a6fc3c899e5f36893", - "sha256:3e76101a733bbb0b97d44bdbcb407678b9e2b487047acb6f4c19b72909a6b12f", - "sha256:412f107c458d2951711b4d3ec53587244cd3acc032944e855f49cf94a1adc36e", - "sha256:4948c10c61e627950900cdccf506eb7398d2b28f33cf72bb4b5d9c5c572925e7", - "sha256:a8b2d7713a7dfc0b18b5c16db58ab6e015df14e4fbed0249ed49e630b2d6a86f", - "sha256:ec99c2c65962157c7ee7b947d29f2775291860b81cba62c5bd9f92fdeca2d137", - "sha256:f2386e66042218f19e511692926cef00a9646a3104d2efddfb5bec7b0388a83b", - "sha256:fc0b37733591315afddee45823d4f6740f9b0567c1ba57a3a3c319669d1fcbad" - ], - "version": "==7.0.4" - }, - "tldextract": { - "hashes": [ - "sha256:2c1c5d9d454f79734b4f3da0d603856dd9f820753410a3e9abf0a0c9fde33e97", - "sha256:b72bef6013de67c7fa181250bc2c2e089a994d259c09ca95a9771f2f97e29ed1" - ], - "index": "pypi", - "version": "==2.2.1" - }, - "tqdm": { - "hashes": [ - "sha256:d385c95361699e5cf7622485d9b9eae2d4864b21cd5a2374a9c381ffed701021", - "sha256:e22977e3ebe961f72362f6ddfb9197cc531c9737aaf5f607ef09740c849ecd05" - ], - "version": "==4.31.1" - }, - "urllib3": { - "hashes": [ - "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4", - "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb" - ], - "version": "==1.24.3" - }, - "wasabi": { - "hashes": [ - "sha256:b4fbee9dd0c8f5cff6554c0463c565e2d52b7c844d7eccb477d29a6ff8567750", - "sha256:f92c83e728bf1db6dc859ffc861afa328d2da8ef0c7a19300e5fb1bd5762b277" - ], - "version": "==0.2.2" - } - }, - "develop": { - "astroid": { - "hashes": [ - "sha256:6560e1e1749f68c64a4b5dee4e091fce798d2f0d84ebe638cf0e0585a343acf4", - "sha256:b65db1bbaac9f9f4d190199bb8680af6f6f84fd3769a5ea883df8a91fe68b4c4" - ], - "version": "==2.2.5" - }, - "atomicwrites": { - "hashes": [ - "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", - "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" - ], - "version": "==1.3.0" - }, - "attrs": { - "hashes": [ - "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", - "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" - ], - "version": "==19.1.0" - }, - "entrypoints": { - "hashes": [ - "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", - "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" - ], - "version": "==0.3" - }, - "flake8": { - "hashes": [ - "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", - "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8" - ], - "index": "pypi", - "version": "==3.7.7" - }, - "isort": { - "hashes": [ - "sha256:1349c6f7c2a0f7539f5f2ace51a9a8e4a37086ce4de6f78f5f53fb041d0a3cd5", - "sha256:f09911f6eb114e5592abe635aded8bf3d2c3144ebcfcaf81ee32e7af7b7d1870" - ], - "version": "==4.3.18" - }, - "lazy-object-proxy": { - "hashes": [ - "sha256:0ce34342b419bd8f018e6666bfef729aec3edf62345a53b537a4dcc115746a33", - "sha256:1b668120716eb7ee21d8a38815e5eb3bb8211117d9a90b0f8e21722c0758cc39", - "sha256:209615b0fe4624d79e50220ce3310ca1a9445fd8e6d3572a896e7f9146bbf019", - "sha256:27bf62cb2b1a2068d443ff7097ee33393f8483b570b475db8ebf7e1cba64f088", - "sha256:27ea6fd1c02dcc78172a82fc37fcc0992a94e4cecf53cb6d73f11749825bd98b", - "sha256:2c1b21b44ac9beb0fc848d3993924147ba45c4ebc24be19825e57aabbe74a99e", - "sha256:2df72ab12046a3496a92476020a1a0abf78b2a7db9ff4dc2036b8dd980203ae6", - "sha256:320ffd3de9699d3892048baee45ebfbbf9388a7d65d832d7e580243ade426d2b", - "sha256:50e3b9a464d5d08cc5227413db0d1c4707b6172e4d4d915c1c70e4de0bbff1f5", - "sha256:5276db7ff62bb7b52f77f1f51ed58850e315154249aceb42e7f4c611f0f847ff", - "sha256:61a6cf00dcb1a7f0c773ed4acc509cb636af2d6337a08f362413c76b2b47a8dd", - "sha256:6ae6c4cb59f199d8827c5a07546b2ab7e85d262acaccaacd49b62f53f7c456f7", - "sha256:7661d401d60d8bf15bb5da39e4dd72f5d764c5aff5a86ef52a042506e3e970ff", - "sha256:7bd527f36a605c914efca5d3d014170b2cb184723e423d26b1fb2fd9108e264d", - "sha256:7cb54db3535c8686ea12e9535eb087d32421184eacc6939ef15ef50f83a5e7e2", - "sha256:7f3a2d740291f7f2c111d86a1c4851b70fb000a6c8883a59660d95ad57b9df35", - "sha256:81304b7d8e9c824d058087dcb89144842c8e0dea6d281c031f59f0acf66963d4", - "sha256:933947e8b4fbe617a51528b09851685138b49d511af0b6c0da2539115d6d4514", - "sha256:94223d7f060301b3a8c09c9b3bc3294b56b2188e7d8179c762a1cda72c979252", - "sha256:ab3ca49afcb47058393b0122428358d2fbe0408cf99f1b58b295cfeb4ed39109", - "sha256:bd6292f565ca46dee4e737ebcc20742e3b5be2b01556dafe169f6c65d088875f", - "sha256:cb924aa3e4a3fb644d0c463cad5bc2572649a6a3f68a7f8e4fbe44aaa6d77e4c", - "sha256:d0fc7a286feac9077ec52a927fc9fe8fe2fabab95426722be4c953c9a8bede92", - "sha256:ddc34786490a6e4ec0a855d401034cbd1242ef186c20d79d2166d6a4bd449577", - "sha256:e34b155e36fa9da7e1b7c738ed7767fc9491a62ec6af70fe9da4a057759edc2d", - "sha256:e5b9e8f6bda48460b7b143c3821b21b452cb3a835e6bbd5dd33aa0c8d3f5137d", - "sha256:e81ebf6c5ee9684be8f2c87563880f93eedd56dd2b6146d8a725b50b7e5adb0f", - "sha256:eb91be369f945f10d3a49f5f9be8b3d0b93a4c2be8f8a5b83b0571b8123e0a7a", - "sha256:f460d1ceb0e4a5dcb2a652db0904224f367c9b3c1470d5a7683c0480e582468b" - ], - "version": "==1.3.1" - }, - "mccabe": { - "hashes": [ - "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", - "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" - ], - "version": "==0.6.1" - }, - "more-itertools": { - "hashes": [ - "sha256:2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7", - "sha256:c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a" - ], - "markers": "python_version > '2.7'", - "version": "==7.0.0" - }, - "pluggy": { - "hashes": [ - "sha256:19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f", - "sha256:84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746" - ], - "version": "==0.9.0" - }, - "py": { - "hashes": [ - "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", - "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" - ], - "version": "==1.8.0" - }, - "pycodestyle": { - "hashes": [ - "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", - "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" - ], - "version": "==2.5.0" - }, - "pyflakes": { - "hashes": [ - "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", - "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" - ], - "version": "==2.1.1" - }, - "pylint": { - "hashes": [ - "sha256:5d77031694a5fb97ea95e828c8d10fc770a1df6eb3906067aaed42201a8a6a09", - "sha256:723e3db49555abaf9bf79dc474c6b9e2935ad82230b10c1138a71ea41ac0fff1" - ], - "index": "pypi", - "version": "==2.3.1" - }, - "pytest": { - "hashes": [ - "sha256:3773f4c235918987d51daf1db66d51c99fac654c81d6f2f709a046ab446d5e5d", - "sha256:b7802283b70ca24d7119b32915efa7c409982f59913c1a6c0640aacf118b95f5" - ], - "index": "pypi", - "version": "==4.4.1" - }, - "six": { - "hashes": [ - "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", - "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" - ], - "version": "==1.12.0" - }, - "typed-ast": { - "hashes": [ - "sha256:132eae51d6ef3ff4a8c47c393a4ef5ebf0d1aecc96880eb5d6c8ceab7017cc9b", - "sha256:18141c1484ab8784006c839be8b985cfc82a2e9725837b0ecfa0203f71c4e39d", - "sha256:2baf617f5bbbfe73fd8846463f5aeafc912b5ee247f410700245d68525ec584a", - "sha256:3d90063f2cbbe39177e9b4d888e45777012652d6110156845b828908c51ae462", - "sha256:4304b2218b842d610aa1a1d87e1dc9559597969acc62ce717ee4dfeaa44d7eee", - "sha256:4983ede548ffc3541bae49a82675996497348e55bafd1554dc4e4a5d6eda541a", - "sha256:5315f4509c1476718a4825f45a203b82d7fdf2a6f5f0c8f166435975b1c9f7d4", - "sha256:6cdfb1b49d5345f7c2b90d638822d16ba62dc82f7616e9b4caa10b72f3f16649", - "sha256:7b325f12635598c604690efd7a0197d0b94b7d7778498e76e0710cd582fd1c7a", - "sha256:8d3b0e3b8626615826f9a626548057c5275a9733512b137984a68ba1598d3d2f", - "sha256:8f8631160c79f53081bd23446525db0bc4c5616f78d04021e6e434b286493fd7", - "sha256:912de10965f3dc89da23936f1cc4ed60764f712e5fa603a09dd904f88c996760", - "sha256:b010c07b975fe853c65d7bbe9d4ac62f1c69086750a574f6292597763781ba18", - "sha256:c908c10505904c48081a5415a1e295d8403e353e0c14c42b6d67f8f97fae6616", - "sha256:c94dd3807c0c0610f7c76f078119f4ea48235a953512752b9175f9f98f5ae2bd", - "sha256:ce65dee7594a84c466e79d7fb7d3303e7295d16a83c22c7c4037071b059e2c21", - "sha256:eaa9cfcb221a8a4c2889be6f93da141ac777eb8819f077e1d09fb12d00a09a93", - "sha256:f3376bc31bad66d46d44b4e6522c5c21976bf9bca4ef5987bb2bf727f4506cbb", - "sha256:f9202fa138544e13a4ec1a6792c35834250a85958fde1251b6a22e07d1260ae7" - ], - "markers": "implementation_name == 'cpython'", - "version": "==1.3.5" - }, - "wrapt": { - "hashes": [ - "sha256:4aea003270831cceb8a90ff27c4031da6ead7ec1886023b80ce0dfe0adf61533" - ], - "version": "==1.11.1" - } - } -} diff --git a/presidio-analyzer/analyzer/predefined_recognizers/us_driver_license_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/us_driver_license_recognizer.py index d671d81d0..3fd3cd2f8 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/us_driver_license_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/us_driver_license_recognizer.py @@ -14,13 +14,12 @@ # pylint: disable=line-too-long,abstract-method WA_WEAK_REGEX = r'\b((?=.*\d)([A-Z][A-Z0-9*]{11})|(?=.*\*)([A-Z][A-Z0-9*]{11}))\b' # noqa: E501 -WA_VERY_WEAK_REGEX = r'\b([A-Z]{12})\b' ALPHANUMERIC_REGEX = r'\b([A-Z][0-9]{3,6}|[A-Z][0-9]{5,9}|[A-Z][0-9]{6,8}|[A-Z][0-9]{4,8}|[A-Z][0-9]{9,11}|[A-Z]{1,2}[0-9]{5,6}|H[0-9]{8}|V[0-9]{6}|X[0-9]{8}|A-Z]{2}[0-9]{2,5}|[A-Z]{2}[0-9]{3,7}|[0-9]{2}[A-Z]{3}[0-9]{5,6}|[A-Z][0-9]{13,14}|[A-Z][0-9]{18}|[A-Z][0-9]{6}R|[A-Z][0-9]{9}|[A-Z][0-9]{1,12}|[0-9]{9}[A-Z]|[A-Z]{2}[0-9]{6}[A-Z]|[0-9]{8}[A-Z]{2}|[0-9]{3}[A-Z]{2}[0-9]{4}|[A-Z][0-9][A-Z][0-9][A-Z]|[0-9]{7,8}[A-Z])\b' # noqa: E501 DIGITS_REGEX = r'\b([0-9]{1,9}|[0-9]{4,10}|[0-9]{6,10}|[0-9]{1,12}|[0-9]{12,14}|[0-9]{16})\b' # noqa: E501 LICENSE_CONTEXT = [ - "driver", "license", "permit", "id", "lic", "identification", "card", - "cards", "dl", "dls", "cdls", "id", "lic#" + "driver", "license", "permit", "id", "lic", "identification", + "dl", "dls", "cdls", "id", "lic#", "driving" ] @@ -31,8 +30,6 @@ class UsLicenseRecognizer(PatternRecognizer): def __init__(self): patterns = [Pattern('Driver License - WA (weak) ', WA_WEAK_REGEX, 0.4), - Pattern('Driver License - WA (very weak) ', - WA_VERY_WEAK_REGEX, 0.01), Pattern('Driver License - Alphanumeric (weak) ', ALPHANUMERIC_REGEX, 0.3), Pattern('Driver License - Digits (very weak)', diff --git a/presidio-analyzer/tests/data/demo.txt b/presidio-analyzer/tests/data/demo.txt index 87e3a1657..1f10cafde 100644 --- a/presidio-analyzer/tests/data/demo.txt +++ b/presidio-analyzer/tests/data/demo.txt @@ -5,7 +5,7 @@ Here are a few examples of entities we currently support: DateTime: September 18 Domain: microsoft.com Email address: test@presidio.site - IBAN: IL150120690000003111111 + IBAN code: IL150120690000003111111 IP: 192.168.0.1 Person name: David Johnson @@ -23,4 +23,4 @@ PR appropriately (e.g., label, comment). Simply follow the instructions provided epos using our CLA. This project has adopted the Microsoft Open Source Code of Conduct. For more information see the Code of Conduct FAQ or contact -opencode@microsoft.com with any additional questions or comments. \ No newline at end of file +opencode@microsoft.com with any additional questions or comments. diff --git a/presidio-analyzer/tests/test_us_driver_license_recognizer.py b/presidio-analyzer/tests/test_us_driver_license_recognizer.py index 5289c4d9e..729b49c15 100644 --- a/presidio-analyzer/tests/test_us_driver_license_recognizer.py +++ b/presidio-analyzer/tests/test_us_driver_license_recognizer.py @@ -67,17 +67,6 @@ def test_valid_us_driver_license_very_weak_digits(self): for result in results: assert 0 < result.score < 0.02 - def test_load_from_file(self): - path = os.path.dirname(__file__) + '/data/demo.txt' - text_file = open(path, 'r') - text = text_file.read() - results = us_license_recognizer.analyze(text, entities) - - assert len(results) == 23 - - # Driver License - Letters (very weak) - 0.00 - # Regex: r'\b([A-Z]{7,9}\b' - def test_valid_us_driver_license_very_weak_letters(self): num = 'ABCDEFG ABCDEFGH ABCDEFGHI' results = us_license_recognizer.analyze(num, entities) From 2504c4f8f25cd496c83b04f92637d703846e02df Mon Sep 17 00:00:00 2001 From: omri374 Date: Sun, 8 Sep 2019 14:38:03 +0300 Subject: [PATCH 68/75] re-added Pipfile.lock --- presidio-analyzer/Pipfile.lock | 625 +++++++++++++++++++++++++++++++++ 1 file changed, 625 insertions(+) create mode 100644 presidio-analyzer/Pipfile.lock diff --git a/presidio-analyzer/Pipfile.lock b/presidio-analyzer/Pipfile.lock new file mode 100644 index 000000000..0eb5b069a --- /dev/null +++ b/presidio-analyzer/Pipfile.lock @@ -0,0 +1,625 @@ +{ + "_meta": { + "hash": { + "sha256": "be7df2b6a129090a66e0049544cdeae3425b032a6333d2d2991aa8e0e26725d2" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.python.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "argcomplete": { + "hashes": [ + "sha256:59909d0ce5be1a46e2fb4e4fa5b714f6d605151ce88c468afb42d800879a6e6d", + "sha256:94423d1a56cdec2ef47699e02c9a48cf8827b9c4465b836c0cefb30afe85e59a" + ], + "version": "==1.9.5" + }, + "blis": { + "hashes": [ + "sha256:039129410a338be8db8cf48c54334bd7c30da7e72bad2741e59313b1d242814b", + "sha256:058f9109aaea9d4f88cb623a44994d96c8cf36448de3e1bd30210628d6b52e9e", + "sha256:278d7b95e56cf82a6bef91cd8283eadc9401f2d3bdbbf2cdfdb605cf9081c36e", + "sha256:2d4ca1508fd6229c7994fc17ba324083a5b83f66612c8ea62623a41a1768b030", + "sha256:51a54bad6175e9b154beeb628a879ed492ee2247c9e40c77bdf6fc772145130c", + "sha256:886b313f96d4e268a0587e98c1637d963c73defa8de51e2e6b0d0bd00f16afbb", + "sha256:9f12e6f1e4b10dbb1e0e34e98f60e8435058a60d544a009cb761351fe1d12cad", + "sha256:a54d4fa1908d586f8bce9851a453cb89d1542e9aca65b8b88e9bb9432d626f80", + "sha256:b9d6cef13d95e3752320cd942df25e09160a6f9dfc3d7b41af7cdc772ab18270", + "sha256:d571464d195a950e60bf1547c8914d4da50952e06a0f38cea7b0829d0a4b985a", + "sha256:d616d64c85e6be92d69a1410dc58146cb9603fd1eb148f9ee512b8fddfd789f6", + "sha256:e477c7eaacf7dcccbb190a29559579efb287ecf5c2a9a7a6f9acb0452899f033", + "sha256:e6ae1986625af86f90f111f9d2d284b9e45fddfe56cf40524cdd9417a6a33b87" + ], + "version": "==0.2.4" + }, + "certifi": { + "hashes": [ + "sha256:59b7658e26ca9c7339e00f8f4636cdfe59d34fa37b9b04f6f9e9926b3cece1a5", + "sha256:b26104d6835d1f5e49452a26eb2ff87fe7090b89dfcaee5ea2212697e1e1d7ae" + ], + "version": "==2019.3.9" + }, + "chardet": { + "hashes": [ + "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae", + "sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691" + ], + "version": "==3.0.4" + }, + "colorama": { + "hashes": [ + "sha256:05eed71e2e327246ad6b38c540c4a3117230b19679b875190486ddd2d721422d", + "sha256:f8ac84de7840f5b9c4e3347b3c1eaa50f7e49c2b07596221daec5edaabbd7c48" + ], + "version": "==0.4.1" + }, + "cymem": { + "hashes": [ + "sha256:081c652ae1aff4759813e93a2fc4df4ba410ce214a0e542988e24c62110d4cd0", + "sha256:0e447fa4cb6dccd0b96257a798370a17bef3ec254a527230058e41816a777c04", + "sha256:2c8267dcb15cc6ab318f01ceaf16b8440c0386ae44014d5b22fefe5b0398d05c", + "sha256:46141111eedbb5b0d8c9386b00226a15f5727a1202b9095f4363d425f259267e", + "sha256:4994c1f3e948bd58a6e38c905221680563b851983a15f1f01e5ff415d560d153", + "sha256:584872fd3df176e50c90e37aaca6cb731ac0abcdea4f5b8ad77c30674cfaaa99", + "sha256:6e3194135b21bb268030f3473beb8b674b356c330a9fa185dced2f5006cbd5ba", + "sha256:71710ee0e946a6bd33c86dd9e71f95ad584c65e8bb02615f00ceb0d8348fb303", + "sha256:741957f541fb8322de5a8c711d5d58f80d684225d2aec32fec92484cac931a52", + "sha256:7f01ba6153427811cd7d35630081c69b32c188a1d330599a826ef3bf17edbd7c", + "sha256:8d96e95902e781950d7c255b19364a1ed50a204843d63dd386b0abc5e6df5e44", + "sha256:8dd169ece1629ec4db1a592321e3ae0a9bb62fda2052a351fc36871f314c3569", + "sha256:8e6ad29636edd559b0dfe0a19c5cb5e6257461a5df90839e8c7710ddb005f4b4", + "sha256:9935b233882732f03fd0fadbeb9e9aa672edcdd126e6d52c36d60adf1def8ea5", + "sha256:a38b3229782411e4b23240f5f90000c4e7a834af88ed8763c66f8e4603db6b51", + "sha256:a5966b3171bad9c84a2b19dccda5ab37ae8437c0709a6b72cb42b64ea76a4bd3", + "sha256:ab88b1534f06df07262d9bc5efb3ba07948cdbe9a363eb9eaa4ad42fae6c7b5e", + "sha256:b08b0dd7adafbff9f0fd7dc8dcad5f3ce6f23c126c81ad8d1666880cc94e6974", + "sha256:ba47b571d480c0b76d282ff1634372070031d4998a46ae5d8305d49563b74ca6", + "sha256:bf049dc9cf0d3aa4a48ba514b7f1699fb6f35b18ad8c6f018bd13e0bccd9d30c", + "sha256:c46a122c524a3270ac5249f590ac2f75f1a83692a3d3a03479cea49de72a0a89", + "sha256:c63337aa7e1ad4ec182cc7847c6d85390589fbbf1f9f67d1fde8133a9acb7fa8", + "sha256:ec51273ea08a2c6389bc4dd6b5183354826d916b149a041f2f274431166191bc" + ], + "version": "==2.0.2" + }, + "cython": { + "hashes": [ + "sha256:0ce8f6c789c907472c9084a44b625eba76a85d0189513de1497ab102a9d39ef8", + "sha256:0d67964b747ac09758ba31fe25da2f66f575437df5f121ff481889a7a4485f56", + "sha256:1630823619a87a814e5c1fa9f96544272ce4f94a037a34093fbec74989342328", + "sha256:1a4c634bb049c8482b7a4f3121330de1f1c1f66eac3570e1e885b0c392b6a451", + "sha256:1ec91cc09e9f9a2c3173606232adccc68f3d14be1a15a8c5dc6ab97b47b31528", + "sha256:237a8fdd8333f7248718875d930d1e963ffa519fefeb0756d01d91cbfadab0bc", + "sha256:28a308cbfdf9b7bb44def918ad4a26b2d25a0095fa2f123addda33a32f308d00", + "sha256:2fe3dde34fa125abf29996580d0182c18b8a240d7fa46d10984cc28d27808731", + "sha256:30bda294346afa78c49a343e26f3ab2ad701e09f6a6373f579593f0cfcb1235a", + "sha256:33d27ea23e12bf0d420e40c20308c03ef192d312e187c1f72f385edd9bd6d570", + "sha256:34d24d9370a6089cdd5afe56aa3c4af456e6400f8b4abb030491710ee765bafc", + "sha256:4e4877c2b96fae90f26ee528a87b9347872472b71c6913715ca15c8fe86a68c9", + "sha256:50d6f1f26702e5f2a19890c7bc3de00f9b8a0ec131b52edccd56a60d02519649", + "sha256:55d081162191b7c11c7bfcb7c68e913827dfd5de6ecdbab1b99dab190586c1e8", + "sha256:59d339c7f99920ff7e1d9d162ea309b35775172e4bab9553f1b968cd43b21d6d", + "sha256:6cf4d10df9edc040c955fca708bbd65234920e44c30fccd057ecf3128efb31ad", + "sha256:6ec362539e2a6cf2329cd9820dec64868d8f0babe0d8dc5deff6c87a84d13f68", + "sha256:7edc61a17c14b6e54d5317b0300d2da23d94a719c466f93cafa3b666b058c43b", + "sha256:8e37fc4db3f2c4e7e1ed98fe4fe313f1b7202df985de4ee1451d2e331332afae", + "sha256:b8c996bde5852545507bff45af44328fa48a7b22b5bec2f43083f0b8d1024fd9", + "sha256:bf9c16f3d46af82f89fdefc0d64b2fb02f899c20da64548a8ea336beefcf8d23", + "sha256:c1038aba898bed34ab1b5ddb0d3f9c9ae33b0649387ab9ffe6d0af677f66bfc1", + "sha256:d405649c1bfc42e20d86178257658a859a3217b6e6d950ee8cb76353fcea9c39", + "sha256:db6eeb20a3bd60e1cdcf6ce9a784bc82aec6ab891c800dc5d7824d5cfbfe77f2", + "sha256:e382f8cb40dca45c3b439359028a4b60e74e22d391dc2deb360c0b8239d6ddc0", + "sha256:f3f6c09e2c76f2537d61f907702dd921b04d1c3972f01d5530ef1f748f22bd89", + "sha256:f749287087f67957c020e1de26906e88b8b0c4ea588facb7349c115a63346f67", + "sha256:f86b96e014732c0d1ded2c1f51444c80176a98c21856d0da533db4e4aef54070" + ], + "index": "pypi", + "version": "==0.29.7" + }, + "en-core-web-lg": { + "file": "https://github.com/explosion/spacy-models/releases/download/en_core_web_lg-2.1.0/en_core_web_lg-2.1.0.tar.gz" + }, + "grpcio": { + "hashes": [ + "sha256:0442f7d0c527ceab6a76159937ae8109941eace90ec00cb1bd08fc4f3179e52e", + "sha256:051957d0f61f4dec90868a54ee969228409926a0a19fd8ed7b4a0e50388effee", + "sha256:0d262794b2339770d5378a5717f8ddbfb68e409974582f0503272b90b7cc79bd", + "sha256:142693dc8bd427c595d030f75bf8d01c843d9ccb659499e8507ad22da832e9cf", + "sha256:18d44515a3fd3a71442abb5a1c65fc1909d859c13cda50c974cbc69742a80cea", + "sha256:1d50674bdffa18ea6143e0df9a1b97cdeab583ce5dd1cabda3502ee75215065c", + "sha256:3945335a5b8332995415c5f03da1a5f6e36da6ede819a611e2cbb093cf752bdd", + "sha256:3a9603ff14070524f4c69634afad6b280b07ad9f8c2c346c4b2290306e1928ac", + "sha256:52861aac5c1dcf4c841eb555b257cfb56d0c840a286495078382f538d0a34d6a", + "sha256:53c512c7c8af9cb9e3e1cc5ce5e4a5fb2f2e7695e69219f90016bc602abe2f3b", + "sha256:57ea92c9b81015e5f2cc355e53f08a4e661b78a207857311c7b8c55137a43b29", + "sha256:5f8574c9e42d1917e41cdedc6312682a96e4547114c7bb0f3de125199a58b3d6", + "sha256:638ff1a45dd7a226b2b9390296a111142363fe2b5503499f3987d599bce0683c", + "sha256:64fe0dc897f1f19a6500948862857cb3b97247be997bc47b4dbade42f8af5f97", + "sha256:67920ec7d2de89845e5232aed41271ef53e1a362c8ffb84f6a6c6e644a75ce3a", + "sha256:714cddc170efeedf6312d8534ef7f52dcf20dd8f5fb7c5e425c2b6819ac1b9ec", + "sha256:7edf33e929b1666ff68bfc280b9021a862ab423d0e6306889cc2bc7c907dfc27", + "sha256:84eb47b1a47e206e78f453fb92a155ed0d18d2ca8747f5c67e4b50b9c37180a7", + "sha256:8a6289e5c38318cba75115f0bf88be166ead40c83c10dd81ace52f1ab5dc1eab", + "sha256:8bd5b8c3c8872da748dc8810b664699a5f1d49f2c9ab2b205b96ec9fe06741ad", + "sha256:93e7672348d4c68ac570c499a794ff4453a1928c39cbe708472a0e1b77176411", + "sha256:9d37fb214674f0f194a80df5ad0b9c9b9f2fa5c5408ceaf0fc796e57588404d9", + "sha256:9de6746a749634004499bac773ad9877d84d826aca2dc14ba4ebd3cd9f64ed74", + "sha256:9e530c69d6e566ca985193a63363af36a7560a23f4979df6e392bb1bdf05caed", + "sha256:b37f36da8f4d0bf07d53eb34395b68f5e0dc0bcee207affde9ba29bbf6bd6ced", + "sha256:cf9b57d139e44eab294ab31eb0181150d877440a8a321bb4422e2c09f6c7a7d9", + "sha256:dd716aab42be3d1fde74577e42b6319b6399b07d418e49b653e0e1bcd88399bc", + "sha256:dea43aa864edc3b3d8de1f6e40144119fbccdf04525b3ece4fef9392b6eed436", + "sha256:e6cbd27559ff91c98991b8ec4ef19f394bf9056d6897aabb9af79568307181d3", + "sha256:f58e3377da8e8e453068dffc00d17691a97ffd1c3a5a7460b890cf83a9ca6edf", + "sha256:f938fdfb780a0658d04e1d727b4fb470490087c56cb31ba75cb54fb4bea515bd", + "sha256:fee4accad7a113004aef226b851f0494c01fc8d281fdebd74468f19cc45354a0" + ], + "index": "pypi", + "version": "==1.20.1" + }, + "idna": { + "hashes": [ + "sha256:c357b3f628cf53ae2c4c05627ecc484553142ca23264e593d327bcde5e9c3407", + "sha256:ea8b7f6188e6fa117537c3df7da9fc686d485087abf6ac197f9c46432f7e4a3c" + ], + "version": "==2.8" + }, + "jmespath": { + "hashes": [ + "sha256:3720a4b1bd659dd2eecad0666459b9788813e032b83e7ba58578e48254e0a0e6", + "sha256:bde2aef6f44302dfb30320115b17d030798de8c4110e28d5cf6cf91a7a31074c" + ], + "version": "==0.9.4" + }, + "jsonschema": { + "hashes": [ + "sha256:000e68abd33c972a5248544925a0cae7d1125f9bf6c58280d37546b946769a08", + "sha256:6ff5f3180870836cae40f06fa10419f557208175f13ad7bc26caa77beb1f6e02" + ], + "version": "==2.6.0" + }, + "knack": { + "hashes": [ + "sha256:2a4b4d86c4700dd6714e5b4ca6bbca6baf2c827d9de28ca2b66640988c1b6ff4", + "sha256:7f17d4a1b34ea76821d3504f5f0f8c1b75bd9f08497db6a5864677214ac76adc" + ], + "index": "pypi", + "version": "==0.6.1" + }, + "murmurhash": { + "hashes": [ + "sha256:27b908fe4bdb426f4e4e4a8821acbe0302915b2945e035ec9d8ca513e2a74b1f", + "sha256:33405103fa8cde15d72ee525a03d5cfe2c7e4901133819754810986e29627d68", + "sha256:386a9eed3cb27cb2cd4394b6521275ba04552642c2d9cab5c9fb42aa5a3325c0", + "sha256:3af36a0dc9f13f6892d9b8b39a6a3ccf216cae5bce38adc7c2d145677987772f", + "sha256:717196a04cdc80cc3103a3da17b2415a8a5e1d0d578b7079259386bf153b3258", + "sha256:8a4ed95cd3456b43ea301679c7c39ade43fc18b844b37d0ba0ac0d6acbff8e0c", + "sha256:a6c071b4b498bcea16a8dc8590cad81fa8d43821f34c74bc00f96499e2527073", + "sha256:b0afe329701b59d02e56bc6cee7325af83e3fee9c299c615fc1df3202b4f886f", + "sha256:ba766343bdbcb928039b8fff609e80ae7a5fd5ed7a4fc5af822224b63e0cbaff", + "sha256:bf33490514d308bcc27ed240cb3eb114f1ec31af031535cd8f27659a7049bd52", + "sha256:c7a646f6b07b033642b4f52ae2e45efd8b80780b3b90e8092a0cec935fbf81e2", + "sha256:d696c394ebd164ca80b5871e2e9ad2f9fdbb81bd3c552c1d5f1e8ee694e6204a", + "sha256:fe344face8d30a5a6aa26e5acf288aa2a8f0f32e05efdda3d314b4bf289ec2af" + ], + "version": "==1.0.2" + }, + "numpy": { + "hashes": [ + "sha256:0e2eed77804b2a6a88741f8fcac02c5499bba3953ec9c71e8b217fad4912c56c", + "sha256:1c666f04553ef70fda54adf097dbae7080645435fc273e2397f26bbf1d127bbb", + "sha256:1f46532afa7b2903bfb1b79becca2954c0a04389d19e03dc73f06b039048ac40", + "sha256:315fa1b1dfc16ae0f03f8fd1c55f23fd15368710f641d570236f3d78af55e340", + "sha256:3d5fcea4f5ed40c3280791d54da3ad2ecf896f4c87c877b113576b8280c59441", + "sha256:48241759b99d60aba63b0e590332c600fc4b46ad597c9b0a53f350b871ef0634", + "sha256:4b4f2924b36d857cf302aec369caac61e43500c17eeef0d7baacad1084c0ee84", + "sha256:54fe3b7ed9e7eb928bbc4318f954d133851865f062fa4bbb02ef8940bc67b5d2", + "sha256:5a8f021c70e6206c317974c93eaaf9bc2b56295b6b1cacccf88846e44a1f33fc", + "sha256:754a6be26d938e6ca91942804eb209307b73f806a1721176278a6038869a1686", + "sha256:771147e654e8b95eea1293174a94f34e2e77d5729ad44aefb62fbf8a79747a15", + "sha256:78a6f89da87eeb48014ec652a65c4ffde370c036d780a995edaeb121d3625621", + "sha256:7fde5c2a3a682a9e101e61d97696687ebdba47637611378b4127fe7e47fdf2bf", + "sha256:80d99399c97f646e873dd8ce87c38cfdbb668956bbc39bc1e6cac4b515bba2a0", + "sha256:88a72c1e45a0ae24d1f249a529d9f71fe82e6fa6a3fd61414b829396ec585900", + "sha256:a4f4460877a16ac73302a9c077ca545498d9fe64e6a81398d8e1a67e4695e3df", + "sha256:a61255a765b3ac73ee4b110b28fccfbf758c985677f526c2b4b39c48cc4b509d", + "sha256:ab4896a8c910b9a04c0142871d8800c76c8a2e5ff44763513e1dd9d9631ce897", + "sha256:abbd6b1c2ef6199f4b7ca9f818eb6b31f17b73a6110aadc4e4298c3f00fab24e", + "sha256:b16d88da290334e33ea992c56492326ea3b06233a00a1855414360b77ca72f26", + "sha256:b78a1defedb0e8f6ae1eb55fa6ac74ab42acc4569c3a2eacc2a407ee5d42ebcb", + "sha256:cfef82c43b8b29ca436560d51b2251d5117818a8d1fb74a8384a83c096745dad", + "sha256:d160e57731fcdec2beda807ebcabf39823c47e9409485b5a3a1db3a8c6ce763e" + ], + "version": "==1.16.3" + }, + "plac": { + "hashes": [ + "sha256:854693ad90367e8267112ffbb8955f57d6fdeac3191791dc9ffce80f87fd2370", + "sha256:ba3f719a018175f0a15a6b04e6cc79c25fd563d348aacd320c3644d2a9baf89b" + ], + "version": "==0.9.6" + }, + "preshed": { + "hashes": [ + "sha256:0c9af79c7b825793f987d477627efb81afd23384ac791bebbc88a257342a77ab", + "sha256:0ebc79431154bc5d12f97b3c93bc350af941702a44f0761dfcd395e970d693f8", + "sha256:102e71dc841c979b2ece44ab05b2b0aa39c8039493ddac40dd22cf23e2484063", + "sha256:15145b24eded01426544be829a6395d6c99e2d62f5f3b88a6e19087ebeef7237", + "sha256:195674dfb4bcf18b26e448feaabdf61adcf028ae69ecaa075c0bdfaf62a19671", + "sha256:38f7fbef59f89d3b2c8c3b102f9a7360cd73a33c829fdeb101c615b18ecc4686", + "sha256:3aa411233dc230247ea4c4558062e5b2d59d41c697107a45fddbfe03e63f3e77", + "sha256:3b8c7b607e6dce0843544cfe4f05355db0516fce8eca0c37d6b5f4f3680493bf", + "sha256:4bda4153d46a603bc6ea65380dfa091d46700f664cb906c7f26a469be6c2a503", + "sha256:541d7ed765d67512d6f9fa24fd01cc1d7a51c7ff2646362924f4db46813b485a", + "sha256:593d23b9f851ae7a4d519ca4489dd2b352d833e08f5d35795d42a591b8badb54", + "sha256:7f6fb8f4108abe958af892847ed50abe6f45aaf45a87853cc8154a7203e75d84", + "sha256:7ff7f18af1f19ea666ac4fbf48842e6acd900fbfdc26bb9aad02f353ff932386", + "sha256:9c0d503d8693bf1e08e0fa1cecbcd3253146abaa9a7501d7d583a72edd29fdd1", + "sha256:9cefe818a97134c0ddf22ef76fced1c841ebd137c2895251c5d1310276c234b5", + "sha256:9e603916a95dc524081d54c0a135611e6f68d787185d5df2b5ab3f076c3d1bd4", + "sha256:a2acacceac79aa6d4b65125e20c7de78fbca1340a251854c87967acef1795490", + "sha256:a3d592e7b265b4faf08c9b4d7493b9e8604e0ba8858cc9bd8c9aee41d3df2a3a", + "sha256:b2030e68c6f539e6dd7bfcea032940042739ef05d50a2eb1d7af24e038971b0f", + "sha256:bc894dc14d8567a5d6a1cded0a701da7fbb360b2124237fe8acde85333825aef", + "sha256:c21d4d10cc0248ba3facbbbfbe63211ce921478a3d5db6de34de39ee1b3484e1", + "sha256:dae01c74313965c487e0ec839e5f28d0c7df9bfd1d978aa5bada3f72ff20a9e5", + "sha256:ee8068035684a4b382bebb3a3f270799360545baff9742b85e627a0a889e6850" + ], + "version": "==2.0.1" + }, + "protobuf": { + "hashes": [ + "sha256:21e395d7959551e759d604940a115c51c6347d90a475c9baf471a1a86b5604a9", + "sha256:57e05e16955aee9e6a0389fcbd58d8289dd2420e47df1a1096b3a232c26eb2dd", + "sha256:67819e8e48a74c68d87f25cad9f40edfe2faf278cdba5ca73173211b9213b8c9", + "sha256:75da7d43a2c8a13b0bc7238ab3c8ae217cbfd5979d33b01e98e1f78defb2d060", + "sha256:78e08371e236f193ce947712c072542ff19d0043ab5318c2ea46bbc2aaebdca6", + "sha256:7ee5b595db5abb0096e8c4755e69c20dfad38b2d0bcc9bc7bafc652d2496b471", + "sha256:86260ecfe7a66c0e9d82d2c61f86a14aa974d340d159b829b26f35f710f615db", + "sha256:92c77db4bd33ea4ee5f15152a835273f2338a5246b2cbb84bab5d0d7f6e9ba94", + "sha256:9c7b90943e0e188394b4f068926a759e3b4f63738190d1ab3d500d53b9ce7614", + "sha256:a77f217ea50b2542bae5b318f7acee50d9fc8c95dd6d3656eaeff646f7cab5ee", + "sha256:ad589ed1d1f83db22df867b10e01fe445516a5a4d7cfa37fe3590a5f6cfc508b", + "sha256:b06a794901bf573f4b2af87e6139e5cd36ac7c91ac85d7ae3fe5b5f6fc317513", + "sha256:bd8592cc5f8b4371d0bad92543370d4658dc41a5ccaaf105597eb5524c616291", + "sha256:be48e5a6248a928ec43adf2bea037073e5da692c0b3c10b34f9904793bd63138", + "sha256:cc5eb13f5ccc4b1b642cc147c2cdd121a34278b341c7a4d79e91182fff425836", + "sha256:cd3b0e0ad69b74ee55e7c321f52a98effed2b4f4cc9a10f3683d869de00590d5", + "sha256:d6e88c4920660aa75c0c2c4b53407aef5efd9a6e0ca7d2fc84d79aba2ccbda3a", + "sha256:ec3c49b6d247152e19110c3a53d9bb4cf917747882017f70796460728b02722e" + ], + "index": "pypi", + "version": "==3.7.1" + }, + "pygments": { + "hashes": [ + "sha256:5ffada19f6203563680669ee7f53b64dabbeb100eb51b61996085e99c03b284a", + "sha256:e8218dd399a61674745138520d0d4cf2621d7e032439341bc3f647bff125818d" + ], + "version": "==2.3.1" + }, + "pyre2": { + "file": "https://github.com/torosent/pyre2/archive/release/0.2.23.zip" + }, + "pyyaml": { + "hashes": [ + "sha256:1adecc22f88d38052fb787d959f003811ca858b799590a5eaa70e63dca50308c", + "sha256:436bc774ecf7c103814098159fbb84c2715d25980175292c648f2da143909f95", + "sha256:460a5a4248763f6f37ea225d19d5c205677d8d525f6a83357ca622ed541830c2", + "sha256:5a22a9c84653debfbf198d02fe592c176ea548cccce47553f35f466e15cf2fd4", + "sha256:7a5d3f26b89d688db27822343dfa25c599627bc92093e788956372285c6298ad", + "sha256:9372b04a02080752d9e6f990179a4ab840227c6e2ce15b95e1278456664cf2ba", + "sha256:a5dcbebee834eaddf3fa7366316b880ff4062e4bcc9787b78c7fbb4a26ff2dd1", + "sha256:aee5bab92a176e7cd034e57f46e9df9a9862a71f8f37cad167c6fc74c65f5b4e", + "sha256:c51f642898c0bacd335fc119da60baae0824f2cde95b0330b56c0553439f0673", + "sha256:c68ea4d3ba1705da1e0d85da6684ac657912679a649e8868bd850d2c299cce13", + "sha256:e23d0cc5299223dcc37885dae624f382297717e459ea24053709675a976a3e19" + ], + "version": "==5.1" + }, + "regex": { + "hashes": [ + "sha256:020429dcf9b76cc7648a99c81b3a70154e45afebc81e0b85364457fe83b525e4", + "sha256:0552802b1c3f3c7e4fee8c85e904a13c48226020aa1a0593246888a1ac55aaaf", + "sha256:308965a80b92e1fec263ac1e4f1094317809a72bc4d26be2ec8a5fd026301175", + "sha256:4d627feef04eb626397aa7bdec772774f53d63a1dc7cc5ee4d1bd2786a769d19", + "sha256:93d1f9fcb1d25e0b4bd622eeba95b080262e7f8f55e5b43c76b8a5677e67334c", + "sha256:c3859bbf29b1345d694f069ddfe53d6907b0393fda5e3794c800ad02902d78e9", + "sha256:d56ce4c7b1a189094b9bee3b81c4aeb3f1ba3e375e91627ec8561b6ab483d0a8", + "sha256:ebc5ef4e10fa3312fa1967dc0a894e6bd985a046768171f042ac3974fadc9680", + "sha256:f9cd39066048066a4abe4c18fb213bc541339728005e72263f023742fb912585" + ], + "index": "pypi", + "version": "==2019.4.14" + }, + "requests": { + "hashes": [ + "sha256:502a824f31acdacb3a35b6690b5fbf0bc41d63a24a45c4004352b0242707598e", + "sha256:7bf2a778576d825600030a110f3c0e3e8edc51dfaafe1c146e39a2027784957b" + ], + "version": "==2.21.0" + }, + "requests-file": { + "hashes": [ + "sha256:75c175eed739270aec3c5279ffd74e6527dada275c5c0d76b5817e9c86bb7dea", + "sha256:8f04aa6201bacda0567e7ac7f677f1499b0fc76b22140c54bc06edf1ba92e2fa" + ], + "version": "==1.4.3" + }, + "six": { + "hashes": [ + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + ], + "version": "==1.12.0" + }, + "spacy": { + "hashes": [ + "sha256:0fe2e5905f2f5b41be3ebea40626f70bea567a7a2cda9c244109fffe8d964429", + "sha256:30f0f09074bf115a0384691e8ba3d64aab431192b3095a13312a93d0e8a71c07", + "sha256:6a82612f0e75c11d541002f49375d80b4800c967e5d2b402d5a8dd40b6c57ae6", + "sha256:74066ac969a587d16d00d65318c1baa3c3e9215e6858d0c81ce2823320fe09dc", + "sha256:b1b86ddf6142fa2782b2e0269d040430ae5696eb0224f3e99408897cac7bb506", + "sha256:be8a7c89461ac22d261e19e1d3eb35752d8ff3e52452af076b303561bb166408", + "sha256:e6522e1242a5a5f12ef7e55f74df020b5deea59f7d1e7b6e69298301e3c0badd", + "sha256:eb699f54bf6d131df701e6dbbef9e91b74a065a42c9d2850964282b3c14560bb", + "sha256:f385942c5b2c8cf07e4a56871f88a49d4c8a9145fcd731c455e39fb5af9b12ba" + ], + "index": "pypi", + "version": "==2.1.3" + }, + "srsly": { + "hashes": [ + "sha256:02ea974c4b80f9ffdea4f953ffece5a8715e4e4b37d09192ab65cf4edfbf74d1", + "sha256:061ade35556e51b2e1da6f8552be7a6327d2d02b69edf0aacc9f5c4319d495f1", + "sha256:1bf6af7a86f34969a3997da09fc8c2f72ee02cd74ff40035e37c2f968776fa23", + "sha256:1e4ef85bf133e384f465865ba4e0a14a52c4f2e4b46c763faf100339a06f09c4", + "sha256:850399e43f4cefdcac7a913363b120ea084cb02fcfdbbde1bd37444804d7def4", + "sha256:977aa6e5fd3f7e9d1c8fe7aeed841dfe3ede75dfce04255d4c670e663faaef2a", + "sha256:abdc5b46866648b123517550582dc4c4b767b816ae54c44e5973bbebc3f0dab4", + "sha256:ac0dbe6e715e1fe3536397a9e65ec8f3c624c99f45b6f30e87d220071ef84721", + "sha256:b8646f0f7cf6fd1de4919ab456d9c030e09e74f741a0cecc941363414109ccdc", + "sha256:b9dc81339c1ab969057e790d7b2a56fd4da87336785bd671c86520e8272e3663", + "sha256:d7c91f59edc2ceeca70adf1b0a46d337234ff4fb7ca2b579ca41885f011b329f", + "sha256:d906a2a3df1cac2cb4bf382b8aaf14e22df2ca3758eba0d3049723c851c8ebf0", + "sha256:ecec49c9cdaae4594011666dd654e1e044e552f63bb3a62a1849c65a92ee302e", + "sha256:ef7897050c04a313f2db99c9bcaf2f0c3c75609677683ca5a6e1e7a515325d72" + ], + "version": "==0.0.5" + }, + "tabulate": { + "hashes": [ + "sha256:8af07a39377cee1103a5c8b3330a421c2d99b9141e9cc5ddd2e3263fea416943" + ], + "version": "==0.8.3" + }, + "thinc": { + "hashes": [ + "sha256:12c003b804fb93c64261a5010df0129f942234adb8f45d489a355a5315e06acf", + "sha256:17f9ada01f1f77a5560bc16ec5a650dca08356b50727ded0df19f0dfb4a32a25", + "sha256:26c9d54ffd90753feebbc462ae59939a9e3d2485ef24ed3dc1861c9b486fdbbe", + "sha256:3258161fc2cefa4082f099dec3748f1dcef5e920df5e9d82258ea6ffec280b9a", + "sha256:38a83b928cdc49c994852538f639b2a889681a0589c44b1a6fc3c899e5f36893", + "sha256:3e76101a733bbb0b97d44bdbcb407678b9e2b487047acb6f4c19b72909a6b12f", + "sha256:412f107c458d2951711b4d3ec53587244cd3acc032944e855f49cf94a1adc36e", + "sha256:4948c10c61e627950900cdccf506eb7398d2b28f33cf72bb4b5d9c5c572925e7", + "sha256:a8b2d7713a7dfc0b18b5c16db58ab6e015df14e4fbed0249ed49e630b2d6a86f", + "sha256:ec99c2c65962157c7ee7b947d29f2775291860b81cba62c5bd9f92fdeca2d137", + "sha256:f2386e66042218f19e511692926cef00a9646a3104d2efddfb5bec7b0388a83b", + "sha256:fc0b37733591315afddee45823d4f6740f9b0567c1ba57a3a3c319669d1fcbad" + ], + "version": "==7.0.4" + }, + "tldextract": { + "hashes": [ + "sha256:2c1c5d9d454f79734b4f3da0d603856dd9f820753410a3e9abf0a0c9fde33e97", + "sha256:b72bef6013de67c7fa181250bc2c2e089a994d259c09ca95a9771f2f97e29ed1" + ], + "index": "pypi", + "version": "==2.2.1" + }, + "tqdm": { + "hashes": [ + "sha256:d385c95361699e5cf7622485d9b9eae2d4864b21cd5a2374a9c381ffed701021", + "sha256:e22977e3ebe961f72362f6ddfb9197cc531c9737aaf5f607ef09740c849ecd05" + ], + "version": "==4.31.1" + }, + "urllib3": { + "hashes": [ + "sha256:2393a695cd12afedd0dcb26fe5d50d0cf248e5a66f75dbd89a3d4eb333a61af4", + "sha256:a637e5fae88995b256e3409dc4d52c2e2e0ba32c42a6365fee8bbd2238de3cfb" + ], + "version": "==1.24.3" + }, + "wasabi": { + "hashes": [ + "sha256:b4fbee9dd0c8f5cff6554c0463c565e2d52b7c844d7eccb477d29a6ff8567750", + "sha256:f92c83e728bf1db6dc859ffc861afa328d2da8ef0c7a19300e5fb1bd5762b277" + ], + "version": "==0.2.2" + } + }, + "develop": { + "astroid": { + "hashes": [ + "sha256:6560e1e1749f68c64a4b5dee4e091fce798d2f0d84ebe638cf0e0585a343acf4", + "sha256:b65db1bbaac9f9f4d190199bb8680af6f6f84fd3769a5ea883df8a91fe68b4c4" + ], + "version": "==2.2.5" + }, + "atomicwrites": { + "hashes": [ + "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", + "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" + ], + "version": "==1.3.0" + }, + "attrs": { + "hashes": [ + "sha256:69c0dbf2ed392de1cb5ec704444b08a5ef81680a61cb899dc08127123af36a79", + "sha256:f0b870f674851ecbfbbbd364d6b5cbdff9dcedbc7f3f5e18a6891057f21fe399" + ], + "version": "==19.1.0" + }, + "entrypoints": { + "hashes": [ + "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", + "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" + ], + "version": "==0.3" + }, + "flake8": { + "hashes": [ + "sha256:859996073f341f2670741b51ec1e67a01da142831aa1fdc6242dbf88dffbe661", + "sha256:a796a115208f5c03b18f332f7c11729812c8c3ded6c46319c59b53efd3819da8" + ], + "index": "pypi", + "version": "==3.7.7" + }, + "isort": { + "hashes": [ + "sha256:1349c6f7c2a0f7539f5f2ace51a9a8e4a37086ce4de6f78f5f53fb041d0a3cd5", + "sha256:f09911f6eb114e5592abe635aded8bf3d2c3144ebcfcaf81ee32e7af7b7d1870" + ], + "version": "==4.3.18" + }, + "lazy-object-proxy": { + "hashes": [ + "sha256:0ce34342b419bd8f018e6666bfef729aec3edf62345a53b537a4dcc115746a33", + "sha256:1b668120716eb7ee21d8a38815e5eb3bb8211117d9a90b0f8e21722c0758cc39", + "sha256:209615b0fe4624d79e50220ce3310ca1a9445fd8e6d3572a896e7f9146bbf019", + "sha256:27bf62cb2b1a2068d443ff7097ee33393f8483b570b475db8ebf7e1cba64f088", + "sha256:27ea6fd1c02dcc78172a82fc37fcc0992a94e4cecf53cb6d73f11749825bd98b", + "sha256:2c1b21b44ac9beb0fc848d3993924147ba45c4ebc24be19825e57aabbe74a99e", + "sha256:2df72ab12046a3496a92476020a1a0abf78b2a7db9ff4dc2036b8dd980203ae6", + "sha256:320ffd3de9699d3892048baee45ebfbbf9388a7d65d832d7e580243ade426d2b", + "sha256:50e3b9a464d5d08cc5227413db0d1c4707b6172e4d4d915c1c70e4de0bbff1f5", + "sha256:5276db7ff62bb7b52f77f1f51ed58850e315154249aceb42e7f4c611f0f847ff", + "sha256:61a6cf00dcb1a7f0c773ed4acc509cb636af2d6337a08f362413c76b2b47a8dd", + "sha256:6ae6c4cb59f199d8827c5a07546b2ab7e85d262acaccaacd49b62f53f7c456f7", + "sha256:7661d401d60d8bf15bb5da39e4dd72f5d764c5aff5a86ef52a042506e3e970ff", + "sha256:7bd527f36a605c914efca5d3d014170b2cb184723e423d26b1fb2fd9108e264d", + "sha256:7cb54db3535c8686ea12e9535eb087d32421184eacc6939ef15ef50f83a5e7e2", + "sha256:7f3a2d740291f7f2c111d86a1c4851b70fb000a6c8883a59660d95ad57b9df35", + "sha256:81304b7d8e9c824d058087dcb89144842c8e0dea6d281c031f59f0acf66963d4", + "sha256:933947e8b4fbe617a51528b09851685138b49d511af0b6c0da2539115d6d4514", + "sha256:94223d7f060301b3a8c09c9b3bc3294b56b2188e7d8179c762a1cda72c979252", + "sha256:ab3ca49afcb47058393b0122428358d2fbe0408cf99f1b58b295cfeb4ed39109", + "sha256:bd6292f565ca46dee4e737ebcc20742e3b5be2b01556dafe169f6c65d088875f", + "sha256:cb924aa3e4a3fb644d0c463cad5bc2572649a6a3f68a7f8e4fbe44aaa6d77e4c", + "sha256:d0fc7a286feac9077ec52a927fc9fe8fe2fabab95426722be4c953c9a8bede92", + "sha256:ddc34786490a6e4ec0a855d401034cbd1242ef186c20d79d2166d6a4bd449577", + "sha256:e34b155e36fa9da7e1b7c738ed7767fc9491a62ec6af70fe9da4a057759edc2d", + "sha256:e5b9e8f6bda48460b7b143c3821b21b452cb3a835e6bbd5dd33aa0c8d3f5137d", + "sha256:e81ebf6c5ee9684be8f2c87563880f93eedd56dd2b6146d8a725b50b7e5adb0f", + "sha256:eb91be369f945f10d3a49f5f9be8b3d0b93a4c2be8f8a5b83b0571b8123e0a7a", + "sha256:f460d1ceb0e4a5dcb2a652db0904224f367c9b3c1470d5a7683c0480e582468b" + ], + "version": "==1.3.1" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "more-itertools": { + "hashes": [ + "sha256:2112d2ca570bb7c3e53ea1a35cd5df42bb0fd10c45f0fb97178679c3c03d64c7", + "sha256:c3e4748ba1aad8dba30a4886b0b1a2004f9a863837b8654e7059eebf727afa5a" + ], + "markers": "python_version > '2.7'", + "version": "==7.0.0" + }, + "pluggy": { + "hashes": [ + "sha256:19ecf9ce9db2fce065a7a0586e07cfb4ac8614fe96edf628a264b1c70116cf8f", + "sha256:84d306a647cc805219916e62aab89caa97a33a1dd8c342e87a37f91073cd4746" + ], + "version": "==0.9.0" + }, + "py": { + "hashes": [ + "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", + "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" + ], + "version": "==1.8.0" + }, + "pycodestyle": { + "hashes": [ + "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", + "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" + ], + "version": "==2.5.0" + }, + "pyflakes": { + "hashes": [ + "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", + "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" + ], + "version": "==2.1.1" + }, + "pylint": { + "hashes": [ + "sha256:5d77031694a5fb97ea95e828c8d10fc770a1df6eb3906067aaed42201a8a6a09", + "sha256:723e3db49555abaf9bf79dc474c6b9e2935ad82230b10c1138a71ea41ac0fff1" + ], + "index": "pypi", + "version": "==2.3.1" + }, + "pytest": { + "hashes": [ + "sha256:3773f4c235918987d51daf1db66d51c99fac654c81d6f2f709a046ab446d5e5d", + "sha256:b7802283b70ca24d7119b32915efa7c409982f59913c1a6c0640aacf118b95f5" + ], + "index": "pypi", + "version": "==4.4.1" + }, + "six": { + "hashes": [ + "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", + "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" + ], + "version": "==1.12.0" + }, + "typed-ast": { + "hashes": [ + "sha256:132eae51d6ef3ff4a8c47c393a4ef5ebf0d1aecc96880eb5d6c8ceab7017cc9b", + "sha256:18141c1484ab8784006c839be8b985cfc82a2e9725837b0ecfa0203f71c4e39d", + "sha256:2baf617f5bbbfe73fd8846463f5aeafc912b5ee247f410700245d68525ec584a", + "sha256:3d90063f2cbbe39177e9b4d888e45777012652d6110156845b828908c51ae462", + "sha256:4304b2218b842d610aa1a1d87e1dc9559597969acc62ce717ee4dfeaa44d7eee", + "sha256:4983ede548ffc3541bae49a82675996497348e55bafd1554dc4e4a5d6eda541a", + "sha256:5315f4509c1476718a4825f45a203b82d7fdf2a6f5f0c8f166435975b1c9f7d4", + "sha256:6cdfb1b49d5345f7c2b90d638822d16ba62dc82f7616e9b4caa10b72f3f16649", + "sha256:7b325f12635598c604690efd7a0197d0b94b7d7778498e76e0710cd582fd1c7a", + "sha256:8d3b0e3b8626615826f9a626548057c5275a9733512b137984a68ba1598d3d2f", + "sha256:8f8631160c79f53081bd23446525db0bc4c5616f78d04021e6e434b286493fd7", + "sha256:912de10965f3dc89da23936f1cc4ed60764f712e5fa603a09dd904f88c996760", + "sha256:b010c07b975fe853c65d7bbe9d4ac62f1c69086750a574f6292597763781ba18", + "sha256:c908c10505904c48081a5415a1e295d8403e353e0c14c42b6d67f8f97fae6616", + "sha256:c94dd3807c0c0610f7c76f078119f4ea48235a953512752b9175f9f98f5ae2bd", + "sha256:ce65dee7594a84c466e79d7fb7d3303e7295d16a83c22c7c4037071b059e2c21", + "sha256:eaa9cfcb221a8a4c2889be6f93da141ac777eb8819f077e1d09fb12d00a09a93", + "sha256:f3376bc31bad66d46d44b4e6522c5c21976bf9bca4ef5987bb2bf727f4506cbb", + "sha256:f9202fa138544e13a4ec1a6792c35834250a85958fde1251b6a22e07d1260ae7" + ], + "markers": "implementation_name == 'cpython'", + "version": "==1.3.5" + }, + "wrapt": { + "hashes": [ + "sha256:4aea003270831cceb8a90ff27c4031da6ead7ec1886023b80ce0dfe0adf61533" + ], + "version": "==1.11.1" + } + } +} From 5e79f66cb8b05a30da64af51aefe2919e902b262 Mon Sep 17 00:00:00 2001 From: Natasa Manousopoulou Date: Mon, 9 Sep 2019 12:57:15 +0300 Subject: [PATCH 69/75] Create SECURITY.MD (#217) --- SECURITY.MD | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 SECURITY.MD diff --git a/SECURITY.MD b/SECURITY.MD new file mode 100644 index 000000000..f4357d6b6 --- /dev/null +++ b/SECURITY.MD @@ -0,0 +1,31 @@ +## Security + +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), [Xamarin](https://github.com/xamarin), and [many more](https://opensource.microsoft.com/). + +If you believe you have found a security vulnerability in any Microsoft-owned repository that meets Microsoft's [definition](https://docs.microsoft.com/en-us/previous-versions/tn-archive/cc751383(v=technet.10)) of a security vulnerability, please report it to us as described below. + +## Reporting Security Issues + +**Please do not report security vulnerabilities through public GitHub issues.** Instead, please report them to the Microsoft Security Response Center at [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://technet.microsoft.com/en-us/security/dn606155). + +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). + +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: + + * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) + * Full paths of source file(s) related to the manifestation of the issue + * The location of the affected source code (tag/branch/commit or direct URL) + * Any special configuration required to reproduce the issue + * Step-by-step instructions to reproduce the issue + * Proof-of-concept or exploit code (if possible) + * Impact of the issue, including how an attacker might exploit the issue + +This information will help us triage your report more quickly. + +## Preferred Languages + +We prefer all communications to be in English. + +## Policy + +Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://www.microsoft.com/en-us/msrc/cvd). From 6a05689a6f8ad79356ac6a7d730ce14fd2c7cf33 Mon Sep 17 00:00:00 2001 From: Omri Mendels Date: Mon, 9 Sep 2019 16:31:57 +0300 Subject: [PATCH 70/75] Note on long build time (#214) * Note on long build time Co-Authored-By: Elad Iwanir <13205761+eladiw@users.noreply.github.com> --- docs/install.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/install.md b/docs/install.md index df7a6b82f..1e6fa7611 100644 --- a/docs/install.md +++ b/docs/install.md @@ -24,6 +24,8 @@ sleep 30 # Wait for the analyzer model to load docker run --rm --name presidio-api --network mynetwork -d -p 8080:8080 -e WEB_PORT=8080 -e ANALYZER_SVC_ADDRESS=presidio-analyzer:3000 -e ANONYMIZER_SVC_ADDRESS=presidio-anonymizer:3001 -e RECOGNIZERS_STORE_SVC_ADDRESS=presidio-recognizers-store:3004 ${DOCKER_REGISTRY}/presidio-api:${PRESIDIO_LABEL} ``` +**NOTE: Building the deps images currently takes some time** (~70 minutes, depending on the build machine). We are working on improving the build time through improving the build and providing pre-built dependencies. + --- ## Presidio As a Service From 307e687723305c517f2d367e4c8b4490ac2c8bcd Mon Sep 17 00:00:00 2001 From: Omri Mendels Date: Tue, 10 Sep 2019 10:52:55 +0300 Subject: [PATCH 71/75] Changed US_DRIVER_LICENSE regexes to be more specific. (#218) Minor refactor to context calculation added a test with the demo text --- .../analyzer/entity_recognizer.py | 94 +++++++++---------- .../us_driver_license_recognizer.py | 4 +- .../tests/test_analyzer_engine.py | 37 +++++++- .../test_us_driver_license_recognizer.py | 4 +- 4 files changed, 83 insertions(+), 56 deletions(-) diff --git a/presidio-analyzer/analyzer/entity_recognizer.py b/presidio-analyzer/analyzer/entity_recognizer.py index faab378fa..924941063 100644 --- a/presidio-analyzer/analyzer/entity_recognizer.py +++ b/presidio-analyzer/analyzer/entity_recognizer.py @@ -9,7 +9,7 @@ class EntityRecognizer: MAX_SCORE = 1.0 CONTEXT_SIMILARITY_THRESHOLD = 0.65 CONTEXT_SIMILARITY_FACTOR = 0.35 - MIN_SCORE_WITH_CONTEXT_SIMILARITY = 0.6 + MIN_SCORE_WITH_CONTEXT_SIMILARITY = 0.4 CONTEXT_PREFIX_COUNT = 5 CONTEXT_SUFFIX_COUNT = 0 @@ -95,7 +95,7 @@ def from_dict(cls, entity_recognizer_dict): return cls(**entity_recognizer_dict) def enhance_using_context(self, text, raw_results, - nlp_artifacts, predefined_context_words): + nlp_artifacts, recognizer_context_words): """ using the surrounding words of the actual word matches, look for specific strings that if found contribute to the score of the result, improving the confidence that the match is @@ -107,7 +107,7 @@ def enhance_using_context(self, text, raw_results, :param nlp_artifacts: The nlp artifacts contains elements such as lemmatized tokens for better accuracy of the context enhancement process - :param predefined_context_words: The words the current recognizer + :param recognizer_context_words: The words the current recognizer supports (words to lookup) """ # create a deep copy of the results object so we can manipulate it @@ -118,20 +118,23 @@ def enhance_using_context(self, text, raw_results, self.logger.warning('[%s]. NLP artifacts were not provided', self.name) return results - if predefined_context_words is None or predefined_context_words == []: + if recognizer_context_words is None or recognizer_context_words == []: self.logger.info("recognizer '%s' does not support context " "enhancement", self.name) return results for result in results: - # extract lemmatized context from the surronding of the match - context = self.__extract_context( + # extract lemmatized context from the surrounding of the match + + word = text[result.start:result.end] + + surrounding_words = self.__extract_surrounding_words( nlp_artifacts=nlp_artifacts, - word=text[result.start:result.end], + word=word, start=result.start) - supportive_context_word = self.__find_supportive_context_word( - context, predefined_context_words) + supportive_context_word = self.__find_supportive_word_in_context( + surrounding_words, recognizer_context_words) if supportive_context_word != "": result.score += \ self.CONTEXT_SIMILARITY_FACTOR @@ -153,35 +156,30 @@ def enhance_using_context(self, text, raw_results, def __context_to_keywords(context): return context.split(' ') - def __find_supportive_context_word(self, - context_text, - context_list): + def __find_supportive_word_in_context(self, + context_list, + recognizer_context_list): """A word is considered a supportive context word if there's exact match between a keyword in context_text and any keyword in context_list - :param context_text words before and after the matched enitity within + :param context_list words before and after the matched entity within a specified window size - :param context_list a list of words considered as context keywords - manually specified by the recognizer's author + :param recognizer_context_list a list of words considered as + context keywords manually specified by the recognizer's author """ word = "" # If the context list is empty, no need to continue - if context_list is None: - return word - - # Take the context text and break it into individual keywords - lemmatized_keywords = self.__context_to_keywords(context_text) - if lemmatized_keywords is None: + if context_list is None or recognizer_context_list is None: return word - for predefined_context_word in context_list: + for predefined_context_word in recognizer_context_list: # result == true only if any of the predefined context words # is found exactly or as a substring in any of the collected # context words result = \ - next((True for keyword in lemmatized_keywords + next((True for keyword in context_list if predefined_context_word in keyword), False) if result: self.logger.debug("Found context keyword '%s'", @@ -196,7 +194,6 @@ def __add_n_words(index, n_words, lemmas, lemmatized_filtered_keywords, - prefix, is_backward): """ Prepare a string of context words, which surrounds a lemma at a given index. The words will be collected only if exist @@ -205,14 +202,14 @@ def __add_n_words(index, :param index: index of the lemma that its surrounding words we want :param n_words: number of words to take :param lemmas: array of lemmas - :param lemmatized_filtered_keywords: the array of filter - lemmas, - :param prefix: string to be attached to the results as a prefix + :param lemmatized_filtered_keywords: the array of filtered + lemmas from the original sentence, :param is_backward: if true take the preceeding words, if false, take the successing words """ i = index - # The entity itself is no intrest to us...however we want to + context_words = [] + # The entity itself is no interest to us...however we want to # consider it anyway for cases were it is attached with no spaces # to an interesting context word, so we allow it and add 1 to # the number of collected words @@ -222,38 +219,33 @@ def __add_n_words(index, while 0 <= i < len(lemmas) and remaining > 0: lower_lemma = lemmas[i].lower() if lower_lemma in lemmatized_filtered_keywords: + context_words.append(lower_lemma) remaining -= 1 - prefix += ' ' + lower_lemma - i = i-1 if is_backward else i+1 - return prefix + return context_words def __add_n_words_forward(self, index, n_words, lemmas, - lemmatized_filtered_keywords, - prefix): + lemmatized_filtered_keywords): return self.__add_n_words( index, n_words, lemmas, lemmatized_filtered_keywords, - prefix, False) def __add_n_words_backward(self, index, n_words, lemmas, - lemmatized_filtered_keywords, - prefix): + lemmatized_filtered_keywords): return self. __add_n_words( index, n_words, lemmas, lemmatized_filtered_keywords, - prefix, True) @staticmethod @@ -280,11 +272,11 @@ def find_index_of_match_token(word, start, tokens, tokens_indices): if not found: raise ValueError("Did not find word '" + word + "' " - "in the list of tokens altough it " + "in the list of tokens although it " "is expected to be found") return i - def __extract_context(self, nlp_artifacts, word, start): + def __extract_surrounding_words(self, nlp_artifacts, word, start): """ Extracts words surronding another given word. The text from which the context is extracted is given in the nlp doc @@ -317,20 +309,22 @@ def __extract_context(self, nlp_artifacts, word, start): nlp_artifacts.tokens_indices) # index i belongs to the PII entity, take the preceding n words - # and the successing m words into a context string - context_str = '' - context_str = \ + # and the successing m words into a context list + + backward_context = \ self.__add_n_words_backward(token_index, EntityRecognizer.CONTEXT_PREFIX_COUNT, nlp_artifacts.lemmas, - lemmatized_keywords, - context_str) - context_str = \ + lemmatized_keywords) + forward_context = \ self.__add_n_words_forward(token_index, EntityRecognizer.CONTEXT_SUFFIX_COUNT, nlp_artifacts.lemmas, - lemmatized_keywords, - context_str) - - self.logger.debug('Context sentence is: %s', context_str) - return context_str + lemmatized_keywords) + + context_list = [] + context_list.extend(backward_context) + context_list.extend(forward_context) + context_list = list(set(context_list)) + self.logger.debug('Context list is: %s', " ".join(context_list)) + return context_list diff --git a/presidio-analyzer/analyzer/predefined_recognizers/us_driver_license_recognizer.py b/presidio-analyzer/analyzer/predefined_recognizers/us_driver_license_recognizer.py index 3fd3cd2f8..77002569c 100644 --- a/presidio-analyzer/analyzer/predefined_recognizers/us_driver_license_recognizer.py +++ b/presidio-analyzer/analyzer/predefined_recognizers/us_driver_license_recognizer.py @@ -15,10 +15,10 @@ # pylint: disable=line-too-long,abstract-method WA_WEAK_REGEX = r'\b((?=.*\d)([A-Z][A-Z0-9*]{11})|(?=.*\*)([A-Z][A-Z0-9*]{11}))\b' # noqa: E501 ALPHANUMERIC_REGEX = r'\b([A-Z][0-9]{3,6}|[A-Z][0-9]{5,9}|[A-Z][0-9]{6,8}|[A-Z][0-9]{4,8}|[A-Z][0-9]{9,11}|[A-Z]{1,2}[0-9]{5,6}|H[0-9]{8}|V[0-9]{6}|X[0-9]{8}|A-Z]{2}[0-9]{2,5}|[A-Z]{2}[0-9]{3,7}|[0-9]{2}[A-Z]{3}[0-9]{5,6}|[A-Z][0-9]{13,14}|[A-Z][0-9]{18}|[A-Z][0-9]{6}R|[A-Z][0-9]{9}|[A-Z][0-9]{1,12}|[0-9]{9}[A-Z]|[A-Z]{2}[0-9]{6}[A-Z]|[0-9]{8}[A-Z]{2}|[0-9]{3}[A-Z]{2}[0-9]{4}|[A-Z][0-9][A-Z][0-9][A-Z]|[0-9]{7,8}[A-Z])\b' # noqa: E501 -DIGITS_REGEX = r'\b([0-9]{1,9}|[0-9]{4,10}|[0-9]{6,10}|[0-9]{1,12}|[0-9]{12,14}|[0-9]{16})\b' # noqa: E501 +DIGITS_REGEX = r'\b([0-9]{6,14}|[0-9]{16})\b' # noqa: E501 LICENSE_CONTEXT = [ - "driver", "license", "permit", "id", "lic", "identification", + "driver", "license", "permit", "lic", "identification", "dl", "dls", "cdls", "id", "lic#", "driving" ] diff --git a/presidio-analyzer/tests/test_analyzer_engine.py b/presidio-analyzer/tests/test_analyzer_engine.py index 5cbca3993..5e79a1265 100644 --- a/presidio-analyzer/tests/test_analyzer_engine.py +++ b/presidio-analyzer/tests/test_analyzer_engine.py @@ -317,7 +317,7 @@ def test_when_allFields_is_true_return_all_fields(self): assert "DOMAIN_NAME" in returned_entities def test_when_allFields_is_true_full_recognizers_list_return_all_fields( - self): + self): analyze_engine = AnalyzerEngine(registry=RecognizerRegistry(), nlp_engine=loaded_spacy_nlp_engine) request = AnalyzeRequest() @@ -429,4 +429,37 @@ def test_when_default_threshold_is_zero_all_results_pass(self): entities, language, all_fields=False) - assert len(results) == 2 \ No newline at end of file + assert len(results) == 2 + + def test_demo_text(self): + text = "Here are a few examples of entities we currently support: \n" \ + "Credit card: 4095-2609-9393-4932 \n" \ + "Crypto wallet id: 16Yeky6GMjeNkAiNcBY7ZhrLoMSgg1BoyZ \n" \ + "DateTime: September 18 n" \ + "Domain: microsoft.com \n" \ + "Email address: test@presidio.site \n" \ + "IBAN code: IL150120690000003111111 \n" \ + "IP: 192.168.0.1 i\n" \ + "Person name: David Johnson\n" \ + "Bank account: 2854567876542\n" \ + "Driver license number: H12234567\n" \ + "Passport: 912803456\n" \ + "Phone number: (212) 555-1234.\n" \ + "Social security number: 078-05-1120\n" \ + "" \ + "This project welcomes contributions and suggestions. Most contributions require you to agree to a " \ + "Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us " \ + "the rights to use your contribution. For details, visit https://cla.microsoft.com.\n" \ + "When you submit a pull request, a CLA-bot will automatically determine whether you need to provide " \ + "a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions " \ + "provided by the bot. You will only need to do this once across all repos using our CLA.\n\n" \ + "This project has adopted the Microsoft Open Source Code of Conduct. For more information see the " \ + "Code of Conduct FAQ or contact opencode@microsoft.com with any additional questions or comments." + + language = "en" + + analyzer_engine = AnalyzerEngine(default_score_threshold=0.6) + results = analyzer_engine.analyze(correlation_id=self.unit_test_guid, text=text, entities=None, + language=language, all_fields=True) + + assert len(results) == 15 diff --git a/presidio-analyzer/tests/test_us_driver_license_recognizer.py b/presidio-analyzer/tests/test_us_driver_license_recognizer.py index 729b49c15..4f49c0b59 100644 --- a/presidio-analyzer/tests/test_us_driver_license_recognizer.py +++ b/presidio-analyzer/tests/test_us_driver_license_recognizer.py @@ -58,9 +58,9 @@ def test_invalid_us_driver_license(self): # Driver License - Digits (very weak) - 0.05 # Regex: r'\b([0-9]{1,9}|[0-9]{4,10}|[0-9]{6,10}|[0-9]{1,12}|[0-9]{12,14}|[0-9]{16})\b' - + # Regex: r'\b([0-9]{6,14}|[0-9]{16})\b' def test_valid_us_driver_license_very_weak_digits(self): - num = '123456789 1234567890 12345679012 123456790123 1234567901234' + num = '123456789 1234567890 12345679012 123456790123 1234567901234 1234' results = us_license_recognizer.analyze(num, entities) assert len(results) == 5 From 136c097069f2af493513ac6590e4824f3b35c042 Mon Sep 17 00:00:00 2001 From: balteravishay Date: Sun, 22 Sep 2019 11:37:11 +0300 Subject: [PATCH 72/75] Fix When request has both template and a template ID, the template is ignored (#225) --- .../cmd/presidio-api/api/analyze/analyze.go | 8 +++++--- .../presidio-api/api/analyze/analyze_test.go | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/presidio-api/cmd/presidio-api/api/analyze/analyze.go b/presidio-api/cmd/presidio-api/api/analyze/analyze.go index 28157859e..faf6edc93 100644 --- a/presidio-api/cmd/presidio-api/api/analyze/analyze.go +++ b/presidio-api/cmd/presidio-api/api/analyze/analyze.go @@ -11,10 +11,12 @@ import ( //Analyze text func Analyze(ctx context.Context, api *store.API, analyzeAPIRequest *types.AnalyzeApiRequest, project string) (*types.AnalyzeResponse, error) { - - if analyzeAPIRequest.AnalyzeTemplateId == "" && analyzeAPIRequest.AnalyzeTemplate == nil { + switch { + case analyzeAPIRequest.AnalyzeTemplateId == "" && analyzeAPIRequest.AnalyzeTemplate == nil: return nil, fmt.Errorf("Analyze template is missing or empty") - } else if analyzeAPIRequest.AnalyzeTemplate == nil { + case analyzeAPIRequest.AnalyzeTemplateId != "" && analyzeAPIRequest.AnalyzeTemplate != nil: + return nil, fmt.Errorf("Analyze template and Analyze template ID are mutually exclusive") + case analyzeAPIRequest.AnalyzeTemplate == nil: analyzeAPIRequest.AnalyzeTemplate = &types.AnalyzeTemplate{} } diff --git a/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go b/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go index a3253f48c..d6fb2d940 100644 --- a/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go +++ b/presidio-api/cmd/presidio-api/api/analyze/analyze_test.go @@ -45,7 +45,6 @@ func TestAnalyzeWithTemplateId(t *testing.T) { analyzeAPIRequest := &types.AnalyzeApiRequest{ Text: "My number is (555) 253-0000 and email johnsnow@foo.com", AnalyzeTemplateId: "test", - AnalyzeTemplate: &types.AnalyzeTemplate{}, } response, err := Analyze(context.Background(), api, analyzeAPIRequest, project) assert.NoError(t, err) @@ -96,7 +95,6 @@ func TestLanguageCode(t *testing.T) { analyzeAPIRequest := &types.AnalyzeApiRequest{ Text: "My number is (555) 253-0000 and email johnsnow@foo.com", AnalyzeTemplateId: "test", - AnalyzeTemplate: &types.AnalyzeTemplate{}, } Analyze(context.Background(), api, analyzeAPIRequest, project) assert.Equal(t, "langtest", analyzeAPIRequest.AnalyzeTemplate.Language) @@ -136,3 +134,18 @@ func TestAnalyzeWhenNoEntitiesFoundThenExpectEmptyResponse(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 0, len(response.AnalyzeResults)) } + +func TestSettingTemplateAndTemplateIdReturnsError(t *testing.T) { + api := setupMockServices() + + project := "tests" + analyzeAPIRequest := &types.AnalyzeApiRequest{ + Text: "My number is (555) 253-0000 and email johnsnow@foo.com", + AnalyzeTemplate: &types.AnalyzeTemplate{ + Language: "en", + AllFields: true}, + AnalyzeTemplateId: "123", + } + _, err := Analyze(context.Background(), api, analyzeAPIRequest, project) + assert.Error(t, err) +} From 6b4acdd7a5b10d2de625debe50e045f6bd7e1b27 Mon Sep 17 00:00:00 2001 From: balteravishay Date: Mon, 23 Sep 2019 12:04:12 +0300 Subject: [PATCH 73/75] Fix Current make file throws errors if container registry is undefined (#226) --- Makefile | 4 ++-- docs/development.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 342cd10dd..850ad605c 100644 --- a/Makefile +++ b/Makefile @@ -28,8 +28,8 @@ $(BINS): vendor .PHONY: docker-build-deps docker-build-deps: - -docker pull $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) - -docker pull $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) + -docker pull $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) ||: + -docker pull $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) ||: docker build -t $(DOCKER_REGISTRY)/$(GOLANG_DEPS):$(PRESIDIO_DEPS_LABEL) -f Dockerfile.golang.deps . docker build -t $(DOCKER_REGISTRY)/$(PYTHON_DEPS):$(PRESIDIO_DEPS_LABEL) -f Dockerfile.python.deps . diff --git a/docs/development.md b/docs/development.md index 7adf1d63c..943bfd7c8 100644 --- a/docs/development.md +++ b/docs/development.md @@ -122,7 +122,7 @@ For more info, see https://grpc.io/docs/tutorials/basic/python.html ## Development notes - Build the bins with `make build` -- Build the base containers with `make docker-build-deps DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_DEPS_LABEL=${PRESIDIO_DEPS_LABEL}` +- Build the base containers with `make docker-build-deps DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_DEPS_LABEL=${PRESIDIO_DEPS_LABEL}` (If you do not specify a valid, logged-in, registry a warning will echo to the standard output) - Build the the Docker image with `make docker-build DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_DEPS_LABEL=${PRESIDIO_DEPS_LABEL} PRESIDIO_LABEL=${PRESIDIO_LABEL}` - Push the Docker images with `make docker-push DOCKER_REGISTRY=${DOCKER_REGISTRY} PRESIDIO_LABEL=${PRESIDIO_LABEL}` - Run the tests with `make test` From f6d890a6336f923043da0c3f3bc81303030b6377 Mon Sep 17 00:00:00 2001 From: Shail Shouryya <42100758+Shail-Shouryya@users.noreply.github.com> Date: Mon, 23 Sep 2019 02:05:27 -0700 Subject: [PATCH 74/75] fixed minor spelling mistakes (#223) --- presidio-analyzer/analyzer/entity_recognizer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/presidio-analyzer/analyzer/entity_recognizer.py b/presidio-analyzer/analyzer/entity_recognizer.py index 924941063..49a06d5dc 100644 --- a/presidio-analyzer/analyzer/entity_recognizer.py +++ b/presidio-analyzer/analyzer/entity_recognizer.py @@ -277,11 +277,11 @@ def find_index_of_match_token(word, start, tokens, tokens_indices): return i def __extract_surrounding_words(self, nlp_artifacts, word, start): - """ Extracts words surronding another given word. + """ Extracts words surrounding another given word. The text from which the context is extracted is given in the nlp doc :param nlp_artifacts: An abstraction layer which holds different - items which are result of a NLP pipeline + items which are the result of a NLP pipeline execution on a given text :param word: The word to look for context around :param start: The start index of the word in the original text From 986bbbd7295dc0500a372453711a8bb089ed8a35 Mon Sep 17 00:00:00 2001 From: Omri Mendels Date: Mon, 23 Sep 2019 12:05:49 +0300 Subject: [PATCH 75/75] Create pull_request_template.md (#222) --- .github/PULL_REQUEST_TEMPLATE/pull_request_template.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE/pull_request_template.md diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md new file mode 100644 index 000000000..e3b95ca10 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md @@ -0,0 +1,3 @@ +Fixes issue: + +Changes proposed in this pull request: